pi-claude-supervisor 0.5.3 → 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 +11 -2
- package/README.cn.md +24 -15
- package/README.md +27 -15
- package/docs/architecture.md +43 -27
- package/docs/automation-hardening-plan.md +2 -1
- package/docs/autonomy-target.md +9 -6
- package/docs/engineering-plan.md +1 -1
- package/docs/implementation-review.md +8 -0
- package/docs/testing.md +22 -10
- package/package.json +1 -1
- package/src/decision-session-store.ts +8 -1
- package/src/index.ts +12 -9
- package/src/policy.ts +98 -1
- package/src/reviewer.ts +64 -9
- package/src/supervisor.ts +6 -4
- package/src/types.ts +3 -1
- package/src/verifier.ts +18 -1
- package/src/worker/environment.ts +51 -3
- package/src/worker/process-adapter.ts +366 -79
- 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,23 +158,30 @@ 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 };
|
|
175
186
|
try {
|
|
176
187
|
// This is the last asynchronous operation before spawn. Automatic mode
|
|
@@ -178,15 +189,17 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
178
189
|
// setup work must invoke the hook only after that work is complete.
|
|
179
190
|
await input.preSpawnCheck?.();
|
|
180
191
|
} catch (error) {
|
|
181
|
-
if (cgroupPath) await
|
|
192
|
+
if (cgroupPath) await removeCgroupDirectory(cgroupPath).catch(() => {});
|
|
182
193
|
throw error;
|
|
183
194
|
}
|
|
184
195
|
const finalAbortReason = input.startupToken ? this.#pendingStartupAborts.get(input.startupToken) : undefined;
|
|
185
196
|
if (input.startupToken) this.#pendingStartupAborts.delete(input.startupToken);
|
|
186
197
|
if (input.abortSignal?.aborted || finalAbortReason !== undefined) {
|
|
187
|
-
if (cgroupPath) await
|
|
198
|
+
if (cgroupPath) await removeCgroupDirectory(cgroupPath).catch(() => {});
|
|
188
199
|
throw new Error(`worker startup aborted${finalAbortReason ? `: ${finalAbortReason}` : " before spawn"}`);
|
|
189
200
|
}
|
|
201
|
+
const guardedLaunch = useGuardedBootstrap;
|
|
202
|
+
let bootstrapReady = !guardedLaunch;
|
|
190
203
|
let child: ChildProcess;
|
|
191
204
|
try {
|
|
192
205
|
child = spawn(launch.command, launch.args, {
|
|
@@ -196,7 +209,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
196
209
|
stdio: ["pipe", "pipe", "pipe"],
|
|
197
210
|
});
|
|
198
211
|
} catch (error) {
|
|
199
|
-
if (cgroupPath) await
|
|
212
|
+
if (cgroupPath) await removeCgroupDirectory(cgroupPath).catch(() => {});
|
|
200
213
|
throw error;
|
|
201
214
|
}
|
|
202
215
|
handle.pid = child.pid;
|
|
@@ -233,6 +246,9 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
233
246
|
inputTail: Promise.resolve(),
|
|
234
247
|
listeners: new Set(input.eventListener ? [input.eventListener] : []),
|
|
235
248
|
permissionResponses: new Set(),
|
|
249
|
+
enforceNestedClaude: Boolean(input.automatic),
|
|
250
|
+
expectedClaudeCommand: input.automatic ? input.command : undefined,
|
|
251
|
+
trustedClaudeProcesses: new Map(),
|
|
236
252
|
starting: true,
|
|
237
253
|
abortRequested: false,
|
|
238
254
|
startupToken: input.startupToken,
|
|
@@ -249,7 +265,13 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
249
265
|
this.#records.set(handle.id, record);
|
|
250
266
|
if (input.abortSignal?.aborted) abortListener();
|
|
251
267
|
const capture = (stream: "stdout" | "stderr") => (chunk: Buffer | string) => {
|
|
252
|
-
|
|
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;
|
|
253
275
|
record.lastOutputAt = new Date().toISOString();
|
|
254
276
|
const outputChunk = { stream, text, at: record.lastOutputAt } as WorkerOutputChunk;
|
|
255
277
|
this.#appendOutput(record, outputChunk);
|
|
@@ -271,7 +293,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
271
293
|
record.processGroupIdentityError = error instanceof Error ? error : new Error(String(error));
|
|
272
294
|
}
|
|
273
295
|
}
|
|
274
|
-
resolveSpawn();
|
|
296
|
+
if (!guardedLaunch) resolveSpawn();
|
|
275
297
|
});
|
|
276
298
|
child.once("error", (error) => {
|
|
277
299
|
if (!record.spawnedSuccessfully) {
|
|
@@ -287,6 +309,11 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
287
309
|
}
|
|
288
310
|
});
|
|
289
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
|
+
}
|
|
290
317
|
if (!record.spawnError) {
|
|
291
318
|
record.exitCode = code;
|
|
292
319
|
record.signal = signal ?? undefined;
|
|
@@ -316,6 +343,23 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
316
343
|
}
|
|
317
344
|
throw startupError;
|
|
318
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
|
+
}
|
|
319
363
|
if (input.task) {
|
|
320
364
|
this.#assertNotAborted(record);
|
|
321
365
|
record.lastInputAt = new Date().toISOString();
|
|
@@ -366,6 +410,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
366
410
|
async getStatus(handle: WorkerHandle): Promise<WorkerStatus> {
|
|
367
411
|
const record = this.#record(handle);
|
|
368
412
|
const running = record.exitCode === undefined;
|
|
413
|
+
if (running && record.enforceNestedClaude) await this.#checkNestedClaude(record);
|
|
369
414
|
if (!running && record.groupCleanup) {
|
|
370
415
|
try {
|
|
371
416
|
await record.groupCleanup;
|
|
@@ -381,10 +426,10 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
381
426
|
lastOutputAt: record.lastOutputAt,
|
|
382
427
|
lastInputAt: record.lastInputAt,
|
|
383
428
|
activeRequests: this.#mode === "claude-jsonl" ? record.activeRequests : undefined,
|
|
384
|
-
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",
|
|
385
430
|
processGroupCleaned: record.groupCleanupComplete,
|
|
386
431
|
cgroupCleaned: record.cgroupPath ? record.groupCleanupComplete : undefined,
|
|
387
|
-
cgroupRequired: this.#cgroupMode === "required",
|
|
432
|
+
cgroupRequired: this.#cgroupMode === "required" || record.enforceNestedClaude,
|
|
388
433
|
cgroupError: record.cgroupError?.message,
|
|
389
434
|
cleanupError: record.cleanupError?.message,
|
|
390
435
|
runtimeError: record.runtimeError?.message,
|
|
@@ -650,32 +695,82 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
650
695
|
}
|
|
651
696
|
|
|
652
697
|
async #preflightRequiredCgroup(): Promise<void> {
|
|
653
|
-
|
|
654
|
-
let probePath: string | undefined;
|
|
655
|
-
try {
|
|
656
|
-
const parent = this.#cgroupParentPath ?? await currentCgroupPath();
|
|
657
|
-
await access(parent, fsConstants.W_OK);
|
|
658
|
-
probePath = `${parent}/pi-claude-supervisor-preflight-${process.pid}-${randomUUID()}`;
|
|
659
|
-
await mkdir(probePath);
|
|
660
|
-
await stat(`${probePath}/cgroup.kill`);
|
|
661
|
-
await stat(`${probePath}/cgroup.events`);
|
|
662
|
-
await writeFile(`${probePath}/cgroup.kill`, "1\n");
|
|
663
|
-
const events = await readFile(`${probePath}/cgroup.events`, "utf8");
|
|
664
|
-
if (!/^populated 0$/mu.test(events)) throw new Error("preflight cgroup did not report populated 0");
|
|
665
|
-
} catch (error) {
|
|
666
|
-
throw new Error(`required cgroup preflight failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
667
|
-
} finally {
|
|
668
|
-
if (probePath) await rm(probePath, { recursive: true, force: true }).catch(() => {});
|
|
669
|
-
}
|
|
698
|
+
await preflightCgroupContainment(this.#cgroupParentPath);
|
|
670
699
|
}
|
|
671
700
|
|
|
672
701
|
async #createCgroup(id: string): Promise<string> {
|
|
673
702
|
const parent = this.#cgroupParentPath ?? await currentCgroupPath();
|
|
674
703
|
const path = `${parent}/pi-claude-supervisor-${id}`;
|
|
675
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
|
+
}
|
|
676
713
|
return path;
|
|
677
714
|
}
|
|
678
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
|
+
|
|
679
774
|
#record(handle: WorkerHandle): ProcessRecord {
|
|
680
775
|
const record = this.#records.get(handle.id);
|
|
681
776
|
if (!record) throw new Error(`unknown worker handle: ${handle.id}`);
|
|
@@ -724,12 +819,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
724
819
|
}
|
|
725
820
|
const deadline = Date.now() + this.#killGraceMs;
|
|
726
821
|
while (Date.now() <= deadline) {
|
|
727
|
-
|
|
728
|
-
process.kill(-pid, 0);
|
|
729
|
-
} catch (error) {
|
|
730
|
-
if (error instanceof Error && /ESRCH/u.test(error.message)) return;
|
|
731
|
-
throw error;
|
|
732
|
-
}
|
|
822
|
+
if (!(await processGroupHasLiveMember(pid))) return;
|
|
733
823
|
await delay(Math.min(10, Math.max(1, deadline - Date.now())));
|
|
734
824
|
}
|
|
735
825
|
throw new Error(`worker process group ${pid} did not exit before cleanup deadline`);
|
|
@@ -742,91 +832,176 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
742
832
|
}
|
|
743
833
|
}
|
|
744
834
|
|
|
745
|
-
const
|
|
835
|
+
const BOOTSTRAP_READY_MARKER = "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_READY";
|
|
836
|
+
|
|
837
|
+
const BOOTSTRAP_KEYS = {
|
|
746
838
|
command: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_COMMAND",
|
|
747
839
|
args: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_ARGS",
|
|
748
840
|
cwd: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_CWD",
|
|
749
841
|
cgroup: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_CGROUP",
|
|
842
|
+
parentPid: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_PARENT_PID",
|
|
843
|
+
parentStartTime: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_PARENT_START",
|
|
750
844
|
} as const;
|
|
751
845
|
|
|
752
|
-
|
|
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 = `
|
|
753
854
|
const { spawn } = require("node:child_process");
|
|
754
|
-
const { writeFileSync } = require("node:fs");
|
|
755
|
-
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))};
|
|
756
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
|
+
};
|
|
757
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
|
+
};
|
|
758
925
|
try {
|
|
759
|
-
|
|
760
|
-
writeFileSync(cgroup + "/cgroup.procs", String(process.pid) + "\\n");
|
|
926
|
+
if (cgroup) writeFileSync(cgroup + "/cgroup.procs", String(process.pid) + "\\n");
|
|
761
927
|
const env = { ...process.env };
|
|
762
928
|
for (const key of keys) delete env[key];
|
|
763
929
|
child = spawn(
|
|
764
|
-
decode(${JSON.stringify(
|
|
765
|
-
JSON.parse(decode(${JSON.stringify(
|
|
766
|
-
{ 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" },
|
|
767
933
|
);
|
|
768
934
|
} catch (error) {
|
|
769
|
-
console.error("worker
|
|
770
|
-
|
|
935
|
+
console.error("worker bootstrap failed:", error instanceof Error ? error.message : String(error));
|
|
936
|
+
moveOutOfCgroup();
|
|
937
|
+
finishAfterCgroup(125);
|
|
771
938
|
}
|
|
939
|
+
if (child) {
|
|
940
|
+
process.stderr.write("${BOOTSTRAP_READY_MARKER}\\n");
|
|
941
|
+
parentWatch = setInterval(() => { if (!parentAlive()) stopParentlessWorker(); }, 100);
|
|
942
|
+
parentWatch.unref();
|
|
772
943
|
const forwardedSignals = ["SIGTERM", "SIGINT", "SIGQUIT"];
|
|
773
|
-
for (const signal of forwardedSignals) {
|
|
774
|
-
process.on(signal, () => {
|
|
775
|
-
try { child.kill(signal); } catch {}
|
|
776
|
-
});
|
|
777
|
-
}
|
|
778
|
-
let finished = false;
|
|
944
|
+
for (const signal of forwardedSignals) process.on(signal, () => { try { child.kill(signal); } catch {} });
|
|
779
945
|
child.once("error", (error) => {
|
|
780
|
-
if (finished) return;
|
|
946
|
+
if (finished || orphaning) return;
|
|
781
947
|
finished = true;
|
|
948
|
+
clearInterval(parentWatch);
|
|
782
949
|
console.error("worker bootstrap child failed:", error.message);
|
|
783
|
-
|
|
950
|
+
moveOutOfCgroup();
|
|
951
|
+
finishAfterCgroup(127);
|
|
784
952
|
});
|
|
785
953
|
child.once("exit", (code, signal) => {
|
|
786
|
-
if (finished) return;
|
|
954
|
+
if (finished || orphaning) return;
|
|
787
955
|
finished = true;
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
setTimeout(() => process.exit(128), 50).unref();
|
|
792
|
-
return;
|
|
793
|
-
}
|
|
794
|
-
process.exit(code ?? 1);
|
|
956
|
+
clearInterval(parentWatch);
|
|
957
|
+
moveOutOfCgroup();
|
|
958
|
+
finishAfterCgroup(code ?? 1, signal);
|
|
795
959
|
});
|
|
960
|
+
}
|
|
796
961
|
`;
|
|
797
962
|
|
|
798
|
-
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 } {
|
|
799
964
|
const encode = (value: string) => Buffer.from(value, "utf8").toString("base64");
|
|
800
965
|
return {
|
|
801
966
|
command: process.execPath,
|
|
802
|
-
args: ["-e",
|
|
967
|
+
args: ["-e", GUARDED_BOOTSTRAP_SCRIPT],
|
|
803
968
|
env: {
|
|
804
969
|
...env,
|
|
805
|
-
[
|
|
806
|
-
[
|
|
807
|
-
[
|
|
808
|
-
[
|
|
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),
|
|
809
976
|
},
|
|
810
977
|
};
|
|
811
978
|
}
|
|
812
979
|
|
|
813
|
-
function claudeJsonlArgs(args: readonly string[] = []): string[] {
|
|
980
|
+
export function claudeJsonlArgs(args: readonly string[] = []): string[] {
|
|
814
981
|
const result = [...args];
|
|
815
982
|
if (!result.includes("-p") && !result.includes("--print")) result.push("-p");
|
|
816
983
|
ensureOption(result, "--input-format", "stream-json");
|
|
817
984
|
ensureOption(result, "--output-format", "stream-json");
|
|
818
985
|
ensureOption(result, "--permission-prompt-tool", "stdio");
|
|
986
|
+
ensureOption(result, "--permission-prompts", "host");
|
|
819
987
|
if (!result.includes("--verbose")) result.push("--verbose");
|
|
820
988
|
return result;
|
|
821
989
|
}
|
|
822
990
|
|
|
823
991
|
function ensureOption(args: string[], option: string, expected: string): void {
|
|
824
|
-
const
|
|
825
|
-
|
|
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) {
|
|
826
997
|
args.push(option, expected);
|
|
827
998
|
return;
|
|
828
999
|
}
|
|
829
|
-
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`);
|
|
830
1005
|
}
|
|
831
1006
|
|
|
832
1007
|
async function assertWorkerCwd(cwd: string): Promise<void> {
|
|
@@ -850,7 +1025,7 @@ async function assertExecutable(command: string, pathValue: string | undefined):
|
|
|
850
1025
|
throw new Error(`worker executable preflight failed (ENOENT): ${command}`);
|
|
851
1026
|
}
|
|
852
1027
|
|
|
853
|
-
async function currentCgroupPath(): Promise<string> {
|
|
1028
|
+
export async function currentCgroupPath(): Promise<string> {
|
|
854
1029
|
const contents = await readFile("/proc/self/cgroup", "utf8");
|
|
855
1030
|
const match = contents.match(/^0::([^\n]*)$/mu);
|
|
856
1031
|
if (!match) throw new Error("cgroup v2 is not active");
|
|
@@ -859,6 +1034,71 @@ async function currentCgroupPath(): Promise<string> {
|
|
|
859
1034
|
return `/sys/fs/cgroup${match[1]}`;
|
|
860
1035
|
}
|
|
861
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
|
+
|
|
862
1102
|
function parseProcessGroupIdentity(contents: string, pid: number): ProcessGroupIdentity {
|
|
863
1103
|
const closeParen = contents.lastIndexOf(")");
|
|
864
1104
|
if (closeParen < 0) throw new Error(`unable to read process identity for ${pid}`);
|
|
@@ -873,6 +1113,11 @@ function readProcessGroupIdentitySync(pid: number): ProcessGroupIdentity {
|
|
|
873
1113
|
return parseProcessGroupIdentity(readFileSync(`/proc/${pid}/stat`, "utf8"), pid);
|
|
874
1114
|
}
|
|
875
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
|
+
|
|
876
1121
|
async function readProcessGroupIdentity(pid: number): Promise<ProcessGroupIdentity> {
|
|
877
1122
|
return parseProcessGroupIdentity(await readFile(`/proc/${pid}/stat`, "utf8"), pid);
|
|
878
1123
|
}
|
|
@@ -892,7 +1137,21 @@ async function assertProcessGroupIdentity(record: ProcessRecord, pid: number): P
|
|
|
892
1137
|
}
|
|
893
1138
|
}
|
|
894
1139
|
|
|
895
|
-
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> {
|
|
896
1155
|
try {
|
|
897
1156
|
await stat(path);
|
|
898
1157
|
} catch (error) {
|
|
@@ -918,7 +1177,7 @@ async function cleanupCgroup(path: string, graceMs: number): Promise<void> {
|
|
|
918
1177
|
try {
|
|
919
1178
|
const events = await readFile(`${path}/cgroup.events`, "utf8");
|
|
920
1179
|
if (/^populated 0$/mu.test(events)) {
|
|
921
|
-
await
|
|
1180
|
+
await removeCgroupDirectory(path);
|
|
922
1181
|
return;
|
|
923
1182
|
}
|
|
924
1183
|
} catch (error) {
|
|
@@ -969,3 +1228,31 @@ function boundedPositiveInteger(value: number, name: string): number {
|
|
|
969
1228
|
if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`);
|
|
970
1229
|
return value;
|
|
971
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
|
+
}
|