@junghanacs/entwurf 0.35.0 → 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.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +338 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +15 -0
- package/FAQ.md +3 -3
- package/README.md +30 -16
- package/VERIFY.md +14 -11
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +29 -3
- package/docs/setup-clean-host.md +31 -23
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +46 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/model-ids.js +67 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +9 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +4 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +11 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +27 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +2 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +99 -18
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +3 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +13 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +178 -25
- package/mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js +44 -0
- package/mcp/entwurf-bridge/tsconfig.build.json +5 -0
- package/package.json +20 -15
- package/pi/pi-durable/bootstrap.mjs +12 -8
- package/pi/pi-durable/carrier/runtime.js +52 -2
- package/pi/pi-durable/carrier/subagent.js +6 -1
- package/pi/pi-durable/carrier/tui.js +31 -4
- package/pi/pi-durable/carrier-relocation.mjs +2 -2
- package/pi/pi-durable/overlay/upstream-pin.json +79 -36
- package/pi-extensions/lib/acp/backend-adapter.ts +52 -2
- package/pi-extensions/lib/acp/model-ids.js +77 -0
- package/pi-extensions/lib/acp/models.ts +15 -22
- package/pi-extensions/lib/codex-fresh-preflight.ts +4 -3
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +12 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +25 -1
- package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
- package/pi-extensions/lib/mux-fresh-call.ts +3 -0
- package/pi-extensions/lib/native-push/codex-ws-client.ts +129 -18
- package/pi-extensions/lib/pi-durable-runtime.ts +4 -4
- package/pi-extensions/lib/resume-launch-identity.ts +12 -4
- package/pi-extensions/meta-bridge-pi-durable.ts +230 -34
- package/run.sh +193 -58
- package/scripts/check-acp-backend-preflight.ts +10 -2
- package/scripts/check-acp-cortex.ts +7 -6
- package/scripts/check-acp-prompt-lifecycle.ts +2 -2
- package/scripts/check-acp-provider-surface.ts +1 -1
- package/scripts/check-acp-session-reuse.ts +5 -5
- package/scripts/check-acp-stop-reason.ts +2 -2
- package/scripts/check-acp-stream-hooks.ts +2 -2
- package/scripts/check-acp-usage-accounting.ts +2 -2
- package/scripts/check-codex-app-server-launch.ts +55 -12
- package/scripts/check-codex-native-push.ts +231 -12
- package/scripts/check-fresh-call-dispatch.ts +47 -4
- package/scripts/check-gate-qualification.ts +8 -6
- package/scripts/check-probe-ordering.ts +13 -6
- package/scripts/check-resume-launch-identity.ts +38 -0
- package/scripts/codex-app-server-launch.sh +54 -29
- package/scripts/codex-socket-classify.ts +46 -0
- package/scripts/emit-pi-durable-carrier.ts +159 -168
- package/scripts/mutants/acp-cortex.json +17 -8
- package/scripts/mutants/acp-set-model-readback.json +28 -0
- package/scripts/mutants/codex-app-server-launch.json +39 -12
- package/scripts/mutants/codex-native.json +15 -0
- package/scripts/mutants/control-send-receipt.json +1 -1
- package/scripts/mutants/fresh-call-dispatch.json +11 -0
- package/scripts/mutants/herdr-dev-checkout.json +70 -0
- package/scripts/mutants/pack-install.json +3 -3
- package/scripts/mutants/pi-durable.json +282 -16
- package/scripts/mutants/pi-floor.json +5 -5
- package/scripts/mutants/resume-launch-identity.json +15 -0
- package/scripts/mutants/v2-visible-resume.json +11 -0
- package/scripts/pi-durable-contact-host.mjs +1 -1
- package/scripts/resolve-acp-bridge.ts +1 -1
- package/scripts/smoke-acp-bundled-mcp-live.ts +1 -1
- package/scripts/smoke-acp-carrier-augment-live.ts +1 -1
- package/scripts/smoke-acp-long-turn-live.ts +1 -1
- package/scripts/smoke-acp-mcp-live.ts +1 -1
- package/scripts/smoke-acp-memory-containment-live.ts +1 -1
- package/scripts/smoke-acp-ordering-probe-live.ts +1 -1
- package/scripts/smoke-acp-overlay-live.ts +1 -1
- package/scripts/smoke-acp-provider-live.ts +1 -1
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-session-reuse-live.ts +1 -1
- package/scripts/smoke-acp-skill-live.ts +1 -1
- package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
- package/scripts/smoke-acp-v2-send-live.ts +1 -1
- package/scripts/smoke-entwurf-chain-live.ts +1 -1
- package/scripts/smoke-mux-lifecycle-live.ts +1 -1
- package/pi/pi-durable/overlay/runtime-contacts.patch +0 -283
|
@@ -15,30 +15,108 @@ export function resolveCodexHome(env) {
|
|
|
15
15
|
export function resolveCodexDefaultSocketPath(env) {
|
|
16
16
|
return path.join(resolveCodexHome(env), "app-server-control", "app-server-control.sock");
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The advertised path is exactly one of two layouts, and anything else is refused.
|
|
20
|
+
*
|
|
21
|
+
* (A) A plain socket owned by the effective uid: Codex up to 0.153.4 binds the advertised
|
|
22
|
+
* path itself. `connectPath` is that path.
|
|
23
|
+
* (B) Codex's rendezvous alias. From 0.162.1 the advertised path "is only an alias": the
|
|
24
|
+
* server binds a 0600 socket under `realpath(/tmp)/codex-daemon-<euid>/` named by the
|
|
25
|
+
* sha256 of the canonical advertised path, then symlinks the advertised path to it
|
|
26
|
+
* (`[source]` rust-v0.162.1 `app-server-transport/src/transport/unix_socket.rs:43-131,
|
|
27
|
+
* 290-304`; `uds/src/daemon_directory.rs:1-38`). Upstream's reason is a privileged
|
|
28
|
+
* rendezvous root that sandboxes hide; the alias also keeps an over-long advertised path
|
|
29
|
+
* usable (`uds/src/lib.rs:167-174`). The alias is accepted only when every hop is the
|
|
30
|
+
* vendor's own: the link is owned by the effective uid; its target is BYTE-EQUAL to the
|
|
31
|
+
* path Codex derives (a relative, foreign, re-hashed or chained target is refused); the
|
|
32
|
+
* daemon directory is a real directory owned by the effective uid with `mode & 0o777`
|
|
33
|
+
* exactly 0700, the vendor's own rule; and the target is a socket owned by the effective
|
|
34
|
+
* uid that no group or other user can open. `connectPath` is the verified target, so the
|
|
35
|
+
* alias is never re-resolved between this check and the connect. A same-euid process can
|
|
36
|
+
* still replace the verified socket file itself; that residual is named, not locked.
|
|
37
|
+
*
|
|
38
|
+
* Only `lstat` and one `readlink` touch the alias; it is never followed or `realpath`ed.
|
|
39
|
+
* The effective uid is the vendor's (`libc::geteuid`), not the real uid.
|
|
40
|
+
*/
|
|
41
|
+
export function checkCodexSocketFile(socketPath, deps = {}) {
|
|
42
|
+
const euid = deps.euid ?? process.geteuid?.();
|
|
43
|
+
let stat;
|
|
19
44
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
return { ok: false, status: "
|
|
45
|
+
stat = fs.lstatSync(socketPath);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
const code = error.code;
|
|
49
|
+
if (code === "ENOENT") {
|
|
50
|
+
return { ok: false, status: "dead", layout: "absent", reason: `app-server socket is absent: ${socketPath}` };
|
|
26
51
|
}
|
|
27
|
-
|
|
52
|
+
return { ok: false, status: "indeterminate", reason: error instanceof Error ? error.message : String(error) };
|
|
53
|
+
}
|
|
54
|
+
if (stat.isSymbolicLink())
|
|
55
|
+
return checkCodexRendezvousAlias(socketPath, stat, euid, deps);
|
|
56
|
+
if (!stat.isSocket()) {
|
|
57
|
+
return { ok: false, status: "indeterminate", reason: `app-server path is not a socket: ${socketPath}` };
|
|
58
|
+
}
|
|
59
|
+
if (euid !== undefined && stat.uid !== euid) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
status: "indeterminate",
|
|
63
|
+
reason: `app-server socket is owned by uid ${stat.uid}, not the effective uid ${euid}`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, connectPath: socketPath };
|
|
67
|
+
}
|
|
68
|
+
function checkCodexRendezvousAlias(socketPath, link, euid, deps) {
|
|
69
|
+
const refuse = (reason) => ({ ok: false, status: "indeterminate", reason });
|
|
70
|
+
if (euid === undefined) {
|
|
71
|
+
return refuse(`app-server socket is a symlink and this platform has no effective uid to verify it: ${socketPath}`);
|
|
72
|
+
}
|
|
73
|
+
if (link.uid !== euid) {
|
|
74
|
+
return refuse(`app-server socket symlink is owned by uid ${link.uid}, not the effective uid ${euid}`);
|
|
75
|
+
}
|
|
76
|
+
let daemonRoot;
|
|
77
|
+
let expected;
|
|
78
|
+
let target;
|
|
79
|
+
try {
|
|
80
|
+
daemonRoot = deps.daemonRoot ?? path.join(fs.realpathSync("/tmp"), `codex-daemon-${euid}`);
|
|
81
|
+
const canonical = path.join(fs.realpathSync(path.dirname(socketPath)), path.basename(socketPath));
|
|
82
|
+
expected = path.join(daemonRoot, crypto.createHash("sha256").update(canonical).digest("hex"));
|
|
83
|
+
target = fs.readlinkSync(socketPath);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
return refuse(`could not verify the app-server socket symlink ${socketPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
87
|
+
}
|
|
88
|
+
if (target !== expected) {
|
|
89
|
+
return refuse(`app-server socket is a symlink that is not Codex's rendezvous alias: ${socketPath} -> ${target}`);
|
|
90
|
+
}
|
|
91
|
+
let directory;
|
|
92
|
+
try {
|
|
93
|
+
directory = fs.lstatSync(daemonRoot);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
return refuse(`Codex's socket directory cannot be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
97
|
+
}
|
|
98
|
+
if (!directory.isDirectory() || directory.uid !== euid || (directory.mode & 0o777) !== 0o700) {
|
|
99
|
+
return refuse(`Codex's socket directory ${daemonRoot} must be a real directory owned by uid ${euid} with mode 0700`);
|
|
100
|
+
}
|
|
101
|
+
let physical;
|
|
102
|
+
try {
|
|
103
|
+
physical = fs.lstatSync(expected);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error.code === "ENOENT") {
|
|
28
107
|
return {
|
|
29
108
|
ok: false,
|
|
30
|
-
status: "
|
|
31
|
-
|
|
109
|
+
status: "dead",
|
|
110
|
+
layout: "dangling-alias",
|
|
111
|
+
reason: `app-server socket is a dangling rendezvous alias: ${socketPath} -> ${expected}`,
|
|
32
112
|
};
|
|
33
113
|
}
|
|
34
|
-
return
|
|
114
|
+
return refuse(error instanceof Error ? error.message : String(error));
|
|
35
115
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (code === "ENOENT")
|
|
39
|
-
return { ok: false, status: "dead", reason: `app-server socket is absent: ${socketPath}` };
|
|
40
|
-
return { ok: false, status: "indeterminate", reason: error instanceof Error ? error.message : String(error) };
|
|
116
|
+
if (!physical.isSocket() || physical.uid !== euid || (physical.mode & 0o077) !== 0) {
|
|
117
|
+
return refuse(`Codex's physical socket ${expected} must be a socket owned by uid ${euid} that no group or other user can open`);
|
|
41
118
|
}
|
|
119
|
+
return { ok: true, connectPath: expected };
|
|
42
120
|
}
|
|
43
121
|
function promiseResolvers() {
|
|
44
122
|
return Promise.withResolvers();
|
|
@@ -302,10 +380,13 @@ const realCodexExecRunner = {
|
|
|
302
380
|
},
|
|
303
381
|
};
|
|
304
382
|
export async function probeCodexThread(nativeSessionId, deps = {}) {
|
|
305
|
-
const
|
|
306
|
-
const socketCheck = (deps.checkSocket ?? checkCodexSocketFile)(socketPath);
|
|
383
|
+
const socketCheck = (deps.checkSocket ?? checkCodexSocketFile)(resolveCodexDefaultSocketPath(deps.env ?? process.env));
|
|
307
384
|
if (!socketCheck.ok)
|
|
308
385
|
return { status: socketCheck.status, reason: socketCheck.reason };
|
|
386
|
+
// The probe, and the queue send its route carries, both use the endpoint just verified. The
|
|
387
|
+
// advertised path is not resolved again: on 0.162.1 it is an alias that could be retargeted
|
|
388
|
+
// between the check and a second transport.
|
|
389
|
+
const socketPath = socketCheck.connectPath;
|
|
309
390
|
let protocol;
|
|
310
391
|
try {
|
|
311
392
|
protocol = await (deps.openProtocol ?? realCodexProtocolOpener).open(socketPath);
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* under (#129 W, #130).
|
|
4
4
|
*
|
|
5
5
|
* The experimental durable app is source-only upstream: published `@earendil-works/pi-coding-agent`
|
|
6
|
-
* ships no `dist/experimental`. Entwurf therefore ships the app itself as a CARRIER —
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* ships no `dist/experimental`. Entwurf therefore ships the app itself as a CARRIER — its owned
|
|
7
|
+
* source (pi/pi-durable/src, #141), type-stripped and relocated (pi/pi-durable/carrier/, emitted only
|
|
8
|
+
* by scripts/emit-pi-durable-carrier.ts) — and declares the published SDK set it runs
|
|
9
9
|
* against as exact production dependencies. Nothing is provisioned beside the package: there is no
|
|
10
10
|
* runtime directory, no source checkout and no model data to copy (it rides the published pi-ai).
|
|
11
11
|
*
|
|
@@ -19,11 +19,12 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { existsSync } from "node:fs";
|
|
21
21
|
import path from "node:path";
|
|
22
|
+
import { AcpModelNotCuratedError, isCuratedAcpModelId, PROVIDER_ID } from "./acp/model-ids.js";
|
|
22
23
|
import { getEntwurfExplicitExtensions, readSessionIdentity } from "./entwurf-core.js";
|
|
23
24
|
import { readAddressableMetaIdentity } from "./meta-session.js";
|
|
24
25
|
/**
|
|
25
|
-
*
|
|
26
|
-
* same-id resume at all.
|
|
26
|
+
* One of the TWO expected refusals on this leaf: the target is a citizen of a backend that has no
|
|
27
|
+
* same-id resume at all. The other is `AcpModelNotCuratedError` (#128, `acp/model-ids.js`).
|
|
27
28
|
*
|
|
28
29
|
* Everything else this function throws is a defect in the record, the transcript or the
|
|
29
30
|
* environment — a stale path, a foreign session file, an unrecorded model, an unresolvable
|
|
@@ -126,11 +127,19 @@ export function resolveResumeLaunchIdentity(gardenId) {
|
|
|
126
127
|
if (!identity.cwd) {
|
|
127
128
|
throw new Error(`resume-launch-identity: ${gardenId} header has no cwd (the cold-resume authority, #9).`);
|
|
128
129
|
}
|
|
130
|
+
// #128: the pair checked is the one the launch will EMIT, not the recorded provider alone —
|
|
131
|
+
// `getEntwurfExplicitExtensions` also routes a bare Claude model id to `entwurf`. Under that
|
|
132
|
+
// provider Pi resolves `--model` by substring, so a retired `claude-sonnet-5` would reopen on
|
|
133
|
+
// `cortex-claude-sonnet-5`. The record and transcript stay as they are; the resume is refused.
|
|
134
|
+
const provider = explicitExtensions.provider ?? identity.provider;
|
|
135
|
+
const model = explicitExtensions.modelOverride ?? resumeModel;
|
|
136
|
+
if (provider === PROVIDER_ID && !isCuratedAcpModelId(model))
|
|
137
|
+
throw new AcpModelNotCuratedError(model);
|
|
129
138
|
return {
|
|
130
139
|
sessionFile,
|
|
131
140
|
cwd: identity.cwd,
|
|
132
141
|
explicitExtensionArgs: explicitExtensions.args,
|
|
133
|
-
provider
|
|
134
|
-
model
|
|
142
|
+
provider,
|
|
143
|
+
model,
|
|
135
144
|
};
|
|
136
145
|
}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* WHAT THIS IS. The long-running durable app (`@earendil-works/pi-coding-agent`
|
|
5
5
|
* `experimental/durable`, which upstream omits from its npm distribution — Entwurf's package
|
|
6
|
-
* supplies it as a
|
|
7
|
-
* models/auth, storage, prompt and recovery. This module is
|
|
6
|
+
* supplies it as a carrier emitted from its owned source, #130/#141, not as an operator
|
|
7
|
+
* prerequisite) owns its runtime, TUI, models/auth, storage, prompt and recovery. This module is
|
|
8
|
+
* the whole Entwurf side of its contact:
|
|
8
9
|
*
|
|
9
10
|
* createPiDurableContact({ bridgeEntry })
|
|
10
11
|
* .extension tools-only durable Extension, handed to the overlay's
|
|
@@ -49,14 +50,34 @@
|
|
|
49
50
|
* bridge whose list differs: the callable bridge schema is the truth, the copy only has to agree.
|
|
50
51
|
*
|
|
51
52
|
* RECEIVE (self-fetch, announce-only). When the bootstrap hands `attach` the overlay's
|
|
52
|
-
* `submitToRoot`, the contact arms a doorbell after the bridge is ready: mailbox, then signal,
|
|
53
|
+
* `submitToRoot` and `onFailed`, the contact arms a doorbell after the bridge is ready: mailbox, then signal,
|
|
53
54
|
* then the watch, then a check that the record and this process's sender marker still name the
|
|
54
55
|
* session, and only then the receiver marker. A fresh `.msg` is stamped `.msg.delivered` and
|
|
55
56
|
* announced to the ROOT conversation as input; the body stays in the mailbox until the model
|
|
56
57
|
* calls `entwurf_inbox_read`, which is the read receipt. The watch and the sender marker share
|
|
57
58
|
* this process's pid, so the #101 owner join applies (`pi-durable-host`). A watch error, a
|
|
58
|
-
* vanished signal, a failed ring, a rejected admission
|
|
59
|
-
*
|
|
59
|
+
* vanished signal, a failed ring, a rejected admission, the session failing (at once, from the
|
|
60
|
+
* overlay's `onFailed`, not at the next ring — and before `attach` returns when it had already
|
|
61
|
+
* failed, with no receiver marker left behind) or close retires the doorbell — watch closed, our own
|
|
62
|
+
* marker removed, the reason kept by name — and nothing retries it.
|
|
63
|
+
*
|
|
64
|
+
* WITHDRAW (#140). Each notice is a native `followUp`, placed one per run; a read drains EVERY
|
|
65
|
+
* unread body. So after a read the notices still queued for that mail would each cost a turn and an
|
|
66
|
+
* empty read. The contact keeps the native `Submission` handle of each notice it admitted, with the
|
|
67
|
+
* `.msg` names that notice announced, only while that mail is unread. After every
|
|
68
|
+
* `entwurf_inbox_read` attempt — whatever its garden id or outcome — it lists its OWN mailbox once
|
|
69
|
+
* and withdraws, through the native `Submission.abort` and the read's own invocation Context, each
|
|
70
|
+
* notice none of whose names is still `.msg.delivered`. That per-notice file predicate is the only
|
|
71
|
+
* authority: no garden-id guard, no requestId lookup, no queue of our own, no rewritten text. A placed
|
|
72
|
+
* or settled notice answers so and is let go; a withdraw that fails is let go too, named in one
|
|
73
|
+
* stderr line and on the receiver report, never retried and never retiring the doorbell; a mailbox
|
|
74
|
+
* that cannot be listed proves nothing absent, so every notice is kept and that is named the same
|
|
75
|
+
* way. The read's own result or error is returned unchanged — no diagnostic joins it. Ring passes
|
|
76
|
+
* and reads share one gate: a read waits for an admission in flight (else its drain could beat the
|
|
77
|
+
* handle it must withdraw), and an arrival during a read is rung once the read lets go. The count
|
|
78
|
+
* in a notice is its admission-time snapshot. Not covered: notices admitted before a restart (no
|
|
79
|
+
* handle), mail drained by another process (until this contact's next read), and operator abort,
|
|
80
|
+
* which natively withdraws queued notices while their mail stays unread.
|
|
60
81
|
*
|
|
61
82
|
* VISIBLE IDENTITY (a static projection). `withGardenIdentity` hands the native TUI a view whose
|
|
62
83
|
* every conversation label carries this citizen's garden id, so the footer the TUI keeps on screen
|
|
@@ -79,7 +100,8 @@
|
|
|
79
100
|
*
|
|
80
101
|
* NOT HERE: crash-safe receive. `entwurf_inbox_read` archives a body before the durable tool
|
|
81
102
|
* result commits, so a crash between the two loses that body to the model; recovery settles the
|
|
82
|
-
* call as interrupted and no doorbell rings again.
|
|
103
|
+
* call as interrupted and no doorbell rings again. A drain that fails part-way has archived the
|
|
104
|
+
* bodies before it (the bridge returns its error), and those too never reach the model. No resume.
|
|
83
105
|
*/
|
|
84
106
|
import * as fs from "node:fs";
|
|
85
107
|
import * as path from "node:path";
|
|
@@ -461,10 +483,11 @@ export function createPiDurableContact(options) {
|
|
|
461
483
|
throw new Error(`${name} arguments must be an object`);
|
|
462
484
|
}
|
|
463
485
|
const client = await waitReady(context?.abortSignal);
|
|
464
|
-
const
|
|
486
|
+
const call = () => client.callTool(name, (args ?? {}), context?.abortSignal);
|
|
487
|
+
const result = name === "entwurf_inbox_read" ? await readInbox(call, context) : await call();
|
|
465
488
|
if (result.isError)
|
|
466
489
|
throw new Error(result.text || `${name} failed`);
|
|
467
|
-
return {
|
|
490
|
+
return { output: [{ type: "text", text: result.text }] };
|
|
468
491
|
},
|
|
469
492
|
});
|
|
470
493
|
const extension = {
|
|
@@ -475,8 +498,33 @@ export function createPiDurableContact(options) {
|
|
|
475
498
|
let doorbell = null;
|
|
476
499
|
let report = { status: "off" };
|
|
477
500
|
const admissions = [];
|
|
478
|
-
|
|
501
|
+
/** The armed mailbox, kept past a retirement so notices admitted before it can still be judged. */
|
|
502
|
+
let inbox = null;
|
|
503
|
+
/** Every admitted notice still owed a withdraw judgement, oldest first. */
|
|
504
|
+
const notices = [];
|
|
505
|
+
let withdrawalFailure;
|
|
506
|
+
// One gate for ring passes and inbox reads; `pending` is a signal that landed while it was held.
|
|
507
|
+
let gated = false;
|
|
479
508
|
let pending = false;
|
|
509
|
+
const gateWaiters = [];
|
|
510
|
+
const enterGate = () => {
|
|
511
|
+
if (!gated) {
|
|
512
|
+
gated = true;
|
|
513
|
+
return Promise.resolve();
|
|
514
|
+
}
|
|
515
|
+
return new Promise((resolve) => gateWaiters.push(resolve));
|
|
516
|
+
};
|
|
517
|
+
/** Hand the gate to the next waiting read, else open it — and ring once for a signal that landed meanwhile. */
|
|
518
|
+
const leaveGate = () => {
|
|
519
|
+
const next = gateWaiters.shift();
|
|
520
|
+
if (next !== undefined) {
|
|
521
|
+
next();
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
gated = false;
|
|
525
|
+
if (pending)
|
|
526
|
+
void ring("signal");
|
|
527
|
+
};
|
|
480
528
|
/**
|
|
481
529
|
* Give the doorbell back: close the watch and remove the marker, in one step, because a
|
|
482
530
|
* closed watch under a surviving marker is the false deliverability this unit refuses. Only a
|
|
@@ -489,6 +537,7 @@ export function createPiDurableContact(options) {
|
|
|
489
537
|
if (armed === null)
|
|
490
538
|
return [];
|
|
491
539
|
doorbell = null;
|
|
540
|
+
armed.unsubscribeFailed();
|
|
492
541
|
const problems = [];
|
|
493
542
|
try {
|
|
494
543
|
armed.watcher.close();
|
|
@@ -520,20 +569,20 @@ export function createPiDurableContact(options) {
|
|
|
520
569
|
/**
|
|
521
570
|
* One contract with the other self-fetch doorbells: a fresh `*.msg` is the trigger, it is
|
|
522
571
|
* stamped `.msg.delivered` before the announce, and the announced count is every
|
|
523
|
-
* `.msg.delivered`. Events that land while an admission
|
|
524
|
-
* pass. A failed pass retires the doorbell; it is not retried. Only a fresh `.msg`
|
|
525
|
-
* the startup pass included — so a `.msg.delivered` left undrained by an earlier host is
|
|
526
|
-
* announced again on its own; it shows only in the count of the next fresh arrival.
|
|
572
|
+
* `.msg.delivered`. Events that land while an admission or an inbox read holds the gate coalesce
|
|
573
|
+
* into one more pass. A failed pass retires the doorbell; it is not retried. Only a fresh `.msg`
|
|
574
|
+
* rings — the startup pass included — so a `.msg.delivered` left undrained by an earlier host is
|
|
575
|
+
* not announced again on its own; it shows only in the count of the next fresh arrival.
|
|
527
576
|
*/
|
|
528
577
|
const ring = async (why) => {
|
|
529
578
|
const armed = doorbell;
|
|
530
579
|
if (armed === null)
|
|
531
580
|
return;
|
|
532
|
-
if (
|
|
581
|
+
if (gated) {
|
|
533
582
|
pending = true;
|
|
534
583
|
return;
|
|
535
584
|
}
|
|
536
|
-
|
|
585
|
+
gated = true;
|
|
537
586
|
try {
|
|
538
587
|
do {
|
|
539
588
|
pending = false;
|
|
@@ -568,22 +617,83 @@ export function createPiDurableContact(options) {
|
|
|
568
617
|
}
|
|
569
618
|
// Latest only: the diagnostic names the LAST success and never grows with the host's lifetime.
|
|
570
619
|
admissions.splice(0, admissions.length, { why, requestId, submissionId: submission.id, fresh, unread });
|
|
620
|
+
notices.push({ requestId, fresh, submission });
|
|
571
621
|
} while (pending && doorbell === armed);
|
|
572
622
|
}
|
|
573
623
|
catch (error) {
|
|
574
624
|
retire("doorbell-failed", describeError(error));
|
|
575
625
|
}
|
|
576
626
|
finally {
|
|
577
|
-
|
|
627
|
+
leaveGate();
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
/**
|
|
631
|
+
* One named stderr line per failed withdrawal — stderr ALWAYS, as `entwurf-control` does, since
|
|
632
|
+
* nothing in the native app reads the receiver report — and the report keeps the latest one.
|
|
633
|
+
*/
|
|
634
|
+
const noteWithdrawalFailure = (failure) => {
|
|
635
|
+
withdrawalFailure = failure;
|
|
636
|
+
const subject = failure.requestId === undefined ? "" : ` ${failure.requestId}`;
|
|
637
|
+
const detail = failure.detail.replace(/\s+/g, " ");
|
|
638
|
+
process.stderr.write(`[entwurf pi-durable] notice withdrawal ${failure.reason}${subject}: ${detail}\n`);
|
|
639
|
+
};
|
|
640
|
+
/**
|
|
641
|
+
* Withdraw every held notice none of whose announced `.msg` names is still `.msg.delivered` in the
|
|
642
|
+
* armed mailbox, listed once. A mailbox that cannot be listed proves nothing absent: every notice
|
|
643
|
+
* is kept and that is named. A judged notice is let go whatever the native withdraw answers — or
|
|
644
|
+
* when it rejects, which is named and never retried — so no handle outlives its mail by more
|
|
645
|
+
* than one read. Never throws: the read's own answer is what the caller gets.
|
|
646
|
+
*/
|
|
647
|
+
const withdrawDrained = async (context) => {
|
|
648
|
+
if (inbox === null || notices.length === 0)
|
|
649
|
+
return;
|
|
650
|
+
let unread;
|
|
651
|
+
try {
|
|
652
|
+
unread = new Set(fs.readdirSync(inbox).filter((name) => name.endsWith(".msg.delivered")));
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
noteWithdrawalFailure({ reason: "inspection-failed", detail: `${inbox}: ${describeError(error)}` });
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
for (const notice of [...notices]) {
|
|
659
|
+
if (notice.fresh.some((name) => unread.has(`${name}.delivered`)))
|
|
660
|
+
continue;
|
|
661
|
+
notices.splice(notices.indexOf(notice), 1);
|
|
662
|
+
try {
|
|
663
|
+
await notice.submission.abort(context);
|
|
664
|
+
}
|
|
665
|
+
catch (error) {
|
|
666
|
+
noteWithdrawalFailure({ reason: "abort-failed", requestId: notice.requestId, detail: describeError(error) });
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
/**
|
|
671
|
+
* An inbox read under the doorbell's gate, then the withdrawal its drain made due — after a
|
|
672
|
+
* failed read too, whose partial drain may already have archived mail. The bridge's own answer,
|
|
673
|
+
* result or rejection, is returned unchanged.
|
|
674
|
+
*/
|
|
675
|
+
const readInbox = async (call, context) => {
|
|
676
|
+
await enterGate();
|
|
677
|
+
try {
|
|
678
|
+
return await call();
|
|
679
|
+
}
|
|
680
|
+
finally {
|
|
681
|
+
try {
|
|
682
|
+
await withdrawDrained(context);
|
|
683
|
+
}
|
|
684
|
+
finally {
|
|
685
|
+
leaveGate();
|
|
686
|
+
}
|
|
578
687
|
}
|
|
579
688
|
};
|
|
580
689
|
/**
|
|
581
690
|
* Mailbox, signal, watch — then the check that the record and this process's sender marker
|
|
582
691
|
* still name the session — and only then the receiver marker, the one fact a sender reads.
|
|
583
692
|
* Synchronous, so nothing can close the contact between the check and the publish. Throws,
|
|
584
|
-
* after closing whatever it opened, when any step fails.
|
|
693
|
+
* after closing whatever it opened, when any step fails. Returns null when the session had
|
|
694
|
+
* already failed: the doorbell was retired as `session-failed` before this returned.
|
|
585
695
|
*/
|
|
586
|
-
const arm = (gardenId, nativeSessionId, ownerPid, roots,
|
|
696
|
+
const arm = (gardenId, nativeSessionId, ownerPid, roots, root) => {
|
|
587
697
|
if (ownerPid !== process.pid) {
|
|
588
698
|
throw new Error(`the doorbell's watch lives in this process (${process.pid}); a receiver marker naming owner ${ownerPid} would advertise a watch that pid does not hold`);
|
|
589
699
|
}
|
|
@@ -593,6 +703,7 @@ export function createPiDurableContact(options) {
|
|
|
593
703
|
if (!fs.existsSync(signal))
|
|
594
704
|
fs.writeFileSync(signal, "", { mode: 0o600 });
|
|
595
705
|
const watcher = watch(signal, () => void ring("signal"));
|
|
706
|
+
let unsubscribeFailed = () => { };
|
|
596
707
|
try {
|
|
597
708
|
watcher.on("error", (error) => retire("watch-error", describeError(error)));
|
|
598
709
|
const identity = readAddressableMetaIdentity(gardenId, roots.sessionsDir);
|
|
@@ -605,6 +716,22 @@ export function createPiDurableContact(options) {
|
|
|
605
716
|
? `no live sender marker for owner ${ownerPid}`
|
|
606
717
|
: `the sender marker for owner ${ownerPid} names ${sender.gardenId}/${sender.nativeSessionId}, not ${gardenId}/${nativeSessionId}`);
|
|
607
718
|
}
|
|
719
|
+
// A failed session admits nothing: retire at once, so no sender reads a marker for a host that cannot wake.
|
|
720
|
+
// Subscribed before the marker is written, so a registration that throws leaves no marker. A session that
|
|
721
|
+
// failed before this arm says so during the subscription, before the doorbell exists: that is noted, and
|
|
722
|
+
// the retirement runs once the doorbell is published below. It is final; nothing re-arms.
|
|
723
|
+
let published = false;
|
|
724
|
+
let failedBeforePublished = false;
|
|
725
|
+
const sessionFailed = () => {
|
|
726
|
+
if (!published) {
|
|
727
|
+
failedBeforePublished = true;
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const problems = retire("session-failed", "the durable session failed and closed itself");
|
|
731
|
+
if (problems.length > 0)
|
|
732
|
+
throw new Error(`the doorbell did not retire cleanly: ${problems.join("; ")}`);
|
|
733
|
+
};
|
|
734
|
+
unsubscribeFailed = root.onFailed(sessionFailed);
|
|
608
735
|
const markerPath = writeMetaReceiverMarker({
|
|
609
736
|
gardenId,
|
|
610
737
|
backend: PI_DURABLE_BACKEND,
|
|
@@ -614,16 +741,37 @@ export function createPiDurableContact(options) {
|
|
|
614
741
|
armProvenance: "session-start",
|
|
615
742
|
receiversDir: roots.receiversDir,
|
|
616
743
|
});
|
|
617
|
-
doorbell = {
|
|
744
|
+
doorbell = {
|
|
745
|
+
gardenId,
|
|
746
|
+
ownerPid,
|
|
747
|
+
mailbox,
|
|
748
|
+
signal,
|
|
749
|
+
markerPath,
|
|
750
|
+
watcher,
|
|
751
|
+
submitToRoot: root.submitToRoot,
|
|
752
|
+
unsubscribeFailed,
|
|
753
|
+
};
|
|
754
|
+
inbox = mailbox;
|
|
618
755
|
report = { status: "armed", gardenId, markerPath, admissions };
|
|
756
|
+
published = true;
|
|
757
|
+
if (failedBeforePublished) {
|
|
758
|
+
sessionFailed();
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
619
761
|
return markerPath;
|
|
620
762
|
}
|
|
621
763
|
catch (error) {
|
|
622
|
-
|
|
623
|
-
|
|
764
|
+
const cleanup = [];
|
|
765
|
+
for (const step of [unsubscribeFailed, () => watcher.close()]) {
|
|
766
|
+
try {
|
|
767
|
+
step();
|
|
768
|
+
}
|
|
769
|
+
catch (cleanupError) {
|
|
770
|
+
cleanup.push(cleanupError);
|
|
771
|
+
}
|
|
624
772
|
}
|
|
625
|
-
|
|
626
|
-
throw new AggregateError([error,
|
|
773
|
+
if (cleanup.length > 0) {
|
|
774
|
+
throw new AggregateError([error, ...cleanup], `${describeError(error)} (cleanup also failed: ${cleanup.map(describeError).join("; ")})`);
|
|
627
775
|
}
|
|
628
776
|
throw error;
|
|
629
777
|
}
|
|
@@ -651,6 +799,9 @@ export function createPiDurableContact(options) {
|
|
|
651
799
|
if (root !== undefined && typeof root.submitToRoot !== "function") {
|
|
652
800
|
throw new Error("the root admission handed to attach has no submitToRoot function");
|
|
653
801
|
}
|
|
802
|
+
if (root !== undefined && typeof root.onFailed !== "function") {
|
|
803
|
+
throw new Error("the root admission handed to attach has no onFailed function");
|
|
804
|
+
}
|
|
654
805
|
const ownerPid = options.ownerPid ?? process.pid;
|
|
655
806
|
const sendersDir = path.resolve(options.sendersDir ?? defaultMetaSendersDir());
|
|
656
807
|
const upsert = upsertMetaSession({
|
|
@@ -700,7 +851,7 @@ export function createPiDurableContact(options) {
|
|
|
700
851
|
let receiverMarkerPath = null;
|
|
701
852
|
if (root !== undefined) {
|
|
702
853
|
try {
|
|
703
|
-
receiverMarkerPath = arm(gardenId, id, ownerPid, roots, root
|
|
854
|
+
receiverMarkerPath = arm(gardenId, id, ownerPid, roots, root);
|
|
704
855
|
}
|
|
705
856
|
catch (error) {
|
|
706
857
|
return await failAfterCleanup(new Error(`the doorbell did not arm: ${describeError(error)}`), () => client.close());
|
|
@@ -731,13 +882,15 @@ export function createPiDurableContact(options) {
|
|
|
731
882
|
settleFailed(describeError(error));
|
|
732
883
|
},
|
|
733
884
|
receiver() {
|
|
734
|
-
return report;
|
|
885
|
+
return withdrawalFailure === undefined || report.status === "off" ? report : { ...report, withdrawalFailure };
|
|
735
886
|
},
|
|
736
887
|
async close() {
|
|
737
888
|
const previous = state;
|
|
738
889
|
settleFailed("the contact closed before attach completed");
|
|
739
890
|
state = { kind: "closed" };
|
|
740
891
|
const client = previous.kind === "ready" ? previous.client : undefined;
|
|
892
|
+
// Queued notices stay native (a continued session places them); this contact judges no more.
|
|
893
|
+
notices.length = 0;
|
|
741
894
|
await closeAll(disarmOnClose, async () => {
|
|
742
895
|
await client?.close();
|
|
743
896
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
import { checkCodexSocketFile, resolveCodexDefaultSocketPath, } from "../pi-extensions/lib/native-push/codex-ws-client.js";
|
|
31
|
+
if (process.argv.length > 2) {
|
|
32
|
+
console.error("usage: codex-socket-classify (no arguments; the environment is the input)");
|
|
33
|
+
process.exit(2);
|
|
34
|
+
}
|
|
35
|
+
const oneLine = (text) => text.replace(/[\t\r\n]+/g, " ");
|
|
36
|
+
const check = checkCodexSocketFile(resolveCodexDefaultSocketPath(process.env));
|
|
37
|
+
if (check.ok)
|
|
38
|
+
process.stdout.write(`socket\t${check.connectPath}`);
|
|
39
|
+
else if (check.status === "dead" && check.layout === "absent")
|
|
40
|
+
process.stdout.write("absent");
|
|
41
|
+
else if (check.status === "dead")
|
|
42
|
+
process.stdout.write(`dangling\t${oneLine(check.reason)}`);
|
|
43
|
+
else
|
|
44
|
+
process.stdout.write(`indeterminate\t${oneLine(check.reason)}`);
|
|
@@ -122,6 +122,10 @@
|
|
|
122
122
|
// twin would make the one-command launch die before its first line. Its only repo import is
|
|
123
123
|
// native-push/codex-ws-client.ts, ALREADY emitted for meta-bridge-hook-codex, so this adds ONE
|
|
124
124
|
// leaf JS and ZERO new deps.
|
|
125
|
+
// WHY codex-socket-classify rides THIS build (#141): the same launcher asks it whether a launch
|
|
126
|
+
// would clobber something, and runs THIS compiled twin directly with `node` in a checkout and an
|
|
127
|
+
// installed package alike (no run.sh verb, no run_ts crossing), so the emitted file is the only
|
|
128
|
+
// route. Its only repo import is that same codex-ws-client.ts: ONE leaf JS, ZERO new deps.
|
|
125
129
|
// WHY meta-bridge-pi-durable rides THIS build (#129 W, L4): the packaged durable bootstrap
|
|
126
130
|
// (pi/pi-durable/bootstrap.mjs) imports the contact from THIS closure — the same closure whose
|
|
127
131
|
// bridge entry the contact spawns and whose meta-bridge-omp.js decodes the first-input payload —
|
|
@@ -151,6 +155,7 @@
|
|
|
151
155
|
"../../scripts/peer-facts.ts",
|
|
152
156
|
"../../scripts/omp-receive-facts.ts",
|
|
153
157
|
"../../scripts/codex-socket-path.ts",
|
|
158
|
+
"../../scripts/codex-socket-classify.ts",
|
|
154
159
|
"../../pi-extensions/meta-bridge-hook.ts",
|
|
155
160
|
"../../pi-extensions/meta-bridge-hook-copilot.ts",
|
|
156
161
|
"../../pi-extensions/meta-bridge-hook-codex.ts",
|