@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.
- package/AGENTS.md +85 -190
- package/BASELINE.md +6 -3
- package/CHANGELOG.md +473 -14
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +332 -60
- package/README.md +100 -22
- package/VERIFY.md +93 -7
- package/docs/acp-backend-rail.md +0 -1
- package/docs/external-mcp-host.md +64 -33
- package/docs/setup-clean-host.md +151 -17
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +130 -114
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +519 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +22 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +30 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +7 -1
- 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/entwurf-v2-surface.js +7 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +20 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +227 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +189 -19
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +10 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +403 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-codex.js +325 -0
- package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +6 -1
- package/mcp/entwurf-bridge/src/index.ts +154 -116
- package/mcp/entwurf-bridge/tsconfig.build.json +10 -0
- package/package.json +13 -4
- package/pi-extensions/entwurf-control.ts +71 -19
- package/pi-extensions/lib/codex-caller-seat.ts +204 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +580 -0
- package/pi-extensions/lib/compaction-send-guard.ts +80 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +29 -3
- package/pi-extensions/lib/entwurf-peers-render.ts +4 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +6 -7
- package/pi-extensions/lib/entwurf-v2-native-push.ts +35 -18
- package/pi-extensions/lib/entwurf-v2-production.ts +10 -3
- package/pi-extensions/lib/entwurf-v2-runner.ts +3 -2
- package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
- package/pi-extensions/lib/entwurf-v2-surface.ts +7 -4
- package/pi-extensions/lib/meta-mailbox-body.ts +22 -13
- package/pi-extensions/lib/meta-sender-identity.ts +305 -0
- package/pi-extensions/lib/mux-fresh-call.ts +233 -29
- package/pi-extensions/lib/native-push/adapter.ts +21 -24
- package/pi-extensions/lib/native-push/codex-ws-client.ts +506 -0
- package/pi-extensions/lib/native-push/register.ts +7 -9
- package/pi-extensions/meta-bridge-hook-codex.ts +371 -0
- package/run.sh +342 -28
- package/scripts/check-agy-sender-identity.ts +1 -1
- package/scripts/check-codex-app-server-launch.ts +445 -0
- package/scripts/check-codex-birth-hook.ts +264 -0
- package/scripts/check-codex-bridge-identity.ts +179 -0
- package/scripts/check-codex-native-push.ts +386 -0
- package/scripts/check-codex-sender-identity.ts +495 -0
- package/scripts/check-compaction-send-guard.ts +130 -0
- package/scripts/check-copilot-receive-arm.ts +4 -1
- package/scripts/check-entwurf-fact-provider.ts +38 -0
- package/scripts/check-entwurf-peers-surface.ts +13 -1
- package/scripts/check-entwurf-self-address.ts +15 -16
- package/scripts/check-entwurf-v2-contract.ts +4 -3
- package/scripts/check-entwurf-v2-decider.ts +7 -5
- package/scripts/check-entwurf-v2-native-push.ts +35 -7
- package/scripts/check-entwurf-v2-production.ts +245 -12
- package/scripts/check-entwurf-v2-runner.ts +1 -1
- package/scripts/check-entwurf-v2-send.ts +26 -7
- package/scripts/check-entwurf-v2-surface.ts +1 -1
- package/scripts/check-gate-qualification.ts +8 -3
- package/scripts/check-harness-admission-parity.ts +0 -1
- package/scripts/check-mux-launch-tmux.ts +345 -4
- package/scripts/check-native-push-adapter.ts +20 -16
- package/scripts/check-native-push-register.ts +5 -1
- package/scripts/check-release-gate-outcomes.ts +47 -1
- package/scripts/check-setup-qualification.sh +3 -1
- package/scripts/codex-app-server-launch.sh +275 -0
- package/scripts/codex-birth-doctor.sh +276 -0
- package/scripts/codex-birth-install.sh +414 -0
- package/scripts/codex-birth-uninstall.sh +170 -0
- package/scripts/codex-mcp-config.py +435 -0
- package/scripts/codex-socket-path.ts +33 -0
- package/scripts/codex-statusline-config.py +434 -0
- package/scripts/codex-terminal-title-config.py +500 -0
- package/scripts/codex_toml_io.py +653 -0
- package/scripts/lib/codex-fresh-live-protocol.ts +121 -0
- package/scripts/lib/codex-fresh-source-receipts.ts +426 -0
- package/scripts/lib/launch-receipt-windows.ts +46 -0
- package/scripts/lib/tmux-coordinate-row.ts +58 -0
- package/scripts/meta-bridge-fresh-cut.ts +6 -1
- package/scripts/mutants/codex-app-server-launch.json +157 -0
- package/scripts/mutants/codex-caller-seat.json +336 -0
- package/scripts/mutants/codex-native.json +838 -0
- package/scripts/mutants/compaction-send-guard.json +103 -0
- package/scripts/mutants/entwurf-peers.json +19 -0
- package/scripts/mutants/mux-fresh-call.json +93 -10
- package/scripts/mutants/omp-fresh.json +6 -4
- package/scripts/mutants/release-gate.json +13 -0
- package/scripts/mutants/v2-surface.json +75 -1
- package/scripts/raw-async-delivery/README.md +2 -1
- package/scripts/raw-codex-measure/README.md +114 -46
- package/scripts/smoke-agy-native-push-live.ts +3 -1
- package/scripts/smoke-codex-birth.sh +347 -0
- package/scripts/smoke-codex-config-state.sh +700 -0
- package/scripts/smoke-codex-fresh-live.ts +1426 -0
- package/scripts/smoke-codex-native-push-live.ts +75 -0
- package/scripts/smoke-entwurf-chain-live.ts +50 -0
- package/scripts/smoke-setup-verdict.sh +125 -10
- package/scripts/tsconfig.json +1 -0
|
@@ -466,7 +466,7 @@ try {
|
|
|
466
466
|
"probe alive → replyable true",
|
|
467
467
|
(await probeNativeSenderAlive(
|
|
468
468
|
identity,
|
|
469
|
-
adapterWith(async () => ({ status: "alive", route: { lsAddress: "127.0.0.1:1" } })),
|
|
469
|
+
adapterWith(async () => ({ status: "alive", route: { backend: "antigravity", lsAddress: "127.0.0.1:1" } })),
|
|
470
470
|
)) === true,
|
|
471
471
|
);
|
|
472
472
|
|
|
@@ -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
|
+
}
|