@junghanacs/entwurf 0.39.0 → 0.41.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/BASELINE.md +60 -5
- package/CHANGELOG.md +557 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +52 -2
- package/FAQ.md +8 -4
- package/README.md +37 -17
- package/VERIFY.md +23 -10
- package/docs/acp-backend-rail.md +5 -1
- package/docs/external-mcp-host.md +8 -6
- package/docs/fresh-cut-policy.md +9 -1
- package/docs/setup-clean-host.md +64 -9
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +100 -24
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +10 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +96 -16
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-receive.js +123 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +259 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-transport.js +80 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
- package/mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js +65 -0
- package/mcp/entwurf-bridge/src/index.ts +118 -24
- package/mcp/entwurf-bridge/test.sh +2 -2
- package/mcp/entwurf-bridge/tsconfig.build.json +17 -1
- package/package.json +5 -5
- package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
- package/pi-extensions/entwurf-control.ts +55 -20
- package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
- package/pi-extensions/lib/cc-retire-request.ts +553 -0
- package/pi-extensions/lib/control-retire.ts +195 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
- package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
- package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
- package/pi-extensions/lib/fresh-call-composition.ts +15 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
- package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +13 -4
- package/pi-extensions/lib/meta-session.ts +140 -16
- package/pi-extensions/lib/mux-fresh-call.ts +7 -27
- package/pi-extensions/lib/mux-launch.ts +29 -0
- package/pi-extensions/lib/mux-resume-call.ts +33 -12
- package/pi-extensions/lib/pi-retire-request.ts +111 -0
- package/pi-extensions/lib/remote-pair-receive.ts +184 -0
- package/pi-extensions/lib/remote-pair-send.ts +325 -0
- package/pi-extensions/lib/remote-pair-transport.ts +81 -0
- package/pi-extensions/lib/resume-launch-identity.ts +104 -18
- package/pi-extensions/lib/retirement-event.ts +253 -0
- package/pi-extensions/meta-bridge-hook.ts +142 -13
- package/pi-extensions/meta-bridge-pi-durable.ts +56 -2
- package/run.sh +122 -7
- package/scripts/agy-bridge-config.py +4 -1
- package/scripts/build-bridge.sh +14 -0
- package/scripts/check-capability-bundle-reach.ts +135 -2
- package/scripts/check-entwurf-bridge-boot.ts +2 -1
- package/scripts/check-entwurf-fact-provider.ts +1 -0
- package/scripts/check-entwurf-v2-production.ts +2 -0
- package/scripts/check-entwurf-v2-surface.ts +7 -0
- package/scripts/check-gate-qualification.ts +20 -8
- package/scripts/check-herdr-activation.ts +2 -2
- package/scripts/check-herdr-placement.ts +1 -0
- package/scripts/check-hook-launch-topology.ts +6 -15
- package/scripts/check-install-container.sh +2 -2
- package/scripts/check-meta-doctor-oracle.sh +27 -1
- package/scripts/check-meta-facts.ts +8 -3
- package/scripts/check-meta-hook-session-switch.ts +230 -13
- package/scripts/check-meta-identity-consumers.ts +1 -0
- package/scripts/check-meta-listing.ts +1 -0
- package/scripts/check-meta-manifest-schema.py +1 -1
- package/scripts/check-meta-session.ts +31 -0
- package/scripts/check-meta-v3-record.ts +55 -1
- package/scripts/check-mux-resume-call.ts +35 -9
- package/scripts/check-peer-facts.ts +1 -0
- package/scripts/check-pi-durable-contact.ts +3 -1
- package/scripts/check-pi-durable-send.ts +4 -2
- package/scripts/check-pi-mcp-bridge.ts +1 -0
- package/scripts/check-remote-pair-installed.ts +242 -0
- package/scripts/check-resume-launch-identity.ts +62 -23
- package/scripts/lib/meta-hook-bundle.ts +72 -0
- package/scripts/lib/remote-pair-installed-audit.ts +25 -0
- package/scripts/meta-bridge-claude-floor.sh +13 -9
- package/scripts/meta-bridge-doctor.sh +17 -0
- package/scripts/meta-bridge-install.sh +6 -0
- package/scripts/meta-facts.ts +1 -1
- package/scripts/mutants/bridge-boot-resume.json +5 -2
- package/scripts/mutants/cc-retire-pidfd.json +129 -0
- package/scripts/mutants/cc-retire-request.json +171 -0
- package/scripts/mutants/control-retire.json +105 -0
- package/scripts/mutants/control-send-receipt.json +2 -2
- package/scripts/mutants/copilot-receive.json +2 -6
- package/scripts/mutants/entwurf-v2-retire.json +476 -0
- package/scripts/mutants/fresh-call-dispatch.json +37 -0
- package/scripts/mutants/meta-hook-session-switch.json +88 -0
- package/scripts/mutants/meta-identity.json +57 -0
- package/scripts/mutants/mux-fresh-call.json +1 -1
- package/scripts/mutants/mux-resume-call.json +40 -4
- package/scripts/mutants/pi-durable.json +79 -0
- package/scripts/mutants/pi-retire-request.json +72 -0
- package/scripts/mutants/remote-pair-display.json +53 -0
- package/scripts/mutants/remote-pair-installed-audit.json +28 -0
- package/scripts/mutants/remote-pair-receive.json +126 -0
- package/scripts/mutants/remote-pair-roundtrip.json +83 -0
- package/scripts/mutants/remote-pair-send.json +194 -0
- package/scripts/mutants/remote-pair-transport.json +66 -0
- package/scripts/mutants/resume-launch-identity.json +35 -8
- package/scripts/mutants/retirement-event.json +195 -0
- package/scripts/mutants/v2-surface.json +16 -0
- package/scripts/mutants/v2-visible-resume.json +283 -13
- package/scripts/probe-bridge-command.ts +1 -0
- package/scripts/remote-pair-receive.ts +73 -0
- package/scripts/smoke-agy-install-state.sh +2 -2
- package/scripts/smoke-copilot-mcp-state.sh +1 -1
- package/scripts/smoke-herdr-raw-install-live.sh +5 -4
- package/scripts/smoke-meta-install-state.sh +4 -4
- package/scripts/smoke-mux-lifecycle-live.ts +10 -10
- package/scripts/smoke-omp-mcp-state.sh +1 -1
- package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
- package/scripts/smoke-pi-provider-state.sh +3 -3
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cc-retire-request — the Node side of retiring ONE Claude Code citizen through the packaged pidfd
|
|
3
|
+
* helper (#147 G3, core-side leaf).
|
|
4
|
+
*
|
|
5
|
+
* NOT an authorization boundary. The caller (a future core verb) owns every decision about WHETHER
|
|
6
|
+
* this citizen may be retired: who asked, self-retire refusal, and the per-target lock. This leaf
|
|
7
|
+
* takes no lock of its own, so a caller holding the target lock cannot deadlock against it. It
|
|
8
|
+
* opens no public tool, reads no role or permission, and never reads a transcript, message body or
|
|
9
|
+
* credential.
|
|
10
|
+
*
|
|
11
|
+
* What it adds over the helper is the binding check while the helper HOLDS the pidfd. The helper
|
|
12
|
+
* pins a PROCESS; a Claude Code process can switch its native session in place (`/resume`,
|
|
13
|
+
* `/clear`), after which the same pid is a different citizen. So the record, the receiver marker
|
|
14
|
+
* and the sender marker the hook moves on every event are read once before the helper starts and
|
|
15
|
+
* once more after it reports `ready` — the fd is held by then, so the pid the second read names
|
|
16
|
+
* cannot have been reused. Only when that second read still names the same garden, native
|
|
17
|
+
* session, binding and owner is `go` written; any other answer writes `cancel`. A switch that
|
|
18
|
+
* commits AFTER the second read is not covered: nothing here claims to close that window.
|
|
19
|
+
*
|
|
20
|
+
* Signals: Node sends none to the target. The only signal Node sends is to its OWN helper child,
|
|
21
|
+
* by ChildProcess handle, after the helper has gone silent past a fixed bound — and that child is
|
|
22
|
+
* not yet reaped when it is sent, so its pid cannot have been reused.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import * as path from "node:path";
|
|
28
|
+
import type { ReceiverInspection } from "./entwurf-v2-visible-resume.ts";
|
|
29
|
+
import { inspectReceiverMarker } from "./entwurf-v2-visible-resume.ts";
|
|
30
|
+
import {
|
|
31
|
+
type MetaIdentity,
|
|
32
|
+
type MetaReceiverArmProvenance,
|
|
33
|
+
type MetaSenderMarker,
|
|
34
|
+
readAddressableMetaIdentity,
|
|
35
|
+
readMetaSenderMarker,
|
|
36
|
+
} from "./meta-session.ts";
|
|
37
|
+
|
|
38
|
+
export const CC_RETIRE_HELPER_FILE = "cc-retire-pidfd.py";
|
|
39
|
+
|
|
40
|
+
/** The owner kind the Claude Code hook's receiver marker carries: `writeMetaReceiverMarker`'s
|
|
41
|
+
* default (meta-session.ts), which the hook does not override. A marker naming any other owner is
|
|
42
|
+
* another harness's process, never a Claude Code foreground owner. */
|
|
43
|
+
export const CC_RECEIVER_OWNER_KIND = "claude-code-cli";
|
|
44
|
+
|
|
45
|
+
/** The arm provenances the Claude Code hook writes. `extension-join` is Copilot's, not Claude's. */
|
|
46
|
+
export const CC_RECEIVER_ARM_PROVENANCES: readonly MetaReceiverArmProvenance[] = [
|
|
47
|
+
"session-start",
|
|
48
|
+
"cwd-changed",
|
|
49
|
+
"file-changed",
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** Wall-clock bound on the whole helper exchange. The helper bounds itself (10 s for the control
|
|
53
|
+
* frame, 5 s for the exit); this only catches a helper that stops talking. */
|
|
54
|
+
export const CC_RETIRE_HELPER_WALL_MS = 30_000;
|
|
55
|
+
|
|
56
|
+
/** At most this many stdout bytes are accepted from the helper; more is a protocol failure. */
|
|
57
|
+
const STDOUT_MAX = 4096;
|
|
58
|
+
const STDERR_MAX = 4096;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The packaged helper: the fixed basename BESIDE this module, nowhere else. The canonical file is
|
|
62
|
+
* `pi-extensions/lib/cc-retire-pidfd.py`, which is beside the source; `build-bridge.sh` byte-copies
|
|
63
|
+
* it beside the emitted adapter in `mcp/entwurf-bridge/dist/pi-extensions/lib/`, so the same rule
|
|
64
|
+
* holds at both depths (check-capability-bundle-reach asks the compiled copy from where it lives).
|
|
65
|
+
* `null` when that entry is not a regular file — a packaging defect, refused by name; no parent
|
|
66
|
+
* directory, environment path or download is ever consulted.
|
|
67
|
+
*/
|
|
68
|
+
export function ccRetireHelperPath(from: string = import.meta.dirname): string | null {
|
|
69
|
+
const candidate = path.join(from, CC_RETIRE_HELPER_FILE);
|
|
70
|
+
try {
|
|
71
|
+
return fs.lstatSync(candidate).isFile() ? candidate : null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The exact argv the helper is started with: isolated interpreter, the fixed file, two facts. */
|
|
78
|
+
export function ccRetireHelperArgv(helperPath: string, pid: number, startKey: string): string[] {
|
|
79
|
+
return ["-I", "-S", helperPath, String(pid), startKey];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The record facts the caller authorised. A change in any of them between that decision and the
|
|
83
|
+
* retirement is a different citizen state, so it refuses. */
|
|
84
|
+
export type CcRetireExpected = Pick<
|
|
85
|
+
MetaIdentity,
|
|
86
|
+
"gardenId" | "backend" | "nativeSessionId" | "createdAt" | "attachIncarnation"
|
|
87
|
+
>;
|
|
88
|
+
|
|
89
|
+
export interface CcRetireDeps {
|
|
90
|
+
readRecord(gardenId: string): MetaIdentity;
|
|
91
|
+
inspectReceiver(gardenId: string): ReceiverInspection;
|
|
92
|
+
readSender(ownerPid: number): MetaSenderMarker | null;
|
|
93
|
+
helperPath(): string | null;
|
|
94
|
+
/** Start the helper with exactly `argv` (interpreter args included). */
|
|
95
|
+
spawnHelper(argv: readonly string[]): ChildProcessWithoutNullStreams;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function makeCcRetireDeps(overrides: Partial<CcRetireDeps> = {}): CcRetireDeps {
|
|
99
|
+
return {
|
|
100
|
+
readRecord: (gid) => readAddressableMetaIdentity(gid),
|
|
101
|
+
inspectReceiver: (gid) => inspectReceiverMarker(gid),
|
|
102
|
+
readSender: (ownerPid) => readMetaSenderMarker({ backend: "claude-code", ownerPid }),
|
|
103
|
+
helperPath: () => ccRetireHelperPath(),
|
|
104
|
+
spawnHelper: (argv) =>
|
|
105
|
+
spawn("python3", [...argv], { stdio: ["pipe", "pipe", "pipe"], env: { PATH: process.env.PATH ?? "" } }),
|
|
106
|
+
...overrides,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Why the binding check refused. Each names the ONE fact that did not hold. */
|
|
111
|
+
export type CcRetireBindingRefusal =
|
|
112
|
+
| "record-unreadable"
|
|
113
|
+
| "target-not-claude-code"
|
|
114
|
+
| "record-drifted"
|
|
115
|
+
| "target-unbound"
|
|
116
|
+
| "receiver-unreadable"
|
|
117
|
+
| "receiver-absent"
|
|
118
|
+
| "receiver-corrupt"
|
|
119
|
+
| "receiver-drifted"
|
|
120
|
+
| "receiver-unbound"
|
|
121
|
+
| "receiver-foreign-owner"
|
|
122
|
+
| "receiver-foreign-provenance"
|
|
123
|
+
| "receiver-dead"
|
|
124
|
+
| "receiver-uncertain"
|
|
125
|
+
| "start-key-unsupported"
|
|
126
|
+
| "sender-unreadable"
|
|
127
|
+
| "sender-absent"
|
|
128
|
+
| "sender-foreign-backend"
|
|
129
|
+
| "sender-drifted";
|
|
130
|
+
|
|
131
|
+
export type CcRetireResult =
|
|
132
|
+
/** SIGTERM was requested through the pidfd and THAT process's exit was observed. */
|
|
133
|
+
| { outcome: "exited"; gardenId: string; pid: number }
|
|
134
|
+
/** Nothing was signalled: refused before the helper held the fd, or the helper refused. */
|
|
135
|
+
| { outcome: "refused"; reason: string; detail: string }
|
|
136
|
+
/** The helper held the fd and sent nothing (cancel, drift after ready, already exited, …). */
|
|
137
|
+
| { outcome: "not-signalled"; reason: string; detail: string }
|
|
138
|
+
/** `go` was written: the signal may have been sent and its effect was not observed. Not retried. */
|
|
139
|
+
| { outcome: "unknown"; reason: string; detail: string };
|
|
140
|
+
|
|
141
|
+
interface Owner {
|
|
142
|
+
pid: number;
|
|
143
|
+
startKey: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
type BindingCheck = { ok: true; owner: Owner } | { ok: false; reason: CcRetireBindingRefusal; detail: string };
|
|
147
|
+
|
|
148
|
+
const LINUX_KEY = /^linux:[0-9]{1,20}$/;
|
|
149
|
+
|
|
150
|
+
function causeOf(err: unknown): string {
|
|
151
|
+
return err instanceof Error ? err.message : String(err);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* One read of the three witnesses, compared against what the caller authorised and against each
|
|
156
|
+
* other. `pinned` (the second read) also requires the SAME owner as the first, because the helper
|
|
157
|
+
* is holding that process.
|
|
158
|
+
*/
|
|
159
|
+
export function checkCcRetireBinding(expected: CcRetireExpected, deps: CcRetireDeps, pinned?: Owner): BindingCheck {
|
|
160
|
+
const gid = expected.gardenId;
|
|
161
|
+
let record: MetaIdentity;
|
|
162
|
+
try {
|
|
163
|
+
record = deps.readRecord(gid);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return { ok: false, reason: "record-unreadable", detail: causeOf(err) };
|
|
166
|
+
}
|
|
167
|
+
if (record.backend !== "claude-code") {
|
|
168
|
+
return { ok: false, reason: "target-not-claude-code", detail: `record backend is ${record.backend}` };
|
|
169
|
+
}
|
|
170
|
+
if (record.attachIncarnation === null) {
|
|
171
|
+
return { ok: false, reason: "target-unbound", detail: "record carries no attachIncarnation" };
|
|
172
|
+
}
|
|
173
|
+
for (const key of ["gardenId", "backend", "nativeSessionId", "createdAt", "attachIncarnation"] as const) {
|
|
174
|
+
if (record[key] !== expected[key]) {
|
|
175
|
+
return { ok: false, reason: "record-drifted", detail: `record ${key} is not the one the caller authorised` };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let inspection: ReceiverInspection;
|
|
180
|
+
try {
|
|
181
|
+
inspection = deps.inspectReceiver(gid);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
return { ok: false, reason: "receiver-unreadable", detail: causeOf(err) };
|
|
184
|
+
}
|
|
185
|
+
if (inspection.kind === "absent") return { ok: false, reason: "receiver-absent", detail: "no receiver marker" };
|
|
186
|
+
if (inspection.kind === "corrupt")
|
|
187
|
+
return { ok: false, reason: "receiver-corrupt", detail: "receiver marker unreadable" };
|
|
188
|
+
const marker = inspection.marker;
|
|
189
|
+
if (
|
|
190
|
+
marker.gardenId !== gid ||
|
|
191
|
+
marker.backend !== "claude-code" ||
|
|
192
|
+
marker.nativeSessionId !== record.nativeSessionId
|
|
193
|
+
) {
|
|
194
|
+
return {
|
|
195
|
+
ok: false,
|
|
196
|
+
reason: "receiver-drifted",
|
|
197
|
+
detail: "receiver marker names another garden, backend or session",
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (marker.ownerKind !== CC_RECEIVER_OWNER_KIND) {
|
|
201
|
+
return { ok: false, reason: "receiver-foreign-owner", detail: `receiver owner kind is ${marker.ownerKind}` };
|
|
202
|
+
}
|
|
203
|
+
if (!(CC_RECEIVER_ARM_PROVENANCES as readonly string[]).includes(marker.armProvenance)) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
reason: "receiver-foreign-provenance",
|
|
207
|
+
detail: `receiver arm provenance is ${marker.armProvenance}`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (marker.attachIncarnation === null) {
|
|
211
|
+
return { ok: false, reason: "receiver-unbound", detail: "receiver marker carries no attachIncarnation" };
|
|
212
|
+
}
|
|
213
|
+
if (marker.attachIncarnation !== record.attachIncarnation) {
|
|
214
|
+
return { ok: false, reason: "receiver-drifted", detail: "receiver binding differs from the record's" };
|
|
215
|
+
}
|
|
216
|
+
if (inspection.owner === "dead")
|
|
217
|
+
return { ok: false, reason: "receiver-dead", detail: `owner ${marker.ownerPid} is gone` };
|
|
218
|
+
if (inspection.owner !== "live") {
|
|
219
|
+
return { ok: false, reason: "receiver-uncertain", detail: `owner ${marker.ownerPid} cannot be proven live` };
|
|
220
|
+
}
|
|
221
|
+
if (!LINUX_KEY.test(marker.ownerStartKey)) {
|
|
222
|
+
return { ok: false, reason: "start-key-unsupported", detail: "owner start key is not a linux:<starttime> key" };
|
|
223
|
+
}
|
|
224
|
+
if (pinned && (marker.ownerPid !== pinned.pid || marker.ownerStartKey !== pinned.startKey)) {
|
|
225
|
+
return { ok: false, reason: "receiver-drifted", detail: "receiver owner is not the process the helper holds" };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let sender: MetaSenderMarker | null;
|
|
229
|
+
try {
|
|
230
|
+
sender = deps.readSender(marker.ownerPid);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
return { ok: false, reason: "sender-unreadable", detail: causeOf(err) };
|
|
233
|
+
}
|
|
234
|
+
if (sender === null) {
|
|
235
|
+
return { ok: false, reason: "sender-absent", detail: `no trusted sender marker for owner ${marker.ownerPid}` };
|
|
236
|
+
}
|
|
237
|
+
// The reader is keyed by backend directory but does not compare the body's backend with it.
|
|
238
|
+
if (sender.backend !== "claude-code") {
|
|
239
|
+
return { ok: false, reason: "sender-foreign-backend", detail: `sender marker backend is ${sender.backend}` };
|
|
240
|
+
}
|
|
241
|
+
if (
|
|
242
|
+
sender.gardenId !== gid ||
|
|
243
|
+
sender.nativeSessionId !== record.nativeSessionId ||
|
|
244
|
+
sender.ownerPid !== marker.ownerPid ||
|
|
245
|
+
sender.ownerStartKey !== marker.ownerStartKey
|
|
246
|
+
) {
|
|
247
|
+
return { ok: false, reason: "sender-drifted", detail: "the owner's sender marker now names another citizen" };
|
|
248
|
+
}
|
|
249
|
+
return { ok: true, owner: { pid: marker.ownerPid, startKey: marker.ownerStartKey } };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// --- helper protocol ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
type Line = Record<string, unknown>;
|
|
255
|
+
|
|
256
|
+
function exactKeys(line: Line, keys: readonly string[]): boolean {
|
|
257
|
+
const own = Object.keys(line).sort();
|
|
258
|
+
const want = [...keys].sort();
|
|
259
|
+
return own.length === want.length && own.every((k, i) => k === want[i]);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Outcomes the helper may print and the exit code each must come with. */
|
|
263
|
+
const PRE_READY_OUTCOMES: Record<string, number> = { refused: 2, "already-exited": 3 };
|
|
264
|
+
const CANCEL_OUTCOMES: Record<string, number> = { cancelled: 3 };
|
|
265
|
+
const AFTER_GO_NO_SIGNAL: Record<string, number> = {
|
|
266
|
+
"already-exited": 3,
|
|
267
|
+
"observe-failed-before-signal": 3,
|
|
268
|
+
refused: 2,
|
|
269
|
+
};
|
|
270
|
+
const AFTER_SIGNAL: Record<string, number> = { exited: 0, "exit-unobserved": 4, "observe-failed": 4 };
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The helper's terminal line, closed per outcome (its `finish(...)` calls in cc-retire-pidfd.py):
|
|
274
|
+
* `refused` carries a `reason` and may carry an `errno` (name or number); `exit-unobserved` carries
|
|
275
|
+
* `waitedS`; every other outcome carries nothing else. A missing, extra or mistyped key is not that
|
|
276
|
+
* outcome. The refusal `reason` text is diagnostic and stays open — reported, never branched on.
|
|
277
|
+
*/
|
|
278
|
+
function terminalShapeOk(line: Line): boolean {
|
|
279
|
+
switch (line.outcome) {
|
|
280
|
+
case "refused": {
|
|
281
|
+
const hasErrno = "errno" in line;
|
|
282
|
+
if (hasErrno && typeof line.errno !== "string" && typeof line.errno !== "number") return false;
|
|
283
|
+
return (
|
|
284
|
+
exactKeys(line, hasErrno ? ["outcome", "reason", "errno"] : ["outcome", "reason"]) &&
|
|
285
|
+
typeof line.reason === "string" &&
|
|
286
|
+
line.reason.length > 0
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
case "exit-unobserved":
|
|
290
|
+
return exactKeys(line, ["outcome", "waitedS"]) && typeof line.waitedS === "number";
|
|
291
|
+
default:
|
|
292
|
+
return exactKeys(line, ["outcome"]);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function outcomeLine(line: Line | undefined, allowed: Record<string, number>, code: number | null): string | null {
|
|
297
|
+
if (!line || typeof line.outcome !== "string") return null;
|
|
298
|
+
const want = allowed[line.outcome];
|
|
299
|
+
if (want === undefined || want !== code) return null;
|
|
300
|
+
if (!terminalShapeOk(line)) return null;
|
|
301
|
+
return line.outcome;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export interface CcRetireOptions {
|
|
305
|
+
signal?: AbortSignal;
|
|
306
|
+
wallMs?: number;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Retire `expected.gardenId` if, and only if, every witness still names the citizen the caller
|
|
311
|
+
* authorised both before the helper starts and after it holds the pidfd.
|
|
312
|
+
*/
|
|
313
|
+
export async function requestCcRetire(
|
|
314
|
+
expected: CcRetireExpected,
|
|
315
|
+
deps: CcRetireDeps = makeCcRetireDeps(),
|
|
316
|
+
opts: CcRetireOptions = {},
|
|
317
|
+
): Promise<CcRetireResult> {
|
|
318
|
+
if (opts.signal?.aborted) return { outcome: "refused", reason: "aborted", detail: "aborted before any check" };
|
|
319
|
+
const first = checkCcRetireBinding(expected, deps);
|
|
320
|
+
if (!first.ok) return { outcome: "refused", reason: first.reason, detail: first.detail };
|
|
321
|
+
const helperPath = deps.helperPath();
|
|
322
|
+
if (helperPath === null) {
|
|
323
|
+
return { outcome: "refused", reason: "helper-missing", detail: `packaged ${CC_RETIRE_HELPER_FILE} not found` };
|
|
324
|
+
}
|
|
325
|
+
if (opts.signal?.aborted)
|
|
326
|
+
return { outcome: "refused", reason: "aborted", detail: "aborted before the helper started" };
|
|
327
|
+
const owner = first.owner;
|
|
328
|
+
const argv = ccRetireHelperArgv(helperPath, owner.pid, owner.startKey);
|
|
329
|
+
|
|
330
|
+
return await new Promise<CcRetireResult>((resolve) => {
|
|
331
|
+
let child: ChildProcessWithoutNullStreams;
|
|
332
|
+
try {
|
|
333
|
+
child = deps.spawnHelper(argv);
|
|
334
|
+
} catch (err) {
|
|
335
|
+
resolve({ outcome: "refused", reason: "helper-spawn-failed", detail: causeOf(err) });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const lines: Line[] = [];
|
|
339
|
+
let buf = "";
|
|
340
|
+
let stdoutBytes = 0;
|
|
341
|
+
let stderr = "";
|
|
342
|
+
let protocolError: string | null = null;
|
|
343
|
+
/** What Node has written: nothing yet, cancel, or go. Decides how every ending is read. */
|
|
344
|
+
let sent: "none" | "cancel" | "go" = "none";
|
|
345
|
+
let cancelReason: { reason: string; detail: string } | null = null;
|
|
346
|
+
let settled = false;
|
|
347
|
+
let spawnError: string | null = null;
|
|
348
|
+
let wallExpired = false;
|
|
349
|
+
let abortedAfterGo = false;
|
|
350
|
+
|
|
351
|
+
const finish = (result: CcRetireResult): void => {
|
|
352
|
+
if (settled) return;
|
|
353
|
+
settled = true;
|
|
354
|
+
clearTimeout(wall);
|
|
355
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
356
|
+
resolve(result);
|
|
357
|
+
};
|
|
358
|
+
const killOwnHelper = (): void => {
|
|
359
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
360
|
+
};
|
|
361
|
+
/** Terminal latch: once a protocol failure, the wall bound, an abort after go or the ending
|
|
362
|
+
* has been recorded, no frame — above all no `go` — is ever written. Killing the helper is
|
|
363
|
+
* cleanup; THIS is the guard. */
|
|
364
|
+
const terminal = (): boolean => settled || protocolError !== null || wallExpired || abortedAfterGo;
|
|
365
|
+
const write = (frame: "go" | "cancel"): void => {
|
|
366
|
+
if (terminal() || sent !== "none") return;
|
|
367
|
+
// Recorded BEFORE the write: a `go` whose write threw may still have reached the pipe.
|
|
368
|
+
sent = frame;
|
|
369
|
+
try {
|
|
370
|
+
child.stdin.end(`${frame}\n`);
|
|
371
|
+
} catch (err) {
|
|
372
|
+
protocolError ??= `writing the ${frame} frame threw: ${causeOf(err)}`;
|
|
373
|
+
killOwnHelper();
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
const cancel = (reason: string, detail: string): void => {
|
|
377
|
+
if (terminal() || sent !== "none") return;
|
|
378
|
+
cancelReason = { reason, detail };
|
|
379
|
+
write("cancel");
|
|
380
|
+
};
|
|
381
|
+
const onAbort = (): void => {
|
|
382
|
+
if (sent === "none" && lines.length > 0)
|
|
383
|
+
cancel("aborted-before-go", "the requester aborted while the fd was held");
|
|
384
|
+
else if (sent === "none") {
|
|
385
|
+
cancelReason = { reason: "aborted-before-go", detail: "the requester aborted before the helper was ready" };
|
|
386
|
+
sent = "cancel";
|
|
387
|
+
try {
|
|
388
|
+
child.stdin.end();
|
|
389
|
+
} catch (err) {
|
|
390
|
+
protocolError ??= `closing the control pipe threw: ${causeOf(err)}`;
|
|
391
|
+
killOwnHelper();
|
|
392
|
+
}
|
|
393
|
+
} else if (sent === "go") {
|
|
394
|
+
abortedAfterGo = true;
|
|
395
|
+
killOwnHelper();
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
const wall = setTimeout(() => {
|
|
399
|
+
wallExpired = true;
|
|
400
|
+
killOwnHelper();
|
|
401
|
+
}, opts.wallMs ?? CC_RETIRE_HELPER_WALL_MS);
|
|
402
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
403
|
+
|
|
404
|
+
child.stdin.on("error", () => {
|
|
405
|
+
// EPIPE: the helper ended first. Its exit and its lines are the evidence, not this.
|
|
406
|
+
});
|
|
407
|
+
child.on("error", (err) => {
|
|
408
|
+
spawnError = causeOf(err);
|
|
409
|
+
// No pid: the interpreter never started, so nothing can be held or signalled. Do not wait
|
|
410
|
+
// for a `close` that a failed spawn is not obliged to deliver.
|
|
411
|
+
if (child.pid === undefined) finish({ outcome: "refused", reason: "helper-spawn-failed", detail: spawnError });
|
|
412
|
+
});
|
|
413
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
414
|
+
if (stderr.length < STDERR_MAX) stderr += chunk.toString("utf8").slice(0, STDERR_MAX - stderr.length);
|
|
415
|
+
});
|
|
416
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
417
|
+
// After a protocol failure nothing further is read: a buffered `ready` behind a bad line
|
|
418
|
+
// must not be acted on.
|
|
419
|
+
if (protocolError !== null || settled) return;
|
|
420
|
+
stdoutBytes += chunk.length;
|
|
421
|
+
if (stdoutBytes > STDOUT_MAX) {
|
|
422
|
+
protocolError ??= "helper printed more than the bounded output";
|
|
423
|
+
killOwnHelper();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
buf += chunk.toString("utf8");
|
|
427
|
+
let nl = buf.indexOf("\n");
|
|
428
|
+
while (nl >= 0) {
|
|
429
|
+
const text = buf.slice(0, nl);
|
|
430
|
+
buf = buf.slice(nl + 1);
|
|
431
|
+
let line: Line;
|
|
432
|
+
try {
|
|
433
|
+
const parsed: unknown = JSON.parse(text);
|
|
434
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not an object");
|
|
435
|
+
line = parsed as Line;
|
|
436
|
+
} catch {
|
|
437
|
+
protocolError ??= `helper printed a non-JSON-object line: ${JSON.stringify(text.slice(0, 120))}`;
|
|
438
|
+
killOwnHelper();
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
lines.push(line);
|
|
442
|
+
onLine(line);
|
|
443
|
+
if (protocolError !== null) return;
|
|
444
|
+
nl = buf.indexOf("\n");
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
const onLine = (line: Line): void => {
|
|
449
|
+
if (lines.length !== 1 || !("phase" in line)) return;
|
|
450
|
+
if (!exactKeys(line, ["phase", "pid"]) || line.phase !== "ready" || line.pid !== owner.pid) {
|
|
451
|
+
protocolError ??= `first phase line is not ready for pid ${owner.pid}`;
|
|
452
|
+
killOwnHelper();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (sent !== "none") return; // aborted while starting: cancel already written
|
|
456
|
+
if (opts.signal?.aborted) {
|
|
457
|
+
cancel("aborted-before-go", "the requester aborted while the fd was held");
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const second = checkCcRetireBinding(expected, deps, owner);
|
|
461
|
+
if (!second.ok) {
|
|
462
|
+
cancel(second.reason, second.detail);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
write("go");
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
child.on("close", (code) => {
|
|
469
|
+
// Incomplete trailing output is not a protocol the helper finished: never success.
|
|
470
|
+
if (buf.length > 0)
|
|
471
|
+
protocolError ??= `helper left incomplete trailing output: ${JSON.stringify(buf.slice(0, 120))}`;
|
|
472
|
+
const helperDetail = (msg: string): string => (stderr ? `${msg}; helper stderr: ${stderr.trim()}` : msg);
|
|
473
|
+
if (spawnError !== null && lines.length === 0) {
|
|
474
|
+
finish({ outcome: "refused", reason: "helper-spawn-failed", detail: spawnError });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const ready = lines[0]?.phase === "ready";
|
|
478
|
+
if (sent === "go") {
|
|
479
|
+
const rest = lines.slice(1);
|
|
480
|
+
const signalled = rest[0]?.phase === "signal-requested";
|
|
481
|
+
if (protocolError === null && !wallExpired && !abortedAfterGo) {
|
|
482
|
+
if (
|
|
483
|
+
signalled &&
|
|
484
|
+
rest.length === 2 &&
|
|
485
|
+
exactKeys(rest[0] as Line, ["phase", "signal"]) &&
|
|
486
|
+
(rest[0] as Line).signal === "SIGTERM"
|
|
487
|
+
) {
|
|
488
|
+
const out = outcomeLine(rest[1], AFTER_SIGNAL, code);
|
|
489
|
+
if (out === "exited" && exactKeys(rest[1] as Line, ["outcome"])) {
|
|
490
|
+
finish({ outcome: "exited", gardenId: expected.gardenId, pid: owner.pid });
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (out !== null && out !== "exited") {
|
|
494
|
+
finish({ outcome: "unknown", reason: out, detail: helperDetail(`SIGTERM requested; ${out}`) });
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (!signalled && rest.length === 1) {
|
|
499
|
+
const out = outcomeLine(rest[0], AFTER_GO_NO_SIGNAL, code);
|
|
500
|
+
if (out !== null) {
|
|
501
|
+
const reason = out === "refused" ? `helper-refused:${String((rest[0] as Line).reason)}` : out;
|
|
502
|
+
finish({ outcome: "not-signalled", reason, detail: helperDetail(`after go: ${reason}`) });
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
const why = abortedAfterGo
|
|
508
|
+
? "aborted-after-go"
|
|
509
|
+
: wallExpired
|
|
510
|
+
? "helper-wall-timeout"
|
|
511
|
+
: (protocolError ?? `helper ended with code ${code} after go without a valid outcome`);
|
|
512
|
+
finish({
|
|
513
|
+
outcome: "unknown",
|
|
514
|
+
reason: abortedAfterGo ? "aborted-after-go" : wallExpired ? "helper-wall-timeout" : "helper-protocol",
|
|
515
|
+
detail: helperDetail(`${why}${signalled ? " (signal-requested was reported)" : ""}`),
|
|
516
|
+
});
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
// No go was written, so the helper cannot have signalled: every ending here is non-success.
|
|
520
|
+
if (sent === "cancel" && cancelReason !== null) {
|
|
521
|
+
const r: { reason: string; detail: string } = cancelReason;
|
|
522
|
+
const confirmed = ready && lines.length === 2 && outcomeLine(lines[1], CANCEL_OUTCOMES, code) !== null;
|
|
523
|
+
const eofConfirmed = !ready || (lines.length === 2 && lines[1]?.outcome === "cancelled-eof");
|
|
524
|
+
finish({
|
|
525
|
+
outcome: "not-signalled",
|
|
526
|
+
reason: r.reason,
|
|
527
|
+
detail: helperDetail(confirmed || eofConfirmed ? r.detail : `${r.detail}; helper ending not confirmed`),
|
|
528
|
+
});
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (!ready && protocolError === null && lines.length === 1) {
|
|
532
|
+
const out = outcomeLine(lines[0], PRE_READY_OUTCOMES, code);
|
|
533
|
+
if (out === "refused") {
|
|
534
|
+
finish({
|
|
535
|
+
outcome: "refused",
|
|
536
|
+
reason: `helper-refused:${String((lines[0] as Line).reason)}`,
|
|
537
|
+
detail: helperDetail("helper refused before ready"),
|
|
538
|
+
});
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (out === "already-exited") {
|
|
542
|
+
finish({ outcome: "not-signalled", reason: "already-exited", detail: helperDetail("target exited") });
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
finish({
|
|
547
|
+
outcome: ready ? "not-signalled" : "refused",
|
|
548
|
+
reason: wallExpired ? "helper-wall-timeout" : "helper-protocol",
|
|
549
|
+
detail: helperDetail(protocolError ?? `helper ended with code ${code} before go`),
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
}
|