@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
@@ -0,0 +1,264 @@
1
+ /**
2
+ * check-codex-birth-hook — #95 gate: the Codex BIRTH path, proven without Codex.
3
+ *
4
+ * WHAT MOVED HERE, AND WHY THE ROOT SANDBOX IS GONE. This gate used to run inside
5
+ * `unshare --map-root-user --mount` with an overlayfs over the real `/etc`, because the
6
+ * birth unit published into `/etc/codex` and a production installer whose target can be
7
+ * redirected is one whose refusals can be side-stepped. The unit now publishes into the
8
+ * operator's own `$CODEX_HOME` and `$XDG_DATA_HOME`, so a plain mkdtemp fences it exactly:
9
+ * no namespace, no overlay, no euid 0, and no honest-SKIP hole on a host without userns.
10
+ *
11
+ * The claims that died with the root unit were claims ABOUT root — `/etc` provenance, a
12
+ * root-owned 0444 receipt, a 0755 traversal path for setup. The claims about the PAYLOAD
13
+ * and the DECLARATION are the same ones, asserted the same way, and they are what this file
14
+ * carries forward: one V3 record per thread and only for a top-level SessionStart, the
15
+ * envelope's own cwd/model/transcript rather than a guess, no marker of any kind, a resume
16
+ * that attaches instead of minting twice, and a declaration whose grammar IS the identity
17
+ * the operator trusts once.
18
+ *
19
+ * The unit's install/inverse/doctor refusals — foreign bytes, drift, symlinks, unsafe
20
+ * ownership, the vendor trust receipt — live in `scripts/smoke-codex-birth.sh`, which owns
21
+ * the same sandbox shape at shell level. This file owns the payload.
22
+ */
23
+
24
+ import assert from "node:assert/strict";
25
+ import { spawnSync } from "node:child_process";
26
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync } from "node:fs";
27
+ import { tmpdir } from "node:os";
28
+ import * as path from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+ import { listAllMetaIdentitiesDir, type MetaIdentity } from "../pi-extensions/lib/meta-session.ts";
31
+ import { reclaimOnExit } from "./lib/reclaim-on-exit.ts";
32
+
33
+ const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
34
+ const INSTALL = path.join(REPO, "scripts", "codex-birth-install.sh");
35
+
36
+ let passed = 0;
37
+ function ok(label: string, cond: boolean): void {
38
+ assert.ok(cond, label);
39
+ console.log(` ok ${label}`);
40
+ passed++;
41
+ }
42
+
43
+ // The fence: HOME, XDG_DATA_HOME and CODEX_HOME all inside one temp dir, so the installer's
44
+ // fixed paths resolve entirely within it. Nothing is redirected by a flag the unit does not
45
+ // have; the unit reads the same env an operator's shell does.
46
+ const scratch = reclaimOnExit(mkdtempSync(path.join(tmpdir(), "entwurf-codex-birth.")));
47
+ const HOME = path.join(scratch, "home");
48
+ const XDG = path.join(scratch, "xdg");
49
+ const CODEX_HOME = path.join(HOME, ".codex");
50
+ mkdirSync(CODEX_HOME, { recursive: true });
51
+ mkdirSync(XDG, { recursive: true });
52
+
53
+ const HOOKS_FILE = path.join(CODEX_HOME, "hooks.json");
54
+ const HELPER_DIR = path.join(XDG, "entwurf", "codex-birth", "helper");
55
+ const LAUNCHER = path.join(HELPER_DIR, "codex-birth-launch.sh");
56
+ const STATE_FILE = path.join(XDG, "entwurf", "codex-birth", "install-state.json");
57
+ const UNIT_ENV: NodeJS.ProcessEnv = { ...process.env, HOME, XDG_DATA_HOME: XDG, CODEX_HOME };
58
+
59
+ const mode = (file: string): string => (statSync(file).mode & 0o7777).toString(8).padStart(4, "0");
60
+
61
+ interface Ran {
62
+ status: number | null;
63
+ stdout: string;
64
+ stderr: string;
65
+ }
66
+ function sh(script: string, args: string[] = [], env: NodeJS.ProcessEnv = UNIT_ENV): Ran {
67
+ const res = spawnSync("bash", [script, ...args], { encoding: "utf8", env });
68
+ return { status: res.status, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
69
+ }
70
+
71
+ // ── 1. a clean install, and every byte it claims ─────────────────────────────
72
+ const first = sh(INSTALL);
73
+ ok(`the real installer succeeds on a clean host (exit ${first.status})`, first.status === 0);
74
+ ok("hooks.json was published as a regular file", existsSync(HOOKS_FILE) && statSync(HOOKS_FILE).isFile());
75
+ ok("hooks.json is owned by the operator running it", statSync(HOOKS_FILE).uid === process.getuid?.());
76
+ ok("the launcher is published executable", mode(LAUNCHER) === "0755");
77
+ for (const member of [
78
+ "meta-bridge-hook-codex.ts",
79
+ "lib/meta-session.ts",
80
+ "lib/native-push/codex-ws-client.ts",
81
+ "lib/session-id.js",
82
+ "entwurf-capabilities.json",
83
+ ]) {
84
+ const file = path.join(HELPER_DIR, member);
85
+ ok(`the closure member ${member} is published 0644`, existsSync(file) && mode(file) === "0644");
86
+ }
87
+ ok("the ownership state is a regular file the operator owns", statSync(STATE_FILE).uid === process.getuid?.());
88
+
89
+ // asserted as omissions, not as values: an empty `matcher` is not "no matcher", and an
90
+ // `async: true` birth hook would let the turn proceed before the record exists.
91
+ const hooks = JSON.parse(readFileSync(HOOKS_FILE, "utf8")) as {
92
+ description?: unknown;
93
+ hooks?: Record<string, Array<{ matcher?: unknown; hooks?: Array<Record<string, unknown>> }>>;
94
+ };
95
+ ok(
96
+ "hooks.json top level carries exactly `description` + `hooks`",
97
+ JSON.stringify(Object.keys(hooks).sort()) === JSON.stringify(["description", "hooks"]),
98
+ );
99
+ ok(
100
+ "[QK:CODEX-BIRTH-DECLARES-ONLY-SESSIONSTART] the events map declares SessionStart and nothing else",
101
+ JSON.stringify(Object.keys(hooks.hooks ?? {})) === JSON.stringify(["SessionStart"]),
102
+ );
103
+ const groups = hooks.hooks?.SessionStart ?? [];
104
+ ok("SessionStart holds exactly one matcher group", groups.length === 1);
105
+ ok(
106
+ "[QK:CODEX-BIRTH-NO-MATCHER] the group carries NO matcher key, so all four sources (startup|resume|clear|compact) fire",
107
+ !("matcher" in (groups[0] ?? {})),
108
+ );
109
+ const handlers = groups[0]?.hooks ?? [];
110
+ ok("the group holds exactly one handler", handlers.length === 1);
111
+ const handler = handlers[0] ?? {};
112
+ ok(
113
+ "the handler is a `command` handler with exactly type+command+timeout",
114
+ JSON.stringify(Object.keys(handler).sort()) === JSON.stringify(["command", "timeout", "type"]),
115
+ );
116
+ ok(
117
+ "[QK:CODEX-BIRTH-SYNCHRONOUS] the handler carries no `async` — the record must exist before the turn proceeds",
118
+ !("async" in handler),
119
+ );
120
+ ok(
121
+ "[QK:CODEX-BIRTH-QUOTED-ABSOLUTE-LAUNCHER] the command is the single-quoted absolute launcher path (codex runs it as a SHELL STRING; there is no argv form)",
122
+ handler.command === `'${LAUNCHER}'`,
123
+ );
124
+ ok("the handler timeout is 30", handler.timeout === 30);
125
+
126
+ // ── 3. FIRE the installed launcher, the way codex fires it ──────────────────
127
+ const MEASURED = {
128
+ session_id: "01a08147-fbad-78d2-b266-e058f322125e",
129
+ transcript_path:
130
+ "/home/junghan/.codex/sessions/2026/09/08/rollout-2026-09-08T22-49-33-01a08147-fbad-78d2-b266-e058f322125e.jsonl",
131
+ cwd: "/home/junghan/repos/gh/entwurf",
132
+ hook_event_name: "SessionStart",
133
+ model: "gpt-6-astra",
134
+ permission_mode: "default",
135
+ source: "startup",
136
+ };
137
+ function isolatedEnv(storeDir: string): NodeJS.ProcessEnv {
138
+ const env: NodeJS.ProcessEnv = { ...UNIT_ENV, PI_CODING_AGENT_DIR: storeDir };
139
+ delete env.ENTWURF_META_SENDERS_DIR;
140
+ delete env.ENTWURF_META_SESSIONS_DIR;
141
+ return env;
142
+ }
143
+ function fire(envelope: unknown, storeDir: string): Ran {
144
+ const res = spawnSync(LAUNCHER, [], {
145
+ input: typeof envelope === "string" ? envelope : JSON.stringify(envelope),
146
+ env: isolatedEnv(storeDir),
147
+ encoding: "utf8",
148
+ });
149
+ return { status: res.status, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
150
+ }
151
+ function records(storeDir: string): readonly MetaIdentity[] {
152
+ const dir = path.join(storeDir, "meta-sessions");
153
+ return existsSync(dir) ? listAllMetaIdentitiesDir(dir).identities : [];
154
+ }
155
+ function hookLog(storeDir: string): string {
156
+ const file = path.join(storeDir, "meta-bridge-hook.log");
157
+ return existsSync(file) ? readFileSync(file, "utf8") : "";
158
+ }
159
+
160
+ const store = path.join(scratch, "store");
161
+ mkdirSync(store, { recursive: true });
162
+ const born = fire(MEASURED, store);
163
+ ok("a fire with the MEASURED 0.153.4 envelope exits 0", born.status === 0);
164
+ ok("the hook emits the neutral response and nothing else", born.stdout.trim() === "{}");
165
+ let live = records(store);
166
+ ok("exactly one record was minted", live.length === 1);
167
+ ok("[QK:CODEX-BIRTH-MINTS-CODEX] the record's backend is codex", live[0]?.backend === "codex");
168
+ ok(
169
+ "[QK:CODEX-BIRTH-JOINS-ON-THREAD-ID] the record joins on session_id — the string that IS the app-server threadId and the MCP _meta.threadId",
170
+ live[0]?.nativeSessionId === MEASURED.session_id,
171
+ );
172
+ ok(
173
+ "the record carries the envelope's cwd, model and transcript path — none of them guessed",
174
+ live[0]?.cwd === MEASURED.cwd &&
175
+ live[0]?.model === MEASURED.model &&
176
+ live[0]?.transcriptPath === MEASURED.transcript_path,
177
+ );
178
+ const bornGardenId = live[0]?.gardenId ?? "";
179
+ ok("the log names the mint with its own token", hookLog(store).includes(`birth-mint ${bornGardenId}`));
180
+
181
+ // A resume is the SAME thread: one citizen, attached, with refreshed axes.
182
+ const resumed = fire({ ...MEASURED, source: "resume", model: "gpt-6-astra-mini" }, store);
183
+ ok("a resume of the same thread exits 0", resumed.status === 0);
184
+ live = records(store);
185
+ ok("[QK:CODEX-BIRTH-RESUME-ATTACHES] the store still holds exactly ONE record after resume", live.length === 1);
186
+ ok("the resume attached to the same garden id", live[0]?.gardenId === bornGardenId);
187
+ ok("the attach refreshed the model rather than keeping a stale one", live[0]?.model === "gpt-6-astra-mini");
188
+ ok("the log distinguishes attach from mint", hookLog(store).includes(`birth-attach ${bornGardenId}`));
189
+
190
+ // ── 4. birth writes NO marker of any kind ───────────────────────────────────
191
+ // Not a pid marker (`[host]` one ppid is shared by every hook AND every MCP child of
192
+ // every window on this host, so it names no single citizen), and not a receiver marker
193
+ // (`[source]` codex has no filesystem-wake vocabulary at all; receive is native-push
194
+ // and ack-only). Either one would be a claim this backend cannot honour.
195
+ const entries = readdirSync(store);
196
+ ok(
197
+ "[QK:CODEX-BIRTH-WRITES-NO-MARKER] the store holds records only — no senders, no receivers, no mailbox",
198
+ !entries.includes("meta-senders") && !entries.includes("meta-receivers") && !entries.includes("meta-mailbox"),
199
+ );
200
+
201
+ // ── 5. refusals: every one is a REFUSAL, not a guessed record ───────────────
202
+ function refuses(label: string, envelope: unknown, expectInLog: string): void {
203
+ const dir = mkdtempSync(path.join(scratch, "refuse."));
204
+ const res = fire(envelope, dir);
205
+ ok(
206
+ `${label}: exits 0 and emits {} (a hook must never break the turn)`,
207
+ res.status === 0 && res.stdout.trim() === "{}",
208
+ );
209
+ ok(`${label}: minted NOTHING`, records(dir).length === 0);
210
+ ok(
211
+ `${label}: the log says why (${expectInLog})`,
212
+ hookLog(dir).includes("birth-refused") && hookLog(dir).includes(expectInLog),
213
+ );
214
+ }
215
+ // The one a tolerant reader keyed on session_id would swallow: `[host]` SubagentStart
216
+ // carries the PARENT's session_id, so it would mint the parent's identity a second time.
217
+ refuses(
218
+ "[QK:CODEX-BIRTH-REFUSES-SUBAGENT] a SubagentStart envelope",
219
+ {
220
+ ...MEASURED,
221
+ hook_event_name: "SubagentStart",
222
+ agent_id: "01a08149-959a-7113-9d35-1845ab19a17f",
223
+ agent_type: "default",
224
+ },
225
+ "hook_event_name is not exactly",
226
+ );
227
+ refuses(
228
+ "a SessionStart NAME carrying subagent keys (hand-built envelope)",
229
+ { ...MEASURED, agent_id: "01a08149-959a-7113-9d35-1845ab19a17f" },
230
+ "a subagent key (agent_id) is present",
231
+ );
232
+ refuses(
233
+ "a UserPromptSubmit envelope",
234
+ { ...MEASURED, hook_event_name: "UserPromptSubmit", turn_id: "t-1", prompt: "hi" },
235
+ "hook_event_name is not exactly",
236
+ );
237
+ refuses("a relative cwd", { ...MEASURED, cwd: "relative/dir" }, "cwd is not an absolute POSIX path");
238
+ refuses("a missing transcript_path", { ...MEASURED, transcript_path: undefined }, "transcript_path missing");
239
+ refuses(
240
+ "a relative transcript_path",
241
+ { ...MEASURED, transcript_path: "sessions/x.jsonl" },
242
+ "transcript_path is not an absolute POSIX path",
243
+ );
244
+ refuses("an unknown source", { ...MEASURED, source: "teleport" }, "source is not one of");
245
+ refuses("a non-string model", { ...MEASURED, model: 7 }, "model is present but not a non-empty string");
246
+ refuses("a session_id with whitespace", { ...MEASURED, session_id: "01a0 8147" }, "session_id carries whitespace");
247
+ refuses("no session_id", { ...MEASURED, session_id: undefined }, "session_id missing");
248
+ refuses(
249
+ "[QK:CODEX-BIRTH-REQUIRES-MODEL] a missing model key",
250
+ { ...MEASURED, model: undefined },
251
+ "model key is missing",
252
+ );
253
+ refuses("a malformed envelope", "{not json", "envelope parse failed");
254
+ // An explicit null model is a FACT, not a refusal — the record's own nullable axis.
255
+ {
256
+ const dir = mkdtempSync(path.join(scratch, "nullmodel."));
257
+ const res = fire({ ...MEASURED, model: null }, dir);
258
+ ok(
259
+ "an explicit null model mints with model=null (nullable axis, never guessed)",
260
+ res.status === 0 && records(dir).length === 1 && records(dir)[0]?.model === null,
261
+ );
262
+ }
263
+
264
+ console.log(`\n[check-codex-birth-hook] ${passed} checks passed — payload + declaration, fenced in ${scratch}`);
@@ -0,0 +1,179 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import * as fs from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import { createInterface } from "node:readline";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ import { upsertMetaSession } from "../pi-extensions/lib/meta-session.ts";
10
+
11
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12
+ const THREAD_ID = "00000000-codex-bridge-gate-0001";
13
+ const OTHER_GARDEN_ID = "20990101T000000-beefed";
14
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-codex-bridge-identity-"));
15
+ const sessionsDir = path.join(tmp, "meta-sessions");
16
+ const senderDir = path.join(tmp, "meta-senders");
17
+ const codexHome = path.join(tmp, "codex-home");
18
+ const born = upsertMetaSession({
19
+ input: {
20
+ backend: "codex",
21
+ nativeSessionId: THREAD_ID,
22
+ cwd: "/gate/codex/bridge-cwd",
23
+ model: "gate-codex-model",
24
+ transcriptPath: "/gate/codex/rollout.jsonl",
25
+ },
26
+ dir: sessionsDir,
27
+ });
28
+
29
+ const requestMeta = {
30
+ threadId: THREAD_ID,
31
+ "x-codex-turn-metadata": {
32
+ session_id: THREAD_ID,
33
+ thread_id: THREAD_ID,
34
+ },
35
+ };
36
+
37
+ interface RpcRequest {
38
+ jsonrpc: "2.0";
39
+ id?: number;
40
+ method: string;
41
+ params: Record<string, unknown>;
42
+ }
43
+
44
+ async function callBridge(
45
+ extraEnv: NodeJS.ProcessEnv,
46
+ requests: RpcRequest[],
47
+ wantedIds: number[],
48
+ ): Promise<Map<number, unknown>> {
49
+ const child = spawn("bash", [path.join(ROOT, "mcp/entwurf-bridge/start.sh")], {
50
+ cwd: ROOT,
51
+ env: {
52
+ ...process.env,
53
+ PI_SESSION_ID: "",
54
+ PI_AGENT_ID: "",
55
+ ENTWURF_META_SESSIONS_DIR: sessionsDir,
56
+ ENTWURF_META_SENDERS_DIR: senderDir,
57
+ ENTWURF_BRIDGE_NATIVE_HOST: "codex",
58
+ CODEX_HOME: codexHome,
59
+ ...extraEnv,
60
+ },
61
+ stdio: ["pipe", "pipe", "pipe"],
62
+ });
63
+ let stderr = "";
64
+ child.stderr.setEncoding("utf8");
65
+ child.stderr.on("data", (chunk) => {
66
+ stderr += chunk;
67
+ });
68
+ const lines = createInterface({ input: child.stdout });
69
+ const responses = new Map<number, unknown>();
70
+ const timer = setTimeout(() => child.kill("SIGKILL"), 10_000);
71
+ try {
72
+ const done = new Promise<void>((resolve, reject) => {
73
+ lines.on("line", (line) => {
74
+ try {
75
+ const value = JSON.parse(line) as { id?: unknown };
76
+ if (typeof value.id === "number") responses.set(value.id, value);
77
+ if (wantedIds.every((id) => responses.has(id))) resolve();
78
+ } catch (error) {
79
+ reject(new Error(`bridge wrote non-JSON stdout: ${line}`, { cause: error }));
80
+ }
81
+ });
82
+ child.once("error", reject);
83
+ child.once("exit", (code) => {
84
+ if (!wantedIds.every((id) => responses.has(id))) {
85
+ reject(new Error(`bridge exited ${code} before replies ${wantedIds.join(", ")}: ${stderr}`));
86
+ }
87
+ });
88
+ });
89
+ for (const request of requests) child.stdin.write(`${JSON.stringify(request)}\n`);
90
+ await done;
91
+ return responses;
92
+ } finally {
93
+ clearTimeout(timer);
94
+ lines.close();
95
+ child.kill("SIGTERM");
96
+ }
97
+ }
98
+
99
+ function initialize(id: number): RpcRequest {
100
+ return {
101
+ jsonrpc: "2.0",
102
+ id,
103
+ method: "initialize",
104
+ params: {
105
+ protocolVersion: "2025-06-18",
106
+ capabilities: {},
107
+ clientInfo: { name: "codex-mcp-client", title: "Codex", version: "0.153.4" },
108
+ },
109
+ };
110
+ }
111
+
112
+ function selfCall(id: number, meta?: Record<string, unknown>): RpcRequest {
113
+ return {
114
+ jsonrpc: "2.0",
115
+ id,
116
+ method: "tools/call",
117
+ params: { name: "entwurf_self", arguments: {}, ...(meta ? { _meta: meta } : {}) },
118
+ };
119
+ }
120
+
121
+ function toolText(response: unknown): { text: string; isError: boolean } {
122
+ if (typeof response !== "object" || response === null || !("result" in response)) {
123
+ throw new Error("tools/call returned no result object");
124
+ }
125
+ const result = response.result;
126
+ if (typeof result !== "object" || result === null || !("content" in result) || !Array.isArray(result.content)) {
127
+ throw new Error("tools/call result has no content array");
128
+ }
129
+ const first = result.content[0];
130
+ if (typeof first !== "object" || first === null || !("text" in first) || typeof first.text !== "string") {
131
+ throw new Error("tools/call result has no text content");
132
+ }
133
+ return { text: first.text, isError: "isError" in result && result.isError === true };
134
+ }
135
+
136
+ try {
137
+ const replies = await callBridge(
138
+ {},
139
+ [
140
+ initialize(1),
141
+ { jsonrpc: "2.0", method: "notifications/initialized", params: {} },
142
+ selfCall(2, requestMeta),
143
+ selfCall(3),
144
+ selfCall(4, {
145
+ ...requestMeta,
146
+ "x-codex-turn-metadata": { session_id: THREAD_ID, thread_id: "different-thread" },
147
+ }),
148
+ ],
149
+ [1, 2, 3, 4],
150
+ );
151
+ const self = toolText(replies.get(2));
152
+ assert.equal(self.isError, false, `[QK:CODEX-BRIDGE-REQUEST-IDENTITY] ${self.text}`);
153
+ assert.match(self.text, new RegExp(born.record.gardenId));
154
+ assert.match(self.text, /agentId:\s+meta-session\/codex/);
155
+ assert.match(self.text, /origin:\s+meta-session/);
156
+ assert.match(self.text, /cwd:\s+\/gate\/codex\/bridge-cwd/);
157
+
158
+ const absent = toolText(replies.get(3));
159
+ assert.equal(absent.isError, true, "a Codex request without _meta must not borrow the preceding request's identity");
160
+ assert.match(absent.text, /identity|wiring incomplete/);
161
+
162
+ const mismatch = toolText(replies.get(4));
163
+ assert.equal(mismatch.isError, true, "disagreeing per-request ids must fail loud");
164
+ assert.match(mismatch.text, /three ids disagree/);
165
+
166
+ const conflictReplies = await callBridge(
167
+ { PI_SESSION_ID: OTHER_GARDEN_ID, PI_AGENT_ID: OTHER_GARDEN_ID },
168
+ [initialize(10), { jsonrpc: "2.0", method: "notifications/initialized", params: {} }, selfCall(11, requestMeta)],
169
+ [10, 11],
170
+ );
171
+ const conflict = toolText(conflictReplies.get(11));
172
+ assert.equal(conflict.isError, true, "a leaked pi identity and Codex request identity must not be priority-resolved");
173
+ assert.match(conflict.text, /conflicting sender identity/);
174
+ console.log(
175
+ "check-codex-bridge-identity: request metadata reaches the bridge identity resolver; absent, mismatched, and conflicting claims refuse",
176
+ );
177
+ } finally {
178
+ fs.rmSync(tmp, { recursive: true, force: true });
179
+ }