From 34f8df1cf2965fd17c72befd7400034e975d17b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Boh=C3=A1=C4=8Dek?= Date: Sat, 26 Sep 2026 21:52:08 +0200 Subject: [PATCH] Add launchers with a state dir per login --- modules/launcher.nix | 177 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 modules/launcher.nix diff --git a/modules/launcher.nix b/modules/launcher.nix new file mode 100644 index 0000000000000000000000000000000000000000..233757325a8165e9fa0b6a43e20aa2bde3214ce0 --- /dev/null +++ b/modules/launcher.nix @@ -0,0 +1,177 @@ +{ + config, + lib, + pkgs, + agentEnvLib, + ... +}: +let + inherit (lib) mkOption types; + inherit (config) build; + + instructionsDrv = pkgs.writeText "agent-instructions.md" build.instructionsText; + + # Relative path in the state dir -> store path, for one agent. + managedLinks = + agent: + lib.optionalAttrs (config.instructions != { }) { + ${agent.instructionsFile} = "${instructionsDrv}"; + } + // lib.listToAttrs (map (s: lib.nameValuePair "skills/${s.skillName}" "${s}") build.skills) + // lib.mapAttrs (_: toString) agent.files; + + bin = pkg: name: "${lib.getExe' pkg name}"; + mv = bin pkgs.coreutils "mv"; + ln = bin pkgs.coreutils "ln"; + rm = bin pkgs.coreutils "rm"; + mkdir = bin pkgs.coreutils "mkdir"; + mktemp = bin pkgs.coreutils "mktemp"; + flock = lib.getExe pkgs.flock; + + # A literal path; only a leading ~/ is expanded (to $HOME). + stateDirShell = + dir: + if lib.hasPrefix "~/" dir then + "\"$HOME\"/${lib.escapeShellArg (lib.removePrefix "~/" dir)}" + else + lib.escapeShellArg dir; + + mkLauncher = + name: launcher: + let + agent = build.agents.${launcher.agent}; + links = managedLinks agent; + runtimePath = lib.makeBinPath build.skillRuntimeInputs; + in + pkgs.writeShellApplication { + inherit name; + text = '' + # shellcheck disable=SC2016 # stateDir is literal on purpose + state=${stateDirShell launcher.stateDir} + if [ -L "$state/skills" ]; then + echo "${name}: '$state/skills' is a symlink; refusing to manage links outside the state dir." >&2 + exit 1 + fi + ${mkdir} -p "$state/skills" + + # Append mode: never truncate whatever the lock path might point at. + exec {lock}>>"$state/.agent-environments.lock" + ${flock} "$lock" + + manifest="$state/.agent-environments-managed" + declare -A wanted=( + ${ + lib.concatStrings ( + lib.mapAttrsToList (p: target: " [${lib.escapeShellArg p}]=${lib.escapeShellArg target}\n") links + ) + }) + declare -A owned=() + if [ -f "$manifest" ]; then + while IFS= read -r p; do + [ -n "$p" ] && owned["$p"]=1 + done <"$manifest" + fi + + # Refuse to touch anything we do not own before changing anything. + for p in "''${!wanted[@]}"; do + f="$state/$p" + if [ -e "$f" ] || [ -L "$f" ]; then + if [ -z "''${owned[$p]+x}" ] || [ ! -L "$f" ]; then + echo "${name}: refusing to replace '$f': it is not managed by agent-environments." >&2 + echo "${name}: move it away and start again." >&2 + exit 1 + fi + fi + done + + for p in "''${!owned[@]}"; do + if [ -z "''${wanted[$p]+x}" ] && [ -L "$state/$p" ]; then + ${rm} -f "$state/$p" + fi + done + + for p in "''${!wanted[@]}"; do + # No -f: a unique temp name that already exists is an error, not something to delete. + tmp="$state/$p.agent-env-tmp.$$" + ${ln} -sT "''${wanted[$p]}" "$tmp" + ${mv} -fT "$tmp" "$state/$p" + done + + tmp=$(${mktemp} "$manifest.XXXXXX") + if [ "''${#wanted[@]}" -gt 0 ]; then + printf '%s\n' "''${!wanted[@]}" >"$tmp" + fi + ${mv} -fT "$tmp" "$manifest" + + exec {lock}>&- + + export ${agent.stateEnvVar}="$state" + ${lib.optionalString ( + runtimePath != "" + ) ''export PATH=${lib.escapeShellArg runtimePath}"''${PATH:+:$PATH}"''} + exec ${lib.escapeShellArgs ([ agent.exe ] ++ agent.flags)} "$@" + ''; + }; +in +{ + options.launchers = mkOption { + default = { }; + description = "Commands that start an agent with this environment. The attribute name is the command name."; + type = types.attrsOf ( + types.submodule ( + { name, config, ... }: + { + options = { + agent = mkOption { + type = types.enum [ + "claude" + "codex" + ]; + }; + id = mkOption { + type = types.str; + default = name; + description = "Identity of the state dir; launchers with different ids have separate logins."; + }; + stateDir = mkOption { + type = types.str; + default = "~/.local/agent-environments/${config.id}"; + description = "State dir (CLAUDE_CONFIG_DIR / CODEX_HOME): an absolute path, or one starting with ~/ for $HOME. Taken literally."; + }; + }; + } + ) + ); + }; + + config = { + build = { + launchers = lib.mapAttrs mkLauncher config.launchers; + package = pkgs.symlinkJoin { + name = "agent-environment"; + paths = lib.attrValues config.build.launchers; + }; + }; + + assertions = lib.concatLists ( + lib.mapAttrsToList (name: l: [ + { + assertion = agentEnvLib.safeName name; + message = "launcher name '${name}' is not a safe command name"; + } + { + assertion = agentEnvLib.safeName l.id; + message = "launcher '${name}': id '${l.id}' is not a safe path component"; + } + { + assertion = lib.hasPrefix "/" l.stateDir || lib.hasPrefix "~/" l.stateDir; + message = "launcher '${name}': stateDir must be absolute or start with ~/"; + } + { + assertion = config.build.agents ? ${l.agent}; + message = "launcher '${name}' uses ${l.agent}, but ${l.agent}.enable is false"; + } + ]) config.launchers + ); + }; +}