patchwarden 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PatchWarden.cmd +51 -0
- package/README.en.md +1022 -0
- package/README.md +897 -358
- package/dist/assessments/agentAssessor.d.ts +15 -0
- package/dist/assessments/agentAssessor.js +293 -0
- package/dist/assessments/assessmentStore.d.ts +133 -0
- package/dist/assessments/assessmentStore.js +238 -0
- package/dist/assessments/confirmCli.d.ts +9 -0
- package/dist/assessments/confirmCli.js +26 -0
- package/dist/config.d.ts +22 -1
- package/dist/config.js +156 -3
- package/dist/direct/directAudit.d.ts +23 -0
- package/dist/direct/directAudit.js +309 -0
- package/dist/direct/directGuards.d.ts +20 -0
- package/dist/direct/directGuards.js +137 -0
- package/dist/direct/directPatch.d.ts +17 -0
- package/dist/direct/directPatch.js +113 -0
- package/dist/direct/directSessionStore.d.ts +63 -0
- package/dist/direct/directSessionStore.js +192 -0
- package/dist/direct/directVerification.d.ts +12 -0
- package/dist/direct/directVerification.js +96 -0
- package/dist/doctor.js +73 -5
- package/dist/runner/agentInvocation.d.ts +23 -0
- package/dist/runner/agentInvocation.js +106 -0
- package/dist/runner/changeCapture.d.ts +29 -0
- package/dist/runner/changeCapture.js +107 -5
- package/dist/runner/runTask.js +70 -44
- package/dist/runner/simpleProcess.d.ts +21 -0
- package/dist/runner/simpleProcess.js +206 -0
- package/dist/runner/watch.js +17 -2
- package/dist/security/commandGuard.d.ts +2 -1
- package/dist/security/commandGuard.js +25 -3
- package/dist/security/riskEngine.d.ts +27 -0
- package/dist/security/riskEngine.js +118 -0
- package/dist/security/runtimeGuard.d.ts +6 -0
- package/dist/security/runtimeGuard.js +28 -0
- package/dist/smoke-test.js +1353 -7
- package/dist/tools/applyPatch.d.ts +16 -0
- package/dist/tools/applyPatch.js +41 -0
- package/dist/tools/auditSession.d.ts +5 -0
- package/dist/tools/auditSession.js +12 -0
- package/dist/tools/auditTask.d.ts +7 -0
- package/dist/tools/auditTask.js +94 -2
- package/dist/tools/createDirectSession.d.ts +14 -0
- package/dist/tools/createDirectSession.js +54 -0
- package/dist/tools/createTask.d.ts +48 -1
- package/dist/tools/createTask.js +298 -47
- package/dist/tools/finalizeDirectSession.d.ts +19 -0
- package/dist/tools/finalizeDirectSession.js +84 -0
- package/dist/tools/getTaskSummary.d.ts +47 -0
- package/dist/tools/getTaskSummary.js +64 -1
- package/dist/tools/healthCheck.d.ts +22 -12
- package/dist/tools/healthCheck.js +23 -6
- package/dist/tools/readWorkspaceFile.d.ts +7 -1
- package/dist/tools/readWorkspaceFile.js +48 -2
- package/dist/tools/registry.js +217 -19
- package/dist/tools/runVerification.d.ts +16 -0
- package/dist/tools/runVerification.js +32 -0
- package/dist/tools/savePlan.d.ts +1 -0
- package/dist/tools/savePlan.js +38 -7
- package/dist/tools/searchWorkspace.d.ts +19 -0
- package/dist/tools/searchWorkspace.js +205 -0
- package/dist/tools/taskTemplates.js +2 -1
- package/dist/tools/toolCatalog.d.ts +3 -2
- package/dist/tools/toolCatalog.js +32 -5
- package/dist/tools/waitForTask.d.ts +2 -2
- package/dist/tools/waitForTask.js +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/docs/chatgpt-usage.md +101 -0
- package/docs/release-checklist.md +14 -0
- package/docs/release-v0.6.0.md +71 -0
- package/examples/config.example.json +5 -0
- package/examples/openai-tunnel/README.md +11 -4
- package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
- package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
- package/package.json +12 -8
- package/scripts/brand-check.js +58 -12
- package/scripts/control-smoke.js +206 -0
- package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
- package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
- package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
- package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
- package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
- package/scripts/lifecycle-smoke.js +66 -6
- package/scripts/manage-patchwarden.ps1 +639 -0
- package/scripts/mcp-manifest-check.js +100 -58
- package/scripts/mcp-smoke.js +157 -2
- package/scripts/pack-clean.js +3 -4
- package/scripts/package-manifest-check.js +78 -0
- package/scripts/patchwarden-mcp-direct.cmd +7 -0
- package/scripts/patchwarden-mcp-stdio.cmd +1 -1
- package/scripts/restart-patchwarden.ps1 +5 -4
- package/scripts/start-patchwarden-tunnel.ps1 +261 -30
- package/scripts/tunnel-supervisor-smoke.js +36 -4
- package/scripts/watcher-supervisor-smoke.js +10 -6
- package/src/assessments/agentAssessor.ts +324 -0
- package/src/assessments/assessmentStore.ts +426 -0
- package/src/assessments/confirmCli.ts +29 -0
- package/src/config.ts +178 -4
- package/src/direct/directAudit.ts +400 -0
- package/src/direct/directGuards.ts +279 -0
- package/src/direct/directPatch.ts +258 -0
- package/src/direct/directSessionStore.ts +345 -0
- package/src/direct/directVerification.ts +138 -0
- package/src/doctor.ts +103 -7
- package/src/runner/agentInvocation.ts +125 -0
- package/src/runner/changeCapture.ts +140 -5
- package/src/runner/runTask.ts +61 -43
- package/src/runner/simpleProcess.ts +223 -0
- package/src/runner/watch.ts +18 -2
- package/src/security/commandGuard.ts +46 -4
- package/src/security/riskEngine.ts +160 -0
- package/src/security/runtimeGuard.ts +41 -0
- package/src/smoke-test.ts +1291 -4
- package/src/tools/applyPatch.ts +86 -0
- package/src/tools/auditSession.ts +28 -0
- package/src/tools/auditTask.ts +100 -2
- package/src/tools/createDirectSession.ts +113 -0
- package/src/tools/createTask.ts +405 -55
- package/src/tools/finalizeDirectSession.ts +144 -0
- package/src/tools/getTaskSummary.ts +111 -1
- package/src/tools/healthCheck.ts +23 -6
- package/src/tools/readWorkspaceFile.ts +85 -2
- package/src/tools/registry.ts +242 -19
- package/src/tools/runVerification.ts +58 -0
- package/src/tools/savePlan.ts +57 -7
- package/src/tools/searchWorkspace.ts +275 -0
- package/src/tools/taskTemplates.ts +2 -1
- package/src/tools/toolCatalog.ts +35 -6
- package/src/tools/waitForTask.ts +3 -3
- package/src/version.ts +2 -2
- package/tsconfig.json +18 -17
- package/Check-PatchWarden-Health.cmd +0 -6
- package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
- package/Restart-PatchWarden.cmd +0 -19
- package/Start-PatchWarden-Tunnel.cmd +0 -7
- package/docs/release-v0.3.0.md +0 -43
- package/docs/release-v0.4.0.md +0 -74
package/dist/runner/runTask.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import { createWriteStream, existsSync, readFileSync, writeFileSync, } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import { getTasksDir, getPlansDir, getConfig, resolveWorkspaceRoot } from "../config.js";
|
|
5
5
|
import { guardPath, guardWorkspacePath } from "../security/pathGuard.js";
|
|
6
|
-
import {
|
|
6
|
+
import { guardTestCommand, } from "../security/commandGuard.js";
|
|
7
7
|
import { writeTaskProgress } from "../taskProgress.js";
|
|
8
8
|
import { writeTaskRuntime } from "../taskRuntime.js";
|
|
9
|
-
import {
|
|
9
|
+
import { validateAssessmentFreshness } from "../assessments/assessmentStore.js";
|
|
10
|
+
import { buildAgentInvocation, buildExecutionPrompt } from "./agentInvocation.js";
|
|
11
|
+
import { buildChangeArtifacts, captureRepoSnapshot, compareSnapshots, emptyArtifactHygiene, writeSnapshot, } from "./changeCapture.js";
|
|
10
12
|
const HEARTBEAT_INTERVAL_MS = 2000;
|
|
11
13
|
const GRACEFUL_KILL_MS = 2000;
|
|
12
14
|
const MAX_CAPTURE_CHARS = 100_000;
|
|
@@ -29,7 +31,6 @@ export async function runTask(taskId) {
|
|
|
29
31
|
let verifyCommands;
|
|
30
32
|
let timeoutSeconds;
|
|
31
33
|
try {
|
|
32
|
-
verifyCommands = normalizeVerifyCommands(initialStatus.verify_commands, testCommand, config);
|
|
33
34
|
timeoutSeconds = normalizeTimeout(initialStatus.timeout_seconds, config);
|
|
34
35
|
}
|
|
35
36
|
catch (error) {
|
|
@@ -45,6 +46,29 @@ export async function runTask(taskId) {
|
|
|
45
46
|
const message = `repo_path validation failed: ${errorMessage(error)}`;
|
|
46
47
|
return failBeforeExecution(taskId, taskDir, message);
|
|
47
48
|
}
|
|
49
|
+
try {
|
|
50
|
+
verifyCommands = normalizeVerifyCommands(initialStatus.verify_commands, testCommand, config, repoPath);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
const message = `verification metadata validation failed: ${errorMessage(error)}`;
|
|
54
|
+
return failBeforeExecution(taskId, taskDir, message);
|
|
55
|
+
}
|
|
56
|
+
// ── Assessment freshness revalidation before execution ──
|
|
57
|
+
const assessmentId = String(initialStatus.assessment_id || "");
|
|
58
|
+
if (assessmentId) {
|
|
59
|
+
try {
|
|
60
|
+
const preExecSnapshot = captureRepoSnapshot(repoPath);
|
|
61
|
+
const validation = validateAssessmentFreshness(assessmentId, preExecSnapshot);
|
|
62
|
+
if (!validation.valid) {
|
|
63
|
+
const message = `assessment validation failed: ${validation.failure_reason}. Re-run create_task with execution_mode=assess_only to get a fresh assessment_id.`;
|
|
64
|
+
return failBeforeExecution(taskId, taskDir, message);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
const message = `assessment validation error: ${errorMessage(error)}`;
|
|
69
|
+
return failBeforeExecution(taskId, taskDir, message);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
48
72
|
updateStatus(taskDir, {
|
|
49
73
|
status: "running",
|
|
50
74
|
phase: "preparing",
|
|
@@ -77,20 +101,13 @@ export async function runTask(taskId) {
|
|
|
77
101
|
if (!existsSync(planFile))
|
|
78
102
|
throw new Error(`Plan not found: "${planId}". Save the plan first.`);
|
|
79
103
|
const planContent = readFileSync(planFile, "utf-8");
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
if (arg === "{repo}")
|
|
84
|
-
return repoPath;
|
|
85
|
-
if (arg === "{prompt}")
|
|
86
|
-
return prompt;
|
|
87
|
-
return arg;
|
|
88
|
-
});
|
|
89
|
-
const agentCommandLabel = `${basename(agentCmd.command)} (configured agent command)`;
|
|
104
|
+
const prompt = buildExecutionPrompt(planContent, repoPath, testCommand);
|
|
105
|
+
const invocation = buildAgentInvocation(agentName, repoPath, prompt, config);
|
|
106
|
+
const agentCommandLabel = invocation.commandLabel;
|
|
90
107
|
setTaskPhase(taskDir, "executing_agent", agentCommandLabel);
|
|
91
108
|
agentResult = await runManagedProcess({
|
|
92
|
-
command:
|
|
93
|
-
args:
|
|
109
|
+
command: invocation.command,
|
|
110
|
+
args: invocation.args,
|
|
94
111
|
cwd: repoPath,
|
|
95
112
|
taskDir,
|
|
96
113
|
statusFile,
|
|
@@ -177,6 +194,7 @@ export async function runTask(taskId) {
|
|
|
177
194
|
workspace_dirty_after: beforeSnapshot.workspace_dirty,
|
|
178
195
|
patch_mode: "hash_only",
|
|
179
196
|
unavailable_reason: `Change capture failed: ${errorMessage(error)}`,
|
|
197
|
+
artifact_hygiene: emptyArtifactHygiene(),
|
|
180
198
|
};
|
|
181
199
|
finalError ||= `Change capture failed: ${errorMessage(error)}`;
|
|
182
200
|
finalStatus = "failed";
|
|
@@ -270,6 +288,7 @@ export async function runTask(taskId) {
|
|
|
270
288
|
change_policy: changePolicy,
|
|
271
289
|
summary: finalError || "Agent execution and configured verification completed successfully.",
|
|
272
290
|
changed_files: changes.changed_files,
|
|
291
|
+
artifact_hygiene: changes.artifact_hygiene,
|
|
273
292
|
out_of_scope_changes: outOfScopeChanges,
|
|
274
293
|
verify_status: verifyJson.status,
|
|
275
294
|
verify_commands: verifyJson.commands,
|
|
@@ -309,6 +328,7 @@ export async function runTask(taskId) {
|
|
|
309
328
|
finished_at: finishedAt,
|
|
310
329
|
error: finalError,
|
|
311
330
|
changed_files: changes.changed_files.map(({ path, change }) => ({ path, change })),
|
|
331
|
+
artifact_hygiene_counts: changes.artifact_hygiene.counts,
|
|
312
332
|
out_of_scope_changes: outOfScopeChanges,
|
|
313
333
|
verify_status: verifyJson.status,
|
|
314
334
|
verify_commands: verifyCommands,
|
|
@@ -403,6 +423,8 @@ async function runManagedProcess(options) {
|
|
|
403
423
|
let spawnError = null;
|
|
404
424
|
let terminationReason = null;
|
|
405
425
|
let forceTimer = null;
|
|
426
|
+
let fallbackTimer = null;
|
|
427
|
+
let childExitFinish = null;
|
|
406
428
|
let terminationStarted = false;
|
|
407
429
|
const heartbeat = () => {
|
|
408
430
|
const now = new Date().toISOString();
|
|
@@ -442,6 +464,22 @@ async function runManagedProcess(options) {
|
|
|
442
464
|
gracefulKill(child);
|
|
443
465
|
forceTimer = setTimeout(() => forceKill(child), GRACEFUL_KILL_MS);
|
|
444
466
|
}
|
|
467
|
+
// Fallback: if neither close nor exit fires within 10s after taskkill,
|
|
468
|
+
// force-resolve to prevent the runner from hanging indefinitely.
|
|
469
|
+
// Only starts AFTER termination is requested — normal long tasks are unaffected.
|
|
470
|
+
fallbackTimer = setTimeout(() => {
|
|
471
|
+
try {
|
|
472
|
+
forceKill(child);
|
|
473
|
+
}
|
|
474
|
+
catch { }
|
|
475
|
+
try {
|
|
476
|
+
child.kill("SIGKILL");
|
|
477
|
+
}
|
|
478
|
+
catch { }
|
|
479
|
+
// Resolve the exit promise via the shared finish handle
|
|
480
|
+
if (childExitFinish)
|
|
481
|
+
childExitFinish(null);
|
|
482
|
+
}, 10000);
|
|
445
483
|
};
|
|
446
484
|
child.stdout?.on("data", (chunk) => {
|
|
447
485
|
const text = chunk.toString("utf-8");
|
|
@@ -461,9 +499,19 @@ async function runManagedProcess(options) {
|
|
|
461
499
|
if (settled)
|
|
462
500
|
return;
|
|
463
501
|
settled = true;
|
|
502
|
+
if (fallbackTimer)
|
|
503
|
+
clearTimeout(fallbackTimer);
|
|
504
|
+
childExitFinish = null;
|
|
464
505
|
resolveExit(code);
|
|
465
506
|
};
|
|
507
|
+
childExitFinish = finish;
|
|
466
508
|
child.once("close", (code) => finish(code));
|
|
509
|
+
child.once("exit", (code) => {
|
|
510
|
+
// On Windows, "close" can lag behind "exit" when stdio pipes drain
|
|
511
|
+
// after taskkill. Resolve on exit to avoid hanging the runner, but
|
|
512
|
+
// still let "close" fire if it arrives first.
|
|
513
|
+
finish(code);
|
|
514
|
+
});
|
|
467
515
|
child.once("error", (error) => {
|
|
468
516
|
spawnError = error.message;
|
|
469
517
|
finish(null);
|
|
@@ -472,8 +520,8 @@ async function runManagedProcess(options) {
|
|
|
472
520
|
clearInterval(heartbeatTimer);
|
|
473
521
|
if (forceTimer)
|
|
474
522
|
clearTimeout(forceTimer);
|
|
475
|
-
stdoutStream?.
|
|
476
|
-
stderrStream?.
|
|
523
|
+
stdoutStream?.destroy();
|
|
524
|
+
stderrStream?.destroy();
|
|
477
525
|
writeTaskRuntime(options.taskDir, {
|
|
478
526
|
phase: options.phase,
|
|
479
527
|
last_heartbeat_at: new Date().toISOString(),
|
|
@@ -485,7 +533,7 @@ async function runManagedProcess(options) {
|
|
|
485
533
|
}
|
|
486
534
|
async function runTrustedTestCommand(testCommand, repoPath, taskDir, statusFile, deadlineMs) {
|
|
487
535
|
const config = getConfig();
|
|
488
|
-
const trusted = guardTestCommand(testCommand, config);
|
|
536
|
+
const trusted = guardTestCommand(testCommand, config, repoPath);
|
|
489
537
|
const startedAtMs = Date.now();
|
|
490
538
|
const startedAt = new Date(startedAtMs).toISOString();
|
|
491
539
|
const parts = trusted.split(/\s+/).filter(Boolean);
|
|
@@ -518,28 +566,6 @@ async function runTrustedTestCommand(testCommand, repoPath, taskDir, statusFile,
|
|
|
518
566
|
duration_ms: finishedAtMs - startedAtMs,
|
|
519
567
|
};
|
|
520
568
|
}
|
|
521
|
-
function buildExecutionPrompt(plan, repoPath, testCommand) {
|
|
522
|
-
let prompt = `You are executing a pre-written plan in a local repository.
|
|
523
|
-
|
|
524
|
-
## Repository
|
|
525
|
-
${repoPath}
|
|
526
|
-
|
|
527
|
-
## Plan
|
|
528
|
-
${plan}
|
|
529
|
-
|
|
530
|
-
## Instructions
|
|
531
|
-
1. Read the plan carefully.
|
|
532
|
-
2. Implement the changes in this repository only.
|
|
533
|
-
3. Do NOT modify files outside this repository.
|
|
534
|
-
4. Do NOT commit or push changes.
|
|
535
|
-
5. After implementing, describe what you changed.
|
|
536
|
-
6. Output a summary with what was done, files modified, and issues encountered.
|
|
537
|
-
`;
|
|
538
|
-
if (testCommand) {
|
|
539
|
-
prompt += `\n7. You may run ${testCommand}; PatchWarden will independently run it again for verification.`;
|
|
540
|
-
}
|
|
541
|
-
return prompt;
|
|
542
|
-
}
|
|
543
569
|
function buildTestLog(result) {
|
|
544
570
|
if (result.skipped)
|
|
545
571
|
return `${result.command || "(no test command)"}\nExit code: not run\n${result.stderr}\n`;
|
|
@@ -671,13 +697,13 @@ function buildVerifyLog(commands) {
|
|
|
671
697
|
entry.stderr_tail || "(empty)",
|
|
672
698
|
].join("\n")).join("\n\n");
|
|
673
699
|
}
|
|
674
|
-
function normalizeVerifyCommands(value, legacyTestCommand, config) {
|
|
700
|
+
function normalizeVerifyCommands(value, legacyTestCommand, config, repoPath) {
|
|
675
701
|
if (value !== undefined && !Array.isArray(value)) {
|
|
676
702
|
throw new Error("Invalid task verify_commands metadata; expected an array.");
|
|
677
703
|
}
|
|
678
704
|
return [...new Set([
|
|
679
|
-
...(value || []).map((command) => guardTestCommand(String(command), config)),
|
|
680
|
-
...(legacyTestCommand ? [guardTestCommand(legacyTestCommand, config)] : []),
|
|
705
|
+
...(value || []).map((command) => guardTestCommand(String(command), config, repoPath)),
|
|
706
|
+
...(legacyTestCommand ? [guardTestCommand(legacyTestCommand, config, repoPath)] : []),
|
|
681
707
|
])];
|
|
682
708
|
}
|
|
683
709
|
function summarizeOutput(value) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface SimpleProcessOptions {
|
|
2
|
+
command: string;
|
|
3
|
+
args: string[];
|
|
4
|
+
cwd: string;
|
|
5
|
+
timeoutMs: number;
|
|
6
|
+
maxStdoutBytes?: number;
|
|
7
|
+
maxStderrBytes?: number;
|
|
8
|
+
stdoutPath?: string;
|
|
9
|
+
stderrPath?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface SimpleProcessResult {
|
|
12
|
+
exitCode: number | null;
|
|
13
|
+
stdout: string;
|
|
14
|
+
stderr: string;
|
|
15
|
+
spawnError: string | null;
|
|
16
|
+
timedOut: boolean;
|
|
17
|
+
stdoutTruncated: boolean;
|
|
18
|
+
stderrTruncated: boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare function runSimpleProcessSync(options: SimpleProcessOptions): SimpleProcessResult;
|
|
21
|
+
export declare function runSimpleProcess(options: SimpleProcessOptions): Promise<SimpleProcessResult>;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { createWriteStream, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
3
|
+
const GRACEFUL_KILL_MS = 2000;
|
|
4
|
+
const DEFAULT_MAX_STDOUT = 524288;
|
|
5
|
+
const DEFAULT_MAX_STDERR = 131072;
|
|
6
|
+
export function runSimpleProcessSync(options) {
|
|
7
|
+
const maxStdout = options.maxStdoutBytes ?? DEFAULT_MAX_STDOUT;
|
|
8
|
+
const maxStderr = options.maxStderrBytes ?? DEFAULT_MAX_STDERR;
|
|
9
|
+
let result;
|
|
10
|
+
try {
|
|
11
|
+
result = spawnSync(options.command, options.args, {
|
|
12
|
+
cwd: options.cwd,
|
|
13
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
14
|
+
timeout: options.timeoutMs,
|
|
15
|
+
windowsHide: true,
|
|
16
|
+
maxBuffer: maxStdout + 1024,
|
|
17
|
+
encoding: "utf-8",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
return {
|
|
22
|
+
exitCode: null,
|
|
23
|
+
stdout: "",
|
|
24
|
+
stderr: "",
|
|
25
|
+
spawnError: error instanceof Error ? error.message : String(error),
|
|
26
|
+
timedOut: false,
|
|
27
|
+
stdoutTruncated: false,
|
|
28
|
+
stderrTruncated: false,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const stdout = result.stdout || "";
|
|
32
|
+
const stderr = result.stderr || "";
|
|
33
|
+
const timedOut = Boolean(result.signal === "SIGTERM" || result.signal === "SIGKILL" ||
|
|
34
|
+
(result.signal !== null && result.status === null) ||
|
|
35
|
+
(result.error && result.error.code === "ETIMEDOUT"));
|
|
36
|
+
if (options.stdoutPath && stdout) {
|
|
37
|
+
try {
|
|
38
|
+
writeFileSyncAppend(options.stdoutPath, stdout);
|
|
39
|
+
}
|
|
40
|
+
catch { }
|
|
41
|
+
}
|
|
42
|
+
if (options.stderrPath && stderr) {
|
|
43
|
+
try {
|
|
44
|
+
writeFileSyncAppend(options.stderrPath, stderr);
|
|
45
|
+
}
|
|
46
|
+
catch { }
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
exitCode: result.status,
|
|
50
|
+
stdout: stdout.length > maxStdout ? stdout.slice(0, maxStdout) : stdout,
|
|
51
|
+
stderr: stderr.length > maxStderr ? stderr.slice(0, maxStderr) : stderr,
|
|
52
|
+
// If timed out, don't report spawnError — it's a timeout, not a spawn failure
|
|
53
|
+
spawnError: timedOut ? null : (result.error ? result.error.message : null),
|
|
54
|
+
timedOut,
|
|
55
|
+
stdoutTruncated: stdout.length > maxStdout,
|
|
56
|
+
stderrTruncated: stderr.length > maxStderr,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export async function runSimpleProcess(options) {
|
|
60
|
+
const maxStdout = options.maxStdoutBytes ?? DEFAULT_MAX_STDOUT;
|
|
61
|
+
const maxStderr = options.maxStderrBytes ?? DEFAULT_MAX_STDERR;
|
|
62
|
+
const deadlineMs = Date.now() + options.timeoutMs;
|
|
63
|
+
let child;
|
|
64
|
+
try {
|
|
65
|
+
child = spawn(options.command, options.args, {
|
|
66
|
+
cwd: options.cwd,
|
|
67
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
68
|
+
detached: process.platform !== "win32",
|
|
69
|
+
windowsHide: true,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return {
|
|
74
|
+
exitCode: null,
|
|
75
|
+
stdout: "",
|
|
76
|
+
stderr: "",
|
|
77
|
+
spawnError: error instanceof Error ? error.message : String(error),
|
|
78
|
+
timedOut: false,
|
|
79
|
+
stdoutTruncated: false,
|
|
80
|
+
stderrTruncated: false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const stdoutStream = options.stdoutPath ? createWriteStream(options.stdoutPath, { flags: "a" }) : null;
|
|
84
|
+
const stderrStream = options.stderrPath ? createWriteStream(options.stderrPath, { flags: "a" }) : null;
|
|
85
|
+
let stdoutBuf = Buffer.alloc(0);
|
|
86
|
+
let stderrBuf = Buffer.alloc(0);
|
|
87
|
+
let stdoutTruncated = false;
|
|
88
|
+
let stderrTruncated = false;
|
|
89
|
+
let spawnError = null;
|
|
90
|
+
let timedOut = false;
|
|
91
|
+
let forceTimer = null;
|
|
92
|
+
let terminationStarted = false;
|
|
93
|
+
const requestTermination = (reason, force) => {
|
|
94
|
+
if (terminationStarted)
|
|
95
|
+
return;
|
|
96
|
+
terminationStarted = true;
|
|
97
|
+
if (reason === "timeout")
|
|
98
|
+
timedOut = true;
|
|
99
|
+
if (force) {
|
|
100
|
+
forceKill(child);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
gracefulKill(child);
|
|
104
|
+
forceTimer = setTimeout(() => forceKill(child), GRACEFUL_KILL_MS);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
child.stdout?.on("data", (chunk) => {
|
|
108
|
+
stdoutStream?.write(chunk);
|
|
109
|
+
if (stdoutBuf.length < maxStdout) {
|
|
110
|
+
const remaining = maxStdout - stdoutBuf.length;
|
|
111
|
+
if (chunk.length <= remaining) {
|
|
112
|
+
stdoutBuf = Buffer.concat([stdoutBuf, chunk]);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
stdoutBuf = Buffer.concat([stdoutBuf, chunk.slice(0, remaining)]);
|
|
116
|
+
stdoutTruncated = true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
stdoutTruncated = true;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
child.stderr?.on("data", (chunk) => {
|
|
124
|
+
stderrStream?.write(chunk);
|
|
125
|
+
if (stderrBuf.length < maxStderr) {
|
|
126
|
+
const remaining = maxStderr - stderrBuf.length;
|
|
127
|
+
if (chunk.length <= remaining) {
|
|
128
|
+
stderrBuf = Buffer.concat([stderrBuf, chunk]);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
stderrBuf = Buffer.concat([stderrBuf, chunk.slice(0, remaining)]);
|
|
132
|
+
stderrTruncated = true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
stderrTruncated = true;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
const timeoutTimer = setTimeout(() => requestTermination("timeout", true), options.timeoutMs);
|
|
140
|
+
const exitCode = await new Promise((resolveExit) => {
|
|
141
|
+
let settled = false;
|
|
142
|
+
const finish = (code) => {
|
|
143
|
+
if (settled)
|
|
144
|
+
return;
|
|
145
|
+
settled = true;
|
|
146
|
+
resolveExit(code);
|
|
147
|
+
};
|
|
148
|
+
child.once("close", (code) => finish(code));
|
|
149
|
+
child.once("exit", (code) => finish(code));
|
|
150
|
+
child.once("error", (error) => {
|
|
151
|
+
spawnError = error.message;
|
|
152
|
+
finish(null);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
clearTimeout(timeoutTimer);
|
|
156
|
+
if (forceTimer)
|
|
157
|
+
clearTimeout(forceTimer);
|
|
158
|
+
stdoutStream?.destroy();
|
|
159
|
+
stderrStream?.destroy();
|
|
160
|
+
return {
|
|
161
|
+
exitCode,
|
|
162
|
+
stdout: stdoutBuf.toString("utf-8"),
|
|
163
|
+
stderr: stderrBuf.toString("utf-8"),
|
|
164
|
+
spawnError,
|
|
165
|
+
timedOut,
|
|
166
|
+
stdoutTruncated,
|
|
167
|
+
stderrTruncated,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function gracefulKill(child) {
|
|
171
|
+
try {
|
|
172
|
+
if (process.platform !== "win32" && child.pid)
|
|
173
|
+
process.kill(-child.pid, "SIGTERM");
|
|
174
|
+
else
|
|
175
|
+
child.kill("SIGTERM");
|
|
176
|
+
}
|
|
177
|
+
catch { }
|
|
178
|
+
}
|
|
179
|
+
function forceKill(child) {
|
|
180
|
+
if (!child.pid)
|
|
181
|
+
return;
|
|
182
|
+
try {
|
|
183
|
+
if (process.platform === "win32") {
|
|
184
|
+
const result = spawnSync("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], {
|
|
185
|
+
stdio: "ignore",
|
|
186
|
+
timeout: 5000,
|
|
187
|
+
windowsHide: true,
|
|
188
|
+
});
|
|
189
|
+
if (result.status !== 0)
|
|
190
|
+
child.kill("SIGKILL");
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
process.kill(-child.pid, "SIGKILL");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
try {
|
|
198
|
+
child.kill("SIGKILL");
|
|
199
|
+
}
|
|
200
|
+
catch { }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function writeFileSyncAppend(path, content) {
|
|
204
|
+
const existing = existsSync(path) ? readFileSync(path, "utf-8") : "";
|
|
205
|
+
writeFileSync(path, existing + content, "utf-8");
|
|
206
|
+
}
|
package/dist/runner/watch.js
CHANGED
|
@@ -21,6 +21,8 @@ import { resolve, join, dirname } from "node:path";
|
|
|
21
21
|
import { loadConfig, getConfig, getTasksDir, resolveWorkspaceRoot } from "../config.js";
|
|
22
22
|
import { guardWorkspacePath } from "../security/pathGuard.js";
|
|
23
23
|
import { guardAgentCommand, guardTestCommand } from "../security/commandGuard.js";
|
|
24
|
+
import { validateAssessmentFreshness } from "../assessments/assessmentStore.js";
|
|
25
|
+
import { captureRepoSnapshot } from "./changeCapture.js";
|
|
24
26
|
import { runTask } from "./runTask.js";
|
|
25
27
|
// ── Bootstrap ─────────────────────────────────────────────────────
|
|
26
28
|
loadConfig();
|
|
@@ -80,12 +82,25 @@ async function tick() {
|
|
|
80
82
|
// ── Pre-flight safety checks ──
|
|
81
83
|
try {
|
|
82
84
|
// Check repo_path
|
|
83
|
-
guardWorkspacePath(statusData.repo_path || wsRoot, wsRoot);
|
|
85
|
+
const resolvedRepoPath = guardWorkspacePath(statusData.resolved_repo_path || statusData.repo_path || wsRoot, wsRoot);
|
|
84
86
|
// Check agent
|
|
85
87
|
guardAgentCommand(statusData.agent, config);
|
|
86
88
|
// Check test_command
|
|
87
89
|
if (statusData.test_command) {
|
|
88
|
-
guardTestCommand(statusData.test_command, config);
|
|
90
|
+
guardTestCommand(statusData.test_command, config, resolvedRepoPath);
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(statusData.verify_commands)) {
|
|
93
|
+
for (const command of statusData.verify_commands) {
|
|
94
|
+
guardTestCommand(String(command), config, resolvedRepoPath);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Assessment freshness revalidation
|
|
98
|
+
if (statusData.assessment_id) {
|
|
99
|
+
const preExecSnapshot = captureRepoSnapshot(resolvedRepoPath);
|
|
100
|
+
const validation = validateAssessmentFreshness(String(statusData.assessment_id), preExecSnapshot);
|
|
101
|
+
if (!validation.valid) {
|
|
102
|
+
throw new Error(`assessment validation failed: ${validation.failure_reason}`);
|
|
103
|
+
}
|
|
89
104
|
}
|
|
90
105
|
}
|
|
91
106
|
catch (err) {
|
|
@@ -13,7 +13,8 @@ export interface AllowedCommand {
|
|
|
13
13
|
args: string[];
|
|
14
14
|
}
|
|
15
15
|
export declare function guardAgentCommand(agent: string, config: PatchWardenConfig): AllowedCommand;
|
|
16
|
-
export declare function guardTestCommand(testCommand: string, config: PatchWardenConfig): string;
|
|
16
|
+
export declare function guardTestCommand(testCommand: string, config: PatchWardenConfig, repoPath?: string): string;
|
|
17
|
+
export declare function guardDirectCommand(command: string, config: PatchWardenConfig, repoPath?: string): string;
|
|
17
18
|
/**
|
|
18
19
|
* Escape a user-provided string for safe use in shell arguments.
|
|
19
20
|
* We prevent injection by refusing to pass arbitrary strings to shell.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getRepoAllowedTestCommands, getRepoDirectAllowedCommands } from "../config.js";
|
|
1
2
|
import { PatchWardenError } from "../errors.js";
|
|
2
3
|
export function guardAgentCommand(agent, config) {
|
|
3
4
|
const agentCfg = config.agents[agent];
|
|
@@ -32,7 +33,7 @@ function isSafeConfiguredCommand(command) {
|
|
|
32
33
|
const basename = parts[parts.length - 1] || "";
|
|
33
34
|
return /^[a-zA-Z0-9._-]+$/.test(basename);
|
|
34
35
|
}
|
|
35
|
-
export function guardTestCommand(testCommand, config) {
|
|
36
|
+
export function guardTestCommand(testCommand, config, repoPath) {
|
|
36
37
|
if (!testCommand || typeof testCommand !== "string") {
|
|
37
38
|
// If no test command specified, that's ok — skip tests
|
|
38
39
|
return "";
|
|
@@ -40,8 +41,29 @@ export function guardTestCommand(testCommand, config) {
|
|
|
40
41
|
const trimmed = testCommand.trim();
|
|
41
42
|
if (trimmed === "")
|
|
42
43
|
return "";
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
const allowedCommands = [
|
|
45
|
+
...config.allowedTestCommands,
|
|
46
|
+
...(repoPath ? getRepoAllowedTestCommands(config, repoPath) : []),
|
|
47
|
+
];
|
|
48
|
+
if (!allowedCommands.includes(trimmed)) {
|
|
49
|
+
throw new PatchWardenError("test_command_not_allowlisted", `Test command "${trimmed}" is not allowed for this repository. Allowed: ${allowedCommands.join(", ")}`, "Use an exact allowed command shown by create_task, or omit test_command.");
|
|
50
|
+
}
|
|
51
|
+
return trimmed;
|
|
52
|
+
}
|
|
53
|
+
export function guardDirectCommand(command, config, repoPath) {
|
|
54
|
+
if (!command || typeof command !== "string") {
|
|
55
|
+
throw new PatchWardenError("direct_command_required", "A command string is required for run_verification.", "Provide one of the allowed Direct verification commands.");
|
|
56
|
+
}
|
|
57
|
+
const trimmed = command.trim();
|
|
58
|
+
if (trimmed === "") {
|
|
59
|
+
throw new PatchWardenError("direct_command_required", "A command string is required for run_verification.", "Provide one of the allowed Direct verification commands.");
|
|
60
|
+
}
|
|
61
|
+
const allowedCommands = [
|
|
62
|
+
...(config.directAllowedCommands || []),
|
|
63
|
+
...(repoPath ? getRepoDirectAllowedCommands(config, repoPath) : []),
|
|
64
|
+
];
|
|
65
|
+
if (!allowedCommands.includes(trimmed)) {
|
|
66
|
+
throw new PatchWardenError("direct_command_not_allowlisted", `Direct command "${trimmed}" is not allowed. Allowed: ${allowedCommands.join(", ")}`, "Use an exact allowed command from the Direct allowlist.");
|
|
45
67
|
}
|
|
46
68
|
return trimmed;
|
|
47
69
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { PatchWardenConfig } from "../config.js";
|
|
2
|
+
import type { TaskTemplateName } from "../tools/taskTemplates.js";
|
|
3
|
+
export type RiskLevel = "low" | "medium" | "high";
|
|
4
|
+
export type RiskDecision = "allow" | "needs_confirm" | "blocked";
|
|
5
|
+
export interface RiskAssessmentInput {
|
|
6
|
+
repoPath: string;
|
|
7
|
+
resolvedRepoPath: string;
|
|
8
|
+
planContent: string;
|
|
9
|
+
planTitle: string;
|
|
10
|
+
testCommand: string;
|
|
11
|
+
verifyCommands: string[];
|
|
12
|
+
template?: TaskTemplateName;
|
|
13
|
+
goal?: string;
|
|
14
|
+
agent: string;
|
|
15
|
+
config: PatchWardenConfig;
|
|
16
|
+
snapshotTruncated: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface RiskAssessmentResult {
|
|
19
|
+
risk_level: RiskLevel;
|
|
20
|
+
decision: RiskDecision;
|
|
21
|
+
reason_codes: string[];
|
|
22
|
+
risk_hints: string[];
|
|
23
|
+
hard_rule_hits: string[];
|
|
24
|
+
}
|
|
25
|
+
export declare function assessRisk(input: RiskAssessmentInput): RiskAssessmentResult;
|
|
26
|
+
/** Risk hints — keyword detection only, never affects risk_level (收缩 #4). */
|
|
27
|
+
export declare function collectRiskHints(input: RiskAssessmentInput): string[];
|