pi-claude-supervisor 0.5.1 → 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 +19 -0
- package/README.cn.md +15 -6
- package/README.md +20 -12
- package/docs/architecture.md +41 -11
- package/docs/automation-hardening-plan.md +211 -0
- package/docs/engineering-plan.md +24 -2
- package/docs/testing.md +59 -19
- package/package.json +1 -1
- package/src/decision-worker.ts +56 -9
- package/src/index.ts +29 -7
- package/src/redaction.ts +2 -1
- package/src/reviewer.ts +69 -16
- package/src/supervisor.ts +238 -37
- package/src/types.ts +6 -2
- package/src/verifier.ts +175 -20
- package/src/worker/process-adapter.ts +245 -47
- package/src/worker/tmux-adapter.ts +42 -3
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,
|
|
167
|
+
env: workerEnvironment(process.env, { GIT_TERMINAL_PROMPT: "0" }),
|
|
168
|
+
});
|
|
169
|
+
const bounded = boundEvidence(`${result.stdout}${result.stderr}`);
|
|
170
|
+
return { ...bounded, text: bounded.text || "(none)" };
|
|
171
|
+
} catch (error) {
|
|
172
|
+
const failure = error as { stdout?: string; stderr?: string; message?: string };
|
|
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,
|
|
113
193
|
env: workerEnvironment(process.env, { GIT_TERMINAL_PROMPT: "0" }),
|
|
114
194
|
});
|
|
115
|
-
|
|
195
|
+
output = result.stdout;
|
|
196
|
+
throwIfAborted(signal);
|
|
116
197
|
} catch (error) {
|
|
117
198
|
const failure = error as { stdout?: string; stderr?: string; message?: string };
|
|
118
|
-
return
|
|
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,6 +270,14 @@ 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
282
|
const encoded = Buffer.from(value, "utf8");
|
|
128
283
|
if (encoded.byteLength <= MAX_OUTPUT_BYTES) return value;
|