@junghanacs/entwurf 0.20.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/AGENTS.md +85 -190
  2. package/BASELINE.md +6 -3
  3. package/CHANGELOG.md +473 -14
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +332 -60
  6. package/README.md +100 -22
  7. package/VERIFY.md +93 -7
  8. package/docs/acp-backend-rail.md +0 -1
  9. package/docs/external-mcp-host.md +64 -33
  10. package/docs/setup-clean-host.md +151 -17
  11. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +130 -114
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +519 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +22 -2
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +3 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -7
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +30 -17
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +7 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -2
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +8 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +7 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +20 -11
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +227 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +189 -19
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +10 -7
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +403 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +4 -4
  28. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-codex.js +325 -0
  29. package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
  30. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +6 -1
  31. package/mcp/entwurf-bridge/src/index.ts +154 -116
  32. package/mcp/entwurf-bridge/tsconfig.build.json +10 -0
  33. package/package.json +13 -4
  34. package/pi-extensions/entwurf-control.ts +71 -19
  35. package/pi-extensions/lib/codex-caller-seat.ts +204 -0
  36. package/pi-extensions/lib/codex-fresh-preflight.ts +580 -0
  37. package/pi-extensions/lib/compaction-send-guard.ts +80 -0
  38. package/pi-extensions/lib/entwurf-fact-provider.ts +29 -3
  39. package/pi-extensions/lib/entwurf-peers-render.ts +4 -1
  40. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -7
  41. package/pi-extensions/lib/entwurf-v2-native-push.ts +35 -18
  42. package/pi-extensions/lib/entwurf-v2-production.ts +10 -3
  43. package/pi-extensions/lib/entwurf-v2-runner.ts +3 -2
  44. package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
  45. package/pi-extensions/lib/entwurf-v2-surface.ts +7 -4
  46. package/pi-extensions/lib/meta-mailbox-body.ts +22 -13
  47. package/pi-extensions/lib/meta-sender-identity.ts +305 -0
  48. package/pi-extensions/lib/mux-fresh-call.ts +233 -29
  49. package/pi-extensions/lib/native-push/adapter.ts +21 -24
  50. package/pi-extensions/lib/native-push/codex-ws-client.ts +506 -0
  51. package/pi-extensions/lib/native-push/register.ts +7 -9
  52. package/pi-extensions/meta-bridge-hook-codex.ts +371 -0
  53. package/run.sh +342 -28
  54. package/scripts/check-agy-sender-identity.ts +1 -1
  55. package/scripts/check-codex-app-server-launch.ts +445 -0
  56. package/scripts/check-codex-birth-hook.ts +264 -0
  57. package/scripts/check-codex-bridge-identity.ts +179 -0
  58. package/scripts/check-codex-native-push.ts +386 -0
  59. package/scripts/check-codex-sender-identity.ts +495 -0
  60. package/scripts/check-compaction-send-guard.ts +130 -0
  61. package/scripts/check-copilot-receive-arm.ts +4 -1
  62. package/scripts/check-entwurf-fact-provider.ts +38 -0
  63. package/scripts/check-entwurf-peers-surface.ts +13 -1
  64. package/scripts/check-entwurf-self-address.ts +15 -16
  65. package/scripts/check-entwurf-v2-contract.ts +4 -3
  66. package/scripts/check-entwurf-v2-decider.ts +7 -5
  67. package/scripts/check-entwurf-v2-native-push.ts +35 -7
  68. package/scripts/check-entwurf-v2-production.ts +245 -12
  69. package/scripts/check-entwurf-v2-runner.ts +1 -1
  70. package/scripts/check-entwurf-v2-send.ts +26 -7
  71. package/scripts/check-entwurf-v2-surface.ts +1 -1
  72. package/scripts/check-gate-qualification.ts +8 -3
  73. package/scripts/check-harness-admission-parity.ts +0 -1
  74. package/scripts/check-mux-launch-tmux.ts +345 -4
  75. package/scripts/check-native-push-adapter.ts +20 -16
  76. package/scripts/check-native-push-register.ts +5 -1
  77. package/scripts/check-release-gate-outcomes.ts +47 -1
  78. package/scripts/check-setup-qualification.sh +3 -1
  79. package/scripts/codex-app-server-launch.sh +275 -0
  80. package/scripts/codex-birth-doctor.sh +276 -0
  81. package/scripts/codex-birth-install.sh +414 -0
  82. package/scripts/codex-birth-uninstall.sh +170 -0
  83. package/scripts/codex-mcp-config.py +435 -0
  84. package/scripts/codex-socket-path.ts +33 -0
  85. package/scripts/codex-statusline-config.py +434 -0
  86. package/scripts/codex-terminal-title-config.py +500 -0
  87. package/scripts/codex_toml_io.py +653 -0
  88. package/scripts/lib/codex-fresh-live-protocol.ts +121 -0
  89. package/scripts/lib/codex-fresh-source-receipts.ts +426 -0
  90. package/scripts/lib/launch-receipt-windows.ts +46 -0
  91. package/scripts/lib/tmux-coordinate-row.ts +58 -0
  92. package/scripts/meta-bridge-fresh-cut.ts +6 -1
  93. package/scripts/mutants/codex-app-server-launch.json +157 -0
  94. package/scripts/mutants/codex-caller-seat.json +336 -0
  95. package/scripts/mutants/codex-native.json +838 -0
  96. package/scripts/mutants/compaction-send-guard.json +103 -0
  97. package/scripts/mutants/entwurf-peers.json +19 -0
  98. package/scripts/mutants/mux-fresh-call.json +93 -10
  99. package/scripts/mutants/omp-fresh.json +6 -4
  100. package/scripts/mutants/release-gate.json +13 -0
  101. package/scripts/mutants/v2-surface.json +75 -1
  102. package/scripts/raw-async-delivery/README.md +2 -1
  103. package/scripts/raw-codex-measure/README.md +114 -46
  104. package/scripts/smoke-agy-native-push-live.ts +3 -1
  105. package/scripts/smoke-codex-birth.sh +347 -0
  106. package/scripts/smoke-codex-config-state.sh +700 -0
  107. package/scripts/smoke-codex-fresh-live.ts +1426 -0
  108. package/scripts/smoke-codex-native-push-live.ts +75 -0
  109. package/scripts/smoke-entwurf-chain-live.ts +50 -0
  110. package/scripts/smoke-setup-verdict.sh +125 -10
  111. package/scripts/tsconfig.json +1 -0
@@ -297,6 +297,7 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
297
297
  "smoke-acp-long-turn-live": ["VERIFY.md", "on-demand, not part of `release-gate`"],
298
298
  "smoke-mux-fresh-call-live": ["VERIFY.md", "Fresh-call LIVE is on-demand, not part of `release-gate`"],
299
299
  "smoke-agy-native-push-live": ["VERIFY.md", "Aggregate release-gate does not own an agy conversation id"],
300
+ "smoke-codex-native-push-live": ["VERIFY.md", "Aggregate release-gate does not own a loaded Codex thread"],
300
301
  "smoke-acp-ordering-probe-live": ["docs/acp-backend-rail.md", "opt-in paired observation"],
301
302
  // Cortex needs an external Snowflake connection the HOST owns, so an aggregate
302
303
  // that required it would block every cut taken without that account. Excluded —
@@ -806,6 +807,48 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
806
807
  );
807
808
  }
808
809
 
810
+ // ===========================================================================
811
+ // Codex first admission is a MUST; the loaded-thread probe remains on-demand
812
+ //
813
+ // The old native-push smoke consumes a thread the operator loaded in an attached
814
+ // TUI, so the aggregate cannot own its prerequisite. The first-admission smoke
815
+ // instead owns all three visible windows in the Pi -> Codex -> Pi chain and takes
816
+ // only an explicit app-server PID as seat authority. The new gate must not silently
817
+ // pull the old loaded-thread probe into release-gate, and keeping the old exclusion
818
+ // must not excuse the new end-to-end acceptance.
819
+ // ===========================================================================
820
+ {
821
+ const runSh = readFileSync(join(REPO_DIR, "run.sh"), "utf8");
822
+ const gateBody = runSh.slice(runSh.indexOf("release_gate() {"), runSh.indexOf("# 5. Summary"));
823
+ const verify = readFileSync(join(REPO_DIR, "VERIFY.md"), "utf8");
824
+ const admissionSteps = gateBody.split("\n").filter((line) => line.includes('"$self" smoke-codex-fresh-live'));
825
+ const loadedThreadSteps = gateBody
826
+ .split("\n")
827
+ .filter((line) => line.includes('"$self" smoke-codex-native-push-live'));
828
+ const gaps: string[] = [];
829
+ if (admissionSteps.length !== 1) {
830
+ gaps.push(`release_gate runs smoke-codex-fresh-live ${admissionSteps.length}x (need exactly one MUST step)`);
831
+ }
832
+ if (admissionSteps.length === 1 && !admissionSteps[0]?.includes("run_live_step")) {
833
+ gaps.push("smoke-codex-fresh-live does not go through run_live_step, so LIVE=1/SKIP cannot reach --cut");
834
+ }
835
+ if (loadedThreadSteps.length !== 0) {
836
+ gaps.push(`release_gate runs the operator-thread smoke ${loadedThreadSteps.length}x (need zero)`);
837
+ }
838
+ if (!verify.includes("Aggregate release-gate does not own a loaded Codex thread")) {
839
+ gaps.push("VERIFY.md no longer states why smoke-codex-native-push-live remains on-demand");
840
+ }
841
+ if (!runSh.includes("ENTWURF_CODEX_APP_SERVER_PID")) {
842
+ gaps.push("run.sh no longer names the explicit operator-owned app-server PID prerequisite");
843
+ }
844
+ assert.ok(
845
+ gaps.length === 0,
846
+ "[QK:CODEX-FIRST-ADMISSION-IS-RELEASE-MUST] the fresh Codex acceptance must run exactly once through " +
847
+ "run_live_step, while the loaded-thread native-push smoke stays excluded for its documented operator-owned " +
848
+ `prerequisite. Broken: ${gaps.join("; ")}`,
849
+ );
850
+ }
851
+
809
852
  // ===========================================================================
810
853
  // The operator's CONFIGURED bridge invocation is proven BEFORE the cost-bearing LIVE tier
811
854
  //
@@ -873,5 +916,8 @@ console.log(
873
916
  "five historical reds; and " +
874
917
  "every gate a committed mutant names is itself inside check:full or states its exclusion in prose an operator " +
875
918
  "reads; and the CI push trigger is filtered to branch refs, so a release tag creates no duplicate run; and " +
876
- "the operator's CONFIGURED bridge invocation is booted exactly once through run_step, before the ACP LIVE tier",
919
+ "Pi -> visible Codex -> visible Pi first-admission callback/addressed/outbound acceptance is a release MUST " +
920
+ "while the operator-loaded-thread Codex native-push probe remains excluded; and the operator's CONFIGURED " +
921
+ "bridge invocation is booted exactly once " +
922
+ "through run_step, before the ACP LIVE tier",
877
923
  );
@@ -73,7 +73,7 @@ run_setup() { # $1=HOME-root $2=project $3=PATH $4=PI_BIN $5=COPILOT_BIN(opt) $6
73
73
  set +e
74
74
  # ONE physical line by contract: check-install-surface S5c is a line-scoped static tripwire,
75
75
  # so the sandbox env assignments must ride the same line as the run.sh drive they guard.
76
- OUT="$(HOME="$1" XDG_DATA_HOME="$1/.local/share" XDG_STATE_HOME="$1/.local/state" XDG_CACHE_HOME="$1/.cache" XDG_CONFIG_HOME="$1/.config" PI_CODING_AGENT_DIR="$1/.pi/agent" PATH="$3" PI_BIN="$4" CLAUDE_BIN="$ABSENT" AGY_BIN="$ABSENT" COPILOT_BIN="${5:-$ABSENT}" OMP_BIN="${6:-$ABSENT}" ENTWURF_OMP_AGENT_DIR="$1/.omp/agent" bash "$PKG/run.sh" setup "$2" 2>&1)"
76
+ OUT="$(HOME="$1" XDG_DATA_HOME="$1/.local/share" XDG_STATE_HOME="$1/.local/state" XDG_CACHE_HOME="$1/.cache" XDG_CONFIG_HOME="$1/.config" PI_CODING_AGENT_DIR="$1/.pi/agent" PATH="$3" PI_BIN="$4" CLAUDE_BIN="$ABSENT" AGY_BIN="$ABSENT" COPILOT_BIN="${5:-$ABSENT}" OMP_BIN="${6:-$ABSENT}" CODEX_BIN="$ABSENT" ENTWURF_OMP_AGENT_DIR="$1/.omp/agent" bash "$PKG/run.sh" setup "$2" 2>&1)"
77
77
  RC=$?
78
78
  set -e
79
79
  }
@@ -93,6 +93,8 @@ want "A: an absent copilot is one zero-state SKIP — no unit composed, no .copi
93
93
  "printf '%s' \"\$OUT\" | grep -q 'copilot: SKIP' && [ ! -e '$HOME_A/.copilot' ]"
94
94
  want "A: an absent omp is one zero-state SKIP — no unit composed, no .omp written [QK:SETUP-OMP-ABSENT-SKIP]" \
95
95
  "printf '%s' \"\$OUT\" | grep -q 'omp: SKIP' && [ ! -e '$HOME_A/.omp' ]"
96
+ want "A: an absent Codex is one zero-state SKIP — no unit composed, no .codex written [QK:CODEX-SETUP-ZERO-STATE]" \
97
+ "printf '%s' \"\$OUT\" | grep -q 'codex: SKIP' && [ ! -e '$HOME_A/.codex' ]"
96
98
  want "A control: mode named first, pi/claude/agy SKIP, bins PASS, core FAIL, NON-GREEN summary" \
97
99
  "printf '%s' \"\$OUT\" | head -n 1 | grep -q 'mode: installed package' && printf '%s' \"\$OUT\" | grep -q 'pi: SKIP' && printf '%s' \"\$OUT\" | grep -q 'claude: SKIP' && printf '%s' \"\$OUT\" | grep -q 'agy: SKIP' && printf '%s' \"\$OUT\" | grep -q 'bins: PASS' && printf '%s' \"\$OUT\" | grep -q 'core: FAIL' && printf '%s' \"\$OUT\" | grep -q 'NON-GREEN'"
98
100
 
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env bash
2
+ # codex-app-server-launch.sh — the managed Codex app-server launch,
3
+ # `entwurf codex-app-server` (#95, the operator-UX half).
4
+ #
5
+ # WHY THIS EXISTS. Every Codex rail in this repo — native-push delivery, visible fresh,
6
+ # the loaded-thread probe — needs one thing the operator has to start themselves:
7
+ #
8
+ # codex app-server --listen "unix://$CODEX_HOME/app-server-control/app-server-control.sock"
9
+ #
10
+ # That string is correct and nobody types it. It appeared in three documents and in the
11
+ # text of a refusal, which means the first time most operators meet it is AFTER something
12
+ # has already failed. This leaf owns the SPELLING of that command and nothing else.
13
+ #
14
+ # WHAT IT IS NOT — and this is the whole boundary. It is not a supervisor, a daemon, a
15
+ # restarter, a health loop or a pid file. It does not fork. It `exec`s in the caller's own
16
+ # terminal, so the server's cwd, tty, pid and exit status are the vendor's, and Ctrl-C is
17
+ # the operator's. Entwurf still does not own the app-server's lifecycle (AGENTS Hard Rule
18
+ # 16, NEXT CARRIED step 2): a missing app-server still REJECTS at fresh-call preflight
19
+ # rather than being started behind anyone's back. What changed is only that the refusal can
20
+ # now name a command a person can actually type.
21
+ #
22
+ # THE SOCKET PATH IS SPELLED ONCE, SOMEWHERE ELSE. `resolveCodexDefaultSocketPath`
23
+ # (pi-extensions/lib/native-push/codex-ws-client.ts) is where the product computes this path,
24
+ # and it is the leaf every OTHER Codex surface reads. A bash leaf cannot import it, so this one
25
+ # ASKS — `run.sh codex-socket-path`, through `run_ts`. It was written the other way first, with
26
+ # a bash transcription and a gate comparing the two over an environment matrix; they agreed on
27
+ # every ASCII input and diverged on a BOM-only CODEX_HOME and on path normalization. See
28
+ # scripts/codex-socket-path.ts for that measurement. A transcription can only be tested on the
29
+ # inputs somebody thought of, which is why there is no longer one to test.
30
+ #
31
+ # THE TMUX LINE IS A FACT, NOT A REFUSAL. The MCP bridge is a CHILD of this server and
32
+ # inherits its `TMUX`, so the tmux server this process sits in is the one caller-seat
33
+ # lookups will read (docs/mux-launch-rail.md §Codex). Running outside tmux is a legitimate
34
+ # operator choice with a consequence, so it is reported as a consequence. Refusing it here
35
+ # would make this leaf an authority over the operator's terminal, which it is not.
36
+ set -euo pipefail
37
+
38
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
39
+ REPO_DIR="$(cd "$HERE/.." && pwd)"
40
+
41
+ # The vendor command this leaf manages. A CONSTANT, deliberately not overridable: an env
42
+ # seam here would be a production switch for "which binary is the Codex CLI", and anything
43
+ # that can redirect an exec is an authority, not a test convenience. The gate proves the
44
+ # real contract instead — it puts its fake vendor on a sandbox PATH under this exact name.
45
+ VENDOR_CMD="codex"
46
+
47
+ fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
48
+ note() { printf '[entwurf] %s\n' "$*" >&2; }
49
+
50
+ # --- recursion fence -------------------------------------------------------
51
+ # A `codex` earlier on PATH that itself calls `entwurf codex-app-server` would spin
52
+ # forever, and the symptom would be a hung terminal rather than an error. The sentinel is
53
+ # invocation-local, so seeing it already set means we are inside our own exec chain.
54
+ if [ -n "${ENTWURF_CODEX_APP_SERVER_ACTIVE:-}" ]; then
55
+ fail "recursive managed launch detected (ENTWURF_CODEX_APP_SERVER_ACTIVE is already set).
56
+ Something on PATH named '$VENDOR_CMD' resolves back to this launcher. Fix PATH so
57
+ '$VENDOR_CMD' is the OpenAI Codex CLI, or run the vendor binary by its full path."
58
+ fi
59
+
60
+ # --- the socket path: ASKED, never re-derived -------------------------------
61
+ # This leaf does no path arithmetic at all, and that is the correction rather than the style.
62
+ # Its first version mirrored `resolveCodexHome` in bash — `${CODEX_HOME:-$HOME/.codex}` plus a
63
+ # POSIX `[:space:]` trim — with a gate comparing the two spellings over four ASCII-normal
64
+ # inputs they happened to agree on. `[측정 2026-09-16, independent review]` they disagree
65
+ # elsewhere: with `CODEX_HOME=$'\ufeff'` the TS leaf trims (JS trim strips U+FEFF) and falls
66
+ # back to `$HOME/.codex`, while the bash trim kept the byte and produced
67
+ # `<BOM>/app-server-control/app-server-control.sock`; `path.join` likewise normalizes a
68
+ # trailing slash or a `..` segment where the bash concatenation did not. Each of those starts a
69
+ # server at an address delivery and preflight never look at.
70
+ #
71
+ # So the second spelling is gone instead of widened — a transcription can only ever be tested
72
+ # on the inputs somebody thought of. `run.sh codex-socket-path` prints what
73
+ # `resolveCodexDefaultSocketPath` computes for THIS environment, through `run_ts` (compiled twin
74
+ # when installed, strip-types in a clone). An empty or failed answer is a hard refusal: there is
75
+ # no fallback spelling left to guess with, and that is the point.
76
+ if ! SOCK="$(bash "$REPO_DIR/run.sh" codex-socket-path)"; then
77
+ fail "could not resolve the Codex control-socket path.
78
+ '$REPO_DIR/run.sh codex-socket-path' failed; that leaf is the only spelling of this address,
79
+ and this launcher will not invent a second one. Its output above says why."
80
+ fi
81
+ [ -n "$SOCK" ] \
82
+ || fail "the Codex control-socket resolver returned an empty path — neither CODEX_HOME nor
83
+ HOME names a usable Codex home. Set CODEX_HOME."
84
+
85
+ # Asking one authority for the address does not make the ANSWER safe to act on, and this is the
86
+ # one place that acts on it: `mkdir -p` creates a directory at it and the vendor binds it. The
87
+ # resolver faithfully returns whatever `CODEX_HOME` holds — it neither requires an absolute path
88
+ # nor rejects control characters, because the surfaces that merely READ it (preflight's lstat,
89
+ # the delivery probe) fail closed on their own. A RELATIVE address is a different file for every
90
+ # process that resolves it, and the bridge that will look for this socket is the app-server's MCP
91
+ # child with its own cwd — so the one thing a launch cannot do is bind a path whose meaning
92
+ # depends on where it was typed. Control characters are refused with it: a newline in an address
93
+ # is not a path this repo will create on an operator's behalf. Both refuse BEFORE any write.
94
+ case "$SOCK" in
95
+ /*) ;;
96
+ *) fail "codex-app-server-socket-path-not-absolute: the resolver returned
97
+ '$SOCK'
98
+ A relative control-socket address resolves to a different file for every process that reads it,
99
+ and the bridge that looks for this socket runs with its own working directory. Set CODEX_HOME to
100
+ an absolute path." ;;
101
+ esac
102
+ case "$SOCK" in
103
+ *[[:cntrl:]]*) fail "codex-app-server-socket-path-untrusted: the resolved control-socket address
104
+ carries a control character (CODEX_HOME almost certainly does). Refusing to create or bind it." ;;
105
+ esac
106
+
107
+ # --- operator argv: forwarded, never reinterpreted -------------------------
108
+ # Everything the operator passes is appended after the injected pair and crosses
109
+ # byte-identical. The single exception is a second `--listen`: injecting ours beside theirs
110
+ # would hand the vendor two listen addresses and let one of them win silently, and a silent
111
+ # winner is exactly the false success this repo refuses. `--opt=value` and `--opt value` are
112
+ # the same option, so the head is what is compared.
113
+ for tok in "$@"; do
114
+ if [ "${tok%%=*}" = "--listen" ]; then
115
+ fail "codex-app-server-listen-override: this verb exists to spell ONE --listen address
116
+ (unix://$SOCK), and you passed your own. Run the vendor directly if you mean a
117
+ different address: $VENDOR_CMD app-server --listen <your-address>"
118
+ fi
119
+ done
120
+
121
+ # --- resolve the vendor executable -----------------------------------------
122
+ # `type -P` returns the PATH hit only — never a shell function, alias or builtin — so what
123
+ # is exec'd is a real external file. `exec command codex` is deliberately NOT used: it would
124
+ # re-enter shell lookup at exec time and could pick up something other than the file
125
+ # validated here.
126
+ codex_bin="$(type -P "$VENDOR_CMD" 2>/dev/null || true)"
127
+ [ -n "$codex_bin" ] \
128
+ || fail "no '$VENDOR_CMD' executable found on PATH.
129
+ The managed launch runs the OpenAI Codex CLI; install it, or put it on PATH."
130
+ [ -f "$codex_bin" ] && [ -x "$codex_bin" ] \
131
+ || fail "'$codex_bin' is not an executable regular file — refusing to exec it."
132
+
133
+ # Self-exec fence, the second half of the recursion guard: resolve symlinks and refuse
134
+ # anything that is one of our own entrypoints even if the sentinel was stripped. POSIX walk,
135
+ # same shape as scripts/copilot-launch.sh — BSD readlink has no -f.
136
+ resolve_path() {
137
+ local SOURCE="$1" DIR TARGET
138
+ while [ -L "$SOURCE" ]; do
139
+ DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
140
+ TARGET="$(readlink "$SOURCE")"
141
+ case "$TARGET" in
142
+ /*) SOURCE="$TARGET" ;;
143
+ *) SOURCE="$DIR/$TARGET" ;;
144
+ esac
145
+ done
146
+ DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
147
+ printf '%s/%s\n' "$DIR" "$(basename "$SOURCE")"
148
+ }
149
+ resolved_bin="$(resolve_path "$codex_bin")"
150
+ for own in "$HERE/codex-app-server-launch.sh" "$REPO_DIR/run.sh"; do
151
+ own_resolved="$(resolve_path "$own")"
152
+ [ "$resolved_bin" = "$own_resolved" ] \
153
+ && fail "'$VENDOR_CMD' on PATH resolves to entwurf's own '$own_resolved' — that is a launch loop, not the vendor CLI."
154
+ done
155
+
156
+ # --- is somebody already listening there? ----------------------------------
157
+ # Three outcomes, and they are NOT the same repair. A live socket means a second server
158
+ # would either lose the bind race or silently replace the endpoint every citizen's record
159
+ # already points at — refuse and name the holder. An indeterminate path (symlink, not a
160
+ # socket, owned by another uid) is the classification `checkCodexSocketFile` already uses in
161
+ # the product, and a launcher that guessed past it would be clobbering something it cannot
162
+ # identify. A dead socket file is the ordinary leftover of a Ctrl-C, and the vendor replaces
163
+ # it — that one is a fact line, not a refusal.
164
+ probe="$(python3 - "$SOCK" <<'PY'
165
+ import os, socket, stat, sys
166
+
167
+ path = sys.argv[1]
168
+ try:
169
+ st = os.lstat(path)
170
+ except FileNotFoundError:
171
+ print("absent")
172
+ raise SystemExit(0)
173
+ except OSError as exc:
174
+ print(f"indeterminate\t{exc.strerror or exc}")
175
+ raise SystemExit(0)
176
+
177
+ if stat.S_ISLNK(st.st_mode):
178
+ print("indeterminate\tthe path is a symlink")
179
+ raise SystemExit(0)
180
+ if not stat.S_ISSOCK(st.st_mode):
181
+ print("indeterminate\tthe path exists and is not a socket")
182
+ raise SystemExit(0)
183
+ if st.st_uid != os.getuid():
184
+ print(f"indeterminate\tthe socket is owned by uid {st.st_uid}, not {os.getuid()}")
185
+ raise SystemExit(0)
186
+
187
+ probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
188
+ probe.settimeout(2)
189
+ try:
190
+ probe.connect(path)
191
+ except (ConnectionRefusedError, FileNotFoundError):
192
+ print("stale")
193
+ except OSError as exc:
194
+ print(f"indeterminate\t{exc.strerror or exc}")
195
+ else:
196
+ print("live")
197
+ finally:
198
+ probe.close()
199
+ PY
200
+ )" || fail "could not classify the control socket at $SOCK (python3 is required by this launcher)."
201
+
202
+ probe_status="${probe%% *}"
203
+ probe_reason="${probe#* }"
204
+ case "$probe_status" in
205
+ live)
206
+ # Show what the host actually says, and say so when it says nothing. The owner is read
207
+ # out of /proc rather than inferred from the socket: a socket file names no pid, so the
208
+ # only honest answer when no cmdline matches is that no match was found.
209
+ owner=""
210
+ if [ -d /proc ]; then
211
+ for cl in /proc/[0-9]*/cmdline; do
212
+ [ -r "$cl" ] || continue
213
+ if tr '\0' ' ' < "$cl" 2>/dev/null | grep -qF "$SOCK"; then
214
+ owner="${owner} pid $(basename "$(dirname "$cl")"): $(tr '\0' ' ' < "$cl")
215
+ "
216
+ fi
217
+ done
218
+ fi
219
+ [ -n "$owner" ] || owner=" (no process on this host spells that socket in its cmdline — read, not inferred)
220
+ "
221
+ fail "codex-app-server-already-listening: a live app-server is answering on
222
+ unix://$SOCK
223
+ Entwurf never replaces one. What /proc reports about it:
224
+ $owner Use that server, or stop it first."
225
+ ;;
226
+ indeterminate)
227
+ fail "codex-app-server-socket-indeterminate: $probe_reason
228
+ Path: $SOCK
229
+ This is the same classification the delivery rail's socket check uses, and a launcher that
230
+ guessed past it would be clobbering something it cannot identify. Inspect that path."
231
+ ;;
232
+ stale)
233
+ note "a dead control socket is already at $SOCK (nothing is listening); the vendor replaces it."
234
+ ;;
235
+ absent)
236
+ : # nothing is there; the ordinary first launch
237
+ ;;
238
+ *)
239
+ # Hard Rule 15. Every branch above is a decision about whether this launch would
240
+ # CLOBBER a running server, so an unrecognised classification is the one case where
241
+ # proceeding is unsafe — and a probe that exits 0 while printing something nobody wrote
242
+ # would otherwise fall straight through to the exec.
243
+ fail "codex-app-server-socket-probe-unrecognised: the socket classifier answered
244
+ '$probe_status', which is not one of live/stale/indeterminate/absent. Refusing to launch on a
245
+ reading nothing understands."
246
+ ;;
247
+ esac
248
+
249
+ # --- the control directory the address lives in ----------------------------
250
+ # The same `mkdir -p` the three documents told the operator to run by hand. It is inside
251
+ # their own CODEX_HOME, and without it a first launch fails on a directory rather than on
252
+ # anything meaningful.
253
+ mkdir -p "$(dirname "$SOCK")" \
254
+ || fail "could not create $(dirname "$SOCK") — the control socket has nowhere to live."
255
+
256
+ # --- the tmux fact, stated once --------------------------------------------
257
+ note "this app-server's tmux seat: ${TMUX:-(none — not inside tmux)}"
258
+ note "the bridge is this server's MCP child and inherits that TMUX, so caller-seat lookups read THAT tmux server's panes."
259
+ note "entwurf neither supervises nor restarts this process — Ctrl-C is yours."
260
+
261
+ export ENTWURF_CODEX_APP_SERVER_ACTIVE=1
262
+
263
+ # --- foreign identity carriers, removed before exec ------------------------
264
+ # The MCP bridge trusts a COMPLETE `PI_SESSION_ID` + `PI_AGENT_ID` pair ahead of a native
265
+ # sender marker when it resolves who is speaking. Inside the pi process that planted them
266
+ # from record birth that is correct. Here they would be somebody else's identity inherited by
267
+ # the app-server and by EVERY bridge child it spawns — start the server from a pi citizen's
268
+ # bash and each Codex thread's MCP child could speak under the parent pi garden id.
269
+ #
270
+ # Both go, together: clearing one only changes the wording of a later failure while leaving a
271
+ # carrier for a partial reader. Nothing is substituted — a Codex thread's identity comes from
272
+ # its own trusted birth hook, which is where the record authority already lives.
273
+ unset PI_SESSION_ID PI_AGENT_ID
274
+
275
+ exec "$codex_bin" app-server --listen "unix://$SOCK" "$@"
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env bash
2
+ # codex-birth-doctor — the Codex birth unit verdict: our bytes, and the vendor receipt.
3
+ #
4
+ # Three axes, reported independently, because they fail for different reasons and an
5
+ # operator repairs them with different hands:
6
+ #
7
+ # RUNTIME is there a node the launcher can exec, and a Codex CLI at all?
8
+ # UNIT are the declaration, launcher and closure present, ours by digest, and
9
+ # owned/permissioned so that nobody else could have written them?
10
+ # TRUST has the VENDOR recorded the operator's one-time decision for this exact
11
+ # declaration identity?
12
+ #
13
+ # WHAT THE TRUST AXIS IS, EXACTLY. It reads `$CODEX_HOME/config.toml` and looks for the
14
+ # vendor's own receipt at the fixed key `<hooks.json>:session_start:0:0`, whose value must
15
+ # be a `trusted_hash` of the shape `sha256:<64 hex>`. That is ALL it claims: a vendor trust
16
+ # receipt is present for the declaration identity this unit publishes. It does NOT compute
17
+ # what that hash should be, does not compare it to anything of ours, and therefore never
18
+ # says the approval is cryptographically valid — the vendor hashes a normalized identity of
19
+ # its own (`[source]` hooks/src/engine/discovery.rs:775-792) and recomputing it here would
20
+ # be entwurf asserting authority over somebody else's security decision.
21
+ #
22
+ # WHAT THIS DOCTOR NEVER DOES. It does not write, pre-seed or repair `[hooks.state]`, and it
23
+ # never launches Codex. A missing receipt is not something to fix from here: the operator
24
+ # opens a visible Codex, answers the vendor's prompt once, and comes back.
25
+ #
26
+ # Trust missing makes the WHOLE doctor red, on purpose. A unit whose bytes are perfect but
27
+ # whose hook the vendor will not run is a citizen nobody will ever be born as — reporting
28
+ # that as green is the exact false success this file exists to refuse. `--unit-only` exists
29
+ # for the sandbox gate, which has no vendor and no operator; it can never make setup or the
30
+ # fresh preflight green, because neither passes that flag.
31
+ set -euo pipefail
32
+
33
+ UNIT_ONLY=0
34
+ for arg in "$@"; do
35
+ case "$arg" in
36
+ # For the hermetic gate only: there is no vendor and no operator inside a sandbox, so the
37
+ # TRUST axis has nothing to read. It is REPORTED as skipped, never as green, and setup and
38
+ # the fresh preflight both call this doctor without the flag.
39
+ --unit-only) UNIT_ONLY=1 ;;
40
+ *) printf '[codex-birth-doctor] unknown argument %s — this doctor takes only --unit-only.\n' "'$arg'" >&2; exit 1 ;;
41
+ esac
42
+ done
43
+
44
+ CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
45
+ HOOKS_FILE="$CODEX_HOME/hooks.json"
46
+ PACKAGE_STATE_ROOT="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf"
47
+ UNIT_ROOT="$PACKAGE_STATE_ROOT/codex-birth"
48
+ HELPER_DIR="$UNIT_ROOT/helper"
49
+ LAUNCHER_NAME="codex-birth-launch.sh"
50
+ LAUNCHER="$HELPER_DIR/$LAUNCHER_NAME"
51
+ STATE_FILE="$UNIT_ROOT/install-state.json"
52
+ ME="$(id -u)"
53
+
54
+ RED=0
55
+ section() { printf '\n── %s %s\n' "$1" "─────────────────────────────────────────"; }
56
+ bad() { printf ' RED %s\n' "$1"; RED=$((RED + 1)); }
57
+ ok() { printf ' ok %s\n' "$1"; }
58
+ info() { printf ' · %s\n' "$1"; }
59
+
60
+ sha_of() { sha256sum -- "$1" | cut -d' ' -f1; }
61
+
62
+ section "PATHS"
63
+ # The declaration records an ABSOLUTE launcher path and the vendor keys its trust receipt to
64
+ # it, so a relative CODEX_HOME or XDG_DATA_HOME would make this doctor judge a different unit
65
+ # depending on the caller's cwd.
66
+ case "$CODEX_HOME" in
67
+ /*) ok "CODEX_HOME resolves absolute ($CODEX_HOME)" ;;
68
+ *) bad "CODEX_HOME resolves to a relative path ('$CODEX_HOME') — every verdict below would depend on the caller's cwd." ;;
69
+ esac
70
+ case "$UNIT_ROOT" in
71
+ /*) ok "the unit root resolves absolute ($UNIT_ROOT)" ;;
72
+ *) bad "the unit root resolves to a relative path ('$UNIT_ROOT') — set an absolute XDG_DATA_HOME or HOME." ;;
73
+ esac
74
+
75
+ section "RUNTIME"
76
+ if NODE_BIN="$(command -v node 2>/dev/null)"; then
77
+ NODE_MAJOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
78
+ if [ "$NODE_MAJOR" -ge 24 ] 2>/dev/null; then
79
+ ok "node $("$NODE_BIN" -p 'process.versions.node') at $NODE_BIN (>= 24, so strip-types runs the payload)"
80
+ else
81
+ bad "node at $NODE_BIN reports major '$NODE_MAJOR'; the payload is raw TypeScript and needs >= 24."
82
+ fi
83
+ else
84
+ bad "no node on PATH — the hook payload cannot run."
85
+ NODE_BIN=""
86
+ fi
87
+ if CODEX_BIN="$(command -v "${CODEX_BIN:-codex}" 2>/dev/null)"; then
88
+ info "codex CLI: $CODEX_BIN $("$CODEX_BIN" --version 2>/dev/null | head -1)"
89
+ else
90
+ info "no codex CLI on PATH — the unit's bytes are still judged below; the harness is the operator's."
91
+ fi
92
+
93
+ # The directory holding the receipt carries the receipt's authority: anyone who can write it
94
+ # can replace what licenses every removal. Judged before the state is read, and never repaired.
95
+ judge_parent() { # $1 = dir, $2 = label
96
+ [ -e "$1" ] || return 0
97
+ if [ -L "$1" ]; then bad "$2 ($1) is a SYMLINK — ownership authority may not come through a link."; return 0; fi
98
+ [ -d "$1" ] || { bad "$2 ($1) is not a directory."; return 0; }
99
+ local st mode uid
100
+ st="$(stat -c '%a %u' -- "$1")"; mode="$(printf '%s' "$st" | cut -d' ' -f1)"; uid="$(printf '%s' "$st" | cut -d' ' -f2)"
101
+ [ "$uid" = "$ME" ] || { bad "$2 ($1) is owned by uid $uid, not you ($ME) — it holds the receipt that licenses removals."; return 0; }
102
+ [ $((8#$mode & 0022)) -eq 0 ] || bad "$2 ($1) is group/world-writable (mode $mode), so the ownership state inside it is not provably ours."
103
+ }
104
+
105
+ section "UNIT (the bytes this unit publishes, and whether they are still ours)"
106
+ judge_parent "$PACKAGE_STATE_ROOT" "the package state root"
107
+ judge_parent "$UNIT_ROOT" "the unit root"
108
+ if [ ! -e "$STATE_FILE" ] && [ ! -L "$STATE_FILE" ]; then
109
+ if [ -e "$HOOKS_FILE" ] || [ -e "$HELPER_DIR" ]; then
110
+ bad "no ownership state at $STATE_FILE, yet $HOOKS_FILE or $HELPER_DIR exists — those bytes are not provably ours and the inverse will refuse them. Inspect them."
111
+ else
112
+ # NOT an informational note. "Nothing is installed" is the most complete way for this
113
+ # unit to be broken: no declaration, no closure, no record will ever be minted — and a
114
+ # host that has trusted an OLDER declaration still carries a vendor receipt, so the TRUST
115
+ # axis alone can read green over an empty unit. Measured exactly that way before this was
116
+ # red. An absent unit is a red unit, in both modes.
117
+ bad "no Codex birth unit is installed here (no ownership state at $STATE_FILE, no declaration, no closure), so no Codex thread on this host can become a citizen.
118
+ Install: ./run.sh install-codex-birth"
119
+ fi
120
+ elif [ -L "$STATE_FILE" ]; then
121
+ bad "$STATE_FILE is a SYMLINK — removal authority may not come through a link."
122
+ elif [ ! -f "$STATE_FILE" ]; then
123
+ bad "$STATE_FILE is not a regular file."
124
+ elif [ -z "$NODE_BIN" ]; then
125
+ bad "the ownership state cannot be parsed without node."
126
+ else
127
+ STATE_ST="$(stat -c '%a %u' -- "$STATE_FILE")"
128
+ STATE_MODE="$(printf '%s' "$STATE_ST" | cut -d' ' -f1)"
129
+ STATE_UID="$(printf '%s' "$STATE_ST" | cut -d' ' -f2)"
130
+ [ "$STATE_UID" = "$ME" ] || bad "$STATE_FILE is owned by uid $STATE_UID, not you ($ME) — anyone who can rewrite it can license removal of the paths it names."
131
+ [ $((8#$STATE_MODE & 0022)) -eq 0 ] || bad "$STATE_FILE is group/world-writable (mode $STATE_MODE) — the same authority problem."
132
+
133
+ INVENTORY="$("$NODE_BIN" -e '
134
+ const fs = require("node:fs");
135
+ const path = require("node:path");
136
+ const s = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
137
+ const out = [];
138
+ const hex = (v) => typeof v === "string" && /^[0-9a-f]{64}$/.test(v);
139
+ if (s.schema !== "codex-birth-install-state/v1") out.push("BAD\tforeign state schema " + JSON.stringify(s.schema));
140
+ if (s.status !== "installed") out.push("BAD\tthe state status is " + JSON.stringify(s.status) + ", so a previous install did not finish publishing. Repair: ./run.sh install-codex-birth");
141
+ if (s.hooksFile !== process.argv[2]) out.push("BAD\tthe state is bound to " + s.hooksFile + ", not the fixed path " + process.argv[2]);
142
+ if (s.helperDir !== process.argv[3]) out.push("BAD\tthe state is bound to helper dir " + s.helperDir + ", not the fixed path " + process.argv[3]);
143
+ if (!hex(s.hooksSha256)) out.push("BAD\thooksSha256 is not a digest");
144
+ else out.push("WANT\t" + s.hooksSha256 + "\t" + s.hooksFile + "\tthe declaration");
145
+ const expected = [
146
+ "codex-birth-launch.sh",
147
+ "meta-bridge-hook-codex.ts",
148
+ "lib/meta-session.ts",
149
+ "lib/native-push/codex-ws-client.ts",
150
+ "lib/session-id.js",
151
+ "entwurf-capabilities.json",
152
+ ];
153
+ if (!Array.isArray(s.helperFiles) || s.helperFiles.length === 0) out.push("BAD\thelperFiles is empty");
154
+ else {
155
+ // Exactly the closure this unit publishes. A short inventory hides a file the inverse
156
+ // can never reclaim; a long one licenses deleting something we never wrote.
157
+ const named = s.helperFiles.map((f) => (f == null ? "" : f.path));
158
+ const missing = expected.filter((name) => !named.includes(name));
159
+ const extra = named.filter((name) => !expected.includes(name));
160
+ const duplicate = named.filter((name, i) => named.indexOf(name) !== i);
161
+ if (missing.length) out.push("BAD\tthe recorded closure inventory is missing " + missing.join(", ") + " — the doctor cannot judge bytes no state names");
162
+ if (extra.length) out.push("BAD\tthe recorded closure inventory names files this unit never publishes: " + extra.join(", "));
163
+ if (duplicate.length) out.push("BAD\tthe recorded closure inventory repeats " + duplicate.join(", "));
164
+ }
165
+ if (Array.isArray(s.helperFiles)) for (const f of s.helperFiles) {
166
+ if (typeof f?.path !== "string" || f.path.startsWith("/") || f.path.split("/").includes("..")) { out.push("BAD\tunsafe helper path " + JSON.stringify(f?.path)); continue; }
167
+ if (!hex(f.sha256)) { out.push("BAD\tbad helper digest for " + f.path); continue; }
168
+ out.push(["WANT", f.sha256, path.join(s.helperDir, f.path), "helper member " + f.path, f.mode ?? ""].join("\t"));
169
+ }
170
+ out.push("INFO\tunitVersion=" + (s.unitVersion ?? "?") + " nodeBin=" + (s.nodeBin ?? "?"));
171
+ process.stdout.write(out.join("\n") + "\n");
172
+ ' "$STATE_FILE" "$HOOKS_FILE" "$HELPER_DIR" 2>&1)" || { bad "the ownership state is unreadable or malformed: $INVENTORY"; INVENTORY=""; }
173
+
174
+ while IFS="$(printf '\t')" read -r kind a b c d; do
175
+ case "$kind" in
176
+ BAD) bad "$a" ;;
177
+ INFO) info "$a" ;;
178
+ WANT)
179
+ if [ -L "$b" ]; then bad "$c is a SYMLINK ($b) — this unit publishes no links."
180
+ elif [ ! -e "$b" ]; then bad "$c is MISSING: $b"
181
+ elif [ ! -f "$b" ]; then bad "$c is not a regular file: $b"
182
+ else
183
+ live="$(sha_of "$b")"
184
+ st="$(stat -c '%a %u' -- "$b")"
185
+ mode="$(printf '%s' "$st" | cut -d' ' -f1)"; uid="$(printf '%s' "$st" | cut -d' ' -f2)"
186
+ if [ "$live" != "$a" ]; then bad "$c was EDITED after install ($b: live $live, recorded $a) — the inverse will refuse to remove it."
187
+ elif [ "$uid" != "$ME" ]; then bad "$c is owned by uid $uid, not you ($ME): $b"
188
+ elif [ $((8#$mode & 0022)) -ne 0 ]; then bad "$c is group/world-writable (mode $mode): $b"
189
+ else ok "$c matches the state ($b)"
190
+ fi
191
+ [ -n "$d" ] && [ "$mode" != "${d#0}" ] && [ "0$mode" != "$d" ] && info "$c mode is $mode, state recorded $d"
192
+ fi
193
+ ;;
194
+ esac
195
+ done <<EOF
196
+ $INVENTORY
197
+ EOF
198
+ fi
199
+
200
+ TRUST_REPAIR="Repair: open a visible plain Codex (no flags), answer the vendor's prompt with
201
+ 'Trust all and continue', send one first turn, then re-run this doctor."
202
+
203
+ section "TRUST (the vendor's receipt for this exact declaration)"
204
+ if [ "$UNIT_ONLY" = "1" ]; then
205
+ info "--unit-only: the vendor receipt was NOT read. This is a skipped axis, not a green one."
206
+ elif [ ! -e "$CODEX_HOME/config.toml" ]; then
207
+ bad "$CODEX_HOME/config.toml does not exist, so the vendor has recorded no trust decision for $HOOKS_FILE.
208
+ $TRUST_REPAIR"
209
+ elif ! command -v python3 >/dev/null 2>&1; then
210
+ bad "python3 is required to read the vendor config (the same reader the MCP/status-line atoms use) and is not on PATH."
211
+ else
212
+ # The key the vendor writes is `<declaration path>:<event>:<group>:<handler>` — measured on
213
+ # this host after one 'Trust all'. We look for exactly ours: a receipt for a DIFFERENT
214
+ # declaration is not this unit's approval, however valid it is for whoever owns it.
215
+ TRUST_KEY="$HOOKS_FILE:session_start:0:0"
216
+ # python3 + tomllib, the same reader the two config atoms already use — node has no TOML
217
+ # parser and inventing one here would be a second opinion about the vendor's own file.
218
+ TRUST_OUT="$(python3 -c '
219
+ import sys, tomllib
220
+ cfg_path, key = sys.argv[1], sys.argv[2]
221
+ try:
222
+ cfg = tomllib.load(open(cfg_path, "rb"))
223
+ except Exception as err: # noqa: BLE001 - the message is the verdict
224
+ sys.stdout.write("ERROR\t" + str(err)); raise SystemExit(0)
225
+ state = cfg.get("hooks", {}).get("state")
226
+ if not isinstance(state, dict):
227
+ sys.stdout.write("ABSENT\tno [hooks.state] table"); raise SystemExit(0)
228
+ if key not in state:
229
+ others = sorted(state.keys())
230
+ sys.stdout.write("WRONGKEY\t" + (", ".join(others) if others else "(none)")); raise SystemExit(0)
231
+ entry = state[key]
232
+ digest = entry.get("trusted_hash") if isinstance(entry, dict) else None
233
+ if not isinstance(digest, str):
234
+ sys.stdout.write("MALFORMED\tthe receipt carries no trusted_hash string"); raise SystemExit(0)
235
+ import re
236
+ if not re.fullmatch(r"sha256:[0-9a-f]{64}", digest):
237
+ sys.stdout.write("MALFORMED\ttrusted_hash is not sha256:<64 hex>: " + repr(digest)); raise SystemExit(0)
238
+ sys.stdout.write("PRESENT\t" + digest)
239
+ ' "$CODEX_HOME/config.toml" "$TRUST_KEY" 2>&1)" || TRUST_OUT="ERROR $TRUST_OUT"
240
+ TRUST_KIND="$(printf '%s' "$TRUST_OUT" | cut -f1)"
241
+ TRUST_DETAIL="$(printf '%s' "$TRUST_OUT" | cut -f2-)"
242
+ case "$TRUST_KIND" in
243
+ PRESENT)
244
+ ok "a vendor trust receipt is present for $TRUST_KEY ($TRUST_DETAIL)"
245
+ info "this reports that the vendor RECORDED a decision for this declaration identity — it is"
246
+ info "not a cryptographic validation, and this doctor never computes what that hash should be."
247
+ ;;
248
+ ABSENT)
249
+ bad "no vendor trust receipt for this declaration — $TRUST_DETAIL. Until the operator answers the
250
+ vendor's prompt once, this hook is declared and never runs.
251
+ $TRUST_REPAIR"
252
+ ;;
253
+ WRONGKEY)
254
+ bad "the vendor has trust receipts, but NONE at this unit's key $TRUST_KEY (present: $TRUST_DETAIL).
255
+ A receipt for another declaration is somebody else's approval, not this one's.
256
+ $TRUST_REPAIR"
257
+ ;;
258
+ MALFORMED)
259
+ bad "the vendor trust receipt at $TRUST_KEY is unreadable: $TRUST_DETAIL.
260
+ $TRUST_REPAIR"
261
+ ;;
262
+ *)
263
+ bad "the vendor config at $CODEX_HOME/config.toml could not be read: $TRUST_DETAIL"
264
+ ;;
265
+ esac
266
+ fi
267
+
268
+ if [ "$RED" -ne 0 ]; then
269
+ printf '\n[codex-birth-doctor] %d RED — see above.\n' "$RED" >&2
270
+ exit 1
271
+ fi
272
+ if [ "$UNIT_ONLY" = "1" ]; then
273
+ printf '\n[codex-birth-doctor] unit bytes and ownership are intact; the TRUST axis was skipped (--unit-only).\n'
274
+ else
275
+ printf '\n[codex-birth-doctor] unit bytes are ours and the vendor trust receipt is present for this declaration.\n'
276
+ fi