Skip to content

Commit 3323be6

Browse files
fix: unify hook receipts with build identity
agent-session-id: 30eb8b8b-8d33-4f8b-8bdb-95e94c1f90d4 agent-tool: Codex CLI agent-tool-version: 0.145.0 agent-model: unknown agent-runtime-profile: /nix/store/i8y8b542cyqi385ywcjw5fvsq24f75v4-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/i81qxhzlrzcxrrdwpp6i8hagka2gby8y-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@unknown-dirty
1 parent cd6114f commit 3323be6

15 files changed

Lines changed: 274 additions & 103 deletions

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ Prerequisites:
2121
- Rust and Cargo;
2222
- `pty` on `PATH`;
2323
- at least one supported harness on `PATH`: `codex` or `claude`;
24-
- Git when a declaration materializes workspace files.
24+
- Git when a declaration materializes workspace files;
25+
- Bash and `jq` on `PATH` when lifecycle hooks are enabled.
2526

2627
From a checkout:
2728

@@ -46,8 +47,9 @@ Lifecycle hooks are installed only by the explicit `st2 hooks install` command.
4647
publishes an immutable content-addressed set, then atomically selects it with a receipt. `st2 up`
4748
verifies that receipt for Codex launches; any local workspace render that actually references
4849
`$ST_HOOKS` verifies it before writing. Hook-free materialization does not require an installed
49-
set. These checks never create, refresh, or rewrite hooks. An intentional rollback to an older set
50-
requires `st2 hooks install --allow-downgrade`.
50+
set. These checks never create, refresh, or rewrite hooks. To select this binary's exact hook set
51+
when the installed and candidate builds are older or cannot be ordered, use
52+
`st2 hooks install --replace`.
5153

5254
`ST_HOOKS` overrides the machine-local hook root for installation, verification, and managed tasks.
5355
During materialization, hook commands such as `$ST_HOOKS/codex-stop.sh` resolve to the selected

build.rs

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@
44
//! NixStamp can never be overridden by this (see src/version.rs). A hermetic Nix
55
//! build has no `.git`, so this yields nothing there and the NixStamp is used.
66
//!
7-
//! A full revision and commit timestamp are also exposed privately to the
8-
//! receipt-bearing lifecycle-hook installer. They do not affect the shared
9-
//! human/machine version contract.
107
use std::process::Command;
118

129
fn git(args: &[&str]) -> Option<String> {
@@ -29,14 +26,6 @@ fn main() {
2926
let stamp =
3027
format!(r#"{{"type":"local","rev":"{rev}","commitTs":{commit_ts},"dirty":{dirty}}}"#);
3128
println!("cargo:rustc-env=ST2_BUILD_STAMP_LOCAL={stamp}");
32-
println!(
33-
"cargo:rustc-env=ST2_GIT_SHA_FULL={}",
34-
git(&["rev-parse", "HEAD"]).unwrap_or(rev)
35-
);
36-
println!("cargo:rustc-env=ST2_GIT_COMMIT_UNIX={commit_ts}");
37-
} else {
38-
println!("cargo:rustc-env=ST2_GIT_SHA_FULL=unknown");
39-
println!("cargo:rustc-env=ST2_GIT_COMMIT_UNIX=0");
4029
}
4130
// Rebuild the stamp when HEAD moves or the working tree changes (dirty flag).
4231
println!("cargo:rerun-if-changed=.git/HEAD");

docs/vrs/requirements.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,12 @@ accepted.
5555
complete effective launch definition, including environment and supported
5656
launch fields.
5757
- **R07 Verified hooks:** Required hook content is installed explicitly and
58-
verified before a rendered agent depends on it.
58+
verified before a rendered agent depends on it. The selected receipt carries
59+
the binary's real source identity regardless of build system. Ordered
60+
upgrades are automatic; selecting an older, unorderable, or unreadable exact
61+
hook set requires explicit replacement authority. Hook interpreters and
62+
runtime dependencies are portable and explicit on every supported package
63+
environment.
5964
- **R11 Control-plane replacement safety:** Stopping or killing `st2 up` must
6065
not stop, restart, or replace any agent it launched. st2 can be reinstalled
6166
and restarted while running agents continue unchanged; the replacement

docs/vrs/spec.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,13 @@ validate ──► materialize ──► host-local st2 scheduler/reconciler
4646
- **R06:** st2 passes the complete effective task definition to the underlying
4747
launcher so manual and supervised restarts are equivalent.
4848
- **R07:** Hook bundles are explicit, content-addressed, installed separately,
49-
and verified before materialization references them.
49+
and verified before materialization references them. Their receipts use the
50+
same resolved build identity as the binary's version surfaces for both
51+
hermetic package builds and source builds. Installation automatically accepts
52+
an ordered upgrade; `--replace` is the explicit exact-state authority for a
53+
downgrade, an unorderable build, or an unreadable receipt. Shipped hooks
54+
resolve Bash through `PATH`; the Nix package executes their integration gate
55+
with Bash and `jq` declared.
5056
- **R11:** `st2 up` is a replaceable control plane, not the lifetime owner of
5157
its agents. Normal exit, forced termination, binary replacement, and restart
5258
leave every running agent PID and creation identity unchanged. The new

flake.nix

Lines changed: 71 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,15 @@
2828
# reader) — `src/version.rs` reads it via `option_env!("CLI_BUILD_STAMP")`.
2929
# `self.shortRev`/`lastModified` are absent only for a dirty tree, where
3030
# `dirtyShortRev` and the working-tree mtime stand in and `dirty` is true.
31+
sourceRev = self.shortRev or self.dirtyShortRev or "unknown";
32+
sourceCommitUnix = self.lastModified or 0;
33+
sourceDirty = !(self ? rev);
3134
buildStamp = builtins.toJSON {
3235
type = "nix";
3336
inherit version;
34-
rev = self.shortRev or self.dirtyShortRev or "unknown";
35-
commitTs = self.lastModified or 0;
36-
dirty = !(self ? rev);
37+
rev = sourceRev;
38+
commitTs = sourceCommitUnix;
39+
dirty = sourceDirty;
3740
};
3841

3942
completionShells = [
@@ -59,11 +62,15 @@
5962
# src/version.rs). A derivation env var change rebuilds the crate.
6063
CLI_BUILD_STAMP = buildStamp;
6164

62-
# `git` is present for the tests below (they init throwaway repos);
63-
# `installShellFiles` provides `installShellCompletion`.
65+
# The hook integration test executes the shipped Bash scripts with
66+
# their real jq dependency. `git` is present for tests that initialize
67+
# throwaway repositories; `installShellFiles` provides
68+
# `installShellCompletion`.
6469
nativeBuildInputs = [
70+
pkgs.bash
6571
pkgs.git
6672
pkgs.installShellFiles
73+
pkgs.jq
6774
];
6875

6976
# Completions are generated by the binary we just built (never
@@ -80,19 +87,16 @@
8087
--fish completions-fish
8188
'';
8289

83-
# Run only the hermetic **unit** tests (`--lib --bins`). The integration
84-
# tests in `tests/*.rs` each assume a real environment the Nix build
85-
# sandbox deliberately lacks — `/bin/bash` + `jq` (the shipped Codex
86-
# hooks), `/usr/bin/git` on a hardcoded `PATH` (materialize's
87-
# git-worktree safety check), and live PTY backends or a systemd
88-
# `--user` manager (the survival + render-neutrality gates). Chasing
89-
# those with per-test skips is unbounded as the suite grows, so they run
90-
# on native CI (real runner) while the flake proves the package here:
91-
# it builds, its ~150 pure unit tests pass, and `--help`/completions
92-
# smoke-test the wired binary below.
90+
# Run the hermetic unit tests plus the real lifecycle-hook integration
91+
# test. The remaining integration tests assume facilities the Nix build
92+
# sandbox deliberately lacks: `/usr/bin/git` on a hardcoded `PATH`,
93+
# live PTY backends, or a systemd `--user` manager. They remain native
94+
# gates, while the flake proves that its own packaged hooks execute.
9395
cargoTestFlags = [
9496
"--lib"
9597
"--bins"
98+
"--test"
99+
"codex_hooks"
96100
];
97101

98102
# A few unit tests write under $HOME; the sandbox HOME is not writable.
@@ -105,6 +109,24 @@
105109
mainProgram = "st2";
106110
};
107111
};
112+
113+
hookSuccessorSource = pkgs.runCommand "st2-hook-successor-source" { } ''
114+
cp -R ${self} $out
115+
chmod -R u+w $out
116+
printf '\n# Nix hook replacement acceptance probe.\n' >> $out/hooks/codex-stop.sh
117+
'';
118+
119+
st2HookSuccessor = st2.overrideAttrs (_: {
120+
pname = "st2-hook-successor";
121+
src = hookSuccessorSource;
122+
CLI_BUILD_STAMP = builtins.toJSON {
123+
type = "nix";
124+
inherit version;
125+
rev = "hook-successor";
126+
commitTs = sourceCommitUnix;
127+
dirty = false;
128+
};
129+
});
108130
in
109131
{
110132
packages.st2 = st2;
@@ -147,6 +169,40 @@
147169
touch $out
148170
'';
149171

172+
# End-to-end receipt proof across two real Nix-built binaries. The
173+
# synthetic successor changes embedded hook bytes while deliberately
174+
# retaining the same source timestamp: replacement must be explicit,
175+
# and must not be mislabeled as a downgrade.
176+
checks.hooks-replacement = pkgs.runCommand "st2-hooks-replacement-${version}" {
177+
nativeBuildInputs = [ pkgs.jq ];
178+
} ''
179+
export HOME=$(mktemp -d)
180+
export ST_HOOKS=$HOME/hooks
181+
182+
${st2}/bin/st2 hooks install
183+
jq -e \
184+
--arg rev ${pkgs.lib.escapeShellArg sourceRev} \
185+
--argjson commit ${toString sourceCommitUnix} \
186+
--argjson dirty ${builtins.toJSON sourceDirty} \
187+
'.st2GitSha == $rev and .sourceCommitUnix == $commit and .sourceDirty == $dirty' \
188+
"$ST_HOOKS/current.json" >/dev/null
189+
190+
if ${st2HookSuccessor}/bin/st2 hooks install 2>replacement.err; then
191+
echo "same-order hook replacement unexpectedly succeeded" >&2
192+
exit 1
193+
fi
194+
grep -F -- '--replace' replacement.err >/dev/null
195+
196+
${st2HookSuccessor}/bin/st2 hooks install --replace
197+
${st2HookSuccessor}/bin/st2 hooks verify
198+
jq -e \
199+
--argjson commit ${toString sourceCommitUnix} \
200+
'.st2GitSha == "hook-successor" and .sourceCommitUnix == $commit and .sourceDirty == false' \
201+
"$ST_HOOKS/current.json" >/dev/null
202+
203+
touch $out
204+
'';
205+
150206
devShells.default = pkgs.mkShell {
151207
packages = [
152208
pkgs.cargo

hooks/claude-pre-compact.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
#!/bin/bash
1+
#!/usr/bin/env bash
22
# The persistence behavior is harness-agnostic. Keep this entrypoint trivial and fail-open so a
33
# parse/runtime error in the implementation can never block Claude compaction.
44

55
impl="$(dirname "$0")/codex-pre-compact.sh"
66
if [[ -r "$impl" ]]; then
7-
/bin/bash "$impl" "$@" || true
7+
bash "$impl" "$@" || true
88
fi
99
exit 0

hooks/claude-session-start.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#!/bin/bash
1+
#!/usr/bin/env bash
22
# st2 Claude SessionStart hook. Claude treats exit 2 + stderr as a model-visible reminder; the hook
33
# restores fresh durable context before that reminder. Missing state remains a valid cold start.
44

hooks/claude-stop-failure.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#!/bin/bash
1+
#!/usr/bin/env bash
22
# st2 Claude StopFailure hook: surface infrastructure wedges through presence and, when declared,
33
# the agent's supervisor inbox. Fail-open; lifecycle reporting must never wedge the harness.
44

hooks/codex-pre-compact.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#!/bin/bash
1+
#!/usr/bin/env bash
22
# st2 Codex PreCompact hook. Never block compaction. If the agent has not captured any durable
33
# working state, write a reconstruction stub; never overwrite non-whitespace state.
44

hooks/codex-session-start.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#!/bin/bash
1+
#!/usr/bin/env bash
22
# st2 Codex SessionStart hook: restore fresh working state, expose the unread inbox, and remind the
33
# model to complete its boot ritual. Fail-open: a missing dependency never prevents Codex startup.
44

0 commit comments

Comments
 (0)