@junghanacs/entwurf 0.21.0 → 0.23.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.
- package/AGENTS.md +3 -2
- package/BASELINE.md +3 -1
- package/CHANGELOG.md +658 -0
- package/DELIVERY.md +167 -29
- package/README.md +104 -14
- package/VERIFY.md +73 -11
- package/docs/external-mcp-host.md +16 -6
- package/docs/setup-clean-host.md +73 -22
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +54 -31
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +289 -57
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +8 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +171 -263
- package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
- package/mcp/entwurf-bridge/src/index.ts +65 -31
- package/mcp/entwurf-bridge/tsconfig.build.json +10 -0
- package/package.json +4 -4
- package/pi-extensions/entwurf-control.ts +91 -52
- package/pi-extensions/lib/codex-caller-seat.ts +204 -0
- package/pi-extensions/lib/codex-declaration.js +612 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +325 -52
- package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
- package/pi-extensions/lib/entwurf-facts.ts +14 -1
- package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
- package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
- package/pi-extensions/lib/entwurf-self-address.ts +33 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +3 -2
- package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
- package/pi-extensions/lib/fresh-call-composition.ts +493 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
- package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
- package/pi-extensions/lib/herdr-placement.ts +276 -0
- package/pi-extensions/lib/mux-fresh-call.ts +257 -304
- package/run.sh +522 -10
- package/scripts/check-codex-app-server-launch.ts +445 -0
- package/scripts/check-codex-birth-hook.ts +187 -0
- package/scripts/check-entwurf-control-rpc.ts +138 -0
- package/scripts/check-entwurf-fact-provider.ts +4 -2
- package/scripts/check-entwurf-facts.ts +12 -6
- package/scripts/check-entwurf-peers-surface.ts +3 -1
- package/scripts/check-entwurf-self-address.ts +68 -0
- package/scripts/check-entwurf-v2-production.ts +42 -1
- package/scripts/check-entwurf-v2-send.ts +26 -7
- package/scripts/check-fresh-call-dispatch.ts +301 -0
- package/scripts/check-gate-qualification.ts +19 -5
- package/scripts/check-herdr-activation.ts +1293 -0
- package/scripts/check-herdr-fresh-call.ts +1250 -0
- package/scripts/check-herdr-placement.ts +383 -0
- package/scripts/check-herdr-plugin-build.ts +842 -0
- package/scripts/check-herdr-plugin-profile.ts +371 -0
- package/scripts/check-herdr-plugin.ts +523 -0
- package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
- package/scripts/check-herdr-sandbox.ts +456 -0
- package/scripts/check-herdr-supply.ts +184 -0
- package/scripts/check-mux-launch-tmux.ts +331 -35
- package/scripts/check-mux-launcher-fence.ts +138 -0
- package/scripts/check-peer-facts.ts +331 -0
- package/scripts/check-release-gate-outcomes.ts +12 -0
- package/scripts/check-typing-call-fence.ts +296 -0
- package/scripts/codex-app-server-launch.sh +275 -0
- package/scripts/codex-birth-doctor.sh +159 -16
- package/scripts/codex-birth-install.sh +178 -72
- package/scripts/codex-birth-uninstall.sh +162 -7
- package/scripts/codex-socket-path.ts +33 -0
- package/scripts/codex-terminal-title-config.py +500 -0
- package/scripts/codex_toml_io.py +121 -0
- package/scripts/fixtures/herdr-supply.json +29 -0
- package/scripts/herdr-activation.mjs +536 -0
- package/scripts/herdr-plugin-activate.mjs +270 -0
- package/scripts/herdr-plugin-deactivate.mjs +193 -0
- package/scripts/herdr-runtime.mjs +1203 -0
- package/scripts/install-herdr-ci.sh +96 -0
- package/scripts/lib/codex-fresh-live-protocol.ts +13 -5
- package/scripts/lib/codex-fresh-source-receipts.ts +29 -2
- package/scripts/lib/tree-digest.ts +97 -0
- package/scripts/meta-bridge-install.sh +19 -2
- package/scripts/meta-bridge-state.py +167 -11
- package/scripts/mutants/codex-app-server-launch.json +157 -0
- package/scripts/mutants/codex-caller-seat.json +334 -0
- package/scripts/mutants/codex-native.json +195 -29
- package/scripts/mutants/control-socket-disconnect.json +57 -0
- package/scripts/mutants/fresh-call-dispatch.json +154 -0
- package/scripts/mutants/herdr-activation.json +305 -0
- package/scripts/mutants/herdr-fresh-call.json +448 -0
- package/scripts/mutants/herdr-placement.json +161 -0
- package/scripts/mutants/herdr-plugin-build.json +160 -0
- package/scripts/mutants/herdr-plugin-profile.json +202 -0
- package/scripts/mutants/herdr-plugin.json +133 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
- package/scripts/mutants/herdr-supply.json +133 -0
- package/scripts/mutants/meta-hook-session-switch.json +2 -2
- package/scripts/mutants/mux-fresh-call.json +184 -25
- package/scripts/mutants/mux-launcher-fence.json +13 -0
- package/scripts/mutants/omp-fresh.json +4 -4
- package/scripts/mutants/peer-facts.json +98 -0
- package/scripts/mutants/self-address.json +33 -0
- package/scripts/mutants/typing-call-fence.json +17 -0
- package/scripts/mutants/v2-surface.json +22 -0
- package/scripts/peer-facts.ts +120 -0
- package/scripts/register-pi-package.py +8 -0
- package/scripts/register-pi-provider.py +88 -12
- package/scripts/smoke-codex-birth.sh +281 -12
- package/scripts/smoke-codex-config-state.sh +192 -3
- package/scripts/smoke-codex-fresh-live.ts +277 -37
- package/scripts/smoke-entwurf-chain-live.ts +50 -0
- package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
- package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
- package/scripts/smoke-setup-verdict.sh +13 -11
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-codex-app-server-launch — deterministic gate for the managed Codex app-server
|
|
3
|
+
* launch (`entwurf codex-app-server`, #95). Hermetic: no Codex CLI, no app-server, no
|
|
4
|
+
* network, no model turn, no write outside its own temp root.
|
|
5
|
+
*
|
|
6
|
+
* WHAT IS UNDER TEST is a process replacement, so the oracle is a FAKE VENDOR: a real
|
|
7
|
+
* executable placed on a sandbox PATH under the real name `codex`, which reports the argv,
|
|
8
|
+
* pid and cwd it was handed and then exits. Everything is asserted from that report, never
|
|
9
|
+
* from reading the launcher's source. The launcher is driven through its PUBLIC address
|
|
10
|
+
* (`run.sh codex-app-server`), because the dispatcher's own argv handling is part of the
|
|
11
|
+
* contract: the verb must not reach the vendor.
|
|
12
|
+
*
|
|
13
|
+
* THE ADDRESS ORACLE, AND WHY IT IS SHAPED LIKE THIS NOW. The first version of this launcher
|
|
14
|
+
* re-derived the socket path in bash, and this gate compared the two spellings over four
|
|
15
|
+
* ASCII-normal inputs. They agreed on those four and diverged elsewhere: `[측정 2026-09-16,
|
|
16
|
+
* independent review]` `CODEX_HOME=$'\ufeff'` trims to nothing in JS and keeps its byte in a
|
|
17
|
+
* POSIX `[:space:]` trim, so the launcher would have started a server at
|
|
18
|
+
* `<BOM>/app-server-control/app-server-control.sock` while delivery looked at `$HOME/.codex`.
|
|
19
|
+
* A matrix can only ever hold the inputs somebody thought of, so the second spelling was
|
|
20
|
+
* removed rather than widened — the launcher now ASKS `run.sh codex-socket-path`, which prints
|
|
21
|
+
* what `resolveCodexDefaultSocketPath` computes.
|
|
22
|
+
*
|
|
23
|
+
* That makes the cells below a WIRING oracle rather than a transcription oracle, and they are
|
|
24
|
+
* written to fail if the wiring is ever replaced by arithmetic again: the matrix keeps the
|
|
25
|
+
* ASCII cases AND carries the hostile inputs that caught the divergence, with the expectation
|
|
26
|
+
* computed by the real TS function on the same environment. The mutant that matters is not
|
|
27
|
+
* "drop CODEX_HOME" any more; it is "derive the path here instead of asking".
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import assert from "node:assert/strict";
|
|
31
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
32
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
33
|
+
import * as net from "node:net";
|
|
34
|
+
import { tmpdir } from "node:os";
|
|
35
|
+
import * as path from "node:path";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
|
|
38
|
+
import { resolveCodexDefaultSocketPath } from "../pi-extensions/lib/native-push/codex-ws-client.ts";
|
|
39
|
+
|
|
40
|
+
const REPO = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
41
|
+
|
|
42
|
+
let passed = 0;
|
|
43
|
+
function ok(label: string, cond: boolean): void {
|
|
44
|
+
assert.ok(cond, label);
|
|
45
|
+
console.log(` ok ${label}`);
|
|
46
|
+
passed++;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const root = mkdtempSync(path.join(tmpdir(), "entwurf-codex-app-server-launch."));
|
|
50
|
+
const servers: net.Server[] = [];
|
|
51
|
+
try {
|
|
52
|
+
const home = path.join(root, "home");
|
|
53
|
+
const bin = path.join(root, "bin");
|
|
54
|
+
for (const d of [home, bin]) mkdirSync(d, { recursive: true });
|
|
55
|
+
|
|
56
|
+
// The fake vendor. `printf '%s\n'` per element keeps empty strings and embedded spaces
|
|
57
|
+
// visible as themselves, which is the only way to assert byte preservation.
|
|
58
|
+
const vendor = path.join(bin, "codex");
|
|
59
|
+
writeFileSync(
|
|
60
|
+
vendor,
|
|
61
|
+
`#!/usr/bin/env bash
|
|
62
|
+
echo "CWD=$PWD"
|
|
63
|
+
echo "PID=$$"
|
|
64
|
+
echo "PPID=$PPID"
|
|
65
|
+
echo "PISESSION=[\${PI_SESSION_ID-<unset>}]"
|
|
66
|
+
echo "PIAGENT=[\${PI_AGENT_ID-<unset>}]"
|
|
67
|
+
echo "KEEP=\${ENTWURF_FIXTURE_KEEP-0}"
|
|
68
|
+
for a in "$@"; do printf 'ARG<%s>\\n' "$a"; done
|
|
69
|
+
exit "\${FAKE_CODEX_EXIT:-0}"
|
|
70
|
+
`,
|
|
71
|
+
);
|
|
72
|
+
chmodSync(vendor, 0o755);
|
|
73
|
+
|
|
74
|
+
interface Run {
|
|
75
|
+
status: number | null;
|
|
76
|
+
out: string;
|
|
77
|
+
args: string[];
|
|
78
|
+
cwd: string;
|
|
79
|
+
pid: string;
|
|
80
|
+
ppid: string;
|
|
81
|
+
piSession: string;
|
|
82
|
+
piAgent: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A PATH with no `codex` anywhere on it — built by dropping every real PATH entry that
|
|
86
|
+
// actually holds one, rather than by emptying PATH (the launcher still needs python3,
|
|
87
|
+
// readlink and friends).
|
|
88
|
+
const pathWithoutVendor = (process.env.PATH ?? "")
|
|
89
|
+
.split(":")
|
|
90
|
+
.filter((d) => d !== "" && !existsSync(path.join(d, "codex")))
|
|
91
|
+
.join(":");
|
|
92
|
+
|
|
93
|
+
function launch(args: string[], extraEnv: Record<string, string | undefined> = {}, cwd = root): Run {
|
|
94
|
+
const r = spawnSync("bash", [path.join(REPO, "run.sh"), "codex-app-server", ...args], {
|
|
95
|
+
cwd,
|
|
96
|
+
encoding: "utf8",
|
|
97
|
+
env: {
|
|
98
|
+
...process.env,
|
|
99
|
+
HOME: home,
|
|
100
|
+
CODEX_HOME: undefined as unknown as string,
|
|
101
|
+
TMUX: undefined as unknown as string,
|
|
102
|
+
PATH: `${bin}:${process.env.PATH ?? ""}`,
|
|
103
|
+
ENTWURF_CODEX_APP_SERVER_ACTIVE: undefined as unknown as string,
|
|
104
|
+
...extraEnv,
|
|
105
|
+
} as NodeJS.ProcessEnv,
|
|
106
|
+
});
|
|
107
|
+
const out = `${r.stdout ?? ""}${r.stderr ?? ""}`;
|
|
108
|
+
const argv: string[] = [];
|
|
109
|
+
for (const line of out.split("\n")) {
|
|
110
|
+
const m = /^ARG<([\s\S]*)>$/.exec(line);
|
|
111
|
+
if (m) argv.push(m[1]);
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
status: r.status,
|
|
115
|
+
out,
|
|
116
|
+
args: argv,
|
|
117
|
+
// Line-anchored, NOT `[\s\S]*`: a greedy any-character match would run past this
|
|
118
|
+
// line's delimiter and capture everything down to the last matching line.
|
|
119
|
+
cwd: /^CWD=(.*)$/m.exec(out)?.[1] ?? "",
|
|
120
|
+
pid: /^PID=(.*)$/m.exec(out)?.[1] ?? "",
|
|
121
|
+
ppid: /^PPID=(.*)$/m.exec(out)?.[1] ?? "",
|
|
122
|
+
piSession: /^PISESSION=\[(.*)\]$/m.exec(out)?.[1] ?? "<no-launch>",
|
|
123
|
+
piAgent: /^PIAGENT=\[(.*)\]$/m.exec(out)?.[1] ?? "<no-launch>",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── 1. the address, taken from the leaf the product reads ───────────────────
|
|
128
|
+
// Two groups, and the second is the load-bearing one. The ASCII cells are the environment
|
|
129
|
+
// shapes `resolveCodexHome` distinguishes at all. The HOSTILE cells are the inputs on which
|
|
130
|
+
// a bash transcription was MEASURED to diverge from it — a BOM-only CODEX_HOME (JS `trim`
|
|
131
|
+
// strips U+FEFF, a POSIX `[:space:]` trim does not) and the two `path.join` normalizations.
|
|
132
|
+
// They are here so that replacing the `codex-socket-path` call with arithmetic goes red
|
|
133
|
+
// instead of passing on well-behaved paths, which is exactly how the first version passed.
|
|
134
|
+
{
|
|
135
|
+
const explicit = path.join(root, "explicit-codex-home");
|
|
136
|
+
const matrix: Array<{ label: string; env: Record<string, string | undefined> }> = [
|
|
137
|
+
{ label: "HOME only", env: {} },
|
|
138
|
+
{ label: "explicit CODEX_HOME", env: { CODEX_HOME: explicit } },
|
|
139
|
+
{ label: "whitespace CODEX_HOME falls back to HOME", env: { CODEX_HOME: " " } },
|
|
140
|
+
{ label: "CODEX_HOME with surrounding whitespace is trimmed", env: { CODEX_HOME: ` ${explicit} ` } },
|
|
141
|
+
{ label: "BOM-only CODEX_HOME is not a value and falls back to HOME", env: { CODEX_HOME: "\ufeff" } },
|
|
142
|
+
{ label: "a trailing slash is normalized away", env: { CODEX_HOME: `${explicit}/` } },
|
|
143
|
+
{ label: "a .. segment is normalized", env: { CODEX_HOME: `${explicit}/sub/..` } },
|
|
144
|
+
];
|
|
145
|
+
for (const cell of matrix) {
|
|
146
|
+
const r = launch([], cell.env);
|
|
147
|
+
const want = `unix://${resolveCodexDefaultSocketPath({
|
|
148
|
+
HOME: home,
|
|
149
|
+
CODEX_HOME: cell.env.CODEX_HOME,
|
|
150
|
+
})}`;
|
|
151
|
+
ok(
|
|
152
|
+
`[QK:CODEX-APP-SERVER-ADDRESS-MATCHES-PRODUCT-LEAF] ${cell.label}: the vendor is handed exactly the address resolveCodexDefaultSocketPath computes (want ${want}, got ${JSON.stringify(r.args)})`,
|
|
153
|
+
r.status === 0 && JSON.stringify(r.args) === JSON.stringify(["app-server", "--listen", want]),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
{
|
|
159
|
+
// Asking one authority for the address does not make the ANSWER safe to act on. The
|
|
160
|
+
// resolver returns `CODEX_HOME` faithfully, absolute or not, and this launcher is the one
|
|
161
|
+
// surface that CREATES a directory at that path and binds it. A relative address is a
|
|
162
|
+
// different file for every process that resolves it — and the bridge that will look for
|
|
163
|
+
// this socket is the app-server's MCP child, with its own cwd.
|
|
164
|
+
const cwdProbe = path.join(root, "relative-cwd");
|
|
165
|
+
mkdirSync(cwdProbe, { recursive: true });
|
|
166
|
+
const r = launch([], { CODEX_HOME: "relative-control-home" }, cwdProbe);
|
|
167
|
+
ok(
|
|
168
|
+
"[QK:CODEX-APP-SERVER-REFUSES-RELATIVE-SOCKET] a relative resolved address refuses before the vendor AND before anything is created on disk",
|
|
169
|
+
r.status !== 0 &&
|
|
170
|
+
r.out.includes("codex-app-server-socket-path-not-absolute") &&
|
|
171
|
+
r.args.length === 0 &&
|
|
172
|
+
!existsSync(path.join(cwdProbe, "relative-control-home")),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
{
|
|
176
|
+
// ABSOLUTE on purpose, so this cell is not shadowed by the one above: the two refusals
|
|
177
|
+
// are separate guards and a mutant that removes only this one must still go red.
|
|
178
|
+
const weird = path.join(root, "ctrl-home\nsecond-line");
|
|
179
|
+
const r = launch([], { CODEX_HOME: weird });
|
|
180
|
+
ok(
|
|
181
|
+
"[QK:CODEX-APP-SERVER-REFUSES-CONTROL-CHAR-SOCKET] an absolute address carrying a control character refuses rather than creating or binding it",
|
|
182
|
+
r.status !== 0 && r.out.includes("codex-app-server-socket-path-untrusted") && r.args.length === 0,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── 2. the subcommand, and the operator's argv after it ─────────────────────
|
|
187
|
+
{
|
|
188
|
+
const r = launch(["--config", "a=b", "", "two words"]);
|
|
189
|
+
const want = `unix://${resolveCodexDefaultSocketPath({ HOME: home })}`;
|
|
190
|
+
ok(
|
|
191
|
+
`[QK:CODEX-APP-SERVER-FORWARDS-OPERATOR-ARGV] operator arguments follow the injected address byte-identical, empty strings and spaces intact (got ${JSON.stringify(r.args)})`,
|
|
192
|
+
r.status === 0 &&
|
|
193
|
+
JSON.stringify(r.args) === JSON.stringify(["app-server", "--listen", want, "--config", "a=b", "", "two words"]),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
{
|
|
197
|
+
const r = launch([]);
|
|
198
|
+
ok(
|
|
199
|
+
"the dispatcher verb never reaches the vendor — a stray `codex-app-server` argument would arrive as a vendor subcommand",
|
|
200
|
+
!r.args.includes("codex-app-server"),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
{
|
|
204
|
+
// A second `--listen` is refused rather than appended. Two listen addresses let one
|
|
205
|
+
// win silently, and the silent winner is an endpoint no record points at.
|
|
206
|
+
const equals = launch(["--listen=unix:///tmp/mine.sock"]);
|
|
207
|
+
const spaced = launch(["--listen", "unix:///tmp/mine.sock"]);
|
|
208
|
+
ok(
|
|
209
|
+
"[QK:CODEX-APP-SERVER-REFUSES-SECOND-LISTEN] an operator --listen is a named refusal, in both spellings, and never reaches the vendor",
|
|
210
|
+
equals.status !== 0 &&
|
|
211
|
+
spaced.status !== 0 &&
|
|
212
|
+
equals.out.includes("codex-app-server-listen-override") &&
|
|
213
|
+
spaced.out.includes("codex-app-server-listen-override") &&
|
|
214
|
+
equals.args.length === 0 &&
|
|
215
|
+
spaced.args.length === 0,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── 3. exec, not fork ───────────────────────────────────────────────────────
|
|
220
|
+
{
|
|
221
|
+
// `exec` is what makes Ctrl-C, the exit status and the process identity the vendor's.
|
|
222
|
+
// A forking launcher would leave run.sh sitting between the operator and the server:
|
|
223
|
+
// signals would hit the wrapper, and the thing the operator thinks they killed would
|
|
224
|
+
// not be the thing that dies.
|
|
225
|
+
//
|
|
226
|
+
// The oracle is the vendor's PARENT, which is the only side of this that a single run
|
|
227
|
+
// can measure honestly. This gate spawns bash directly, so an unbroken exec chain
|
|
228
|
+
// (run.sh -> launcher -> vendor) leaves the vendor as THIS process's own child. Any
|
|
229
|
+
// fork anywhere along the way inserts a bash between them, and the reported parent
|
|
230
|
+
// stops being this gate. Comparing a pid across two separate runs would prove nothing.
|
|
231
|
+
const r = launch([]);
|
|
232
|
+
ok(
|
|
233
|
+
`[QK:CODEX-APP-SERVER-EXECS-NOT-FORKS] the vendor runs AS the launcher process, not as a child of it — its parent is this gate itself (ppid ${r.ppid}, gate ${process.pid})`,
|
|
234
|
+
r.status === 0 && r.pid !== "" && r.ppid === String(process.pid),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
{
|
|
238
|
+
const r = launch([], { FAKE_CODEX_EXIT: "37" });
|
|
239
|
+
ok(
|
|
240
|
+
"the vendor's exit status is the caller's exit status — an app-server that dies on a bad config must not read as a successful launch",
|
|
241
|
+
r.status === 37,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
{
|
|
245
|
+
const r = launch([], {}, home);
|
|
246
|
+
ok("the vendor inherits the caller's cwd — no subshell, no cd", r.status === 0 && r.cwd === home);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── 4. the socket is somebody else's until proven otherwise ─────────────────
|
|
250
|
+
{
|
|
251
|
+
// A LIVE socket is the case that matters: a second server would either lose the bind
|
|
252
|
+
// race or replace the endpoint every existing record points at. The fixture is a real
|
|
253
|
+
// listening AF_UNIX socket, so the launcher's own connect probe is what decides.
|
|
254
|
+
const liveHome = path.join(root, "live-home");
|
|
255
|
+
const liveSock = resolveCodexDefaultSocketPath({ CODEX_HOME: liveHome });
|
|
256
|
+
mkdirSync(path.dirname(liveSock), { recursive: true });
|
|
257
|
+
const server = net.createServer();
|
|
258
|
+
servers.push(server);
|
|
259
|
+
server.listen(liveSock);
|
|
260
|
+
const r = launch([], { CODEX_HOME: liveHome });
|
|
261
|
+
ok(
|
|
262
|
+
"[QK:CODEX-APP-SERVER-REFUSES-LIVE-SOCKET] a live control socket is a named refusal and the vendor is never reached",
|
|
263
|
+
r.status !== 0 && r.out.includes("codex-app-server-already-listening") && r.args.length === 0,
|
|
264
|
+
);
|
|
265
|
+
ok(
|
|
266
|
+
"with nothing on this host spelling that socket, the refusal SAYS so rather than naming a holder it cannot see",
|
|
267
|
+
r.out.includes("What /proc reports about it:") && r.out.includes("read, not inferred") && !/pid \d+:/.test(r.out),
|
|
268
|
+
);
|
|
269
|
+
server.close();
|
|
270
|
+
servers.pop();
|
|
271
|
+
}
|
|
272
|
+
{
|
|
273
|
+
// The other half, and the pair is what makes either one discriminating. The first
|
|
274
|
+
// version asserted "fallback text OR a pid line", which every run satisfied through the
|
|
275
|
+
// fallback branch — deleting the scan entirely would have passed it. So this cell puts a
|
|
276
|
+
// process on the host whose cmdline really does carry the socket path and requires the
|
|
277
|
+
// refusal to name THAT pid. A launcher that stopped reading /proc now goes red here, and
|
|
278
|
+
// a launcher that invented an owner goes red in the cell above.
|
|
279
|
+
const ownedHome = path.join(root, "owned-home");
|
|
280
|
+
const ownedSock = resolveCodexDefaultSocketPath({ CODEX_HOME: ownedHome });
|
|
281
|
+
mkdirSync(path.dirname(ownedSock), { recursive: true });
|
|
282
|
+
const server = net.createServer();
|
|
283
|
+
servers.push(server);
|
|
284
|
+
server.listen(ownedSock);
|
|
285
|
+
// A decoy whose ARGV carries the path. It does not hold the socket, and it must not: the
|
|
286
|
+
// launcher reports what `/proc/*/cmdline` says, which is a READING, and this cell pins
|
|
287
|
+
// exactly that reading rather than a claim about socket ownership the kernel never made.
|
|
288
|
+
const holder = spawn("python3", ["-c", "import time; time.sleep(120)", ownedSock], {
|
|
289
|
+
stdio: "ignore",
|
|
290
|
+
detached: false,
|
|
291
|
+
});
|
|
292
|
+
try {
|
|
293
|
+
const r = launch([], { CODEX_HOME: ownedHome });
|
|
294
|
+
ok(
|
|
295
|
+
`[QK:CODEX-APP-SERVER-READS-PROC-HOLDER] the refusal names the pid whose cmdline actually carries that socket (want pid ${holder.pid})`,
|
|
296
|
+
r.status !== 0 &&
|
|
297
|
+
r.out.includes("codex-app-server-already-listening") &&
|
|
298
|
+
r.out.includes(`pid ${holder.pid}:`) &&
|
|
299
|
+
!r.out.includes("read, not inferred"),
|
|
300
|
+
);
|
|
301
|
+
} finally {
|
|
302
|
+
holder.kill("SIGKILL");
|
|
303
|
+
}
|
|
304
|
+
server.close();
|
|
305
|
+
servers.pop();
|
|
306
|
+
}
|
|
307
|
+
{
|
|
308
|
+
// A DEAD socket file is the ordinary leftover of a hard kill: the file survives, the
|
|
309
|
+
// listener does not. The vendor replaces it, so this is a FACT LINE and not a refusal —
|
|
310
|
+
// and the separation matters, because folding it into the live case would make every
|
|
311
|
+
// crashed server a permanent block on restarting one. The fixture binds an AF_UNIX
|
|
312
|
+
// socket in a process that then exits without unlinking, which is exactly the on-disk
|
|
313
|
+
// state a killed app-server leaves.
|
|
314
|
+
const staleHome = path.join(root, "stale-home");
|
|
315
|
+
const staleSock = resolveCodexDefaultSocketPath({ CODEX_HOME: staleHome });
|
|
316
|
+
mkdirSync(path.dirname(staleSock), { recursive: true });
|
|
317
|
+
spawnSync("python3", ["-c", "import socket,sys;s=socket.socket(socket.AF_UNIX);s.bind(sys.argv[1])", staleSock]);
|
|
318
|
+
const r = launch([], { CODEX_HOME: staleHome });
|
|
319
|
+
ok(
|
|
320
|
+
`[QK:CODEX-APP-SERVER-LAUNCHES-OVER-DEAD-SOCKET] a socket file with no listener is reported and LAUNCHED over, not refused (status ${r.status})`,
|
|
321
|
+
r.status === 0 && r.out.includes("a dead control socket is already at") && r.args.length === 3,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
{
|
|
325
|
+
const fileHome = path.join(root, "file-home");
|
|
326
|
+
const fileSock = resolveCodexDefaultSocketPath({ CODEX_HOME: fileHome });
|
|
327
|
+
mkdirSync(path.dirname(fileSock), { recursive: true });
|
|
328
|
+
writeFileSync(fileSock, "");
|
|
329
|
+
const r = launch([], { CODEX_HOME: fileHome });
|
|
330
|
+
ok(
|
|
331
|
+
"a path that exists and is not a socket is indeterminate, not stale — the launcher refuses instead of clobbering something it cannot identify",
|
|
332
|
+
r.status !== 0 && r.out.includes("codex-app-server-socket-indeterminate") && r.args.length === 0,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
{
|
|
336
|
+
const linkHome = path.join(root, "link-home");
|
|
337
|
+
const linkSock = resolveCodexDefaultSocketPath({ CODEX_HOME: linkHome });
|
|
338
|
+
mkdirSync(path.dirname(linkSock), { recursive: true });
|
|
339
|
+
symlinkSync(path.join(root, "nowhere.sock"), linkSock);
|
|
340
|
+
const r = launch([], { CODEX_HOME: linkHome });
|
|
341
|
+
ok(
|
|
342
|
+
"[QK:CODEX-APP-SERVER-REFUSES-INDETERMINATE-SOCKET] a symlinked control socket is refused by name — the same classification the delivery rail's socket check uses",
|
|
343
|
+
r.status !== 0 && r.out.includes("codex-app-server-socket-indeterminate") && r.args.length === 0,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
{
|
|
347
|
+
// An absent socket is the normal first launch, and the control directory is created
|
|
348
|
+
// for it — that mkdir is what makes this ONE command instead of two.
|
|
349
|
+
const freshHome = path.join(root, "fresh-home");
|
|
350
|
+
const r = launch([], { CODEX_HOME: freshHome });
|
|
351
|
+
ok(
|
|
352
|
+
"a first launch on a host with no control directory creates it and reaches the vendor",
|
|
353
|
+
r.status === 0 && existsSync(path.dirname(resolveCodexDefaultSocketPath({ CODEX_HOME: freshHome }))),
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
{
|
|
358
|
+
// Hard Rule 15: an unrecognised reading is the one case where proceeding is unsafe,
|
|
359
|
+
// because every branch above is a decision about whether this launch would clobber a
|
|
360
|
+
// running server. The stimulus is a sandbox `python3` that exits 0 while printing
|
|
361
|
+
// something nobody wrote — the exact shape a silent fall-through needs.
|
|
362
|
+
const oddBin = path.join(root, "odd-probe-bin");
|
|
363
|
+
mkdirSync(oddBin, { recursive: true });
|
|
364
|
+
const oddPython = path.join(oddBin, "python3");
|
|
365
|
+
writeFileSync(oddPython, "#!/usr/bin/env bash\ncat >/dev/null\necho 'unexpected-probe-status'\nexit 0\n");
|
|
366
|
+
chmodSync(oddPython, 0o755);
|
|
367
|
+
const r = launch([], { PATH: `${oddBin}:${bin}:${process.env.PATH ?? ""}` });
|
|
368
|
+
ok(
|
|
369
|
+
"[QK:CODEX-APP-SERVER-REFUSES-UNRECOGNISED-PROBE] a socket classifier that exits 0 with a reading nobody wrote REFUSES instead of falling through to the exec",
|
|
370
|
+
r.status !== 0 && r.out.includes("codex-app-server-socket-probe-unrecognised") && r.args.length === 0,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── 5. refusals that keep this from becoming something it is not ────────────
|
|
375
|
+
{
|
|
376
|
+
const r = launch([], { PATH: pathWithoutVendor });
|
|
377
|
+
ok(
|
|
378
|
+
"no codex on PATH is a named refusal that names the repair, not a silent no-op",
|
|
379
|
+
r.status !== 0 && r.out.includes("no 'codex' executable found on PATH"),
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
{
|
|
383
|
+
const r = launch([], { ENTWURF_CODEX_APP_SERVER_ACTIVE: "1" });
|
|
384
|
+
ok(
|
|
385
|
+
"[QK:CODEX-APP-SERVER-REFUSES-RECURSION] an already-set launch sentinel refuses instead of spinning a launch loop",
|
|
386
|
+
r.status !== 0 && r.out.includes("recursive managed launch detected"),
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
{
|
|
390
|
+
// The self-exec fence, with the sentinel deliberately absent: a `codex` on PATH that
|
|
391
|
+
// resolves back to our own entrypoint is a loop the sentinel alone would not catch if
|
|
392
|
+
// it were ever stripped between hops.
|
|
393
|
+
const loopBin = path.join(root, "loop-bin");
|
|
394
|
+
mkdirSync(loopBin, { recursive: true });
|
|
395
|
+
symlinkSync(path.join(REPO, "scripts", "codex-app-server-launch.sh"), path.join(loopBin, "codex"));
|
|
396
|
+
const r = launch([], { PATH: `${loopBin}:${pathWithoutVendor}` });
|
|
397
|
+
ok(
|
|
398
|
+
"a PATH `codex` that resolves to entwurf's own launcher is refused as a launch loop",
|
|
399
|
+
r.status !== 0 && r.out.includes("launch loop"),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ── 6. the tmux line is a fact, and the identity carriers are not ───────────
|
|
404
|
+
{
|
|
405
|
+
const inside = launch([], { TMUX: "/tmp/tmux-1000/default,1234,0" });
|
|
406
|
+
const outside = launch([]);
|
|
407
|
+
ok(
|
|
408
|
+
"[QK:CODEX-APP-SERVER-REPORTS-TMUX-SEAT] the tmux seat is REPORTED in both directions and refuses neither — running outside tmux is an operator choice with a consequence, not an error",
|
|
409
|
+
inside.status === 0 &&
|
|
410
|
+
outside.status === 0 &&
|
|
411
|
+
inside.out.includes("/tmp/tmux-1000/default,1234,0") &&
|
|
412
|
+
outside.out.includes("(none — not inside tmux)") &&
|
|
413
|
+
outside.out.includes("caller-seat lookups"),
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
{
|
|
417
|
+
// The app-server is the parent of every bridge child, so a pi identity inherited here
|
|
418
|
+
// would be inherited by all of them. Both carriers go together: clearing one only
|
|
419
|
+
// changes the wording of a later failure while leaving a carrier for a partial reader.
|
|
420
|
+
const both = launch([], { PI_SESSION_ID: "pi-session-fixture", PI_AGENT_ID: "pi-agent-fixture" });
|
|
421
|
+
const onlySession = launch([], { PI_SESSION_ID: "pi-session-fixture" });
|
|
422
|
+
const onlyAgent = launch([], { PI_AGENT_ID: "pi-agent-fixture" });
|
|
423
|
+
ok(
|
|
424
|
+
`[QK:CODEX-APP-SERVER-STRIPS-IDENTITY-CARRIERS] neither PI_SESSION_ID nor PI_AGENT_ID survives into the server every bridge child inherits from (got "${both.piSession}"/"${both.piAgent}", "${onlySession.piSession}", "${onlyAgent.piAgent}")`,
|
|
425
|
+
both.piSession === "<unset>" &&
|
|
426
|
+
both.piAgent === "<unset>" &&
|
|
427
|
+
onlySession.piSession === "<unset>" &&
|
|
428
|
+
onlyAgent.piAgent === "<unset>",
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
{
|
|
432
|
+
// The strip is identity-only. A launch that also swallowed the operator's own
|
|
433
|
+
// environment would be a different, quieter defect.
|
|
434
|
+
const r = launch(["--config", "x=1"], { PI_SESSION_ID: "x", ENTWURF_FIXTURE_KEEP: "1" });
|
|
435
|
+
ok(
|
|
436
|
+
"the strip touches ONLY the two identity carriers — the operator's argv and unrelated environment survive it",
|
|
437
|
+
r.status === 0 && r.args.includes("--config") && r.args.includes("x=1") && r.out.includes("KEEP=1"),
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
console.log(`\n[check-codex-app-server-launch] PASS (${passed} assertions)`);
|
|
442
|
+
} finally {
|
|
443
|
+
for (const s of servers) s.close();
|
|
444
|
+
rmSync(root, { recursive: true, force: true });
|
|
445
|
+
}
|
|
@@ -27,6 +27,17 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync
|
|
|
27
27
|
import { tmpdir } from "node:os";
|
|
28
28
|
import * as path from "node:path";
|
|
29
29
|
import { fileURLToPath } from "node:url";
|
|
30
|
+
import {
|
|
31
|
+
appendSessionStartGroup,
|
|
32
|
+
canonicalJson,
|
|
33
|
+
certifySplice,
|
|
34
|
+
declarationDigest,
|
|
35
|
+
entwurfDeclarationGroup,
|
|
36
|
+
removeEntwurfDescription,
|
|
37
|
+
removeSessionStartGroup,
|
|
38
|
+
selectEntwurfDeclaration,
|
|
39
|
+
trustReceiptKey,
|
|
40
|
+
} from "../pi-extensions/lib/codex-declaration.js";
|
|
30
41
|
import { listAllMetaIdentitiesDir, type MetaIdentity } from "../pi-extensions/lib/meta-session.ts";
|
|
31
42
|
import { reclaimOnExit } from "./lib/reclaim-on-exit.ts";
|
|
32
43
|
|
|
@@ -123,6 +134,182 @@ ok(
|
|
|
123
134
|
);
|
|
124
135
|
ok("the handler timeout is 30", handler.timeout === 30);
|
|
125
136
|
|
|
137
|
+
// ── 2. the declaration leaf: what entwurf owns inside a file it SHARES (#117) ─
|
|
138
|
+
// The installer, the inverse, the doctor and the fresh preflight all decide with this leaf, so
|
|
139
|
+
// its two mechanisms are proven here once rather than four times through their shells. The
|
|
140
|
+
// mechanisms are: a NORMALIZED digest (blind to whatever a neighbour's serializer does) and a
|
|
141
|
+
// SPAN SPLICE (so a neighbour's bytes are copied through rather than rewritten).
|
|
142
|
+
{
|
|
143
|
+
const fakeLauncher = "/opt/entwurf/codex-birth-launch.sh";
|
|
144
|
+
const ourGroup = entwurfDeclarationGroup(fakeLauncher);
|
|
145
|
+
const herdrGroup = {
|
|
146
|
+
hooks: [{ type: "command", command: "bash '/home/op/.codex/herdr-agent-state.sh' session", timeout: 10 }],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
ok(
|
|
150
|
+
"[QK:CODEX-DECL-CANONICAL-KEY-ORDER] the canonical form sorts keys recursively, so two documents that differ only in key order digest identically",
|
|
151
|
+
canonicalJson({ b: 1, a: [{ y: 2, x: 3 }] }) === canonicalJson({ a: [{ x: 3, y: 2 }], b: 1 }),
|
|
152
|
+
);
|
|
153
|
+
ok(
|
|
154
|
+
"[QK:CODEX-DECL-DIGEST-BINDS-LAUNCHER] the digest is sensitive to the launcher path — the one string the vendor keys its trust receipt to",
|
|
155
|
+
declarationDigest(ourGroup) !== declarationDigest(entwurfDeclarationGroup(`${fakeLauncher}.bak`)),
|
|
156
|
+
);
|
|
157
|
+
ok(
|
|
158
|
+
"[QK:CODEX-DECL-DIGEST-BINDS-EVENT] the digest covers the EVENT as well as the group, so the same group under another event is another identity",
|
|
159
|
+
declarationDigest(ourGroup) !== declarationDigest(ourGroup, "SubagentStart"),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// SELECTION is by the launcher command, at whatever index that command happens to sit.
|
|
163
|
+
for (const [label, groups, wantIndex] of [
|
|
164
|
+
["alone", [ourGroup], 0],
|
|
165
|
+
["after a neighbour", [herdrGroup, ourGroup], 1],
|
|
166
|
+
["before a neighbour", [ourGroup, herdrGroup], 0],
|
|
167
|
+
["between neighbours", [herdrGroup, ourGroup, herdrGroup], 1],
|
|
168
|
+
] as const) {
|
|
169
|
+
const picked = selectEntwurfDeclaration({ hooks: { SessionStart: groups } }, fakeLauncher);
|
|
170
|
+
ok(
|
|
171
|
+
`[QK:CODEX-DECL-SELECT-BY-COMMAND] entwurf's declaration is selected by its command, ${label} (index ${wantIndex})`,
|
|
172
|
+
picked.ok && picked.groupIndex === wantIndex && picked.handlerIndex === 0,
|
|
173
|
+
);
|
|
174
|
+
ok(
|
|
175
|
+
`the trust key names that measured position, ${label}`,
|
|
176
|
+
picked.ok &&
|
|
177
|
+
trustReceiptKey("/h/hooks.json", picked.groupIndex, picked.handlerIndex) ===
|
|
178
|
+
`/h/hooks.json:session_start:${wantIndex}:0`,
|
|
179
|
+
);
|
|
180
|
+
ok(
|
|
181
|
+
`every other group is reported FOREIGN and none of ours is, ${label}`,
|
|
182
|
+
picked.ok && picked.foreign.length === groups.length - 1 && !picked.foreign.some((g) => g.index === wantIndex),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// THE HANDLER KEY SET — and this claim is a DIAGNOSIS contract, not an acceptance one. Say it
|
|
187
|
+
// plainly, because the QK token predates the narrowing and an unchanged name over a changed
|
|
188
|
+
// meaning is how a mutant stops testing what its claim says (sol, 2026-09-18). ACCEPTANCE is
|
|
189
|
+
// owned by the normalized digest: a handler carrying `async` digests differently, so the
|
|
190
|
+
// preflight refuses it whether or not this predicate exists, and the mutant below cannot make
|
|
191
|
+
// a drifted declaration green. What ONLY this predicate buys is the NAME — "the extra key is
|
|
192
|
+
// `async`, and every extra key changes the trust identity" — instead of an anonymous digest
|
|
193
|
+
// mismatch, and an operator repairs those two with different hands. `async: true` is the case
|
|
194
|
+
// that earns the name: it would let the turn proceed before the record exists.
|
|
195
|
+
{
|
|
196
|
+
const extra = selectEntwurfDeclaration(
|
|
197
|
+
{ hooks: { SessionStart: [{ hooks: [{ ...ourGroup.hooks[0], async: true }] }] } },
|
|
198
|
+
fakeLauncher,
|
|
199
|
+
);
|
|
200
|
+
ok(
|
|
201
|
+
"[QK:FRESHCALL-CODEX-HOOK-KEYS] a handler carrying `async` — or any key the installer never writes — is NAMED as a shape drift that quotes the extra key; acceptance is the digest's job either way, and what this claim owns is the diagnosis an operator reads",
|
|
202
|
+
!extra.ok &&
|
|
203
|
+
extra.code === "declaration-shape-drifted" &&
|
|
204
|
+
extra.detail.includes("exactly type+command+timeout") &&
|
|
205
|
+
extra.detail.includes("async"),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const [code, document] of [
|
|
210
|
+
["declaration-absent", { hooks: { SessionStart: [herdrGroup] } }],
|
|
211
|
+
["declaration-duplicated", { hooks: { SessionStart: [ourGroup, herdrGroup, ourGroup] } }],
|
|
212
|
+
["declaration-shape-drifted", { hooks: { SessionStart: [{ matcher: "startup", ...ourGroup }] } }],
|
|
213
|
+
["declaration-shape-drifted", { hooks: { SessionStart: [{ hooks: [{ ...ourGroup.hooks[0], async: true }] }] } }],
|
|
214
|
+
["declaration-shape-drifted", { hooks: { SessionStart: [{ hooks: [ourGroup.hooks[0], herdrGroup.hooks[0]] }] } }],
|
|
215
|
+
["hooks-unreadable", { hooks: { SessionStart: "not an array" } }],
|
|
216
|
+
] as const) {
|
|
217
|
+
const picked = selectEntwurfDeclaration(document, fakeLauncher);
|
|
218
|
+
ok(
|
|
219
|
+
`[QK:CODEX-DECL-NAMED-REFUSALS] ${code} is returned by name rather than as a silent miss (${JSON.stringify(document).slice(0, 60)}…)`,
|
|
220
|
+
!picked.ok && picked.code === code,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// THE SPLICE. What is asserted is not "the result parses" but "every byte a neighbour owns is
|
|
225
|
+
// literally still there", because a re-serialize would also parse. So the fixtures below are
|
|
226
|
+
// deliberately formatted the way NOTHING in this repo serializes — tabs, inline groups, the
|
|
227
|
+
// neighbour's keys in the order serde emits them — and the assertion is a literal substring.
|
|
228
|
+
// A fixture written with `JSON.stringify(_, null, 2)` would let a whole-document rewrite pass
|
|
229
|
+
// unnoticed, which is the one failure this mechanism exists to prevent.
|
|
230
|
+
const herdrLine = `\t\t\t{"hooks": [{"command": ${JSON.stringify(herdrGroup.hooks[0].command)}, "timeout": 10, "type": "command"}]}`;
|
|
231
|
+
const ourLine = `\t\t\t{"hooks": [{"type": "command", "command": "'${fakeLauncher}'", "timeout": 30}]}`;
|
|
232
|
+
const shared = [
|
|
233
|
+
"{",
|
|
234
|
+
'\t"description": "entwurf codex-birth 9.9.9 — prose we authored",',
|
|
235
|
+
'\t"hooks": {',
|
|
236
|
+
'\t\t"SessionStart": [',
|
|
237
|
+
`${ourLine},`,
|
|
238
|
+
herdrLine,
|
|
239
|
+
"\t\t]",
|
|
240
|
+
"\t}",
|
|
241
|
+
"}",
|
|
242
|
+
].join("\n");
|
|
243
|
+
ok(
|
|
244
|
+
"the shared fixture really does hold the neighbour's bytes verbatim, in formatting nothing here would reproduce",
|
|
245
|
+
shared.includes(herdrLine) && JSON.stringify(JSON.parse(shared), null, 2) !== shared,
|
|
246
|
+
);
|
|
247
|
+
const withoutOurs = removeEntwurfDescription(
|
|
248
|
+
certifySplice(removeSessionStartGroup(shared, 0), { ...JSON.parse(shared), hooks: { SessionStart: [herdrGroup] } }),
|
|
249
|
+
);
|
|
250
|
+
ok(
|
|
251
|
+
"[QK:CODEX-DECL-SPLICE-KEEPS-FOREIGN-BYTES] removing entwurf's group leaves the neighbour's bytes literally untouched",
|
|
252
|
+
withoutOurs.includes(herdrLine),
|
|
253
|
+
);
|
|
254
|
+
ok(
|
|
255
|
+
"removing entwurf's group also removes entwurf's own description and nothing else",
|
|
256
|
+
canonicalJson(JSON.parse(withoutOurs)) === canonicalJson({ hooks: { SessionStart: [herdrGroup] } }),
|
|
257
|
+
);
|
|
258
|
+
const foreignOnly = ["{", '\t"hooks": {', '\t\t"SessionStart": [', herdrLine, "\t\t]", "\t}", "}"].join("\n");
|
|
259
|
+
const rejoined = certifySplice(appendSessionStartGroup(foreignOnly, ourGroup), {
|
|
260
|
+
hooks: { SessionStart: [herdrGroup, ourGroup] },
|
|
261
|
+
});
|
|
262
|
+
ok(
|
|
263
|
+
"[QK:CODEX-DECL-SPLICE-APPENDS-LAST] appending entwurf's group leaves the neighbour at its own index — and therefore at its own trust receipt — with its bytes unchanged",
|
|
264
|
+
selectEntwurfDeclaration(JSON.parse(rejoined), fakeLauncher).groupIndex === 1 && rejoined.includes(herdrLine),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// THE POST-CONDITION IS THE SAFETY. The span reader is the only new way this unit can damage
|
|
268
|
+
// a file nobody asked it to touch, so no splice is ever trusted on the reader's word.
|
|
269
|
+
let refused = "";
|
|
270
|
+
try {
|
|
271
|
+
certifySplice(rejoined, { hooks: { SessionStart: [herdrGroup] } });
|
|
272
|
+
} catch (err) {
|
|
273
|
+
refused = err instanceof Error ? err.message : String(err);
|
|
274
|
+
}
|
|
275
|
+
ok(
|
|
276
|
+
"[QK:CODEX-DECL-SPLICE-CERTIFIED] a splice whose result is not the value the caller intended is REFUSED, never returned",
|
|
277
|
+
refused.includes("not the value this edit intended"),
|
|
278
|
+
);
|
|
279
|
+
refused = "";
|
|
280
|
+
try {
|
|
281
|
+
certifySplice("{not json", { hooks: {} });
|
|
282
|
+
} catch (err) {
|
|
283
|
+
refused = err instanceof Error ? err.message : String(err);
|
|
284
|
+
}
|
|
285
|
+
ok("a splice that does not parse is refused with its own reason", refused.includes("does not parse"));
|
|
286
|
+
|
|
287
|
+
// The span reader must find the SAME structure `JSON.parse` does, across the shapes a hooks
|
|
288
|
+
// file is actually written in. A disagreement here is the reader silently editing the wrong
|
|
289
|
+
// range, which is exactly what the post-condition above is guarding.
|
|
290
|
+
for (const [label, text] of [
|
|
291
|
+
["compact", JSON.stringify({ hooks: { SessionStart: [ourGroup, herdrGroup] } })],
|
|
292
|
+
["2-space", JSON.stringify({ hooks: { SessionStart: [ourGroup, herdrGroup] } }, null, 2)],
|
|
293
|
+
["tab", JSON.stringify({ hooks: { SessionStart: [ourGroup, herdrGroup] } }, null, "\t")],
|
|
294
|
+
[
|
|
295
|
+
"strings that contain braces and escaped quotes",
|
|
296
|
+
JSON.stringify({
|
|
297
|
+
description: 'a } b ] c \\" d',
|
|
298
|
+
hooks: { SessionStart: [ourGroup, { hooks: [{ type: "command", command: '] } "x"', timeout: 1 }] }] },
|
|
299
|
+
}),
|
|
300
|
+
],
|
|
301
|
+
] as const) {
|
|
302
|
+
const trimmed = certifySplice(removeSessionStartGroup(text, 0), {
|
|
303
|
+
...(JSON.parse(text) as Record<string, unknown>),
|
|
304
|
+
hooks: { SessionStart: [(JSON.parse(text) as { hooks: { SessionStart: unknown[] } }).hooks.SessionStart[1]] },
|
|
305
|
+
});
|
|
306
|
+
ok(
|
|
307
|
+
`[QK:CODEX-DECL-SPAN-READER-EXACT] the span reader agrees with JSON.parse on ${label} formatting`,
|
|
308
|
+
JSON.parse(trimmed) !== null,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
126
313
|
// ── 3. FIRE the installed launcher, the way codex fires it ──────────────────
|
|
127
314
|
const MEASURED = {
|
|
128
315
|
session_id: "01a08147-fbad-78d2-b266-e058f322125e",
|