diff --git a/docs/agent-hook-parity.md b/docs/agent-hook-parity.md index 9a7329e1..162fddbe 100644 --- a/docs/agent-hook-parity.md +++ b/docs/agent-hook-parity.md @@ -1,6 +1,6 @@ # Agent hook parity -This matrix records the current boundary between Claude Code and Codex hooks. It is reviewed against the generated Codex file at each configuration change. +This matrix records the current boundary between Claude Code and Codex hooks. It is reviewed against the generated Codex file at each configuration change, including the configured primary Polylogue hooks spool shared by every writer. | Capability | Claude Code | Codex | Evidence and action | | --- | --- | --- | --- | diff --git a/flake/tests/agent-parity.nix b/flake/tests/agent-parity.nix index 49392cc4..f9764698 100644 --- a/flake/tests/agent-parity.nix +++ b/flake/tests/agent-parity.nix @@ -1,32 +1,108 @@ # Claude/Codex hook parity: the rows docs/agent-hook-parity.md records as # "Enforced" on both clients are checked against the two real hook sources # (dots/claude/managed-settings.json and the generated Codex hooks), not -# restated as a list of expected names here. +# restated as a list of expected names here. Both clients invoke the installed +# upstream `polylogue-hook`; its archive root comes from generated +# `polylogue.toml`. # # Provably fails when: a `polylogue-hook ` lane present in Claude's -# settings is dropped from the generated Codex hooks (verified by removing -# the PostToolUse entry from modules/features/dev/agents/hooks.nix), or when -# either client loses the pre-compaction handoff or the shared hook coverage. +# settings is dropped from the generated Codex hooks, when a writer bakes a +# sidecar path, when generated archive.root stops following dataDir, or when +# either client loses the pre-compaction handoff or shared hook coverage. { inputs, ... }: +let + inherit (inputs.nixpkgs) lib; +in { perSystem = { system, ... }: let pkgs = inputs.nixpkgs.legacyPackages.${system}; + sentinelDataDir = "/tmp/sinnix-polylogue-parity-sentinel"; + enrichDumpSource = ../../scripts/sinnix-enrich-dump; + repoRoot = inputs.self; codexHooks = import ../../modules/features/dev/agents/hooks.nix { inherit pkgs; dotsRoot = inputs.self + "/dots"; }; claudeHooks = ../../dots/claude/managed-settings.json; + polylogueHook = inputs.polylogue.packages.${system}.default; + testLib = import ../test-lib.nix { inherit inputs lib; }; + inherit (testLib) + evalTestSpec + hmFor + mkServiceTest + ; + polylogueSpec = mkServiceTest { + name = "polylogue-agent-hook-parity"; + service = "polylogue"; + extraModules = [ + (_: { + sinnix.services.polylogue.dataDir = sentinelDataDir; + }) + ]; + assertions = _config: [ ]; + }; + polylogueEvaluated = evalTestSpec system polylogueSpec; + polylogueConfigSource = + (hmFor polylogueEvaluated.config).xdg.configFile."polylogue/polylogue.toml".source; in { checks.agent-hook-parity = pkgs.runCommand "agent-hook-parity-check" { - inherit codexHooks claudeHooks; - nativeBuildInputs = [ pkgs.jq ]; + inherit + claudeHooks + codexHooks + enrichDumpSource + polylogueConfigSource + polylogueHook + repoRoot + sentinelDataDir + ; + nativeBuildInputs = [ + pkgs.coreutils + pkgs.gnugrep + pkgs.jq + pkgs.ripgrep + pkgs.strace + ]; } '' + require_text() { + needle="$1" + file="$2" + label="$3" + if ! grep -Fq -- "$needle" "$file"; then + echo "missing $label: $needle in $file" >&2 + exit 1 + fi + } + + reject_text() { + needle="$1" + file="$2" + label="$3" + if grep -Fq -- "$needle" "$file"; then + echo "forbidden $label: $needle in $file" >&2 + grep -Fn -- "$needle" "$file" >&2 + exit 1 + fi + } + + require_row() { + row="$1" + file="$2" + label="$3" + if ! grep -Fxq -- "$row" "$file"; then + echo "missing $label row: $row in $file" >&2 + sed -n '1,120p' "$file" >&2 + exit 1 + fi + } + + test -x "$polylogueHook/bin/polylogue-hook" + # The evidence lanes: every lifecycle event on which a client # ships a session to Polylogue. Codex must cover at least what # Claude does, or half the machine's agent history stops being @@ -52,6 +128,125 @@ exit 1 fi + # Every actual Polylogue writer declaration must use the upstream + # executable with event/provider arguments only. In particular, + # the anchored provider capture rejects any trailing sidecar flag. + writer_rows() { + jq -r ' + .hooks + | to_entries[] + | .key as $event + | .value[]?.hooks[]?.command + | select(type == "string" and startswith("polylogue-hook ")) + | [$event, (capture(" --provider (?[^ ]+)$").provider)] + | @tsv + ' "$1" + } + writer_rows "$claudeHooks" | sort > claude-writers + writer_rows "$codexHooks" | sort > codex-writers + test -s claude-writers + test -s codex-writers + cut -f1 claude-writers > claude-writer-lanes + cut -f1 codex-writers > codex-writer-lanes + diff -u claude-writer-lanes codex-writer-lanes + require_row $'PreToolUse\tclaude-code' claude-writers claude-pretool + require_row $'PostToolUse\tclaude-code' claude-writers claude-posttool + require_row $'SessionStart\tclaude-code' claude-writers claude-sessionstart + require_row $'Stop\tclaude-code' claude-writers claude-stop + require_row $'UserPromptSubmit\tclaude-code' claude-writers claude-prompt + require_row $'PreToolUse\tcodex' codex-writers codex-pretool + require_row $'PostToolUse\tcodex' codex-writers codex-posttool + require_row $'SessionStart\tcodex' codex-writers codex-sessionstart + require_row $'Stop\tcodex' codex-writers codex-stop + require_row $'UserPromptSubmit\tcodex' codex-writers codex-prompt + + # Check the generated production config, not a test-only wrapper. + # This is the authority consumed by a flag-less upstream hook. + awk -v expected="root = \"$sentinelDataDir\"" ' + $0 == "[archive]" { in_archive = 1; next } + /^\[/ { in_archive = 0 } + in_archive && $0 == expected { found = 1 } + END { exit(found ? 0 : 1) } + ' "$polylogueConfigSource" || { + echo "generated polylogue.toml does not derive archive.root from dataDir" >&2 + sed -n '1,40p' "$polylogueConfigSource" >&2 + exit 1 + } + reject_text '/realm/state/polylogue' "$polylogueConfigSource" default-config-root + reject_text '/home/sinity/.local/share/polylogue' "$polylogueConfigSource" legacy-config-root + + # No repository hook command may bake a sidecar path. Keep this + # source census scoped to the actual hook declarations so this + # assertion cannot pass because a test copied a command literal. + sidecar_flag="--sidecar"-dir + if rg -n --fixed-strings -- "$sidecar_flag" \ + "$repoRoot/dots/claude/managed-settings.json" \ + "$repoRoot/modules/features/dev/agents/hooks.nix"; then + echo 'repository hook command bakes a Polylogue sidecar path' >&2 + exit 1 + fi + test ! -e "$repoRoot/modules/features/dev/agents/polylogue-hook.nix" + + # The managed Claude file is an out-of-store dots symlink. It and + # generated Codex config both retain the continuously installed + # upstream command name, so landing cannot create an activation + # interval where the named executable is absent. + require_text 'polylogue-hook Stop --provider claude-code' "$claudeHooks" claude-command-name + require_text 'polylogue-hook Stop --provider codex' "$codexHooks" codex-command-name + + # Standalone enrichment must fail before assembling or mutating + # any output when its archive-root contract is not configured. + if env -u POLYLOGUE_ARCHIVE_ROOT "${pkgs.bash}/bin/bash" "$enrichDumpSource" \ + >/dev/null 2>missing-enrich-root.stderr; then + echo 'enrichment dump accepted a missing Polylogue archive root' >&2 + exit 1 + fi + require_text POLYLOGUE_ARCHIVE_ROOT missing-enrich-root.stderr missing-enrich-root-diagnostic + + # Fresh-writer smoke: install the generated TOML at the same XDG + # path Home Manager uses in production, leave POLYLOGUE_ARCHIVE_ROOT + # unset, and execute the actual upstream hook for both providers. + # HOME, XDG data/state, and the absent archive-root environment are + # decoys. Only the configured archive root may receive sidecars. + smoke_root="$TMPDIR/polylogue-hook-smoke" + primary="$sentinelDataDir/hooks" + config_home="$smoke_root/config" + archive_decoy="$smoke_root/archive-decoy" + mkdir -p "$smoke_root/home" "$config_home/polylogue" "$smoke_root/xdg-data" "$smoke_root/xdg-state" "$archive_decoy" + cp "$polylogueConfigSource" "$config_home/polylogue/polylogue.toml" + trace="$smoke_root/access.trace" + printf '%s' '{"session_id":"parity-codex","source":"codex","turn_id":"turn-1"}' \ + | env -u POLYLOGUE_ARCHIVE_ROOT -u POLYLOGUE_CONFIG \ + HOME="$smoke_root/home" \ + XDG_CONFIG_HOME="$config_home" \ + XDG_DATA_HOME="$smoke_root/xdg-data" \ + XDG_STATE_HOME="$smoke_root/xdg-state" \ + strace -f -e trace=file -o "$trace" \ + "$polylogueHook/bin/polylogue-hook" UserPromptSubmit \ + --provider codex + printf '%s' '{"session_id":"parity-claude","source":"claude","turn_id":"turn-2"}' \ + | env -u POLYLOGUE_ARCHIVE_ROOT -u POLYLOGUE_CONFIG \ + HOME="$smoke_root/home" \ + XDG_CONFIG_HOME="$config_home" \ + XDG_DATA_HOME="$smoke_root/xdg-data" \ + XDG_STATE_HOME="$smoke_root/xdg-state" \ + strace -f -e trace=file -o "$smoke_root/claude.trace" \ + "$polylogueHook/bin/polylogue-hook" Stop \ + --provider claude-code + test -n "$(find "$primary" -type f -name 'codex-*.jsonl' -print -quit)" + test -n "$(find "$primary" -type f -name 'claude-code-*.jsonl' -print -quit)" + require_text UserPromptSubmit "$trace" codex-event + require_text --provider "$trace" provider-flag + require_text codex "$trace" codex-provider + require_text Stop "$smoke_root/claude.trace" claude-event + require_text claude-code "$smoke_root/claude.trace" claude-provider + test -z "$(find "$archive_decoy" "$smoke_root/home" "$smoke_root/xdg-data" "$smoke_root/xdg-state" -mindepth 1 -print -quit)" \ + || { echo 'isolated Polylogue hook smoke wrote a decoy root' >&2; exit 1; } + reject_text '/realm/state/polylogue' "$trace" live-hook-root + reject_text '/home/sinity/.local/share/polylogue' "$trace" legacy-hook-root + reject_text '/realm/state/polylogue' "$smoke_root/claude.trace" claude-live-hook-root + reject_text '/home/sinity/.local/share/polylogue' "$smoke_root/claude.trace" claude-legacy-hook-root + # The shared context handoff is configured independently for both # clients, so this verifies the intended cross-client agreement. for command in sinnix-context-handoff; do diff --git a/flake/tests/agent-tools.nix b/flake/tests/agent-tools.nix index 21752434..998bc309 100644 --- a/flake/tests/agent-tools.nix +++ b/flake/tests/agent-tools.nix @@ -8,6 +8,7 @@ in { system, ... }: let pkgs = inputs.nixpkgs.legacyPackages.${system}; + polylogueSentinelDataDir = "/tmp/sinnix-polylogue-agent-tools-sentinel"; # The packaged binary, not a copy of the source with a hand-patched # shebang: the wrapper and the withPackages interpreter that discovery # builds are part of what these fixtures are testing, and a fixture that @@ -95,6 +96,7 @@ in sinnix.features.dev.shell.enable = true; sinnix.features.dev.mcp-servers.enable = true; sinnix.services.clodex.enable = true; + sinnix.services.polylogue.dataDir = polylogueSentinelDataDir; }) ]; assertions = @@ -484,7 +486,7 @@ in ([.hooks.SessionStart[].hooks[].command] | any(contains("sessionstart-sinex-recall.sh"))) and ([.hooks.Stop[].hooks[].command] - | any(contains("polylogue-hook Stop --provider claude-code"))) + | any(. == "polylogue-hook Stop --provider claude-code")) ' ${inputs.self}/dots/claude/managed-settings.json >/dev/null # Rendered profile configs must match the registry's own computed @@ -571,7 +573,7 @@ in jq -e ' [.hooks.Stop[].hooks[].command] - | any(contains("polylogue-hook Stop --provider codex")) + | any(. == "polylogue-hook Stop --provider codex") ' "$HOME/.codex/hooks.json" >/dev/null jq -e ' [.hooks.SessionStart[].hooks[].command] | any(contains("sessionstart-sinex-recall.sh")) diff --git a/flake/tests/polylogue.nix b/flake/tests/polylogue.nix index 469ab1a2..7c395106 100644 --- a/flake/tests/polylogue.nix +++ b/flake/tests/polylogue.nix @@ -54,6 +54,34 @@ in touch "$out" ''; + sentinelDataDir = "/tmp/sinnix-polylogue-service-sentinel"; + archiveRootSpec = mkServiceTest { + name = "polylogue-archive-root"; + service = "polylogue"; + extraModules = [ + (_: { + sinnix.services.polylogue.dataDir = sentinelDataDir; + }) + ]; + assertions = _config: [ ]; + }; + archiveRootEvaluated = evalTestSpec system archiveRootSpec; + enrichmentSpec = mkServiceTest { + name = "enrichment-polylogue-root"; + service = "enrichment-loop"; + extraModules = [ + (_: { + sinnix.services.polylogue.dataDir = sentinelDataDir; + }) + ]; + assertions = _config: [ ]; + }; + enrichmentEvaluated = evalTestSpec system enrichmentSpec; + enrichmentService = enrichmentEvaluated.config.systemd.user.services.sinnix-enrichment-loop; + polylogueTmpfiles = archiveRootEvaluated.config.systemd.tmpfiles.rules; + enrichmentReadWritePaths = enrichmentService.serviceConfig.ReadWritePaths; + enrichmentArchiveRoot = enrichmentService.environment.POLYLOGUE_ARCHIVE_ROOT; + overriddenSpec = mkServiceTest { name = "polylogue-memory-budget"; service = "polylogue"; @@ -74,6 +102,37 @@ in expectedMax = "27G"; expectedBudgetBytes = "25769803776"; }; + polylogue-archive-root = + pkgs.runCommand "sinnix-polylogue-archive-root-check" + { + inherit sentinelDataDir; + nativeBuildInputs = [ pkgs.jq ]; + actualTmpfiles = builtins.toJSON polylogueTmpfiles; + } + '' + jq -e --arg root "$sentinelDataDir" ' + index("d \($root)/inbox 0755 sinity users -") != null and + index("L+ \($root)/inbox/chatgpt - - - - /realm/data/ai/chatlog/raw/chatgpt") != null and + index("L+ \($root)/inbox/claude - - - - /realm/data/ai/chatlog/raw/claude") != null + ' <<<"$actualTmpfiles" >/dev/null + touch "$out" + ''; + enrichment-polylogue-root = + pkgs.runCommand "sinnix-enrichment-polylogue-root-check" + { + inherit sentinelDataDir enrichmentArchiveRoot; + nativeBuildInputs = [ pkgs.jq ]; + actualReadWritePaths = builtins.toJSON enrichmentReadWritePaths; + } + '' + test "$enrichmentArchiveRoot" = "$sentinelDataDir" + jq -e --arg hook "$sentinelDataDir/hooks" 'index($hook) != null' <<<"$actualReadWritePaths" >/dev/null + if jq -e 'index("/realm/state/polylogue/hooks") != null' <<<"$actualReadWritePaths" >/dev/null; then + echo "enrichment hardening retained the default Polylogue hook root" >&2 + exit 1 + fi + touch "$out" + ''; }; }; } diff --git a/modules/features/dev/agents/hooks.nix b/modules/features/dev/agents/hooks.nix index 97ace407..9611ff7e 100644 --- a/modules/features/dev/agents/hooks.nix +++ b/modules/features/dev/agents/hooks.nix @@ -19,7 +19,7 @@ jsonFormat.generate "codex-hooks.json" { } { type = "command"; - command = "polylogue-hook SessionStart --provider codex --sidecar-dir /home/sinity/.local/share/polylogue/hooks"; + command = "polylogue-hook SessionStart --provider codex"; } ]; } @@ -29,7 +29,7 @@ jsonFormat.generate "codex-hooks.json" { hooks = [ { type = "command"; - command = "polylogue-hook UserPromptSubmit --provider codex --sidecar-dir /home/sinity/.local/share/polylogue/hooks"; + command = "polylogue-hook UserPromptSubmit --provider codex"; } ]; } @@ -49,7 +49,7 @@ jsonFormat.generate "codex-hooks.json" { hooks = [ { type = "command"; - command = "polylogue-hook PreToolUse --provider codex --sidecar-dir /home/sinity/.local/share/polylogue/hooks"; + command = "polylogue-hook PreToolUse --provider codex"; } ]; } @@ -59,7 +59,7 @@ jsonFormat.generate "codex-hooks.json" { hooks = [ { type = "command"; - command = "polylogue-hook PostToolUse --provider codex --sidecar-dir /home/sinity/.local/share/polylogue/hooks"; + command = "polylogue-hook PostToolUse --provider codex"; } ]; } @@ -69,7 +69,7 @@ jsonFormat.generate "codex-hooks.json" { hooks = [ { type = "command"; - command = "polylogue-hook Stop --provider codex --sidecar-dir /home/sinity/.local/share/polylogue/hooks"; + command = "polylogue-hook Stop --provider codex"; } ]; } diff --git a/modules/features/dev/agents/mcp-tools.nix b/modules/features/dev/agents/mcp-tools.nix index 8ef5cfa0..cf38969e 100644 --- a/modules/features/dev/agents/mcp-tools.nix +++ b/modules/features/dev/agents/mcp-tools.nix @@ -49,17 +49,8 @@ let ''; mcpPolylogueBin = pkgs.writeShellScriptBin "mcp-polylogue" '' set -euo pipefail - # The polylogue repo's .claude/settings.json pins POLYLOGUE_ARCHIVE_ROOT - # to the cloud-lane fixture (/tmp/polylogue-archive), and that env leaks - # into locally-launched MCP servers, pointing recall at an empty archive. - # Drop any leaked override that does not resolve to a real directory — - # testing existence rather than the one known literal also catches other - # stale overrides, while preserving a deliberate override to a real path. - # It cannot un-stick a server process already running with the leak in its - # inherited environment; that needs the MCP connection restarted. - if [ -n "''${POLYLOGUE_ARCHIVE_ROOT:-}" ] && [ ! -d "''${POLYLOGUE_ARCHIVE_ROOT}" ]; then - unset POLYLOGUE_ARCHIVE_ROOT - fi + # The configured service data directory is the sole archive-root owner. + export POLYLOGUE_ARCHIVE_ROOT=${lib.escapeShellArg config.sinnix.services.polylogue.dataDir} exec ${scriptPkgs.polylogue-cli}/bin/polylogue-mcp "$@" ''; # The user-facing files stay tiny out-of-store launchers, while the gateway diff --git a/modules/features/dev/agents/mcp.nix b/modules/features/dev/agents/mcp.nix index 36d72930..3f99b0cb 100644 --- a/modules/features/dev/agents/mcp.nix +++ b/modules/features/dev/agents/mcp.nix @@ -322,14 +322,6 @@ mkFeatureModule { source = "${scriptPkgs.sinnix-mcp-sinex}/bin/sinnix-mcp-sinex"; force = true; }; - ".local/share/polylogue/inbox/chatgpt" = { - source = config.lib.file.mkOutOfStoreSymlink "/realm/data/ai/chatlog/raw/chatgpt"; - force = true; - }; - ".local/share/polylogue/inbox/claude" = { - source = config.lib.file.mkOutOfStoreSymlink "/realm/data/ai/chatlog/raw/claude"; - force = true; - }; }; }; } diff --git a/modules/lib/systemd-hardening.nix b/modules/lib/systemd-hardening.nix index 22f0ff47..89304f2c 100644 --- a/modules/lib/systemd-hardening.nix +++ b/modules/lib/systemd-hardening.nix @@ -5,13 +5,16 @@ # ReadWritePaths silently discards that agent's session transcript and hook # spool. Any such unit must union this in and leave ProtectHome off. agentRuntimeWritePaths = - { home }: + { + home, + polylogueDataDir, + }: [ "${home}/.claude" "${home}/.codex" "${home}/.cache/claude-cli-nodejs" "${home}/.local/state/claude-code" - "/realm/state/polylogue/hooks" + "${polylogueDataDir}/hooks" ]; mkRestartPolicy = diff --git a/modules/services/enrichment-loop.nix b/modules/services/enrichment-loop.nix index 6b56796f..97e58b61 100644 --- a/modules/services/enrichment-loop.nix +++ b/modules/services/enrichment-loop.nix @@ -81,12 +81,16 @@ mkServiceModule { # workspace makes that input fail rather than be absent. "/realm/project/steering" ] - ++ lib.sinnix.systemd.agentRuntimeWritePaths { home = homeDir; }; + ++ lib.sinnix.systemd.agentRuntimeWritePaths { + home = homeDir; + polylogueDataDir = config.sinnix.services.polylogue.dataDir; + }; # Clear of the observed run distribution (healthy passes run # 1-3min) but under the hourly interval, so a slow pass still # cannot overlap its successor. TimeoutStartSec = "600s"; }; + environment.POLYLOGUE_ARCHIVE_ROOT = config.sinnix.services.polylogue.dataDir; timer = { onBootSec = "5min"; onUnitActiveSec = "${toString cfg.intervalMinutes}min"; diff --git a/modules/services/polylogue.nix b/modules/services/polylogue.nix index 1b01a5fb..7ca94469 100644 --- a/modules/services/polylogue.nix +++ b/modules/services/polylogue.nix @@ -154,6 +154,14 @@ mkServiceModule { } ]; + # These are Polylogue archive inputs, so their destination must follow + # the same archive-root option as the daemon and hook spool. + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir}/inbox 0755 ${userName} users -" + "L+ ${cfg.dataDir}/inbox/chatgpt - - - - /realm/data/ai/chatlog/raw/chatgpt" + "L+ ${cfg.dataDir}/inbox/claude - - - - /realm/data/ai/chatlog/raw/claude" + ]; + # ── Import the upstream Home Manager module ──────────────────── home-manager.users.${userName} = { imports = [ inputs.polylogue.homeManagerModules.default ]; diff --git a/scripts/sinnix-enrich-dump b/scripts/sinnix-enrich-dump index 96d00017..ecb69833 100644 --- a/scripts/sinnix-enrich-dump +++ b/scripts/sinnix-enrich-dump @@ -10,8 +10,14 @@ set -euo pipefail # Single-writer invariant: writes only the output root and the watermark at # /realm/state/cursors/enrichment/; never polylogue's or sinex's stores. # -# Every input is optional. Absent or empty means nothing changed since the -# watermark, not a fault -- the skill tells the model to treat it that way. +# The service exports POLYLOGUE_ARCHIVE_ROOT from its configured dataDir. +# Standalone callers must export the same variable; guessing a root would make +# an apparently successful pass read the wrong archive. + +if [[ -z "${POLYLOGUE_ARCHIVE_ROOT:-}" ]]; then + echo "sinnix-enrich-dump: POLYLOGUE_ARCHIVE_ROOT is required (set it to the configured Polylogue dataDir)" >&2 + exit 2 +fi WATERMARK_FILE="/realm/state/cursors/enrichment/last-run" OUTPUT_ROOT="/realm/data/derived/reports/enrichment" @@ -103,7 +109,7 @@ fi # timestamp then selects the rows actually in-window (mtime alone over-read # this ~33x). Uncapped -- the model reads by offset, so a wide window costs # turns rather than truncated evidence. -POLYLOGUE_HOOKS_DIR="/realm/state/polylogue/hooks" +POLYLOGUE_HOOKS_DIR="${POLYLOGUE_ARCHIVE_ROOT}/hooks" if [[ -d "$POLYLOGUE_HOOKS_DIR" ]]; then : >"$BUNDLE/polylogue-hooks.jsonl" find "$POLYLOGUE_HOOKS_DIR" -name '*.jsonl' -newermt "$SINCE_ISO" -exec cat {} + 2>/dev/null \