pi-claude-supervisor 0.5.2 → 0.5.4
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/CHANGELOG.md +22 -2
- package/README.cn.md +58 -40
- package/README.md +84 -43
- package/docs/architecture.md +108 -65
- package/docs/automation-hardening-plan.md +24 -22
- package/docs/autonomy-target.md +130 -0
- package/docs/engineering-plan.md +136 -138
- package/docs/implementation-review.md +37 -14
- package/docs/independent-review.md +23 -18
- package/docs/releasing.md +7 -2
- package/docs/testing.md +53 -27
- package/package.json +1 -1
- package/src/acceptance.ts +16 -0
- package/src/config.ts +31 -0
- package/src/decision-session-store.ts +27 -2
- package/src/decision-worker.ts +46 -18
- package/src/index.ts +46 -54
- package/src/notifications.ts +29 -11
- package/src/policy.ts +294 -22
- package/src/reviewer.ts +90 -10
- package/src/state.ts +7 -6
- package/src/supervisor.ts +332 -120
- package/src/types.ts +26 -1
- package/src/verifier.ts +106 -7
- package/src/worker/environment.ts +217 -0
- package/src/worker/process-adapter.ts +379 -77
- package/src/worker/process-tree.ts +218 -0
- package/src/worker/tmux-adapter.ts +669 -25
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
3
3
|
import { constants as fsConstants, readFileSync } from "node:fs";
|
|
4
|
-
import { access, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { access, mkdir, readFile, readdir, rm, rmdir, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import { delimiter, isAbsolute, join } from "node:path";
|
|
6
6
|
import type {
|
|
7
7
|
WorkerAdapter,
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
} from "../types.ts";
|
|
16
16
|
import { assertSafeWorkerCommand } from "../policy.ts";
|
|
17
17
|
import { workerEnvironment } from "./environment.ts";
|
|
18
|
+
import { isClaudeProcess, linuxCgroupProcesses, linuxProcessTree, sameProcessIdentity, unexpectedClaudeProcess, unexpectedReviewerProcess, type ProcessTreeEntry } from "./process-tree.ts";
|
|
18
19
|
|
|
19
20
|
export interface ProcessWorkerAdapterOptions {
|
|
20
21
|
/** Use Claude Code's documented stream-json stdin/stdout framing. */
|
|
@@ -79,6 +80,9 @@ interface ProcessRecord {
|
|
|
79
80
|
inputTail: Promise<void>;
|
|
80
81
|
listeners: Set<WorkerEventListener>;
|
|
81
82
|
permissionResponses: Set<string>;
|
|
83
|
+
enforceNestedClaude: boolean;
|
|
84
|
+
expectedClaudeCommand?: string;
|
|
85
|
+
trustedClaudeProcesses: Map<number, ProcessTreeEntry>;
|
|
82
86
|
stopping?: boolean;
|
|
83
87
|
starting: boolean;
|
|
84
88
|
abortRequested: boolean;
|
|
@@ -129,19 +133,19 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
129
133
|
};
|
|
130
134
|
}
|
|
131
135
|
|
|
132
|
-
async preflight(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval">): Promise<void> {
|
|
136
|
+
async preflight(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval" | "automatic">): Promise<void> {
|
|
133
137
|
const args = this.#mode === "claude-jsonl" ? claudeJsonlArgs(input.args) : (input.args ?? []);
|
|
134
138
|
assertSafeWorkerCommand(input.command, args, input.approval);
|
|
135
139
|
await assertWorkerCwd(input.cwd);
|
|
136
140
|
await assertExecutable(input.command, input.env?.PATH ?? process.env.PATH);
|
|
137
|
-
if (this.#cgroupMode === "required") await this.#preflightRequiredCgroup();
|
|
141
|
+
if (this.#cgroupMode === "required" || input.automatic) await this.#preflightRequiredCgroup();
|
|
138
142
|
}
|
|
139
143
|
|
|
140
144
|
async start(input: WorkerStartInput): Promise<WorkerHandle> {
|
|
141
145
|
if (input.abortSignal?.aborted) throw new Error("worker startup aborted before spawn");
|
|
142
146
|
const args = this.#mode === "claude-jsonl" ? claudeJsonlArgs(input.args) : (input.args ?? []);
|
|
143
147
|
assertSafeWorkerCommand(input.command, args, input.approval);
|
|
144
|
-
if (this.#cgroupMode === "required") await this.preflight(input);
|
|
148
|
+
if (this.#cgroupMode === "required" || input.automatic) await this.preflight(input);
|
|
145
149
|
const handle: WorkerHandle = {
|
|
146
150
|
id: randomUUID(),
|
|
147
151
|
startedAt: new Date().toISOString(),
|
|
@@ -154,24 +158,48 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
154
158
|
if (pendingAbortReason !== undefined) throw new Error(`worker startup aborted: ${pendingAbortReason}`);
|
|
155
159
|
let cgroupPath: string | undefined;
|
|
156
160
|
let cgroupError: Error | undefined;
|
|
157
|
-
if (this.#cgroupMode
|
|
161
|
+
if (input.automatic && this.#cgroupMode === "off") throw new Error("automatic supervision requires cgroup containment");
|
|
162
|
+
if ((this.#cgroupMode !== "off" || input.automatic) && process.platform === "linux") {
|
|
158
163
|
try {
|
|
159
164
|
cgroupPath = await this.#createCgroup(handle.id);
|
|
160
165
|
handle.cgroupPath = cgroupPath;
|
|
161
166
|
} catch (error) {
|
|
162
167
|
cgroupError = error instanceof Error ? error : new Error(String(error));
|
|
163
|
-
if (this.#cgroupMode === "required") throw new Error(`unable to create a worker cgroup: ${cgroupError.message}`, { cause: cgroupError });
|
|
168
|
+
if (this.#cgroupMode === "required" || input.automatic) throw new Error(`unable to create a worker cgroup: ${cgroupError.message}`, { cause: cgroupError });
|
|
164
169
|
}
|
|
165
170
|
}
|
|
166
171
|
const delayedAbortReason = input.startupToken ? this.#pendingStartupAborts.get(input.startupToken) : undefined;
|
|
167
172
|
if (input.startupToken) this.#pendingStartupAborts.delete(input.startupToken);
|
|
168
173
|
if (input.abortSignal?.aborted || delayedAbortReason !== undefined) {
|
|
169
|
-
if (cgroupPath) await
|
|
174
|
+
if (cgroupPath) await removeCgroupDirectory(cgroupPath).catch(() => {});
|
|
170
175
|
throw new Error(`worker startup aborted${delayedAbortReason ? `: ${delayedAbortReason}` : " before spawn"}`);
|
|
171
176
|
}
|
|
172
|
-
const
|
|
173
|
-
|
|
177
|
+
const parentStartTime = process.platform === "linux" ? readProcessStartTimeSync(process.pid) : undefined;
|
|
178
|
+
if (input.automatic && process.platform === "linux" && !parentStartTime) {
|
|
179
|
+
if (cgroupPath) await removeCgroupDirectory(cgroupPath).catch(() => {});
|
|
180
|
+
throw new Error("automatic worker parent identity is unavailable");
|
|
181
|
+
}
|
|
182
|
+
const useGuardedBootstrap = process.platform === "linux" && Boolean(parentStartTime) && Boolean(cgroupPath);
|
|
183
|
+
const launch = useGuardedBootstrap
|
|
184
|
+
? guardedBootstrapLaunch(input.command, args, input.cwd, workerEnv, cgroupPath, process.pid, parentStartTime!)
|
|
174
185
|
: { command: input.command, args, env: workerEnv };
|
|
186
|
+
try {
|
|
187
|
+
// This is the last asynchronous operation before spawn. Automatic mode
|
|
188
|
+
// uses it for an exact repository HEAD assertion; adapters that add
|
|
189
|
+
// setup work must invoke the hook only after that work is complete.
|
|
190
|
+
await input.preSpawnCheck?.();
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (cgroupPath) await removeCgroupDirectory(cgroupPath).catch(() => {});
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
const finalAbortReason = input.startupToken ? this.#pendingStartupAborts.get(input.startupToken) : undefined;
|
|
196
|
+
if (input.startupToken) this.#pendingStartupAborts.delete(input.startupToken);
|
|
197
|
+
if (input.abortSignal?.aborted || finalAbortReason !== undefined) {
|
|
198
|
+
if (cgroupPath) await removeCgroupDirectory(cgroupPath).catch(() => {});
|
|
199
|
+
throw new Error(`worker startup aborted${finalAbortReason ? `: ${finalAbortReason}` : " before spawn"}`);
|
|
200
|
+
}
|
|
201
|
+
const guardedLaunch = useGuardedBootstrap;
|
|
202
|
+
let bootstrapReady = !guardedLaunch;
|
|
175
203
|
let child: ChildProcess;
|
|
176
204
|
try {
|
|
177
205
|
child = spawn(launch.command, launch.args, {
|
|
@@ -181,7 +209,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
181
209
|
stdio: ["pipe", "pipe", "pipe"],
|
|
182
210
|
});
|
|
183
211
|
} catch (error) {
|
|
184
|
-
if (cgroupPath) await
|
|
212
|
+
if (cgroupPath) await removeCgroupDirectory(cgroupPath).catch(() => {});
|
|
185
213
|
throw error;
|
|
186
214
|
}
|
|
187
215
|
handle.pid = child.pid;
|
|
@@ -218,6 +246,9 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
218
246
|
inputTail: Promise.resolve(),
|
|
219
247
|
listeners: new Set(input.eventListener ? [input.eventListener] : []),
|
|
220
248
|
permissionResponses: new Set(),
|
|
249
|
+
enforceNestedClaude: Boolean(input.automatic),
|
|
250
|
+
expectedClaudeCommand: input.automatic ? input.command : undefined,
|
|
251
|
+
trustedClaudeProcesses: new Map(),
|
|
221
252
|
starting: true,
|
|
222
253
|
abortRequested: false,
|
|
223
254
|
startupToken: input.startupToken,
|
|
@@ -234,7 +265,13 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
234
265
|
this.#records.set(handle.id, record);
|
|
235
266
|
if (input.abortSignal?.aborted) abortListener();
|
|
236
267
|
const capture = (stream: "stdout" | "stderr") => (chunk: Buffer | string) => {
|
|
237
|
-
|
|
268
|
+
let text = String(chunk);
|
|
269
|
+
if (guardedLaunch && stream === "stderr" && text.includes(BOOTSTRAP_READY_MARKER)) {
|
|
270
|
+
text = text.replaceAll(`${BOOTSTRAP_READY_MARKER}\n`, "").replaceAll(BOOTSTRAP_READY_MARKER, "");
|
|
271
|
+
bootstrapReady = true;
|
|
272
|
+
resolveSpawn();
|
|
273
|
+
}
|
|
274
|
+
if (!text) return;
|
|
238
275
|
record.lastOutputAt = new Date().toISOString();
|
|
239
276
|
const outputChunk = { stream, text, at: record.lastOutputAt } as WorkerOutputChunk;
|
|
240
277
|
this.#appendOutput(record, outputChunk);
|
|
@@ -256,7 +293,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
256
293
|
record.processGroupIdentityError = error instanceof Error ? error : new Error(String(error));
|
|
257
294
|
}
|
|
258
295
|
}
|
|
259
|
-
resolveSpawn();
|
|
296
|
+
if (!guardedLaunch) resolveSpawn();
|
|
260
297
|
});
|
|
261
298
|
child.once("error", (error) => {
|
|
262
299
|
if (!record.spawnedSuccessfully) {
|
|
@@ -272,6 +309,11 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
272
309
|
}
|
|
273
310
|
});
|
|
274
311
|
child.once("exit", (code, signal) => {
|
|
312
|
+
if (guardedLaunch && !bootstrapReady && !record.spawnError) {
|
|
313
|
+
const error = new Error("worker bootstrap exited before reporting readiness");
|
|
314
|
+
record.spawnError = error;
|
|
315
|
+
rejectSpawn(error);
|
|
316
|
+
}
|
|
275
317
|
if (!record.spawnError) {
|
|
276
318
|
record.exitCode = code;
|
|
277
319
|
record.signal = signal ?? undefined;
|
|
@@ -301,6 +343,23 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
301
343
|
}
|
|
302
344
|
throw startupError;
|
|
303
345
|
}
|
|
346
|
+
if (record.enforceNestedClaude) {
|
|
347
|
+
try {
|
|
348
|
+
await this.#rememberClaudeProcesses(record);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
let cleanupError: unknown;
|
|
351
|
+
try { await this.stop(handle, "worker containment startup failed"); }
|
|
352
|
+
catch (stopError) { cleanupError = stopError; }
|
|
353
|
+
if (cleanupError) {
|
|
354
|
+
const startupError = error instanceof Error ? error : new Error(String(error));
|
|
355
|
+
startupError.message = `${startupError.message}; startup cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
|
|
356
|
+
Object.defineProperty(startupError, "workerHandle", { value: handle, enumerable: false });
|
|
357
|
+
Object.defineProperty(startupError, "workerCleanupRequired", { value: true, enumerable: false });
|
|
358
|
+
throw startupError;
|
|
359
|
+
}
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
304
363
|
if (input.task) {
|
|
305
364
|
this.#assertNotAborted(record);
|
|
306
365
|
record.lastInputAt = new Date().toISOString();
|
|
@@ -351,6 +410,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
351
410
|
async getStatus(handle: WorkerHandle): Promise<WorkerStatus> {
|
|
352
411
|
const record = this.#record(handle);
|
|
353
412
|
const running = record.exitCode === undefined;
|
|
413
|
+
if (running && record.enforceNestedClaude) await this.#checkNestedClaude(record);
|
|
354
414
|
if (!running && record.groupCleanup) {
|
|
355
415
|
try {
|
|
356
416
|
await record.groupCleanup;
|
|
@@ -366,10 +426,10 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
366
426
|
lastOutputAt: record.lastOutputAt,
|
|
367
427
|
lastInputAt: record.lastInputAt,
|
|
368
428
|
activeRequests: this.#mode === "claude-jsonl" ? record.activeRequests : undefined,
|
|
369
|
-
exitReason: running ? undefined : record.stopping ? "stopped" : record.signal ? "crashed" : record.exitCode === 0 ? "completed" : "failed",
|
|
429
|
+
exitReason: running ? undefined : record.runtimeError ? "failed" : record.stopping ? "stopped" : record.signal ? "crashed" : record.exitCode === 0 ? "completed" : "failed",
|
|
370
430
|
processGroupCleaned: record.groupCleanupComplete,
|
|
371
431
|
cgroupCleaned: record.cgroupPath ? record.groupCleanupComplete : undefined,
|
|
372
|
-
cgroupRequired: this.#cgroupMode === "required",
|
|
432
|
+
cgroupRequired: this.#cgroupMode === "required" || record.enforceNestedClaude,
|
|
373
433
|
cgroupError: record.cgroupError?.message,
|
|
374
434
|
cleanupError: record.cleanupError?.message,
|
|
375
435
|
runtimeError: record.runtimeError?.message,
|
|
@@ -635,32 +695,82 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
635
695
|
}
|
|
636
696
|
|
|
637
697
|
async #preflightRequiredCgroup(): Promise<void> {
|
|
638
|
-
|
|
639
|
-
let probePath: string | undefined;
|
|
640
|
-
try {
|
|
641
|
-
const parent = this.#cgroupParentPath ?? await currentCgroupPath();
|
|
642
|
-
await access(parent, fsConstants.W_OK);
|
|
643
|
-
probePath = `${parent}/pi-claude-supervisor-preflight-${process.pid}-${randomUUID()}`;
|
|
644
|
-
await mkdir(probePath);
|
|
645
|
-
await stat(`${probePath}/cgroup.kill`);
|
|
646
|
-
await stat(`${probePath}/cgroup.events`);
|
|
647
|
-
await writeFile(`${probePath}/cgroup.kill`, "1\n");
|
|
648
|
-
const events = await readFile(`${probePath}/cgroup.events`, "utf8");
|
|
649
|
-
if (!/^populated 0$/mu.test(events)) throw new Error("preflight cgroup did not report populated 0");
|
|
650
|
-
} catch (error) {
|
|
651
|
-
throw new Error(`required cgroup preflight failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
652
|
-
} finally {
|
|
653
|
-
if (probePath) await rm(probePath, { recursive: true, force: true }).catch(() => {});
|
|
654
|
-
}
|
|
698
|
+
await preflightCgroupContainment(this.#cgroupParentPath);
|
|
655
699
|
}
|
|
656
700
|
|
|
657
701
|
async #createCgroup(id: string): Promise<string> {
|
|
658
702
|
const parent = this.#cgroupParentPath ?? await currentCgroupPath();
|
|
659
703
|
const path = `${parent}/pi-claude-supervisor-${id}`;
|
|
660
704
|
await mkdir(path);
|
|
705
|
+
try {
|
|
706
|
+
await access(`${path}/cgroup.procs`, fsConstants.R_OK | fsConstants.W_OK);
|
|
707
|
+
await access(`${path}/cgroup.events`, fsConstants.R_OK);
|
|
708
|
+
await access(`${path}/cgroup.kill`, fsConstants.W_OK);
|
|
709
|
+
} catch (error) {
|
|
710
|
+
await removeCgroupDirectory(path).catch(() => {});
|
|
711
|
+
throw new Error(`worker cgroup controls are unavailable: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
712
|
+
}
|
|
661
713
|
return path;
|
|
662
714
|
}
|
|
663
715
|
|
|
716
|
+
async #processScope(record: ProcessRecord): Promise<ProcessTreeEntry[]> {
|
|
717
|
+
const rootPid = record.handle.pid ?? 0;
|
|
718
|
+
const tree = await linuxProcessTree(rootPid);
|
|
719
|
+
if (!record.cgroupPath) return tree;
|
|
720
|
+
const group = await linuxCgroupProcesses(record.cgroupPath);
|
|
721
|
+
if (record.enforceNestedClaude && !group.some((entry) => entry.pid === rootPid)) {
|
|
722
|
+
throw new Error("automatic worker is not contained by its required cgroup");
|
|
723
|
+
}
|
|
724
|
+
return [...new Map([...tree, ...group].map((entry) => [entry.pid, entry])).values()];
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async #rememberClaudeProcesses(record: ProcessRecord): Promise<void> {
|
|
728
|
+
const rootPid = record.handle.pid ?? 0;
|
|
729
|
+
let entries: ProcessTreeEntry[] = [];
|
|
730
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
731
|
+
try {
|
|
732
|
+
entries = await this.#processScope(record);
|
|
733
|
+
if (entries.some((entry) => entry.ppid === rootPid && isClaudeProcess(entry, record.expectedClaudeCommand))) break;
|
|
734
|
+
} catch (error) {
|
|
735
|
+
if (attempt === 19) throw error;
|
|
736
|
+
}
|
|
737
|
+
await delay(25);
|
|
738
|
+
}
|
|
739
|
+
record.trustedClaudeProcesses = new Map(entries.filter((entry) => entry.ppid === rootPid && isClaudeProcess(entry, record.expectedClaudeCommand)).map((entry) => [entry.pid, entry]));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async #checkNestedClaude(record: ProcessRecord): Promise<void> {
|
|
743
|
+
const rootPid = record.handle.pid ?? 0;
|
|
744
|
+
let entries: ProcessTreeEntry[];
|
|
745
|
+
try { entries = await this.#processScope(record); }
|
|
746
|
+
catch (error) {
|
|
747
|
+
if (process.platform === "linux" && !record.runtimeError) {
|
|
748
|
+
record.runtimeError = new Error(`nested Claude guard unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
749
|
+
record.stopping = true;
|
|
750
|
+
try { record.child.kill("SIGTERM"); } catch {}
|
|
751
|
+
this.#appendOutput(record, { stream: "stderr", text: `${record.runtimeError.message}\n`, at: new Date().toISOString() });
|
|
752
|
+
}
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (record.trustedClaudeProcesses.size === 0) {
|
|
756
|
+
const directClaude = entries.filter((entry) => entry.ppid === rootPid && isClaudeProcess(entry, record.expectedClaudeCommand));
|
|
757
|
+
if (directClaude.length > 0) {
|
|
758
|
+
record.trustedClaudeProcesses = new Map(directClaude.map((entry) => [entry.pid, entry]));
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
const nestedClaude = unexpectedClaudeProcess(entries, record.trustedClaudeProcesses, record.expectedClaudeCommand);
|
|
763
|
+
const reviewerCandidate = unexpectedReviewerProcess(entries);
|
|
764
|
+
const trustedReviewerCandidate = reviewerCandidate && record.trustedClaudeProcesses.get(reviewerCandidate.pid);
|
|
765
|
+
const nestedReviewer = reviewerCandidate && (!trustedReviewerCandidate || !sameProcessIdentity(trustedReviewerCandidate, reviewerCandidate)) ? reviewerCandidate : undefined;
|
|
766
|
+
const nested = nestedClaude ?? nestedReviewer;
|
|
767
|
+
if (!nested || record.runtimeError) return;
|
|
768
|
+
record.runtimeError = new Error(`${nested === nestedReviewer ? "nested Reviewer" : "nested Claude"} process denied (pid=${nested.pid})`);
|
|
769
|
+
record.stopping = true;
|
|
770
|
+
try { record.child.kill("SIGTERM"); } catch {}
|
|
771
|
+
this.#appendOutput(record, { stream: "stderr", text: `${record.runtimeError.message}\n`, at: new Date().toISOString() });
|
|
772
|
+
}
|
|
773
|
+
|
|
664
774
|
#record(handle: WorkerHandle): ProcessRecord {
|
|
665
775
|
const record = this.#records.get(handle.id);
|
|
666
776
|
if (!record) throw new Error(`unknown worker handle: ${handle.id}`);
|
|
@@ -709,12 +819,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
709
819
|
}
|
|
710
820
|
const deadline = Date.now() + this.#killGraceMs;
|
|
711
821
|
while (Date.now() <= deadline) {
|
|
712
|
-
|
|
713
|
-
process.kill(-pid, 0);
|
|
714
|
-
} catch (error) {
|
|
715
|
-
if (error instanceof Error && /ESRCH/u.test(error.message)) return;
|
|
716
|
-
throw error;
|
|
717
|
-
}
|
|
822
|
+
if (!(await processGroupHasLiveMember(pid))) return;
|
|
718
823
|
await delay(Math.min(10, Math.max(1, deadline - Date.now())));
|
|
719
824
|
}
|
|
720
825
|
throw new Error(`worker process group ${pid} did not exit before cleanup deadline`);
|
|
@@ -727,91 +832,176 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
727
832
|
}
|
|
728
833
|
}
|
|
729
834
|
|
|
730
|
-
const
|
|
835
|
+
const BOOTSTRAP_READY_MARKER = "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_READY";
|
|
836
|
+
|
|
837
|
+
const BOOTSTRAP_KEYS = {
|
|
731
838
|
command: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_COMMAND",
|
|
732
839
|
args: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_ARGS",
|
|
733
840
|
cwd: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_CWD",
|
|
734
841
|
cgroup: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_CGROUP",
|
|
842
|
+
parentPid: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_PARENT_PID",
|
|
843
|
+
parentStartTime: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_PARENT_START",
|
|
735
844
|
} as const;
|
|
736
845
|
|
|
737
|
-
|
|
846
|
+
/**
|
|
847
|
+
* Keep the worker in a small bootstrap process group which watches the
|
|
848
|
+
* Supervisor's PID/start-time identity. A SIGKILL cannot run cleanup in the
|
|
849
|
+
* Supervisor itself, so the detached worker must notice that its owner died
|
|
850
|
+
* and terminate its own descendants. The cgroup path remains an additional
|
|
851
|
+
* descendant boundary when v2 is available.
|
|
852
|
+
*/
|
|
853
|
+
const GUARDED_BOOTSTRAP_SCRIPT = `
|
|
738
854
|
const { spawn } = require("node:child_process");
|
|
739
|
-
const { writeFileSync } = require("node:fs");
|
|
740
|
-
const
|
|
855
|
+
const { readFileSync, rmSync, rmdirSync, writeFileSync } = require("node:fs");
|
|
856
|
+
const { dirname } = require("node:path");
|
|
857
|
+
const keys = ${JSON.stringify(Object.values(BOOTSTRAP_KEYS))};
|
|
741
858
|
const decode = (key) => Buffer.from(process.env[key] || "", "base64").toString("utf8");
|
|
859
|
+
const readStart = (pid) => {
|
|
860
|
+
try {
|
|
861
|
+
const stat = readFileSync("/proc/" + pid + "/stat", "utf8");
|
|
862
|
+
const close = stat.lastIndexOf(")");
|
|
863
|
+
return close < 0 ? undefined : stat.slice(close + 2).trim().split(/\\s+/u)[19];
|
|
864
|
+
} catch { return undefined; }
|
|
865
|
+
};
|
|
866
|
+
const parentPid = Number(decode(${JSON.stringify(BOOTSTRAP_KEYS.parentPid)}));
|
|
867
|
+
const parentStart = decode(${JSON.stringify(BOOTSTRAP_KEYS.parentStartTime)});
|
|
868
|
+
const cgroup = decode(${JSON.stringify(BOOTSTRAP_KEYS.cgroup)});
|
|
869
|
+
const parentCgroup = cgroup ? dirname(cgroup) : undefined;
|
|
870
|
+
const parentAlive = () => Boolean(parentPid > 0 && process.ppid === parentPid && (() => {
|
|
871
|
+
try { process.kill(parentPid, 0); } catch { return false; }
|
|
872
|
+
return !parentStart || readStart(parentPid) === parentStart;
|
|
873
|
+
})());
|
|
874
|
+
const moveOutOfCgroup = () => {
|
|
875
|
+
if (!parentCgroup) return;
|
|
876
|
+
try { writeFileSync(parentCgroup + "/cgroup.procs", String(process.pid) + "\\n"); } catch {}
|
|
877
|
+
};
|
|
878
|
+
const removeCgroup = () => {
|
|
879
|
+
if (!cgroup) return;
|
|
880
|
+
try { rmdirSync(cgroup); return; } catch {}
|
|
881
|
+
try { rmSync(cgroup, { recursive: true, force: true }); } catch {}
|
|
882
|
+
try { rmdirSync(cgroup); } catch {}
|
|
883
|
+
};
|
|
884
|
+
const killGroup = (signal) => {
|
|
885
|
+
try { process.kill(-process.pid, signal); } catch {}
|
|
886
|
+
};
|
|
887
|
+
const finishAfterCgroup = (code, signal) => {
|
|
888
|
+
const finish = () => {
|
|
889
|
+
removeCgroup();
|
|
890
|
+
if (signal) {
|
|
891
|
+
for (const forwarded of ["SIGTERM", "SIGINT", "SIGQUIT"]) process.removeAllListeners(forwarded);
|
|
892
|
+
try { process.kill(process.pid, signal); } catch {}
|
|
893
|
+
setTimeout(() => process.exit(128), 50).unref();
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
process.exit(code ?? 1);
|
|
897
|
+
};
|
|
898
|
+
if (!cgroup) { killGroup("SIGKILL"); return; }
|
|
899
|
+
try { writeFileSync(cgroup + "/cgroup.kill", "1\\n"); } catch {}
|
|
900
|
+
const deadline = Date.now() + 250;
|
|
901
|
+
const reap = () => {
|
|
902
|
+
let empty = false;
|
|
903
|
+
try { empty = /^populated 0$/mu.test(readFileSync(cgroup + "/cgroup.events", "utf8")); }
|
|
904
|
+
catch (error) {
|
|
905
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { finish(); return; }
|
|
906
|
+
}
|
|
907
|
+
if (empty || Date.now() >= deadline) { finish(); return; }
|
|
908
|
+
setTimeout(reap, 25).unref();
|
|
909
|
+
};
|
|
910
|
+
reap();
|
|
911
|
+
};
|
|
742
912
|
let child;
|
|
913
|
+
let finished = false;
|
|
914
|
+
let orphaning = false;
|
|
915
|
+
let parentWatch;
|
|
916
|
+
const stopParentlessWorker = () => {
|
|
917
|
+
if (orphaning || finished) return;
|
|
918
|
+
orphaning = true;
|
|
919
|
+
if (parentWatch) clearInterval(parentWatch);
|
|
920
|
+
try { child?.kill("SIGTERM"); } catch {}
|
|
921
|
+
// Move the bootstrap out first so cgroup.kill cannot kill the cleanup code.
|
|
922
|
+
moveOutOfCgroup();
|
|
923
|
+
setTimeout(() => finishAfterCgroup(143), 250).unref();
|
|
924
|
+
};
|
|
743
925
|
try {
|
|
744
|
-
|
|
745
|
-
writeFileSync(cgroup + "/cgroup.procs", String(process.pid) + "\\n");
|
|
926
|
+
if (cgroup) writeFileSync(cgroup + "/cgroup.procs", String(process.pid) + "\\n");
|
|
746
927
|
const env = { ...process.env };
|
|
747
928
|
for (const key of keys) delete env[key];
|
|
748
929
|
child = spawn(
|
|
749
|
-
decode(${JSON.stringify(
|
|
750
|
-
JSON.parse(decode(${JSON.stringify(
|
|
751
|
-
{ cwd: decode(${JSON.stringify(
|
|
930
|
+
decode(${JSON.stringify(BOOTSTRAP_KEYS.command)}),
|
|
931
|
+
JSON.parse(decode(${JSON.stringify(BOOTSTRAP_KEYS.args)})),
|
|
932
|
+
{ cwd: decode(${JSON.stringify(BOOTSTRAP_KEYS.cwd)}), env, stdio: "inherit" },
|
|
752
933
|
);
|
|
753
934
|
} catch (error) {
|
|
754
|
-
console.error("worker
|
|
755
|
-
|
|
935
|
+
console.error("worker bootstrap failed:", error instanceof Error ? error.message : String(error));
|
|
936
|
+
moveOutOfCgroup();
|
|
937
|
+
finishAfterCgroup(125);
|
|
756
938
|
}
|
|
939
|
+
if (child) {
|
|
940
|
+
process.stderr.write("${BOOTSTRAP_READY_MARKER}\\n");
|
|
941
|
+
parentWatch = setInterval(() => { if (!parentAlive()) stopParentlessWorker(); }, 100);
|
|
942
|
+
parentWatch.unref();
|
|
757
943
|
const forwardedSignals = ["SIGTERM", "SIGINT", "SIGQUIT"];
|
|
758
|
-
for (const signal of forwardedSignals) {
|
|
759
|
-
process.on(signal, () => {
|
|
760
|
-
try { child.kill(signal); } catch {}
|
|
761
|
-
});
|
|
762
|
-
}
|
|
763
|
-
let finished = false;
|
|
944
|
+
for (const signal of forwardedSignals) process.on(signal, () => { try { child.kill(signal); } catch {} });
|
|
764
945
|
child.once("error", (error) => {
|
|
765
|
-
if (finished) return;
|
|
946
|
+
if (finished || orphaning) return;
|
|
766
947
|
finished = true;
|
|
948
|
+
clearInterval(parentWatch);
|
|
767
949
|
console.error("worker bootstrap child failed:", error.message);
|
|
768
|
-
|
|
950
|
+
moveOutOfCgroup();
|
|
951
|
+
finishAfterCgroup(127);
|
|
769
952
|
});
|
|
770
953
|
child.once("exit", (code, signal) => {
|
|
771
|
-
if (finished) return;
|
|
954
|
+
if (finished || orphaning) return;
|
|
772
955
|
finished = true;
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
setTimeout(() => process.exit(128), 50).unref();
|
|
777
|
-
return;
|
|
778
|
-
}
|
|
779
|
-
process.exit(code ?? 1);
|
|
956
|
+
clearInterval(parentWatch);
|
|
957
|
+
moveOutOfCgroup();
|
|
958
|
+
finishAfterCgroup(code ?? 1, signal);
|
|
780
959
|
});
|
|
960
|
+
}
|
|
781
961
|
`;
|
|
782
962
|
|
|
783
|
-
function
|
|
963
|
+
function guardedBootstrapLaunch(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv, cgroupPath: string | undefined, parentPid: number, parentStartTime: string): { command: string; args: string[]; env: NodeJS.ProcessEnv } {
|
|
784
964
|
const encode = (value: string) => Buffer.from(value, "utf8").toString("base64");
|
|
785
965
|
return {
|
|
786
966
|
command: process.execPath,
|
|
787
|
-
args: ["-e",
|
|
967
|
+
args: ["-e", GUARDED_BOOTSTRAP_SCRIPT],
|
|
788
968
|
env: {
|
|
789
969
|
...env,
|
|
790
|
-
[
|
|
791
|
-
[
|
|
792
|
-
[
|
|
793
|
-
[
|
|
970
|
+
[BOOTSTRAP_KEYS.command]: encode(command),
|
|
971
|
+
[BOOTSTRAP_KEYS.args]: encode(JSON.stringify(args)),
|
|
972
|
+
[BOOTSTRAP_KEYS.cwd]: encode(cwd),
|
|
973
|
+
[BOOTSTRAP_KEYS.cgroup]: encode(cgroupPath ?? ""),
|
|
974
|
+
[BOOTSTRAP_KEYS.parentPid]: encode(String(parentPid)),
|
|
975
|
+
[BOOTSTRAP_KEYS.parentStartTime]: encode(parentStartTime),
|
|
794
976
|
},
|
|
795
977
|
};
|
|
796
978
|
}
|
|
797
979
|
|
|
798
|
-
function claudeJsonlArgs(args: readonly string[] = []): string[] {
|
|
980
|
+
export function claudeJsonlArgs(args: readonly string[] = []): string[] {
|
|
799
981
|
const result = [...args];
|
|
800
982
|
if (!result.includes("-p") && !result.includes("--print")) result.push("-p");
|
|
801
983
|
ensureOption(result, "--input-format", "stream-json");
|
|
802
984
|
ensureOption(result, "--output-format", "stream-json");
|
|
803
985
|
ensureOption(result, "--permission-prompt-tool", "stdio");
|
|
986
|
+
ensureOption(result, "--permission-prompts", "host");
|
|
804
987
|
if (!result.includes("--verbose")) result.push("--verbose");
|
|
805
988
|
return result;
|
|
806
989
|
}
|
|
807
990
|
|
|
808
991
|
function ensureOption(args: string[], option: string, expected: string): void {
|
|
809
|
-
const
|
|
810
|
-
|
|
992
|
+
const equalPrefix = `${option}=`;
|
|
993
|
+
const indexes = args.flatMap((value, index) => value === option || value.startsWith(equalPrefix) ? [index] : []);
|
|
994
|
+
if (indexes.length > 1) throw new Error(`${option} may not be repeated in claude-jsonl mode`);
|
|
995
|
+
const index = indexes[0];
|
|
996
|
+
if (index === undefined) {
|
|
811
997
|
args.push(option, expected);
|
|
812
998
|
return;
|
|
813
999
|
}
|
|
814
|
-
if (args[index
|
|
1000
|
+
if (args[index] === equalPrefix + expected) {
|
|
1001
|
+
args.splice(index, 1, option, expected);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (args[index] !== option || args[index + 1] !== expected) throw new Error(`${option} must be ${expected} in claude-jsonl mode`);
|
|
815
1005
|
}
|
|
816
1006
|
|
|
817
1007
|
async function assertWorkerCwd(cwd: string): Promise<void> {
|
|
@@ -835,7 +1025,7 @@ async function assertExecutable(command: string, pathValue: string | undefined):
|
|
|
835
1025
|
throw new Error(`worker executable preflight failed (ENOENT): ${command}`);
|
|
836
1026
|
}
|
|
837
1027
|
|
|
838
|
-
async function currentCgroupPath(): Promise<string> {
|
|
1028
|
+
export async function currentCgroupPath(): Promise<string> {
|
|
839
1029
|
const contents = await readFile("/proc/self/cgroup", "utf8");
|
|
840
1030
|
const match = contents.match(/^0::([^\n]*)$/mu);
|
|
841
1031
|
if (!match) throw new Error("cgroup v2 is not active");
|
|
@@ -844,6 +1034,71 @@ async function currentCgroupPath(): Promise<string> {
|
|
|
844
1034
|
return `/sys/fs/cgroup${match[1]}`;
|
|
845
1035
|
}
|
|
846
1036
|
|
|
1037
|
+
export async function preflightCgroupContainment(parentPath?: string): Promise<void> {
|
|
1038
|
+
if (process.platform !== "linux") throw new Error("required cgroup cleanup is unavailable on this platform");
|
|
1039
|
+
let probePath: string | undefined;
|
|
1040
|
+
let probeChild: ChildProcess | undefined;
|
|
1041
|
+
try {
|
|
1042
|
+
const parent = parentPath ?? await currentCgroupPath();
|
|
1043
|
+
await access(parent, fsConstants.W_OK);
|
|
1044
|
+
probePath = `${parent}/pi-claude-supervisor-preflight-${process.pid}-${randomUUID()}`;
|
|
1045
|
+
await mkdir(probePath);
|
|
1046
|
+
await stat(`${probePath}/cgroup.kill`);
|
|
1047
|
+
await stat(`${probePath}/cgroup.events`);
|
|
1048
|
+
const script = "const fs=require('node:fs'); fs.writeFileSync(process.env.PI_CLAUDE_SUPERVISOR_PREFLIGHT_CGROUP + '/cgroup.procs', String(process.pid) + String.fromCharCode(10)); setInterval(() => {}, 10000);";
|
|
1049
|
+
probeChild = spawn(process.execPath, ["-e", script], {
|
|
1050
|
+
env: { PATH: process.env.PATH ?? "", PI_CLAUDE_SUPERVISOR_PREFLIGHT_CGROUP: probePath },
|
|
1051
|
+
stdio: "ignore",
|
|
1052
|
+
});
|
|
1053
|
+
await new Promise<void>((resolve, reject) => {
|
|
1054
|
+
const timer = setTimeout(() => reject(new Error("preflight child did not join cgroup")), 1_000);
|
|
1055
|
+
const poll = async (): Promise<void> => {
|
|
1056
|
+
if (!probeChild || probeChild.exitCode !== null) {
|
|
1057
|
+
clearTimeout(timer);
|
|
1058
|
+
reject(new Error("preflight cgroup probe exited before joining"));
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
try {
|
|
1062
|
+
const members = await readFile(`${probePath}/cgroup.procs`, "utf8");
|
|
1063
|
+
if (members.split(/\s+/u).includes(String(probeChild.pid))) {
|
|
1064
|
+
clearTimeout(timer);
|
|
1065
|
+
resolve();
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
} catch {
|
|
1069
|
+
// Retry until the bounded probe deadline.
|
|
1070
|
+
}
|
|
1071
|
+
setTimeout(() => { void poll(); }, 10).unref();
|
|
1072
|
+
};
|
|
1073
|
+
void poll();
|
|
1074
|
+
});
|
|
1075
|
+
await writeFile(`${probePath}/cgroup.kill`, "1" + String.fromCharCode(10));
|
|
1076
|
+
const deadline = Date.now() + 1_000;
|
|
1077
|
+
let empty = false;
|
|
1078
|
+
while (Date.now() <= deadline) {
|
|
1079
|
+
const events = await readFile(`${probePath}/cgroup.events`, "utf8");
|
|
1080
|
+
if (/^populated 0$/mu.test(events)) { empty = true; break; }
|
|
1081
|
+
await delay(10);
|
|
1082
|
+
}
|
|
1083
|
+
if (!empty) throw new Error("preflight cgroup did not report populated 0");
|
|
1084
|
+
} catch (error) {
|
|
1085
|
+
throw new Error(`required cgroup preflight failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
1086
|
+
} finally {
|
|
1087
|
+
if (probePath) {
|
|
1088
|
+
try { await writeFile(`${probePath}/cgroup.kill`, "1" + String.fromCharCode(10)); } catch {}
|
|
1089
|
+
}
|
|
1090
|
+
const child = probeChild;
|
|
1091
|
+
if (child && child.exitCode === null) {
|
|
1092
|
+
try { child.kill("SIGKILL"); } catch {}
|
|
1093
|
+
await Promise.race([
|
|
1094
|
+
new Promise<void>((resolve) => child.once("close", () => resolve())),
|
|
1095
|
+
delay(250),
|
|
1096
|
+
]);
|
|
1097
|
+
}
|
|
1098
|
+
if (probePath) await removeCgroupDirectory(probePath).catch(() => {});
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
847
1102
|
function parseProcessGroupIdentity(contents: string, pid: number): ProcessGroupIdentity {
|
|
848
1103
|
const closeParen = contents.lastIndexOf(")");
|
|
849
1104
|
if (closeParen < 0) throw new Error(`unable to read process identity for ${pid}`);
|
|
@@ -858,6 +1113,11 @@ function readProcessGroupIdentitySync(pid: number): ProcessGroupIdentity {
|
|
|
858
1113
|
return parseProcessGroupIdentity(readFileSync(`/proc/${pid}/stat`, "utf8"), pid);
|
|
859
1114
|
}
|
|
860
1115
|
|
|
1116
|
+
function readProcessStartTimeSync(pid: number): string | undefined {
|
|
1117
|
+
try { return parseProcessGroupIdentity(readFileSync(`/proc/${pid}/stat`, "utf8"), pid).startTime; }
|
|
1118
|
+
catch { return undefined; }
|
|
1119
|
+
}
|
|
1120
|
+
|
|
861
1121
|
async function readProcessGroupIdentity(pid: number): Promise<ProcessGroupIdentity> {
|
|
862
1122
|
return parseProcessGroupIdentity(await readFile(`/proc/${pid}/stat`, "utf8"), pid);
|
|
863
1123
|
}
|
|
@@ -877,7 +1137,21 @@ async function assertProcessGroupIdentity(record: ProcessRecord, pid: number): P
|
|
|
877
1137
|
}
|
|
878
1138
|
}
|
|
879
1139
|
|
|
880
|
-
async function
|
|
1140
|
+
async function removeCgroupDirectory(path: string): Promise<void> {
|
|
1141
|
+
try {
|
|
1142
|
+
await rmdir(path);
|
|
1143
|
+
return;
|
|
1144
|
+
} catch (error) {
|
|
1145
|
+
if (error instanceof Error && /ENOENT/u.test(error.message)) return;
|
|
1146
|
+
}
|
|
1147
|
+
await rm(path, { recursive: true, force: true });
|
|
1148
|
+
try { await rmdir(path); }
|
|
1149
|
+
catch (error) {
|
|
1150
|
+
if (!(error instanceof Error && /ENOENT/u.test(error.message))) throw error;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
export async function cleanupCgroup(path: string, graceMs: number): Promise<void> {
|
|
881
1155
|
try {
|
|
882
1156
|
await stat(path);
|
|
883
1157
|
} catch (error) {
|
|
@@ -903,7 +1177,7 @@ async function cleanupCgroup(path: string, graceMs: number): Promise<void> {
|
|
|
903
1177
|
try {
|
|
904
1178
|
const events = await readFile(`${path}/cgroup.events`, "utf8");
|
|
905
1179
|
if (/^populated 0$/mu.test(events)) {
|
|
906
|
-
await
|
|
1180
|
+
await removeCgroupDirectory(path);
|
|
907
1181
|
return;
|
|
908
1182
|
}
|
|
909
1183
|
} catch (error) {
|
|
@@ -954,3 +1228,31 @@ function boundedPositiveInteger(value: number, name: string): number {
|
|
|
954
1228
|
if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`);
|
|
955
1229
|
return value;
|
|
956
1230
|
}
|
|
1231
|
+
|
|
1232
|
+
async function processGroupHasLiveMember(pgid: number): Promise<boolean> {
|
|
1233
|
+
if (process.platform !== "linux") {
|
|
1234
|
+
try { process.kill(-pgid, 0); return true; }
|
|
1235
|
+
catch (error) {
|
|
1236
|
+
if (error instanceof Error && /ESRCH/u.test(error.message)) return false;
|
|
1237
|
+
throw error;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
let names: string[];
|
|
1241
|
+
try { names = await readdir("/proc"); }
|
|
1242
|
+
catch { return true; }
|
|
1243
|
+
for (const name of names) {
|
|
1244
|
+
if (!/^\\d+$/u.test(name)) continue;
|
|
1245
|
+
try {
|
|
1246
|
+
const statText = await readFile(`/proc/${name}/stat`, "utf8");
|
|
1247
|
+
const closeParen = statText.lastIndexOf(")");
|
|
1248
|
+
if (closeParen < 0) continue;
|
|
1249
|
+
const fields = statText.slice(closeParen + 2).trim().split(/\\s+/u);
|
|
1250
|
+
const state = fields[0];
|
|
1251
|
+
const processGroup = Number(fields[2]);
|
|
1252
|
+
if (processGroup === pgid && state !== "Z") return true;
|
|
1253
|
+
} catch {
|
|
1254
|
+
// A process can disappear between /proc enumeration and stat read.
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return false;
|
|
1258
|
+
}
|