@junghanacs/entwurf 0.30.1 → 0.32.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 +4 -2
- package/CHANGELOG.md +194 -0
- package/DELIVERY.md +1 -0
- package/README.md +64 -14
- package/VERIFY.md +36 -1
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +1 -1
- package/docs/setup-clean-host.md +121 -4
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +7 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +34 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +28 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +28 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +92 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +236 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +746 -0
- package/mcp/entwurf-bridge/src/index.ts +7 -7
- package/mcp/entwurf-bridge/tsconfig.build.json +8 -1
- package/package.json +19 -19
- package/pi/entwurf-capabilities.json +1 -0
- package/pi/pi-durable/bootstrap.mjs +404 -0
- package/pi/pi-durable/carrier/LICENSE +21 -0
- package/pi/pi-durable/carrier/harness-setup.js +123 -0
- package/pi/pi-durable/carrier/prompt.js +74 -0
- package/pi/pi-durable/carrier/runtime.js +402 -0
- package/pi/pi-durable/carrier/sessions.js +56 -0
- package/pi/pi-durable/carrier/subagent.js +55 -0
- package/pi/pi-durable/carrier/tui.js +655 -0
- package/pi/pi-durable/carrier-relocation.mjs +160 -0
- package/pi/pi-durable/carrier-resolver.mjs +38 -0
- package/pi/pi-durable/overlay/runtime-contacts.patch +91 -0
- package/pi/pi-durable/overlay/upstream-pin.json +108 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +7 -1
- package/pi-extensions/lib/fresh-call-composition.ts +42 -7
- package/pi-extensions/lib/meta-sender-identity.ts +7 -1
- package/pi-extensions/lib/meta-session.ts +28 -2
- package/pi-extensions/lib/mux-fresh-call.ts +32 -2
- package/pi-extensions/lib/pi-durable-fresh-preflight.ts +113 -0
- package/pi-extensions/lib/pi-durable-runtime.ts +318 -0
- package/pi-extensions/meta-bridge-pi-durable.ts +1024 -0
- package/run.sh +624 -184
- package/scripts/check-entwurf-bridge-pi-free.ts +7 -5
- package/scripts/check-fresh-call-dispatch.ts +18 -0
- package/scripts/check-gate-qualification.ts +87 -34
- package/scripts/check-herdr-activation.ts +5 -4
- package/scripts/check-pi-durable-contact.ts +767 -0
- package/scripts/check-pi-durable-receive.ts +1079 -0
- package/scripts/check-pi-durable-send.ts +1821 -0
- package/scripts/emit-pi-durable-carrier.ts +355 -0
- package/scripts/lib/mutation-qualify.ts +21 -45
- package/scripts/lib/pi-durable-fresh-live-receipts.ts +139 -0
- package/scripts/lib/pi-durable-operator-guard.ts +89 -0
- package/scripts/lib/pi-durable-r1.ts +100 -0
- package/scripts/lib/pi-durable-reap.ts +27 -0
- package/scripts/lib/pi-durable-receive.ts +137 -0
- package/scripts/lib/qualification-receipt.ts +7 -11
- package/scripts/mutants/control-send-receipt.json +1 -1
- package/scripts/mutants/copilot-birth.json +2 -2
- package/scripts/mutants/gate-qualification.json +13 -0
- package/scripts/mutants/meta-hook-session-switch.json +4 -2
- package/scripts/mutants/mux-fresh-call.json +69 -3
- package/scripts/mutants/omp-birth.json +2 -2
- package/scripts/mutants/omp-fresh.json +6 -4
- package/scripts/mutants/pack-install.json +3 -3
- package/scripts/mutants/pi-durable.json +723 -0
- package/scripts/mutants/pi-floor.json +24 -2
- package/scripts/pi-durable-contact-host.mjs +105 -0
- package/scripts/pi-durable-receive-host.mjs +127 -0
- package/scripts/pi-durable-send-host.mjs +284 -0
- package/scripts/smoke-herdr-raw-install-live.sh +1 -1
- package/scripts/smoke-pi-durable-fresh-live.ts +624 -0
- package/scripts/smoke-setup-verdict.sh +22 -3
- package/scripts/smoke-user-scope-citizen.sh +1 -1
- package/scripts/tsconfig.json +1 -0
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G-contact (#129) — the real overlay durable app, the real compiled bridge, one host.
|
|
3
|
+
* CHECKOUT-ONLY dev gate: an installed package refuses it in `run_ts` before it reads an input.
|
|
4
|
+
*
|
|
5
|
+
* LABEL: adapter-direct, harness-bypassed. The durable app is opened through the production
|
|
6
|
+
* bootstrap composition from THIS checkout's carrier (pi/pi-durable/carrier/, under its resolver,
|
|
7
|
+
* against the checkout's pinned pi SDK set — verified first), the contact is born, and its
|
|
8
|
+
* registered tools are executed DIRECTLY in the host (scripts/pi-durable-contact-host.mjs). No
|
|
9
|
+
* model turn, no ToolTask: this gate does NOT prove that the harness selects, validates or
|
|
10
|
+
* replays these tools, that a model accepts their schema, or anything about send / delivery /
|
|
11
|
+
* fresh / resume / visible identity. Of receive it observes only the ARM — the raw receiver marker,
|
|
12
|
+
* the contact's own `receiver()` state and the bridge's replyability verdict — never a doorbell, an
|
|
13
|
+
* admission or a drain; those are check-pi-durable-receive's.
|
|
14
|
+
*
|
|
15
|
+
* Input, required, not provisioned here (missing → SKIP, never a pass). The durable app itself is
|
|
16
|
+
* no longer an input: it is this checkout's carrier, refused by name before anything spawns when it
|
|
17
|
+
* or the SDK set it resolves is not the pin (#130).
|
|
18
|
+
* ENTWURF_PI_DURABLE_BRIDGE_ENTRY <bundle>/mcp/entwurf-bridge/src/index.js emitted from THIS
|
|
19
|
+
* checkout into a private bundle (a shared/installed bridge
|
|
20
|
+
* carries the old backend list and is refused as stale) — the
|
|
21
|
+
* bundle must carry the `pi-durable-host` owner join
|
|
22
|
+
*
|
|
23
|
+
* Matrix: garden roots {default (PI_CODING_AGENT_DIR), explicit (contact options, distinct from
|
|
24
|
+
* the default)} × open {fresh, --continue}. The explicit cells are the root-coupling proof: the
|
|
25
|
+
* child inherits an environment that points at the DEFAULT roots, so it can only find the birth
|
|
26
|
+
* if the contact bound it to the roots it wrote.
|
|
27
|
+
*
|
|
28
|
+
* Oracles are independent of the subject and of the bridge's sender resolver: records and
|
|
29
|
+
* markers are read as raw JSON from roots this gate built, the durable session directory is
|
|
30
|
+
* located by hashing the project path here, the bridge child is found by scanning /proc for a
|
|
31
|
+
* process whose PPID is the host pid this gate spawned, and peers is asked through a SECOND bridge
|
|
32
|
+
* whose parent is this gate — also the negative control that the host's identity does not leak
|
|
33
|
+
* to a process it did not spawn. The host runs with poisoned identity carriers; the cell is green
|
|
34
|
+
* only if the contact keeps them from its bridge child. That is proven at the SDK CONNECTOR BOUNDARY
|
|
35
|
+
* — the spawn spec the adapter hands the production stdio connector, recorded by the host driver
|
|
36
|
+
* (identity carrier names and the four root values only) — plus the bridge's own behaviour
|
|
37
|
+
* (entwurf_self, the raw records/markers, peers). It is NOT a kernel child-environ observation:
|
|
38
|
+
* this gate reads no /proc/<pid>/environ.
|
|
39
|
+
*
|
|
40
|
+
* After a host is spawned, every failure is THROWN, and the gate reaps only the processes it
|
|
41
|
+
* owns — the host it spawned and the bridges observed as that host's children (pid + start key) —
|
|
42
|
+
* before reporting the cause and any cleanup failure. `ENTWURF_PI_DURABLE_CONTACT_FAULT` is the
|
|
43
|
+
* gate's own control for that path: `after-birth` throws right after the first birth;
|
|
44
|
+
* `malformed-report` makes the gate's host driver send a non-JSON report line, which arrives while
|
|
45
|
+
* the gate is already waiting for it (the pending-line path of `Host.next`).
|
|
46
|
+
*/
|
|
47
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
48
|
+
import { createHash } from "node:crypto";
|
|
49
|
+
import * as fs from "node:fs";
|
|
50
|
+
import * as os from "node:os";
|
|
51
|
+
import * as path from "node:path";
|
|
52
|
+
import { createInterface } from "node:readline";
|
|
53
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
54
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
55
|
+
import { checkPiDurableRuntime, piDurableLayoutAt } from "../pi-extensions/lib/pi-durable-runtime.ts";
|
|
56
|
+
import { skipLive } from "./lib/live-skip.ts";
|
|
57
|
+
import {
|
|
58
|
+
describeOperatorSnapshot,
|
|
59
|
+
operatorPiDurableSnapshot,
|
|
60
|
+
operatorSnapshotUnchanged,
|
|
61
|
+
} from "./lib/pi-durable-operator-guard.ts";
|
|
62
|
+
|
|
63
|
+
const LABEL = "check-pi-durable-contact";
|
|
64
|
+
const REPO = path.resolve(import.meta.dirname, "..");
|
|
65
|
+
const HOST_DRIVER = path.join(REPO, "scripts", "pi-durable-contact-host.mjs");
|
|
66
|
+
const REPORT_TIMEOUT_MS = 90_000;
|
|
67
|
+
const CLOSE_TIMEOUT_MS = 30_000;
|
|
68
|
+
const REAP_TIMEOUT_MS = 5_000;
|
|
69
|
+
const FAULT = process.env.ENTWURF_PI_DURABLE_CONTACT_FAULT?.trim() || undefined;
|
|
70
|
+
if (FAULT !== undefined && FAULT !== "after-birth" && FAULT !== "malformed-report") {
|
|
71
|
+
console.error(
|
|
72
|
+
`${LABEL}: unknown ENTWURF_PI_DURABLE_CONTACT_FAULT ${JSON.stringify(FAULT)} (only "after-birth", "malformed-report")`,
|
|
73
|
+
);
|
|
74
|
+
process.exit(2);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const bridgeInput = process.env.ENTWURF_PI_DURABLE_BRIDGE_ENTRY?.trim();
|
|
78
|
+
if (!bridgeInput) {
|
|
79
|
+
skipLive(
|
|
80
|
+
LABEL,
|
|
81
|
+
"set ENTWURF_PI_DURABLE_BRIDGE_ENTRY=<bridge index.js emitted from this checkout into a private bundle>; " +
|
|
82
|
+
"this gate does not provision it",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const bridgeEntry = path.resolve(bridgeInput);
|
|
86
|
+
|
|
87
|
+
let passed = 0;
|
|
88
|
+
let failed = 0;
|
|
89
|
+
function ok(label: string, cond: boolean): void {
|
|
90
|
+
if (cond) {
|
|
91
|
+
console.log(` ok ${label}`);
|
|
92
|
+
passed++;
|
|
93
|
+
} else {
|
|
94
|
+
console.log(` FAIL ${label}`);
|
|
95
|
+
failed++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Pre-spawn only: nothing is owned yet, so exiting leaves nothing behind. */
|
|
99
|
+
function fatal(message: string): never {
|
|
100
|
+
console.error(`${LABEL}: ${message}`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// 0. Inputs. A provisioned-but-wrong input is broken, not absent: these FAIL, they do not skip.
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// The carrier and its SDK set are verified by the ONE verifier the managed launch, setup and the
|
|
108
|
+
// fresh preflight use (pi-extensions/lib/pi-durable-runtime.ts), from this checkout's layout.
|
|
109
|
+
const carrier = checkPiDurableRuntime(piDurableLayoutAt(REPO));
|
|
110
|
+
console.log("0. inputs: the checkout carrier and its pi SDK set, bridge bundle registry");
|
|
111
|
+
ok(
|
|
112
|
+
`[QK:PDC-CARRIER-VERIFIED] this checkout's pi-durable carrier and the pi SDK set it resolves are the pin (${carrier.ok ? "verified" : `${carrier.reason}: ${carrier.detail}`})`,
|
|
113
|
+
carrier.ok,
|
|
114
|
+
);
|
|
115
|
+
const resolver = carrier.ok ? carrier.resolver : "";
|
|
116
|
+
const bundleRoot = path.resolve(bridgeEntry, "..", "..", "..", "..");
|
|
117
|
+
const bundleRegistry = path.join(bundleRoot, "pi-extensions", "entwurf-capabilities.json");
|
|
118
|
+
const repoRegistry = path.join(REPO, "pi", "entwurf-capabilities.json");
|
|
119
|
+
ok(
|
|
120
|
+
`[QK:PDC-BUNDLE-REGISTRY-MATCH] the bridge bundle carries a capability registry byte-identical to this checkout's (${bundleRegistry}) — a registry fact only; the bundle's code provenance is its build receipt, not this assertion`,
|
|
121
|
+
fs.existsSync(bridgeEntry) &&
|
|
122
|
+
fs.existsSync(bundleRegistry) &&
|
|
123
|
+
fs.readFileSync(bundleRegistry).equals(fs.readFileSync(repoRegistry)),
|
|
124
|
+
);
|
|
125
|
+
// The compiled bundle's owner join is what decides the armed host's replyability below; a bundle
|
|
126
|
+
// emitted before the join would answer for the wrong source. Read from the emitted bytes, not inferred.
|
|
127
|
+
const bundleDeliverability = path.join(bundleRoot, "pi-extensions", "lib", "entwurf-deliverability.js");
|
|
128
|
+
const joinLine = fs.existsSync(bundleDeliverability)
|
|
129
|
+
? (/SENDER_JOINED_RECEIVER_OWNER_KINDS\s*=\s*(\[[^\]]*\])/.exec(fs.readFileSync(bundleDeliverability, "utf8"))?.[1] ??
|
|
130
|
+
"")
|
|
131
|
+
: "";
|
|
132
|
+
ok(
|
|
133
|
+
`[QK:PDC-BUNDLE-OWNER-JOIN] the bridge bundle's compiled owner join admits pi-durable-host (${joinLine || "not found"})`,
|
|
134
|
+
joinLine.includes('"pi-durable-host"'),
|
|
135
|
+
);
|
|
136
|
+
if (failed > 0) fatal("inputs are not the verified carrier, this checkout's registry and owner join — refusing to run");
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Operator guard — COARSE and pi-durable-specific: the operator's own roots gain no pi-durable
|
|
140
|
+
// record, sender dir or durable session tree. Not a proof about any other operator byte.
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
const operatorAgent = path.join(os.homedir(), ".pi", "agent");
|
|
143
|
+
const operatorBefore = operatorPiDurableSnapshot(operatorAgent);
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Sandbox: private root, env built from nothing, children at umask 022.
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-durable-contact-"));
|
|
149
|
+
const dirs = {
|
|
150
|
+
home: path.join(root, "home"),
|
|
151
|
+
agent: path.join(root, "agent"),
|
|
152
|
+
tmp: path.join(root, "tmp"),
|
|
153
|
+
projectDefault: path.join(root, "project-default"),
|
|
154
|
+
projectExplicit: path.join(root, "project-explicit"),
|
|
155
|
+
explicitSessions: path.join(root, "explicit-roots", "sessions"),
|
|
156
|
+
explicitSenders: path.join(root, "explicit-roots", "senders"),
|
|
157
|
+
explicitMailbox: path.join(root, "explicit-roots", "mailbox"),
|
|
158
|
+
explicitReceivers: path.join(root, "explicit-roots", "receivers"),
|
|
159
|
+
xdgConfig: path.join(root, "xdg", "config"),
|
|
160
|
+
xdgCache: path.join(root, "xdg", "cache"),
|
|
161
|
+
xdgData: path.join(root, "xdg", "data"),
|
|
162
|
+
xdgState: path.join(root, "xdg", "state"),
|
|
163
|
+
};
|
|
164
|
+
for (const dir of Object.values(dirs)) fs.mkdirSync(dir, { recursive: true });
|
|
165
|
+
process.umask(0o022);
|
|
166
|
+
const sandboxEnv: Record<string, string> = {
|
|
167
|
+
PATH: process.env.PATH ?? "/run/current-system/sw/bin",
|
|
168
|
+
HOME: dirs.home,
|
|
169
|
+
TMPDIR: dirs.tmp,
|
|
170
|
+
XDG_CONFIG_HOME: dirs.xdgConfig,
|
|
171
|
+
XDG_CACHE_HOME: dirs.xdgCache,
|
|
172
|
+
XDG_DATA_HOME: dirs.xdgData,
|
|
173
|
+
XDG_STATE_HOME: dirs.xdgState,
|
|
174
|
+
PI_CODING_AGENT_DIR: dirs.agent,
|
|
175
|
+
PI_OFFLINE: "1",
|
|
176
|
+
NOSYSBASHRC: "1",
|
|
177
|
+
TERM: "dumb",
|
|
178
|
+
};
|
|
179
|
+
const poisoned = {
|
|
180
|
+
PI_SESSION_ID: "20260101T000000-000000",
|
|
181
|
+
PI_AGENT_ID: "pi/poisoned",
|
|
182
|
+
ENTWURF_META_SENDER_MARKER: path.join(root, "poisoned-marker.json"),
|
|
183
|
+
ENTWURF_BRIDGE_NATIVE_HOST: "codex",
|
|
184
|
+
};
|
|
185
|
+
const hostEnv: Record<string, string> = {
|
|
186
|
+
...sandboxEnv,
|
|
187
|
+
...poisoned,
|
|
188
|
+
...(FAULT === "malformed-report" ? { ENTWURF_PI_DURABLE_CONTACT_FAULT: FAULT } : {}),
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
interface RootsCase {
|
|
192
|
+
name: "default" | "explicit";
|
|
193
|
+
project: string;
|
|
194
|
+
sessionsDir: string;
|
|
195
|
+
sendersDir: string;
|
|
196
|
+
mailboxDir: string;
|
|
197
|
+
receiversDir: string;
|
|
198
|
+
/** Extra host-driver argv: explicit roots go to the contact; default passes none. */
|
|
199
|
+
hostArgs: string[];
|
|
200
|
+
}
|
|
201
|
+
const defaultRoots: RootsCase = {
|
|
202
|
+
name: "default",
|
|
203
|
+
project: dirs.projectDefault,
|
|
204
|
+
sessionsDir: path.join(dirs.agent, "meta-sessions"),
|
|
205
|
+
sendersDir: path.join(dirs.agent, "meta-senders"),
|
|
206
|
+
mailboxDir: path.join(dirs.agent, "meta-mailbox"),
|
|
207
|
+
receiversDir: path.join(dirs.agent, "meta-receivers"),
|
|
208
|
+
hostArgs: [],
|
|
209
|
+
};
|
|
210
|
+
const explicitRoots: RootsCase = {
|
|
211
|
+
name: "explicit",
|
|
212
|
+
project: dirs.projectExplicit,
|
|
213
|
+
sessionsDir: dirs.explicitSessions,
|
|
214
|
+
sendersDir: dirs.explicitSenders,
|
|
215
|
+
mailboxDir: dirs.explicitMailbox,
|
|
216
|
+
receiversDir: dirs.explicitReceivers,
|
|
217
|
+
hostArgs: [dirs.explicitSessions, dirs.explicitSenders, dirs.explicitMailbox, dirs.explicitReceivers],
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// /proc helpers
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
function procStat(pid: number): { ppid: number; start: string } | null {
|
|
224
|
+
try {
|
|
225
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
226
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
227
|
+
return { ppid: Number(fields[1]), start: fields[19] as string };
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function procArgv(pid: number): string[] {
|
|
234
|
+
try {
|
|
235
|
+
return fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean);
|
|
236
|
+
} catch {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Every process whose PPID is `parent` and whose argv runs `entry`. */
|
|
242
|
+
function childrenRunning(parent: number, entry: string): number[] {
|
|
243
|
+
return fs
|
|
244
|
+
.readdirSync("/proc")
|
|
245
|
+
.filter((n) => /^\d+$/.test(n))
|
|
246
|
+
.map(Number)
|
|
247
|
+
.filter((pid) => procStat(pid)?.ppid === parent && procArgv(pid).includes(entry));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
251
|
+
|
|
252
|
+
/** Wait (bounded) until `pid` with start key `start` no longer exists. */
|
|
253
|
+
async function goneWithin(pid: number, start: string, ms: number): Promise<boolean> {
|
|
254
|
+
const deadline = Date.now() + ms;
|
|
255
|
+
while (Date.now() < deadline) {
|
|
256
|
+
if (procStat(pid)?.start !== start) return true;
|
|
257
|
+
await sleep(50);
|
|
258
|
+
}
|
|
259
|
+
return procStat(pid)?.start !== start;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Owned hosts: spawned here, reaped here — nothing else is touched.
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
interface OwnedProcess {
|
|
266
|
+
pid: number;
|
|
267
|
+
start: string;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
interface Host {
|
|
271
|
+
child: ChildProcess;
|
|
272
|
+
pid: number;
|
|
273
|
+
start: string;
|
|
274
|
+
/** Bridges observed as this host's children, recorded while the host was alive. */
|
|
275
|
+
bridges: OwnedProcess[];
|
|
276
|
+
next(timeoutMs: number): Promise<Record<string, unknown>>;
|
|
277
|
+
stderr(): string;
|
|
278
|
+
exitCode: number | null | undefined;
|
|
279
|
+
exited: Promise<number | null>;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const owned: Host[] = [];
|
|
283
|
+
|
|
284
|
+
function observeBridges(host: Host): void {
|
|
285
|
+
for (const pid of childrenRunning(host.pid, bridgeEntry)) {
|
|
286
|
+
const start = procStat(pid)?.start;
|
|
287
|
+
if (start !== undefined && !host.bridges.some((b) => b.pid === pid)) host.bridges.push({ pid, start });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function startHost(roots: RootsCase, mode: "new" | "continue"): Host {
|
|
292
|
+
const child = spawn(process.execPath, ["--import", resolver, HOST_DRIVER, bridgeEntry, mode, ...roots.hostArgs], {
|
|
293
|
+
cwd: roots.project,
|
|
294
|
+
env: hostEnv,
|
|
295
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
296
|
+
});
|
|
297
|
+
// Stream and process errors are recorded and surface through next()/exit — never as an
|
|
298
|
+
// uncaught event that would skip the single cleanup (a write to an exited host's stdin is EPIPE).
|
|
299
|
+
let streamError = "";
|
|
300
|
+
child.on("error", (error) => {
|
|
301
|
+
streamError += ` [host process error: ${error.message}]`;
|
|
302
|
+
});
|
|
303
|
+
child.stdin?.on("error", (error) => {
|
|
304
|
+
streamError += ` [host stdin error: ${error.message}]`;
|
|
305
|
+
});
|
|
306
|
+
const pid = child.pid;
|
|
307
|
+
if (pid === undefined) throw new Error(`the host did not spawn${streamError}`);
|
|
308
|
+
let stderr = "";
|
|
309
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
310
|
+
stderr = (stderr + chunk.toString("utf8")).slice(-8192);
|
|
311
|
+
});
|
|
312
|
+
const queue: string[] = [];
|
|
313
|
+
const waiters: ((line: string | null) => void)[] = [];
|
|
314
|
+
let ended = false;
|
|
315
|
+
createInterface({ input: child.stdout as NodeJS.ReadableStream })
|
|
316
|
+
.on("line", (line) => {
|
|
317
|
+
const waiter = waiters.shift();
|
|
318
|
+
if (waiter) waiter(line);
|
|
319
|
+
else queue.push(line);
|
|
320
|
+
})
|
|
321
|
+
.on("close", () => {
|
|
322
|
+
ended = true;
|
|
323
|
+
for (const waiter of waiters.splice(0)) waiter(null);
|
|
324
|
+
});
|
|
325
|
+
const host: Host = {
|
|
326
|
+
child,
|
|
327
|
+
pid,
|
|
328
|
+
start: procStat(pid)?.start ?? "",
|
|
329
|
+
bridges: [],
|
|
330
|
+
stderr: () => stderr,
|
|
331
|
+
exitCode: undefined,
|
|
332
|
+
exited: new Promise<number | null>((resolve) =>
|
|
333
|
+
child.on("exit", (code) => {
|
|
334
|
+
host.exitCode = code;
|
|
335
|
+
resolve(code);
|
|
336
|
+
}),
|
|
337
|
+
),
|
|
338
|
+
next(timeoutMs) {
|
|
339
|
+
const parse = (line: string, path: "queued" | "pending"): Record<string, unknown> => {
|
|
340
|
+
try {
|
|
341
|
+
return JSON.parse(line) as Record<string, unknown>;
|
|
342
|
+
} catch (error) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`host sent a malformed line on the ${path}-line path (${(error as Error).message}): ${line.slice(0, 200)}`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
return new Promise((resolve, reject) => {
|
|
349
|
+
const queued = queue.shift();
|
|
350
|
+
if (queued !== undefined) return resolve(parse(queued, "queued"));
|
|
351
|
+
if (ended) return reject(new Error(`host ended without a line${streamError}; stderr: ${stderr.trim()}`));
|
|
352
|
+
const timer = setTimeout(
|
|
353
|
+
() => reject(new Error(`host gave no line within ${timeoutMs}ms${streamError}; stderr: ${stderr.trim()}`)),
|
|
354
|
+
timeoutMs,
|
|
355
|
+
);
|
|
356
|
+
// Runs in the readline callback, not the executor: a throw here would escape as an
|
|
357
|
+
// uncaught exception, so every outcome is settled explicitly.
|
|
358
|
+
waiters.push((line) => {
|
|
359
|
+
clearTimeout(timer);
|
|
360
|
+
if (line === null) {
|
|
361
|
+
reject(new Error(`host ended without a line${streamError}; stderr: ${stderr.trim()}`));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
resolve(parse(line, "pending"));
|
|
366
|
+
} catch (error) {
|
|
367
|
+
reject(error);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
owned.push(host);
|
|
374
|
+
return host;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Ask the host to close; it must answer and exit 0 within the bound. */
|
|
378
|
+
async function closeHost(host: Host): Promise<{ closed: boolean; code: number | null | undefined }> {
|
|
379
|
+
host.child.stdin?.write("close\n");
|
|
380
|
+
const line = await host.next(CLOSE_TIMEOUT_MS);
|
|
381
|
+
await Promise.race([host.exited, sleep(CLOSE_TIMEOUT_MS)]);
|
|
382
|
+
return { closed: line.closed === true, code: host.exitCode };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Reap one owned host and the bridges observed under it: TERM, bounded wait, KILL, bounded wait.
|
|
387
|
+
* A pid is signalled only while its start key still matches what was recorded.
|
|
388
|
+
*/
|
|
389
|
+
async function reap(host: Host): Promise<string[]> {
|
|
390
|
+
const problems: string[] = [];
|
|
391
|
+
if (host.exitCode === undefined && procStat(host.pid)?.start === host.start) observeBridges(host);
|
|
392
|
+
const targets: (OwnedProcess & { what: string })[] = [
|
|
393
|
+
{ pid: host.pid, start: host.start, what: "host" },
|
|
394
|
+
...host.bridges.map((b) => ({ ...b, what: "bridge" })),
|
|
395
|
+
];
|
|
396
|
+
for (const target of targets) {
|
|
397
|
+
if (procStat(target.pid)?.start !== target.start) continue;
|
|
398
|
+
try {
|
|
399
|
+
process.kill(target.pid, "SIGTERM");
|
|
400
|
+
} catch (error) {
|
|
401
|
+
problems.push(`${target.what} ${target.pid}: SIGTERM failed (${(error as Error).message})`);
|
|
402
|
+
}
|
|
403
|
+
if (await goneWithin(target.pid, target.start, REAP_TIMEOUT_MS)) continue;
|
|
404
|
+
try {
|
|
405
|
+
process.kill(target.pid, "SIGKILL");
|
|
406
|
+
} catch (error) {
|
|
407
|
+
problems.push(`${target.what} ${target.pid}: SIGKILL failed (${(error as Error).message})`);
|
|
408
|
+
}
|
|
409
|
+
if (!(await goneWithin(target.pid, target.start, REAP_TIMEOUT_MS))) {
|
|
410
|
+
problems.push(`${target.what} ${target.pid} is still alive after SIGKILL`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return problems;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// Independent oracles
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
function recordsIn(dir: string): Record<string, unknown>[] {
|
|
420
|
+
if (!fs.existsSync(dir)) return [];
|
|
421
|
+
return fs
|
|
422
|
+
.readdirSync(dir)
|
|
423
|
+
.filter((n) => n.endsWith(".meta.json"))
|
|
424
|
+
.map((n) => JSON.parse(fs.readFileSync(path.join(dir, n), "utf8")) as Record<string, unknown>);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Durable session directories for a project, located by hashing its real path here. Only names in
|
|
429
|
+
* the vendor's session grammar count: proper-lockfile keeps a `<session>.lock` DIRECTORY beside a
|
|
430
|
+
* locked session (measured, first G-contact run), and that is not a session.
|
|
431
|
+
*/
|
|
432
|
+
const DURABLE_SESSION_NAME = /^\d{13}-[0-9a-f-]{36}$/;
|
|
433
|
+
function durableSessionDirs(project: string): string[] {
|
|
434
|
+
const dir = path.join(
|
|
435
|
+
dirs.agent,
|
|
436
|
+
"experimental",
|
|
437
|
+
"durable-sessions",
|
|
438
|
+
createHash("sha256").update(fs.realpathSync(project)).digest("hex").slice(0, 24),
|
|
439
|
+
);
|
|
440
|
+
return fs.existsSync(dir)
|
|
441
|
+
? fs.readdirSync(dir).filter((n) => DURABLE_SESSION_NAME.test(n) && fs.statSync(path.join(dir, n)).isDirectory())
|
|
442
|
+
: [];
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function lastJsonLine(text: string): Record<string, unknown> {
|
|
446
|
+
const line = text.trim().split("\n").at(-1) ?? "";
|
|
447
|
+
try {
|
|
448
|
+
return JSON.parse(line) as Record<string, unknown>;
|
|
449
|
+
} catch {
|
|
450
|
+
return {};
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** A bridge spawned by THIS gate on the case's roots — its parent is not the host. */
|
|
455
|
+
async function outsideBridge(roots: RootsCase): Promise<{ peers: string; self: { text: string; isError: boolean } }> {
|
|
456
|
+
const transport = new StdioClientTransport({
|
|
457
|
+
command: process.execPath,
|
|
458
|
+
args: [bridgeEntry],
|
|
459
|
+
env: {
|
|
460
|
+
...sandboxEnv,
|
|
461
|
+
ENTWURF_META_SESSIONS_DIR: roots.sessionsDir,
|
|
462
|
+
ENTWURF_META_SENDERS_DIR: roots.sendersDir,
|
|
463
|
+
ENTWURF_META_MAILBOX_DIR: roots.mailboxDir,
|
|
464
|
+
ENTWURF_META_RECEIVERS_DIR: roots.receiversDir,
|
|
465
|
+
},
|
|
466
|
+
cwd: roots.project,
|
|
467
|
+
stderr: "pipe",
|
|
468
|
+
});
|
|
469
|
+
transport.stderr?.on("data", () => {});
|
|
470
|
+
const client = new Client({ name: "check-pi-durable-contact", version: "1" });
|
|
471
|
+
try {
|
|
472
|
+
await client.connect(transport);
|
|
473
|
+
} catch (error) {
|
|
474
|
+
// A failed connect may already have spawned the bridge: close the transport (which ends the
|
|
475
|
+
// child it spawned) before rethrowing, so no unowned bridge outlives the gate; keep the cause.
|
|
476
|
+
try {
|
|
477
|
+
await transport.close();
|
|
478
|
+
} catch (closeError) {
|
|
479
|
+
throw new AggregateError([error, closeError], `outside bridge did not connect: ${(error as Error).message}`);
|
|
480
|
+
}
|
|
481
|
+
throw error;
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
const text = (r: Awaited<ReturnType<Client["callTool"]>>): string =>
|
|
485
|
+
(Array.isArray(r.content) ? r.content : [])
|
|
486
|
+
.flatMap((b) => (b.type === "text" && typeof b.text === "string" ? [b.text] : []))
|
|
487
|
+
.join("\n");
|
|
488
|
+
const peers = text(await client.callTool({ name: "entwurf_peers", arguments: {} }));
|
|
489
|
+
const selfResult = await client.callTool({ name: "entwurf_self", arguments: {} });
|
|
490
|
+
return { peers, self: { text: text(selfResult), isError: selfResult.isError === true } };
|
|
491
|
+
} finally {
|
|
492
|
+
await client.close();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
// The cell
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
interface HostReport {
|
|
500
|
+
hostPid: number;
|
|
501
|
+
session: { id: string; directory: string; cwd: string };
|
|
502
|
+
attachment: {
|
|
503
|
+
gardenId: string;
|
|
504
|
+
action: string;
|
|
505
|
+
recordPath: string;
|
|
506
|
+
markerPath: string;
|
|
507
|
+
roots: { sessionsDir: string; sendersDir: string; mailboxDir: string; receiversDir: string };
|
|
508
|
+
bridgePid: number | null;
|
|
509
|
+
};
|
|
510
|
+
receiver: { status: string; gardenId?: string; markerPath?: string; admissions?: unknown[] };
|
|
511
|
+
toolNames: string[];
|
|
512
|
+
/** Spawn specs the adapter handed the production stdio connector, as the host driver recorded them. */
|
|
513
|
+
bridgeSpecs?: {
|
|
514
|
+
command: string;
|
|
515
|
+
args: string[];
|
|
516
|
+
cwd: string;
|
|
517
|
+
identityCarrierPresent: Record<string, boolean>;
|
|
518
|
+
rootCarriers: Record<string, string | null>;
|
|
519
|
+
}[];
|
|
520
|
+
self: string;
|
|
521
|
+
peers: string;
|
|
522
|
+
notices: string[];
|
|
523
|
+
error?: string;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* The SDK connector-boundary QKs. They need only the case's roots and project, so they are judged on a
|
|
528
|
+
* host's failure report too — before the gate throws that report's error, which stays the cause.
|
|
529
|
+
*/
|
|
530
|
+
function judgeBridgeSpec(
|
|
531
|
+
report: { hostPid?: number; bridgeSpecs?: HostReport["bridgeSpecs"] },
|
|
532
|
+
host: Host,
|
|
533
|
+
roots: RootsCase,
|
|
534
|
+
projectReal: string,
|
|
535
|
+
): void {
|
|
536
|
+
// SDK connector boundary, not kernel environ: the ONE spawn spec the adapter handed the production
|
|
537
|
+
// stdio connector, as the host driver recorded it. Absent, repeated, malformed or from another pid is red.
|
|
538
|
+
const spec = report.hostPid === host.pid && report.bridgeSpecs?.length === 1 ? report.bridgeSpecs[0] : undefined;
|
|
539
|
+
const identityNames = Object.keys(poisoned);
|
|
540
|
+
ok(
|
|
541
|
+
`[QK:PDC-BRIDGE-ENV-SCRUBBED] at the SDK connector boundary (the one spawn spec the adapter handed the production stdio connector — node on this gate's bridge entry in the project; not a kernel child-environ read), the bridge's environment carries none of the host's four poisoned identity carrier names (${report.bridgeSpecs?.length ?? "no"} spec(s), ${JSON.stringify(spec?.identityCarrierPresent ?? null)})`,
|
|
542
|
+
spec !== undefined &&
|
|
543
|
+
spec.command === process.execPath &&
|
|
544
|
+
JSON.stringify(spec.args) === JSON.stringify([bridgeEntry]) &&
|
|
545
|
+
spec.cwd === projectReal &&
|
|
546
|
+
JSON.stringify(Object.keys(spec.identityCarrierPresent ?? {})) === JSON.stringify(identityNames) &&
|
|
547
|
+
identityNames.every((name) => spec.identityCarrierPresent[name] === false),
|
|
548
|
+
);
|
|
549
|
+
ok(
|
|
550
|
+
`[QK:PDC-BRIDGE-ROOTS-BOUND] at the same SDK connector boundary, the bridge's four root carriers are the case's literal roots, whatever the host inherited (${JSON.stringify(spec?.rootCarriers ?? null)})`,
|
|
551
|
+
spec !== undefined &&
|
|
552
|
+
Object.keys(spec.rootCarriers ?? {}).length === 4 &&
|
|
553
|
+
spec.rootCarriers.ENTWURF_META_SESSIONS_DIR === roots.sessionsDir &&
|
|
554
|
+
spec.rootCarriers.ENTWURF_META_SENDERS_DIR === roots.sendersDir &&
|
|
555
|
+
spec.rootCarriers.ENTWURF_META_MAILBOX_DIR === roots.mailboxDir &&
|
|
556
|
+
spec.rootCarriers.ENTWURF_META_RECEIVERS_DIR === roots.receiversDir,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function birthCell(
|
|
561
|
+
step: string,
|
|
562
|
+
roots: RootsCase,
|
|
563
|
+
mode: "new" | "continue",
|
|
564
|
+
expectGardenId: string | null,
|
|
565
|
+
): Promise<string> {
|
|
566
|
+
console.log(`${step}. roots=${roots.name}, ${mode === "new" ? "fresh host" : "reopen with --continue"} (input 0)`);
|
|
567
|
+
const projectReal = fs.realpathSync(roots.project);
|
|
568
|
+
const defaultStoreBefore = recordsIn(defaultRoots.sessionsDir).length;
|
|
569
|
+
const host = startHost(roots, mode);
|
|
570
|
+
const report = (await host.next(REPORT_TIMEOUT_MS)) as unknown as HostReport;
|
|
571
|
+
if (report.error !== undefined) {
|
|
572
|
+
judgeBridgeSpec(report, host, roots, projectReal);
|
|
573
|
+
throw new Error(`host failed: ${report.error}; stderr: ${host.stderr().trim()}`);
|
|
574
|
+
}
|
|
575
|
+
observeBridges(host);
|
|
576
|
+
const gardenId = report.attachment.gardenId;
|
|
577
|
+
if (FAULT === "after-birth") {
|
|
578
|
+
console.log(
|
|
579
|
+
` owned: host=${host.pid} bridges=${host.bridges.map((b) => b.pid).join(",") || "none"} garden=${gardenId}`,
|
|
580
|
+
);
|
|
581
|
+
throw new Error("[controlled fault] after-birth: the gate fails on purpose with its host and bridge alive");
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
ok(
|
|
585
|
+
`[QK:PDC-HOST-IS-SPAWNED] the reporting host is the process this gate spawned (${report.hostPid})`,
|
|
586
|
+
report.hostPid === host.pid,
|
|
587
|
+
);
|
|
588
|
+
ok(
|
|
589
|
+
`[QK:PDC-TOOLS] the registered extension exposes exactly entwurf_self, entwurf_peers, entwurf_v2, entwurf_inbox_read, entwurf_callback, entwurf_fresh_call, in that order (${report.toolNames.join(", ")})`,
|
|
590
|
+
JSON.stringify(report.toolNames) ===
|
|
591
|
+
JSON.stringify([
|
|
592
|
+
"entwurf_self",
|
|
593
|
+
"entwurf_peers",
|
|
594
|
+
"entwurf_v2",
|
|
595
|
+
"entwurf_inbox_read",
|
|
596
|
+
"entwurf_callback",
|
|
597
|
+
"entwurf_fresh_call",
|
|
598
|
+
]),
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
const records = recordsIn(roots.sessionsDir);
|
|
602
|
+
const record = records.find((r) => r.nativeSessionId === report.session.id) ?? {};
|
|
603
|
+
ok(
|
|
604
|
+
`[QK:PDC-ONE-RECORD] the case's store (${roots.sessionsDir}) holds exactly one record (got ${records.length})`,
|
|
605
|
+
records.length === 1,
|
|
606
|
+
);
|
|
607
|
+
ok(
|
|
608
|
+
`[QK:PDC-RECORD-BINDING] that record is pi-durable, keyed by the durable session id, at the project's real cwd (${String(record.backend)}, ${String(record.nativeSessionId)})`,
|
|
609
|
+
record.backend === "pi-durable" && record.cwd === projectReal && record.gardenId === gardenId,
|
|
610
|
+
);
|
|
611
|
+
const sessionDirs = durableSessionDirs(roots.project);
|
|
612
|
+
ok(
|
|
613
|
+
`[QK:PDC-NATIVE-ID-IS-DIRECTORY] the native id is the one durable session directory under the project's hash (${sessionDirs.join(", ")})`,
|
|
614
|
+
sessionDirs.length === 1 && sessionDirs[0] === report.session.id,
|
|
615
|
+
);
|
|
616
|
+
if (expectGardenId === null) {
|
|
617
|
+
ok(
|
|
618
|
+
`[QK:PDC-CREATE] a fresh host creates its record (action ${report.attachment.action})`,
|
|
619
|
+
report.attachment.action === "create",
|
|
620
|
+
);
|
|
621
|
+
} else {
|
|
622
|
+
ok(
|
|
623
|
+
`[QK:PDC-REOPEN-SAME-GARDEN] --continue reattaches the same garden id (${gardenId} vs ${expectGardenId}, action ${report.attachment.action})`,
|
|
624
|
+
gardenId === expectGardenId && report.attachment.action === "attach",
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
ok(
|
|
628
|
+
`[QK:PDC-ROOTS-REPORTED] the contact reports the case's four roots (${Object.values(report.attachment.roots).join(", ")})`,
|
|
629
|
+
report.attachment.roots.sessionsDir === roots.sessionsDir &&
|
|
630
|
+
report.attachment.roots.sendersDir === roots.sendersDir &&
|
|
631
|
+
report.attachment.roots.mailboxDir === roots.mailboxDir &&
|
|
632
|
+
report.attachment.roots.receiversDir === roots.receiversDir,
|
|
633
|
+
);
|
|
634
|
+
if (roots.name === "explicit") {
|
|
635
|
+
const defaultRecords = recordsIn(defaultRoots.sessionsDir);
|
|
636
|
+
ok(
|
|
637
|
+
`[QK:PDC-EXPLICIT-NO-DEFAULT-WRITE] an explicit-root birth leaves the default roots alone (default store ${defaultStoreBefore} → ${defaultRecords.length} records, none for this session; no default sender marker, receiver marker or mailbox for it)`,
|
|
638
|
+
defaultRecords.length === defaultStoreBefore &&
|
|
639
|
+
!defaultRecords.some((r) => r.nativeSessionId === report.session.id) &&
|
|
640
|
+
!fs.existsSync(path.join(defaultRoots.sendersDir, "pi-durable", `${host.pid}.json`)) &&
|
|
641
|
+
!fs.existsSync(path.join(defaultRoots.receiversDir, `${gardenId}.json`)) &&
|
|
642
|
+
!fs.existsSync(path.join(defaultRoots.mailboxDir, gardenId)),
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const markerFile = path.join(roots.sendersDir, "pi-durable", `${host.pid}.json`);
|
|
647
|
+
const marker = fs.existsSync(markerFile)
|
|
648
|
+
? (JSON.parse(fs.readFileSync(markerFile, "utf8")) as Record<string, unknown>)
|
|
649
|
+
: {};
|
|
650
|
+
ok(
|
|
651
|
+
`[QK:PDC-SENDER-MARKER] the sender marker under the case's sender root is keyed to the spawned host pid with its /proc start-key and names the record`,
|
|
652
|
+
marker.gardenId === gardenId &&
|
|
653
|
+
marker.ownerPid === host.pid &&
|
|
654
|
+
marker.ownerStartKey === `linux:${host.start}` &&
|
|
655
|
+
marker.nativeSessionId === report.session.id,
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
const bridgePid = host.bridges[0]?.pid ?? -1;
|
|
659
|
+
ok(
|
|
660
|
+
`[QK:PDC-PPID-DIRECT] /proc shows exactly one bridge child whose PPID is the host and whose argv[0] is node — no shell between (pids ${host.bridges.map((b) => b.pid).join(", ") || "none"}, reported ${report.attachment.bridgePid})`,
|
|
661
|
+
host.bridges.length === 1 &&
|
|
662
|
+
bridgePid === report.attachment.bridgePid &&
|
|
663
|
+
procArgv(bridgePid)[0] === process.execPath,
|
|
664
|
+
);
|
|
665
|
+
judgeBridgeSpec(report, host, roots, projectReal);
|
|
666
|
+
|
|
667
|
+
const self = lastJsonLine(report.self);
|
|
668
|
+
ok(
|
|
669
|
+
`[QK:PDC-SELF] entwurf_self through the host's bridge names the record's garden id as a meta-session pi-durable sender (${String(self.sessionId)}, ${String(self.agentId)})`,
|
|
670
|
+
self.sessionId === gardenId &&
|
|
671
|
+
self.agentId === "meta-session/pi-durable" &&
|
|
672
|
+
self.origin === "meta-session" &&
|
|
673
|
+
self.cwd === projectReal,
|
|
674
|
+
);
|
|
675
|
+
// The arm, read raw: a path proves nothing, so the marker's owner is checked against the host this
|
|
676
|
+
// gate spawned (pid + /proc start key), and its join partner is the sender marker read above.
|
|
677
|
+
const receiverFile = path.join(roots.receiversDir, `${gardenId}.json`);
|
|
678
|
+
const receiverMarker = fs.existsSync(receiverFile)
|
|
679
|
+
? (JSON.parse(fs.readFileSync(receiverFile, "utf8")) as Record<string, unknown>)
|
|
680
|
+
: {};
|
|
681
|
+
ok(
|
|
682
|
+
`[QK:PDC-RECEIVER-ARMED] the case's receiver root holds this garden's marker, owned by the spawned host (pid + /proc start key) as pi-durable-host on the durable session, with the mailbox signal beside it; the host's sender marker names the same garden, which is the #101 join (${receiverFile})`,
|
|
683
|
+
receiverMarker.gardenId === gardenId &&
|
|
684
|
+
receiverMarker.backend === "pi-durable" &&
|
|
685
|
+
receiverMarker.nativeSessionId === report.session.id &&
|
|
686
|
+
receiverMarker.ownerPid === host.pid &&
|
|
687
|
+
receiverMarker.ownerStartKey === `linux:${host.start}` &&
|
|
688
|
+
receiverMarker.ownerKind === "pi-durable-host" &&
|
|
689
|
+
receiverMarker.armProvenance === "session-start" &&
|
|
690
|
+
marker.gardenId === gardenId &&
|
|
691
|
+
marker.ownerPid === host.pid &&
|
|
692
|
+
fs.existsSync(path.join(roots.mailboxDir, gardenId, "inbox.signal")),
|
|
693
|
+
);
|
|
694
|
+
ok(
|
|
695
|
+
`[QK:PDC-RECEIVER-STATE] the contact's own receiver() says armed for that garden at that marker (${report.receiver.status}, ${String(report.receiver.markerPath)})`,
|
|
696
|
+
report.receiver.status === "armed" &&
|
|
697
|
+
report.receiver.gardenId === gardenId &&
|
|
698
|
+
report.receiver.markerPath === receiverFile,
|
|
699
|
+
);
|
|
700
|
+
ok(
|
|
701
|
+
`[QK:PDC-REPLYABLE] with that receiver armed and joined, the bridge's own entwurf_self reports the citizen replyable (replyable ${String(self.replyable)})`,
|
|
702
|
+
self.replyable === true,
|
|
703
|
+
);
|
|
704
|
+
ok(`[QK:PDC-PEERS-HOST] entwurf_peers through the host's bridge lists the citizen`, report.peers.includes(gardenId));
|
|
705
|
+
|
|
706
|
+
const outside = await outsideBridge(roots);
|
|
707
|
+
ok(
|
|
708
|
+
`[QK:PDC-PEERS-OUTSIDE] a bridge this gate spawned on the case's roots (not the host's child) lists the citizen as pi-durable`,
|
|
709
|
+
outside.peers.includes(gardenId) && outside.peers.includes("pi-durable"),
|
|
710
|
+
);
|
|
711
|
+
ok(
|
|
712
|
+
`[QK:PDC-IDENTITY-HOST-SCOPED] that outside bridge is NOT the citizen — the marker joins only the host's own child (isError ${outside.self.isError})`,
|
|
713
|
+
outside.self.isError && !outside.self.text.includes(gardenId),
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
const closed = await closeHost(host);
|
|
717
|
+
ok(
|
|
718
|
+
`[QK:PDC-CLOSE] the host closes and exits 0 (closed ${closed.closed}, exit ${closed.code})`,
|
|
719
|
+
closed.closed && closed.code === 0,
|
|
720
|
+
);
|
|
721
|
+
ok(`[QK:PDC-BRIDGE-GONE] the bridge child is gone after close`, procStat(bridgePid) === null);
|
|
722
|
+
ok(
|
|
723
|
+
`[QK:PDC-RECEIVER-RETIRED-ON-CLOSE] the host's close removed the receiver marker it owned (${receiverFile})`,
|
|
724
|
+
!fs.existsSync(receiverFile),
|
|
725
|
+
);
|
|
726
|
+
console.log(` notices: ${report.notices.join(" | ") || "none"}`);
|
|
727
|
+
return gardenId;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
let primary: unknown;
|
|
731
|
+
try {
|
|
732
|
+
const defaultGarden = await birthCell("1", defaultRoots, "new", null);
|
|
733
|
+
await birthCell("2", defaultRoots, "continue", defaultGarden);
|
|
734
|
+
const explicitGarden = await birthCell("3", explicitRoots, "new", null);
|
|
735
|
+
await birthCell("4", explicitRoots, "continue", explicitGarden);
|
|
736
|
+
} catch (error) {
|
|
737
|
+
primary = error;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Cleanup runs on every path: reap what this gate owns, then the operator guard, then the roots.
|
|
741
|
+
const cleanupProblems: string[] = [];
|
|
742
|
+
for (const host of owned) cleanupProblems.push(...(await reap(host)));
|
|
743
|
+
const stillAlive = owned.flatMap((h) =>
|
|
744
|
+
[{ pid: h.pid, start: h.start }, ...h.bridges].filter((p) => procStat(p.pid)?.start === p.start).map((p) => p.pid),
|
|
745
|
+
);
|
|
746
|
+
console.log("5. cleanup and operator guard");
|
|
747
|
+
const ownedSummary = owned
|
|
748
|
+
.map((h) => `host ${h.pid}${h.bridges.length > 0 ? ` bridges ${h.bridges.map((b) => b.pid).join(",")}` : ""}`)
|
|
749
|
+
.join("; ");
|
|
750
|
+
ok(
|
|
751
|
+
`[QK:PDC-OWNED-REAPED] every host this gate spawned and every bridge observed under it is gone (${ownedSummary || "none spawned"}; alive ${stillAlive.join(",") || "none"})`,
|
|
752
|
+
stillAlive.length === 0 && cleanupProblems.length === 0,
|
|
753
|
+
);
|
|
754
|
+
ok(
|
|
755
|
+
`[QK:PDC-OPERATOR-UNTOUCHED] coarse pi-durable registration guard: this run left the operator's pi-durable registrations exactly as it found them (${describeOperatorSnapshot(operatorBefore)} — same names, same bytes; existing state is not a red; no auth or session content read)`,
|
|
756
|
+
operatorSnapshotUnchanged(operatorBefore, operatorPiDurableSnapshot(operatorAgent)),
|
|
757
|
+
);
|
|
758
|
+
if (stillAlive.length > 0) console.log(`sandbox kept because owned processes are still alive: ${root}`);
|
|
759
|
+
else if (process.env.ENTWURF_PI_DURABLE_KEEP === "1") console.log(`sandbox kept: ${root}`);
|
|
760
|
+
else fs.rmSync(root, { recursive: true, force: true });
|
|
761
|
+
|
|
762
|
+
if (primary !== undefined) {
|
|
763
|
+
console.error(`${LABEL}: FAILED — ${primary instanceof Error ? primary.message : String(primary)}`);
|
|
764
|
+
}
|
|
765
|
+
for (const problem of cleanupProblems) console.error(`${LABEL}: cleanup — ${problem}`);
|
|
766
|
+
console.log(`\n${LABEL}: ${passed} passed, ${failed} failed — adapter-direct, harness-bypassed, checkout-only`);
|
|
767
|
+
process.exit(primary === undefined && failed === 0 && cleanupProblems.length === 0 ? 0 : 1);
|