{ pkgs, self }: let inherit (pkgs) lib; inherit (self.lib) mkEnvironment mkSkill mkSkillModule; # Stub agents record what the launcher handed them, so the checks can assert on # the contract between launcher and agent without needing a login or network. stub = name: pkgs.writeShellScriptBin name '' log="$STUB_LOG" : >"$log.args" if [ $# -gt 0 ]; then printf '%s\n' "$@" >"$log.args"; fi { echo "CLAUDE_CONFIG_DIR=''${CLAUDE_CONFIG_DIR:-}" echo "CODEX_HOME=''${CODEX_HOME:-}" echo "AGENT_ENV_LAUNCHER=''${AGENT_ENV_LAUNCHER:-}" echo "PATH=$PATH" if command -v hello >/dev/null; then echo "HELLO=yes"; else echo "HELLO=no"; fi } >"$log.env" ''; stubs = { claude.package = stub "claude"; codex.package = stub "codex"; }; skillSrc = name: pkgs.writeTextDir "SKILL.md" '' --- name: ${name} description: test skill ${name} --- ''; # `leaf` needs the `hello` tool; `top` depends on `leaf`. leaf = mkSkill { inherit pkgs; name = "leaf"; src = skillSrc "leaf"; runtimeInputs = [ pkgs.hello ]; }; top = mkSkill { inherit pkgs; name = "top"; src = skillSrc "top"; skills = [ leaf ]; }; full = mkEnvironment { inherit pkgs; modules = [ stubs (mkSkillModule top) { claude.enable = true; codex.enable = true; modules.skills.top.enable = true; # Names sort opposite to their order, so the check proves `order` wins. instructions.a-second = { text = "SECOND"; order = 20; }; instructions.z-first = { text = "FIRST"; order = 10; }; claude.settings.model = "opus"; codex.settings.model = "gpt-5"; launchers = { claude-work.agent = "claude"; claude-pers.agent = "claude"; codex-work.agent = "codex"; # Same state dir as claude-work, used to test switching managed content. claude-work-bare = { agent = "claude"; id = "claude-work"; }; }; } ]; }; # Same launcher id as full's claude-work, but no skills, instructions or settings. bare = mkEnvironment { inherit pkgs; modules = [ stubs { claude.enable = true; codex.enable = true; launchers.claude-work.agent = "claude"; launchers.codex-bare.agent = "codex"; } ]; }; fails = modules: !(builtins.tryEval (mkEnvironment { inherit pkgs modules; }).package.drvPath).success; otherLeaf = mkSkill { inherit pkgs; name = "leaf"; src = skillSrc "leaf-other"; }; runTest = name: script: pkgs.runCommand "agent-environments-${name}" { } '' set -euo pipefail export HOME=$TMPDIR/home mkdir -p $HOME export STUB_LOG=$TMPDIR/stub fail() { echo "FAIL: $*" >&2; exit 1; } ${script} touch $out ''; in { launch-contract = runTest "launch-contract" '' ${full.launchers.claude-work}/bin/claude-work --resume x s=$HOME/.local/agent-environments/claude-work grep -qxF "CLAUDE_CONFIG_DIR=$s" $STUB_LOG.env || fail "CLAUDE_CONFIG_DIR not set to state dir" grep -qxF "CODEX_HOME=" $STUB_LOG.env || fail "claude launcher leaked CODEX_HOME" # Instructions: order field wins over names, fragments separated by a blank line. [ "$(cat $s/CLAUDE.md)" = "$(printf 'FIRST\n\nSECOND')" ] || fail "CLAUDE.md content: $(cat $s/CLAUDE.md)" # Skills: dependency closure linked, and the dependency's tool is on PATH. [ -f $s/skills/top/SKILL.md ] || fail "top skill missing" [ -f $s/skills/leaf/SKILL.md ] || fail "dependency skill leaf missing" grep -qxF "HELLO=yes" $STUB_LOG.env || fail "skill runtime input not on PATH" # Settings layered via --settings, user args passed through after it. [ "$(sed -n 1p $STUB_LOG.args)" = "--settings" ] || fail "missing --settings" grep -q '"model": *"opus"' "$(sed -n 2p $STUB_LOG.args)" || fail "settings file content" [ "$(sed -n '3,$p' $STUB_LOG.args)" = "$(printf -- '--resume\nx')" ] || fail "user args not passed through" [ ! -e $s/settings.json ] || fail "launcher must not own settings.json" ${full.launchers.codex-work}/bin/codex-work exec hi c=$HOME/.local/agent-environments/codex-work grep -qxF "CODEX_HOME=$c" $STUB_LOG.env || fail "CODEX_HOME not set" [ "$(cat $c/AGENTS.md)" = "$(printf 'FIRST\n\nSECOND')" ] || fail "AGENTS.md content" [ -f $c/skills/leaf/SKILL.md ] || fail "codex skills missing" grep -q 'model = "gpt-5"' $c/agent-environments.config.toml || fail "codex profile content" [ "$(cat $STUB_LOG.args)" = "$(printf -- '--profile\nagent-environments\nexec\nhi')" ] || fail "codex args: $(cat $STUB_LOG.args)" [ ! -e $c/config.toml ] || fail "launcher must not own config.toml" ''; # Separate launchers from one environment must not share a login. separate-logins = runTest "separate-logins" '' ${full.launchers.claude-work}/bin/claude-work a=$(grep CLAUDE_CONFIG_DIR $STUB_LOG.env) ${full.launchers.claude-pers}/bin/claude-pers b=$(grep CLAUDE_CONFIG_DIR $STUB_LOG.env) [ "$a" != "$b" ] || fail "claude-work and claude-pers share $a" [ "$b" = "CLAUDE_CONFIG_DIR=$HOME/.local/agent-environments/claude-pers" ] || fail "$b" ''; # Hooks in agent settings call tools that no skill provides; `packages` puts # them on PATH, and an environment without them must not have them. packages-on-path = let env = mkEnvironment { inherit pkgs; modules = [ stubs { codex.enable = true; packages = [ pkgs.hello ]; launchers.codex-tools.agent = "codex"; } ]; }; in runTest "packages-on-path" '' ${bare.launchers.codex-bare}/bin/codex-bare grep -qxF "HELLO=no" $STUB_LOG.env || fail "hello on PATH without packages" ${env.launchers.codex-tools}/bin/codex-tools grep -qxF "HELLO=yes" $STUB_LOG.env || fail "packages not on PATH" ''; # Without settings, no flags are injected and no files are managed. no-settings = runTest "no-settings" '' ${bare.launchers.claude-work}/bin/claude-work [ ! -s $STUB_LOG.args ] || fail "unexpected args: $(cat $STUB_LOG.args)" ${bare.launchers.codex-bare}/bin/codex-bare [ ! -s $STUB_LOG.args ] || fail "unexpected codex args: $(cat $STUB_LOG.args)" c=$HOME/.local/agent-environments/codex-bare [ ! -e $c/AGENTS.md ] && [ ! -e $c/agent-environments.config.toml ] || fail "files managed without config" ''; # Switching to an environment with fewer skills removes only what we linked. cleanup-keeps-user-files = runTest "cleanup-keeps-user-files" '' s=$HOME/.local/agent-environments/claude-work mkdir -p $s/skills/.system $s/skills/mine ln -s /nix/store $s/skills/user-link ${full.launchers.claude-work}/bin/claude-work [ -L $s/skills/top ] || fail "top not linked" ${bare.launchers.claude-work}/bin/claude-work [ ! -e $s/skills/top ] && [ ! -e $s/skills/leaf ] || fail "stale skills kept" [ ! -e $s/CLAUDE.md ] || fail "stale CLAUDE.md kept" [ -d $s/skills/.system ] && [ -d $s/skills/mine ] && [ -L $s/skills/user-link ] || fail "user files removed" # And back again: the manifest must let us re-create the links. ${full.launchers.claude-work-bare}/bin/claude-work-bare [ -L $s/skills/top ] || fail "relink failed" ''; # User-owned files at managed paths are never replaced. refuses-unmanaged = runTest "refuses-unmanaged" '' s=$HOME/.local/agent-environments/claude-work mkdir -p $s/skills echo mine >$s/CLAUDE.md if ${full.launchers.claude-work}/bin/claude-work 2>err; then fail "launcher replaced user CLAUDE.md"; fi grep -q "refusing to replace '$s/CLAUDE.md'" err || fail "unclear error: $(cat err)" [ "$(cat $s/CLAUDE.md)" = mine ] || fail "user CLAUDE.md modified" [ ! -e $STUB_LOG.args ] || fail "agent started despite conflict" rm $s/CLAUDE.md ln -s /nix/store $s/skills/top if ${full.launchers.claude-work}/bin/claude-work 2>err; then fail "launcher replaced user symlink"; fi [ "$(readlink $s/skills/top)" = /nix/store ] || fail "user symlink modified" # A path we managed before, but which the user replaced with real data, is theirs now. rm $s/skills/top ${full.launchers.claude-work}/bin/claude-work rm $s/CLAUDE.md && echo edited >$s/CLAUDE.md if ${full.launchers.claude-work}/bin/claude-work 2>err; then fail "launcher replaced user file at managed path"; fi [ "$(cat $s/CLAUDE.md)" = edited ] || fail "user file modified" ''; # The launcher's own bookkeeping paths must not write through symlinks into # files or directories outside the state dir. no-writes-through-symlinks = runTest "no-writes-through-symlinks" '' s=$HOME/.local/agent-environments/claude-work mkdir -p $s/skills echo precious >$TMPDIR/precious ln -s $TMPDIR/precious $s/.agent-environments.lock ${full.launchers.claude-work}/bin/claude-work [ "$(cat $TMPDIR/precious)" = precious ] || fail "lock open truncated a symlinked file" p=$HOME/.local/agent-environments/claude-pers mkdir -p $p $TMPDIR/shared-skills ln -s $TMPDIR/shared-skills $p/skills if ${full.launchers.claude-pers}/bin/claude-pers 2>err; then fail "managed links through a symlinked skills dir"; fi [ -z "$(ls -A $TMPDIR/shared-skills)" ] || fail "wrote into the shared skills dir" ''; # stateDir is data, not shell code: only a leading ~/ is expanded. literal-state-dir = let env = mkEnvironment { inherit pkgs; modules = [ stubs { claude.enable = true; launchers.claude-odd = { agent = "claude"; stateDir = ''~/odd "dir"/$(touch injected)''; }; } ]; }; in runTest "literal-state-dir" '' cd $TMPDIR ${env.launchers.claude-odd}/bin/claude-odd [ ! -e injected ] || fail "stateDir was executed as shell code" grep -qxF "CLAUDE_CONFIG_DIR=$HOME/odd \"dir\"/\$(touch injected)" $STUB_LOG.env || fail "$(cat $STUB_LOG.env)" ''; # With companions, a session can start every profile of its environment, but a # companion session cannot start further companions. companions = let env = mkEnvironment { inherit pkgs; modules = [ stubs { claude.enable = true; codex.enable = true; companions.enable = true; launchers = { claude-work.agent = "claude"; codex-pers.agent = "codex"; }; } ]; }; in runTest "companions" '' ${env.launchers.claude-work}/bin/claude-work grep -qxF "AGENT_ENV_LAUNCHER=claude-work" $STUB_LOG.env || fail "AGENT_ENV_LAUNCHER not exported" session_path=$(sed -n 's/^PATH=//p' $STUB_LOG.env) companion=$(PATH=$session_path command -v codex-pers) || fail "codex-pers not on the session PATH" PATH=$session_path command -v claude-work >/dev/null || fail "own profile not available as companion" skill=$HOME/.local/agent-environments/claude-work/skills/companions/SKILL.md grep -qF '`codex-pers exec --sandbox read-only' $skill || fail "codex call missing from skill" grep -qF '`claude-work -p ""' $skill || fail "claude call missing from skill" # The companion runs with its own login and without companions of its own. $companion exec x grep -qxF "CODEX_HOME=$HOME/.local/agent-environments/codex-pers" $STUB_LOG.env || fail "companion login" grep -qxF "AGENT_ENV_LAUNCHER=codex-pers" $STUB_LOG.env || fail "companion name" nested_path=$(sed -n 's/^PATH=//p' $STUB_LOG.env) if PATH=$nested_path command -v claude-work >/dev/null; then fail "companion can start companions"; fi ''; # The stubs only prove what the launcher hands over; this proves the real Codex # reads it: AGENTS.md, linked skills and the settings profile all reach the prompt. codex-integration = let env = mkEnvironment { inherit pkgs; modules = [ { codex.enable = true; instructions.probe.text = "PROBE_INSTRUCTION_MARKER"; skills.packages = [ (mkSkill { inherit pkgs; name = "probe"; src = pkgs.writeTextDir "SKILL.md" '' --- name: probe description: PROBE_SKILL_MARKER use when probing --- ''; }) ]; codex.settings.developer_instructions = "PROBE_PROFILE_MARKER"; launchers.codex-probe.agent = "codex"; } ]; }; in runTest "codex-integration" '' ${env.launchers.codex-probe}/bin/codex-probe debug prompt-input hi >prompt.json for m in PROBE_INSTRUCTION_MARKER PROBE_SKILL_MARKER PROBE_PROFILE_MARKER; do grep -q $m prompt.json || fail "codex prompt lacks $m" done ''; skill-without-skill-md = pkgs.testers.testBuildFailure (mkSkill { inherit pkgs; name = "broken"; src = pkgs.writeTextDir "README.md" ""; }); # Configuration mistakes are caught at evaluation time. eval-errors = assert fails [ stubs { claude.enable = true; launchers.x = { agent = "claude"; stateDir = "relative/dir"; }; } ]; assert fails [ stubs { launchers.x.agent = "claude"; } ]; assert fails [ stubs { claude.enable = true; launchers.x = { agent = "claude"; id = "../escape"; }; } ]; assert fails [ stubs { claude.enable = true; launchers."bad name".agent = "claude"; } ]; assert fails [ stubs { claude.enable = true; skills.packages = [ top otherLeaf ]; launchers.x.agent = "claude"; } ]; assert !(builtins.tryEval (mkSkill { inherit pkgs; name = "a/b"; src = skillSrc "x"; }).drvPath ).success; pkgs.runCommand "agent-environments-eval-errors" { } "touch $out"; }