@junghanacs/entwurf 0.35.1 → 0.36.1
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 +403 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +17 -0
- package/FAQ.md +3 -3
- package/README.md +30 -16
- package/VERIFY.md +12 -10
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +69 -5
- package/docs/setup-clean-host.md +30 -22
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +4 -3
- 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/meta-bridge-pi-durable.js +59 -14
- package/mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js +44 -0
- package/mcp/entwurf-bridge/tsconfig.build.json +5 -0
- package/package.json +21 -16
- 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/codex-fresh-preflight.ts +4 -3
- 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/meta-bridge-pi-durable.ts +77 -21
- package/run.sh +116 -39
- package/scripts/check-acp-prompt-lifecycle.ts +4 -2
- package/scripts/check-acp-session-reuse.ts +4 -2
- package/scripts/check-acp-stop-reason.ts +4 -2
- package/scripts/check-acp-stream-hooks.ts +4 -2
- package/scripts/check-acp-usage-accounting.ts +4 -2
- package/scripts/check-codex-app-server-launch.ts +55 -12
- package/scripts/check-codex-native-push.ts +231 -12
- package/scripts/check-gate-qualification.ts +4 -4
- package/scripts/check-herdr-supply.ts +32 -7
- 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/fixtures/herdr-supply.json +10 -9
- package/scripts/lib/acp-gate-home.ts +75 -0
- package/scripts/lib/codex-socket-listener.ts +429 -0
- package/scripts/mutants/acp-stop-reason.json +11 -0
- package/scripts/mutants/codex-app-server-launch.json +39 -12
- package/scripts/mutants/codex-native.json +67 -0
- package/scripts/mutants/control-send-receipt.json +1 -1
- package/scripts/mutants/herdr-supply.json +6 -6
- package/scripts/mutants/pack-install.json +3 -3
- package/scripts/mutants/pi-durable.json +165 -16
- package/scripts/mutants/pi-floor.json +5 -5
- package/scripts/pi-durable-contact-host.mjs +1 -1
- package/scripts/smoke-codex-fresh-live.ts +31 -7
- package/scripts/smoke-pi-durable-fresh-live.ts +22 -3
- package/pi/pi-durable/overlay/runtime-contacts.patch +0 -283
|
@@ -1,64 +1,107 @@
|
|
|
1
1
|
{
|
|
2
|
-
"schemaVersion":
|
|
3
|
-
"comment": "The
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
"schemaVersion": 3,
|
|
3
|
+
"comment": "The pi-durable carrier: what it is emitted from, what it is, and the published SDK set it runs against. The experimental durable app is source-only upstream (published pi-coding-agent ships no dist/experimental), so Entwurf owns its thin application/TUI composition as canonical source (pi/pi-durable/src) and ships it as a CARRIER: those files, type-stripped, with the relocation in pi/pi-durable/carrier-relocation.mjs. The SDK engine (execution, scheduler, queues, storage/transcript, compaction, cancellation/recovery, providers, auth) stays the published packages in `sdk`. scripts/emit-pi-durable-carrier.ts is the only writer of the generated fields (carrier blob, sha256, emitter.node); an installed package never reads source. Model data rides the published @earendil-works/pi-ai.",
|
|
4
|
+
"origin": {
|
|
5
|
+
"comment": "Frozen history, never an input: the upstream commit the owned source was first taken from, its objects as recorded then, the overlay patch that carried Entwurf's changes until it was folded into the source (#141), and the carrier bytes the last patch-built release shipped. Neither install nor emit reads this; `emit-pi-durable-carrier --origin <clone>` checks it as provenance only.",
|
|
6
|
+
"repository": "https://github.com/earendil-works/pi",
|
|
7
|
+
"tag": "v1.1.0",
|
|
8
|
+
"commit": "abe508e1b89912adde45528136c3221eb69acdd7",
|
|
9
|
+
"license": {
|
|
10
|
+
"path": "LICENSE",
|
|
11
|
+
"blob": "b0a8e9b81083294360c69b4ec45d3d39a2b28197"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "packages/coding-agent/src/experimental/durable/runtime.ts",
|
|
16
|
+
"blob": "fcd6e5239e999ebf17dea0335fb3dc9cd7f7ce45"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "packages/coding-agent/src/experimental/durable/tui.ts",
|
|
20
|
+
"blob": "397b276cbeb1c90e1fbc96c3cc59e6a25f92dad7"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "packages/coding-agent/src/experimental/durable/harness-setup.ts",
|
|
24
|
+
"blob": "4d6a4be5333ebb8e0f17eac1987f18ea35a91fc7"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"path": "packages/coding-agent/src/experimental/durable/prompt.ts",
|
|
28
|
+
"blob": "8e2f5d5273fba58da8b314766956ed62951c3814"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "packages/coding-agent/src/experimental/durable/sessions.ts",
|
|
32
|
+
"blob": "34f9ceeeaeaf874f7478d56115da2e4f726c6a85"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"path": "packages/coding-agent/src/experimental/durable/subagent.ts",
|
|
36
|
+
"blob": "e767d54e0bf2908e0f4f41c6933b55c00562eb25"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"retiredOverlay": {
|
|
40
|
+
"name": "runtime-contacts.patch",
|
|
41
|
+
"sha256": "5b1bd9b48d4ff58ad223006d81cc12c4e0b18959cca5cda5ab3ad89f68bbe898"
|
|
42
|
+
},
|
|
43
|
+
"baselineCarrier": {
|
|
44
|
+
"release": "0.35.1",
|
|
45
|
+
"node": "v24.18.1",
|
|
46
|
+
"files": {
|
|
47
|
+
"runtime.js": "bfd88844fae61e8bd32d57b5b986f48056c64eebb5466d9826318a0783ac9005",
|
|
48
|
+
"tui.js": "f61cc68e5d32552a8196785093ae9e2f4a946088fe71582bb05690517db8c5b4",
|
|
49
|
+
"harness-setup.js": "712b630145dc6f5c655a85b01b1e20299c962542e63c5494d2202bb7bf414a15",
|
|
50
|
+
"prompt.js": "ff1ff765fa313e5cb1c3d7537c901c23adb0562ee433bd050fd35eb3d892134c",
|
|
51
|
+
"sessions.js": "1bd1a04ece66f5ab3ca22860c64dba9abc848334d11a27a9afe543f1365c7254",
|
|
52
|
+
"subagent.js": "8a75d934ef87efbb0bb2006a2141f1ea932560851f1718b991ed947d6efdb54c",
|
|
53
|
+
"LICENSE": "0457f5bcec3b3b211605dfb5d1a49042fd638f3686a410fe099c24a25af13c48"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
8
57
|
"carrier": {
|
|
9
|
-
"comment": "files: the six app files bootstrap.mjs needs (main.ts is replaced by the bootstrap)
|
|
58
|
+
"comment": "files: the six app files bootstrap.mjs needs (main.ts is replaced by the bootstrap), each emitted from its owned `source`. distTargets: every pi-coding-agent internal the stripped files import, as paths under the published dist/; the emitter refuses a set that differs and the resolver answers no other.",
|
|
10
59
|
"emitter": {
|
|
11
60
|
"tool": "node:module.stripTypeScriptTypes",
|
|
12
61
|
"mode": "strip",
|
|
13
62
|
"node": "v24.18.1"
|
|
14
63
|
},
|
|
15
64
|
"license": {
|
|
16
|
-
"source": "LICENSE",
|
|
65
|
+
"source": "pi/pi-durable/src/LICENSE",
|
|
17
66
|
"blob": "b0a8e9b81083294360c69b4ec45d3d39a2b28197",
|
|
18
67
|
"sha256": "0457f5bcec3b3b211605dfb5d1a49042fd638f3686a410fe099c24a25af13c48"
|
|
19
68
|
},
|
|
20
69
|
"files": [
|
|
21
70
|
{
|
|
22
71
|
"name": "runtime.js",
|
|
23
|
-
"source": "
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"sha256": "bfd88844fae61e8bd32d57b5b986f48056c64eebb5466d9826318a0783ac9005"
|
|
72
|
+
"source": "pi/pi-durable/src/experimental/durable/runtime.ts",
|
|
73
|
+
"blob": "36c352291620ab86184172a1b4a0df4562ab9581",
|
|
74
|
+
"sha256": "60c2965eadbdbbe78fce1b7eeb89d97eb3104a30b7d58ebca87009d2e9118403"
|
|
27
75
|
},
|
|
28
76
|
{
|
|
29
77
|
"name": "tui.js",
|
|
30
|
-
"source": "
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"sha256": "f61cc68e5d32552a8196785093ae9e2f4a946088fe71582bb05690517db8c5b4"
|
|
78
|
+
"source": "pi/pi-durable/src/experimental/durable/tui.ts",
|
|
79
|
+
"blob": "dab99cd4778ae1db7f062e64db2d0c068f7e342d",
|
|
80
|
+
"sha256": "0f9ebaf8fbb2a532ccd1a7198e1a25533a99720b5fedbf03f42d5c381db3abc5"
|
|
34
81
|
},
|
|
35
82
|
{
|
|
36
83
|
"name": "harness-setup.js",
|
|
37
|
-
"source": "
|
|
38
|
-
"patched": false,
|
|
84
|
+
"source": "pi/pi-durable/src/experimental/durable/harness-setup.ts",
|
|
39
85
|
"blob": "4d6a4be5333ebb8e0f17eac1987f18ea35a91fc7",
|
|
40
86
|
"sha256": "712b630145dc6f5c655a85b01b1e20299c962542e63c5494d2202bb7bf414a15"
|
|
41
87
|
},
|
|
42
88
|
{
|
|
43
89
|
"name": "prompt.js",
|
|
44
|
-
"source": "
|
|
45
|
-
"patched": false,
|
|
90
|
+
"source": "pi/pi-durable/src/experimental/durable/prompt.ts",
|
|
46
91
|
"blob": "8e2f5d5273fba58da8b314766956ed62951c3814",
|
|
47
92
|
"sha256": "ff1ff765fa313e5cb1c3d7537c901c23adb0562ee433bd050fd35eb3d892134c"
|
|
48
93
|
},
|
|
49
94
|
{
|
|
50
95
|
"name": "sessions.js",
|
|
51
|
-
"source": "
|
|
52
|
-
"patched": false,
|
|
96
|
+
"source": "pi/pi-durable/src/experimental/durable/sessions.ts",
|
|
53
97
|
"blob": "34f9ceeeaeaf874f7478d56115da2e4f726c6a85",
|
|
54
98
|
"sha256": "1bd1a04ece66f5ab3ca22860c64dba9abc848334d11a27a9afe543f1365c7254"
|
|
55
99
|
},
|
|
56
100
|
{
|
|
57
101
|
"name": "subagent.js",
|
|
58
|
-
"source": "
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"sha256": "8a75d934ef87efbb0bb2006a2141f1ea932560851f1718b991ed947d6efdb54c"
|
|
102
|
+
"source": "pi/pi-durable/src/experimental/durable/subagent.ts",
|
|
103
|
+
"blob": "3fa9aebbe93e82cfff436e7b9d4c9877e0ed0e27",
|
|
104
|
+
"sha256": "1cb2390b673a0d914117bc07708c39b004fb10e48b31436bf9d9d2b953a64220"
|
|
62
105
|
}
|
|
63
106
|
],
|
|
64
107
|
"distTargets": [
|
|
@@ -89,17 +132,17 @@
|
|
|
89
132
|
]
|
|
90
133
|
},
|
|
91
134
|
"sdk": {
|
|
92
|
-
"comment": "The published library set the carrier runs against, derived from the 1.
|
|
135
|
+
"comment": "The published library set the carrier runs against, derived from the 1.2.0 declarations. members: the @earendil-works packages the carrier imports and every @earendil-works package those declare. direct: a third-party package a carrier file imports itself (sessions.ts: proper-lockfile, at the version pi-coding-agent 1.2.0 pins). Each is an exact production dependency in package.json; the members' own edges are caret ranges, so the installed physical edges, not semver, are what must equal these versions.",
|
|
93
136
|
"members": {
|
|
94
|
-
"@earendil-works/chord": "1.
|
|
95
|
-
"@earendil-works/pi-agent-core": "1.
|
|
96
|
-
"@earendil-works/pi-ai": "1.
|
|
97
|
-
"@earendil-works/pi-codemode": "1.
|
|
98
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
99
|
-
"@earendil-works/pi-durable": "1.
|
|
100
|
-
"@earendil-works/pi-mcp": "1.
|
|
101
|
-
"@earendil-works/pi-telemetry": "1.
|
|
102
|
-
"@earendil-works/pi-tui": "1.
|
|
137
|
+
"@earendil-works/chord": "1.2.0",
|
|
138
|
+
"@earendil-works/pi-agent-core": "1.2.0",
|
|
139
|
+
"@earendil-works/pi-ai": "1.2.0",
|
|
140
|
+
"@earendil-works/pi-codemode": "1.2.0",
|
|
141
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
142
|
+
"@earendil-works/pi-durable": "1.2.0",
|
|
143
|
+
"@earendil-works/pi-mcp": "1.2.0",
|
|
144
|
+
"@earendil-works/pi-telemetry": "1.2.0",
|
|
145
|
+
"@earendil-works/pi-tui": "1.2.0"
|
|
103
146
|
},
|
|
104
147
|
"direct": {
|
|
105
148
|
"proper-lockfile": "4.1.2"
|
|
@@ -624,11 +624,12 @@ export async function codexFreshPreflight(
|
|
|
624
624
|
if (config === null || trustReceiptMissing(config, key)) return "codex-birth-trust-missing";
|
|
625
625
|
if (mcpMissing(config)) return "codex-mcp-hand-missing";
|
|
626
626
|
if (visibleIdentityMissing(config)) return "codex-visible-identity-missing";
|
|
627
|
-
const
|
|
628
|
-
const socket = (deps.checkSocket ?? checkCodexSocketFile)(socketPath);
|
|
627
|
+
const socket = (deps.checkSocket ?? checkCodexSocketFile)(resolveCodexDefaultSocketPath(env));
|
|
629
628
|
if (!socket.ok) return "codex-app-server-unavailable";
|
|
629
|
+
// The protocol exchange goes to the endpoint the check verified, never the advertised path
|
|
630
|
+
// again (on Codex 0.162.1 that path is an alias; see `checkCodexSocketFile`).
|
|
630
631
|
const live = await appServerIsLive(
|
|
631
|
-
|
|
632
|
+
socket.connectPath,
|
|
632
633
|
deps.openProtocol ?? realCodexProtocolOpener,
|
|
633
634
|
deps.appServerTimeoutMs ?? DEFAULT_APP_SERVER_TIMEOUT_MS,
|
|
634
635
|
);
|
|
@@ -23,30 +23,135 @@ export function resolveCodexDefaultSocketPath(env: CodexSocketEnv): string {
|
|
|
23
23
|
return path.join(resolveCodexHome(env), "app-server-control", "app-server-control.sock");
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* What the advertised control-socket path is, and the one endpoint a caller may connect to.
|
|
28
|
+
*
|
|
29
|
+
* `connectPath` is a transport endpoint for one exchange: the socket this check verified. It is
|
|
30
|
+
* not an address, a record field or a replacement for `resolveCodexDefaultSocketPath`. `dead`
|
|
31
|
+
* names which absence it is, because the managed launcher treats them differently: an absent
|
|
32
|
+
* path is a first launch, and a dangling rendezvous alias is the leftover of a crashed server
|
|
33
|
+
* that the vendor removes on its next start.
|
|
34
|
+
*/
|
|
35
|
+
export type CodexSocketFileCheck =
|
|
36
|
+
| { ok: true; connectPath: string }
|
|
37
|
+
| { ok: false; status: "dead"; layout: "absent" | "dangling-alias"; reason: string }
|
|
38
|
+
| { ok: false; status: "indeterminate"; reason: string };
|
|
39
|
+
|
|
40
|
+
/** Test seam only: the gate points these at a sandbox. Production callers pass nothing. */
|
|
41
|
+
export interface CodexSocketFileDeps {
|
|
42
|
+
readonly daemonRoot?: string;
|
|
43
|
+
readonly euid?: number;
|
|
44
|
+
}
|
|
27
45
|
|
|
28
|
-
|
|
46
|
+
/**
|
|
47
|
+
* The advertised path is exactly one of two layouts, and anything else is refused.
|
|
48
|
+
*
|
|
49
|
+
* (A) A plain socket owned by the effective uid: Codex up to 0.153.4 binds the advertised
|
|
50
|
+
* path itself. `connectPath` is that path.
|
|
51
|
+
* (B) Codex's rendezvous alias. From 0.162.1 the advertised path "is only an alias": the
|
|
52
|
+
* server binds a 0600 socket under `realpath(/tmp)/codex-daemon-<euid>/` named by the
|
|
53
|
+
* sha256 of the canonical advertised path, then symlinks the advertised path to it
|
|
54
|
+
* (`[source]` rust-v0.162.1 `app-server-transport/src/transport/unix_socket.rs:43-131,
|
|
55
|
+
* 290-304`; `uds/src/daemon_directory.rs:1-38`). Upstream's reason is a privileged
|
|
56
|
+
* rendezvous root that sandboxes hide; the alias also keeps an over-long advertised path
|
|
57
|
+
* usable (`uds/src/lib.rs:167-174`). The alias is accepted only when every hop is the
|
|
58
|
+
* vendor's own: the link is owned by the effective uid; its target is BYTE-EQUAL to the
|
|
59
|
+
* path Codex derives (a relative, foreign, re-hashed or chained target is refused); the
|
|
60
|
+
* daemon directory is a real directory owned by the effective uid with `mode & 0o777`
|
|
61
|
+
* exactly 0700, the vendor's own rule; and the target is a socket owned by the effective
|
|
62
|
+
* uid that no group or other user can open. `connectPath` is the verified target, so the
|
|
63
|
+
* alias is never re-resolved between this check and the connect. A same-euid process can
|
|
64
|
+
* still replace the verified socket file itself; that residual is named, not locked.
|
|
65
|
+
*
|
|
66
|
+
* Only `lstat` and one `readlink` touch the alias; it is never followed or `realpath`ed.
|
|
67
|
+
* The effective uid is the vendor's (`libc::geteuid`), not the real uid.
|
|
68
|
+
*/
|
|
69
|
+
export function checkCodexSocketFile(socketPath: string, deps: CodexSocketFileDeps = {}): CodexSocketFileCheck {
|
|
70
|
+
const euid = deps.euid ?? process.geteuid?.();
|
|
71
|
+
let stat: fs.Stats;
|
|
29
72
|
try {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return { ok: false, status: "indeterminate", reason: `app-server path is not a socket: ${socketPath}` };
|
|
73
|
+
stat = fs.lstatSync(socketPath);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
76
|
+
if (code === "ENOENT") {
|
|
77
|
+
return { ok: false, status: "dead", layout: "absent", reason: `app-server socket is absent: ${socketPath}` };
|
|
36
78
|
}
|
|
37
|
-
|
|
79
|
+
return { ok: false, status: "indeterminate", reason: error instanceof Error ? error.message : String(error) };
|
|
80
|
+
}
|
|
81
|
+
if (stat.isSymbolicLink()) return checkCodexRendezvousAlias(socketPath, stat, euid, deps);
|
|
82
|
+
if (!stat.isSocket()) {
|
|
83
|
+
return { ok: false, status: "indeterminate", reason: `app-server path is not a socket: ${socketPath}` };
|
|
84
|
+
}
|
|
85
|
+
if (euid !== undefined && stat.uid !== euid) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
status: "indeterminate",
|
|
89
|
+
reason: `app-server socket is owned by uid ${stat.uid}, not the effective uid ${euid}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return { ok: true, connectPath: socketPath };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function checkCodexRendezvousAlias(
|
|
96
|
+
socketPath: string,
|
|
97
|
+
link: fs.Stats,
|
|
98
|
+
euid: number | undefined,
|
|
99
|
+
deps: CodexSocketFileDeps,
|
|
100
|
+
): CodexSocketFileCheck {
|
|
101
|
+
const refuse = (reason: string): CodexSocketFileCheck => ({ ok: false, status: "indeterminate", reason });
|
|
102
|
+
if (euid === undefined) {
|
|
103
|
+
return refuse(`app-server socket is a symlink and this platform has no effective uid to verify it: ${socketPath}`);
|
|
104
|
+
}
|
|
105
|
+
if (link.uid !== euid) {
|
|
106
|
+
return refuse(`app-server socket symlink is owned by uid ${link.uid}, not the effective uid ${euid}`);
|
|
107
|
+
}
|
|
108
|
+
let daemonRoot: string;
|
|
109
|
+
let expected: string;
|
|
110
|
+
let target: string;
|
|
111
|
+
try {
|
|
112
|
+
daemonRoot = deps.daemonRoot ?? path.join(fs.realpathSync("/tmp"), `codex-daemon-${euid}`);
|
|
113
|
+
const canonical = path.join(fs.realpathSync(path.dirname(socketPath)), path.basename(socketPath));
|
|
114
|
+
expected = path.join(daemonRoot, crypto.createHash("sha256").update(canonical).digest("hex"));
|
|
115
|
+
target = fs.readlinkSync(socketPath);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return refuse(
|
|
118
|
+
`could not verify the app-server socket symlink ${socketPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (target !== expected) {
|
|
122
|
+
return refuse(`app-server socket is a symlink that is not Codex's rendezvous alias: ${socketPath} -> ${target}`);
|
|
123
|
+
}
|
|
124
|
+
let directory: fs.Stats;
|
|
125
|
+
try {
|
|
126
|
+
directory = fs.lstatSync(daemonRoot);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return refuse(`Codex's socket directory cannot be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
129
|
+
}
|
|
130
|
+
if (!directory.isDirectory() || directory.uid !== euid || (directory.mode & 0o777) !== 0o700) {
|
|
131
|
+
return refuse(
|
|
132
|
+
`Codex's socket directory ${daemonRoot} must be a real directory owned by uid ${euid} with mode 0700`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
let physical: fs.Stats;
|
|
136
|
+
try {
|
|
137
|
+
physical = fs.lstatSync(expected);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
38
140
|
return {
|
|
39
141
|
ok: false,
|
|
40
|
-
status: "
|
|
41
|
-
|
|
142
|
+
status: "dead",
|
|
143
|
+
layout: "dangling-alias",
|
|
144
|
+
reason: `app-server socket is a dangling rendezvous alias: ${socketPath} -> ${expected}`,
|
|
42
145
|
};
|
|
43
146
|
}
|
|
44
|
-
return
|
|
45
|
-
} catch (error) {
|
|
46
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
47
|
-
if (code === "ENOENT") return { ok: false, status: "dead", reason: `app-server socket is absent: ${socketPath}` };
|
|
48
|
-
return { ok: false, status: "indeterminate", reason: error instanceof Error ? error.message : String(error) };
|
|
147
|
+
return refuse(error instanceof Error ? error.message : String(error));
|
|
49
148
|
}
|
|
149
|
+
if (!physical.isSocket() || physical.uid !== euid || (physical.mode & 0o077) !== 0) {
|
|
150
|
+
return refuse(
|
|
151
|
+
`Codex's physical socket ${expected} must be a socket owned by uid ${euid} that no group or other user can open`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return { ok: true, connectPath: expected };
|
|
50
155
|
}
|
|
51
156
|
|
|
52
157
|
export interface CodexRpcProtocol {
|
|
@@ -315,6 +420,7 @@ async function initialize(protocol: CodexRpcProtocol): Promise<void> {
|
|
|
315
420
|
|
|
316
421
|
export interface CodexRoute {
|
|
317
422
|
readonly backend: "codex";
|
|
423
|
+
/** The endpoint `checkCodexSocketFile` verified for this probe, which the send also uses. Not an address. */
|
|
318
424
|
readonly socketPath: string;
|
|
319
425
|
}
|
|
320
426
|
|
|
@@ -359,9 +465,14 @@ export async function probeCodexThread(
|
|
|
359
465
|
nativeSessionId: string,
|
|
360
466
|
deps: CodexAdapterDeps = {},
|
|
361
467
|
): Promise<CodexProbeResult> {
|
|
362
|
-
const
|
|
363
|
-
|
|
468
|
+
const socketCheck = (deps.checkSocket ?? checkCodexSocketFile)(
|
|
469
|
+
resolveCodexDefaultSocketPath(deps.env ?? process.env),
|
|
470
|
+
);
|
|
364
471
|
if (!socketCheck.ok) return { status: socketCheck.status, reason: socketCheck.reason };
|
|
472
|
+
// The probe, and the queue send its route carries, both use the endpoint just verified. The
|
|
473
|
+
// advertised path is not resolved again: on 0.162.1 it is an alias that could be retargeted
|
|
474
|
+
// between the check and a second transport.
|
|
475
|
+
const socketPath = socketCheck.connectPath;
|
|
365
476
|
let protocol: CodexRpcProtocol;
|
|
366
477
|
try {
|
|
367
478
|
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
|
*
|
|
@@ -47,7 +47,7 @@ export type PiDurableRuntimeRejectReason =
|
|
|
47
47
|
export interface PiDurablePackageLayout {
|
|
48
48
|
readonly root: string;
|
|
49
49
|
readonly overlayDir: string;
|
|
50
|
-
/** The emitted
|
|
50
|
+
/** The app files emitted from the owned source, and their license. */
|
|
51
51
|
readonly carrierDir: string;
|
|
52
52
|
/** The `--import` resolver the launcher runs the bootstrap under. */
|
|
53
53
|
readonly resolver: string;
|
|
@@ -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,16 @@
|
|
|
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.
|
|
60
63
|
*
|
|
61
64
|
* WITHDRAW (#140). Each notice is a native `followUp`, placed one per run; a read drains EVERY
|
|
62
65
|
* unread body. So after a read the notices still queued for that mail would each cost a turn and an
|
|
@@ -295,9 +298,14 @@ export interface PiDurableRoots {
|
|
|
295
298
|
// ---------------------------------------------------------------------------
|
|
296
299
|
// Durable surface, typed NARROWLY and locally — read at pi-durable 1.0.2 @cd32f77 (#129,
|
|
297
300
|
// `harness/types.ts`; historical coordinates, not re-anchored here). The current supply is the
|
|
298
|
-
// carrier
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
+
// carrier emitted from Entwurf's owned source (pi/pi-durable/src, #141) against the 1.2.0 SDK set
|
|
302
|
+
// `pi/pi-durable/overlay/upstream-pin.json` names (origin `abe508e`). `[read 2026-10-13]` at
|
|
303
|
+
// v1.2.0 one shape typed below moved: `ToolExecutionResult.content` is now `output`, beside the new
|
|
304
|
+
// `structuredOutput` (`harness/types.ts`, its CHANGELOG's breaking changes). An omitted `output`
|
|
305
|
+
// gives the model the text a tool retained through `api.output()`; `content` is not read, so a
|
|
306
|
+
// result naming only `content` that retained no output reaches the model as nothing. The contact's
|
|
307
|
+
// tools retain none, so they answer in `output`.
|
|
308
|
+
// The submission draft, its `abort()` answers and the identity `define*` helpers did not move.
|
|
301
309
|
// `defineExtension`/`defineTool` are identity functions (`harness/define.ts:6-17`), so a plain
|
|
302
310
|
// object is the extension.
|
|
303
311
|
// ---------------------------------------------------------------------------
|
|
@@ -314,8 +322,9 @@ export interface DurableCallContext {
|
|
|
314
322
|
readonly abortSignal?: AbortSignal;
|
|
315
323
|
}
|
|
316
324
|
|
|
325
|
+
/** `ToolExecutionResult`, narrowed: `output` is what the model reads. */
|
|
317
326
|
export interface DurableToolResult {
|
|
318
|
-
|
|
327
|
+
output: { type: "text"; text: string }[];
|
|
319
328
|
}
|
|
320
329
|
|
|
321
330
|
export interface DurableTool {
|
|
@@ -562,14 +571,19 @@ export interface DurableAdmittedSubmission {
|
|
|
562
571
|
/** The overlay's `OpenDurableResult.submitToRoot`. */
|
|
563
572
|
export type DurableSubmitToRoot = (draft: DurableDoorbellDraft) => Promise<DurableAdmittedSubmission>;
|
|
564
573
|
|
|
574
|
+
/** The overlay's `OpenDurableResult.onFailed`: one synchronous call when the session fails and closes itself. */
|
|
575
|
+
export type DurableOnFailed = (listener: () => void) => () => void;
|
|
576
|
+
|
|
565
577
|
/** What `attach` needs to arm the doorbell. Without it the citizen only sends. */
|
|
566
578
|
export interface DurableRootAdmission {
|
|
567
579
|
submitToRoot: DurableSubmitToRoot;
|
|
580
|
+
onFailed: DurableOnFailed;
|
|
568
581
|
}
|
|
569
582
|
|
|
570
583
|
/** Why an armed doorbell was retired. Nothing re-arms it. */
|
|
571
584
|
export type PiDurableReceiverRetirement =
|
|
572
585
|
| "closed"
|
|
586
|
+
| "session-failed"
|
|
573
587
|
| "watch-error"
|
|
574
588
|
| "signal-vanished"
|
|
575
589
|
| "doorbell-failed"
|
|
@@ -630,7 +644,7 @@ export interface PiDurableAttachment {
|
|
|
630
644
|
/** The roots written — and the roots the bridge child was bound to. */
|
|
631
645
|
roots: PiDurableRoots;
|
|
632
646
|
bridgePid: number | null;
|
|
633
|
-
/** The receiver marker this attach published
|
|
647
|
+
/** The receiver marker this attach published; null when no root admission was handed in, or the session had already failed. */
|
|
634
648
|
receiverMarkerPath: string | null;
|
|
635
649
|
}
|
|
636
650
|
|
|
@@ -682,6 +696,7 @@ interface ArmedDoorbell {
|
|
|
682
696
|
markerPath: string;
|
|
683
697
|
watcher: fs.FSWatcher;
|
|
684
698
|
submitToRoot: DurableSubmitToRoot;
|
|
699
|
+
unsubscribeFailed: () => void;
|
|
685
700
|
}
|
|
686
701
|
|
|
687
702
|
/** One admitted notice: its native handle and the `.msg` names it announced. */
|
|
@@ -783,7 +798,7 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
|
|
|
783
798
|
const call = () => client.callTool(name, (args ?? {}) as Record<string, unknown>, context?.abortSignal);
|
|
784
799
|
const result = name === "entwurf_inbox_read" ? await readInbox(call, context) : await call();
|
|
785
800
|
if (result.isError) throw new Error(result.text || `${name} failed`);
|
|
786
|
-
return {
|
|
801
|
+
return { output: [{ type: "text", text: result.text }] };
|
|
787
802
|
},
|
|
788
803
|
});
|
|
789
804
|
|
|
@@ -836,6 +851,7 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
|
|
|
836
851
|
const armed = doorbell;
|
|
837
852
|
if (armed === null) return [];
|
|
838
853
|
doorbell = null;
|
|
854
|
+
armed.unsubscribeFailed();
|
|
839
855
|
const problems: string[] = [];
|
|
840
856
|
try {
|
|
841
857
|
armed.watcher.close();
|
|
@@ -985,15 +1001,16 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
|
|
|
985
1001
|
* Mailbox, signal, watch — then the check that the record and this process's sender marker
|
|
986
1002
|
* still name the session — and only then the receiver marker, the one fact a sender reads.
|
|
987
1003
|
* Synchronous, so nothing can close the contact between the check and the publish. Throws,
|
|
988
|
-
* after closing whatever it opened, when any step fails.
|
|
1004
|
+
* after closing whatever it opened, when any step fails. Returns null when the session had
|
|
1005
|
+
* already failed: the doorbell was retired as `session-failed` before this returned.
|
|
989
1006
|
*/
|
|
990
1007
|
const arm = (
|
|
991
1008
|
gardenId: string,
|
|
992
1009
|
nativeSessionId: string,
|
|
993
1010
|
ownerPid: number,
|
|
994
1011
|
roots: PiDurableRoots,
|
|
995
|
-
|
|
996
|
-
): string => {
|
|
1012
|
+
root: DurableRootAdmission,
|
|
1013
|
+
): string | null => {
|
|
997
1014
|
if (ownerPid !== process.pid) {
|
|
998
1015
|
throw new Error(
|
|
999
1016
|
`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`,
|
|
@@ -1004,6 +1021,7 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
|
|
|
1004
1021
|
fs.mkdirSync(mailbox, { recursive: true });
|
|
1005
1022
|
if (!fs.existsSync(signal)) fs.writeFileSync(signal, "", { mode: 0o600 });
|
|
1006
1023
|
const watcher = watch(signal, () => void ring("signal"));
|
|
1024
|
+
let unsubscribeFailed = (): void => {};
|
|
1007
1025
|
try {
|
|
1008
1026
|
watcher.on("error", (error) => retire("watch-error", describeError(error)));
|
|
1009
1027
|
const identity = readAddressableMetaIdentity(gardenId, roots.sessionsDir);
|
|
@@ -1020,6 +1038,21 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
|
|
|
1020
1038
|
: `the sender marker for owner ${ownerPid} names ${sender.gardenId}/${sender.nativeSessionId}, not ${gardenId}/${nativeSessionId}`,
|
|
1021
1039
|
);
|
|
1022
1040
|
}
|
|
1041
|
+
// A failed session admits nothing: retire at once, so no sender reads a marker for a host that cannot wake.
|
|
1042
|
+
// Subscribed before the marker is written, so a registration that throws leaves no marker. A session that
|
|
1043
|
+
// failed before this arm says so during the subscription, before the doorbell exists: that is noted, and
|
|
1044
|
+
// the retirement runs once the doorbell is published below. It is final; nothing re-arms.
|
|
1045
|
+
let published = false;
|
|
1046
|
+
let failedBeforePublished = false;
|
|
1047
|
+
const sessionFailed = (): void => {
|
|
1048
|
+
if (!published) {
|
|
1049
|
+
failedBeforePublished = true;
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
const problems = retire("session-failed", "the durable session failed and closed itself");
|
|
1053
|
+
if (problems.length > 0) throw new Error(`the doorbell did not retire cleanly: ${problems.join("; ")}`);
|
|
1054
|
+
};
|
|
1055
|
+
unsubscribeFailed = root.onFailed(sessionFailed);
|
|
1023
1056
|
const markerPath = writeMetaReceiverMarker({
|
|
1024
1057
|
gardenId,
|
|
1025
1058
|
backend: PI_DURABLE_BACKEND,
|
|
@@ -1029,17 +1062,37 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
|
|
|
1029
1062
|
armProvenance: "session-start",
|
|
1030
1063
|
receiversDir: roots.receiversDir,
|
|
1031
1064
|
});
|
|
1032
|
-
doorbell = {
|
|
1065
|
+
doorbell = {
|
|
1066
|
+
gardenId,
|
|
1067
|
+
ownerPid,
|
|
1068
|
+
mailbox,
|
|
1069
|
+
signal,
|
|
1070
|
+
markerPath,
|
|
1071
|
+
watcher,
|
|
1072
|
+
submitToRoot: root.submitToRoot,
|
|
1073
|
+
unsubscribeFailed,
|
|
1074
|
+
};
|
|
1033
1075
|
inbox = mailbox;
|
|
1034
1076
|
report = { status: "armed", gardenId, markerPath, admissions };
|
|
1077
|
+
published = true;
|
|
1078
|
+
if (failedBeforePublished) {
|
|
1079
|
+
sessionFailed();
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1035
1082
|
return markerPath;
|
|
1036
1083
|
} catch (error) {
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1084
|
+
const cleanup: unknown[] = [];
|
|
1085
|
+
for (const step of [unsubscribeFailed, () => watcher.close()]) {
|
|
1086
|
+
try {
|
|
1087
|
+
step();
|
|
1088
|
+
} catch (cleanupError) {
|
|
1089
|
+
cleanup.push(cleanupError);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
if (cleanup.length > 0) {
|
|
1040
1093
|
throw new AggregateError(
|
|
1041
|
-
[error,
|
|
1042
|
-
`${describeError(error)} (cleanup also failed: ${describeError(
|
|
1094
|
+
[error, ...cleanup],
|
|
1095
|
+
`${describeError(error)} (cleanup also failed: ${cleanup.map(describeError).join("; ")})`,
|
|
1043
1096
|
);
|
|
1044
1097
|
}
|
|
1045
1098
|
throw error;
|
|
@@ -1068,6 +1121,9 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
|
|
|
1068
1121
|
if (root !== undefined && typeof root.submitToRoot !== "function") {
|
|
1069
1122
|
throw new Error("the root admission handed to attach has no submitToRoot function");
|
|
1070
1123
|
}
|
|
1124
|
+
if (root !== undefined && typeof root.onFailed !== "function") {
|
|
1125
|
+
throw new Error("the root admission handed to attach has no onFailed function");
|
|
1126
|
+
}
|
|
1071
1127
|
const ownerPid = options.ownerPid ?? process.pid;
|
|
1072
1128
|
const sendersDir = path.resolve(options.sendersDir ?? defaultMetaSendersDir());
|
|
1073
1129
|
const upsert = upsertMetaSession({
|
|
@@ -1119,7 +1175,7 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
|
|
|
1119
1175
|
let receiverMarkerPath: string | null = null;
|
|
1120
1176
|
if (root !== undefined) {
|
|
1121
1177
|
try {
|
|
1122
|
-
receiverMarkerPath = arm(gardenId, id, ownerPid, roots, root
|
|
1178
|
+
receiverMarkerPath = arm(gardenId, id, ownerPid, roots, root);
|
|
1123
1179
|
} catch (error) {
|
|
1124
1180
|
return await failAfterCleanup(new Error(`the doorbell did not arm: ${describeError(error)}`), () =>
|
|
1125
1181
|
client.close(),
|