patchwarden 0.6.4 → 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 (212) hide show
  1. package/README.en.md +18 -18
  2. package/README.md +20 -20
  3. package/dist/agents/agentRouter.d.ts +40 -0
  4. package/dist/agents/agentRouter.js +95 -0
  5. package/dist/config.d.ts +1 -1
  6. package/dist/config.js +6 -2
  7. package/dist/controlCenter.js +15 -2
  8. package/dist/doctor.js +32 -1
  9. package/dist/goal/acceptanceEngine.d.ts +62 -0
  10. package/dist/goal/acceptanceEngine.js +103 -0
  11. package/dist/goal/acceptanceTemplate.d.ts +10 -0
  12. package/dist/goal/acceptanceTemplate.js +104 -0
  13. package/dist/goal/goalGraph.d.ts +58 -0
  14. package/dist/goal/goalGraph.js +189 -0
  15. package/dist/goal/goalProgress.d.ts +81 -0
  16. package/dist/goal/goalProgress.js +216 -0
  17. package/dist/goal/goalStatus.d.ts +60 -0
  18. package/dist/goal/goalStatus.js +137 -0
  19. package/dist/goal/goalStore.d.ts +79 -0
  20. package/dist/goal/goalStore.js +211 -0
  21. package/dist/goal/handoffExport.d.ts +34 -0
  22. package/dist/goal/handoffExport.js +183 -0
  23. package/dist/goal/subgoalSync.d.ts +40 -0
  24. package/dist/goal/subgoalSync.js +72 -0
  25. package/dist/goal/worktreeManager.d.ts +96 -0
  26. package/dist/goal/worktreeManager.js +292 -0
  27. package/dist/logging.d.ts +44 -0
  28. package/dist/logging.js +68 -0
  29. package/dist/release/releaseGate.d.ts +99 -0
  30. package/dist/release/releaseGate.js +475 -0
  31. package/dist/runner/postTaskCleanup.d.ts +13 -0
  32. package/dist/runner/postTaskCleanup.js +147 -0
  33. package/dist/runner/runTask.js +50 -10
  34. package/dist/security/discoveryTokenStore.d.ts +62 -0
  35. package/dist/security/discoveryTokenStore.js +100 -0
  36. package/dist/security/toolInvocationGuard.d.ts +35 -0
  37. package/dist/security/toolInvocationGuard.js +153 -0
  38. package/dist/smoke-test.js +18 -10
  39. package/dist/taskRuntime.d.ts +17 -0
  40. package/dist/test/unit/acceptance-engine.test.d.ts +1 -0
  41. package/dist/test/unit/acceptance-engine.test.js +228 -0
  42. package/dist/test/unit/agent-router.test.d.ts +1 -0
  43. package/dist/test/unit/agent-router.test.js +287 -0
  44. package/dist/test/unit/audit-checks.test.d.ts +1 -0
  45. package/dist/test/unit/audit-checks.test.js +350 -0
  46. package/dist/test/unit/diagnose-task.test.d.ts +1 -0
  47. package/dist/test/unit/diagnose-task.test.js +457 -0
  48. package/dist/test/unit/discovery-token-store.test.d.ts +1 -0
  49. package/dist/test/unit/discovery-token-store.test.js +139 -0
  50. package/dist/test/unit/goal-graph.test.d.ts +1 -0
  51. package/dist/test/unit/goal-graph.test.js +298 -0
  52. package/dist/test/unit/goal-progress.test.d.ts +1 -0
  53. package/dist/test/unit/goal-progress.test.js +381 -0
  54. package/dist/test/unit/goal-status.test.d.ts +1 -0
  55. package/dist/test/unit/goal-status.test.js +215 -0
  56. package/dist/test/unit/goal-store.test.d.ts +1 -0
  57. package/dist/test/unit/goal-store.test.js +253 -0
  58. package/dist/test/unit/goal-subgoal-task.test.d.ts +1 -0
  59. package/dist/test/unit/goal-subgoal-task.test.js +55 -0
  60. package/dist/test/unit/goal-tools-registry.test.d.ts +1 -0
  61. package/dist/test/unit/goal-tools-registry.test.js +190 -0
  62. package/dist/test/unit/handoff-export.test.d.ts +1 -0
  63. package/dist/test/unit/handoff-export.test.js +263 -0
  64. package/dist/test/unit/invoke-discovered-tool.test.d.ts +1 -0
  65. package/dist/test/unit/invoke-discovered-tool.test.js +167 -0
  66. package/dist/test/unit/logging.test.js +127 -5
  67. package/dist/test/unit/post-task-cleanup.test.d.ts +1 -0
  68. package/dist/test/unit/post-task-cleanup.test.js +48 -0
  69. package/dist/test/unit/reconcile-tasks.test.d.ts +1 -0
  70. package/dist/test/unit/reconcile-tasks.test.js +456 -0
  71. package/dist/test/unit/release-gate.test.d.ts +1 -0
  72. package/dist/test/unit/release-gate.test.js +242 -0
  73. package/dist/test/unit/safe-views.test.d.ts +1 -0
  74. package/dist/test/unit/safe-views.test.js +171 -0
  75. package/dist/test/unit/schema-drift-check.test.d.ts +1 -0
  76. package/dist/test/unit/schema-drift-check.test.js +175 -0
  77. package/dist/test/unit/subgoal-sync.test.d.ts +1 -0
  78. package/dist/test/unit/subgoal-sync.test.js +183 -0
  79. package/dist/test/unit/tool-invocation-guard.test.d.ts +1 -0
  80. package/dist/test/unit/tool-invocation-guard.test.js +432 -0
  81. package/dist/test/unit/tool-usage-stats.test.d.ts +1 -0
  82. package/dist/test/unit/tool-usage-stats.test.js +300 -0
  83. package/dist/test/unit/toolSearch.test.d.ts +1 -0
  84. package/dist/test/unit/toolSearch.test.js +571 -0
  85. package/dist/test/unit/worktree-manager.test.d.ts +1 -0
  86. package/dist/test/unit/worktree-manager.test.js +176 -0
  87. package/dist/tools/auditTask.d.ts +103 -1
  88. package/dist/tools/auditTask.js +461 -8
  89. package/dist/tools/cancelTask.js +1 -1
  90. package/dist/tools/checkReleaseGate.d.ts +21 -0
  91. package/dist/tools/checkReleaseGate.js +22 -0
  92. package/dist/tools/createTask.d.ts +18 -2
  93. package/dist/tools/createTask.js +29 -1
  94. package/dist/tools/diagnoseTask.d.ts +45 -0
  95. package/dist/tools/diagnoseTask.js +347 -0
  96. package/dist/tools/discardWorktree.d.ts +24 -0
  97. package/dist/tools/discardWorktree.js +27 -0
  98. package/dist/tools/discoverTools.d.ts +11 -0
  99. package/dist/tools/discoverTools.js +39 -0
  100. package/dist/tools/explainTool.d.ts +11 -0
  101. package/dist/tools/explainTool.js +21 -0
  102. package/dist/tools/getTaskSummary.js +2 -1
  103. package/dist/tools/goalSubgoalTask.d.ts +51 -0
  104. package/dist/tools/goalSubgoalTask.js +110 -0
  105. package/dist/tools/healthCheck.d.ts +1 -0
  106. package/dist/tools/healthCheck.js +5 -1
  107. package/dist/tools/invokeDiscoveredTool.d.ts +37 -0
  108. package/dist/tools/invokeDiscoveredTool.js +112 -0
  109. package/dist/tools/listTasks.d.ts +3 -1
  110. package/dist/tools/listTasks.js +14 -1
  111. package/dist/tools/mergeWorktree.d.ts +24 -0
  112. package/dist/tools/mergeWorktree.js +27 -0
  113. package/dist/tools/reconcileTasks.d.ts +41 -0
  114. package/dist/tools/reconcileTasks.js +352 -0
  115. package/dist/tools/registry.js +550 -0
  116. package/dist/tools/safeStatus.d.ts +31 -1
  117. package/dist/tools/safeStatus.js +43 -1
  118. package/dist/tools/safeViews.d.ts +256 -0
  119. package/dist/tools/safeViews.js +250 -0
  120. package/dist/tools/schemaDriftCheck.d.ts +46 -0
  121. package/dist/tools/schemaDriftCheck.js +80 -0
  122. package/dist/tools/toolCatalog.d.ts +4 -3
  123. package/dist/tools/toolCatalog.js +33 -11
  124. package/dist/tools/toolRegistry.d.ts +61 -0
  125. package/dist/tools/toolRegistry.js +724 -0
  126. package/dist/tools/toolSearch.d.ts +110 -0
  127. package/dist/tools/toolSearch.js +331 -0
  128. package/dist/tools/toolUsageStats.d.ts +41 -0
  129. package/dist/tools/toolUsageStats.js +116 -0
  130. package/dist/tools/waitForTask.js +1 -0
  131. package/dist/version.d.ts +2 -2
  132. package/dist/version.js +2 -2
  133. package/dist/watcherStatus.d.ts +29 -0
  134. package/dist/watcherStatus.js +92 -1
  135. package/package.json +3 -2
  136. package/scripts/checks/control-smoke.js +2 -2
  137. package/scripts/checks/doctor-smoke.js +1 -0
  138. package/scripts/checks/lifecycle-smoke.js +13 -9
  139. package/scripts/checks/mcp-manifest-check.js +53 -6
  140. package/scripts/checks/mcp-smoke.js +36 -9
  141. package/scripts/checks/watcher-supervisor-smoke.js +2 -2
  142. package/src/agents/agentRouter.ts +149 -0
  143. package/src/config.ts +9 -3
  144. package/src/controlCenter.ts +17 -3
  145. package/src/doctor.ts +36 -1
  146. package/src/goal/acceptanceEngine.ts +160 -0
  147. package/src/goal/acceptanceTemplate.ts +121 -0
  148. package/src/goal/goalGraph.ts +225 -0
  149. package/src/goal/goalProgress.ts +301 -0
  150. package/src/goal/goalStatus.ts +234 -0
  151. package/src/goal/goalStore.ts +306 -0
  152. package/src/goal/handoffExport.ts +211 -0
  153. package/src/goal/subgoalSync.ts +82 -0
  154. package/src/goal/worktreeManager.ts +404 -0
  155. package/src/logging.ts +91 -0
  156. package/src/release/releaseGate.ts +567 -0
  157. package/src/runner/postTaskCleanup.ts +154 -0
  158. package/src/runner/runTask.ts +49 -10
  159. package/src/security/discoveryTokenStore.ts +157 -0
  160. package/src/security/toolInvocationGuard.ts +251 -0
  161. package/src/smoke-test.ts +15 -7
  162. package/src/taskRuntime.ts +17 -0
  163. package/src/test/unit/acceptance-engine.test.ts +261 -0
  164. package/src/test/unit/agent-router.test.ts +342 -0
  165. package/src/test/unit/audit-checks.test.ts +567 -0
  166. package/src/test/unit/diagnose-task.test.ts +544 -0
  167. package/src/test/unit/discovery-token-store.test.ts +181 -0
  168. package/src/test/unit/goal-graph.test.ts +347 -0
  169. package/src/test/unit/goal-progress.test.ts +538 -0
  170. package/src/test/unit/goal-status.test.ts +270 -0
  171. package/src/test/unit/goal-store.test.ts +318 -0
  172. package/src/test/unit/goal-subgoal-task.test.ts +72 -0
  173. package/src/test/unit/goal-tools-registry.test.ts +243 -0
  174. package/src/test/unit/handoff-export.test.ts +295 -0
  175. package/src/test/unit/invoke-discovered-tool.test.ts +216 -0
  176. package/src/test/unit/logging.test.ts +177 -5
  177. package/src/test/unit/post-task-cleanup.test.ts +53 -0
  178. package/src/test/unit/reconcile-tasks.test.ts +551 -0
  179. package/src/test/unit/release-gate.test.ts +314 -0
  180. package/src/test/unit/safe-views.test.ts +184 -0
  181. package/src/test/unit/schema-drift-check.test.ts +258 -0
  182. package/src/test/unit/subgoal-sync.test.ts +236 -0
  183. package/src/test/unit/tool-invocation-guard.test.ts +542 -0
  184. package/src/test/unit/tool-usage-stats.test.ts +384 -0
  185. package/src/test/unit/toolSearch.test.ts +756 -0
  186. package/src/test/unit/worktree-manager.test.ts +247 -0
  187. package/src/tools/auditTask.ts +831 -402
  188. package/src/tools/cancelTask.ts +1 -1
  189. package/src/tools/checkReleaseGate.ts +45 -0
  190. package/src/tools/createTask.ts +64 -6
  191. package/src/tools/diagnoseTask.ts +460 -0
  192. package/src/tools/discardWorktree.ts +42 -0
  193. package/src/tools/discoverTools.ts +51 -0
  194. package/src/tools/explainTool.ts +26 -0
  195. package/src/tools/getTaskSummary.ts +2 -1
  196. package/src/tools/goalSubgoalTask.ts +170 -0
  197. package/src/tools/healthCheck.ts +4 -1
  198. package/src/tools/invokeDiscoveredTool.ts +163 -0
  199. package/src/tools/listTasks.ts +16 -2
  200. package/src/tools/mergeWorktree.ts +42 -0
  201. package/src/tools/reconcileTasks.ts +464 -0
  202. package/src/tools/registry.ts +618 -1
  203. package/src/tools/safeStatus.ts +73 -2
  204. package/src/tools/safeViews.ts +271 -0
  205. package/src/tools/schemaDriftCheck.ts +120 -0
  206. package/src/tools/toolCatalog.ts +34 -11
  207. package/src/tools/toolRegistry.ts +786 -0
  208. package/src/tools/toolSearch.ts +464 -0
  209. package/src/tools/toolUsageStats.ts +151 -0
  210. package/src/tools/waitForTask.ts +1 -0
  211. package/src/version.ts +2 -2
  212. package/src/watcherStatus.ts +97 -1
@@ -0,0 +1,183 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { createGoal, readGoalStatus, writeGoalStatus } from "../../goal/goalStore.js";
7
+ import { addSubgoal, updateSubgoalStatus } from "../../goal/goalStatus.js";
8
+ import { syncSubgoalOnTaskDone, readTaskGoalMeta } from "../../goal/subgoalSync.js";
9
+ // ── Helpers ───────────────────────────────────────────────────────
10
+ let tempDir;
11
+ beforeEach(() => {
12
+ tempDir = mkdtempSync(join(tmpdir(), "pw-subgoalsync-"));
13
+ });
14
+ afterEach(() => {
15
+ rmSync(tempDir, { recursive: true, force: true });
16
+ });
17
+ /**
18
+ * 在 tempDir 下创建 goal,添加一个 subgoal 并置为 running,写回 goal_status.json。
19
+ * 返回 goal_id 和 subgoalId。
20
+ */
21
+ function setupGoalWithRunningSubgoal(title = "Test Goal") {
22
+ const { goal_id } = createGoal("repo", title, "desc", tempDir);
23
+ let status = readGoalStatus(goal_id, tempDir);
24
+ const { goalStatus: withSub, subgoalId } = addSubgoal(status, "First subgoal");
25
+ const withRunning = updateSubgoalStatus(withSub, subgoalId, "running");
26
+ writeGoalStatus(goal_id, withRunning, tempDir);
27
+ return { goalId: goal_id, subgoalId };
28
+ }
29
+ /** 创建一个假的 task 目录,写入 status.json(可包含 goal_id/subgoal_id)。 */
30
+ function writeTaskStatus(taskId, fields) {
31
+ const taskDir = join(tempDir, ".patchwarden", "tasks", taskId);
32
+ mkdirSync(taskDir, { recursive: true });
33
+ writeFileSync(join(taskDir, "status.json"), JSON.stringify({ task_id: taskId, ...fields }, null, 2), "utf-8");
34
+ return taskDir;
35
+ }
36
+ // ── Tests ─────────────────────────────────────────────────────────
37
+ describe("subgoalSync", () => {
38
+ describe("syncSubgoalOnTaskDone", () => {
39
+ it("无 goal_id/subgoal_id 直接返回(向后兼容)", () => {
40
+ const { goalId, subgoalId } = setupGoalWithRunningSubgoal();
41
+ // 不传 goal_id / subgoal_id
42
+ syncSubgoalOnTaskDone("task_001", { goal_id: null, subgoal_id: null }, tempDir);
43
+ // subgoal 状态不变
44
+ const status = readGoalStatus(goalId, tempDir);
45
+ assert.equal(status.subgoals[0].status, "running");
46
+ });
47
+ it("仅 goal_id 缺失直接返回", () => {
48
+ const { subgoalId } = setupGoalWithRunningSubgoal();
49
+ syncSubgoalOnTaskDone("task_001", { goal_id: null, subgoal_id: subgoalId }, tempDir);
50
+ // 不应抛错
51
+ });
52
+ it("仅 subgoal_id 缺失直接返回", () => {
53
+ const { goalId } = setupGoalWithRunningSubgoal();
54
+ syncSubgoalOnTaskDone("task_001", { goal_id: goalId, subgoal_id: null }, tempDir);
55
+ // 不应抛错
56
+ });
57
+ it("subgoal 状态为 running → 更新为 done_by_agent", () => {
58
+ const { goalId, subgoalId } = setupGoalWithRunningSubgoal();
59
+ syncSubgoalOnTaskDone("task_001", { goal_id: goalId, subgoal_id: subgoalId }, tempDir);
60
+ const status = readGoalStatus(goalId, tempDir);
61
+ assert.equal(status.subgoals[0].status, "done_by_agent");
62
+ });
63
+ it("subgoal 状态非 running(如 ready)→ 不更新", () => {
64
+ const { goal_id } = createGoal("repo", "Ready Goal", "desc", tempDir);
65
+ let status = readGoalStatus(goal_id, tempDir);
66
+ const { goalStatus: withSub, subgoalId } = addSubgoal(status, "Ready subgoal");
67
+ // 不做 running 转换,保持 ready
68
+ writeGoalStatus(goal_id, withSub, tempDir);
69
+ syncSubgoalOnTaskDone("task_001", { goal_id: goal_id, subgoal_id: subgoalId }, tempDir);
70
+ const after = readGoalStatus(goal_id, tempDir);
71
+ assert.equal(after.subgoals[0].status, "ready");
72
+ });
73
+ it("subgoal 状态为 done_by_agent → 不重复更新", () => {
74
+ const { goalId, subgoalId } = setupGoalWithRunningSubgoal();
75
+ // 第一次同步:running → done_by_agent
76
+ syncSubgoalOnTaskDone("task_001", { goal_id: goalId, subgoal_id: subgoalId }, tempDir);
77
+ // 第二次同步:done_by_agent 不应再变(running → done_by_agent 已完成,且 done_by_agent → done_by_agent 非法)
78
+ syncSubgoalOnTaskDone("task_001", { goal_id: goalId, subgoal_id: subgoalId }, tempDir);
79
+ const status = readGoalStatus(goalId, tempDir);
80
+ assert.equal(status.subgoals[0].status, "done_by_agent");
81
+ });
82
+ it("goal 不存在 → 不抛错(try/catch 隔离)", () => {
83
+ // 不抛错即通过
84
+ assert.doesNotThrow(() => {
85
+ syncSubgoalOnTaskDone("task_001", { goal_id: "goal_nonexistent", subgoal_id: "subgoal-001" }, tempDir);
86
+ });
87
+ });
88
+ it("subgoal 不存在 → 不抛错", () => {
89
+ const { goalId } = setupGoalWithRunningSubgoal();
90
+ assert.doesNotThrow(() => {
91
+ syncSubgoalOnTaskDone("task_001", { goal_id: goalId, subgoal_id: "subgoal-999" }, tempDir);
92
+ });
93
+ // 原 subgoal 不受影响
94
+ const status = readGoalStatus(goalId, tempDir);
95
+ assert.equal(status.subgoals[0].status, "running");
96
+ });
97
+ it("不传 workspaceRoot 时不抛错(使用默认 getConfig)", () => {
98
+ // 此测试验证向后兼容签名;默认 getConfig().workspaceRoot 指向 cwd,
99
+ // goal 不存在于那里,应被 try/catch 吞掉,不抛错。
100
+ const { goalId, subgoalId } = setupGoalWithRunningSubgoal();
101
+ assert.doesNotThrow(() => {
102
+ syncSubgoalOnTaskDone("task_001", { goal_id: goalId, subgoal_id: subgoalId });
103
+ });
104
+ });
105
+ });
106
+ describe("readTaskGoalMeta", () => {
107
+ it("正常读取 status.json 中的 goal_id/subgoal_id", () => {
108
+ const taskDir = writeTaskStatus("task_001", {
109
+ goal_id: "goal_20260101_test",
110
+ subgoal_id: "subgoal-001",
111
+ });
112
+ const meta = readTaskGoalMeta(taskDir);
113
+ assert.equal(meta.goal_id, "goal_20260101_test");
114
+ assert.equal(meta.subgoal_id, "subgoal-001");
115
+ });
116
+ it("status.json 不存在 → 返回 { null, null }", () => {
117
+ const taskDir = join(tempDir, ".patchwarden", "tasks", "nonexistent");
118
+ const meta = readTaskGoalMeta(taskDir);
119
+ assert.equal(meta.goal_id, null);
120
+ assert.equal(meta.subgoal_id, null);
121
+ });
122
+ it("status.json 无 goal_id/subgoal_id 字段 → 返回 { null, null }", () => {
123
+ const taskDir = writeTaskStatus("task_002", { status: "pending" });
124
+ const meta = readTaskGoalMeta(taskDir);
125
+ assert.equal(meta.goal_id, null);
126
+ assert.equal(meta.subgoal_id, null);
127
+ });
128
+ it("status.json 字段为非字符串 → 返回 { null, null }", () => {
129
+ const taskDir = writeTaskStatus("task_003", {
130
+ goal_id: 123,
131
+ subgoal_id: null,
132
+ });
133
+ const meta = readTaskGoalMeta(taskDir);
134
+ assert.equal(meta.goal_id, null);
135
+ assert.equal(meta.subgoal_id, null);
136
+ });
137
+ it("status.json 为损坏 JSON → 返回 { null, null }", () => {
138
+ const taskDir = join(tempDir, ".patchwarden", "tasks", "task_004");
139
+ mkdirSync(taskDir, { recursive: true });
140
+ writeFileSync(join(taskDir, "status.json"), "{ not valid json", "utf-8");
141
+ const meta = readTaskGoalMeta(taskDir);
142
+ assert.equal(meta.goal_id, null);
143
+ assert.equal(meta.subgoal_id, null);
144
+ });
145
+ it("仅 goal_id 字段存在 → subgoal_id 为 null", () => {
146
+ const taskDir = writeTaskStatus("task_005", { goal_id: "goal_x" });
147
+ const meta = readTaskGoalMeta(taskDir);
148
+ assert.equal(meta.goal_id, "goal_x");
149
+ assert.equal(meta.subgoal_id, null);
150
+ });
151
+ });
152
+ describe("end-to-end: task 完成 → subgoal 同步", () => {
153
+ it("模拟 reconcileTasks done_by_agent 流程:task status.json 写入后同步 subgoal", () => {
154
+ const { goalId, subgoalId } = setupGoalWithRunningSubgoal();
155
+ const taskId = "task_20260101_abc123";
156
+ // 模拟 createTask 写入 task status.json(含 goal_id/subgoal_id)
157
+ const taskDir = writeTaskStatus(taskId, {
158
+ status: "pending",
159
+ goal_id: goalId,
160
+ subgoal_id: subgoalId,
161
+ });
162
+ // 模拟 reconcileTasks 将状态置为 done_by_agent 后调用同步
163
+ const meta = readTaskGoalMeta(taskDir);
164
+ assert.equal(meta.subgoal_id, subgoalId);
165
+ syncSubgoalOnTaskDone(taskId, meta, tempDir);
166
+ // 验证 subgoal 状态已同步为 done_by_agent
167
+ const status = readGoalStatus(goalId, tempDir);
168
+ assert.equal(status.subgoals[0].status, "done_by_agent");
169
+ });
170
+ it("无关联的 task 完成 → subgoal 状态不变", () => {
171
+ const { goalId, subgoalId } = setupGoalWithRunningSubgoal();
172
+ const taskId = "task_20260101_nogoal";
173
+ // task 没有 goal_id/subgoal_id 关联
174
+ const taskDir = writeTaskStatus(taskId, { status: "pending" });
175
+ const meta = readTaskGoalMeta(taskDir);
176
+ assert.equal(meta.subgoal_id, null);
177
+ // 即便误调用同步,也不会影响 subgoal
178
+ syncSubgoalOnTaskDone(taskId, meta, tempDir);
179
+ const status = readGoalStatus(goalId, tempDir);
180
+ assert.equal(status.subgoals[0].status, "running");
181
+ });
182
+ });
183
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,432 @@
1
+ import { describe, it } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { checkInvocation } from "../../security/toolInvocationGuard.js";
4
+ import { PatchWardenError } from "../../errors.js";
5
+ // ── Helpers ───────────────────────────────────────────────────────
6
+ function makeToolMeta(overrides = {}) {
7
+ return {
8
+ name: "read_workspace_file",
9
+ title: "Read Workspace File",
10
+ summary: "读取工作区文件内容(自动脱敏敏感路径)",
11
+ description: "Read workspace file content with sensitive path redaction.",
12
+ profiles: ["full", "chatgpt_core", "chatgpt_direct"],
13
+ modes: ["delegate", "direct", "audit"],
14
+ tags: ["read", "file", "workspace"],
15
+ aliases: ["read_file"],
16
+ risk: "workspace_read_sensitive",
17
+ requiresConfirmation: false,
18
+ inputSchemaDigest: "sha256:abc123",
19
+ ...overrides,
20
+ };
21
+ }
22
+ function makeTokenRecord(overrides = {}) {
23
+ return {
24
+ token: "tok",
25
+ toolName: "read_workspace_file",
26
+ risk: "workspace_read_sensitive",
27
+ issuedAt: "2026-01-01T00:00:00.000Z",
28
+ expiresAt: "2026-01-01T00:10:00.000Z",
29
+ query: "read file",
30
+ schemaDigest: "sha256:abc123",
31
+ profile: "full",
32
+ ...overrides,
33
+ };
34
+ }
35
+ function makeInput(overrides = {}) {
36
+ return {
37
+ toolName: "read_workspace_file",
38
+ toolMeta: makeToolMeta(),
39
+ args: {},
40
+ discoveryTokenRecord: makeTokenRecord(),
41
+ profile: "full",
42
+ assessmentId: undefined,
43
+ ...overrides,
44
+ };
45
+ }
46
+ // 辅助:断言会抛出指定 reason 的 PatchWardenError
47
+ function assertPatchWardenError(fn, reason, messageContains) {
48
+ assert.throws(fn, (err) => {
49
+ assert.ok(err instanceof PatchWardenError, `expected PatchWardenError, got ${err?.constructor?.name}`);
50
+ assert.equal(err.reason, reason, `expected reason "${reason}", got "${err.reason}"`);
51
+ if (messageContains) {
52
+ assert.ok(err.message.includes(messageContains), `expected message to contain "${messageContains}", got "${err.message}"`);
53
+ }
54
+ assert.equal(err.blocked, true, "blocked should default to true");
55
+ return true;
56
+ });
57
+ }
58
+ // ── Tests ─────────────────────────────────────────────────────────
59
+ describe("toolInvocationGuard", () => {
60
+ describe("① token_tool_mismatch", () => {
61
+ it("toolName 与 record.toolName 不一致时抛 token_tool_mismatch", () => {
62
+ const input = makeInput({
63
+ toolName: "save_plan",
64
+ discoveryTokenRecord: makeTokenRecord({ toolName: "read_workspace_file" }),
65
+ });
66
+ assertPatchWardenError(() => checkInvocation(input), "token_tool_mismatch", "save_plan");
67
+ });
68
+ it("toolName 一致时不抛该错误(成功路径在后面统一验证)", () => {
69
+ const input = makeInput({
70
+ toolName: "read_workspace_file",
71
+ discoveryTokenRecord: makeTokenRecord({ toolName: "read_workspace_file" }),
72
+ });
73
+ // 不应抛 token_tool_mismatch;后续校验会通过
74
+ const result = checkInvocation(input);
75
+ assert.deepEqual(result, { allowed: true });
76
+ });
77
+ });
78
+ describe("② profile_not_allowed", () => {
79
+ it("toolMeta.profiles 不含当前 profile 时抛 profile_not_allowed", () => {
80
+ const input = makeInput({
81
+ toolMeta: makeToolMeta({
82
+ profiles: ["full", "chatgpt_core"],
83
+ }),
84
+ profile: "chatgpt_direct",
85
+ });
86
+ assertPatchWardenError(() => checkInvocation(input), "profile_not_allowed", "chatgpt_direct");
87
+ });
88
+ it("profile 在允许列表内时通过(后续校验继续)", () => {
89
+ const input = makeInput({
90
+ toolMeta: makeToolMeta({ profiles: ["full", "chatgpt_direct"] }),
91
+ profile: "chatgpt_direct",
92
+ });
93
+ const result = checkInvocation(input);
94
+ assert.deepEqual(result, { allowed: true });
95
+ });
96
+ });
97
+ describe("③ risk_exceeded", () => {
98
+ it("toolMeta.risk 高于 record.risk 时抛 risk_exceeded(schema 漂移)", () => {
99
+ const input = makeInput({
100
+ toolMeta: makeToolMeta({
101
+ name: "create_task",
102
+ risk: "workspace_write", // rank 2
103
+ }),
104
+ toolName: "create_task",
105
+ discoveryTokenRecord: makeTokenRecord({
106
+ toolName: "create_task",
107
+ risk: "workspace_read_sensitive", // rank 1
108
+ }),
109
+ // workspace_write 需要 assessmentId
110
+ assessmentId: "asm_risk_exceeded_test",
111
+ });
112
+ assertPatchWardenError(() => checkInvocation(input), "risk_exceeded", "workspace_write");
113
+ });
114
+ it("toolMeta.risk 等于 record.risk 时通过(相等不算超过)", () => {
115
+ const input = makeInput({
116
+ toolMeta: makeToolMeta({ risk: "workspace_read_sensitive" }),
117
+ discoveryTokenRecord: makeTokenRecord({ risk: "workspace_read_sensitive" }),
118
+ });
119
+ const result = checkInvocation(input);
120
+ assert.deepEqual(result, { allowed: true });
121
+ });
122
+ it("toolMeta.risk 低于 record.risk 时通过(更宽松 token 调更安全工具)", () => {
123
+ const input = makeInput({
124
+ toolMeta: makeToolMeta({
125
+ name: "health_check",
126
+ risk: "readonly", // rank 0
127
+ profiles: ["full"],
128
+ }),
129
+ toolName: "health_check",
130
+ discoveryTokenRecord: makeTokenRecord({
131
+ toolName: "health_check",
132
+ risk: "workspace_read_sensitive", // rank 1,更高
133
+ }),
134
+ });
135
+ const result = checkInvocation(input);
136
+ assert.deepEqual(result, { allowed: true });
137
+ });
138
+ });
139
+ describe("④ sensitive_path_blocked", () => {
140
+ it("workspace_read_sensitive 工具读 .env 路径时抛 sensitive_path_blocked", () => {
141
+ const input = makeInput({
142
+ args: { path: ".env" },
143
+ });
144
+ assertPatchWardenError(() => checkInvocation(input), "sensitive_path_blocked", ".env");
145
+ });
146
+ it("workspace_read_sensitive 工具读非敏感路径时通过", () => {
147
+ const input = makeInput({
148
+ args: { path: "src/main.ts" },
149
+ });
150
+ const result = checkInvocation(input);
151
+ assert.deepEqual(result, { allowed: true });
152
+ });
153
+ it("无路径参数时跳过敏感校验", () => {
154
+ const input = makeInput({
155
+ args: { query: "some text" },
156
+ });
157
+ const result = checkInvocation(input);
158
+ assert.deepEqual(result, { allowed: true });
159
+ });
160
+ it("支持 file / repo_path / target_path 等多种路径字段", () => {
161
+ // file 字段命中敏感
162
+ assertPatchWardenError(() => checkInvocation(makeInput({ args: { file: "id_rsa" } })), "sensitive_path_blocked");
163
+ // repo_path 字段命中敏感
164
+ assertPatchWardenError(() => checkInvocation(makeInput({ args: { repo_path: ".npmrc" } })), "sensitive_path_blocked");
165
+ // target_path 字段命中敏感
166
+ assertPatchWardenError(() => checkInvocation(makeInput({ args: { target_path: "config.json" } })), "sensitive_path_blocked");
167
+ });
168
+ });
169
+ describe("⑤ assessment_required", () => {
170
+ it("workspace_write 工具无 assessmentId 时抛 assessment_required", () => {
171
+ const input = makeInput({
172
+ toolMeta: makeToolMeta({
173
+ name: "save_plan",
174
+ risk: "workspace_write",
175
+ profiles: ["full"],
176
+ }),
177
+ toolName: "save_plan",
178
+ discoveryTokenRecord: makeTokenRecord({
179
+ toolName: "save_plan",
180
+ risk: "workspace_write",
181
+ }),
182
+ assessmentId: undefined,
183
+ });
184
+ assertPatchWardenError(() => checkInvocation(input), "assessment_required", "save_plan");
185
+ });
186
+ it("workspace_write 工具 assessmentId 为空字符串时抛 assessment_required", () => {
187
+ const input = makeInput({
188
+ toolMeta: makeToolMeta({
189
+ name: "save_plan",
190
+ risk: "workspace_write",
191
+ profiles: ["full"],
192
+ }),
193
+ toolName: "save_plan",
194
+ discoveryTokenRecord: makeTokenRecord({
195
+ toolName: "save_plan",
196
+ risk: "workspace_write",
197
+ }),
198
+ assessmentId: " ",
199
+ });
200
+ assertPatchWardenError(() => checkInvocation(input), "assessment_required");
201
+ });
202
+ it("workspace_write 工具有 assessmentId 时通过", () => {
203
+ const input = makeInput({
204
+ toolMeta: makeToolMeta({
205
+ name: "save_plan",
206
+ risk: "workspace_write",
207
+ profiles: ["full"],
208
+ }),
209
+ toolName: "save_plan",
210
+ discoveryTokenRecord: makeTokenRecord({
211
+ toolName: "save_plan",
212
+ risk: "workspace_write",
213
+ }),
214
+ assessmentId: "asm_workspace_write_ok",
215
+ });
216
+ const result = checkInvocation(input);
217
+ assert.deepEqual(result, { allowed: true });
218
+ });
219
+ });
220
+ describe("⑥ command_not_allowed", () => {
221
+ function commandInput(command) {
222
+ return makeInput({
223
+ toolMeta: makeToolMeta({
224
+ name: "run_verification",
225
+ risk: "command",
226
+ profiles: ["full", "chatgpt_direct"],
227
+ }),
228
+ toolName: "run_verification",
229
+ discoveryTokenRecord: makeTokenRecord({
230
+ toolName: "run_verification",
231
+ risk: "command",
232
+ }),
233
+ args: { command },
234
+ });
235
+ }
236
+ it("args.command 含管道 | 时抛 command_not_allowed", () => {
237
+ assertPatchWardenError(() => checkInvocation(commandInput("ls | grep secret")), "command_not_allowed");
238
+ });
239
+ it("args.command 含 & 时抛 command_not_allowed", () => {
240
+ assertPatchWardenError(() => checkInvocation(commandInput("rm -rf & background")), "command_not_allowed");
241
+ });
242
+ it("args.command 含 $() 时抛 command_not_allowed", () => {
243
+ assertPatchWardenError(() => checkInvocation(commandInput("echo $(cat .env)")), "command_not_allowed");
244
+ });
245
+ it("args.command 含反引号时抛 command_not_allowed", () => {
246
+ assertPatchWardenError(() => checkInvocation(commandInput("echo `whoami`")), "command_not_allowed");
247
+ });
248
+ it("args.command 含分号时抛 command_not_allowed", () => {
249
+ assertPatchWardenError(() => checkInvocation(commandInput("ls; rm -rf /")), "command_not_allowed");
250
+ });
251
+ it("args.command 不含元字符时通过(白名单由 handler 校验)", () => {
252
+ const result = checkInvocation(commandInput("npm test"));
253
+ assert.deepEqual(result, { allowed: true });
254
+ });
255
+ it("args.command 不存在时跳过(handler 内部再校验)", () => {
256
+ const result = checkInvocation(commandInput(undefined));
257
+ assert.deepEqual(result, { allowed: true });
258
+ });
259
+ it("args.command 为空字符串时跳过", () => {
260
+ const result = checkInvocation(commandInput(" "));
261
+ assert.deepEqual(result, { allowed: true });
262
+ });
263
+ });
264
+ describe("⑦ release_confirmation_required", () => {
265
+ it("release 工具无 assessmentId 时抛 release_confirmation_required", () => {
266
+ const input = makeInput({
267
+ toolMeta: makeToolMeta({
268
+ name: "publish_release",
269
+ risk: "release",
270
+ profiles: ["full"],
271
+ }),
272
+ toolName: "publish_release",
273
+ discoveryTokenRecord: makeTokenRecord({
274
+ toolName: "publish_release",
275
+ risk: "release",
276
+ }),
277
+ assessmentId: undefined,
278
+ });
279
+ assertPatchWardenError(() => checkInvocation(input), "release_confirmation_required", "publish_release");
280
+ });
281
+ it("release 工具有 assessmentId 时通过", () => {
282
+ const input = makeInput({
283
+ toolMeta: makeToolMeta({
284
+ name: "publish_release",
285
+ risk: "release",
286
+ profiles: ["full"],
287
+ }),
288
+ toolName: "publish_release",
289
+ discoveryTokenRecord: makeTokenRecord({
290
+ toolName: "publish_release",
291
+ risk: "release",
292
+ }),
293
+ assessmentId: "asm_release_confirm",
294
+ });
295
+ const result = checkInvocation(input);
296
+ assert.deepEqual(result, { allowed: true });
297
+ });
298
+ });
299
+ describe("⑧ credential_sensitive_blocked", () => {
300
+ it("credential_sensitive 工具总是被拒绝", () => {
301
+ const input = makeInput({
302
+ toolMeta: makeToolMeta({
303
+ name: "rotate_secret",
304
+ risk: "credential_sensitive",
305
+ profiles: ["full"],
306
+ }),
307
+ toolName: "rotate_secret",
308
+ discoveryTokenRecord: makeTokenRecord({
309
+ toolName: "rotate_secret",
310
+ risk: "credential_sensitive",
311
+ }),
312
+ assessmentId: "asm_does_not_matter",
313
+ });
314
+ assertPatchWardenError(() => checkInvocation(input), "credential_sensitive_blocked", "rotate_secret");
315
+ });
316
+ it("credential_sensitive 工具即使有 assessmentId 也被拒绝", () => {
317
+ const input = makeInput({
318
+ toolMeta: makeToolMeta({
319
+ name: "rotate_secret",
320
+ risk: "credential_sensitive",
321
+ profiles: ["full"],
322
+ }),
323
+ toolName: "rotate_secret",
324
+ discoveryTokenRecord: makeTokenRecord({
325
+ toolName: "rotate_secret",
326
+ risk: "credential_sensitive",
327
+ }),
328
+ assessmentId: "asm_credential",
329
+ });
330
+ assertPatchWardenError(() => checkInvocation(input), "credential_sensitive_blocked");
331
+ });
332
+ });
333
+ describe("成功场景", () => {
334
+ it("成功场景 1: readonly 工具 + 有效 token + profile 允许 → { allowed: true }", () => {
335
+ const input = makeInput({
336
+ toolMeta: makeToolMeta({
337
+ name: "health_check",
338
+ risk: "readonly",
339
+ profiles: ["full", "chatgpt_core", "chatgpt_direct"],
340
+ }),
341
+ toolName: "health_check",
342
+ discoveryTokenRecord: makeTokenRecord({
343
+ toolName: "health_check",
344
+ risk: "readonly",
345
+ }),
346
+ profile: "chatgpt_core",
347
+ args: {},
348
+ });
349
+ const result = checkInvocation(input);
350
+ assert.deepEqual(result, { allowed: true });
351
+ });
352
+ it("成功场景 2: workspace_write 工具 + 有效 token + 有 assessmentId → { allowed: true }", () => {
353
+ const input = makeInput({
354
+ toolMeta: makeToolMeta({
355
+ name: "create_task",
356
+ risk: "workspace_write",
357
+ profiles: ["full", "chatgpt_core"],
358
+ }),
359
+ toolName: "create_task",
360
+ discoveryTokenRecord: makeTokenRecord({
361
+ toolName: "create_task",
362
+ risk: "workspace_write",
363
+ }),
364
+ profile: "chatgpt_core",
365
+ assessmentId: "asm_success_write",
366
+ args: { prompt: "do something" },
367
+ });
368
+ const result = checkInvocation(input);
369
+ assert.deepEqual(result, { allowed: true });
370
+ });
371
+ it("成功场景 3: workspace_read_sensitive 工具 + 有效 token + 非敏感路径 → { allowed: true }", () => {
372
+ const input = makeInput({
373
+ toolMeta: makeToolMeta({
374
+ name: "read_workspace_file",
375
+ risk: "workspace_read_sensitive",
376
+ profiles: ["full", "chatgpt_core", "chatgpt_direct"],
377
+ }),
378
+ toolName: "read_workspace_file",
379
+ discoveryTokenRecord: makeTokenRecord({
380
+ toolName: "read_workspace_file",
381
+ risk: "workspace_read_sensitive",
382
+ }),
383
+ profile: "chatgpt_direct",
384
+ args: { path: "src/index.ts", file: "README.md" },
385
+ });
386
+ const result = checkInvocation(input);
387
+ assert.deepEqual(result, { allowed: true });
388
+ });
389
+ it("成功场景 4: command 工具 + 有效 token + 安全命令 → { allowed: true }", () => {
390
+ const input = makeInput({
391
+ toolMeta: makeToolMeta({
392
+ name: "run_verification",
393
+ risk: "command",
394
+ profiles: ["full", "chatgpt_direct"],
395
+ }),
396
+ toolName: "run_verification",
397
+ discoveryTokenRecord: makeTokenRecord({
398
+ toolName: "run_verification",
399
+ risk: "command",
400
+ }),
401
+ profile: "chatgpt_direct",
402
+ args: { command: "npm.cmd test" },
403
+ });
404
+ const result = checkInvocation(input);
405
+ assert.deepEqual(result, { allowed: true });
406
+ });
407
+ });
408
+ describe("GuardInput 类型守卫与边界", () => {
409
+ it("args.command 为非字符串类型时跳过(handler 内再校验)", () => {
410
+ const input = makeInput({
411
+ toolMeta: makeToolMeta({
412
+ name: "run_verification",
413
+ risk: "command",
414
+ profiles: ["full"],
415
+ }),
416
+ toolName: "run_verification",
417
+ discoveryTokenRecord: makeTokenRecord({
418
+ toolName: "run_verification",
419
+ risk: "command",
420
+ }),
421
+ args: { command: 123 },
422
+ });
423
+ const result = checkInvocation(input);
424
+ assert.deepEqual(result, { allowed: true });
425
+ });
426
+ it("GuardResult.allowed 始终为 true(失败时已抛错)", () => {
427
+ const result = checkInvocation(makeInput());
428
+ assert.equal(result.allowed, true);
429
+ assert.equal(Object.keys(result).length, 1, "GuardResult 只应有 allowed 字段");
430
+ });
431
+ });
432
+ });
@@ -0,0 +1 @@
1
+ export {};