feat(nix): give Home Manager a programs module and the desktop app
Home Manager separates an installation from a daemon. This module put both under `services.hermes-agent`, and `installPackage` added a program to the PATH from a service module. `programs.hermes-agent` now installs the command line application and the desktop application. `services.hermes-agent` keeps the state, the configuration and the daemons, and stays the authority: the new module reads `hermesHome` and the backend address from it. A person can enable one without the other, which is a machine with an application and no gateway, or a headless gateway with no display. The desktop application needs this split to work correctly. A launcher that starts from the desktop menu reads no shell profile, thus the HERMES_HOME that `home.sessionVariables` exports reaches an interactive shell only. Home Manager writes `systemd.user.sessionVariables` to environment.d, and this module puts no HERMES_HOME there, because that file applies to each user unit. The application then opens ~/.hermes while the services use `hermesHome`, and the person sees no sessions and no keys. Thus the launcher carries the value itself, through a new `extraEnv` argument on the desktop package. The application also gets the Nix agent package, with HERMES_DESKTOP_HERMES. The usual distribution of the Electron application carries its own Hermes runtime and downloads more at the first start. `hermesDesktop` is a passthru of the agent and pins `finalAttrs.finalPackage`, so an override of `extraPythonPackages` or `extraDependencyGroups` reaches both. One machine thus has one runtime. `backend.sessionTokenFile` connects the application to the backend of the service. Without it the module runs `hermes serve` and the application starts a backend of its own, which gives two backends on one HERMES_HOME. The backend reads the file into HERMES_DASHBOARD_SESSION_TOKEN. The launcher reads the same file into HERMES_DESKTOP_REMOTE_TOKEN, beside a HERMES_DESKTOP_REMOTE_URL that names the address of the service. Measurements against a live `hermes serve` on loopback show why that shape is the correct one: - `_resolve_session_token()` reads HERMES_DASHBOARD_SESSION_TOKEN, and `_has_valid_session_token` accepts that value as a Bearer credential. A request without it gets 401, and a request with the wrong value gets 401. - The /api/ws socket accepts a query parameter only. A header gets 403, and `?token=` connects. Hermes Desktop builds exactly that URL, in `apps/desktop/electron/connection-config.ts`. Thus a test of the HTTP leg alone is a false positive. - `resolveDesktopRemoteRoute` throws when the URL is set and the token is not. Thus the two variables travel together or not at all. The token enters no Nix store path. `makeWrapper --set` and a systemd `Environment=` value both write a literal into the store, which all users can read. Thus each side reads the file at start time. The launcher does it through a new `extraRun` argument on the desktop package, and the backend through the launcher script that `backend.waitFor` already uses. launchd has no EnvironmentFile, so a script is the one shape that works on Linux and on Darwin. `backendArgv` gives the plain argv only when nothing must run before the backend. `services.hermes-agent.installPackage` is removed. It defaulted to true, so a person who never named it still got the command line. A silent removal thus gives them a machine with no `hermes` and no message. The module refuses a configuration that sets it, and the text names the exact replacement for the value they gave. Checks: - the launcher carries HERMES_HOME - the launcher reports HERMES_MANAGED only when the services own the configuration, because no activation writes a marker without them - the launcher pins the agent package that `programs.enable` installs - the launcher names the backend of the service, and gives a token beside the URL - the backend reads the session token - each side reads the file at start time, and the token is no `--set` value - `programs.enable` alone starts no service - `installPackage` is refused, with a message that names the replacement, and its absence evaluates Each check reads the wrapper of the real package, and not an option value. Each one was tested with a mutation that breaks the behavior it asserts.
This commit is contained in:
308
nix/checks.nix
308
nix/checks.nix
@@ -54,6 +54,31 @@
|
||||
];
|
||||
};
|
||||
|
||||
# The programs./services. split means a check often needs both halves.
|
||||
# This takes each one as its own attribute set.
|
||||
evalHomeSplit =
|
||||
{
|
||||
programs ? { },
|
||||
services ? { },
|
||||
}:
|
||||
inputs.home-manager.lib.homeManagerConfiguration {
|
||||
inherit pkgs;
|
||||
modules = [
|
||||
inputs.self.homeManagerModules.default
|
||||
{
|
||||
home = {
|
||||
username = "hermes-check";
|
||||
homeDirectory = "/home/hermes-check";
|
||||
stateVersion = "24.11";
|
||||
};
|
||||
}
|
||||
{
|
||||
programs.hermes-agent = programs;
|
||||
services.hermes-agent = services;
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
# The option names that each module defines under
|
||||
# services.hermes-agent. The internal names that the module system adds
|
||||
# are not in the list.
|
||||
@@ -149,21 +174,24 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
||||
# agents. Each host checks its own kind of process.
|
||||
home-manager-module =
|
||||
let
|
||||
enabled = evalHomeModule {
|
||||
enable = true;
|
||||
gateway.enable = true;
|
||||
backend.mode = "serve";
|
||||
settings.model.default = "test/model";
|
||||
environment.HERMES_TEST = "1";
|
||||
environmentFiles = [ "/run/secrets/hermes-env" ];
|
||||
hermesHomeFiles."SOUL.md" = "test soul";
|
||||
# documents needs an explicit workingDirectory. The check
|
||||
# workspace-files-need-a-directory below asserts that rule.
|
||||
workingDirectory = "/home/test-user/workspace";
|
||||
documents."AGENTS.md" = "test agents";
|
||||
mcpServers.demo = {
|
||||
command = "echo";
|
||||
args = [ "hi" ];
|
||||
enabled = evalHomeSplit {
|
||||
programs.enable = true;
|
||||
services = {
|
||||
enable = true;
|
||||
gateway.enable = true;
|
||||
backend.mode = "serve";
|
||||
settings.model.default = "test/model";
|
||||
environment.HERMES_TEST = "1";
|
||||
environmentFiles = [ "/run/secrets/hermes-env" ];
|
||||
hermesHomeFiles."SOUL.md" = "test soul";
|
||||
# documents needs an explicit workingDirectory. The check
|
||||
# workspace-files-need-a-directory below asserts that rule.
|
||||
workingDirectory = "/home/test-user/workspace";
|
||||
documents."AGENTS.md" = "test agents";
|
||||
mcpServers.demo = {
|
||||
command = "echo";
|
||||
args = [ "hi" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
cfg = enabled.config;
|
||||
@@ -217,7 +245,7 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
||||
) "gateway and backend must share one HERMES_HOME"
|
||||
++ lib.optional (
|
||||
cfg.home.sessionVariables.HERMES_HOME or null != "/home/hermes-check/.hermes"
|
||||
) "installPackage must export HERMES_HOME for interactive shells"
|
||||
) "programs.hermes-agent.enable must export HERMES_HOME for interactive shells"
|
||||
++ lib.optional (
|
||||
!lib.hasInfix "hermes-config-merge" activation
|
||||
) "activation must deep-merge config.yaml, not overwrite it"
|
||||
@@ -325,6 +353,250 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
||||
''
|
||||
);
|
||||
|
||||
# ── The desktop application shares one HERMES_HOME ───────────────
|
||||
# `programs.enable` exports HERMES_HOME with home.sessionVariables,
|
||||
# which reaches an interactive shell only. Home Manager writes that
|
||||
# file to etc/profile.d, and a launcher from the desktop menu reads
|
||||
# no shell profile. Thus the desktop application would open ~/.hermes
|
||||
# while the services use the HERMES_HOME of the module, and the user
|
||||
# would see an empty application with no sessions and no keys.
|
||||
#
|
||||
# The launcher must therefore carry the value itself. This check
|
||||
# reads the real wrapper text of the package that the module
|
||||
# installs, and not an option value.
|
||||
home-manager-desktop =
|
||||
let
|
||||
tokenFile = "/run/secrets/hermes-desktop-token";
|
||||
|
||||
enabled = evalHomeSplit {
|
||||
programs = {
|
||||
enable = true;
|
||||
desktop.enable = true;
|
||||
};
|
||||
services = {
|
||||
enable = true;
|
||||
hermesHome = "/home/hermes-check/.hermes-work";
|
||||
# An override on purpose. Without one the effective package
|
||||
# IS the default package, so a launcher that pinned the plain
|
||||
# default would look correct while it shipped a second
|
||||
# runtime to anyone who customises theirs.
|
||||
extraDependencyGroups = [ "hindsight" ];
|
||||
backend = {
|
||||
mode = "serve";
|
||||
port = 9231;
|
||||
sessionTokenFile = tokenFile;
|
||||
};
|
||||
};
|
||||
};
|
||||
cfg = enabled.config;
|
||||
|
||||
desktopPackages = builtins.filter (p: (p.pname or "") == "hermes-desktop") cfg.home.packages;
|
||||
desktop = lib.head desktopPackages;
|
||||
wrapper = desktop.installPhase;
|
||||
|
||||
# Read the value that each --set flag gives the launcher. The
|
||||
# quotes are not part of the test: escapeShellArg adds them only
|
||||
# when the value needs them, and a path with no special character
|
||||
# arrives bare.
|
||||
setValue =
|
||||
name:
|
||||
let
|
||||
m = builtins.match ".*--set ${name} ['\"]?([^'\"\n ]*)['\"]?.*" wrapper;
|
||||
in
|
||||
if m == null then null else lib.head m;
|
||||
|
||||
# The agent package that the module installs, and the runtime
|
||||
# that the launcher pins. These must be the same store path: a
|
||||
# second Hermes runtime beside the services is the fault that
|
||||
# `programs.enable` plus a plain desktop package would give.
|
||||
agentPackages = builtins.filter (p: (p.pname or "") == "hermes-agent") cfg.home.packages;
|
||||
|
||||
# The backend of the service, as the unit or the agent runs it.
|
||||
backendScript =
|
||||
let
|
||||
argv =
|
||||
if pkgs.stdenv.hostPlatform.isDarwin then
|
||||
cfg.launchd.agents.hermes-backend.config.ProgramArguments
|
||||
else
|
||||
[ cfg.systemd.user.services.hermes-backend.Service.ExecStart ];
|
||||
first = lib.head (lib.flatten argv);
|
||||
# writeShellScript gives a store path. Read the real text, so
|
||||
# the check tests the script and not the option that made it.
|
||||
path = lib.head (lib.splitString " " first);
|
||||
in
|
||||
builtins.readFile path;
|
||||
|
||||
failures =
|
||||
lib.optional (
|
||||
lib.length desktopPackages != 1
|
||||
) "programs.desktop.enable must install exactly one hermes-desktop package, got ${toString (lib.length desktopPackages)}"
|
||||
++ lib.optional (
|
||||
setValue "HERMES_HOME" != "/home/hermes-check/.hermes-work"
|
||||
) "the launcher must carry HERMES_HOME: a GUI launcher reads no shell profile, so home.sessionVariables never reaches it (got: ${toString (setValue "HERMES_HOME")})"
|
||||
++ lib.optional (
|
||||
setValue "HERMES_MANAGED" != "home-manager"
|
||||
) "the launcher must report HERMES_MANAGED=home-manager while the services own the configuration (got: ${toString (setValue "HERMES_MANAGED")})"
|
||||
++ lib.optional (
|
||||
lib.length agentPackages == 1
|
||||
&& setValue "HERMES_DESKTOP_HERMES" != "${lib.head agentPackages}/bin/hermes"
|
||||
) "the launcher must pin the agent package that programs.enable installs, and not a second runtime: ${toString (setValue "HERMES_DESKTOP_HERMES")}"
|
||||
|
||||
# ── The application reaches the backend of the service ──────
|
||||
++ lib.optional (
|
||||
setValue "HERMES_DESKTOP_REMOTE_URL" != "http://127.0.0.1:9231"
|
||||
) "the launcher must name the backend of the service, or the application starts a second one (got: ${toString (setValue "HERMES_DESKTOP_REMOTE_URL")})"
|
||||
++ lib.optional (
|
||||
!lib.hasInfix "HERMES_DESKTOP_REMOTE_TOKEN" wrapper
|
||||
) "the launcher must give a token with the URL: the desktop resolver throws when the URL is set alone"
|
||||
++ lib.optional (
|
||||
!lib.hasInfix "HERMES_DASHBOARD_SESSION_TOKEN" backendScript
|
||||
) "the backend must read the session token, or it makes a new one that the application cannot know"
|
||||
|
||||
# ── The token never enters the Nix store ────────────────────
|
||||
# Each side must read the file at start time. A --set flag or
|
||||
# an Environment= value writes the literal into a store path
|
||||
# that all users can read.
|
||||
++ lib.optional (
|
||||
!lib.hasInfix tokenFile wrapper || !lib.hasInfix "--run" wrapper
|
||||
) "the launcher must read the token from ${tokenFile} at start time, with --run"
|
||||
++ lib.optional (
|
||||
!lib.hasInfix tokenFile backendScript
|
||||
) "the backend must read the token from ${tokenFile} at start time"
|
||||
++ lib.optional (
|
||||
setValue "HERMES_DESKTOP_REMOTE_TOKEN" != null
|
||||
) "the token must never be a --set value: makeWrapper writes it into the world-readable Nix store";
|
||||
in
|
||||
pkgs.runCommand "hermes-home-manager-desktop" { } (
|
||||
if failures != [ ] then
|
||||
throw "Home Manager desktop check failed:\n${lib.concatMapStringsSep "\n" (f: " - ${f}") failures}"
|
||||
else
|
||||
''
|
||||
echo "PASS: the desktop launcher shares HERMES_HOME, the runtime and the backend of the service"
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
''
|
||||
);
|
||||
|
||||
# ── The desktop application without the services ─────────────────
|
||||
# A person can want the application on a machine that runs no daemon.
|
||||
# Then nothing writes config.yaml or the .managed marker, so the
|
||||
# launcher must not claim a managed install: the CLI would refuse an
|
||||
# edit that nothing else owns. It must also not name a backend, since
|
||||
# there is none.
|
||||
home-manager-desktop-standalone =
|
||||
let
|
||||
enabled = evalHomeSplit {
|
||||
programs = {
|
||||
enable = true;
|
||||
desktop.enable = true;
|
||||
};
|
||||
};
|
||||
cfg = enabled.config;
|
||||
|
||||
desktopPackages = builtins.filter (p: (p.pname or "") == "hermes-desktop") cfg.home.packages;
|
||||
wrapper = (lib.head desktopPackages).installPhase;
|
||||
|
||||
failures =
|
||||
lib.optional (
|
||||
lib.length desktopPackages != 1
|
||||
) "programs.desktop.enable must install the application with no services enabled"
|
||||
++ lib.optional (
|
||||
!lib.hasInfix "--set HERMES_HOME" wrapper
|
||||
) "the launcher must carry HERMES_HOME even with no services"
|
||||
++ lib.optional (
|
||||
lib.hasInfix "HERMES_MANAGED" wrapper
|
||||
) "the launcher must not claim a managed install when no activation writes one"
|
||||
++ lib.optional (
|
||||
lib.hasInfix "HERMES_DESKTOP_REMOTE_URL" wrapper
|
||||
) "the launcher must not name a backend when the services run none"
|
||||
++ lib.optional (
|
||||
cfg.systemd.user.services ? hermes-backend || cfg.launchd.agents ? hermes-backend
|
||||
) "programs.enable alone must start no service";
|
||||
in
|
||||
pkgs.runCommand "hermes-home-manager-desktop-standalone" { } (
|
||||
if failures != [ ] then
|
||||
throw "Home Manager standalone desktop check failed:\n${lib.concatMapStringsSep "\n" (f: " - ${f}") failures}"
|
||||
else
|
||||
''
|
||||
echo "PASS: the application runs with no services, and claims nothing that no activation wrote"
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
''
|
||||
);
|
||||
|
||||
# ── installPackage names its replacement ─────────────────────────
|
||||
# The option was removed by the programs./services. split. It
|
||||
# defaulted to true, so a person who never named it still got the
|
||||
# command line. A silent removal thus leaves them with no `hermes`
|
||||
# and no message. The module must refuse the configuration and name
|
||||
# the replacement.
|
||||
home-manager-install-package-removed =
|
||||
let
|
||||
common = import ./moduleCommon.nix { inherit lib; };
|
||||
|
||||
# `builtins.length` is enough to force the assertion, because
|
||||
# Home Manager wraps the whole `config` in its assertion check.
|
||||
# `lib.deepSeq` would walk each package of the closure instead,
|
||||
# and overflow the stack before it reached an answer.
|
||||
refuses =
|
||||
value:
|
||||
!(builtins.tryEval (
|
||||
builtins.length
|
||||
(evalHomeSplit {
|
||||
services = {
|
||||
enable = true;
|
||||
installPackage = value;
|
||||
};
|
||||
}).config.home.packages
|
||||
)).success;
|
||||
|
||||
# The check calls the same function the module calls, so it reads
|
||||
# the real message. Matching the source text of the module instead
|
||||
# would pass while the message was wrong.
|
||||
messageFor = common.installPackageRemovedMessage;
|
||||
|
||||
cases = [
|
||||
{
|
||||
value = true;
|
||||
expect = "programs.hermes-agent.enable = true;";
|
||||
}
|
||||
{
|
||||
value = false;
|
||||
expect = "programs.hermes-agent.enable = false;";
|
||||
}
|
||||
];
|
||||
|
||||
failures =
|
||||
lib.concatMap (
|
||||
case:
|
||||
lib.optional (
|
||||
!refuses case.value
|
||||
) "installPackage = ${lib.boolToString case.value} must be refused"
|
||||
++ lib.optional (
|
||||
!lib.hasInfix case.expect (messageFor case.value)
|
||||
) "the message for installPackage = ${lib.boolToString case.value} must name `${case.expect}`"
|
||||
++ lib.optional (
|
||||
!lib.hasInfix "installPackage was removed" (messageFor case.value)
|
||||
) "the message must say that the option was removed"
|
||||
) cases
|
||||
++ lib.optional (
|
||||
# A configuration that never names the option must still work.
|
||||
# An assertion that fires on the default value would break each
|
||||
# existing user at once.
|
||||
refuses null
|
||||
) "a configuration that never names installPackage must evaluate";
|
||||
in
|
||||
pkgs.runCommand "hermes-home-manager-install-package-removed" { } (
|
||||
if failures != [ ] then
|
||||
throw "installPackage removal check failed:\n${lib.concatMapStringsSep "\n" (f: " - ${f}") failures}"
|
||||
else
|
||||
''
|
||||
echo "PASS: installPackage is refused with guidance, and its absence evaluates"
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
''
|
||||
);
|
||||
|
||||
# ── The two modules keep the same options ────────────────────────
|
||||
# The modules share one option set, in nix/moduleCommon.nix. Thus a
|
||||
# NixOS example works on Home Manager without a change. This check
|
||||
@@ -629,6 +901,10 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
||||
waitFor = null;
|
||||
interfaceName = null;
|
||||
waitTimeout = 120;
|
||||
# No token here: this case asserts the plain argv, which the
|
||||
# module builds only when nothing must run before the
|
||||
# backend. A token needs the launcher script instead.
|
||||
sessionTokenFile = null;
|
||||
};
|
||||
};
|
||||
sentinel = "--hermes-nix-argv-probe";
|
||||
|
||||
@@ -15,9 +15,30 @@
|
||||
electron,
|
||||
hermesAgent,
|
||||
python3,
|
||||
# Environment to bake into the launcher. A GUI launcher reads none of the
|
||||
# shell profile, so a variable that an interactive shell exports does not
|
||||
# reach an app that the desktop menu starts. The Home Manager module passes
|
||||
# HERMES_HOME and HERMES_MANAGED here, which gives the app the same state
|
||||
# directory as the services.
|
||||
extraEnv ? { },
|
||||
# Shell lines to run before the app starts. A secret belongs here and never
|
||||
# in extraEnv: makeWrapper writes a --set value into the Nix store, which
|
||||
# all users can read. A --run line reads the value from a runtime path at
|
||||
# each start instead.
|
||||
extraRun ? [ ],
|
||||
...
|
||||
}:
|
||||
let
|
||||
# Each flag goes on its own continued line, and the leading backslash is
|
||||
# inside the generated string. An empty attribute set then adds no text at
|
||||
# all, and cannot leave a backslash above a blank line. That fault ends the
|
||||
# makeWrapper command early, and the next flag runs as a shell command.
|
||||
extraEnvFlags = lib.concatMapStrings (
|
||||
name: " \\\n --set ${name} ${lib.escapeShellArg (toString extraEnv.${name})}"
|
||||
) (lib.attrNames extraEnv);
|
||||
|
||||
extraRunFlags = lib.concatMapStrings (line: " \\\n --run ${lib.escapeShellArg line}") extraRun;
|
||||
|
||||
electronHeaders = pkgs.fetchurl {
|
||||
url = "https://artifacts.electronjs.org/headers/dist/v${electron.version}/node-v${electron.version}-headers.tar.gz";
|
||||
sha256 = "sha256-f8bSbLRmtbP93CJAvEBs+sHWDZ1xP2bcpLhC1EnOmZU=";
|
||||
@@ -168,7 +189,7 @@ stdenv.mkDerivation {
|
||||
makeWrapper ${lib.getExe electron} $out/bin/hermes-desktop \
|
||||
--add-flags "$out/share/hermes-desktop" \
|
||||
--set HERMES_DESKTOP_HERMES "${lib.getExe hermesAgent}" \
|
||||
--set ELECTRON_IS_DEV 0
|
||||
--set ELECTRON_IS_DEV 0${extraEnvFlags}${extraRunFlags}
|
||||
|
||||
# XDG launcher entry
|
||||
mkdir -p $out/share/applications $out/share/icons/hicolor/1024x1024/apps
|
||||
|
||||
@@ -17,12 +17,19 @@
|
||||
# changed systemd.services -> systemd.user.services or
|
||||
# launchd.agents
|
||||
# changed system.activationScripts -> home.activation
|
||||
# changed addToSystemPackages -> installPackage and
|
||||
# changed addToSystemPackages -> programs.hermes-agent.enable and
|
||||
# home.sessionVariables
|
||||
# added programs.hermes-agent the CLI and the desktop application,
|
||||
# because Home Manager separates an
|
||||
# installation from a daemon
|
||||
# changed stateDir (+ "/.hermes") -> hermesHome, set directly
|
||||
#
|
||||
# To use the module:
|
||||
# imports = [ hermes-agent.homeManagerModules.default ];
|
||||
# programs.hermes-agent = {
|
||||
# enable = true; # the hermes CLI on your PATH
|
||||
# desktop.enable = true; # the Electron application and a launcher
|
||||
# };
|
||||
# services.hermes-agent = {
|
||||
# enable = true;
|
||||
# gateway.enable = true;
|
||||
@@ -48,6 +55,7 @@
|
||||
|
||||
let
|
||||
cfg = config.services.hermes-agent;
|
||||
cfgPrograms = config.programs.hermes-agent;
|
||||
common = import ./moduleCommon.nix { inherit lib; };
|
||||
|
||||
effectivePackage = common.effectivePackage cfg;
|
||||
@@ -63,6 +71,52 @@
|
||||
};
|
||||
unitPath = lib.makeBinPath (common.processPath { inherit pkgs cfg; });
|
||||
|
||||
# ── The desktop launcher ───────────────────────────────────────────
|
||||
# A GUI launcher reads no shell profile, so home.sessionVariables does
|
||||
# not reach it, and the application would open ~/.hermes while the
|
||||
# services use hermesHome. Thus the launcher carries the value itself.
|
||||
#
|
||||
# HERMES_MANAGED rides along only when the services are enabled. That
|
||||
# variable makes the CLI refuse a configuration change and name the
|
||||
# rebuild command. A person who enables `programs.` alone has no
|
||||
# activation and no managed configuration, so the application must not
|
||||
# claim one and refuse an edit that nothing else owns.
|
||||
desktopEnvironment = {
|
||||
HERMES_HOME = cfg.hermesHome;
|
||||
}
|
||||
// lib.optionalAttrs cfg.enable {
|
||||
inherit (processEnvironment) HERMES_MANAGED;
|
||||
}
|
||||
// lib.optionalAttrs desktopUsesService {
|
||||
HERMES_DESKTOP_REMOTE_URL = "http://${cfg.backend.host}:${toString cfg.backend.port}";
|
||||
};
|
||||
|
||||
# The application reaches the backend of the service only when there is
|
||||
# a backend to reach AND a shared token to present with. Without the
|
||||
# token the desktop resolver throws ("HERMES_DESKTOP_REMOTE_URL is set
|
||||
# but HERMES_DESKTOP_REMOTE_TOKEN is not"), so the two variables travel
|
||||
# together or not at all.
|
||||
desktopUsesService = cfg.enable && cfg.backend.mode != "none" && cfg.backend.sessionTokenFile != null;
|
||||
|
||||
# The token is read at start time and never with `--set`. makeWrapper
|
||||
# writes a --set value into the Nix store, which all users can read.
|
||||
desktopRun = lib.optional desktopUsesService ''
|
||||
if [ -r ${lib.escapeShellArg cfg.backend.sessionTokenFile} ]; then
|
||||
HERMES_DESKTOP_REMOTE_TOKEN="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.backend.sessionTokenFile})"
|
||||
export HERMES_DESKTOP_REMOTE_TOKEN
|
||||
else
|
||||
echo "hermes-desktop: cannot read the session token at ${cfg.backend.sessionTokenFile}." >&2
|
||||
echo "hermes-desktop: the application starts its own backend instead of the one of the service." >&2
|
||||
fi
|
||||
'';
|
||||
|
||||
# `override`, and not `overrideAttrs`: the values go into the wrapper
|
||||
# that the installPhase writes, and not into a derivation attribute.
|
||||
desktopPackage = cfgPrograms.desktop.package.override {
|
||||
extraEnv = desktopEnvironment;
|
||||
extraRun = desktopRun;
|
||||
};
|
||||
|
||||
# The systemd unit that the gateway and the backend both start from.
|
||||
mkUnit =
|
||||
{
|
||||
@@ -124,6 +178,73 @@
|
||||
|
||||
in
|
||||
{
|
||||
# ── programs.hermes-agent — the installation ───────────────────────
|
||||
# Home Manager separates "install this application for me" from "run
|
||||
# this daemon". Hermes needs both, and a person can want one without
|
||||
# the other: an application with no gateway, or a headless gateway on
|
||||
# a machine with no display.
|
||||
#
|
||||
# `services.hermes-agent` stays the authority for the state and the
|
||||
# configuration. This module reads hermesHome and the backend address
|
||||
# from it, and never the reverse.
|
||||
options.programs.hermes-agent = {
|
||||
enable = lib.mkEnableOption ''
|
||||
the Hermes Agent command line application.
|
||||
|
||||
This adds `hermes` to home.packages, and exports HERMES_HOME with
|
||||
home.sessionVariables. An interactive shell then uses the same
|
||||
state as `services.hermes-agent`
|
||||
'';
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = effectivePackage;
|
||||
defaultText = lib.literalExpression "config.services.hermes-agent.package";
|
||||
description = ''
|
||||
The hermes-agent package to install.
|
||||
|
||||
The default follows `services.hermes-agent.package`, and applies
|
||||
`extraPythonPackages` and `extraDependencyGroups` from that
|
||||
module. Thus the command line and the services are one build,
|
||||
and a plugin that the services can load is a plugin that your
|
||||
shell can load.
|
||||
'';
|
||||
};
|
||||
|
||||
desktop = {
|
||||
enable = lib.mkEnableOption ''
|
||||
the Hermes Desktop application (Electron).
|
||||
|
||||
This adds `hermes-desktop` to home.packages, with an XDG
|
||||
launcher entry on Linux. The launcher starts the same Hermes
|
||||
runtime that `package` gives, and reads the HERMES_HOME of
|
||||
`services.hermes-agent`. Thus the application, the interactive
|
||||
shell and the services share one state directory.
|
||||
|
||||
The Electron application carries its own Hermes runtime with
|
||||
the usual distribution. This module gives it the Nix package
|
||||
instead, with HERMES_DESKTOP_HERMES. It installs no second copy
|
||||
of Hermes, and it downloads nothing on the first start
|
||||
'';
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = cfgPrograms.package.hermesDesktop;
|
||||
defaultText = lib.literalExpression "config.programs.hermes-agent.package.hermesDesktop";
|
||||
description = ''
|
||||
The hermes-desktop package to use.
|
||||
|
||||
The default follows `package`, and thus also
|
||||
`services.hermes-agent.extraPythonPackages` and
|
||||
`extraDependencyGroups`, because the desktop application is a
|
||||
passthru of the agent package. A package that you set here
|
||||
carries its own Hermes runtime, and this module cannot make
|
||||
it agree with the services.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
options.services.hermes-agent =
|
||||
common.sharedOptions {
|
||||
defaultPackage = hermes-agent;
|
||||
@@ -149,125 +270,159 @@
|
||||
example = "/home/alice/.hermes-work";
|
||||
};
|
||||
|
||||
# `installPackage` moved to `programs.hermes-agent.enable`. The
|
||||
# option is dead, but it must not be silent: it defaulted to true,
|
||||
# so a person who never named it still got the command line, and a
|
||||
# quiet removal gives them a machine with no `hermes` and no
|
||||
# message. mkOption with an assertion, and not
|
||||
# mkRemovedOptionModule, because the message must name the exact
|
||||
# replacement for the value they set.
|
||||
installPackage = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
type = lib.types.nullOr lib.types.bool;
|
||||
default = null;
|
||||
visible = false;
|
||||
description = ''
|
||||
Add the hermes CLI to home.packages, and export HERMES_HOME
|
||||
with home.sessionVariables. Interactive shells then use the
|
||||
same state as the services.
|
||||
|
||||
The equivalent NixOS option, `addToSystemPackages`, exports
|
||||
HERMES_HOME with environment.variables. That variable applies
|
||||
to the full system and replaces the HERMES_HOME of each other
|
||||
user. This module exports the variable for one user session
|
||||
only, which is the reason to use Home Manager.
|
||||
Removed. Use `programs.hermes-agent.enable` instead.
|
||||
'';
|
||||
};
|
||||
|
||||
gateway.enable = lib.mkEnableOption "the messaging gateway service (Telegram, Discord, Slack, ...)";
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable (
|
||||
lib.mkMerge [
|
||||
config = lib.mkMerge [
|
||||
|
||||
# ── Merge MCP servers into settings ────────────────────────────
|
||||
(lib.mkIf (cfg.mcpServers != { }) {
|
||||
services.hermes-agent.settings.mcp_servers = common.mcpServersToConfig cfg.mcpServers;
|
||||
})
|
||||
# ── programs.hermes-agent — the installation ──────────────────────
|
||||
# Outside the `services.enable` guard on purpose. A person can want
|
||||
# the command line or the application on a machine that runs no
|
||||
# daemon at all.
|
||||
(lib.mkIf cfgPrograms.enable {
|
||||
home.packages = [ cfgPrograms.package ];
|
||||
home.sessionVariables.HERMES_HOME = cfg.hermesHome;
|
||||
})
|
||||
|
||||
{
|
||||
assertions =
|
||||
common.pluginNameAssertions {
|
||||
inherit cfg;
|
||||
optionPath = "services.hermes-agent";
|
||||
}
|
||||
++ common.workspaceFilesAssertions {
|
||||
inherit cfg;
|
||||
opt = options.services.hermes-agent.workingDirectory;
|
||||
optionPath = "services.hermes-agent";
|
||||
}
|
||||
++ common.backendBindAssertions {
|
||||
inherit cfg;
|
||||
optionPath = "services.hermes-agent";
|
||||
}
|
||||
++ [
|
||||
{
|
||||
# The interface poll reads `ip`, which iproute2 supplies on
|
||||
# Linux only.
|
||||
assertion = !isDarwin || cfg.backend.waitFor != "interface";
|
||||
message = "services.hermes-agent.backend.waitFor = \"interface\" works on Linux only. Use \"hostname\" on Darwin.";
|
||||
# A launcher from the desktop menu reads no shell profile, so the
|
||||
# HERMES_HOME that `programs.enable` exports does not reach it. Home
|
||||
# Manager writes only systemd.user.sessionVariables into
|
||||
# environment.d, and this module does not put HERMES_HOME there,
|
||||
# because that file applies to each user unit. Thus the launcher
|
||||
# carries the value itself. See desktopEnvironment above.
|
||||
(lib.mkIf cfgPrograms.desktop.enable {
|
||||
home.packages = [ desktopPackage ];
|
||||
})
|
||||
|
||||
{
|
||||
assertions = [
|
||||
{
|
||||
# `installPackage` was removed in favour of the programs/services
|
||||
# split. It defaulted to true, so a quiet removal leaves a person
|
||||
# with no `hermes` on the PATH and no message.
|
||||
assertion = cfg.installPackage == null;
|
||||
message = common.installPackageRemovedMessage cfg.installPackage;
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
(lib.mkIf cfg.enable (
|
||||
lib.mkMerge [
|
||||
|
||||
# ── Merge MCP servers into settings ────────────────────────────
|
||||
(lib.mkIf (cfg.mcpServers != { }) {
|
||||
services.hermes-agent.settings.mcp_servers = common.mcpServersToConfig cfg.mcpServers;
|
||||
})
|
||||
|
||||
{
|
||||
assertions =
|
||||
common.pluginNameAssertions {
|
||||
inherit cfg;
|
||||
optionPath = "services.hermes-agent";
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
# ── Packages and interactive-shell environment ─────────────────
|
||||
(lib.mkIf cfg.installPackage {
|
||||
home.packages = [ effectivePackage ] ++ cfg.extraPackages;
|
||||
home.sessionVariables.HERMES_HOME = cfg.hermesHome;
|
||||
})
|
||||
|
||||
# ── Activation: directories, config, secrets, documents ────────
|
||||
{
|
||||
# The activation runs after writeBoundary, when the home.file
|
||||
# symlinks are in place. It also runs after linkGeneration, when
|
||||
# Home Manager completes the switch. A secret that the activation
|
||||
# entry of sops-nix writes exists at that point.
|
||||
home.activation.hermesAgentSetup =
|
||||
lib.hm.dag.entryAfter
|
||||
[
|
||||
"writeBoundary"
|
||||
"linkGeneration"
|
||||
]
|
||||
(
|
||||
common.mkStateScript {
|
||||
inherit pkgs cfg;
|
||||
inherit (cfg) hermesHome workingDirectory;
|
||||
run = "$DRY_RUN_CMD ";
|
||||
stateDirs = common.stateSubdirs;
|
||||
managedSystem = "home-manager";
|
||||
# This state has one user. No group needs access to it.
|
||||
modes = {
|
||||
config = "0600";
|
||||
env = "0600";
|
||||
managed = "0600";
|
||||
auth = "0600";
|
||||
document = "0600";
|
||||
};
|
||||
++ common.workspaceFilesAssertions {
|
||||
inherit cfg;
|
||||
opt = options.services.hermes-agent.workingDirectory;
|
||||
optionPath = "services.hermes-agent";
|
||||
}
|
||||
++ common.backendBindAssertions {
|
||||
inherit cfg;
|
||||
optionPath = "services.hermes-agent";
|
||||
}
|
||||
++ [
|
||||
{
|
||||
# The interface poll reads `ip`, which iproute2 supplies on
|
||||
# Linux only.
|
||||
assertion = !isDarwin || cfg.backend.waitFor != "interface";
|
||||
message = "services.hermes-agent.backend.waitFor = \"interface\" works on Linux only. Use \"hostname\" on Darwin.";
|
||||
}
|
||||
);
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
# ── Linux: systemd user services ───────────────────────────────
|
||||
(lib.mkIf (isLinux && cfg.gateway.enable) {
|
||||
systemd.user.services.hermes-agent = mkUnit {
|
||||
description = "Hermes Agent Gateway";
|
||||
argv = common.gatewayArgv cfg;
|
||||
};
|
||||
})
|
||||
# The agent runs these tools, so they belong on the PATH of the
|
||||
# person as well as in the unit.
|
||||
(lib.mkIf cfgPrograms.enable {
|
||||
home.packages = cfg.extraPackages;
|
||||
})
|
||||
|
||||
(lib.mkIf (isLinux && cfg.backend.mode != "none") {
|
||||
systemd.user.services.hermes-backend = mkUnit {
|
||||
description = common.backendDescription cfg;
|
||||
argv = common.backendArgv { inherit pkgs cfg; };
|
||||
};
|
||||
})
|
||||
# ── Activation: directories, config, secrets, documents ────────
|
||||
{
|
||||
# The activation runs after writeBoundary, when the home.file
|
||||
# symlinks are in place. It also runs after linkGeneration, when
|
||||
# Home Manager completes the switch. A secret that the activation
|
||||
# entry of sops-nix writes exists at that point.
|
||||
home.activation.hermesAgentSetup =
|
||||
lib.hm.dag.entryAfter
|
||||
[
|
||||
"writeBoundary"
|
||||
"linkGeneration"
|
||||
]
|
||||
(
|
||||
common.mkStateScript {
|
||||
inherit pkgs cfg;
|
||||
inherit (cfg) hermesHome workingDirectory;
|
||||
run = "$DRY_RUN_CMD ";
|
||||
stateDirs = common.stateSubdirs;
|
||||
managedSystem = "home-manager";
|
||||
# This state has one user. No group needs access to it.
|
||||
modes = {
|
||||
config = "0600";
|
||||
env = "0600";
|
||||
managed = "0600";
|
||||
auth = "0600";
|
||||
document = "0600";
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
# ── Darwin: launchd agents ─────────────────────────────────────
|
||||
(lib.mkIf (isDarwin && cfg.gateway.enable) {
|
||||
launchd.agents.hermes-agent = mkAgent {
|
||||
argv = common.gatewayArgv cfg;
|
||||
logName = "hermes-agent";
|
||||
};
|
||||
})
|
||||
# ── Linux: systemd user services ───────────────────────────────
|
||||
(lib.mkIf (isLinux && cfg.gateway.enable) {
|
||||
systemd.user.services.hermes-agent = mkUnit {
|
||||
description = "Hermes Agent Gateway";
|
||||
argv = common.gatewayArgv cfg;
|
||||
};
|
||||
})
|
||||
|
||||
(lib.mkIf (isDarwin && cfg.backend.mode != "none") {
|
||||
launchd.agents.hermes-backend = mkAgent {
|
||||
argv = common.backendArgv { inherit pkgs cfg; };
|
||||
logName = "hermes-backend";
|
||||
};
|
||||
})
|
||||
]
|
||||
);
|
||||
(lib.mkIf (isLinux && cfg.backend.mode != "none") {
|
||||
systemd.user.services.hermes-backend = mkUnit {
|
||||
description = common.backendDescription cfg;
|
||||
argv = common.backendArgv { inherit pkgs cfg; };
|
||||
};
|
||||
})
|
||||
|
||||
# ── Darwin: launchd agents ─────────────────────────────────────
|
||||
(lib.mkIf (isDarwin && cfg.gateway.enable) {
|
||||
launchd.agents.hermes-agent = mkAgent {
|
||||
argv = common.gatewayArgv cfg;
|
||||
logName = "hermes-agent";
|
||||
};
|
||||
})
|
||||
|
||||
(lib.mkIf (isDarwin && cfg.backend.mode != "none") {
|
||||
launchd.agents.hermes-backend = mkAgent {
|
||||
argv = common.backendArgv { inherit pkgs cfg; };
|
||||
logName = "hermes-backend";
|
||||
};
|
||||
})
|
||||
]
|
||||
))
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
# nixosModules.nix the service user and group, stateDir,
|
||||
# addToSystemPackages, container mode, tmpfiles,
|
||||
# system.activationScripts, system systemd units
|
||||
# homeManagerModules.nix hermesHome, installPackage, home.activation,
|
||||
# homeManagerModules.nix hermesHome, programs.hermes-agent (the CLI and
|
||||
# the desktop application), home.activation,
|
||||
# systemd.user.services, launchd.agents
|
||||
#
|
||||
# The split is by scope, not by feature. Code that needs root or a system
|
||||
@@ -618,9 +619,59 @@ let
|
||||
default = [ ];
|
||||
description = "More command-line arguments for the backend command.";
|
||||
};
|
||||
|
||||
sessionTokenFile = mkOption {
|
||||
# The type is `str` and not `path` for the same reason that
|
||||
# environmentFiles uses `str`. A Nix path literal copies the secret
|
||||
# into the Nix store, which all users can read. Use a runtime path
|
||||
# from sops-nix or agenix instead.
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = ''
|
||||
The path to a file that holds the session token of the backend,
|
||||
on one line.
|
||||
|
||||
The backend reads the file at each start and gives the value to
|
||||
HERMES_DASHBOARD_SESSION_TOKEN. That token authorizes the /api
|
||||
routes and the /api/ws socket. Hermes Desktop presents the same
|
||||
value, so the application reaches this backend and starts no
|
||||
second one.
|
||||
|
||||
Without this option the backend makes a new token at each start,
|
||||
which no other process can know.
|
||||
|
||||
CAUTION: The file must hold the raw token and nothing else. Give
|
||||
it mode 0600. Do not use a Nix path literal, because that copies
|
||||
the secret into the Nix store.
|
||||
'';
|
||||
example = literalExpression ''config.sops.secrets."hermes/desktop-token".path'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# ── The removal of installPackage ───────────────────────────────────────
|
||||
# The programs./services. split replaced this option. It defaulted to true,
|
||||
# so a person who never named it still got the command line, and a silent
|
||||
# removal leaves them with no `hermes` on the PATH and no message. The
|
||||
# module refuses the configuration with this text.
|
||||
#
|
||||
# A function, and not a literal in the module, so a check can call the same
|
||||
# code and read the real message. A check that matched the source text of
|
||||
# the module would pass while the message was wrong.
|
||||
installPackageRemovedMessage =
|
||||
value:
|
||||
''
|
||||
services.hermes-agent.installPackage was removed. Hermes now
|
||||
separates the installation from the services, which is the
|
||||
Home Manager convention:
|
||||
|
||||
programs.hermes-agent.enable = ${lib.boolToString (value != false)}; # the hermes CLI, and HERMES_HOME for your shells
|
||||
programs.hermes-agent.desktop.enable = true; # the desktop application
|
||||
|
||||
`services.hermes-agent` keeps the state, the configuration and
|
||||
the daemons. Remove `installPackage` and add the line above.
|
||||
'';
|
||||
|
||||
# ── Package resolution ──────────────────────────────────────────────────
|
||||
effectivePackage =
|
||||
cfg:
|
||||
@@ -862,7 +913,14 @@ let
|
||||
]
|
||||
++ cfg.backend.extraArgs;
|
||||
|
||||
# The launcher that waits for the bind target, then starts the backend.
|
||||
# The launcher that reads the session token, waits for the bind target,
|
||||
# then starts the backend.
|
||||
#
|
||||
# The token cannot go in the unit environment. A systemd `Environment=`
|
||||
# value and a launchd EnvironmentVariables value both land in the Nix
|
||||
# store, which all users can read. Thus the launcher reads the file at
|
||||
# start time. launchd has no EnvironmentFile, so a script is the one shape
|
||||
# that works on both hosts.
|
||||
#
|
||||
# `exec` on the last line keeps hermes as the MainPID of the unit. No shell
|
||||
# stays in the cgroup, and the restart logic of systemd sees the real
|
||||
@@ -879,8 +937,32 @@ let
|
||||
_timeout=${toString cfg.backend.waitTimeout}
|
||||
_waited=0
|
||||
|
||||
${lib.optionalString (cfg.backend.sessionTokenFile != null) ''
|
||||
# Read the token, and never put it on a command line. A command
|
||||
# line is visible to each process on the host.
|
||||
_token_file=${lib.escapeShellArg cfg.backend.sessionTokenFile}
|
||||
|
||||
if [ ! -r "$_token_file" ]; then
|
||||
echo "hermes-backend: cannot read the session token file '$_token_file'. The unit stops." >&2
|
||||
echo "hermes-backend: backend.sessionTokenFile must name a runtime path that this user can read." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HERMES_DASHBOARD_SESSION_TOKEN="$(${pkgs.coreutils}/bin/tr -d '\r\n' < "$_token_file")"
|
||||
export HERMES_DASHBOARD_SESSION_TOKEN
|
||||
|
||||
if [ -z "$HERMES_DASHBOARD_SESSION_TOKEN" ]; then
|
||||
echo "hermes-backend: the session token file '$_token_file' is empty. The unit stops." >&2
|
||||
exit 1
|
||||
fi
|
||||
''}
|
||||
${
|
||||
if cfg.backend.waitFor == "hostname" then
|
||||
if cfg.backend.waitFor == null then
|
||||
''
|
||||
_target=${lib.escapeShellArg cfg.backend.host}
|
||||
_how="the configured address"
|
||||
''
|
||||
else if cfg.backend.waitFor == "hostname" then
|
||||
''
|
||||
_target=${lib.escapeShellArg cfg.backend.host}
|
||||
_how="hostname"
|
||||
@@ -940,7 +1022,10 @@ let
|
||||
|
||||
backendArgv =
|
||||
{ pkgs, cfg }:
|
||||
if cfg.backend.waitFor == null then
|
||||
# A plain argv is enough only when nothing must run before the backend.
|
||||
# A wait needs the address at start time, and a token must be read from
|
||||
# a file that the store must never hold. Either one needs the launcher.
|
||||
if cfg.backend.waitFor == null && cfg.backend.sessionTokenFile == null then
|
||||
backendCommand cfg cfg.backend.host
|
||||
else
|
||||
[ "${backendLauncher { inherit pkgs cfg; }}" ];
|
||||
@@ -1063,6 +1148,7 @@ in
|
||||
deepConfigType
|
||||
effectivePackage
|
||||
gatewayArgv
|
||||
installPackageRemovedMessage
|
||||
mcpServerType
|
||||
mcpServersToConfig
|
||||
mkConfigFiles
|
||||
|
||||
@@ -600,7 +600,8 @@ The option set is the same set that the NixOS module uses. It is `services.herme
|
||||
| Runs as | a system user that you declare, with `user`, `group` and `createUser` | you |
|
||||
| State directory | `stateDir` and `/.hermes` | `hermesHome`, set directly. The default is `~/.hermes`. |
|
||||
| Service | `systemd.services` | `systemd.user.services` on Linux, `launchd.agents` on macOS |
|
||||
| CLI on the PATH | `addToSystemPackages`, which exports `HERMES_HOME` for the full system | `installPackage`, which exports it for your session only |
|
||||
| CLI on the PATH | `addToSystemPackages`, which exports `HERMES_HOME` for the full system | `programs.hermes-agent.enable`, which exports it for your session only |
|
||||
| Desktop application | not supported, because a system service cannot own a user session | `programs.hermes-agent.desktop.enable` |
|
||||
| Container mode | supported | not supported, because it needs root and the Docker socket |
|
||||
|
||||
### Add the Flake Input
|
||||
@@ -1031,9 +1032,50 @@ This option runs the process that Hermes Desktop and the web dashboard connect t
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `hermesHome` | `str` | `"${config.home.homeDirectory}/.hermes"` | `HERMES_HOME` directly. The NixOS module builds it from `stateDir`. |
|
||||
| `installPackage` | `bool` | `true` | Add the `hermes` CLI to `home.packages`, and export `HERMES_HOME` for your shells |
|
||||
| `gateway.enable` | `bool` | `false` | Run the messaging gateway. On the NixOS module the gateway is the service, so that module has no such option. |
|
||||
|
||||
### `programs.hermes-agent` (Home Manager only)
|
||||
|
||||
Home Manager separates "install this application for me" from "run this
|
||||
daemon". `services.hermes-agent` keeps the state, the configuration and the
|
||||
daemons. `programs.hermes-agent` installs what you use, and reads
|
||||
`hermesHome` and the backend address from the services.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `enable` | `bool` | `false` | Add the `hermes` CLI to `home.packages`, and export `HERMES_HOME` for your shells |
|
||||
| `package` | `package` | `services.hermes-agent.package` | The package to install. The default applies `extraPythonPackages` and `extraDependencyGroups` from the services, so both are one build. |
|
||||
| `desktop.enable` | `bool` | `false` | Add the Hermes Desktop application, with a launcher entry on Linux |
|
||||
| `desktop.package` | `package` | `package.hermesDesktop` | The desktop package. The default follows `package`, so the application and the services run one Hermes runtime. |
|
||||
|
||||
```nix
|
||||
programs.hermes-agent = {
|
||||
enable = true;
|
||||
desktop.enable = true;
|
||||
};
|
||||
|
||||
services.hermes-agent = {
|
||||
enable = true;
|
||||
backend.mode = "serve";
|
||||
backend.sessionTokenFile = config.sops.secrets."hermes/desktop-token".path;
|
||||
};
|
||||
```
|
||||
|
||||
The launcher carries `HERMES_HOME` itself. A desktop menu reads no shell
|
||||
profile, so the value that `programs.hermes-agent.enable` exports with
|
||||
`home.sessionVariables` reaches an interactive shell only. Without the
|
||||
value in the launcher, the application opens `~/.hermes` while the
|
||||
services use `hermesHome`, and you see no sessions and no keys.
|
||||
|
||||
With `backend.sessionTokenFile`, the application connects to the backend
|
||||
of the service instead of starting one of its own. Both sides read the
|
||||
file at start time, so the token enters no Nix store path. Without the
|
||||
option, each side runs its own backend.
|
||||
|
||||
`services.hermes-agent.installPackage` was removed by this split. A
|
||||
configuration that still sets it gets an error that names the
|
||||
replacement.
|
||||
|
||||
### Container (NixOS only)
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|
||||
Reference in New Issue
Block a user