@junghanacs/entwurf 0.35.1 → 0.36.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 (42) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +209 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/DELIVERY.md +15 -0
  5. package/FAQ.md +3 -3
  6. package/README.md +30 -16
  7. package/VERIFY.md +9 -7
  8. package/demo/README.md +1 -1
  9. package/docs/acp-backend-rail.md +1 -1
  10. package/docs/setup-clean-host.md +30 -22
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +4 -3
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +99 -18
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +3 -3
  14. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +59 -14
  15. package/mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js +44 -0
  16. package/mcp/entwurf-bridge/tsconfig.build.json +5 -0
  17. package/package.json +19 -14
  18. package/pi/pi-durable/bootstrap.mjs +12 -8
  19. package/pi/pi-durable/carrier/runtime.js +52 -2
  20. package/pi/pi-durable/carrier/subagent.js +6 -1
  21. package/pi/pi-durable/carrier/tui.js +31 -4
  22. package/pi/pi-durable/carrier-relocation.mjs +2 -2
  23. package/pi/pi-durable/overlay/upstream-pin.json +79 -36
  24. package/pi-extensions/lib/codex-fresh-preflight.ts +4 -3
  25. package/pi-extensions/lib/native-push/codex-ws-client.ts +129 -18
  26. package/pi-extensions/lib/pi-durable-runtime.ts +4 -4
  27. package/pi-extensions/meta-bridge-pi-durable.ts +77 -21
  28. package/run.sh +112 -38
  29. package/scripts/check-codex-app-server-launch.ts +55 -12
  30. package/scripts/check-codex-native-push.ts +231 -12
  31. package/scripts/check-gate-qualification.ts +3 -3
  32. package/scripts/codex-app-server-launch.sh +54 -29
  33. package/scripts/codex-socket-classify.ts +46 -0
  34. package/scripts/emit-pi-durable-carrier.ts +159 -168
  35. package/scripts/mutants/codex-app-server-launch.json +39 -12
  36. package/scripts/mutants/codex-native.json +15 -0
  37. package/scripts/mutants/control-send-receipt.json +1 -1
  38. package/scripts/mutants/pack-install.json +3 -3
  39. package/scripts/mutants/pi-durable.json +165 -16
  40. package/scripts/mutants/pi-floor.json +5 -5
  41. package/scripts/pi-durable-contact-host.mjs +1 -1
  42. package/pi/pi-durable/overlay/runtime-contacts.patch +0 -283
@@ -181,18 +181,219 @@ assert.equal(
181
181
  "[QK:CODEX-NATIVE-FIXED-SOCKET] CODEX_HOME owns the vendor default socket root",
182
182
  );
183
183
 
184
- const temp = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-codex-native-"));
184
+ // ── the control endpoint: a plain socket, or Codex's EXACT rendezvous alias (#141) ──────────
185
+ // From Codex 0.162.1 the advertised path is a symlink to a 0600 socket the vendor binds under
186
+ // `realpath(/tmp)/codex-daemon-<euid>/`, named by the sha256 hex of the canonical advertised
187
+ // parent + "/" + basename (`[source]` rust-v0.162.1 `app-server-transport/src/transport/
188
+ // unix_socket.rs:290-304`, `uds/src/daemon_directory.rs:13-38`). The expected target below is
189
+ // derived HERE from that upstream rule, not read back from the product, and every cell builds
190
+ // its own sandbox socket directory: `daemonRoot`/`euid` point the product at it, so the real
191
+ // `/tmp/codex-daemon-<euid>` is never touched. Each refusal cell keeps every OTHER hop valid,
192
+ // so it fails on its own rule and not on a neighbour's. A real other-uid owner needs root and
193
+ // stays unmeasured; so does macOS, where `/tmp` canonicalizes to `/private/tmp`.
194
+ //
195
+ // The root and the socket directory names are SHORT on purpose: a bound socket path must fit
196
+ // sun_path (108 bytes on Linux), and a 64-hex name under a long sandbox root does not — listen
197
+ // fails with EINVAL. That limit is why the vendor keeps its own root at `/tmp/codex-daemon-<euid>`.
198
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-cnp-"));
199
+ const euid = process.geteuid?.() ?? -1;
200
+ const sandboxServers: net.Server[] = [];
201
+ // A tree whose canonical path is longer than PATH_MAX can only be removed through the short
202
+ // symlinked path that reaches it, so it is removed first, before the root.
203
+ const overlongTrees: string[] = [];
185
204
  try {
186
205
  const missing = checkCodexSocketFile(path.join(temp, "missing.sock"));
187
- assert.deepEqual(missing.ok ? null : missing.status, "dead", "missing socket is a dead app-server");
206
+ assert.deepEqual(
207
+ missing.ok ? null : [missing.status, missing.status === "dead" ? missing.layout : null],
208
+ ["dead", "absent"],
209
+ "missing socket is a dead app-server, and says it is the absent layout",
210
+ );
188
211
  const regular = path.join(temp, "regular");
189
212
  fs.writeFileSync(regular, "not a socket");
190
213
  const wrongType = checkCodexSocketFile(regular);
191
214
  assert.deepEqual(wrongType.ok ? null : wrongType.status, "indeterminate", "wrong path type never reads dead");
192
- const link = path.join(temp, "link");
193
- fs.symlinkSync(regular, link);
194
- assert.match(JSON.stringify(checkCodexSocketFile(link)), /symlink/, "symlinked socket is refused by name");
215
+
216
+ const listen = async (socketPath: string, mode: number): Promise<void> => {
217
+ const server = net.createServer();
218
+ sandboxServers.push(server);
219
+ await new Promise<void>((resolve, reject) => {
220
+ server.once("error", reject);
221
+ server.listen(socketPath, () => resolve());
222
+ });
223
+ fs.chmodSync(socketPath, mode);
224
+ };
225
+ const layout = (name: string): { caseRoot: string; advertised: string; daemonRoot: string; expected: string } => {
226
+ const caseRoot = path.join(temp, name);
227
+ const parent = path.join(caseRoot, "home", "app-server-control");
228
+ fs.mkdirSync(parent, { recursive: true });
229
+ const daemonRoot = path.join(caseRoot, "d");
230
+ fs.mkdirSync(daemonRoot);
231
+ fs.chmodSync(daemonRoot, 0o700);
232
+ const hash = crypto
233
+ .createHash("sha256")
234
+ .update(`${fs.realpathSync(parent)}/app-server-control.sock`)
235
+ .digest("hex");
236
+ return {
237
+ caseRoot,
238
+ advertised: path.join(parent, "app-server-control.sock"),
239
+ daemonRoot,
240
+ expected: path.join(daemonRoot, hash),
241
+ };
242
+ };
243
+ const classify = (advertised: string, daemonRoot: string, over: { euid?: number } = {}) =>
244
+ checkCodexSocketFile(advertised, { daemonRoot, euid, ...over });
245
+ const statusOf = (check: ReturnType<typeof checkCodexSocketFile>): string => (check.ok ? "ok" : check.status);
246
+
247
+ const plainDir = path.join(temp, "plain");
248
+ fs.mkdirSync(plainDir);
249
+ const plain = path.join(plainDir, "app-server-control.sock");
250
+ await listen(plain, 0o600);
251
+ assert.deepEqual(
252
+ classify(plain, path.join(temp, "no-daemon-dir")),
253
+ { ok: true, connectPath: plain },
254
+ "(A) a plain socket owned by the effective uid is the endpoint itself — the <=0.153.4 layout is unchanged",
255
+ );
256
+
257
+ const exact = layout("exact");
258
+ await listen(exact.expected, 0o600);
259
+ fs.symlinkSync(exact.expected, exact.advertised);
260
+ assert.deepEqual(
261
+ classify(exact.advertised, exact.daemonRoot),
262
+ { ok: true, connectPath: exact.expected },
263
+ "(B) Codex's exact rendezvous alias is accepted, and the endpoint is the verified physical socket, not the alias",
264
+ );
265
+
266
+ const wrongHash = layout("wrong-hash");
267
+ const elsewhere = path.join(wrongHash.daemonRoot, "0".repeat(64));
268
+ await listen(elsewhere, 0o600);
269
+ fs.symlinkSync(elsewhere, wrongHash.advertised);
270
+ const wrongHashCheck = classify(wrongHash.advertised, wrongHash.daemonRoot);
271
+ assert.equal(
272
+ statusOf(wrongHashCheck),
273
+ "indeterminate",
274
+ "[QK:CODEX-SOCKET-ALIAS-EXACT] an alias into Codex's own socket directory whose name is not the hash of THIS advertised path is refused — a valid 0600 socket in the right 0700 directory is not enough",
275
+ );
276
+ assert.match(JSON.stringify(wrongHashCheck), /not Codex's rendezvous alias/, "that refusal says why, by name");
277
+
278
+ const relative = layout("relative");
279
+ await listen(relative.expected, 0o600);
280
+ fs.symlinkSync(path.relative(path.dirname(relative.advertised), relative.expected), relative.advertised);
281
+ assert.equal(
282
+ statusOf(classify(relative.advertised, relative.daemonRoot)),
283
+ "indeterminate",
284
+ "a relative alias target is refused even when it resolves to the expected socket",
285
+ );
286
+
287
+ const chain = layout("chain");
288
+ await listen(chain.expected, 0o600);
289
+ const hop = path.join(chain.caseRoot, "hop.sock");
290
+ fs.symlinkSync(chain.expected, hop);
291
+ fs.symlinkSync(hop, chain.advertised);
292
+ assert.equal(
293
+ statusOf(classify(chain.advertised, chain.daemonRoot)),
294
+ "indeterminate",
295
+ "an alias that reaches the expected socket through a second link is refused; only the vendor's one hop is accepted",
296
+ );
297
+
298
+ const dangling = layout("dangling");
299
+ fs.symlinkSync(dangling.expected, dangling.advertised);
300
+ const danglingCheck = classify(dangling.advertised, dangling.daemonRoot);
301
+ assert.deepEqual(
302
+ danglingCheck.ok ? null : [danglingCheck.status, danglingCheck.status === "dead" ? danglingCheck.layout : null],
303
+ ["dead", "dangling-alias"],
304
+ "the exact alias with its socket gone is dead, named as the dangling alias the vendor itself removes on start",
305
+ );
306
+
307
+ const openDir = layout("dir-0755");
308
+ await listen(openDir.expected, 0o600);
309
+ fs.symlinkSync(openDir.expected, openDir.advertised);
310
+ fs.chmodSync(openDir.daemonRoot, 0o755);
311
+ assert.equal(
312
+ statusOf(classify(openDir.advertised, openDir.daemonRoot)),
313
+ "indeterminate",
314
+ "a socket directory other users can enter is refused — the vendor's own rule is mode & 0o777 == 0o700",
315
+ );
316
+
317
+ const stickyDir = layout("dir-1700");
318
+ await listen(stickyDir.expected, 0o600);
319
+ fs.symlinkSync(stickyDir.expected, stickyDir.advertised);
320
+ fs.chmodSync(stickyDir.daemonRoot, 0o1700);
321
+ assert.deepEqual(
322
+ classify(stickyDir.advertised, stickyDir.daemonRoot),
323
+ { ok: true, connectPath: stickyDir.expected },
324
+ "a sticky 01700 socket directory is accepted: the vendor masks with 0o777, so refusing it would reject a directory Codex itself uses",
325
+ );
326
+
327
+ const linkedDir = layout("dir-symlink");
328
+ await listen(linkedDir.expected, 0o600);
329
+ fs.symlinkSync(linkedDir.expected, linkedDir.advertised);
330
+ const realDir = path.join(linkedDir.caseRoot, "real-daemon-dir");
331
+ fs.renameSync(linkedDir.daemonRoot, realDir);
332
+ fs.symlinkSync(realDir, linkedDir.daemonRoot);
333
+ assert.equal(
334
+ statusOf(classify(linkedDir.advertised, linkedDir.daemonRoot)),
335
+ "indeterminate",
336
+ "a socket directory that is itself a symlink is refused, as the vendor refuses it",
337
+ );
338
+
339
+ const fileTarget = layout("physical-file");
340
+ fs.writeFileSync(fileTarget.expected, "", { mode: 0o600 });
341
+ fs.symlinkSync(fileTarget.expected, fileTarget.advertised);
342
+ assert.equal(
343
+ statusOf(classify(fileTarget.advertised, fileTarget.daemonRoot)),
344
+ "indeterminate",
345
+ "an exact alias whose target is a regular file is refused, never connected to",
346
+ );
347
+
348
+ const readable = layout("physical-0644");
349
+ await listen(readable.expected, 0o644);
350
+ fs.symlinkSync(readable.expected, readable.advertised);
351
+ assert.equal(
352
+ statusOf(classify(readable.advertised, readable.daemonRoot)),
353
+ "indeterminate",
354
+ "an exact alias whose socket group or other users can open is refused",
355
+ );
356
+
357
+ assert.deepEqual(
358
+ [
359
+ statusOf(classify(plain, path.join(temp, "no-daemon-dir"), { euid: euid + 1 })),
360
+ statusOf(classify(exact.advertised, exact.daemonRoot, { euid: euid + 1 })),
361
+ ],
362
+ ["indeterminate", "indeterminate"],
363
+ "ownership is compared with the EFFECTIVE uid (the vendor's geteuid): under another euid both layouts are refused",
364
+ );
365
+
366
+ // canonicalize(parent) failure is the alias state lstat cannot see coming. The advertised
367
+ // path is short, but it reaches its parent through a symlink, and the parent's canonical
368
+ // path is longer than PATH_MAX — the vendor's own `canonicalize` fails on it the same way.
369
+ // It must be indeterminate: never dead, and never a throw the launcher would read as an
370
+ // unrecognised status.
371
+ const canonicalCase = path.join(temp, "canonical");
372
+ let far = canonicalCase;
373
+ while (far.length < 3600) far = path.join(far, "d".repeat(200));
374
+ fs.mkdirSync(far, { recursive: true });
375
+ const via = path.join(canonicalCase, "via");
376
+ fs.symlinkSync(far, via);
377
+ overlongTrees.push(path.join(via, "e".repeat(200)));
378
+ let overlongParent = via;
379
+ for (let i = 0; i < 4; i += 1) overlongParent = path.join(overlongParent, "e".repeat(200));
380
+ fs.mkdirSync(overlongParent, { recursive: true });
381
+ const overlongAdvertised = path.join(overlongParent, "app-server-control.sock");
382
+ fs.symlinkSync(exact.expected, overlongAdvertised);
383
+ const overlongCheck = classify(overlongAdvertised, exact.daemonRoot);
384
+ assert.equal(
385
+ statusOf(overlongCheck),
386
+ "indeterminate",
387
+ "an alias whose parent cannot be canonicalized is indeterminate",
388
+ );
389
+ assert.match(
390
+ JSON.stringify(overlongCheck),
391
+ /could not verify/,
392
+ "that refusal names the verification it could not do",
393
+ );
195
394
  } finally {
395
+ for (const server of sandboxServers) server.close();
396
+ for (const tree of overlongTrees) fs.rmSync(tree, { recursive: true, force: true });
196
397
  fs.rmSync(temp, { recursive: true, force: true });
197
398
  }
198
399
 
@@ -201,15 +402,33 @@ const firstPage = new FakeProtocol([
201
402
  { data: ["other"], nextCursor: "next" },
202
403
  { data: ["target"], nextCursor: null },
203
404
  ]);
405
+ // The check is asked about the advertised default path, and everything after it — the probe's
406
+ // connect AND the route the queue send uses — goes to the endpoint the check returned. Here
407
+ // the two are different strings, so a probe that went back to the advertised path goes red.
408
+ let checkedPath = "";
409
+ let openedPath = "";
204
410
  const alive = await probeCodexThread("target", {
205
411
  env: { CODEX_HOME: "/fixed" },
206
- checkSocket: () => ({ ok: true }),
207
- openProtocol: opener(firstPage),
412
+ checkSocket: (advertised) => {
413
+ checkedPath = advertised;
414
+ return { ok: true, connectPath: "/verified/physical.sock" };
415
+ },
416
+ openProtocol: {
417
+ open: async (socketPath) => {
418
+ openedPath = socketPath;
419
+ return firstPage;
420
+ },
421
+ },
208
422
  });
209
423
  assert.deepEqual(
210
424
  alive,
211
- { status: "alive", route: { backend: "codex", socketPath: "/fixed/app-server-control/app-server-control.sock" } },
212
- "[QK:CODEX-NATIVE-LOADED-PROBE] full loaded-thread pagination proves the volatile route",
425
+ { status: "alive", route: { backend: "codex", socketPath: "/verified/physical.sock" } },
426
+ "[QK:CODEX-NATIVE-LOADED-PROBE] full loaded-thread pagination proves the volatile route, on the endpoint the socket check verified",
427
+ );
428
+ assert.deepEqual(
429
+ [checkedPath, openedPath],
430
+ ["/fixed/app-server-control/app-server-control.sock", "/verified/physical.sock"],
431
+ "the socket check reads the advertised default path; the probe connects to the endpoint it verified, never the alias again",
213
432
  );
214
433
  assert.equal(firstPage.closed, true, "successful probe closes its connection");
215
434
  assert.deepEqual(
@@ -221,7 +440,7 @@ assert.deepEqual(firstPage.calls[3]?.params, { cursor: "next" }, "pagination car
221
440
  const absentProtocol = new FakeProtocol([{}, { data: [], nextCursor: null }]);
222
441
  const absent = await probeCodexThread("target", {
223
442
  env: { CODEX_HOME: "/fixed" },
224
- checkSocket: () => ({ ok: true }),
443
+ checkSocket: (advertised) => ({ ok: true, connectPath: advertised }),
225
444
  openProtocol: opener(absentProtocol),
226
445
  });
227
446
  assert.equal(absent.status, "dead", "a clean loaded inventory without the target is not delivery-capable");
@@ -230,7 +449,7 @@ assert.equal(absentProtocol.closed, true, "negative probe closes its connection"
230
449
  const malformedProtocol = new FakeProtocol([{}, { data: [{ id: "wrong-shape" }], nextCursor: null }]);
231
450
  const malformed = await probeCodexThread("target", {
232
451
  env: { CODEX_HOME: "/fixed" },
233
- checkSocket: () => ({ ok: true }),
452
+ checkSocket: (advertised) => ({ ok: true, connectPath: advertised }),
234
453
  openProtocol: opener(malformedProtocol),
235
454
  });
236
455
  assert.equal(malformed.status, "indeterminate", "a malformed inventory is unknown, never dead");
@@ -374,7 +593,7 @@ assert.deepEqual(
374
593
 
375
594
  const adapter = createCodexNativePushAdapter({
376
595
  env: { CODEX_HOME: "/fixed" },
377
- checkSocket: () => ({ ok: true }),
596
+ checkSocket: (advertised) => ({ ok: true, connectPath: advertised }),
378
597
  openProtocol: opener(new FakeProtocol([{}, { data: ["target"], nextCursor: null }])),
379
598
  runner: queueRunner,
380
599
  });
@@ -3450,10 +3450,10 @@ let manifestCount: number;
3450
3450
  "bridge-boot-resume": 5,
3451
3451
  "bridge-command-boot": 12,
3452
3452
  "capability-cache": 3,
3453
- "codex-app-server-launch": 9,
3453
+ "codex-app-server-launch": 10,
3454
3454
  "codex-caller-seat": 28,
3455
3455
  "control-send-receipt": 19,
3456
- "codex-native": 82,
3456
+ "codex-native": 83,
3457
3457
  "compaction-send-guard": 7,
3458
3458
  "control-socket-disconnect": 4,
3459
3459
  "demo-reproducibility": 12,
@@ -3464,7 +3464,7 @@ let manifestCount: number;
3464
3464
  "pi-bridge-sender": 4,
3465
3465
  "pi-mcp-bridge": 2,
3466
3466
  "pi-mcp-register": 3,
3467
- "pi-durable": 65,
3467
+ "pi-durable": 78,
3468
3468
  "copilot-receive": 20,
3469
3469
  "entwurf-peers": 1,
3470
3470
  "fresh-call-dispatch": 12,
@@ -156,38 +156,53 @@ done
156
156
  # --- is somebody already listening there? ----------------------------------
157
157
  # Three outcomes, and they are NOT the same repair. A live socket means a second server
158
158
  # would either lose the bind race or silently replace the endpoint every citizen's record
159
- # already points at — refuse and name the holder. An indeterminate path (symlink, not a
160
- # socket, owned by another uid) is the classification `checkCodexSocketFile` already uses in
161
- # the product, and a launcher that guessed past it would be clobbering something it cannot
162
- # identify. A dead socket file is the ordinary leftover of a Ctrl-C, and the vendor replaces
163
- # it — that one is a fact line, not a refusal.
164
- probe="$(python3 - "$SOCK" <<'PY'
165
- import os, socket, stat, sys
166
-
167
- path = sys.argv[1]
168
- try:
169
- st = os.lstat(path)
170
- except FileNotFoundError:
171
- print("absent")
172
- raise SystemExit(0)
173
- except OSError as exc:
174
- print(f"indeterminate\t{exc.strerror or exc}")
175
- raise SystemExit(0)
176
-
177
- if stat.S_ISLNK(st.st_mode):
178
- print("indeterminate\tthe path is a symlink")
179
- raise SystemExit(0)
180
- if not stat.S_ISSOCK(st.st_mode):
181
- print("indeterminate\tthe path exists and is not a socket")
182
- raise SystemExit(0)
183
- if st.st_uid != os.getuid():
184
- print(f"indeterminate\tthe socket is owned by uid {st.st_uid}, not {os.getuid()}")
185
- raise SystemExit(0)
159
+ # already points at — refuse and name the holder. An indeterminate path (a symlink that is not
160
+ # Codex's own alias, not a socket, owned by another uid) is refused, because a launcher that
161
+ # guessed past it would be clobbering something it cannot identify. A dead socket file is the
162
+ # ordinary leftover of a Ctrl-C, and the vendor replaces it — that one is a fact line, not a
163
+ # refusal. So is a dangling rendezvous alias: from Codex 0.162.1 the advertised path is a
164
+ # symlink into the vendor's own socket directory, and the vendor removes exactly that alias
165
+ # when it starts.
166
+ #
167
+ # WHAT A LAUNCH IS ALLOWED TO TRUST is decided ONCE, by the classifier delivery and preflight
168
+ # use (`checkCodexSocketFile`). This launcher keeps no copy of it: telling Codex's exact alias
169
+ # from any other symlink takes a hash and an exact readlink comparison, and a second
170
+ # implementation here would be the transcription `codex-socket-path` already measured drifting.
171
+ # It runs that classifier's COMPILED twin directly with `node`, in a checkout and in an installed
172
+ # package alike: `build-bridge` emits it (`pnpm install` runs it through `prepare`, and the package
173
+ # ships it through `prepack`), so a missing file is an incomplete installation, refused by name —
174
+ # never a reason to fall back to the TypeScript source. The Python below only CONNECTS to the
175
+ # endpoint the classifier verified, to tell a live server from a stale socket file. An answer the
176
+ # classifier never gives is refused right here, before it can be read as one of the probe's own
177
+ # verdicts.
178
+ #
179
+ # The gate's limit, stated rather than hidden: it executes this launcher on non-alias layouts
180
+ # and on a non-exact symlink, so it proves the launcher accepts no more than the classifier.
181
+ # The exact-alias accept path is proven on `checkCodexSocketFile` itself, with a sandbox socket
182
+ # directory. This launcher's own alias branch is NOT exercised by an existing-server LIVE
183
+ # diagnostic; that launch/refusal path needs its own explicitly authorised LIVE observation.
184
+ CLASSIFIER="$REPO_DIR/mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js"
185
+ [ -f "$CLASSIFIER" ] \
186
+ || fail "codex-app-server-classifier-missing: the compiled socket classifier is not at
187
+ $CLASSIFIER
188
+ This launcher runs it directly and keeps no other copy of it. Reinstall @junghanacs/entwurf (the
189
+ package ships it through prepack build-bridge), or run 'pnpm run build-bridge' in a source checkout."
190
+ if ! classified="$(node "$CLASSIFIER")"; then
191
+ fail "could not classify the control socket at $SOCK.
192
+ 'node $CLASSIFIER' failed; it is the only classification of this socket,
193
+ and this launcher will not guess past it. Its output above says why."
194
+ fi
195
+ classified_word="${classified%% *}"
196
+ classified_detail="${classified#* }"
197
+ case "$classified_word" in
198
+ socket)
199
+ probe="$(python3 - "$classified_detail" <<'PY'
200
+ import socket, sys
186
201
 
187
202
  probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
188
203
  probe.settimeout(2)
189
204
  try:
190
- probe.connect(path)
205
+ probe.connect(sys.argv[1])
191
206
  except (ConnectionRefusedError, FileNotFoundError):
192
207
  print("stale")
193
208
  except OSError as exc:
@@ -197,7 +212,17 @@ else:
197
212
  finally:
198
213
  probe.close()
199
214
  PY
200
- )" || fail "could not classify the control socket at $SOCK (python3 is required by this launcher)."
215
+ )" || fail "could not probe the control socket at $classified_detail (python3 is required by this launcher)."
216
+ ;;
217
+ absent) probe="absent" ;;
218
+ dangling) probe="stale $classified_detail" ;;
219
+ indeterminate) probe="indeterminate $classified_detail" ;;
220
+ *)
221
+ fail "codex-app-server-socket-probe-unrecognised: the socket classifier answered
222
+ '$classified_word', which is not one of socket/absent/dangling/indeterminate. Refusing to launch on a
223
+ reading nothing understands."
224
+ ;;
225
+ esac
201
226
 
202
227
  probe_status="${probe%% *}"
203
228
  probe_reason="${probe#* }"
@@ -0,0 +1,46 @@
1
+ /**
2
+ * codex-socket-classify — classify the Codex app-server default control socket for THIS
3
+ * environment, through the one product classifier, and print the answer for a shell.
4
+ *
5
+ * WHY A LEAF. `entwurf codex-app-server` is a bash launcher, and it has to decide whether a
6
+ * launch would clobber something. From Codex 0.162.1 the advertised socket path is a
7
+ * rendezvous alias into a vendor-owned directory, and telling the vendor's exact alias from any
8
+ * other symlink takes a hash, an exact readlink comparison and ownership/mode checks. Doing that
9
+ * in the launcher's Python would be a second implementation of a security classification, and
10
+ * `codex-socket-path.ts` records what a second spelling of this socket did once (it diverged).
11
+ * So the launcher asks `checkCodexSocketFile`, the classifier delivery and preflight use, and
12
+ * only connects to what it was told.
13
+ *
14
+ * HOW IT IS REACHED. The launcher runs this file's COMPILED twin,
15
+ * `mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js`, directly with `node` — in a checkout
16
+ * and in an installed package alike. There is no `run.sh` verb and no `run_ts` crossing for it:
17
+ * `build-bridge` emits it (`pnpm install` runs that through `prepare`, packing through `prepack`),
18
+ * and a missing twin is an incomplete installation the launcher refuses by name.
19
+ *
20
+ * One line on stdout, a word then a tab-separated detail:
21
+ * socket <connectPath> a verified socket; the launcher connects there to tell live from stale
22
+ * absent nothing at the advertised path
23
+ * dangling <reason> Codex's own alias whose socket is gone; the vendor removes it on start
24
+ * indeterminate <reason> anything else; the launcher refuses
25
+ *
26
+ * Reads `process.env` and takes no argument, for the reason `codex-socket-path.ts` gives: an argv
27
+ * seam would be a way to redirect the address. It has no daemon-root input either; the gate's
28
+ * exact-alias cells drive `checkCodexSocketFile` directly with a sandbox root.
29
+ */
30
+
31
+ import {
32
+ checkCodexSocketFile,
33
+ resolveCodexDefaultSocketPath,
34
+ } from "../pi-extensions/lib/native-push/codex-ws-client.ts";
35
+
36
+ if (process.argv.length > 2) {
37
+ console.error("usage: codex-socket-classify (no arguments; the environment is the input)");
38
+ process.exit(2);
39
+ }
40
+
41
+ const oneLine = (text: string): string => text.replace(/[\t\r\n]+/g, " ");
42
+ const check = checkCodexSocketFile(resolveCodexDefaultSocketPath(process.env));
43
+ if (check.ok) process.stdout.write(`socket\t${check.connectPath}`);
44
+ else if (check.status === "dead" && check.layout === "absent") process.stdout.write("absent");
45
+ else if (check.status === "dead") process.stdout.write(`dangling\t${oneLine(check.reason)}`);
46
+ else process.stdout.write(`indeterminate\t${oneLine(check.reason)}`);