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.
Files changed (189) hide show
  1. package/PatchWarden.cmd +51 -0
  2. package/README.en.md +1022 -0
  3. package/README.md +897 -358
  4. package/dist/assessments/agentAssessor.d.ts +15 -0
  5. package/dist/assessments/agentAssessor.js +293 -0
  6. package/dist/assessments/assessmentStore.d.ts +133 -0
  7. package/dist/assessments/assessmentStore.js +238 -0
  8. package/dist/assessments/confirmCli.d.ts +9 -0
  9. package/dist/assessments/confirmCli.js +26 -0
  10. package/dist/config.d.ts +22 -1
  11. package/dist/config.js +156 -3
  12. package/dist/direct/directAudit.d.ts +23 -0
  13. package/dist/direct/directAudit.js +309 -0
  14. package/dist/direct/directGuards.d.ts +20 -0
  15. package/dist/direct/directGuards.js +137 -0
  16. package/dist/direct/directPatch.d.ts +17 -0
  17. package/dist/direct/directPatch.js +113 -0
  18. package/dist/direct/directSessionStore.d.ts +63 -0
  19. package/dist/direct/directSessionStore.js +192 -0
  20. package/dist/direct/directVerification.d.ts +12 -0
  21. package/dist/direct/directVerification.js +96 -0
  22. package/dist/doctor.js +73 -5
  23. package/dist/logging.d.ts +52 -0
  24. package/dist/logging.js +123 -0
  25. package/dist/runner/agentInvocation.d.ts +23 -0
  26. package/dist/runner/agentInvocation.js +106 -0
  27. package/dist/runner/changeCapture.d.ts +70 -0
  28. package/dist/runner/changeCapture.js +278 -6
  29. package/dist/runner/runTask.js +271 -65
  30. package/dist/runner/simpleProcess.d.ts +21 -0
  31. package/dist/runner/simpleProcess.js +206 -0
  32. package/dist/runner/watch.js +17 -2
  33. package/dist/security/commandGuard.d.ts +2 -1
  34. package/dist/security/commandGuard.js +25 -3
  35. package/dist/security/riskEngine.d.ts +27 -0
  36. package/dist/security/riskEngine.js +118 -0
  37. package/dist/security/runtimeGuard.d.ts +6 -0
  38. package/dist/security/runtimeGuard.js +28 -0
  39. package/dist/smoke-test.js +1353 -7
  40. package/dist/test/unit/android-doctor.test.d.ts +1 -0
  41. package/dist/test/unit/android-doctor.test.js +118 -0
  42. package/dist/test/unit/chinese-path.test.d.ts +1 -0
  43. package/dist/test/unit/chinese-path.test.js +91 -0
  44. package/dist/test/unit/command-guard.test.d.ts +1 -0
  45. package/dist/test/unit/command-guard.test.js +160 -0
  46. package/dist/test/unit/direct-guards.test.d.ts +1 -0
  47. package/dist/test/unit/direct-guards.test.js +213 -0
  48. package/dist/test/unit/logging.test.d.ts +1 -0
  49. package/dist/test/unit/logging.test.js +275 -0
  50. package/dist/test/unit/path-guard.test.d.ts +1 -0
  51. package/dist/test/unit/path-guard.test.js +109 -0
  52. package/dist/test/unit/safe-status.test.d.ts +1 -0
  53. package/dist/test/unit/safe-status.test.js +165 -0
  54. package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
  55. package/dist/test/unit/sensitive-guard.test.js +104 -0
  56. package/dist/test/unit/sync-file.test.d.ts +1 -0
  57. package/dist/test/unit/sync-file.test.js +154 -0
  58. package/dist/test/unit/watcher-status.test.d.ts +1 -0
  59. package/dist/test/unit/watcher-status.test.js +169 -0
  60. package/dist/tools/androidDoctor.d.ts +38 -0
  61. package/dist/tools/androidDoctor.js +391 -0
  62. package/dist/tools/applyPatch.d.ts +16 -0
  63. package/dist/tools/applyPatch.js +41 -0
  64. package/dist/tools/auditSession.d.ts +5 -0
  65. package/dist/tools/auditSession.js +12 -0
  66. package/dist/tools/auditTask.d.ts +7 -0
  67. package/dist/tools/auditTask.js +105 -7
  68. package/dist/tools/createDirectSession.d.ts +14 -0
  69. package/dist/tools/createDirectSession.js +54 -0
  70. package/dist/tools/createTask.d.ts +48 -1
  71. package/dist/tools/createTask.js +298 -47
  72. package/dist/tools/finalizeDirectSession.d.ts +19 -0
  73. package/dist/tools/finalizeDirectSession.js +84 -0
  74. package/dist/tools/getTaskSummary.d.ts +50 -0
  75. package/dist/tools/getTaskSummary.js +79 -2
  76. package/dist/tools/healthCheck.d.ts +27 -12
  77. package/dist/tools/healthCheck.js +44 -6
  78. package/dist/tools/readWorkspaceFile.d.ts +7 -1
  79. package/dist/tools/readWorkspaceFile.js +48 -2
  80. package/dist/tools/registry.js +270 -19
  81. package/dist/tools/runVerification.d.ts +16 -0
  82. package/dist/tools/runVerification.js +32 -0
  83. package/dist/tools/safeStatus.d.ts +19 -0
  84. package/dist/tools/safeStatus.js +72 -0
  85. package/dist/tools/savePlan.d.ts +1 -0
  86. package/dist/tools/savePlan.js +38 -7
  87. package/dist/tools/searchWorkspace.d.ts +19 -0
  88. package/dist/tools/searchWorkspace.js +205 -0
  89. package/dist/tools/syncFile.d.ts +18 -0
  90. package/dist/tools/syncFile.js +65 -0
  91. package/dist/tools/taskOutputs.d.ts +2 -2
  92. package/dist/tools/taskTemplates.js +2 -1
  93. package/dist/tools/toolCatalog.d.ts +4 -3
  94. package/dist/tools/toolCatalog.js +34 -5
  95. package/dist/tools/waitForTask.d.ts +2 -2
  96. package/dist/tools/waitForTask.js +1 -1
  97. package/dist/version.d.ts +2 -2
  98. package/dist/version.js +2 -2
  99. package/dist/watcherStatus.d.ts +1 -0
  100. package/dist/watcherStatus.js +96 -4
  101. package/docs/chatgpt-usage.md +101 -0
  102. package/docs/performance-notes.md +55 -0
  103. package/docs/release-checklist.md +14 -0
  104. package/docs/release-v0.6.0.md +71 -0
  105. package/docs/release-v0.6.1.md +75 -0
  106. package/examples/config.example.json +5 -0
  107. package/examples/openai-tunnel/README.md +11 -4
  108. package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
  109. package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
  110. package/package.json +13 -8
  111. package/scripts/brand-check.js +58 -12
  112. package/scripts/control-smoke.js +206 -0
  113. package/scripts/http-mcp-smoke.js +10 -2
  114. package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
  115. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
  116. package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
  117. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
  118. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
  119. package/scripts/lifecycle-smoke.js +402 -8
  120. package/scripts/manage-patchwarden.ps1 +639 -0
  121. package/scripts/mcp-manifest-check.js +123 -58
  122. package/scripts/mcp-smoke.js +160 -2
  123. package/scripts/pack-clean.js +160 -5
  124. package/scripts/package-manifest-check.js +78 -0
  125. package/scripts/patchwarden-mcp-direct.cmd +7 -0
  126. package/scripts/patchwarden-mcp-stdio.cmd +1 -1
  127. package/scripts/restart-patchwarden.ps1 +5 -4
  128. package/scripts/start-patchwarden-tunnel.ps1 +261 -30
  129. package/scripts/tunnel-supervisor-smoke.js +36 -4
  130. package/scripts/unit-tests.js +36 -0
  131. package/scripts/watcher-supervisor-smoke.js +10 -6
  132. package/src/assessments/agentAssessor.ts +324 -0
  133. package/src/assessments/assessmentStore.ts +426 -0
  134. package/src/assessments/confirmCli.ts +29 -0
  135. package/src/config.ts +178 -4
  136. package/src/direct/directAudit.ts +400 -0
  137. package/src/direct/directGuards.ts +279 -0
  138. package/src/direct/directPatch.ts +258 -0
  139. package/src/direct/directSessionStore.ts +345 -0
  140. package/src/direct/directVerification.ts +138 -0
  141. package/src/doctor.ts +103 -7
  142. package/src/logging.ts +152 -0
  143. package/src/runner/agentInvocation.ts +125 -0
  144. package/src/runner/changeCapture.ts +352 -6
  145. package/src/runner/runTask.ts +279 -63
  146. package/src/runner/simpleProcess.ts +223 -0
  147. package/src/runner/watch.ts +18 -2
  148. package/src/security/commandGuard.ts +46 -4
  149. package/src/security/riskEngine.ts +160 -0
  150. package/src/security/runtimeGuard.ts +41 -0
  151. package/src/smoke-test.ts +1291 -4
  152. package/src/test/unit/android-doctor.test.ts +158 -0
  153. package/src/test/unit/chinese-path.test.ts +106 -0
  154. package/src/test/unit/command-guard.test.ts +221 -0
  155. package/src/test/unit/direct-guards.test.ts +297 -0
  156. package/src/test/unit/logging.test.ts +325 -0
  157. package/src/test/unit/path-guard.test.ts +150 -0
  158. package/src/test/unit/safe-status.test.ts +187 -0
  159. package/src/test/unit/sensitive-guard.test.ts +124 -0
  160. package/src/test/unit/sync-file.test.ts +231 -0
  161. package/src/test/unit/watcher-status.test.ts +190 -0
  162. package/src/tools/androidDoctor.ts +424 -0
  163. package/src/tools/applyPatch.ts +86 -0
  164. package/src/tools/auditSession.ts +28 -0
  165. package/src/tools/auditTask.ts +111 -7
  166. package/src/tools/createDirectSession.ts +113 -0
  167. package/src/tools/createTask.ts +405 -55
  168. package/src/tools/finalizeDirectSession.ts +144 -0
  169. package/src/tools/getTaskSummary.ts +133 -2
  170. package/src/tools/healthCheck.ts +45 -6
  171. package/src/tools/readWorkspaceFile.ts +85 -2
  172. package/src/tools/registry.ts +305 -19
  173. package/src/tools/runVerification.ts +58 -0
  174. package/src/tools/safeStatus.ts +96 -0
  175. package/src/tools/savePlan.ts +57 -7
  176. package/src/tools/searchWorkspace.ts +275 -0
  177. package/src/tools/syncFile.ts +122 -0
  178. package/src/tools/taskTemplates.ts +2 -1
  179. package/src/tools/toolCatalog.ts +37 -6
  180. package/src/tools/waitForTask.ts +3 -3
  181. package/src/version.ts +2 -2
  182. package/src/watcherStatus.ts +101 -4
  183. package/tsconfig.json +18 -17
  184. package/Check-PatchWarden-Health.cmd +0 -6
  185. package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
  186. package/Restart-PatchWarden.cmd +0 -19
  187. package/Start-PatchWarden-Tunnel.cmd +0 -7
  188. package/docs/release-v0.3.0.md +0 -43
  189. 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
- if(attempt===1)process.exit(7);
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
- console.log("ok - tunnel supervisor retries, probes readiness, and writes redacted status");
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.4.0",
99
- schema_epoch: "2026-06-20-v2",
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==='doctor'){console.log('{}');process.exit(0)}
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(require('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)}
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
+ }