patchwarden 0.6.1 → 1.1.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.
Files changed (261) hide show
  1. package/PatchWarden-Control-Tray.cmd +11 -0
  2. package/PatchWarden-Control.cmd +6 -0
  3. package/PatchWarden-Desktop.cmd +5 -0
  4. package/PatchWarden.cmd +1 -1
  5. package/README.en.md +112 -24
  6. package/README.md +36 -25
  7. package/Restart-PatchWarden-Control.cmd +6 -0
  8. package/Stop-PatchWarden.cmd +11 -0
  9. package/dist/agents/agentRouter.d.ts +40 -0
  10. package/dist/agents/agentRouter.js +95 -0
  11. package/dist/config.d.ts +1 -1
  12. package/dist/config.js +6 -2
  13. package/dist/controlCenter.d.ts +14 -0
  14. package/dist/controlCenter.js +2015 -0
  15. package/dist/doctor.js +35 -4
  16. package/dist/goal/acceptanceEngine.d.ts +62 -0
  17. package/dist/goal/acceptanceEngine.js +103 -0
  18. package/dist/goal/acceptanceTemplate.d.ts +10 -0
  19. package/dist/goal/acceptanceTemplate.js +104 -0
  20. package/dist/goal/goalGraph.d.ts +58 -0
  21. package/dist/goal/goalGraph.js +189 -0
  22. package/dist/goal/goalProgress.d.ts +81 -0
  23. package/dist/goal/goalProgress.js +216 -0
  24. package/dist/goal/goalStatus.d.ts +60 -0
  25. package/dist/goal/goalStatus.js +137 -0
  26. package/dist/goal/goalStore.d.ts +79 -0
  27. package/dist/goal/goalStore.js +211 -0
  28. package/dist/goal/handoffExport.d.ts +34 -0
  29. package/dist/goal/handoffExport.js +183 -0
  30. package/dist/goal/subgoalSync.d.ts +40 -0
  31. package/dist/goal/subgoalSync.js +72 -0
  32. package/dist/goal/worktreeManager.d.ts +96 -0
  33. package/dist/goal/worktreeManager.js +292 -0
  34. package/dist/logging.d.ts +44 -0
  35. package/dist/logging.js +68 -0
  36. package/dist/release/releaseGate.d.ts +99 -0
  37. package/dist/release/releaseGate.js +475 -0
  38. package/dist/runner/postTaskCleanup.d.ts +13 -0
  39. package/dist/runner/postTaskCleanup.js +147 -0
  40. package/dist/runner/runTask.js +50 -10
  41. package/dist/security/discoveryTokenStore.d.ts +62 -0
  42. package/dist/security/discoveryTokenStore.js +100 -0
  43. package/dist/security/toolInvocationGuard.d.ts +35 -0
  44. package/dist/security/toolInvocationGuard.js +153 -0
  45. package/dist/smoke-test.js +18 -10
  46. package/dist/taskRuntime.d.ts +17 -0
  47. package/dist/test/unit/acceptance-engine.test.d.ts +1 -0
  48. package/dist/test/unit/acceptance-engine.test.js +228 -0
  49. package/dist/test/unit/agent-router.test.d.ts +1 -0
  50. package/dist/test/unit/agent-router.test.js +287 -0
  51. package/dist/test/unit/audit-checks.test.d.ts +1 -0
  52. package/dist/test/unit/audit-checks.test.js +350 -0
  53. package/dist/test/unit/diagnose-task.test.d.ts +1 -0
  54. package/dist/test/unit/diagnose-task.test.js +457 -0
  55. package/dist/test/unit/discovery-token-store.test.d.ts +1 -0
  56. package/dist/test/unit/discovery-token-store.test.js +139 -0
  57. package/dist/test/unit/goal-graph.test.d.ts +1 -0
  58. package/dist/test/unit/goal-graph.test.js +298 -0
  59. package/dist/test/unit/goal-progress.test.d.ts +1 -0
  60. package/dist/test/unit/goal-progress.test.js +381 -0
  61. package/dist/test/unit/goal-status.test.d.ts +1 -0
  62. package/dist/test/unit/goal-status.test.js +215 -0
  63. package/dist/test/unit/goal-store.test.d.ts +1 -0
  64. package/dist/test/unit/goal-store.test.js +253 -0
  65. package/dist/test/unit/goal-subgoal-task.test.d.ts +1 -0
  66. package/dist/test/unit/goal-subgoal-task.test.js +55 -0
  67. package/dist/test/unit/goal-tools-registry.test.d.ts +1 -0
  68. package/dist/test/unit/goal-tools-registry.test.js +190 -0
  69. package/dist/test/unit/handoff-export.test.d.ts +1 -0
  70. package/dist/test/unit/handoff-export.test.js +263 -0
  71. package/dist/test/unit/invoke-discovered-tool.test.d.ts +1 -0
  72. package/dist/test/unit/invoke-discovered-tool.test.js +167 -0
  73. package/dist/test/unit/logging.test.js +127 -5
  74. package/dist/test/unit/post-task-cleanup.test.d.ts +1 -0
  75. package/dist/test/unit/post-task-cleanup.test.js +48 -0
  76. package/dist/test/unit/reconcile-tasks.test.d.ts +1 -0
  77. package/dist/test/unit/reconcile-tasks.test.js +456 -0
  78. package/dist/test/unit/release-gate.test.d.ts +1 -0
  79. package/dist/test/unit/release-gate.test.js +242 -0
  80. package/dist/test/unit/safe-views.test.d.ts +1 -0
  81. package/dist/test/unit/safe-views.test.js +171 -0
  82. package/dist/test/unit/schema-drift-check.test.d.ts +1 -0
  83. package/dist/test/unit/schema-drift-check.test.js +175 -0
  84. package/dist/test/unit/subgoal-sync.test.d.ts +1 -0
  85. package/dist/test/unit/subgoal-sync.test.js +183 -0
  86. package/dist/test/unit/tool-invocation-guard.test.d.ts +1 -0
  87. package/dist/test/unit/tool-invocation-guard.test.js +432 -0
  88. package/dist/test/unit/tool-usage-stats.test.d.ts +1 -0
  89. package/dist/test/unit/tool-usage-stats.test.js +300 -0
  90. package/dist/test/unit/toolSearch.test.d.ts +1 -0
  91. package/dist/test/unit/toolSearch.test.js +571 -0
  92. package/dist/test/unit/worktree-manager.test.d.ts +1 -0
  93. package/dist/test/unit/worktree-manager.test.js +176 -0
  94. package/dist/tools/auditTask.d.ts +103 -1
  95. package/dist/tools/auditTask.js +461 -8
  96. package/dist/tools/cancelTask.js +1 -1
  97. package/dist/tools/checkReleaseGate.d.ts +21 -0
  98. package/dist/tools/checkReleaseGate.js +22 -0
  99. package/dist/tools/createTask.d.ts +18 -2
  100. package/dist/tools/createTask.js +29 -1
  101. package/dist/tools/diagnoseTask.d.ts +45 -0
  102. package/dist/tools/diagnoseTask.js +347 -0
  103. package/dist/tools/discardWorktree.d.ts +24 -0
  104. package/dist/tools/discardWorktree.js +27 -0
  105. package/dist/tools/discoverTools.d.ts +11 -0
  106. package/dist/tools/discoverTools.js +39 -0
  107. package/dist/tools/explainTool.d.ts +11 -0
  108. package/dist/tools/explainTool.js +21 -0
  109. package/dist/tools/getTaskSummary.js +2 -1
  110. package/dist/tools/goalSubgoalTask.d.ts +51 -0
  111. package/dist/tools/goalSubgoalTask.js +110 -0
  112. package/dist/tools/healthCheck.d.ts +1 -0
  113. package/dist/tools/healthCheck.js +5 -1
  114. package/dist/tools/invokeDiscoveredTool.d.ts +37 -0
  115. package/dist/tools/invokeDiscoveredTool.js +112 -0
  116. package/dist/tools/listTasks.d.ts +3 -1
  117. package/dist/tools/listTasks.js +14 -1
  118. package/dist/tools/mergeWorktree.d.ts +24 -0
  119. package/dist/tools/mergeWorktree.js +27 -0
  120. package/dist/tools/reconcileTasks.d.ts +41 -0
  121. package/dist/tools/reconcileTasks.js +352 -0
  122. package/dist/tools/registry.js +550 -0
  123. package/dist/tools/safeStatus.d.ts +31 -1
  124. package/dist/tools/safeStatus.js +43 -1
  125. package/dist/tools/safeViews.d.ts +256 -0
  126. package/dist/tools/safeViews.js +250 -0
  127. package/dist/tools/schemaDriftCheck.d.ts +46 -0
  128. package/dist/tools/schemaDriftCheck.js +80 -0
  129. package/dist/tools/toolCatalog.d.ts +4 -3
  130. package/dist/tools/toolCatalog.js +33 -11
  131. package/dist/tools/toolRegistry.d.ts +61 -0
  132. package/dist/tools/toolRegistry.js +724 -0
  133. package/dist/tools/toolSearch.d.ts +110 -0
  134. package/dist/tools/toolSearch.js +331 -0
  135. package/dist/tools/toolUsageStats.d.ts +41 -0
  136. package/dist/tools/toolUsageStats.js +116 -0
  137. package/dist/tools/waitForTask.js +1 -0
  138. package/dist/version.d.ts +2 -2
  139. package/dist/version.js +2 -2
  140. package/dist/watcherStatus.d.ts +29 -0
  141. package/dist/watcherStatus.js +92 -1
  142. package/docs/control-center/README.md +33 -0
  143. package/docs/control-center/control-center-daily-driver.md +211 -0
  144. package/docs/control-center/control-center-mvp.md +205 -0
  145. package/docs/control-center/control-center-phase2.md +159 -0
  146. package/docs/demo.md +3 -0
  147. package/docs/release-v0.6.4.md +45 -0
  148. package/examples/openai-tunnel/README.md +5 -5
  149. package/examples/openai-tunnel/tunnel-client.example.yaml +3 -3
  150. package/package.json +25 -16
  151. package/scripts/README.md +47 -0
  152. package/scripts/{brand-check.js → checks/brand-check.js} +2 -2
  153. package/scripts/checks/control-center-smoke.js +1098 -0
  154. package/scripts/{control-smoke.js → checks/control-smoke.js} +19 -5
  155. package/scripts/{doctor-smoke.js → checks/doctor-smoke.js} +2 -1
  156. package/scripts/{http-mcp-smoke.js → checks/http-mcp-smoke.js} +2 -2
  157. package/scripts/{lifecycle-smoke.js → checks/lifecycle-smoke.js} +25 -21
  158. package/scripts/{mcp-manifest-check.js → checks/mcp-manifest-check.js} +54 -7
  159. package/scripts/{mcp-smoke.js → checks/mcp-smoke.js} +40 -11
  160. package/scripts/{package-manifest-check.js → checks/package-manifest-check.js} +2 -1
  161. package/scripts/{tunnel-supervisor-smoke.js → checks/tunnel-supervisor-smoke.js} +2 -2
  162. package/scripts/{unit-tests.js → checks/unit-tests.js} +1 -1
  163. package/scripts/{watcher-supervisor-smoke.js → checks/watcher-supervisor-smoke.js} +13 -7
  164. package/scripts/control/control-center-tray.ps1 +281 -0
  165. package/scripts/{get-patchwarden-health.ps1 → control/get-patchwarden-health.ps1} +3 -3
  166. package/scripts/{manage-patchwarden.ps1 → control/manage-patchwarden.ps1} +30 -4
  167. package/scripts/control/restart-control-center.ps1 +173 -0
  168. package/scripts/{restart-patchwarden.ps1 → control/restart-patchwarden.ps1} +1 -1
  169. package/scripts/control/start-control-center.ps1 +263 -0
  170. package/scripts/{start-patchwarden-tunnel.ps1 → control/start-patchwarden-tunnel.ps1} +48 -6
  171. package/scripts/control/stop-patchwarden.ps1 +114 -0
  172. package/scripts/launchers/Check-PatchWarden-Health.cmd +1 -1
  173. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +1 -1
  174. package/scripts/launchers/Restart-PatchWarden.cmd +1 -1
  175. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +1 -1
  176. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +1 -1
  177. package/scripts/{patchwarden-mcp-direct.cmd → mcp/patchwarden-mcp-direct.cmd} +1 -1
  178. package/scripts/{patchwarden-mcp-stdio.cmd → mcp/patchwarden-mcp-stdio.cmd} +1 -1
  179. package/scripts/{pack-clean.js → release/pack-clean.js} +9 -1
  180. package/src/agents/agentRouter.ts +149 -0
  181. package/src/config.ts +9 -3
  182. package/src/controlCenter.ts +2166 -0
  183. package/src/doctor.ts +40 -5
  184. package/src/goal/acceptanceEngine.ts +160 -0
  185. package/src/goal/acceptanceTemplate.ts +121 -0
  186. package/src/goal/goalGraph.ts +225 -0
  187. package/src/goal/goalProgress.ts +301 -0
  188. package/src/goal/goalStatus.ts +234 -0
  189. package/src/goal/goalStore.ts +306 -0
  190. package/src/goal/handoffExport.ts +211 -0
  191. package/src/goal/subgoalSync.ts +82 -0
  192. package/src/goal/worktreeManager.ts +404 -0
  193. package/src/logging.ts +91 -0
  194. package/src/release/releaseGate.ts +567 -0
  195. package/src/runner/postTaskCleanup.ts +154 -0
  196. package/src/runner/runTask.ts +49 -10
  197. package/src/security/discoveryTokenStore.ts +157 -0
  198. package/src/security/toolInvocationGuard.ts +251 -0
  199. package/src/smoke-test.ts +15 -7
  200. package/src/taskRuntime.ts +17 -0
  201. package/src/test/unit/acceptance-engine.test.ts +261 -0
  202. package/src/test/unit/agent-router.test.ts +342 -0
  203. package/src/test/unit/audit-checks.test.ts +567 -0
  204. package/src/test/unit/diagnose-task.test.ts +544 -0
  205. package/src/test/unit/discovery-token-store.test.ts +181 -0
  206. package/src/test/unit/goal-graph.test.ts +347 -0
  207. package/src/test/unit/goal-progress.test.ts +538 -0
  208. package/src/test/unit/goal-status.test.ts +270 -0
  209. package/src/test/unit/goal-store.test.ts +318 -0
  210. package/src/test/unit/goal-subgoal-task.test.ts +72 -0
  211. package/src/test/unit/goal-tools-registry.test.ts +243 -0
  212. package/src/test/unit/handoff-export.test.ts +295 -0
  213. package/src/test/unit/invoke-discovered-tool.test.ts +216 -0
  214. package/src/test/unit/logging.test.ts +177 -5
  215. package/src/test/unit/post-task-cleanup.test.ts +53 -0
  216. package/src/test/unit/reconcile-tasks.test.ts +551 -0
  217. package/src/test/unit/release-gate.test.ts +314 -0
  218. package/src/test/unit/safe-views.test.ts +184 -0
  219. package/src/test/unit/schema-drift-check.test.ts +258 -0
  220. package/src/test/unit/subgoal-sync.test.ts +236 -0
  221. package/src/test/unit/tool-invocation-guard.test.ts +542 -0
  222. package/src/test/unit/tool-usage-stats.test.ts +384 -0
  223. package/src/test/unit/toolSearch.test.ts +756 -0
  224. package/src/test/unit/worktree-manager.test.ts +247 -0
  225. package/src/tools/auditTask.ts +831 -402
  226. package/src/tools/cancelTask.ts +1 -1
  227. package/src/tools/checkReleaseGate.ts +45 -0
  228. package/src/tools/createTask.ts +64 -6
  229. package/src/tools/diagnoseTask.ts +460 -0
  230. package/src/tools/discardWorktree.ts +42 -0
  231. package/src/tools/discoverTools.ts +51 -0
  232. package/src/tools/explainTool.ts +26 -0
  233. package/src/tools/getTaskSummary.ts +2 -1
  234. package/src/tools/goalSubgoalTask.ts +170 -0
  235. package/src/tools/healthCheck.ts +4 -1
  236. package/src/tools/invokeDiscoveredTool.ts +163 -0
  237. package/src/tools/listTasks.ts +16 -2
  238. package/src/tools/mergeWorktree.ts +42 -0
  239. package/src/tools/reconcileTasks.ts +464 -0
  240. package/src/tools/registry.ts +618 -1
  241. package/src/tools/safeStatus.ts +73 -2
  242. package/src/tools/safeViews.ts +271 -0
  243. package/src/tools/schemaDriftCheck.ts +120 -0
  244. package/src/tools/toolCatalog.ts +34 -11
  245. package/src/tools/toolRegistry.ts +786 -0
  246. package/src/tools/toolSearch.ts +464 -0
  247. package/src/tools/toolUsageStats.ts +151 -0
  248. package/src/tools/waitForTask.ts +1 -0
  249. package/src/version.ts +2 -2
  250. package/src/watcherStatus.ts +97 -1
  251. package/ui/colors_and_type.css +141 -0
  252. package/ui/pages/audit.html +743 -0
  253. package/ui/pages/dashboard.html +1154 -0
  254. package/ui/pages/direct-sessions.html +652 -0
  255. package/ui/pages/logs.html +502 -0
  256. package/ui/pages/task-detail.html +1229 -0
  257. package/ui/pages/tasks.html +702 -0
  258. package/ui/pages/workspace.html +947 -0
  259. package/ui/partials/project-shell.html +362 -0
  260. package/ui/vendor/lucide.js +12 -0
  261. package/ui/vendor/tailwindcss-browser.js +947 -0
@@ -0,0 +1,216 @@
1
+ import { describe, it, beforeEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import {
4
+ invokeDiscoveredTool,
5
+ type InvokeDiscoveredToolContext,
6
+ type InvokeDiscoveredToolInput,
7
+ } from "../../tools/invokeDiscoveredTool.js";
8
+ import { issueToken, clearAllTokens } from "../../security/discoveryTokenStore.js";
9
+ import { computeSchemaDigest, type ToolRisk } from "../../tools/toolRegistry.js";
10
+ import type { ToolDef } from "../../tools/registry.js";
11
+
12
+ // ── Helpers ───────────────────────────────────────────────────────
13
+
14
+ function makeToolDef(name: string): ToolDef {
15
+ return {
16
+ name,
17
+ description: `Mock ${name} tool`,
18
+ inputSchema: { type: "object", properties: {} },
19
+ };
20
+ }
21
+
22
+ const MOCK_TOOLS: ToolDef[] = [
23
+ "safe_status",
24
+ "get_diff",
25
+ "save_plan",
26
+ "invoke_discovered_tool",
27
+ "__test_credential_tool",
28
+ ].map(makeToolDef);
29
+
30
+ function makeContext(overrides: Partial<InvokeDiscoveredToolContext> = {}): InvokeDiscoveredToolContext {
31
+ return {
32
+ tools: MOCK_TOOLS,
33
+ profile: "full",
34
+ dispatch: async () => ({ ok: true }),
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ function issueTokenFor(toolName: string, risk: ToolRisk): string {
40
+ return issueToken({
41
+ toolName,
42
+ risk,
43
+ query: "test query",
44
+ schemaDigest: computeSchemaDigest({}),
45
+ profile: "full",
46
+ });
47
+ }
48
+
49
+ function makeInput(overrides: Partial<InvokeDiscoveredToolInput> = {}): InvokeDiscoveredToolInput {
50
+ return {
51
+ toolName: "safe_status",
52
+ arguments: {},
53
+ discoveryToken: "",
54
+ ...overrides,
55
+ };
56
+ }
57
+
58
+ // ── Tests ─────────────────────────────────────────────────────────
59
+
60
+ describe("invokeDiscoveredTool", () => {
61
+ beforeEach(() => {
62
+ clearAllTokens();
63
+ });
64
+
65
+ describe("合法调用", () => {
66
+ it("readonly 工具 + 有效 token + dispatch 成功 → ok=true", async () => {
67
+ const token = issueTokenFor("safe_status", "readonly");
68
+ const result = await invokeDiscoveredTool(
69
+ makeInput({
70
+ toolName: "safe_status",
71
+ arguments: { task_id: "t-1" },
72
+ discoveryToken: token,
73
+ }),
74
+ makeContext({
75
+ dispatch: async () => ({ status: "pending" }),
76
+ }),
77
+ );
78
+ assert.equal(result.ok, true);
79
+ assert.ok(result.result, "result should be present");
80
+ assert.deepEqual(result.result, { status: "pending" });
81
+ assert.equal(result.invocation_log_written, true);
82
+ assert.equal(result.error, undefined);
83
+ });
84
+ });
85
+
86
+ describe("token 校验", () => {
87
+ it("token 不存在 → ok=false, reason=token_not_found", async () => {
88
+ const result = await invokeDiscoveredTool(
89
+ makeInput({
90
+ toolName: "safe_status",
91
+ arguments: {},
92
+ discoveryToken: "tok",
93
+ }),
94
+ makeContext(),
95
+ );
96
+ assert.equal(result.ok, false);
97
+ assert.equal(result.error?.reason, "token_not_found");
98
+ assert.equal(result.invocation_log_written, true);
99
+ });
100
+
101
+ it("token toolName 不匹配 → ok=false, reason=token_tool_mismatch", async () => {
102
+ const token = issueTokenFor("safe_status", "readonly");
103
+ const result = await invokeDiscoveredTool(
104
+ makeInput({
105
+ toolName: "get_diff",
106
+ arguments: { task_id: "t-1" },
107
+ discoveryToken: token,
108
+ }),
109
+ makeContext(),
110
+ );
111
+ assert.equal(result.ok, false);
112
+ assert.equal(result.error?.reason, "token_tool_mismatch");
113
+ });
114
+ });
115
+
116
+ describe("toolMeta 查找", () => {
117
+ it("工具未注册 → ok=false, reason=tool_not_registered", async () => {
118
+ const token = issueTokenFor("safe_status", "readonly");
119
+ const result = await invokeDiscoveredTool(
120
+ makeInput({
121
+ toolName: "safe_status",
122
+ arguments: {},
123
+ discoveryToken: token,
124
+ }),
125
+ makeContext({
126
+ tools: [makeToolDef("get_diff")],
127
+ }),
128
+ );
129
+ assert.equal(result.ok, false);
130
+ assert.equal(result.error?.reason, "tool_not_registered");
131
+ });
132
+ });
133
+
134
+ describe("防递归", () => {
135
+ it("toolName=invoke_discovered_tool → ok=false, reason=recursive_invocation_blocked", async () => {
136
+ const token = issueTokenFor("invoke_discovered_tool", "command");
137
+ const result = await invokeDiscoveredTool(
138
+ makeInput({
139
+ toolName: "invoke_discovered_tool",
140
+ arguments: {},
141
+ discoveryToken: token,
142
+ }),
143
+ makeContext(),
144
+ );
145
+ assert.equal(result.ok, false);
146
+ assert.equal(result.error?.reason, "recursive_invocation_blocked");
147
+ });
148
+ });
149
+
150
+ describe("守卫校验", () => {
151
+ it("credential_sensitive 工具 → ok=false, reason=credential_sensitive_blocked", async () => {
152
+ const token = issueTokenFor("__test_credential_tool", "credential_sensitive");
153
+ const result = await invokeDiscoveredTool(
154
+ makeInput({
155
+ toolName: "__test_credential_tool",
156
+ arguments: {},
157
+ discoveryToken: token,
158
+ }),
159
+ makeContext(),
160
+ );
161
+ assert.equal(result.ok, false);
162
+ assert.equal(result.error?.reason, "credential_sensitive_blocked");
163
+ });
164
+
165
+ it("workspace_write 缺 assessmentId → ok=false, reason=assessment_required", async () => {
166
+ const token = issueTokenFor("save_plan", "workspace_write");
167
+ const result = await invokeDiscoveredTool(
168
+ makeInput({
169
+ toolName: "save_plan",
170
+ arguments: {},
171
+ discoveryToken: token,
172
+ }),
173
+ makeContext(),
174
+ );
175
+ assert.equal(result.ok, false);
176
+ assert.equal(result.error?.reason, "assessment_required");
177
+ });
178
+ });
179
+
180
+ describe("dispatch", () => {
181
+ it("dispatch 抛错 → ok=false, reason=dispatch_error", async () => {
182
+ const token = issueTokenFor("safe_status", "readonly");
183
+ const result = await invokeDiscoveredTool(
184
+ makeInput({
185
+ toolName: "safe_status",
186
+ arguments: {},
187
+ discoveryToken: token,
188
+ }),
189
+ makeContext({
190
+ dispatch: async () => {
191
+ throw new Error("boom");
192
+ },
193
+ }),
194
+ );
195
+ assert.equal(result.ok, false);
196
+ assert.equal(result.error?.reason, "dispatch_error");
197
+ assert.ok(result.error?.message.includes("boom"), `expected message to contain "boom", got "${result.error?.message}"`);
198
+ });
199
+
200
+ it("成功调用 → invocation_log_written=true", async () => {
201
+ const token = issueTokenFor("safe_status", "readonly");
202
+ const result = await invokeDiscoveredTool(
203
+ makeInput({
204
+ toolName: "safe_status",
205
+ arguments: { task_id: "t-log" },
206
+ discoveryToken: token,
207
+ }),
208
+ makeContext({
209
+ dispatch: async () => ({ status: "done" }),
210
+ }),
211
+ );
212
+ assert.equal(result.ok, true);
213
+ assert.equal(result.invocation_log_written, true);
214
+ });
215
+ });
216
+ });
@@ -1,6 +1,16 @@
1
1
  import { describe, it, beforeEach, afterEach } from "node:test";
2
2
  import { strict as assert } from "node:assert";
3
- import { Logger, logUnhandledError, installGlobalHandlers } from "../../logging.js";
3
+ import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import {
7
+ Logger,
8
+ logUnhandledError,
9
+ installGlobalHandlers,
10
+ computeArgumentsDigest,
11
+ logToolInvocation,
12
+ type InvocationLogEntry,
13
+ } from "../../logging.js";
4
14
 
5
15
  // ── Test helpers ──────────────────────────────────────────────────
6
16
 
@@ -166,9 +176,10 @@ describe("Logger — verbose mode (PATCHWARDEN_VERBOSE_LOG)", () => {
166
176
  delete process.env.PATCHWARDEN_VERBOSE_LOG;
167
177
 
168
178
  const log = new Logger();
179
+ const fakeToken = "sk_" + "FAKE_TOKEN_VALUE_123456";
169
180
  const sensitiveArgs = {
170
181
  prompt: "fix the bug",
171
- token: "ghp_" + "a".repeat(20),
182
+ token: fakeToken,
172
183
  };
173
184
 
174
185
  const entries = captureJson(() => {
@@ -182,16 +193,17 @@ describe("Logger — verbose mode (PATCHWARDEN_VERBOSE_LOG)", () => {
182
193
  assert.equal(entry.args, undefined);
183
194
  // The raw token must not appear anywhere in the serialized entry
184
195
  const raw = JSON.stringify(entry);
185
- assert.ok(!raw.includes("ghp_"), "raw token must not leak into log output");
196
+ assert.ok(!raw.includes(fakeToken), "raw token must not leak into log output");
186
197
  });
187
198
 
188
199
  it("logs sanitized args when PATCHWARDEN_VERBOSE_LOG=true", () => {
189
200
  process.env.PATCHWARDEN_VERBOSE_LOG = "true";
190
201
 
191
202
  const log = new Logger();
203
+ const fakeToken = "sk_" + "FAKE_TOKEN_VALUE_123456";
192
204
  const sensitiveArgs = {
193
205
  prompt: "fix the bug",
194
- token: "ghp_" + "a".repeat(20),
206
+ token: fakeToken,
195
207
  };
196
208
 
197
209
  const entries = captureJson(() => {
@@ -205,7 +217,7 @@ describe("Logger — verbose mode (PATCHWARDEN_VERBOSE_LOG)", () => {
205
217
  assert.ok(entry.args !== undefined, "args should be logged in verbose mode");
206
218
  const argsStr = String(entry.args);
207
219
  assert.ok(argsStr.includes("[REDACTED TOKEN]"), "sensitive token must be redacted");
208
- assert.ok(!argsStr.includes("ghp_"), "raw token must not appear in args");
220
+ assert.ok(!argsStr.includes(fakeToken), "raw token must not appear in args");
209
221
  // non-sensitive content is preserved
210
222
  assert.ok(argsStr.includes("fix the bug"), "non-sensitive args content is preserved");
211
223
  });
@@ -323,3 +335,163 @@ describe("installGlobalHandlers", () => {
323
335
  }
324
336
  });
325
337
  });
338
+
339
+ // ── computeArgumentsDigest ───────────────────────────────────────
340
+
341
+ describe("computeArgumentsDigest", () => {
342
+ it("returns the same digest for identical arguments (stability)", () => {
343
+ const args = { b: 2, a: 1, nested: { y: "yes", x: [1, 2] } };
344
+ const d1 = computeArgumentsDigest(args);
345
+ const d2 = computeArgumentsDigest(args);
346
+ assert.equal(d1, d2);
347
+ });
348
+
349
+ it("returns the same digest regardless of key order (stable serialization)", () => {
350
+ const d1 = computeArgumentsDigest({ a: 1, b: 2 });
351
+ const d2 = computeArgumentsDigest({ b: 2, a: 1 });
352
+ assert.equal(d1, d2);
353
+ });
354
+
355
+ it("returns different digests for different arguments", () => {
356
+ const d1 = computeArgumentsDigest({ a: 1 });
357
+ const d2 = computeArgumentsDigest({ a: 2 });
358
+ assert.notEqual(d1, d2);
359
+ });
360
+
361
+ it("returns a 'sha256:' prefixed digest", () => {
362
+ const digest = computeArgumentsDigest({ foo: "bar" });
363
+ assert.ok(
364
+ digest.startsWith("sha256:"),
365
+ `digest should start with "sha256:", got: ${digest}`,
366
+ );
367
+ // full format: sha256: + 64 lowercase hex chars
368
+ assert.match(digest, /^sha256:[a-f0-9]{64}$/);
369
+ });
370
+
371
+ it("does not leak raw argument values into the digest string", () => {
372
+ const fakeToken = "tok-value";
373
+ const sensitiveArgs = { token: fakeToken, prompt: "hello-world" };
374
+ const digest = computeArgumentsDigest(sensitiveArgs);
375
+ assert.ok(
376
+ !digest.includes(fakeToken),
377
+ "digest must not leak raw token value",
378
+ );
379
+ assert.ok(
380
+ !digest.includes("hello-world"),
381
+ "digest must not leak raw prompt value",
382
+ );
383
+ });
384
+ });
385
+
386
+ // ── logToolInvocation ────────────────────────────────────────────
387
+
388
+ describe("logToolInvocation", () => {
389
+ let tempDir: string;
390
+
391
+ beforeEach(() => {
392
+ tempDir = mkdtempSync(join(tmpdir(), "pw-invlog-"));
393
+ });
394
+
395
+ afterEach(() => {
396
+ rmSync(tempDir, { recursive: true, force: true });
397
+ });
398
+
399
+ function sampleEntry(overrides: Partial<InvocationLogEntry> = {}): InvocationLogEntry {
400
+ return {
401
+ timestamp: new Date().toISOString(),
402
+ toolName: "read_workspace_file",
403
+ discoveryToken: "tok",
404
+ risk: "workspace_read_sensitive",
405
+ profile: "full",
406
+ arguments_digest: "sha256:" + "0".repeat(64),
407
+ result: "ok",
408
+ duration_ms: 42,
409
+ ...overrides,
410
+ };
411
+ }
412
+
413
+ it("writes an entry to invocation.log inside the provided logsDir", () => {
414
+ const logsDir = join(tempDir, ".patchwarden", "logs");
415
+ const entry = sampleEntry();
416
+ logToolInvocation(entry, { logsDir });
417
+
418
+ const logPath = join(logsDir, "invocation.log");
419
+ assert.ok(existsSync(logPath), "invocation.log should be created");
420
+
421
+ const content = readFileSync(logPath, "utf-8");
422
+ const lines = content.trim().split("\n");
423
+ assert.equal(lines.length, 1, "exactly one JSON line should be written");
424
+
425
+ const parsed = JSON.parse(lines[0]);
426
+ assert.equal(parsed.toolName, "read_workspace_file");
427
+ assert.equal(parsed.discoveryToken, "tok");
428
+ assert.equal(parsed.risk, "workspace_read_sensitive");
429
+ assert.equal(parsed.profile, "full");
430
+ assert.equal(parsed.result, "ok");
431
+ assert.equal(parsed.duration_ms, 42);
432
+ assert.match(parsed.arguments_digest, /^sha256:[a-f0-9]{64}$/);
433
+ assert.ok(!Number.isNaN(Date.parse(parsed.timestamp)));
434
+ });
435
+
436
+ it("appends multiple entries as JSON Lines", () => {
437
+ const logsDir = join(tempDir, "logs");
438
+ logToolInvocation(sampleEntry({ duration_ms: 1 }), { logsDir });
439
+ logToolInvocation(
440
+ sampleEntry({ duration_ms: 2, result: "error", error_code: "blocked" }),
441
+ { logsDir },
442
+ );
443
+
444
+ const content = readFileSync(join(logsDir, "invocation.log"), "utf-8");
445
+ const lines = content.trim().split("\n");
446
+ assert.equal(lines.length, 2);
447
+
448
+ const first = JSON.parse(lines[0]);
449
+ const second = JSON.parse(lines[1]);
450
+ assert.equal(first.duration_ms, 1);
451
+ assert.equal(first.result, "ok");
452
+ assert.equal(second.duration_ms, 2);
453
+ assert.equal(second.result, "error");
454
+ assert.equal(second.error_code, "blocked");
455
+ });
456
+
457
+ it("includes allowedScope and error_code when provided", () => {
458
+ const logsDir = join(tempDir, "logs");
459
+ logToolInvocation(
460
+ sampleEntry({
461
+ allowedScope: ["src/", "docs/"],
462
+ result: "error",
463
+ error_code: "scope_violation",
464
+ }),
465
+ { logsDir },
466
+ );
467
+
468
+ const parsed = JSON.parse(
469
+ readFileSync(join(logsDir, "invocation.log"), "utf-8").trim(),
470
+ );
471
+ assert.deepEqual(parsed.allowedScope, ["src/", "docs/"]);
472
+ assert.equal(parsed.error_code, "scope_violation");
473
+ });
474
+
475
+ it("omits error_code and allowedScope when not provided", () => {
476
+ const logsDir = join(tempDir, "logs");
477
+ logToolInvocation(sampleEntry(), { logsDir });
478
+
479
+ const parsed = JSON.parse(
480
+ readFileSync(join(logsDir, "invocation.log"), "utf-8").trim(),
481
+ );
482
+ assert.equal(parsed.error_code, undefined);
483
+ assert.equal(parsed.allowedScope, undefined);
484
+ });
485
+
486
+ it("does not throw when the log directory cannot be created", () => {
487
+ // Create a file, then attempt to use a path beneath it as logsDir.
488
+ // mkdirSync will fail with ENOTDIR because the parent is a file.
489
+ const blockingFile = join(tempDir, "blocker");
490
+ writeFileSync(blockingFile, "x", "utf-8");
491
+ const impossibleLogsDir = join(blockingFile, "subdir");
492
+
493
+ assert.doesNotThrow(() => {
494
+ logToolInvocation(sampleEntry(), { logsDir: impossibleLogsDir });
495
+ });
496
+ });
497
+ });
@@ -0,0 +1,53 @@
1
+ import { describe, it } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { execFileSync } from "node:child_process";
4
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { runPostTaskCleanup } from "../../runner/postTaskCleanup.js";
8
+
9
+ describe("postTaskCleanup", () => {
10
+ it("removes untracked low-risk artifacts and skips tracked or excluded paths", () => {
11
+ const root = mkdtempSync(join(tmpdir(), "pw-cleanup-"));
12
+ try {
13
+ execFileSync("git", ["init"], { cwd: root, stdio: "ignore" });
14
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: root, stdio: "ignore" });
15
+ execFileSync("git", ["config", "user.name", "PatchWarden Test"], { cwd: root, stdio: "ignore" });
16
+
17
+ mkdirSync(join(root, "tracked", "__pycache__"), { recursive: true });
18
+ writeFileSync(join(root, "tracked", "__pycache__", "keep.pyc"), "tracked", "utf-8");
19
+ execFileSync("git", ["add", "."], { cwd: root, stdio: "ignore" });
20
+ execFileSync("git", ["commit", "-m", "init"], { cwd: root, stdio: "ignore" });
21
+
22
+ mkdirSync(join(root, "backend", "__pycache__"), { recursive: true });
23
+ mkdirSync(join(root, ".venv", "__pycache__"), { recursive: true });
24
+ mkdirSync(join(root, ".patchwarden", "tasks", "old-task", "__pycache__"), { recursive: true });
25
+ mkdirSync(join(root, "node_modules", "pkg", "__pycache__"), { recursive: true });
26
+ mkdirSync(join(root, "docs", "__pycache__"), { recursive: true });
27
+ writeFileSync(join(root, "backend", "__pycache__", "drop.pyc"), "drop", "utf-8");
28
+ writeFileSync(join(root, ".venv", "__pycache__", "skip.pyc"), "skip", "utf-8");
29
+ writeFileSync(join(root, ".patchwarden", "tasks", "old-task", "__pycache__", "skip.pyc"), "skip", "utf-8");
30
+ writeFileSync(join(root, "node_modules", "pkg", "__pycache__", "skip.pyc"), "skip", "utf-8");
31
+ writeFileSync(join(root, "docs", "__pycache__", "skip.pyc"), "skip", "utf-8");
32
+
33
+ const taskDir = join(root, ".patchwarden", "tasks", "task-1");
34
+ mkdirSync(taskDir, { recursive: true });
35
+ const report = runPostTaskCleanup(root, taskDir);
36
+
37
+ assert.ok(report.removed.some((entry) => entry.path === "backend/__pycache__"));
38
+ assert.ok(report.skipped.some((entry) => entry.path === "tracked/__pycache__" && entry.skip_reason === "tracked_by_git"));
39
+ assert.equal(report.source_files_touched, 0);
40
+ assert.ok(!existsSync(join(root, "backend", "__pycache__")));
41
+ assert.ok(existsSync(join(root, "tracked", "__pycache__", "keep.pyc")));
42
+ assert.ok(existsSync(join(root, ".venv", "__pycache__", "skip.pyc")));
43
+ assert.ok(existsSync(join(root, ".patchwarden", "tasks", "old-task", "__pycache__", "skip.pyc")));
44
+ assert.ok(existsSync(join(root, "node_modules", "pkg", "__pycache__", "skip.pyc")));
45
+ assert.ok(existsSync(join(root, "docs", "__pycache__", "skip.pyc")));
46
+
47
+ const written = JSON.parse(readFileSync(join(taskDir, "post-task-cleanup.json"), "utf-8"));
48
+ assert.equal(written.enabled, true);
49
+ } finally {
50
+ rmSync(root, { recursive: true, force: true });
51
+ }
52
+ });
53
+ });