pi-claude-supervisor 0.5.0 → 0.5.2
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 +30 -1
- package/README.cn.md +26 -9
- package/README.md +38 -18
- package/docs/architecture.md +71 -15
- package/docs/automation-hardening-plan.md +211 -0
- package/docs/engineering-plan.md +76 -3
- package/docs/recovery-review.md +24 -0
- package/docs/stability-matrix-2.1.270.md +32 -0
- package/docs/testing.md +90 -21
- package/package.json +1 -1
- package/src/cwd-lease.ts +95 -6
- package/src/decision-session-store.ts +343 -37
- package/src/decision-worker.ts +97 -13
- package/src/index.ts +189 -88
- package/src/redaction.ts +2 -1
- package/src/reviewer.ts +69 -16
- package/src/supervisor.ts +276 -45
- package/src/types.ts +6 -2
- package/src/verifier.ts +180 -22
- package/src/worker/process-adapter.ts +245 -47
- package/src/worker/tmux-adapter.ts +42 -3
package/src/types.ts
CHANGED
|
@@ -104,6 +104,8 @@ export interface PermissionDecision {
|
|
|
104
104
|
|
|
105
105
|
export interface WorkerAdapter {
|
|
106
106
|
capabilities(): WorkerCapabilities;
|
|
107
|
+
/** Validate host/transport prerequisites before starting a Worker. */
|
|
108
|
+
preflight?(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval">): Promise<void>;
|
|
107
109
|
start(input: WorkerStartInput): Promise<WorkerHandle>;
|
|
108
110
|
/** Cancel adapter-owned startup work before a WorkerHandle is returned. */
|
|
109
111
|
abortStart?(reason: string, startupToken?: string): Promise<void>;
|
|
@@ -137,8 +139,10 @@ export interface WorkerCapabilities {
|
|
|
137
139
|
pause: boolean;
|
|
138
140
|
resumeSession: boolean;
|
|
139
141
|
processGroupControl: boolean;
|
|
140
|
-
/** The worker can remain alive while Pi disconnects from it. */
|
|
142
|
+
/** The worker can remain alive while Pi disconnects from it and be explicitly re-adopted. */
|
|
141
143
|
persistentSession?: boolean;
|
|
144
|
+
/** The current Supervisor can send another bounded turn after a result. */
|
|
145
|
+
repairableSession?: boolean;
|
|
142
146
|
}
|
|
143
147
|
|
|
144
148
|
export interface AcceptanceCheck {
|
|
@@ -150,7 +154,7 @@ export interface AcceptanceCheck {
|
|
|
150
154
|
timeoutMs: number;
|
|
151
155
|
}
|
|
152
156
|
|
|
153
|
-
export type AcceptanceCheckStatus = "passed" | "failed" | "timed_out" | "blocked";
|
|
157
|
+
export type AcceptanceCheckStatus = "passed" | "failed" | "timed_out" | "blocked" | "cancelled";
|
|
154
158
|
|
|
155
159
|
export interface AcceptanceCheckResult {
|
|
156
160
|
check: AcceptanceCheck;
|
package/src/verifier.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { lstat, open, realpath } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
5
|
import { promisify } from "node:util";
|
|
3
6
|
import type { AcceptanceCheck, AcceptanceCheckResult, AcceptanceReport, VerificationResult } from "./types.ts";
|
|
4
7
|
import { assertSafeWorkerCommand } from "./policy.ts";
|
|
@@ -6,15 +9,37 @@ import { workerEnvironment } from "./worker/environment.ts";
|
|
|
6
9
|
|
|
7
10
|
const execFileAsync = promisify(execFile);
|
|
8
11
|
const MAX_OUTPUT_BYTES = 256 * 1024;
|
|
12
|
+
const MAX_EXEC_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
13
|
+
const MAX_UNTRACKED_FILE_BYTES = 64 * 1024;
|
|
14
|
+
const MAX_UNTRACKED_FILES = 128;
|
|
15
|
+
|
|
16
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
17
|
+
if (!signal?.aborted) return;
|
|
18
|
+
const error = new Error("verification aborted");
|
|
19
|
+
error.name = "AbortError";
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
9
22
|
|
|
10
23
|
export interface VerificationCommand {
|
|
11
24
|
command: string;
|
|
12
25
|
args?: string[];
|
|
13
26
|
}
|
|
14
27
|
|
|
28
|
+
export interface VerificationOptions {
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
onCheck?: (info: { check: AcceptanceCheck; result: AcceptanceCheckResult; index: number; total: number }) => void | Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
15
33
|
export interface RepositoryEvidence {
|
|
16
34
|
status: string;
|
|
35
|
+
/** HEAD-relative diff for tracked staged and unstaged changes. */
|
|
17
36
|
diff: string;
|
|
37
|
+
/** Bounded content for untracked regular files. */
|
|
38
|
+
untracked?: string;
|
|
39
|
+
/** False when a command/file could not be safely or completely collected. */
|
|
40
|
+
complete?: boolean;
|
|
41
|
+
/** True when any evidence field was bounded or omitted. */
|
|
42
|
+
truncated?: boolean;
|
|
18
43
|
collectedAt: string;
|
|
19
44
|
}
|
|
20
45
|
|
|
@@ -22,6 +47,7 @@ export async function verify(
|
|
|
22
47
|
cwd: string,
|
|
23
48
|
command: VerificationCommand = { command: "git", args: ["diff", "--check"] },
|
|
24
49
|
timeoutMs = 120_000,
|
|
50
|
+
options: VerificationOptions = {},
|
|
25
51
|
): Promise<VerificationResult> {
|
|
26
52
|
assertSafeWorkerCommand(command.command, command.args);
|
|
27
53
|
const check: AcceptanceCheck = {
|
|
@@ -32,9 +58,9 @@ export async function verify(
|
|
|
32
58
|
required: true,
|
|
33
59
|
timeoutMs,
|
|
34
60
|
};
|
|
35
|
-
const result = await runCheck(cwd, check);
|
|
61
|
+
const result = await runCheck(cwd, check, options.signal);
|
|
36
62
|
return {
|
|
37
|
-
ok: result.ok,
|
|
63
|
+
ok: result.ok && !options.signal?.aborted,
|
|
38
64
|
command: [check.command, ...check.args].join(" "),
|
|
39
65
|
exitCode: result.exitCode,
|
|
40
66
|
output: result.output,
|
|
@@ -42,39 +68,58 @@ export async function verify(
|
|
|
42
68
|
};
|
|
43
69
|
}
|
|
44
70
|
|
|
45
|
-
export async function verifyAll(cwd: string, checks: readonly AcceptanceCheck[]): Promise<AcceptanceReport> {
|
|
71
|
+
export async function verifyAll(cwd: string, checks: readonly AcceptanceCheck[], options: VerificationOptions = {}): Promise<AcceptanceReport> {
|
|
46
72
|
if (checks.length === 0) throw new Error("at least one acceptance check is required");
|
|
47
73
|
const results: AcceptanceCheckResult[] = [];
|
|
48
|
-
for (const check of checks
|
|
49
|
-
|
|
74
|
+
for (const [index, check] of checks.entries()) {
|
|
75
|
+
const result = await runCheck(cwd, check, options.signal);
|
|
76
|
+
results.push(result);
|
|
77
|
+
await options.onCheck?.({ check, result, index, total: checks.length });
|
|
78
|
+
if (options.signal?.aborted) break;
|
|
79
|
+
}
|
|
80
|
+
const cancelled = options.signal?.aborted === true;
|
|
81
|
+
const ok = !cancelled && results.every((result) => !result.check.required || result.ok);
|
|
50
82
|
const firstFailure = results.find((result) => result.check.required && !result.ok);
|
|
51
83
|
return {
|
|
52
84
|
ok,
|
|
53
85
|
command: "acceptance checks",
|
|
54
|
-
exitCode: firstFailure?.exitCode ?? 0,
|
|
55
|
-
output: boundOutput(results.map(formatCheckResult).join("\n\n")),
|
|
86
|
+
exitCode: firstFailure?.exitCode ?? (cancelled ? 1 : 0),
|
|
87
|
+
output: boundOutput(`${results.map(formatCheckResult).join("\n\n")}${cancelled ? "\n\n[verification cancelled]" : ""}`),
|
|
56
88
|
checkedAt: new Date().toISOString(),
|
|
57
89
|
checks: results,
|
|
58
90
|
};
|
|
59
91
|
}
|
|
60
92
|
|
|
61
93
|
/** Collect bounded repository evidence for the independent read-only Reviewer. */
|
|
62
|
-
export async function collectRepositoryEvidence(cwd: string): Promise<RepositoryEvidence> {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
readGitEvidence(cwd, ["
|
|
94
|
+
export async function collectRepositoryEvidence(cwd: string, options: Pick<VerificationOptions, "signal"> = {}): Promise<RepositoryEvidence> {
|
|
95
|
+
throwIfAborted(options.signal);
|
|
96
|
+
const [statusResult, diffResult, untrackedResult] = await Promise.all([
|
|
97
|
+
readGitEvidence(cwd, ["status", "--short", "--untracked-files=all"], options.signal),
|
|
98
|
+
// HEAD-relative diff includes both staged and unstaged changes for tracked files.
|
|
99
|
+
readGitEvidence(cwd, ["diff", "--no-ext-diff", "--unified=3", "HEAD", "--"], options.signal),
|
|
100
|
+
collectUntrackedEvidence(cwd, options.signal),
|
|
66
101
|
]);
|
|
67
|
-
|
|
102
|
+
throwIfAborted(options.signal);
|
|
103
|
+
return {
|
|
104
|
+
status: statusResult.text,
|
|
105
|
+
diff: diffResult.text,
|
|
106
|
+
untracked: untrackedResult.text,
|
|
107
|
+
complete: statusResult.complete && diffResult.complete && untrackedResult.complete,
|
|
108
|
+
truncated: statusResult.truncated || diffResult.truncated || untrackedResult.truncated,
|
|
109
|
+
collectedAt: new Date().toISOString(),
|
|
110
|
+
};
|
|
68
111
|
}
|
|
69
112
|
|
|
70
|
-
async function runCheck(cwd: string, check: AcceptanceCheck): Promise<AcceptanceCheckResult> {
|
|
113
|
+
async function runCheck(cwd: string, check: AcceptanceCheck, signal?: AbortSignal): Promise<AcceptanceCheckResult> {
|
|
114
|
+
throwIfAborted(signal);
|
|
71
115
|
assertSafeWorkerCommand(check.command, check.args);
|
|
72
116
|
const startedAt = new Date().toISOString();
|
|
73
117
|
try {
|
|
74
118
|
const result = await execFileAsync(check.command, check.args, {
|
|
75
119
|
cwd,
|
|
76
120
|
timeout: check.timeoutMs,
|
|
77
|
-
maxBuffer:
|
|
121
|
+
maxBuffer: MAX_EXEC_BUFFER_BYTES,
|
|
122
|
+
signal,
|
|
78
123
|
env: workerEnvironment(process.env, { GIT_TERMINAL_PROMPT: "0" }),
|
|
79
124
|
});
|
|
80
125
|
const finishedAt = new Date().toISOString();
|
|
@@ -88,13 +133,14 @@ async function runCheck(cwd: string, check: AcceptanceCheck): Promise<Acceptance
|
|
|
88
133
|
finishedAt,
|
|
89
134
|
};
|
|
90
135
|
} catch (error) {
|
|
91
|
-
const failure = error as { code?: number | string; signal?: string; stdout?: string; stderr?: string; message?: string; killed?: boolean };
|
|
136
|
+
const failure = error as { code?: number | string; signal?: string; stdout?: string; stderr?: string; message?: string; killed?: boolean; name?: string };
|
|
92
137
|
const finishedAt = new Date().toISOString();
|
|
93
|
-
const
|
|
138
|
+
const cancelled = signal?.aborted === true || failure.name === "AbortError" || failure.code === "ABORT_ERR";
|
|
139
|
+
const timedOut = !cancelled && (failure.killed === true || failure.signal === "SIGTERM" || failure.code === "ETIMEDOUT");
|
|
94
140
|
const exitCode = typeof failure.code === "number" ? failure.code : 1;
|
|
95
141
|
return {
|
|
96
142
|
check,
|
|
97
|
-
status: timedOut ? "timed_out" : "failed",
|
|
143
|
+
status: cancelled ? "cancelled" : timedOut ? "timed_out" : "failed",
|
|
98
144
|
ok: false,
|
|
99
145
|
exitCode,
|
|
100
146
|
output: boundOutput(`${failure.stdout ?? ""}${failure.stderr ?? ""}${failure.message ?? ""}`),
|
|
@@ -104,18 +150,119 @@ async function runCheck(cwd: string, check: AcceptanceCheck): Promise<Acceptance
|
|
|
104
150
|
}
|
|
105
151
|
}
|
|
106
152
|
|
|
107
|
-
|
|
153
|
+
interface EvidencePart {
|
|
154
|
+
text: string;
|
|
155
|
+
complete: boolean;
|
|
156
|
+
truncated: boolean;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function readGitEvidence(cwd: string, args: string[], signal?: AbortSignal): Promise<EvidencePart> {
|
|
160
|
+
throwIfAborted(signal);
|
|
108
161
|
try {
|
|
109
162
|
const result = await execFileAsync("git", args, {
|
|
110
163
|
cwd,
|
|
111
164
|
timeout: 30_000,
|
|
112
165
|
maxBuffer: MAX_OUTPUT_BYTES,
|
|
166
|
+
signal,
|
|
113
167
|
env: workerEnvironment(process.env, { GIT_TERMINAL_PROMPT: "0" }),
|
|
114
168
|
});
|
|
115
|
-
|
|
169
|
+
const bounded = boundEvidence(`${result.stdout}${result.stderr}`);
|
|
170
|
+
return { ...bounded, text: bounded.text || "(none)" };
|
|
116
171
|
} catch (error) {
|
|
117
172
|
const failure = error as { stdout?: string; stderr?: string; message?: string };
|
|
118
|
-
|
|
173
|
+
const bounded = boundEvidence(`${failure.stdout ?? ""}${failure.stderr ?? ""}${failure.message ?? "git evidence unavailable"}`);
|
|
174
|
+
return { ...bounded, complete: false };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function collectUntrackedEvidence(cwd: string, signal?: AbortSignal): Promise<EvidencePart> {
|
|
179
|
+
throwIfAborted(signal);
|
|
180
|
+
let output: string;
|
|
181
|
+
let root: string;
|
|
182
|
+
try {
|
|
183
|
+
root = await realpath(cwd);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return { text: `[UNTRACKED EVIDENCE UNAVAILABLE] ${error instanceof Error ? error.message : String(error)}`, complete: false, truncated: false };
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const result = await execFileAsync("git", ["ls-files", "--others", "--exclude-standard", "-z"], {
|
|
189
|
+
cwd,
|
|
190
|
+
timeout: 30_000,
|
|
191
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
192
|
+
signal,
|
|
193
|
+
env: workerEnvironment(process.env, { GIT_TERMINAL_PROMPT: "0" }),
|
|
194
|
+
});
|
|
195
|
+
output = result.stdout;
|
|
196
|
+
throwIfAborted(signal);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
const failure = error as { stdout?: string; stderr?: string; message?: string };
|
|
199
|
+
return { text: `[UNTRACKED EVIDENCE UNAVAILABLE] ${failure.stderr ?? failure.message ?? "git ls-files failed"}`, complete: false, truncated: false };
|
|
200
|
+
}
|
|
201
|
+
const paths = output.split("\0").filter(Boolean);
|
|
202
|
+
if (paths.length === 0) return { text: "(none)", complete: true, truncated: false };
|
|
203
|
+
let complete = paths.length <= MAX_UNTRACKED_FILES;
|
|
204
|
+
let truncated = false;
|
|
205
|
+
const sections: string[] = [];
|
|
206
|
+
for (const path of paths.slice(0, MAX_UNTRACKED_FILES)) {
|
|
207
|
+
const fullPath = resolve(root, path);
|
|
208
|
+
const relativePath = relative(root, fullPath);
|
|
209
|
+
if (isAbsolute(relativePath) || relativePath === ".." || relativePath.startsWith(`..${"/"}`) || relativePath.startsWith(`..${"\\"}`)) {
|
|
210
|
+
complete = false;
|
|
211
|
+
sections.push(`--- ${JSON.stringify(path)} [unsafe path omitted]`);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
|
215
|
+
try {
|
|
216
|
+
await assertNoSymlinkComponents(root, relativePath);
|
|
217
|
+
const info = await lstat(fullPath);
|
|
218
|
+
if (info.isSymbolicLink() || !info.isFile()) {
|
|
219
|
+
complete = false;
|
|
220
|
+
sections.push(`--- ${JSON.stringify(path)} [non-regular file omitted]`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
|
|
224
|
+
handle = await open(fullPath, fsConstants.O_RDONLY | noFollow);
|
|
225
|
+
const opened = await handle.stat();
|
|
226
|
+
if (!opened.isFile()) {
|
|
227
|
+
complete = false;
|
|
228
|
+
sections.push(`--- ${JSON.stringify(path)} [non-regular file omitted]`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const buffer = Buffer.alloc(MAX_UNTRACKED_FILE_BYTES + 1);
|
|
232
|
+
const read = await handle.read(buffer, 0, buffer.length, 0);
|
|
233
|
+
const bytes = buffer.subarray(0, read.bytesRead);
|
|
234
|
+
if (read.bytesRead > MAX_UNTRACKED_FILE_BYTES) {
|
|
235
|
+
complete = false;
|
|
236
|
+
truncated = true;
|
|
237
|
+
}
|
|
238
|
+
if (bytes.includes(0)) {
|
|
239
|
+
complete = false;
|
|
240
|
+
sections.push(`--- ${JSON.stringify(path)} [binary file omitted]`);
|
|
241
|
+
} else {
|
|
242
|
+
sections.push(`--- ${JSON.stringify(path)}${read.bytesRead > MAX_UNTRACKED_FILE_BYTES ? " [TRUNCATED]" : ""}\n${bytes.toString("utf8")}`);
|
|
243
|
+
}
|
|
244
|
+
} catch (error) {
|
|
245
|
+
complete = false;
|
|
246
|
+
sections.push(`--- ${JSON.stringify(path)} [read failed: ${error instanceof Error ? error.message : String(error)}]`);
|
|
247
|
+
} finally {
|
|
248
|
+
await handle?.close().catch(() => {});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (paths.length > MAX_UNTRACKED_FILES) {
|
|
252
|
+
truncated = true;
|
|
253
|
+
sections.push(`[TRUNCATED: ${paths.length - MAX_UNTRACKED_FILES} untracked paths omitted]`);
|
|
254
|
+
}
|
|
255
|
+
const bounded = boundEvidence(sections.join("\n"));
|
|
256
|
+
return { ...bounded, complete: complete && bounded.complete, truncated: truncated || bounded.truncated };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function assertNoSymlinkComponents(root: string, relativePath: string): Promise<void> {
|
|
260
|
+
const parts = relativePath.split(sep).filter(Boolean);
|
|
261
|
+
let current = root;
|
|
262
|
+
for (const part of parts) {
|
|
263
|
+
current = resolve(current, part);
|
|
264
|
+
const info = await lstat(current);
|
|
265
|
+
if (info.isSymbolicLink()) throw new Error("symlink path component is not allowed in repository evidence");
|
|
119
266
|
}
|
|
120
267
|
}
|
|
121
268
|
|
|
@@ -123,7 +270,18 @@ function formatCheckResult(result: AcceptanceCheckResult): string {
|
|
|
123
270
|
return `[${result.status}] ${result.check.id}: ${result.check.command} ${result.check.args.join(" ")}\n${result.output || "(no output)"}`;
|
|
124
271
|
}
|
|
125
272
|
|
|
273
|
+
function boundEvidence(value: string): EvidencePart {
|
|
274
|
+
const encoded = Buffer.from(value, "utf8");
|
|
275
|
+
if (encoded.byteLength <= MAX_OUTPUT_BYTES) return { text: value, complete: true, truncated: false };
|
|
276
|
+
const marker = Buffer.from("\n[TRUNCATED]", "utf8");
|
|
277
|
+
const suffix = encoded.subarray(-Math.max(0, MAX_OUTPUT_BYTES - marker.byteLength));
|
|
278
|
+
return { text: `${suffix.toString("utf8")}${marker.toString("utf8")}`, complete: false, truncated: true };
|
|
279
|
+
}
|
|
280
|
+
|
|
126
281
|
function boundOutput(value: string): string {
|
|
127
|
-
|
|
128
|
-
|
|
282
|
+
const encoded = Buffer.from(value, "utf8");
|
|
283
|
+
if (encoded.byteLength <= MAX_OUTPUT_BYTES) return value;
|
|
284
|
+
const marker = Buffer.from("\n[TRUNCATED]", "utf8");
|
|
285
|
+
const suffix = encoded.subarray(-Math.max(0, MAX_OUTPUT_BYTES - marker.byteLength));
|
|
286
|
+
return `${suffix.toString("utf8")}${marker.toString("utf8")}`;
|
|
129
287
|
}
|