patchwarden 0.4.0 → 0.6.1
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/logging.d.ts +52 -0
- package/dist/logging.js +123 -0
- package/dist/runner/agentInvocation.d.ts +23 -0
- package/dist/runner/agentInvocation.js +106 -0
- package/dist/runner/changeCapture.d.ts +70 -0
- package/dist/runner/changeCapture.js +278 -6
- package/dist/runner/runTask.js +271 -65
- 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/test/unit/android-doctor.test.d.ts +1 -0
- package/dist/test/unit/android-doctor.test.js +118 -0
- package/dist/test/unit/chinese-path.test.d.ts +1 -0
- package/dist/test/unit/chinese-path.test.js +91 -0
- package/dist/test/unit/command-guard.test.d.ts +1 -0
- package/dist/test/unit/command-guard.test.js +160 -0
- package/dist/test/unit/direct-guards.test.d.ts +1 -0
- package/dist/test/unit/direct-guards.test.js +213 -0
- package/dist/test/unit/logging.test.d.ts +1 -0
- package/dist/test/unit/logging.test.js +275 -0
- package/dist/test/unit/path-guard.test.d.ts +1 -0
- package/dist/test/unit/path-guard.test.js +109 -0
- package/dist/test/unit/safe-status.test.d.ts +1 -0
- package/dist/test/unit/safe-status.test.js +165 -0
- package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
- package/dist/test/unit/sensitive-guard.test.js +104 -0
- package/dist/test/unit/sync-file.test.d.ts +1 -0
- package/dist/test/unit/sync-file.test.js +154 -0
- package/dist/test/unit/watcher-status.test.d.ts +1 -0
- package/dist/test/unit/watcher-status.test.js +169 -0
- package/dist/tools/androidDoctor.d.ts +38 -0
- package/dist/tools/androidDoctor.js +391 -0
- 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 +105 -7
- 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 +50 -0
- package/dist/tools/getTaskSummary.js +79 -2
- package/dist/tools/healthCheck.d.ts +27 -12
- package/dist/tools/healthCheck.js +44 -6
- package/dist/tools/readWorkspaceFile.d.ts +7 -1
- package/dist/tools/readWorkspaceFile.js +48 -2
- package/dist/tools/registry.js +270 -19
- package/dist/tools/runVerification.d.ts +16 -0
- package/dist/tools/runVerification.js +32 -0
- package/dist/tools/safeStatus.d.ts +19 -0
- package/dist/tools/safeStatus.js +72 -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/syncFile.d.ts +18 -0
- package/dist/tools/syncFile.js +65 -0
- package/dist/tools/taskOutputs.d.ts +2 -2
- package/dist/tools/taskTemplates.js +2 -1
- package/dist/tools/toolCatalog.d.ts +4 -3
- package/dist/tools/toolCatalog.js +34 -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/dist/watcherStatus.d.ts +1 -0
- package/dist/watcherStatus.js +96 -4
- package/docs/chatgpt-usage.md +101 -0
- package/docs/performance-notes.md +55 -0
- package/docs/release-checklist.md +14 -0
- package/docs/release-v0.6.0.md +71 -0
- package/docs/release-v0.6.1.md +75 -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 +13 -8
- package/scripts/brand-check.js +58 -12
- package/scripts/control-smoke.js +206 -0
- package/scripts/http-mcp-smoke.js +10 -2
- 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 +402 -8
- package/scripts/manage-patchwarden.ps1 +639 -0
- package/scripts/mcp-manifest-check.js +123 -58
- package/scripts/mcp-smoke.js +160 -2
- package/scripts/pack-clean.js +160 -5
- 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/unit-tests.js +36 -0
- 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/logging.ts +152 -0
- package/src/runner/agentInvocation.ts +125 -0
- package/src/runner/changeCapture.ts +352 -6
- package/src/runner/runTask.ts +279 -63
- 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/test/unit/android-doctor.test.ts +158 -0
- package/src/test/unit/chinese-path.test.ts +106 -0
- package/src/test/unit/command-guard.test.ts +221 -0
- package/src/test/unit/direct-guards.test.ts +297 -0
- package/src/test/unit/logging.test.ts +325 -0
- package/src/test/unit/path-guard.test.ts +150 -0
- package/src/test/unit/safe-status.test.ts +187 -0
- package/src/test/unit/sensitive-guard.test.ts +124 -0
- package/src/test/unit/sync-file.test.ts +231 -0
- package/src/test/unit/watcher-status.test.ts +190 -0
- package/src/tools/androidDoctor.ts +424 -0
- package/src/tools/applyPatch.ts +86 -0
- package/src/tools/auditSession.ts +28 -0
- package/src/tools/auditTask.ts +111 -7
- 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 +133 -2
- package/src/tools/healthCheck.ts +45 -6
- package/src/tools/readWorkspaceFile.ts +85 -2
- package/src/tools/registry.ts +305 -19
- package/src/tools/runVerification.ts +58 -0
- package/src/tools/safeStatus.ts +96 -0
- package/src/tools/savePlan.ts +57 -7
- package/src/tools/searchWorkspace.ts +275 -0
- package/src/tools/syncFile.ts +122 -0
- package/src/tools/taskTemplates.ts +2 -1
- package/src/tools/toolCatalog.ts +37 -6
- package/src/tools/waitForTask.ts +3 -3
- package/src/version.ts +2 -2
- package/src/watcherStatus.ts +101 -4
- 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
|
@@ -14,10 +14,19 @@ const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
|
14
14
|
const temp = mkdtempSync(join(tmpdir(), "patchwarden-tunnel-smoke-"));
|
|
15
15
|
const mockJs = join(temp, "mock-tunnel-client.js");
|
|
16
16
|
const mockCmd = join(temp, "mock-tunnel-client.cmd");
|
|
17
|
+
const mockConfig = join(temp, "patchwarden.config.json");
|
|
17
18
|
const stateFile = join(temp, "attempt.txt");
|
|
18
19
|
const secretMarker = "smoke-secret-must-not-appear";
|
|
19
20
|
|
|
20
21
|
try {
|
|
22
|
+
writeFileSync(mockConfig, JSON.stringify({
|
|
23
|
+
workspaceRoot: temp,
|
|
24
|
+
plansDir: ".patchwarden/plans",
|
|
25
|
+
tasksDir: ".patchwarden/tasks",
|
|
26
|
+
toolProfile: "chatgpt_core",
|
|
27
|
+
agents: { smoke: { command: process.execPath, args: [] } },
|
|
28
|
+
allowedTestCommands: ["npm test"],
|
|
29
|
+
}, null, 2), "utf-8");
|
|
21
30
|
writeFileSync(mockCmd, `@echo off\r\nnode "%MOCK_TUNNEL_JS%" %*\r\n`, "utf-8");
|
|
22
31
|
writeFileSync(mockJs, `
|
|
23
32
|
const fs=require('fs');
|
|
@@ -30,11 +39,12 @@ if(command==='health'){console.log(JSON.stringify({healthz:{ok:true},readyz:{ok:
|
|
|
30
39
|
if(command==='run'){
|
|
31
40
|
let attempt=0;try{attempt=Number(fs.readFileSync(process.env.MOCK_TUNNEL_STATE,'utf8'))||0}catch{}
|
|
32
41
|
attempt++;fs.writeFileSync(process.env.MOCK_TUNNEL_STATE,String(attempt));
|
|
33
|
-
|
|
42
|
+
console.log('fixture cwd='+process.cwd());
|
|
43
|
+
if(attempt===1){console.error('fixture first attempt failed');process.exit(7)}
|
|
34
44
|
const urlFile=flag('--health.url-file');const pidFile=flag('--pid.file');
|
|
35
45
|
const server=http.createServer((req,res)=>{res.writeHead(200,{'content-type':'application/json'});res.end(JSON.stringify({ok:true}))});
|
|
36
46
|
server.listen(18888,'127.0.0.1',()=>{fs.writeFileSync(urlFile,'http://127.0.0.1:18888');fs.writeFileSync(pidFile,String(process.pid))});
|
|
37
|
-
setTimeout(()=>server.close(()=>process.exit(9)),6500);
|
|
47
|
+
setTimeout(()=>server.close(()=>{for(let i=1;i<=35;i++)console.error('fixture stderr line '+i);console.error('CONTROL_PLANE_API_KEY='+process.env.CONTROL_PLANE_API_KEY);process.exit(9)}),6500);
|
|
38
48
|
}
|
|
39
49
|
`, "utf-8");
|
|
40
50
|
|
|
@@ -50,6 +60,7 @@ if(command==='run'){
|
|
|
50
60
|
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", join(root, "scripts", "start-patchwarden-tunnel.ps1"),
|
|
51
61
|
"-TunnelId", "tunnel_smoke_fixture",
|
|
52
62
|
"-TunnelClientExe", mockCmd,
|
|
63
|
+
"-ConfigPath", mockConfig,
|
|
53
64
|
"-ProxyUrl", "http://127.0.0.1:1",
|
|
54
65
|
"-ReconnectBaseSeconds", "1",
|
|
55
66
|
"-ReconnectMaxSeconds", "1",
|
|
@@ -59,6 +70,7 @@ if(command==='run'){
|
|
|
59
70
|
], { cwd: root, env, encoding: "utf-8", timeout: 30_000 });
|
|
60
71
|
|
|
61
72
|
if (result.error) throw result.error;
|
|
73
|
+
const launcherOutput = `${result.stdout}\n${result.stderr}`;
|
|
62
74
|
if (!existsSync(stateFile) || readFileSync(stateFile, "utf-8").trim() !== "2") {
|
|
63
75
|
throw new Error(`Expected two supervised attempts. stdout=${result.stdout} stderr=${result.stderr}`);
|
|
64
76
|
}
|
|
@@ -67,11 +79,31 @@ if(command==='run'){
|
|
|
67
79
|
if (status.reason_code !== "retry_limit_reached" || status.attempt !== 2) {
|
|
68
80
|
throw new Error(`Unexpected final supervisor status: ${JSON.stringify(status)}`);
|
|
69
81
|
}
|
|
82
|
+
if (status.last_exit_code !== 9 || !Array.isArray(status.stderr_tail) || status.stderr_tail.length !== 30) {
|
|
83
|
+
throw new Error(`Supervisor did not preserve the latest exit diagnostics: ${JSON.stringify(status)}\n${launcherOutput}`);
|
|
84
|
+
}
|
|
85
|
+
if (!status.stdout_log?.endsWith("tunnel-client.stdout.log") || !status.stderr_log?.endsWith("tunnel-client.stderr.log")) {
|
|
86
|
+
throw new Error(`Supervisor log paths are missing: ${JSON.stringify(status)}`);
|
|
87
|
+
}
|
|
88
|
+
if (!existsSync(status.stdout_log) || !existsSync(status.stderr_log)) {
|
|
89
|
+
throw new Error(`Supervisor log files were not created: ${JSON.stringify(status)}`);
|
|
90
|
+
}
|
|
91
|
+
const stdoutLog = readFileSync(status.stdout_log, "utf-8").replace(/^\uFEFF/, "");
|
|
92
|
+
const stderrLog = readFileSync(status.stderr_log, "utf-8").replace(/^\uFEFF/, "");
|
|
93
|
+
if (!stdoutLog.includes(`fixture cwd=${root}`)) {
|
|
94
|
+
throw new Error(`Tunnel child did not inherit the project working directory: ${stdoutLog}`);
|
|
95
|
+
}
|
|
96
|
+
if (!stderrLog.includes("fixture stderr line 35") || !stderrLog.includes("[REDACTED]")) {
|
|
97
|
+
throw new Error(`Supervisor stderr log was not captured and sanitized: ${stderrLog}`);
|
|
98
|
+
}
|
|
70
99
|
const serialized = JSON.stringify(status);
|
|
71
|
-
if (serialized.includes(secretMarker) || serialized.includes("tunnel_smoke_fixture")) {
|
|
100
|
+
if (serialized.includes(secretMarker) || launcherOutput.includes(secretMarker) || stdoutLog.includes(secretMarker) || stderrLog.includes(secretMarker) || serialized.includes("tunnel_smoke_fixture")) {
|
|
72
101
|
throw new Error("Supervisor status leaked credential or tunnel identifier material");
|
|
73
102
|
}
|
|
74
|
-
|
|
103
|
+
if (!launcherOutput.includes("[error] stderr tail:") || !launcherOutput.includes("fixture stderr line 35")) {
|
|
104
|
+
throw new Error(`Supervisor did not print the stderr tail: ${launcherOutput}`);
|
|
105
|
+
}
|
|
106
|
+
console.log("ok - tunnel supervisor captures logs, redacts tails, fixes cwd, retries, and writes structured status");
|
|
75
107
|
} finally {
|
|
76
108
|
rmSync(temp, { recursive: true, force: true });
|
|
77
109
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const scriptDir = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
|
8
|
+
const root = resolve(scriptDir, "..");
|
|
9
|
+
const unitDir = resolve(root, "dist", "test", "unit");
|
|
10
|
+
|
|
11
|
+
if (!existsSync(unitDir)) {
|
|
12
|
+
console.error(`[unit-tests] Missing compiled test directory: ${unitDir}`);
|
|
13
|
+
console.error("[unit-tests] Run npm run build before npm run test:unit.");
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const testFiles = readdirSync(unitDir)
|
|
18
|
+
.filter((name) => name.endsWith(".test.js"))
|
|
19
|
+
.sort()
|
|
20
|
+
.map((name) => resolve(unitDir, name));
|
|
21
|
+
|
|
22
|
+
if (testFiles.length === 0) {
|
|
23
|
+
console.error(`[unit-tests] No compiled unit tests found in ${unitDir}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const result = spawnSync(process.execPath, ["--test", ...testFiles], {
|
|
28
|
+
stdio: "inherit",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (result.error) {
|
|
32
|
+
console.error(`[unit-tests] Failed to run unit tests: ${result.error.message}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
process.exit(typeof result.status === "number" ? result.status : 1);
|
|
@@ -37,7 +37,7 @@ try {
|
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
await runExternalScenario();
|
|
40
|
-
console.log("ok - watcher supervisor handles exit, stale heartbeat, retry limit, and external ownership");
|
|
40
|
+
console.log("ok - watcher supervisor isolates environment and handles exit, stale heartbeat, retry limit, and external ownership");
|
|
41
41
|
} finally {
|
|
42
42
|
for (const child of children) {
|
|
43
43
|
if (child.exitCode === null) child.kill("SIGKILL");
|
|
@@ -67,6 +67,7 @@ async function runExternalScenario() {
|
|
|
67
67
|
PATCHWARDEN_CONFIG: fixture.configPath,
|
|
68
68
|
PATCHWARDEN_WATCHER_INSTANCE_ID: "external-fixture",
|
|
69
69
|
PATCHWARDEN_WATCHER_LAUNCHER_PID: "999999",
|
|
70
|
+
XDG_CONFIG_HOME: join(fixture.localAppData, "patchwarden", "opencode-config"),
|
|
70
71
|
},
|
|
71
72
|
stdio: "ignore",
|
|
72
73
|
});
|
|
@@ -95,8 +96,8 @@ function createFixture(name) {
|
|
|
95
96
|
writeFileSync(join(scripts, "patchwarden-mcp-stdio.cmd"), "@echo off\r\nexit /b 0\r\n", "utf-8");
|
|
96
97
|
const manifestFixture = JSON.stringify({
|
|
97
98
|
ok: true,
|
|
98
|
-
server_version: "0.
|
|
99
|
-
schema_epoch: "2026-06-
|
|
99
|
+
server_version: "0.6.0",
|
|
100
|
+
schema_epoch: "2026-06-22-v6",
|
|
100
101
|
tool_profile: "chatgpt_core",
|
|
101
102
|
tool_count: 16,
|
|
102
103
|
tool_names: [],
|
|
@@ -144,6 +145,7 @@ function runLauncher(fixture, mode, maxRestarts, lifetimeMs) {
|
|
|
144
145
|
"-TunnelClientExe", fixture.mockCmd,
|
|
145
146
|
"-ProxyUrl", "http://127.0.0.1:1",
|
|
146
147
|
"-MaxReconnectAttempts", "1",
|
|
148
|
+
"-HealthListenAddr", "127.0.0.1:8080",
|
|
147
149
|
"-WatcherMaxRestartAttempts", String(maxRestarts),
|
|
148
150
|
"-WatcherHealthyResetSeconds", "60",
|
|
149
151
|
], {
|
|
@@ -168,6 +170,7 @@ function fixtureEnv(fixture, mode, lifetimeMs) {
|
|
|
168
170
|
function watcherFixtureSource(attemptPath) {
|
|
169
171
|
return `
|
|
170
172
|
const fs=require('fs');const path=require('path');
|
|
173
|
+
if(!process.env.XDG_CONFIG_HOME){console.error('watcher did not receive XDG_CONFIG_HOME');process.exit(12)}
|
|
171
174
|
let attempt=0;try{attempt=Number(fs.readFileSync(${JSON.stringify(attemptPath)},'utf8'))||0}catch{}
|
|
172
175
|
attempt++;fs.writeFileSync(${JSON.stringify(attemptPath)},String(attempt));
|
|
173
176
|
const cfg=JSON.parse(fs.readFileSync(process.env.PATCHWARDEN_CONFIG,'utf8'));
|
|
@@ -182,11 +185,12 @@ else setInterval(write,250);
|
|
|
182
185
|
|
|
183
186
|
function tunnelFixtureSource() {
|
|
184
187
|
return `
|
|
185
|
-
const fs=require('fs');const args=process.argv.slice(2);const command=args[0]||'';
|
|
188
|
+
const fs=require('fs');const path=require('path');const args=process.argv.slice(2);const command=args[0]||'';
|
|
186
189
|
const flag=(name)=>{const i=args.indexOf(name);return i>=0?args[i+1]:''};
|
|
187
|
-
if(command==='init'||command
|
|
190
|
+
if(command==='init'){const profile=flag('--profile')||'patchwarden';const mcpCommand=flag('--mcp-command')||'';const yamlDir=path.join(process.env.APPDATA,'tunnel-client');const yamlPath=path.join(yamlDir,profile+'.yaml');fs.mkdirSync(yamlDir,{recursive:true});fs.writeFileSync(yamlPath,'mcp:\\n commands:\\n - channel: main\\n command: \"'+mcpCommand.replace(/\\\\/g,'/')+'\"\\n\\nhealth:\\n listen_addr: \"127.0.0.1:8080\"\\n');console.log('{}');process.exit(0)}
|
|
191
|
+
if(command==='doctor'){console.log('{}');process.exit(0)}
|
|
188
192
|
if(command==='health'){console.log(JSON.stringify({healthz:{ok:true},readyz:{ok:true}}));process.exit(0)}
|
|
189
|
-
if(command==='run'){const url=flag('--health.url-file'),pid=flag('--pid.file');fs.mkdirSync(
|
|
193
|
+
if(command==='run'){if(process.env.XDG_CONFIG_HOME){console.error('watcher XDG_CONFIG_HOME leaked into tunnel-client');process.exit(21)}const url=flag('--health.url-file'),pid=flag('--pid.file');fs.mkdirSync(path.dirname(url),{recursive:true});fs.writeFileSync(url,'http://127.0.0.1:18889');fs.writeFileSync(pid,String(process.pid));setTimeout(()=>process.exit(9),Number(process.env.TUNNEL_FIXTURE_LIFETIME_MS)||9000)}
|
|
190
194
|
`;
|
|
191
195
|
}
|
|
192
196
|
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve, relative, isAbsolute } from "node:path";
|
|
3
|
+
import { PatchWardenConfig } from "../config.js";
|
|
4
|
+
import { buildAgentInvocation, buildAssessmentPrompt } from "../runner/agentInvocation.js";
|
|
5
|
+
import { runSimpleProcessSync } from "../runner/simpleProcess.js";
|
|
6
|
+
import { captureRepoSnapshot, compareSnapshots, type RepoSnapshot, type ChangedFile } from "../runner/changeCapture.js";
|
|
7
|
+
import type { AgentAssessmentOutput, AgentAssessmentSummary } from "./assessmentStore.js";
|
|
8
|
+
|
|
9
|
+
const ASSESSMENT_MARKER = "===ASSESSMENT_JSON===";
|
|
10
|
+
|
|
11
|
+
export interface AgentAssessorInput {
|
|
12
|
+
assessmentId: string;
|
|
13
|
+
assessmentDir: string;
|
|
14
|
+
agentName: string;
|
|
15
|
+
repoPath: string;
|
|
16
|
+
workspaceRoot: string;
|
|
17
|
+
goal: string;
|
|
18
|
+
planContent: string;
|
|
19
|
+
timeoutSeconds: number;
|
|
20
|
+
maxOutputBytes: number;
|
|
21
|
+
config: PatchWardenConfig;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function runAgentAssessment(input: AgentAssessorInput): AgentAssessmentSummary {
|
|
25
|
+
const logPaths: AgentAssessmentSummary["log_paths"] = {
|
|
26
|
+
stdout: resolve(input.assessmentDir, "agent-assessment-stdout.log"),
|
|
27
|
+
stderr: resolve(input.assessmentDir, "agent-assessment-stderr.log"),
|
|
28
|
+
assessment: resolve(input.assessmentDir, "agent-assessment.json"),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const emptySummary: AgentAssessmentSummary = {
|
|
32
|
+
attempted: false,
|
|
33
|
+
status: "not_run",
|
|
34
|
+
output: null,
|
|
35
|
+
merged_risk: "low",
|
|
36
|
+
merged_decision: "allow",
|
|
37
|
+
merged_reason_codes: [],
|
|
38
|
+
timed_out: false,
|
|
39
|
+
exit_code: null,
|
|
40
|
+
read_only_violation: false,
|
|
41
|
+
violation_files: [],
|
|
42
|
+
stdout_truncated: false,
|
|
43
|
+
stderr_truncated: false,
|
|
44
|
+
log_paths: logPaths,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ── 1. Build prompt and write to file for {prompt_file} support ──
|
|
48
|
+
const assessmentPrompt = buildAssessmentPrompt(input.goal, input.planContent, input.repoPath);
|
|
49
|
+
const promptFilePath = resolve(input.assessmentDir, "agent-assessment-prompt.md");
|
|
50
|
+
writeFileSync(promptFilePath, assessmentPrompt, "utf-8");
|
|
51
|
+
logPaths.prompt = promptFilePath;
|
|
52
|
+
|
|
53
|
+
// ── 2. Before snapshot (repo-scoped) ──
|
|
54
|
+
let repoBefore: RepoSnapshot;
|
|
55
|
+
try {
|
|
56
|
+
repoBefore = captureRepoSnapshot(input.repoPath);
|
|
57
|
+
} catch {
|
|
58
|
+
// If we can't capture before snapshot, conservatively skip agent assessment
|
|
59
|
+
emptySummary.status = "spawn_failed";
|
|
60
|
+
emptySummary.merged_risk = "medium";
|
|
61
|
+
emptySummary.merged_decision = "needs_confirm";
|
|
62
|
+
emptySummary.merged_reason_codes = ["agent_assessment_snapshot_failed"];
|
|
63
|
+
writeSummary(logPaths.assessment, emptySummary);
|
|
64
|
+
return emptySummary;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── 3. Build agent invocation ──
|
|
68
|
+
let invocation;
|
|
69
|
+
try {
|
|
70
|
+
invocation = buildAgentInvocation(input.agentName, input.repoPath, assessmentPrompt, input.config, promptFilePath);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
emptySummary.status = "spawn_failed";
|
|
73
|
+
emptySummary.merged_risk = "medium";
|
|
74
|
+
emptySummary.merged_decision = "needs_confirm";
|
|
75
|
+
emptySummary.merged_reason_codes = ["agent_assessment_invocation_failed"];
|
|
76
|
+
writeSummary(logPaths.assessment, emptySummary);
|
|
77
|
+
return emptySummary;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
emptySummary.attempted = true;
|
|
81
|
+
|
|
82
|
+
// ── 4. Run agent ──
|
|
83
|
+
const result = runSimpleProcessSync({
|
|
84
|
+
command: invocation.command,
|
|
85
|
+
args: invocation.args,
|
|
86
|
+
cwd: input.repoPath,
|
|
87
|
+
timeoutMs: input.timeoutSeconds * 1000,
|
|
88
|
+
maxStdoutBytes: input.maxOutputBytes,
|
|
89
|
+
maxStderrBytes: Math.max(16384, Math.floor(input.maxOutputBytes / 4)),
|
|
90
|
+
stdoutPath: logPaths.stdout,
|
|
91
|
+
stderrPath: logPaths.stderr,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ── 5. After snapshot + read-only violation check ──
|
|
95
|
+
let readOnlyViolation = false;
|
|
96
|
+
let violationFiles: string[] = [];
|
|
97
|
+
try {
|
|
98
|
+
const repoAfter = captureRepoSnapshot(input.repoPath);
|
|
99
|
+
const changes = compareSnapshots(repoBefore, repoAfter);
|
|
100
|
+
if (changes.length > 0) {
|
|
101
|
+
readOnlyViolation = true;
|
|
102
|
+
violationFiles = changes.map((c) => c.path);
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// 修复 #4: after snapshot 捕获失败 → 保守变为 high / blocked
|
|
106
|
+
readOnlyViolation = true;
|
|
107
|
+
violationFiles = ["(after snapshot capture failed)"];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (readOnlyViolation) {
|
|
111
|
+
const summary: AgentAssessmentSummary = {
|
|
112
|
+
attempted: true,
|
|
113
|
+
status: "read_only_violation",
|
|
114
|
+
output: null,
|
|
115
|
+
merged_risk: "high",
|
|
116
|
+
merged_decision: "blocked",
|
|
117
|
+
merged_reason_codes: ["agent_read_only_violation"],
|
|
118
|
+
timed_out: false,
|
|
119
|
+
exit_code: result.exitCode,
|
|
120
|
+
read_only_violation: true,
|
|
121
|
+
violation_files: violationFiles,
|
|
122
|
+
stdout_truncated: result.stdoutTruncated,
|
|
123
|
+
stderr_truncated: result.stderrTruncated,
|
|
124
|
+
log_paths: { ...logPaths, violation: resolve(input.assessmentDir, "agent-assessment-violation.json") },
|
|
125
|
+
};
|
|
126
|
+
writeFileSync(summary.log_paths.violation!, JSON.stringify({
|
|
127
|
+
violation: "read_only_violation",
|
|
128
|
+
files: violationFiles,
|
|
129
|
+
exit_code: result.exitCode,
|
|
130
|
+
}, null, 2), "utf-8");
|
|
131
|
+
writeSummary(logPaths.assessment, summary);
|
|
132
|
+
return summary;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── 6. Handle spawn failure ──
|
|
136
|
+
if (result.spawnError) {
|
|
137
|
+
const summary: AgentAssessmentSummary = {
|
|
138
|
+
...emptySummary,
|
|
139
|
+
attempted: true,
|
|
140
|
+
status: "spawn_failed",
|
|
141
|
+
merged_risk: "medium",
|
|
142
|
+
merged_decision: "needs_confirm",
|
|
143
|
+
merged_reason_codes: ["agent_assessment_spawn_failed"],
|
|
144
|
+
exit_code: result.exitCode,
|
|
145
|
+
stdout_truncated: result.stdoutTruncated,
|
|
146
|
+
stderr_truncated: result.stderrTruncated,
|
|
147
|
+
};
|
|
148
|
+
writeSummary(logPaths.assessment, summary);
|
|
149
|
+
return summary;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── 7. Handle timeout ──
|
|
153
|
+
if (result.timedOut) {
|
|
154
|
+
const summary: AgentAssessmentSummary = {
|
|
155
|
+
...emptySummary,
|
|
156
|
+
attempted: true,
|
|
157
|
+
status: "timed_out",
|
|
158
|
+
merged_risk: "medium",
|
|
159
|
+
merged_decision: "needs_confirm",
|
|
160
|
+
merged_reason_codes: ["agent_assessment_timed_out"],
|
|
161
|
+
timed_out: true,
|
|
162
|
+
exit_code: result.exitCode,
|
|
163
|
+
stdout_truncated: result.stdoutTruncated,
|
|
164
|
+
stderr_truncated: result.stderrTruncated,
|
|
165
|
+
};
|
|
166
|
+
writeSummary(logPaths.assessment, summary);
|
|
167
|
+
return summary;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── 8. Handle non-zero exit ──
|
|
171
|
+
if (result.exitCode !== 0) {
|
|
172
|
+
const summary: AgentAssessmentSummary = {
|
|
173
|
+
...emptySummary,
|
|
174
|
+
attempted: true,
|
|
175
|
+
status: "non_zero_exit",
|
|
176
|
+
merged_risk: "medium",
|
|
177
|
+
merged_decision: "needs_confirm",
|
|
178
|
+
merged_reason_codes: ["agent_assessment_non_zero_exit"],
|
|
179
|
+
exit_code: result.exitCode,
|
|
180
|
+
stdout_truncated: result.stdoutTruncated,
|
|
181
|
+
stderr_truncated: result.stderrTruncated,
|
|
182
|
+
};
|
|
183
|
+
writeSummary(logPaths.assessment, summary);
|
|
184
|
+
return summary;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── 9. Parse JSON from stdout ──
|
|
188
|
+
const parsed = parseAssessmentJson(result.stdout, input.repoPath);
|
|
189
|
+
|
|
190
|
+
if (!parsed.output) {
|
|
191
|
+
const summary: AgentAssessmentSummary = {
|
|
192
|
+
...emptySummary,
|
|
193
|
+
attempted: true,
|
|
194
|
+
status: "parse_failed",
|
|
195
|
+
merged_risk: "medium",
|
|
196
|
+
merged_decision: "needs_confirm",
|
|
197
|
+
merged_reason_codes: ["agent_assessment_parse_failed", ...parsed.sanitized_reasons],
|
|
198
|
+
exit_code: result.exitCode,
|
|
199
|
+
stdout_truncated: result.stdoutTruncated,
|
|
200
|
+
stderr_truncated: result.stderrTruncated,
|
|
201
|
+
};
|
|
202
|
+
writeSummary(logPaths.assessment, summary);
|
|
203
|
+
return summary;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── 10. Merge risk (agent can only raise, not lower) ──
|
|
207
|
+
const agentRisk = parsed.output.risk_level;
|
|
208
|
+
let mergedRisk: "low" | "medium" | "high" = agentRisk === "high" ? "high" : agentRisk === "medium" ? "medium" : "low";
|
|
209
|
+
let mergedDecision: "allow" | "needs_confirm" | "blocked" =
|
|
210
|
+
mergedRisk === "high" ? "blocked" : mergedRisk === "medium" ? "needs_confirm" : "allow";
|
|
211
|
+
const mergedReasonCodes = ["agent_assessment_completed", ...parsed.sanitized_reasons];
|
|
212
|
+
// 修复 #5: agent requires_user_confirm=true 时至少升级为 medium / needs_confirm
|
|
213
|
+
if (parsed.output.requires_user_confirm && mergedRisk === "low") {
|
|
214
|
+
mergedRisk = "medium";
|
|
215
|
+
mergedDecision = "needs_confirm";
|
|
216
|
+
mergedReasonCodes.push("agent_requested_confirm");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const summary: AgentAssessmentSummary = {
|
|
220
|
+
attempted: true,
|
|
221
|
+
status: "completed",
|
|
222
|
+
output: { ...parsed.output, assessed_at: new Date().toISOString() },
|
|
223
|
+
merged_risk: mergedRisk,
|
|
224
|
+
merged_decision: mergedDecision,
|
|
225
|
+
merged_reason_codes: mergedReasonCodes,
|
|
226
|
+
timed_out: false,
|
|
227
|
+
exit_code: result.exitCode,
|
|
228
|
+
read_only_violation: false,
|
|
229
|
+
violation_files: [],
|
|
230
|
+
stdout_truncated: result.stdoutTruncated,
|
|
231
|
+
stderr_truncated: result.stderrTruncated,
|
|
232
|
+
log_paths: logPaths,
|
|
233
|
+
};
|
|
234
|
+
writeSummary(logPaths.assessment, summary);
|
|
235
|
+
return summary;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
interface ParsedAssessment {
|
|
239
|
+
output: AgentAssessmentOutput | null;
|
|
240
|
+
sanitized_reasons: string[];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function parseAssessmentJson(stdout: string, repoPath: string): ParsedAssessment {
|
|
244
|
+
const sanitized_reasons: string[] = [];
|
|
245
|
+
|
|
246
|
+
// Use the LAST marker only
|
|
247
|
+
const lastMarkerIndex = stdout.lastIndexOf(ASSESSMENT_MARKER);
|
|
248
|
+
if (lastMarkerIndex < 0) return { output: null, sanitized_reasons };
|
|
249
|
+
|
|
250
|
+
const jsonText = stdout.slice(lastMarkerIndex + ASSESSMENT_MARKER.length).trim();
|
|
251
|
+
// Find the JSON object (from first { to last })
|
|
252
|
+
const jsonStart = jsonText.indexOf("{");
|
|
253
|
+
const jsonEnd = jsonText.lastIndexOf("}");
|
|
254
|
+
if (jsonStart < 0 || jsonEnd < 0 || jsonEnd <= jsonStart) return { output: null, sanitized_reasons };
|
|
255
|
+
|
|
256
|
+
let parsed: any;
|
|
257
|
+
try {
|
|
258
|
+
parsed = JSON.parse(jsonText.slice(jsonStart, jsonEnd + 1));
|
|
259
|
+
} catch {
|
|
260
|
+
return { output: null, sanitized_reasons };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Strict validation ──
|
|
264
|
+
if (parsed.risk_level !== "low" && parsed.risk_level !== "medium" && parsed.risk_level !== "high") {
|
|
265
|
+
return { output: null, sanitized_reasons };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const confidence = Number(parsed.confidence);
|
|
269
|
+
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {
|
|
270
|
+
return { output: null, sanitized_reasons };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!Array.isArray(parsed.reason_codes)) return { output: null, sanitized_reasons };
|
|
274
|
+
if (parsed.reason_codes.length > 50) return { output: null, sanitized_reasons };
|
|
275
|
+
const reasonCodes = parsed.reason_codes.filter((r: any) => typeof r === "string" && r.length <= 100);
|
|
276
|
+
if (reasonCodes.length !== parsed.reason_codes.length) {
|
|
277
|
+
sanitized_reasons.push("reason_codes_filtered");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!Array.isArray(parsed.affected_paths)) return { output: null, sanitized_reasons };
|
|
281
|
+
if (parsed.affected_paths.length > 100) return { output: null, sanitized_reasons };
|
|
282
|
+
const affectedPaths: string[] = [];
|
|
283
|
+
let pathsSanitized = false;
|
|
284
|
+
for (const p of parsed.affected_paths) {
|
|
285
|
+
if (typeof p !== "string") { pathsSanitized = true; continue; }
|
|
286
|
+
// Must be relative, not absolute, and within repo
|
|
287
|
+
if (isAbsolute(p)) { pathsSanitized = true; continue; }
|
|
288
|
+
const resolved = resolve(repoPath, p);
|
|
289
|
+
const rel = relative(repoPath, resolved);
|
|
290
|
+
if (rel.startsWith("..")) { pathsSanitized = true; continue; }
|
|
291
|
+
affectedPaths.push(p);
|
|
292
|
+
}
|
|
293
|
+
if (pathsSanitized) {
|
|
294
|
+
sanitized_reasons.push("paths_sanitized");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!Array.isArray(parsed.destructive_actions)) return { output: null, sanitized_reasons };
|
|
298
|
+
if (parsed.destructive_actions.length > 20) return { output: null, sanitized_reasons };
|
|
299
|
+
const destructiveActions = parsed.destructive_actions.filter(
|
|
300
|
+
(d: any) => typeof d === "string" && d.length <= 200
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
if (typeof parsed.requires_user_confirm !== "boolean") return { output: null, sanitized_reasons };
|
|
304
|
+
|
|
305
|
+
const notes = typeof parsed.notes === "string" ? parsed.notes.slice(0, 2000) : "";
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
output: {
|
|
309
|
+
risk_level: parsed.risk_level,
|
|
310
|
+
reason_codes: reasonCodes,
|
|
311
|
+
affected_paths: affectedPaths,
|
|
312
|
+
destructive_actions: destructiveActions,
|
|
313
|
+
requires_user_confirm: parsed.requires_user_confirm,
|
|
314
|
+
confidence,
|
|
315
|
+
notes,
|
|
316
|
+
assessed_at: new Date().toISOString(),
|
|
317
|
+
},
|
|
318
|
+
sanitized_reasons,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function writeSummary(path: string, summary: AgentAssessmentSummary): void {
|
|
323
|
+
writeFileSync(path, JSON.stringify(summary, null, 2), "utf-8");
|
|
324
|
+
}
|