@stigmer/runner 3.10.0 → 3.11.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 (260) hide show
  1. package/README.md +12 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/call-llm.js +9 -10
  4. package/dist/activities/call-llm.js.map +1 -1
  5. package/dist/activities/classify-tool-approvals.d.ts +2 -1
  6. package/dist/activities/classify-tool-approvals.js +28 -2
  7. package/dist/activities/classify-tool-approvals.js.map +1 -1
  8. package/dist/activities/discover-mcp-server.d.ts +32 -0
  9. package/dist/activities/discover-mcp-server.js +162 -27
  10. package/dist/activities/discover-mcp-server.js.map +1 -1
  11. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +8 -0
  12. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
  13. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
  14. package/dist/activities/execute-cursor/approval-state.d.ts +28 -2
  15. package/dist/activities/execute-cursor/approval-state.js +7 -1
  16. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  17. package/dist/activities/execute-cursor/attachment-resolver.d.ts +14 -0
  18. package/dist/activities/execute-cursor/attachment-resolver.js +18 -4
  19. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  20. package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
  21. package/dist/activities/execute-cursor/blueprint-resolver.js +6 -22
  22. package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
  23. package/dist/activities/execute-cursor/env-resolver.js +3 -1
  24. package/dist/activities/execute-cursor/env-resolver.js.map +1 -1
  25. package/dist/activities/execute-cursor/error-classifier.d.ts +40 -3
  26. package/dist/activities/execute-cursor/error-classifier.js +81 -3
  27. package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
  28. package/dist/activities/execute-cursor/extract-structured-output.d.ts +29 -0
  29. package/dist/activities/execute-cursor/extract-structured-output.js +58 -0
  30. package/dist/activities/execute-cursor/extract-structured-output.js.map +1 -0
  31. package/dist/activities/execute-cursor/hook-script.d.ts +14 -3
  32. package/dist/activities/execute-cursor/hook-script.js +72 -10
  33. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  34. package/dist/activities/execute-cursor/index.d.ts +5 -1
  35. package/dist/activities/execute-cursor/index.js +51 -57
  36. package/dist/activities/execute-cursor/index.js.map +1 -1
  37. package/dist/activities/execute-cursor/mcp-resolver.d.ts +24 -1
  38. package/dist/activities/execute-cursor/mcp-resolver.js +5 -2
  39. package/dist/activities/execute-cursor/mcp-resolver.js.map +1 -1
  40. package/dist/activities/execute-cursor/prompt-builder.d.ts +18 -4
  41. package/dist/activities/execute-cursor/prompt-builder.js +12 -7
  42. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  43. package/dist/activities/execute-cursor/turn-stream.js +4 -1
  44. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  45. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +18 -1
  46. package/dist/activities/execute-deep-agent/attachment-injector.js +68 -23
  47. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  48. package/dist/activities/execute-deep-agent/environment.js +3 -1
  49. package/dist/activities/execute-deep-agent/environment.js.map +1 -1
  50. package/dist/activities/execute-deep-agent/index.js +15 -0
  51. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  52. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -7
  53. package/dist/activities/execute-deep-agent/prompt-builder.js +8 -2
  54. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  55. package/dist/activities/execute-deep-agent/setup.d.ts +10 -0
  56. package/dist/activities/execute-deep-agent/setup.js +55 -23
  57. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  58. package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +18 -1
  59. package/dist/activities/execute-deep-agent/subagent-transformer.js +8 -1
  60. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  61. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +11 -4
  62. package/dist/activities/execute-deep-agent/subagent-wiring.js +13 -4
  63. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  64. package/dist/activities/hydrate-workflow-execution.js +3 -1
  65. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  66. package/dist/activities/workflow-event-activities.d.ts +28 -10
  67. package/dist/activities/workflow-event-activities.js +87 -58
  68. package/dist/activities/workflow-event-activities.js.map +1 -1
  69. package/dist/claimcheck/payload-codec.js +21 -1
  70. package/dist/claimcheck/payload-codec.js.map +1 -1
  71. package/dist/client/stigmer-client.d.ts +9 -4
  72. package/dist/client/stigmer-client.js +28 -15
  73. package/dist/client/stigmer-client.js.map +1 -1
  74. package/dist/encryption/config.d.ts +32 -0
  75. package/dist/encryption/config.js +68 -0
  76. package/dist/encryption/config.js.map +1 -0
  77. package/dist/encryption/index.d.ts +3 -0
  78. package/dist/encryption/index.js +3 -0
  79. package/dist/encryption/index.js.map +1 -0
  80. package/dist/encryption/payload-codec.d.ts +41 -0
  81. package/dist/encryption/payload-codec.js +130 -0
  82. package/dist/encryption/payload-codec.js.map +1 -0
  83. package/dist/payload-codecs.d.ts +16 -0
  84. package/dist/payload-codecs.js +38 -0
  85. package/dist/payload-codecs.js.map +1 -0
  86. package/dist/preflight.d.ts +31 -0
  87. package/dist/preflight.js +43 -0
  88. package/dist/preflight.js.map +1 -1
  89. package/dist/runner-manager.js +5 -15
  90. package/dist/runner-manager.js.map +1 -1
  91. package/dist/runner.js +5 -16
  92. package/dist/runner.js.map +1 -1
  93. package/dist/shared/approval-policy.d.ts +9 -3
  94. package/dist/shared/approval-policy.js +15 -6
  95. package/dist/shared/approval-policy.js.map +1 -1
  96. package/dist/shared/attachment-naming.d.ts +53 -0
  97. package/dist/shared/attachment-naming.js +59 -0
  98. package/dist/shared/attachment-naming.js.map +1 -0
  99. package/dist/shared/caller-identity.d.ts +23 -2
  100. package/dist/shared/caller-identity.js +36 -5
  101. package/dist/shared/caller-identity.js.map +1 -1
  102. package/dist/shared/channel-attachment.js +1 -0
  103. package/dist/shared/channel-attachment.js.map +1 -1
  104. package/dist/shared/checkpointer/http-saver.d.ts +42 -1
  105. package/dist/shared/checkpointer/http-saver.js +96 -8
  106. package/dist/shared/checkpointer/http-saver.js.map +1 -1
  107. package/dist/shared/conversation-attachment.js +1 -0
  108. package/dist/shared/conversation-attachment.js.map +1 -1
  109. package/dist/shared/datastore-attachment.d.ts +50 -7
  110. package/dist/shared/datastore-attachment.js +93 -11
  111. package/dist/shared/datastore-attachment.js.map +1 -1
  112. package/dist/shared/http-retry.d.ts +43 -0
  113. package/dist/shared/http-retry.js +50 -0
  114. package/dist/shared/http-retry.js.map +1 -0
  115. package/dist/shared/llm-backend.d.ts +275 -0
  116. package/dist/shared/llm-backend.js +425 -0
  117. package/dist/shared/llm-backend.js.map +1 -0
  118. package/dist/shared/llm-proxy.d.ts +8 -0
  119. package/dist/shared/llm-proxy.js +15 -0
  120. package/dist/shared/llm-proxy.js.map +1 -1
  121. package/dist/shared/mcp-enabled-tools.d.ts +57 -0
  122. package/dist/shared/mcp-enabled-tools.js +86 -0
  123. package/dist/shared/mcp-enabled-tools.js.map +1 -0
  124. package/dist/shared/mcp-manager.d.ts +3 -1
  125. package/dist/shared/mcp-manager.js +17 -4
  126. package/dist/shared/mcp-manager.js.map +1 -1
  127. package/dist/shared/mcp-resolver.d.ts +39 -2
  128. package/dist/shared/mcp-resolver.js +38 -2
  129. package/dist/shared/mcp-resolver.js.map +1 -1
  130. package/dist/shared/model-client.d.ts +12 -5
  131. package/dist/shared/model-client.js +138 -18
  132. package/dist/shared/model-client.js.map +1 -1
  133. package/dist/shared/model-error.js +198 -5
  134. package/dist/shared/model-error.js.map +1 -1
  135. package/dist/shared/plan-mode-permissions.d.ts +26 -0
  136. package/dist/shared/plan-mode-permissions.js +28 -0
  137. package/dist/shared/plan-mode-permissions.js.map +1 -0
  138. package/dist/worker.d.ts +2 -1
  139. package/dist/worker.js +2 -4
  140. package/dist/worker.js.map +1 -1
  141. package/dist/workflow-engine/types.d.ts +18 -0
  142. package/dist/workflow-engine/types.js.map +1 -1
  143. package/dist/workflows/call-agent-orchestrator.d.ts +9 -0
  144. package/dist/workflows/call-agent-orchestrator.js +1 -0
  145. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  146. package/dist/workflows/connect-mcp-server.js +7 -0
  147. package/dist/workflows/connect-mcp-server.js.map +1 -1
  148. package/dist/workflows/engine-core.js +23 -2
  149. package/dist/workflows/engine-core.js.map +1 -1
  150. package/dist/workflows/execute-from-execution.d.ts +1 -1
  151. package/dist/workflows/execute-from-execution.js +11 -1
  152. package/dist/workflows/execute-from-execution.js.map +1 -1
  153. package/package.json +8 -2
  154. package/src/__tests__/claimcheck-codec.test.ts +36 -0
  155. package/src/__tests__/encryption-codec.test.ts +234 -0
  156. package/src/__tests__/fixtures/encrypted-payload-fixture.json +15 -0
  157. package/src/__tests__/history-encryption-e2e.test.ts +243 -0
  158. package/src/__tests__/preflight.test.ts +50 -2
  159. package/src/activities/__tests__/call-llm.test.ts +75 -0
  160. package/src/activities/__tests__/classify-tool-approvals.test.ts +117 -1
  161. package/src/activities/__tests__/discover-mcp-server.hang.test.ts +103 -0
  162. package/src/activities/__tests__/discover-mcp-server.test.ts +203 -0
  163. package/src/activities/__tests__/workflow-event-activities.test.ts +107 -8
  164. package/src/activities/call-llm.ts +9 -16
  165. package/src/activities/classify-tool-approvals.ts +34 -4
  166. package/src/activities/discover-mcp-server.ts +190 -32
  167. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +9 -0
  168. package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +14 -0
  169. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +53 -0
  170. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +40 -14
  171. package/src/activities/execute-cursor/__tests__/error-classifier-extraction.test.ts +208 -0
  172. package/src/activities/execute-cursor/__tests__/extract-structured-output.test.ts +120 -0
  173. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +93 -0
  174. package/src/activities/execute-cursor/__tests__/mcp-resolver.test.ts +125 -0
  175. package/src/activities/execute-cursor/__tests__/prompt-builder-delegation.test.ts +1 -1
  176. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +13 -0
  177. package/src/activities/execute-cursor/approval-state.ts +30 -1
  178. package/src/activities/execute-cursor/attachment-resolver.ts +31 -3
  179. package/src/activities/execute-cursor/blueprint-resolver.ts +7 -27
  180. package/src/activities/execute-cursor/env-resolver.ts +3 -1
  181. package/src/activities/execute-cursor/error-classifier.ts +91 -4
  182. package/src/activities/execute-cursor/extract-structured-output.ts +72 -0
  183. package/src/activities/execute-cursor/hook-script.ts +74 -10
  184. package/src/activities/execute-cursor/index.ts +55 -71
  185. package/src/activities/execute-cursor/mcp-resolver.ts +36 -2
  186. package/src/activities/execute-cursor/prompt-builder.ts +34 -9
  187. package/src/activities/execute-cursor/turn-stream.ts +5 -2
  188. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +110 -8
  189. package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +104 -0
  190. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +2 -0
  191. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  192. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  193. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +34 -5
  194. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  195. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +173 -0
  196. package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +12 -7
  197. package/src/activities/execute-deep-agent/attachment-injector.ts +94 -30
  198. package/src/activities/execute-deep-agent/environment.ts +3 -1
  199. package/src/activities/execute-deep-agent/index.ts +20 -0
  200. package/src/activities/execute-deep-agent/prompt-builder.ts +20 -10
  201. package/src/activities/execute-deep-agent/setup.ts +76 -28
  202. package/src/activities/execute-deep-agent/subagent-transformer.ts +23 -1
  203. package/src/activities/execute-deep-agent/subagent-wiring.ts +14 -4
  204. package/src/activities/hydrate-workflow-execution.ts +3 -1
  205. package/src/activities/workflow-event-activities.ts +96 -69
  206. package/src/claimcheck/payload-codec.ts +33 -1
  207. package/src/client/__tests__/stigmer-client.test.ts +8 -8
  208. package/src/client/stigmer-client.ts +32 -18
  209. package/src/encryption/config.ts +91 -0
  210. package/src/encryption/index.ts +3 -0
  211. package/src/encryption/payload-codec.ts +152 -0
  212. package/src/payload-codecs.ts +56 -0
  213. package/src/preflight.ts +45 -0
  214. package/src/runner-manager.ts +6 -24
  215. package/src/runner.ts +6 -25
  216. package/src/shared/__tests__/approval-policy.test.ts +82 -39
  217. package/src/shared/__tests__/attachment-naming.test.ts +159 -0
  218. package/src/shared/__tests__/bedrock-adapter.test.ts +213 -0
  219. package/src/shared/__tests__/bedrock-seam.test.ts +390 -0
  220. package/src/shared/__tests__/caller-identity.test.ts +25 -0
  221. package/src/shared/__tests__/channel-attachment.test.ts +1 -1
  222. package/src/shared/__tests__/connect-backfill.test.ts +1 -0
  223. package/src/shared/__tests__/conversation-attachment.test.ts +1 -1
  224. package/src/shared/__tests__/datastore-attachment.test.ts +129 -1
  225. package/src/shared/__tests__/foundry-adapter.test.ts +276 -0
  226. package/src/shared/__tests__/foundry-seam.test.ts +482 -0
  227. package/src/shared/__tests__/http-retry.test.ts +67 -0
  228. package/src/shared/__tests__/llm-backend.test.ts +616 -0
  229. package/src/shared/__tests__/mcp-enabled-tools.test.ts +86 -0
  230. package/src/shared/__tests__/mcp-manager.test.ts +84 -2
  231. package/src/shared/__tests__/mcp-resolver.test.ts +146 -3
  232. package/src/shared/__tests__/model-client.test.ts +154 -0
  233. package/src/shared/__tests__/model-error.test.ts +289 -1
  234. package/src/shared/__tests__/synthesized-attachment.test.ts +1 -0
  235. package/src/shared/__tests__/vertex-adapter.test.ts +169 -0
  236. package/src/shared/__tests__/vertex-seam.test.ts +295 -0
  237. package/src/shared/approval-policy.ts +14 -7
  238. package/src/shared/attachment-naming.ts +78 -0
  239. package/src/shared/caller-identity.ts +40 -5
  240. package/src/shared/channel-attachment.ts +1 -0
  241. package/src/shared/checkpointer/__tests__/http-saver.test.ts +196 -1
  242. package/src/shared/checkpointer/http-saver.ts +117 -9
  243. package/src/shared/conversation-attachment.ts +1 -0
  244. package/src/shared/datastore-attachment.ts +106 -11
  245. package/src/shared/http-retry.ts +50 -0
  246. package/src/shared/llm-backend.ts +544 -0
  247. package/src/shared/llm-proxy.ts +15 -0
  248. package/src/shared/mcp-enabled-tools.ts +105 -0
  249. package/src/shared/mcp-manager.ts +21 -4
  250. package/src/shared/mcp-resolver.ts +73 -2
  251. package/src/shared/model-client.ts +161 -19
  252. package/src/shared/model-error.ts +222 -4
  253. package/src/shared/plan-mode-permissions.ts +30 -0
  254. package/src/worker.ts +4 -5
  255. package/src/workflow-engine/types.ts +18 -0
  256. package/src/workflows/__tests__/execute-serverless-workflow.test.ts +68 -2
  257. package/src/workflows/call-agent-orchestrator.ts +10 -0
  258. package/src/workflows/connect-mcp-server.ts +7 -0
  259. package/src/workflows/engine-core.ts +23 -2
  260. package/src/workflows/execute-from-execution.ts +12 -2
@@ -114,6 +114,59 @@ describe("resolveAttachments", () => {
114
114
  expect(readFileSync(join(platformDir, "inputs", "data.csv"), "utf-8")).toBe("a,b,c");
115
115
  });
116
116
 
117
+ it("uniquifies duplicate filenames on the storage branch — neither file's bytes are lost (issue #364)", async () => {
118
+ // Before the fix this branch had no collision check and the second write
119
+ // silently overwrote the first.
120
+ const { storage } = makeInMemoryArtifactStorage();
121
+ await storage.upload("attachments/01AAA/report.pdf", Buffer.from("first bytes"), "application/pdf");
122
+ await storage.upload("attachments/01BBB/report.pdf", Buffer.from("second bytes"), "application/pdf");
123
+
124
+ const result = await resolveAttachments(
125
+ [
126
+ makeAttachment({ filename: "report.pdf", storageKey: "attachments/01AAA/report.pdf" }),
127
+ makeAttachment({ filename: "report.pdf", storageKey: "attachments/01BBB/report.pdf" }),
128
+ ],
129
+ options({ storage }),
130
+ );
131
+
132
+ expect(result).toEqual([
133
+ { filename: "report.pdf", relativePath: ".stigmer/inputs/report.pdf" },
134
+ {
135
+ filename: "report-2.pdf",
136
+ relativePath: ".stigmer/inputs/report-2.pdf",
137
+ renamedFrom: "report.pdf",
138
+ },
139
+ ]);
140
+ expect(readFileSync(join(platformDir, "inputs", "report.pdf"), "utf-8")).toBe("first bytes");
141
+ expect(readFileSync(join(platformDir, "inputs", "report-2.pdf"), "utf-8")).toBe("second bytes");
142
+ });
143
+
144
+ it("uniquifies duplicate filenames across the local and storage branches (one shared taken-set)", async () => {
145
+ const srcPath = join(workspaceDir, "notes.md");
146
+ writeFileSync(srcPath, "local copy");
147
+ const { storage } = makeInMemoryArtifactStorage();
148
+ await storage.upload("attachments/01ABC/notes.md", Buffer.from("uploaded copy"), "text/markdown");
149
+
150
+ const result = await resolveAttachments(
151
+ [
152
+ makeAttachment({ filename: "notes.md", storageKey: "", localPath: srcPath }),
153
+ makeAttachment({ filename: "notes.md", storageKey: "attachments/01ABC/notes.md" }),
154
+ ],
155
+ options({ storage }),
156
+ );
157
+
158
+ expect(result).toEqual([
159
+ { filename: "notes.md", relativePath: ".stigmer/inputs/notes.md" },
160
+ {
161
+ filename: "notes-2.md",
162
+ relativePath: ".stigmer/inputs/notes-2.md",
163
+ renamedFrom: "notes.md",
164
+ },
165
+ ]);
166
+ expect(readFileSync(join(platformDir, "inputs", "notes.md"), "utf-8")).toBe("local copy");
167
+ expect(readFileSync(join(platformDir, "inputs", "notes-2.md"), "utf-8")).toBe("uploaded copy");
168
+ });
169
+
117
170
  it("ignores localPath in cloud mode and downloads by storage key", async () => {
118
171
  const { storage } = makeInMemoryArtifactStorage();
119
172
  await storage.upload("attachments/01ABC/plan.md", Buffer.from("from storage"), "text/markdown");
@@ -47,7 +47,7 @@ function input(overrides: Partial<BuildPromptInput>): BuildPromptInput {
47
47
  subAgents: [],
48
48
  workspaceDirs: ["/tmp/ws"],
49
49
  workspaceFileRefs: [],
50
- attachmentPaths: [],
50
+ attachments: [],
51
51
  pendingApprovals: [],
52
52
  ...overrides,
53
53
  };
@@ -103,6 +103,10 @@ describe("buildPrompt", () => {
103
103
  expect(prompt).toContain("<available_datastores>");
104
104
  expect(prompt).toContain("- clinic");
105
105
  expect(prompt).toContain("describe_datastore");
106
+ // The standing failure-disclosure instruction (issue #325) is this
107
+ // harness's ONLY outage coverage: the Cursor SDK connects MCP itself,
108
+ // so the runner can never reconcile the live roster here.
109
+ expect(prompt).toContain("do not answer from memory");
106
110
  });
107
111
 
108
112
  it("omits the datastores section when the agent uses no datastores", () => {
@@ -269,7 +273,7 @@ describe("attachments on a resumed turn (T04 — the mid-session WhatsApp case)"
269
273
 
270
274
  it("announces this turn's attachments to a resumed agent (per-execution value, never inherited)", () => {
271
275
  const prompt = buildPrompt(
272
- input({ ...RESUMED, attachmentPaths: [".stigmer/inputs/lease.pdf"] }),
276
+ input({ ...RESUMED, attachments: [{ path: ".stigmer/inputs/lease.pdf" }] }),
273
277
  );
274
278
  expect(prompt).toContain("<input_files>");
275
279
  expect(prompt).toContain("`.stigmer/inputs/lease.pdf`");
@@ -277,6 +281,23 @@ describe("attachments on a resumed turn (T04 — the mid-session WhatsApp case)"
277
281
  expect(prompt.endsWith(USER_MESSAGE)).toBe(true);
278
282
  });
279
283
 
284
+ it("discloses a duplicate-renamed attachment's original name (issue #364)", () => {
285
+ const prompt = buildPrompt(
286
+ input({
287
+ ...RESUMED,
288
+ attachments: [
289
+ { path: ".stigmer/inputs/report.pdf" },
290
+ { path: ".stigmer/inputs/report-2.pdf", renamedFrom: "report.pdf" },
291
+ ],
292
+ }),
293
+ );
294
+ expect(prompt).toContain(
295
+ "- `.stigmer/inputs/report-2.pdf` (renamed from duplicate 'report.pdf')",
296
+ );
297
+ // The first file keeps a clean entry — no disclosure noise.
298
+ expect(prompt).toContain("- `.stigmer/inputs/report.pdf`\n");
299
+ });
300
+
280
301
  it("keeps a resumed turn WITHOUT attachments byte-identical to the raw message (regression guard)", () => {
281
302
  const prompt = buildPrompt(input({ ...RESUMED }));
282
303
  expect(prompt).toBe(USER_MESSAGE);
@@ -286,7 +307,7 @@ describe("attachments on a resumed turn (T04 — the mid-session WhatsApp case)"
286
307
  const prompt = buildPrompt(
287
308
  input({
288
309
  ...RESUMED,
289
- attachmentPaths: [".stigmer/inputs/photo.jpg"],
310
+ attachments: [{ path: ".stigmer/inputs/photo.jpg" }],
290
311
  conversationCatchup: "User also said hello on the channel.",
291
312
  }),
292
313
  );
@@ -300,7 +321,7 @@ describe("attachments on a resumed turn (T04 — the mid-session WhatsApp case)"
300
321
  const prompt = buildPrompt(
301
322
  input({
302
323
  ...RESUMED,
303
- attachmentPaths: [".stigmer/inputs/a.jpg", ".stigmer/inputs/big.png"],
324
+ attachments: [{ path: ".stigmer/inputs/a.jpg" }, { path: ".stigmer/inputs/big.png" }],
304
325
  vision: {
305
326
  inlineFilenames: ["a.jpg"],
306
327
  notViewable: [{ path: ".stigmer/inputs/big.png", reason: "too_large" }],
@@ -316,7 +337,7 @@ describe("attachments on a resumed turn (T04 — the mid-session WhatsApp case)"
316
337
  const prompt = buildPrompt(
317
338
  input({
318
339
  resolution: resolution("local", "created_first_execution"),
319
- attachmentPaths: [".stigmer/inputs/a.jpg"],
340
+ attachments: [{ path: ".stigmer/inputs/a.jpg" }],
320
341
  vision: { inlineFilenames: ["a.jpg"], notViewable: [] },
321
342
  }),
322
343
  );
@@ -336,7 +357,7 @@ describe("attachments on a resumed turn (T04 — the mid-session WhatsApp case)"
336
357
  message: "Write file: gated.txt",
337
358
  }),
338
359
  ],
339
- attachmentPaths: [".stigmer/inputs/photo.jpg"],
360
+ attachments: [{ path: ".stigmer/inputs/photo.jpg" }],
340
361
  vision: { inlineFilenames: ["photo.jpg"], notViewable: [] },
341
362
  }),
342
363
  );
@@ -543,7 +564,10 @@ describe("formatImplementPlanSection", () => {
543
564
  const PLAN_PATH = ".stigmer/inputs/plan.md";
544
565
 
545
566
  it("wraps the attached-plan directive when the plan is among the attachments", () => {
546
- const section = formatImplementPlanSection(true, [PLAN_PATH, ".stigmer/inputs/data.csv"]);
567
+ const section = formatImplementPlanSection(true, [
568
+ { path: PLAN_PATH },
569
+ { path: ".stigmer/inputs/data.csv" },
570
+ ]);
547
571
 
548
572
  expect(section).toBeDefined();
549
573
  expect(section!.startsWith("<implement_plan>")).toBe(true);
@@ -553,7 +577,9 @@ describe("formatImplementPlanSection", () => {
553
577
  });
554
578
 
555
579
  it("falls back to the conversation-plan directive when no plan attachment resolved", () => {
556
- const section = formatImplementPlanSection(true, [".stigmer/inputs/data.csv"]);
580
+ const section = formatImplementPlanSection(true, [
581
+ { path: ".stigmer/inputs/data.csv" },
582
+ ]);
557
583
 
558
584
  expect(section).toBeDefined();
559
585
  expect(section).not.toContain("plan.md");
@@ -561,12 +587,12 @@ describe("formatImplementPlanSection", () => {
561
587
  });
562
588
 
563
589
  it("returns undefined for an ordinary (non-build) execution", () => {
564
- expect(formatImplementPlanSection(false, [PLAN_PATH])).toBeUndefined();
565
- expect(formatImplementPlanSection(undefined, [PLAN_PATH])).toBeUndefined();
590
+ expect(formatImplementPlanSection(false, [{ path: PLAN_PATH }])).toBeUndefined();
591
+ expect(formatImplementPlanSection(undefined, [{ path: PLAN_PATH }])).toBeUndefined();
566
592
  });
567
593
 
568
594
  it("carries the plan-derived progress-tracking instruction (Tier 3)", () => {
569
- const section = formatImplementPlanSection(true, [PLAN_PATH]);
595
+ const section = formatImplementPlanSection(true, [{ path: PLAN_PATH }]);
570
596
 
571
597
  expect(section).toContain("to-do list");
572
598
  expect(section).toContain("break the plan into");
@@ -577,7 +603,7 @@ describe("formatImplementPlanSection", () => {
577
603
  input({
578
604
  resolution: resolution("local", "created_first_execution"),
579
605
  buildFromPlan: true,
580
- attachmentPaths: [PLAN_PATH],
606
+ attachments: [{ path: PLAN_PATH }],
581
607
  }),
582
608
  );
583
609
 
@@ -593,7 +619,7 @@ describe("formatImplementPlanSection", () => {
593
619
  input({
594
620
  resolution: resolution("local", "resumed_successfully"),
595
621
  buildFromPlan: true,
596
- attachmentPaths: [PLAN_PATH],
622
+ attachments: [{ path: PLAN_PATH }],
597
623
  }),
598
624
  );
599
625
 
@@ -611,7 +637,7 @@ describe("formatImplementPlanSection", () => {
611
637
  input({
612
638
  resolution: resolution("local", "resumed_successfully"),
613
639
  buildFromPlan: false,
614
- attachmentPaths: [PLAN_PATH],
640
+ attachments: [{ path: PLAN_PATH }],
615
641
  }),
616
642
  );
617
643
 
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Tests for the shape-aware run.wait() error extraction (oss#299).
3
+ *
4
+ * A bare String() on a structured error value yields "[object Object]",
5
+ * which end users saw verbatim AND which shadowed every lower-priority
6
+ * classifier source (stream, rejection, conversation introspection) because
7
+ * classification stops at the first non-empty source. These tests pin:
8
+ *
9
+ * - the extractRunErrorSources shape matrix (strings, Errors, field objects,
10
+ * hopeless values)
11
+ * - first-USABLE-candidate chain order (a hopeless object no longer hides a
12
+ * usable string one field later; an empty string no longer short-circuits)
13
+ * - end-to-end: structured errors classify and re-enable fresh-agent retry;
14
+ * hopeless extraction yields to the introspection sources
15
+ * - the "[object Object]" defense-in-depth guard in classifyFromSources
16
+ */
17
+
18
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
19
+ import {
20
+ extractRunErrorSources,
21
+ synthesizeError,
22
+ shouldRetryWithFreshAgent,
23
+ } from "../error-classifier.js";
24
+
25
+ const FALLBACK = { model: "default", mode: "cloud", agentId: "agent-1" };
26
+
27
+ function base() {
28
+ return {
29
+ sdkResultFields: undefined,
30
+ streamErrorMessage: undefined,
31
+ capturedRejection: undefined,
32
+ isResumedHandle: false,
33
+ fallbackContext: FALLBACK,
34
+ } as const;
35
+ }
36
+
37
+ /** A run.wait()-shaped error result carrying the given error-detail fields. */
38
+ function errorResult(fields: Record<string, unknown>): unknown {
39
+ return { id: "run-1", status: "error", ...fields };
40
+ }
41
+
42
+ const NOTHING = { sdkError: undefined, sdkResultFields: undefined };
43
+
44
+ describe("extractRunErrorSources shape matrix", () => {
45
+ it("routes a plain string to sdkResultFields", () => {
46
+ expect(extractRunErrorSources(errorResult({ result: "rate limit exceeded" })))
47
+ .toEqual({ sdkError: undefined, sdkResultFields: "rate limit exceeded" });
48
+ });
49
+
50
+ it("lifts an Error instance into the structured channel", () => {
51
+ expect(extractRunErrorSources(errorResult({ result: new Error("connection lost") })))
52
+ .toEqual({ sdkError: { message: "connection lost" }, sdkResultFields: undefined });
53
+ });
54
+
55
+ it("lifts an Error carrying a code (Node/SDK error shape)", () => {
56
+ const err = Object.assign(new Error("stream torn down"), { code: "unavailable" });
57
+ expect(extractRunErrorSources(errorResult({ result: err })))
58
+ .toEqual({ sdkError: { code: "unavailable", message: "stream torn down" }, sdkResultFields: undefined });
59
+ });
60
+
61
+ it("lifts { code, status, message } from a plain object", () => {
62
+ expect(extractRunErrorSources(errorResult({ error: { code: "unauthenticated", status: 401, message: "bad token" } })))
63
+ .toEqual({
64
+ sdkError: { code: "unauthenticated", status: 401, message: "bad token" },
65
+ sdkResultFields: undefined,
66
+ });
67
+ });
68
+
69
+ it("lifts a message-only object", () => {
70
+ expect(extractRunErrorSources(errorResult({ error: { message: "boom" } })))
71
+ .toEqual({ sdkError: { message: "boom" }, sdkResultFields: undefined });
72
+ });
73
+
74
+ it("lifts a code-only object", () => {
75
+ expect(extractRunErrorSources(errorResult({ error: { code: "resource_exhausted" } })))
76
+ .toEqual({ sdkError: { code: "resource_exhausted" }, sdkResultFields: undefined });
77
+ });
78
+
79
+ it("yields nothing for an object with no recognizable fields (no JSON.stringify junk)", () => {
80
+ expect(extractRunErrorSources(errorResult({ result: { weird: "shape" } }))).toEqual(NOTHING);
81
+ });
82
+
83
+ it("yields nothing for a circular object", () => {
84
+ const circular: Record<string, unknown> = {};
85
+ circular.self = circular;
86
+ expect(extractRunErrorSources(errorResult({ result: circular }))).toEqual(NOTHING);
87
+ });
88
+
89
+ it("refuses the '[object Object]' junk string itself", () => {
90
+ expect(extractRunErrorSources(errorResult({ result: "[object Object]" }))).toEqual(NOTHING);
91
+ });
92
+
93
+ it("stringifies non-string primitives losslessly", () => {
94
+ expect(extractRunErrorSources(errorResult({ result: 503 })))
95
+ .toEqual({ sdkError: undefined, sdkResultFields: "503" });
96
+ });
97
+
98
+ it("yields nothing when no candidate field is present", () => {
99
+ expect(extractRunErrorSources(errorResult({}))).toEqual(NOTHING);
100
+ });
101
+
102
+ it("yields nothing for non-object results", () => {
103
+ expect(extractRunErrorSources(undefined)).toEqual(NOTHING);
104
+ expect(extractRunErrorSources(null)).toEqual(NOTHING);
105
+ expect(extractRunErrorSources("not-a-result-object")).toEqual(NOTHING);
106
+ });
107
+ });
108
+
109
+ describe("extractRunErrorSources chain order (first USABLE candidate wins)", () => {
110
+ it("a hopeless object in result no longer hides a usable string in message", () => {
111
+ const extracted = extractRunErrorSources(
112
+ errorResult({ result: { weird: "shape" }, message: "the real reason" }),
113
+ );
114
+ expect(extracted).toEqual({ sdkError: undefined, sdkResultFields: "the real reason" });
115
+ });
116
+
117
+ it("an empty string in result no longer short-circuits the chain", () => {
118
+ const extracted = extractRunErrorSources(
119
+ errorResult({ result: "", reason: "torn down mid-stream" }),
120
+ );
121
+ expect(extracted).toEqual({ sdkError: undefined, sdkResultFields: "torn down mid-stream" });
122
+ });
123
+
124
+ it("respects the documented field order: result before error before message before reason", () => {
125
+ const extracted = extractRunErrorSources(
126
+ errorResult({ result: "from-result", error: "from-error", message: "from-message" }),
127
+ );
128
+ expect(extracted.sdkResultFields).toBe("from-result");
129
+ });
130
+
131
+ it("yields nothing when every candidate is hopeless", () => {
132
+ const extracted = extractRunErrorSources(
133
+ errorResult({ result: {}, error: "", message: "[object Object]" }),
134
+ );
135
+ expect(extracted).toEqual(NOTHING);
136
+ });
137
+ });
138
+
139
+ describe("end-to-end through synthesizeError", () => {
140
+ beforeEach(() => {
141
+ vi.spyOn(console, "log").mockImplementation(() => {});
142
+ });
143
+ afterEach(() => {
144
+ vi.restoreAllMocks();
145
+ });
146
+
147
+ it("a structured retryable error classifies and re-enables fresh-agent recovery", () => {
148
+ // The regression at the heart of oss#299: String() turned this into
149
+ // "[object Object]" -> category=unknown, retryable=false -> the
150
+ // poisoned-handle retry could never fire for a plain network flake.
151
+ const extracted = extractRunErrorSources(
152
+ errorResult({ error: { code: "unavailable", message: "upstream connect error" } }),
153
+ );
154
+ const classified = synthesizeError({ ...base(), ...extracted });
155
+
156
+ expect(classified.source).toBe("sdk");
157
+ expect(classified.category).toBe("network");
158
+ expect(classified.message).toBe("upstream connect error");
159
+ expect(classified.retryable).toBe(true);
160
+ expect(shouldRetryWithFreshAgent(classified)).toBe(true);
161
+ });
162
+
163
+ it("hopeless extraction yields to the conversation introspection source", () => {
164
+ const extracted = extractRunErrorSources(errorResult({ result: { weird: "shape" } }));
165
+ const classified = synthesizeError({
166
+ ...base(),
167
+ ...extracted,
168
+ conversationErrorText: "grpc-status 12: routing failure",
169
+ });
170
+
171
+ expect(classified.source).toBe("conversation");
172
+ expect(classified.message).toBe("grpc-status 12: routing failure");
173
+ });
174
+
175
+ it("hopeless extraction yields to the captured rejection source", () => {
176
+ const extracted = extractRunErrorSources(errorResult({ result: { weird: "shape" } }));
177
+ const classified = synthesizeError({
178
+ ...base(),
179
+ ...extracted,
180
+ capturedRejection: { code: "unavailable", message: "socket hang up", timestamp: Date.now() },
181
+ });
182
+
183
+ expect(classified.source).toBe("rejection");
184
+ expect(classified.message).toContain("socket hang up");
185
+ });
186
+ });
187
+
188
+ describe("classifyFromSources '[object Object]' defense-in-depth guard", () => {
189
+ beforeEach(() => {
190
+ vi.spyOn(console, "log").mockImplementation(() => {});
191
+ });
192
+ afterEach(() => {
193
+ vi.restoreAllMocks();
194
+ });
195
+
196
+ it("treats a leaked '[object Object]' sdkResultFields as absent", () => {
197
+ // Extraction never emits it, but any other producer of the junk string
198
+ // must not shadow the sources below it.
199
+ const classified = synthesizeError({
200
+ ...base(),
201
+ sdkResultFields: "[object Object]",
202
+ streamErrorMessage: "fetch failed",
203
+ });
204
+
205
+ expect(classified.source).toBe("stream");
206
+ expect(classified.category).toBe("network");
207
+ });
208
+ });
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { Config } from "../../../config.js";
3
+
4
+ vi.mock("../../../shared/model-registry.js", () => ({
5
+ getEconomyModel: vi.fn().mockResolvedValue("gpt-4o-mini"),
6
+ }));
7
+
8
+ const mockInvoke = vi.fn();
9
+ const mockWithStructuredOutput = vi.fn().mockReturnValue({ invoke: mockInvoke });
10
+
11
+ vi.mock("../../../shared/model-client.js", () => ({
12
+ buildChatModel: vi.fn().mockResolvedValue({
13
+ model: { withStructuredOutput: (...args: unknown[]) => mockWithStructuredOutput(...args) },
14
+ provider: "openai",
15
+ apiModelId: "gpt-4o-mini",
16
+ }),
17
+ }));
18
+
19
+ // llm-backend.js and llm-proxy.js stay real: the pre-check behavior under
20
+ // test IS their composition, and both are pure modules.
21
+
22
+ const SCHEMA = { type: "object", properties: { answer: { type: "string" } } };
23
+
24
+ function makeConfig(overrides: Partial<Config> = {}): Config {
25
+ return {
26
+ proxyEndpoint: null,
27
+ stigmerToken: null,
28
+ ...overrides,
29
+ } as Config;
30
+ }
31
+
32
+ describe("extractStructuredOutput", () => {
33
+ beforeEach(() => {
34
+ vi.clearAllMocks();
35
+ // Deterministic regardless of the developer's shell: blank reads as
36
+ // missing, and backend vars must not leak in from outside.
37
+ vi.stubEnv("OPENAI_API_KEY", "");
38
+ vi.stubEnv("ANTHROPIC_API_KEY", "");
39
+ vi.stubEnv("STIGMER_ANTHROPIC_BACKEND", "");
40
+ vi.stubEnv("STIGMER_OPENAI_BACKEND", "");
41
+ });
42
+
43
+ afterEach(() => {
44
+ vi.unstubAllEnvs();
45
+ });
46
+
47
+ it("direct mode with a key builds a direct-mode model (no endpoint threaded)", async () => {
48
+ vi.stubEnv("OPENAI_API_KEY", "sk-direct");
49
+ const { buildChatModel } = await import("../../../shared/model-client.js");
50
+ const { extractStructuredOutput } = await import("../extract-structured-output.js");
51
+ mockInvoke.mockResolvedValueOnce({ answer: "42" });
52
+
53
+ const result = await extractStructuredOutput("the answer is 42", SCHEMA, makeConfig(), "gpt-4.1");
54
+
55
+ expect(result).toEqual({ answer: "42" });
56
+ // The regression pin: the gRPC control-plane endpoint must never
57
+ // reappear here as a stand-in LLM proxy.
58
+ expect(buildChatModel).toHaveBeenCalledWith(
59
+ expect.objectContaining({ proxyEndpoint: undefined }),
60
+ );
61
+ });
62
+
63
+ it("throws the credential message before any construction when no path exists", async () => {
64
+ const { buildChatModel } = await import("../../../shared/model-client.js");
65
+ const { extractStructuredOutput } = await import("../extract-structured-output.js");
66
+
67
+ await expect(
68
+ extractStructuredOutput("text", SCHEMA, makeConfig(), "gpt-4.1"),
69
+ ).rejects.toThrow(/'gpt-4o-mini'.*OPENAI_API_KEY/s);
70
+ expect(buildChatModel).not.toHaveBeenCalled();
71
+ expect(mockInvoke).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it("proxy mode threads the proxy endpoint and token, consulting no keys", async () => {
75
+ const { buildChatModel } = await import("../../../shared/model-client.js");
76
+ const { extractStructuredOutput } = await import("../extract-structured-output.js");
77
+ mockInvoke.mockResolvedValueOnce({ answer: "ok" });
78
+
79
+ const result = await extractStructuredOutput(
80
+ "text", SCHEMA,
81
+ makeConfig({ proxyEndpoint: "https://api.stigmer.ai", stigmerToken: "tok" }),
82
+ "gpt-4.1",
83
+ );
84
+
85
+ expect(result).toEqual({ answer: "ok" });
86
+ expect(buildChatModel).toHaveBeenCalledWith(
87
+ expect.objectContaining({
88
+ proxyEndpoint: "https://api.stigmer.ai",
89
+ stigmerToken: "tok",
90
+ }),
91
+ );
92
+ });
93
+
94
+ it("defers an un-inferable extraction model to buildChatModel's own error", async () => {
95
+ // The registry-empty fallback returns the primary model verbatim; when
96
+ // its provider can't be inferred the pre-check must not guess — the
97
+ // construction path owns the precise message.
98
+ const { getEconomyModel } = await import("../../../shared/model-registry.js");
99
+ vi.mocked(getEconomyModel).mockResolvedValueOnce("mystery-model");
100
+ const { buildChatModel } = await import("../../../shared/model-client.js");
101
+ const { extractStructuredOutput } = await import("../extract-structured-output.js");
102
+ mockInvoke.mockResolvedValueOnce({ answer: "ok" });
103
+
104
+ await extractStructuredOutput("text", SCHEMA, makeConfig(), "mystery-model");
105
+
106
+ expect(buildChatModel).toHaveBeenCalledWith(
107
+ expect.objectContaining({ modelName: "mystery-model" }),
108
+ );
109
+ });
110
+
111
+ it("normalizes an empty extraction result to null", async () => {
112
+ vi.stubEnv("OPENAI_API_KEY", "sk-direct");
113
+ const { extractStructuredOutput } = await import("../extract-structured-output.js");
114
+ mockInvoke.mockResolvedValueOnce(undefined);
115
+
116
+ const result = await extractStructuredOutput("text", SCHEMA, makeConfig(), "gpt-4.1");
117
+
118
+ expect(result).toBeNull();
119
+ });
120
+ });
@@ -245,6 +245,99 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
245
245
  });
246
246
  });
247
247
 
248
+ // The enabled_tools capability manifest (issue #350): mcpServerEnabledTools
249
+ // holds ONLY restricted servers; the hook denies a listed server's
250
+ // non-listed tool with the non-pausing, permanent "disabled" kind — BEFORE
251
+ // autoApproveAll and the grant checks, because a manifest is not an
252
+ // approval gate (nothing may resurrect a disabled tool). hookMcp payloads
253
+ // carry mcp_server_name "srv".
254
+ describe("MCP enabled_tools manifest (beforeMCPExecution, issue #350)", () => {
255
+ it("denies a non-enabled tool with kind disabled (content-free, single record) and the manifest message", () => {
256
+ const h = setup({ mcpServerEnabledTools: { srv: ["list_apps"] } });
257
+
258
+ const res = h.decide(hookMcp("click", { app: "Slack" }));
259
+
260
+ expect(res.permission).toBe("deny");
261
+ // Permanent-denial framing, never the approval promise: the model must
262
+ // adapt, not wait for a resume that will never come.
263
+ expect(res.raw).toContain("not enabled for this agent");
264
+ expect(res.raw).not.toContain("submitted to the user for approval");
265
+ const ledger = h.ledger();
266
+ expect(ledger).toHaveLength(1);
267
+ expect(ledger[0].kind).toBe("disabled");
268
+ // Attributable under the MCP name-token (the identity the stream row
269
+ // computes), content-free like every non-approval kind.
270
+ expect(ledger[0].token).toBe(grantToken("click", ""));
271
+ expect(ledger[0]).not.toHaveProperty("input");
272
+ });
273
+
274
+ it("allows an enabled tool on a restricted server", () => {
275
+ const h = setup({ mcpServerEnabledTools: { srv: ["list_apps"] } });
276
+ expect(h.decide(hookMcp("list_apps")).permission).toBe("allow");
277
+ expect(h.ledger()).toEqual([]);
278
+ });
279
+
280
+ it("denies even under autoApproveAll (a manifest is not an approval gate)", () => {
281
+ const h = setup({
282
+ autoApproveAll: true,
283
+ mcpServerEnabledTools: { srv: ["list_apps"] },
284
+ });
285
+ const res = h.decide(hookMcp("click"));
286
+ expect(res.permission).toBe("deny");
287
+ expect(h.ledger()[0].kind).toBe("disabled");
288
+ });
289
+
290
+ it("denies even when the tool holds a reinvocation grant (no approval may resurrect it)", () => {
291
+ const h = setup({
292
+ mcpServerEnabledTools: { srv: ["list_apps"] },
293
+ grants: [{ toolName: "click", mcpServerSlug: "srv", key: "click", salient: "", contentDigest: "", sourceToolCallId: "consent-1" }],
294
+ });
295
+ const res = h.decide(hookMcp("click"));
296
+ expect(res.permission).toBe("deny");
297
+ expect(h.ledger()[0].kind).toBe("disabled");
298
+ });
299
+
300
+ it("stays kind disabled under unattended mode (mode-independent, like secret)", () => {
301
+ const h = setup({
302
+ unattendedSkip: true,
303
+ mcpServerEnabledTools: { srv: ["list_apps"] },
304
+ });
305
+ const res = h.decide(hookMcp("click"));
306
+ expect(res.permission).toBe("deny");
307
+ expect(h.ledger()[0].kind).toBe("disabled");
308
+ });
309
+
310
+ it("an enabled tool still flows into the normal approval arm (manifest and gate compose)", () => {
311
+ const h = setup({
312
+ mcpPolicies: { click: { requiresApproval: true, message: "Approve click?" } },
313
+ mcpServerEnabledTools: { srv: ["click"] },
314
+ });
315
+ const res = h.decide(hookMcp("click"));
316
+ expect(res.permission).toBe("deny");
317
+ expect(res.raw).toContain("Approve click?");
318
+ expect(h.ledger()[0].kind).toBe("approval");
319
+ });
320
+
321
+ it("a restriction on ANOTHER server never narrows this one (server-scoped matching)", () => {
322
+ const h = setup({ mcpServerEnabledTools: { other: ["something_else"] } });
323
+ expect(h.decide(hookMcp("click")).permission).toBe("allow");
324
+ expect(h.ledger()).toEqual([]);
325
+ });
326
+
327
+ it("quoted-name matching is exact — an enabled name never allows its prefix-sibling", () => {
328
+ const h = setup({ mcpServerEnabledTools: { srv: ["list_apps_extended"] } });
329
+ const res = h.decide(hookMcp("list_apps"));
330
+ expect(res.permission).toBe("deny");
331
+ expect(h.ledger()[0].kind).toBe("disabled");
332
+ });
333
+
334
+ it("never gates a preToolUse (built-in) payload — the manifest arm is MCP-event-scoped", () => {
335
+ const h = setup({ mcpServerEnabledTools: { srv: ["list_apps"] } });
336
+ expect(h.decide(hookRead("/x/a.txt")).permission).toBe("allow");
337
+ expect(h.ledger()).toEqual([]);
338
+ });
339
+ });
340
+
248
341
  // The hook captures the COMPLETE tool_input on every denial (base64(JSON)),
249
342
  // so the runner can overlay the proposed change onto the gated tool call for
250
343
  // the approval preview — the cursor analog of the native harness reading args