{ 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 "<prompt>"' $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";
}