@stigmer/runner 3.9.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 (270) 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 +25 -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 +59 -58
  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 +65 -26
  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/attachment-vision.d.ts +43 -2
  100. package/dist/shared/attachment-vision.js +72 -6
  101. package/dist/shared/attachment-vision.js.map +1 -1
  102. package/dist/shared/caller-identity.d.ts +23 -2
  103. package/dist/shared/caller-identity.js +36 -5
  104. package/dist/shared/caller-identity.js.map +1 -1
  105. package/dist/shared/channel-attachment.js +1 -0
  106. package/dist/shared/channel-attachment.js.map +1 -1
  107. package/dist/shared/checkpointer/http-saver.d.ts +42 -1
  108. package/dist/shared/checkpointer/http-saver.js +96 -8
  109. package/dist/shared/checkpointer/http-saver.js.map +1 -1
  110. package/dist/shared/conversation-attachment.js +1 -0
  111. package/dist/shared/conversation-attachment.js.map +1 -1
  112. package/dist/shared/datastore-attachment.d.ts +50 -7
  113. package/dist/shared/datastore-attachment.js +93 -11
  114. package/dist/shared/datastore-attachment.js.map +1 -1
  115. package/dist/shared/http-retry.d.ts +43 -0
  116. package/dist/shared/http-retry.js +50 -0
  117. package/dist/shared/http-retry.js.map +1 -0
  118. package/dist/shared/llm-backend.d.ts +275 -0
  119. package/dist/shared/llm-backend.js +425 -0
  120. package/dist/shared/llm-backend.js.map +1 -0
  121. package/dist/shared/llm-proxy.d.ts +8 -0
  122. package/dist/shared/llm-proxy.js +15 -0
  123. package/dist/shared/llm-proxy.js.map +1 -1
  124. package/dist/shared/mcp-enabled-tools.d.ts +57 -0
  125. package/dist/shared/mcp-enabled-tools.js +86 -0
  126. package/dist/shared/mcp-enabled-tools.js.map +1 -0
  127. package/dist/shared/mcp-manager.d.ts +17 -2
  128. package/dist/shared/mcp-manager.js +37 -25
  129. package/dist/shared/mcp-manager.js.map +1 -1
  130. package/dist/shared/mcp-resolver.d.ts +39 -2
  131. package/dist/shared/mcp-resolver.js +38 -2
  132. package/dist/shared/mcp-resolver.js.map +1 -1
  133. package/dist/shared/model-client.d.ts +12 -5
  134. package/dist/shared/model-client.js +138 -18
  135. package/dist/shared/model-client.js.map +1 -1
  136. package/dist/shared/model-error.js +198 -5
  137. package/dist/shared/model-error.js.map +1 -1
  138. package/dist/shared/model-registry.d.ts +20 -2
  139. package/dist/shared/model-registry.js +37 -2
  140. package/dist/shared/model-registry.js.map +1 -1
  141. package/dist/shared/plan-mode-permissions.d.ts +26 -0
  142. package/dist/shared/plan-mode-permissions.js +28 -0
  143. package/dist/shared/plan-mode-permissions.js.map +1 -0
  144. package/dist/worker.d.ts +2 -1
  145. package/dist/worker.js +2 -4
  146. package/dist/worker.js.map +1 -1
  147. package/dist/workflow-engine/types.d.ts +18 -0
  148. package/dist/workflow-engine/types.js.map +1 -1
  149. package/dist/workflows/call-agent-orchestrator.d.ts +9 -0
  150. package/dist/workflows/call-agent-orchestrator.js +1 -0
  151. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  152. package/dist/workflows/connect-mcp-server.js +7 -0
  153. package/dist/workflows/connect-mcp-server.js.map +1 -1
  154. package/dist/workflows/engine-core.js +23 -2
  155. package/dist/workflows/engine-core.js.map +1 -1
  156. package/dist/workflows/execute-from-execution.d.ts +1 -1
  157. package/dist/workflows/execute-from-execution.js +11 -1
  158. package/dist/workflows/execute-from-execution.js.map +1 -1
  159. package/package.json +8 -2
  160. package/src/__tests__/claimcheck-codec.test.ts +36 -0
  161. package/src/__tests__/encryption-codec.test.ts +234 -0
  162. package/src/__tests__/fixtures/encrypted-payload-fixture.json +15 -0
  163. package/src/__tests__/history-encryption-e2e.test.ts +243 -0
  164. package/src/__tests__/preflight.test.ts +50 -2
  165. package/src/activities/__tests__/call-llm.test.ts +75 -0
  166. package/src/activities/__tests__/classify-tool-approvals.test.ts +117 -1
  167. package/src/activities/__tests__/discover-mcp-server.hang.test.ts +103 -0
  168. package/src/activities/__tests__/discover-mcp-server.test.ts +203 -0
  169. package/src/activities/__tests__/workflow-event-activities.test.ts +107 -8
  170. package/src/activities/call-llm.ts +9 -16
  171. package/src/activities/classify-tool-approvals.ts +34 -4
  172. package/src/activities/discover-mcp-server.ts +190 -32
  173. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +9 -0
  174. package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +14 -0
  175. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +92 -0
  176. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +40 -14
  177. package/src/activities/execute-cursor/__tests__/error-classifier-extraction.test.ts +208 -0
  178. package/src/activities/execute-cursor/__tests__/extract-structured-output.test.ts +120 -0
  179. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +93 -0
  180. package/src/activities/execute-cursor/__tests__/mcp-resolver.test.ts +125 -0
  181. package/src/activities/execute-cursor/__tests__/prompt-builder-delegation.test.ts +1 -1
  182. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +13 -0
  183. package/src/activities/execute-cursor/approval-state.ts +30 -1
  184. package/src/activities/execute-cursor/attachment-resolver.ts +38 -3
  185. package/src/activities/execute-cursor/blueprint-resolver.ts +7 -27
  186. package/src/activities/execute-cursor/env-resolver.ts +3 -1
  187. package/src/activities/execute-cursor/error-classifier.ts +91 -4
  188. package/src/activities/execute-cursor/extract-structured-output.ts +72 -0
  189. package/src/activities/execute-cursor/hook-script.ts +74 -10
  190. package/src/activities/execute-cursor/index.ts +63 -72
  191. package/src/activities/execute-cursor/mcp-resolver.ts +36 -2
  192. package/src/activities/execute-cursor/prompt-builder.ts +34 -9
  193. package/src/activities/execute-cursor/turn-stream.ts +5 -2
  194. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +132 -8
  195. package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +104 -0
  196. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +2 -0
  197. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  198. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  199. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +34 -5
  200. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  201. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +173 -0
  202. package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +12 -7
  203. package/src/activities/execute-deep-agent/attachment-injector.ts +94 -30
  204. package/src/activities/execute-deep-agent/environment.ts +3 -1
  205. package/src/activities/execute-deep-agent/index.ts +20 -0
  206. package/src/activities/execute-deep-agent/prompt-builder.ts +20 -10
  207. package/src/activities/execute-deep-agent/setup.ts +86 -31
  208. package/src/activities/execute-deep-agent/subagent-transformer.ts +23 -1
  209. package/src/activities/execute-deep-agent/subagent-wiring.ts +14 -4
  210. package/src/activities/hydrate-workflow-execution.ts +3 -1
  211. package/src/activities/workflow-event-activities.ts +96 -69
  212. package/src/claimcheck/payload-codec.ts +33 -1
  213. package/src/client/__tests__/stigmer-client.test.ts +8 -8
  214. package/src/client/stigmer-client.ts +32 -18
  215. package/src/encryption/config.ts +91 -0
  216. package/src/encryption/index.ts +3 -0
  217. package/src/encryption/payload-codec.ts +152 -0
  218. package/src/payload-codecs.ts +56 -0
  219. package/src/preflight.ts +45 -0
  220. package/src/runner-manager.ts +6 -24
  221. package/src/runner.ts +6 -25
  222. package/src/shared/__tests__/approval-policy.test.ts +82 -39
  223. package/src/shared/__tests__/attachment-naming.test.ts +159 -0
  224. package/src/shared/__tests__/attachment-vision.test.ts +97 -0
  225. package/src/shared/__tests__/bedrock-adapter.test.ts +213 -0
  226. package/src/shared/__tests__/bedrock-seam.test.ts +390 -0
  227. package/src/shared/__tests__/caller-identity.test.ts +25 -0
  228. package/src/shared/__tests__/channel-attachment.test.ts +1 -1
  229. package/src/shared/__tests__/connect-backfill.test.ts +1 -0
  230. package/src/shared/__tests__/conversation-attachment.test.ts +1 -1
  231. package/src/shared/__tests__/datastore-attachment.test.ts +129 -1
  232. package/src/shared/__tests__/foundry-adapter.test.ts +276 -0
  233. package/src/shared/__tests__/foundry-seam.test.ts +482 -0
  234. package/src/shared/__tests__/http-retry.test.ts +67 -0
  235. package/src/shared/__tests__/llm-backend.test.ts +616 -0
  236. package/src/shared/__tests__/mcp-enabled-tools.test.ts +86 -0
  237. package/src/shared/__tests__/mcp-manager.test.ts +170 -2
  238. package/src/shared/__tests__/mcp-resolver.test.ts +146 -3
  239. package/src/shared/__tests__/model-client.test.ts +154 -0
  240. package/src/shared/__tests__/model-error.test.ts +289 -1
  241. package/src/shared/__tests__/model-registry.test.ts +71 -0
  242. package/src/shared/__tests__/synthesized-attachment.test.ts +1 -0
  243. package/src/shared/__tests__/vertex-adapter.test.ts +169 -0
  244. package/src/shared/__tests__/vertex-seam.test.ts +295 -0
  245. package/src/shared/approval-policy.ts +14 -7
  246. package/src/shared/attachment-naming.ts +78 -0
  247. package/src/shared/attachment-vision.ts +92 -9
  248. package/src/shared/caller-identity.ts +40 -5
  249. package/src/shared/channel-attachment.ts +1 -0
  250. package/src/shared/checkpointer/__tests__/http-saver.test.ts +196 -1
  251. package/src/shared/checkpointer/http-saver.ts +117 -9
  252. package/src/shared/conversation-attachment.ts +1 -0
  253. package/src/shared/datastore-attachment.ts +106 -11
  254. package/src/shared/http-retry.ts +50 -0
  255. package/src/shared/llm-backend.ts +544 -0
  256. package/src/shared/llm-proxy.ts +15 -0
  257. package/src/shared/mcp-enabled-tools.ts +105 -0
  258. package/src/shared/mcp-manager.ts +43 -26
  259. package/src/shared/mcp-resolver.ts +73 -2
  260. package/src/shared/model-client.ts +161 -19
  261. package/src/shared/model-error.ts +222 -4
  262. package/src/shared/model-registry.ts +50 -2
  263. package/src/shared/plan-mode-permissions.ts +30 -0
  264. package/src/worker.ts +4 -5
  265. package/src/workflow-engine/types.ts +18 -0
  266. package/src/workflows/__tests__/execute-serverless-workflow.test.ts +68 -2
  267. package/src/workflows/call-agent-orchestrator.ts +10 -0
  268. package/src/workflows/connect-mcp-server.ts +7 -0
  269. package/src/workflows/engine-core.ts +23 -2
  270. package/src/workflows/execute-from-execution.ts +12 -2
@@ -64,6 +64,7 @@ import {
64
64
  toCursorImages,
65
65
  type NotViewableEntry,
66
66
  } from "../../shared/attachment-vision.js";
67
+ import { getModelVisionCapability } from "../../shared/model-registry.js";
67
68
  import { publishPlanArtifact } from "../../shared/plan-artifact.js";
68
69
  import { DeltaEnricher } from "./delta-enricher.js";
69
70
  import { TodoTracker } from "./todo-tracker.js";
@@ -83,6 +84,7 @@ import {
83
84
  import { injectSynthesizedAttachment } from "../../shared/synthesized-attachment.js";
84
85
  import { mergeApprovalPolicies } from "./approval-policy.js";
85
86
  import { deriveActiveLeases, isUnattendedApprovalMode } from "../../shared/approval-policy.js";
87
+ import { enabledToolsBySlug } from "../../shared/mcp-enabled-tools.js";
86
88
  import { backfillMcpServersIfNeeded } from "./connect-backfill.js";
87
89
  import { resolveExecutionEnv } from "./env-resolver.js";
88
90
  import { resolveBlueprint } from "./blueprint-resolver.js";
@@ -138,7 +140,7 @@ import { StreamingUsageSummarySchema } from "@stigmer/protos/ai/stigmer/agentic/
138
140
  import { activityStarted, activityFinished } from "../../idle-watchdog.js";
139
141
  import { normalizeActivityInput, type ExecuteActivityInput } from "../../shared/activity-input.js";
140
142
  import { getCapturedRejection, clearCapturedRejection } from "./rejection-capture.js";
141
- import { synthesizeError, formatClassifiedError, shouldRetryWithFreshAgent } from "./error-classifier.js";
143
+ import { synthesizeError, formatClassifiedError, shouldRetryWithFreshAgent, extractRunErrorSources } from "./error-classifier.js";
142
144
  import type { ClassifiedError } from "./error-classifier.js";
143
145
  import { createAgent, createCloudAgent } from "./session-lifecycle.js";
144
146
  import { setMaxListeners } from "node:events";
@@ -643,9 +645,21 @@ async function executeCursorInner(
643
645
  // the messaging reach; undefined lets a cloud sandbox runner's
644
646
  // ambient session-scoped token or OSS's no-auth apply). The
645
647
  // attachment header falls back to the ambient credential where no
646
- // exchange happens.
647
- const exchangedRunnerToken =
648
- await client.acquireScopedRunnerToken({ agentExecutionId: executionId });
648
+ // exchange happens. Unlike the env read (which hard-fails on a broken
649
+ // exchange — secrets are load-bearing there), this exchange is
650
+ // opportunistic: every consumer below degrades to an empty answer by
651
+ // contract, and the server refuses the ambient fallback safely, so a
652
+ // failed exchange must not kill the run.
653
+ let exchangedRunnerToken: string | undefined;
654
+ try {
655
+ exchangedRunnerToken =
656
+ await client.acquireScopedRunnerToken({ agentExecutionId: executionId });
657
+ } catch (err) {
658
+ console.warn(
659
+ "[execute-cursor] Scoped-token exchange failed for attachment/discovery " +
660
+ `reads; degrading to the ambient credential: ${err instanceof Error ? err.message : err}`,
661
+ );
662
+ }
649
663
  const attachmentCredential = exchangedRunnerToken
650
664
  ?? config.stigmerTokenRef?.current
651
665
  ?? config.stigmerToken;
@@ -733,11 +747,11 @@ async function executeCursorInner(
733
747
  // them as auto-approved); the global bypass empties the map entirely.
734
748
  const leases = deriveActiveLeases(execution);
735
749
  const globalBypass = leases.global;
736
- const agentOverrides = blueprint.mergedMcpServerUsages
737
- .flatMap((u) => u.toolApprovalOverrides ?? []);
750
+ // Layer-3 overrides ride each resolved server from its merged usage —
751
+ // see ResolvedMcpServer.toolApprovalOverrides (issue #349) — so there
752
+ // is no separate override input to pass here.
738
753
  const mergedPolicies = mergeApprovalPolicies(
739
754
  mcpResolution.resolvedServers,
740
- agentOverrides,
741
755
  leases,
742
756
  );
743
757
  heartbeat();
@@ -770,7 +784,13 @@ async function executeCursorInner(
770
784
  // artifactStorage resolved for status offload above. The vision budget
771
785
  // rides along so image attachments are selected for inline delivery while
772
786
  // their bytes are already in hand (attachment-vision.ts owns all policy).
773
- const visionBudget = new VisionBudget(CURSOR_VISION_PROFILE);
787
+ // The budget also carries the requested model's registry vision
788
+ // capability, looked up from the raw executionConfig name — full model
789
+ // validation (Phase 6) isn't needed for this, and ""/"default" (the Auto
790
+ // pool) resolves to unknown, which the policy treats as sighted.
791
+ const visionBudget = new VisionBudget(CURSOR_VISION_PROFILE, {
792
+ modelVision: await getModelVisionCapability(spec.executionConfig?.modelName ?? ""),
793
+ });
774
794
  const attachmentResults = await resolveAttachments(spec.attachments, {
775
795
  sessionId,
776
796
  primaryWorkspaceDir,
@@ -778,7 +798,10 @@ async function executeCursorInner(
778
798
  storage: artifactStorage,
779
799
  visionBudget,
780
800
  });
781
- const attachmentPaths = attachmentResults.map((a) => a.relativePath);
801
+ const attachmentEntries = attachmentResults.map((a) => ({
802
+ path: a.relativePath,
803
+ ...(a.renamedFrom !== undefined ? { renamedFrom: a.renamedFrom } : {}),
804
+ }));
782
805
  // Vision facts, derived once from the single resolution result: the
783
806
  // images the model will see inline (in attachment order) and the ones
784
807
  // that degraded to path-only, disclosed in the prompt.
@@ -899,6 +922,11 @@ async function executeCursorInner(
899
922
  captureIgnored,
900
923
  gitWorkspace,
901
924
  isUnattendedApprovalMode(execution),
925
+ // The enabled_tools capability manifest (issue #350): restricted
926
+ // servers' allow-lists, enforced by the hook's "disabled" arm ahead of
927
+ // every approval bypass. The Cursor SDK config cannot hide a server's
928
+ // tools, so this deny-at-call is the harness's enforcement.
929
+ enabledToolsBySlug(mcpResolution.resolvedServers),
902
930
  );
903
931
  const hitlGate = await installHitlGate({
904
932
  workspaceRoot: primaryWorkspaceDir,
@@ -1123,7 +1151,7 @@ async function executeCursorInner(
1123
1151
  subAgents: blueprint.subAgents,
1124
1152
  workspaceDirs: blueprint.workspaceDirs,
1125
1153
  workspaceFileRefs: spec.workspaceFileRefs ?? [],
1126
- attachmentPaths,
1154
+ attachments: attachmentEntries,
1127
1155
  vision: visionPromptInfo,
1128
1156
  pendingApprovals: adjudicatedApprovals,
1129
1157
  appliedToolCallIds,
@@ -1714,12 +1742,10 @@ async function executeCursorInner(
1714
1742
  status.phase = ExecutionPhase.EXECUTION_COMPLETED;
1715
1743
  break;
1716
1744
  case "error": {
1717
- const resultAny = result as unknown as Record<string, unknown>;
1718
- const sdkError = result.result
1719
- ?? resultAny.error
1720
- ?? resultAny.message
1721
- ?? resultAny.reason;
1722
- const sdkErrorStr = sdkError ? String(sdkError) : undefined;
1745
+ // Shape-aware extraction, NOT String(): the result's error fields are
1746
+ // structured at runtime often enough that a bare coercion showed users
1747
+ // "[object Object]" and shadowed every fallback source below (oss#299).
1748
+ const runErrorSources = extractRunErrorSources(result);
1723
1749
 
1724
1750
  // The SDK frequently resolves run.wait() to a bare { status: "error" }
1725
1751
  // while the real reason (e.g. the original grpc-status 12 routing
@@ -1731,7 +1757,8 @@ async function executeCursorInner(
1731
1757
  if (capturedRejection) clearCapturedRejection(executionId);
1732
1758
 
1733
1759
  const classified = synthesizeError({
1734
- sdkResultFields: sdkErrorStr,
1760
+ sdkError: runErrorSources.sdkError,
1761
+ sdkResultFields: runErrorSources.sdkResultFields,
1735
1762
  streamErrorMessage: turnState.streamErrorMessage,
1736
1763
  capturedRejection,
1737
1764
  conversationErrorText,
@@ -1782,7 +1809,7 @@ async function executeCursorInner(
1782
1809
  subAgents: blueprint.subAgents,
1783
1810
  workspaceDirs: blueprint.workspaceDirs,
1784
1811
  workspaceFileRefs: spec.workspaceFileRefs ?? [],
1785
- attachmentPaths,
1812
+ attachments: attachmentEntries,
1786
1813
  vision: visionPromptInfo,
1787
1814
  pendingApprovals: adjudicatedApprovals,
1788
1815
  interactionMode,
@@ -1849,8 +1876,14 @@ async function executeCursorInner(
1849
1876
 
1850
1877
  const retryConversationErrorText = await introspectConversation(retryRun, executionId);
1851
1878
 
1879
+ // Same shape-aware extraction as the primary error arm — the retry
1880
+ // previously String()-coerced result.result alone, so a structured
1881
+ // retry failure both read "[object Object]" and ignored the
1882
+ // error/message/reason fields the primary arm consults.
1883
+ const retryErrorSources = extractRunErrorSources(retryResult);
1852
1884
  const retryClassified = synthesizeError({
1853
- sdkResultFields: retryResult.result ? String(retryResult.result) : undefined,
1885
+ sdkError: retryErrorSources.sdkError,
1886
+ sdkResultFields: retryErrorSources.sdkResultFields,
1854
1887
  streamErrorMessage: turnState.streamErrorMessage,
1855
1888
  capturedRejection: retryRejection,
1856
1889
  conversationErrorText: retryConversationErrorText,
@@ -1967,6 +2000,7 @@ async function executeCursorInner(
1967
2000
  `finalTextLength=${finalText.length}`,
1968
2001
  );
1969
2002
  try {
2003
+ const { extractStructuredOutput } = await import("./extract-structured-output.js");
1970
2004
  structuredOutput = await extractStructuredOutput(
1971
2005
  finalText, structuredOutputSchema, config, requestedModel,
1972
2006
  );
@@ -2291,53 +2325,6 @@ function seedCursorTranscriptFromExecution(
2291
2325
  return persisted.subAgentExecutions.map((sub) => clone(SubAgentExecutionSchema, sub));
2292
2326
  }
2293
2327
 
2294
- // ---------------------------------------------------------------------------
2295
- // Structured Output Extraction (Cursor Harness Tier 2)
2296
- // ---------------------------------------------------------------------------
2297
-
2298
- /**
2299
- * Extract structured data from an agent's free-text response using an
2300
- * economy-tier LLM with withStructuredOutput (function-calling).
2301
- * Guarantees schema-conformant JSON output via the API's tool-use mechanism.
2302
- *
2303
- * Construction (registry-id resolution, provider inference, proxy wiring) is
2304
- * delegated to the shared buildChatModel so the economy model's registry id is
2305
- * always resolved to a provider API id before the call.
2306
- */
2307
- async function extractStructuredOutput(
2308
- agentResponse: string,
2309
- schema: Record<string, unknown>,
2310
- config: Config,
2311
- primaryModel: string,
2312
- ): Promise<unknown | null> {
2313
- const { getEconomyModel } = await import("../../shared/model-registry.js");
2314
- const { buildChatModel } = await import("../../shared/model-client.js");
2315
-
2316
- const extractionModel = await getEconomyModel(primaryModel);
2317
- const proxyEndpoint = config.proxyEndpoint ?? config.stigmerBackendEndpoint;
2318
-
2319
- const { model: llm } = await buildChatModel({
2320
- modelName: extractionModel,
2321
- proxyEndpoint,
2322
- stigmerToken: config.stigmerToken ?? undefined,
2323
- maxTokens: 4096,
2324
- });
2325
-
2326
- const zodSchema = jsonSchemaToZod(schema);
2327
- const structured = llm.withStructuredOutput(zodSchema);
2328
-
2329
- const result = await structured.invoke([
2330
- { role: "system", content: "Extract the structured data from the agent's response. Return only the data that matches the schema." },
2331
- { role: "user", content: agentResponse },
2332
- ]);
2333
-
2334
- return result ?? null;
2335
- }
2336
-
2337
- // Re-export for use within this module; shared implementation eliminates
2338
- // the three duplicate converters that previously drifted independently.
2339
- import { jsonSchemaToZod } from "../../shared/json-schema-to-zod.js";
2340
-
2341
2328
  // ---------------------------------------------------------------------------
2342
2329
  // Prompt selection
2343
2330
  // ---------------------------------------------------------------------------
@@ -2358,7 +2345,11 @@ export interface BuildPromptInput {
2358
2345
  subAgents: import("@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb").SubAgent[];
2359
2346
  workspaceDirs: string[];
2360
2347
  workspaceFileRefs: string[];
2361
- attachmentPaths: string[];
2348
+ /**
2349
+ * This turn's resolved attachments for the `<input_files>` section —
2350
+ * final paths plus duplicate-rename disclosure (attachment-resolver.ts).
2351
+ */
2352
+ attachments: import("./prompt-builder.js").AttachmentPromptEntry[];
2362
2353
  /**
2363
2354
  * Vision facts for the input-files section (T04): which attachments the
2364
2355
  * model sees inline and which degraded to path-only. PER-TURN like the
@@ -2451,7 +2442,7 @@ export function buildPrompt(input: BuildPromptInput): string {
2451
2442
  subAgents,
2452
2443
  workspaceDirs,
2453
2444
  workspaceFileRefs,
2454
- attachmentPaths,
2445
+ attachments,
2455
2446
  interactionMode,
2456
2447
  buildFromPlan,
2457
2448
  conversationCatchup,
@@ -2485,9 +2476,9 @@ export function buildPrompt(input: BuildPromptInput): string {
2485
2476
  if (resolution.reason === "resumed_successfully") {
2486
2477
  const prefixes = [
2487
2478
  formatInteractionModePrefix(interactionMode),
2488
- formatImplementPlanSection(buildFromPlan, attachmentPaths),
2489
- attachmentPaths.length > 0
2490
- ? formatInputFiles(attachmentPaths, input.vision)
2479
+ formatImplementPlanSection(buildFromPlan, attachments),
2480
+ attachments.length > 0
2481
+ ? formatInputFiles(attachments, input.vision)
2491
2482
  : undefined,
2492
2483
  conversationCatchup !== undefined
2493
2484
  ? formatConversationCatchupSection(conversationCatchup)
@@ -2510,7 +2501,7 @@ export function buildPrompt(input: BuildPromptInput): string {
2510
2501
  subAgents,
2511
2502
  workspaceDirs,
2512
2503
  workspaceFileRefs,
2513
- attachmentPaths,
2504
+ attachments,
2514
2505
  vision: input.vision,
2515
2506
  interactionMode,
2516
2507
  buildFromPlan,
@@ -14,7 +14,7 @@
14
14
  * same pipeline as the Python agent-runner's config_transformer.py.
15
15
  */
16
16
 
17
- import type { McpServerUsage } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
17
+ import type { McpServerUsage, ToolApprovalOverride } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
18
18
  import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
19
19
  import type { ToolApprovalPolicy } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/spec_pb";
20
20
  import type { StigmerClient } from "../../client/stigmer-client.js";
@@ -29,6 +29,7 @@ import {
29
29
  filterEnvToDeclaredKeys,
30
30
  PlaceholderResolutionError,
31
31
  } from "./placeholder-resolver.js";
32
+ import { effectiveEnabledTools } from "../../shared/mcp-enabled-tools.js";
32
33
 
33
34
  /**
34
35
  * Cursor SDK MCP server config shape (matches @cursor/sdk McpServerConfig).
@@ -70,6 +71,29 @@ export interface ResolvedMcpServer {
70
71
  pinnedToolApprovals: ToolApprovalPolicy[];
71
72
  /** True when the server has never been connected (no tool discovery yet). */
72
73
  discoveredCapabilitiesEmpty: boolean;
74
+ /**
75
+ * The EFFECTIVE tool allow-list for this server (issue #350): the usage's
76
+ * enabled_tools, falling back to the server's default_enabled_tools when
77
+ * the usage's list is empty (see shared/mcp-enabled-tools.ts). Absent when
78
+ * unrestricted. The Cursor SDK config cannot hide tools, so this harness
79
+ * enforces it in the HITL hook: the restricted map is written into the
80
+ * approval state (mcpServerEnabledTools) and non-enabled calls are denied
81
+ * with the non-pausing "disabled" kind (see hook-script.ts).
82
+ */
83
+ enabledTools?: string[];
84
+ /**
85
+ * Layer-3 per-agent approval overrides from the usage that resolved this
86
+ * server (issue #349): riding the resolved server is what SCOPES an
87
+ * override to its own server — a flat cross-server list is how an
88
+ * override once leaked onto (or silently un-gated) a same-named tool on
89
+ * another server. Deliberately REQUIRED, not optional: empty means "no
90
+ * overrides", and every construction site — including the shared
91
+ * resolver this one mirrors and the synthesized attachments, which have
92
+ * no usage and therefore no layer 3 — must say so explicitly, so a
93
+ * forgotten mirror cannot compile. Consumed by mergeApprovalPolicies
94
+ * (shared/approval-policy.ts).
95
+ */
96
+ toolApprovalOverrides: ToolApprovalOverride[];
73
97
  }
74
98
 
75
99
  /**
@@ -115,7 +139,13 @@ export async function resolveMcpServers(
115
139
  envVars,
116
140
  ref.slug,
117
141
  );
118
- const server = mcpServerToResolved(mcpServer, ref.slug, serverEnv);
142
+ const server = mcpServerToResolved(
143
+ mcpServer,
144
+ ref.slug,
145
+ serverEnv,
146
+ usage.enabledTools,
147
+ usage.toolApprovalOverrides ?? [],
148
+ );
119
149
  if (server) {
120
150
  assertTransportAllowed(server.slug, server.connectionType, transportPosture);
121
151
  resolved.push(server);
@@ -152,6 +182,8 @@ function mcpServerToResolved(
152
182
  server: McpServer,
153
183
  slug: string,
154
184
  envVars: Record<string, string>,
185
+ usageEnabledTools?: readonly string[],
186
+ usageToolApprovalOverrides: ToolApprovalOverride[] = [],
155
187
  ): ResolvedMcpServer | null {
156
188
  const spec = server.spec;
157
189
  if (!spec) return null;
@@ -167,6 +199,8 @@ function mcpServerToResolved(
167
199
  toolApprovals,
168
200
  pinnedToolApprovals,
169
201
  discoveredCapabilitiesEmpty,
202
+ enabledTools: effectiveEnabledTools(usageEnabledTools, spec.defaultEnabledTools),
203
+ toolApprovalOverrides: usageToolApprovalOverrides,
170
204
  };
171
205
 
172
206
  switch (spec.serverType.case) {
@@ -71,6 +71,18 @@ export interface VisionPromptInfo {
71
71
  notViewable: NotViewableEntry[];
72
72
  }
73
73
 
74
+ /**
75
+ * One resolved attachment as the `<input_files>` section renders it —
76
+ * the final workspace-relative path plus the duplicate-rename disclosure
77
+ * (attachment-resolver.ts / shared/attachment-naming.ts).
78
+ */
79
+ export interface AttachmentPromptEntry {
80
+ /** Workspace-relative path the agent reads (`.stigmer/inputs/{filename}`). */
81
+ path: string;
82
+ /** Original filename, present only when a duplicate name was renamed. */
83
+ renamedFrom?: string;
84
+ }
85
+
74
86
  export interface EnhancedPromptOptions {
75
87
  instructions: string;
76
88
  userMessage: string;
@@ -78,7 +90,10 @@ export interface EnhancedPromptOptions {
78
90
  /**
79
91
  * Datastores attached via `datastore_usages` — rendered as the
80
92
  * `<available_datastores>` section (DD-005 SD-5, skills precedent)
81
- * pointing the model at the synthesized record tools.
93
+ * pointing the model at the synthesized record tools. Always the healthy
94
+ * rendering here: the Cursor SDK connects MCP itself, so this harness can
95
+ * never observe the live roster — the section's standing
96
+ * failure-disclosure instruction is its whole coverage (issue #325).
82
97
  */
83
98
  datastoreUsages?: DatastoreUsage[];
84
99
  /**
@@ -90,7 +105,7 @@ export interface EnhancedPromptOptions {
90
105
  subAgents: SubAgent[];
91
106
  workspaceDirs: string[];
92
107
  workspaceFileRefs: string[];
93
- attachmentPaths: string[];
108
+ attachments: AttachmentPromptEntry[];
94
109
  /** Inline/degraded image facts for the input-files section (T04 vision). */
95
110
  vision?: VisionPromptInfo;
96
111
  interactionMode?: InteractionMode;
@@ -153,7 +168,7 @@ export function buildEnhancedPrompt(options: EnhancedPromptOptions): string {
153
168
 
154
169
  const implementPlan = formatImplementPlanSection(
155
170
  options.buildFromPlan,
156
- options.attachmentPaths,
171
+ options.attachments,
157
172
  );
158
173
  if (implementPlan) {
159
174
  sections.push(implementPlan);
@@ -195,8 +210,8 @@ export function buildEnhancedPrompt(options: EnhancedPromptOptions): string {
195
210
  sections.push(formatWorkspaceContext(safeDirs));
196
211
  }
197
212
 
198
- if (options.attachmentPaths.length > 0) {
199
- sections.push(formatInputFiles(options.attachmentPaths, options.vision));
213
+ if (options.attachments.length > 0) {
214
+ sections.push(formatInputFiles(options.attachments, options.vision));
200
215
  }
201
216
 
202
217
  if (options.workspaceFileRefs.length > 0) {
@@ -479,8 +494,18 @@ export function formatWorkspaceContext(dirs: string[]): string {
479
494
  ].join("\n");
480
495
  }
481
496
 
482
- export function formatInputFiles(paths: string[], vision?: VisionPromptInfo): string {
483
- const entries = paths.map((p) => `- \`${p}\``);
497
+ export function formatInputFiles(
498
+ attachments: readonly AttachmentPromptEntry[],
499
+ vision?: VisionPromptInfo,
500
+ ): string {
501
+ // A duplicate-renamed file (attachment-naming.ts) discloses its original
502
+ // name so the agent can connect "the two report.pdfs" in the user's
503
+ // message to distinct files on disk.
504
+ const entries = attachments.map((a) =>
505
+ a.renamedFrom !== undefined
506
+ ? `- \`${a.path}\` (renamed from duplicate '${a.renamedFrom}')`
507
+ : `- \`${a.path}\``,
508
+ );
484
509
  // The vision lines (shared wording, attachment-vision.ts) tell the model
485
510
  // which of these files it can already SEE inline versus which degraded to
486
511
  // path-only — without them an agent silently ignores a photo the user
@@ -541,14 +566,14 @@ export function formatInteractionModePrefix(
541
566
  */
542
567
  export function formatImplementPlanSection(
543
568
  buildFromPlan: boolean | undefined,
544
- attachmentPaths: string[],
569
+ attachments: readonly AttachmentPromptEntry[],
545
570
  ): string | undefined {
546
571
  if (!buildFromPlan) {
547
572
  return undefined;
548
573
  }
549
574
 
550
575
  const directive = buildImplementPlanDirective(
551
- findApprovedPlanPath(attachmentPaths),
576
+ findApprovedPlanPath(attachments.map((a) => a.path)),
552
577
  );
553
578
  return ["<implement_plan>", directive, "</implement_plan>"].join("\n");
554
579
  }
@@ -387,8 +387,11 @@ export async function consumeCursorTurnStream(
387
387
  console.log(
388
388
  `ExecuteCursor stream status: execution=${executionId}, status=${JSON.stringify(event)}`,
389
389
  );
390
- const statusEvent = event as { status?: string; message?: string };
391
- if (statusEvent.status === "ERROR" && statusEvent.message) {
390
+ const statusEvent = event as { status?: string; message?: unknown };
391
+ // The cast is a claim, not a guarantee: message is untyped at runtime
392
+ // (the oss#299 class of bug). A structured value here would crash
393
+ // classification downstream, so only capture actual text.
394
+ if (statusEvent.status === "ERROR" && typeof statusEvent.message === "string" && statusEvent.message.length > 0) {
392
395
  state.streamErrorMessage = statusEvent.message;
393
396
  }
394
397
  }
@@ -600,24 +600,97 @@ describe("injectAttachments", () => {
600
600
  expect(Buffer.compare(writtenBuffer, binaryContent)).toBe(0);
601
601
  });
602
602
 
603
- it("detects mount path collision before any downloads", async () => {
603
+ it("uniquifies duplicate default-derived filenames instead of failing (issue #364)", async () => {
604
+ const contentA = Buffer.from("first");
605
+ const contentB = Buffer.from("second");
606
+ const storage = makeMockStorage();
607
+ await storage.upload("attachments/a/data.csv", contentA);
608
+ await storage.upload("attachments/b/data.csv", contentB);
609
+
610
+ const backend = mockWorkspaceBackend();
611
+
612
+ const result = await injectAttachments({
613
+ backend,
614
+ attachments: [
615
+ makeAttachment({ filename: "data.csv", storageKey: "attachments/a/data.csv" }),
616
+ makeAttachment({ filename: "data.csv", storageKey: "attachments/b/data.csv" }),
617
+ ],
618
+ storage,
619
+ isLocalMode: false,
620
+ });
621
+
622
+ // Both files land, both downloads run — the execution is never burned
623
+ // over a mechanically resolvable name.
624
+ expect(result).toHaveLength(2);
625
+ expect(result[0]).toMatchObject({
626
+ filename: "data.csv",
627
+ path: ".stigmer/inputs/data.csv",
628
+ });
629
+ expect(result[0].renamedFrom).toBeUndefined();
630
+ // The rename follows the platform's stem-2.ext semantics and carries the
631
+ // disclosure payload for the prompt.
632
+ expect(result[1]).toMatchObject({
633
+ filename: "data-2.csv",
634
+ path: ".stigmer/inputs/data-2.csv",
635
+ renamedFrom: "data.csv",
636
+ });
637
+ expect(backend.writeFileBuffer).toHaveBeenCalledWith(
638
+ ".stigmer/inputs/data.csv", contentA,
639
+ );
640
+ expect(backend.writeFileBuffer).toHaveBeenCalledWith(
641
+ ".stigmer/inputs/data-2.csv", contentB,
642
+ );
643
+ });
644
+
645
+ it("rejects two attachments explicitly pinning the same mountPath (a user contradiction)", async () => {
604
646
  const backend = mockWorkspaceBackend();
605
647
  const storage = makeMockStorage();
606
648
 
607
649
  await expect(injectAttachments({
608
650
  backend,
609
651
  attachments: [
610
- makeAttachment({ filename: "data.csv", storageKey: "key1" }),
611
- makeAttachment({ filename: "data.csv", storageKey: "key2" }),
652
+ makeAttachment({ filename: "a.csv", storageKey: "key1", mountPath: "inputs/data.csv" }),
653
+ makeAttachment({ filename: "b.csv", storageKey: "key2", mountPath: "inputs/data.csv" }),
612
654
  ],
613
655
  storage,
614
656
  isLocalMode: false,
615
657
  })).rejects.toThrow(/collides with/);
616
658
 
617
- // Verify no downloads were attempted
659
+ // The contradiction is detected before any downloads
618
660
  expect(storage.download).not.toHaveBeenCalled();
619
661
  });
620
662
 
663
+ it("default-derived names dodge an explicit mountPath already inside the inputs prefix", async () => {
664
+ const storage = makeMockStorage();
665
+ await storage.upload("attachments/a/pinned", Buffer.from("pinned"));
666
+ await storage.upload("attachments/b/report.pdf", Buffer.from("derived"));
667
+
668
+ const backend = mockWorkspaceBackend();
669
+
670
+ const result = await injectAttachments({
671
+ backend,
672
+ attachments: [
673
+ // Listed AFTER the default-derived attachment: explicit paths claim
674
+ // first regardless of order, so the default still dodges.
675
+ makeAttachment({ filename: "report.pdf", storageKey: "attachments/b/report.pdf" }),
676
+ makeAttachment({
677
+ filename: "report.pdf",
678
+ storageKey: "attachments/a/pinned",
679
+ mountPath: ".stigmer/inputs/report.pdf",
680
+ }),
681
+ ],
682
+ storage,
683
+ isLocalMode: false,
684
+ });
685
+
686
+ const byPath = new Map(result.map((f) => [f.path, f]));
687
+ expect(byPath.get(".stigmer/inputs/report.pdf")?.renamedFrom).toBeUndefined();
688
+ expect(byPath.get(".stigmer/inputs/report-2.pdf")).toMatchObject({
689
+ filename: "report-2.pdf",
690
+ renamedFrom: "report.pdf",
691
+ });
692
+ });
693
+
621
694
  it("derives filename from storageKey when filename is empty", async () => {
622
695
  const content = Buffer.from("fallback");
623
696
  const localFile = join(tempDir, "fallback.txt");
@@ -662,7 +735,7 @@ describe("injectAttachments", () => {
662
735
  expect(result[0].path).toBe("workspace/input.csv");
663
736
  });
664
737
 
665
- it("error message includes attachment filename and actionable suggestion", async () => {
738
+ it("explicit-collision error names both attachments and stays actionable", async () => {
666
739
  const backend = mockWorkspaceBackend();
667
740
  const storage = makeMockStorage();
668
741
 
@@ -670,8 +743,8 @@ describe("injectAttachments", () => {
670
743
  await injectAttachments({
671
744
  backend,
672
745
  attachments: [
673
- makeAttachment({ filename: "first.csv" }),
674
- makeAttachment({ filename: "first.csv", storageKey: "key2" }),
746
+ makeAttachment({ filename: "first.csv", mountPath: "inputs/shared.csv" }),
747
+ makeAttachment({ filename: "second.csv", storageKey: "key2", mountPath: "inputs/shared.csv" }),
675
748
  ],
676
749
  storage,
677
750
  isLocalMode: false,
@@ -680,7 +753,7 @@ describe("injectAttachments", () => {
680
753
  } catch (err) {
681
754
  expect(err).toBeInstanceOf(AttachmentInjectionError);
682
755
  const injErr = err as AttachmentInjectionError;
683
- expect(injErr.attachmentFilename).toBe("first.csv");
756
+ expect(injErr.attachmentFilename).toBe("second.csv");
684
757
  expect(injErr.message).toContain("first.csv");
685
758
  expect(injErr.message).toContain("mountPath");
686
759
  }
@@ -776,6 +849,28 @@ describe("injectAttachments — vision selection", () => {
776
849
  expect(backend.writeFileBuffer).toHaveBeenCalledWith(".stigmer/inputs/photo.png", PNG_BYTES);
777
850
  });
778
851
 
852
+ it("degrades an image with model_no_vision on a blind model, file written intact", async () => {
853
+ const backend = mockWorkspaceBackend();
854
+ const storage = makeMockStorage();
855
+ await storage.upload("attachments/abc/photo.png", PNG_BYTES);
856
+
857
+ const [injected] = await injectAttachments({
858
+ backend,
859
+ attachments: [makeAttachment({
860
+ filename: "photo.png",
861
+ storageKey: "attachments/abc/photo.png",
862
+ contentType: "image/png",
863
+ })],
864
+ storage,
865
+ isLocalMode: false,
866
+ visionBudget: new VisionBudget(DEEP_AGENT_VISION_PROFILE, { modelVision: false }),
867
+ });
868
+
869
+ expect(injected.vision).toBeUndefined();
870
+ expect(injected.visionDegraded).toBe("model_no_vision");
871
+ expect(backend.writeFileBuffer).toHaveBeenCalledWith(".stigmer/inputs/photo.png", PNG_BYTES);
872
+ });
873
+
779
874
  it("accepts WebP on the deep-agent profile (unlike the Cursor harness)", async () => {
780
875
  const backend = mockWorkspaceBackend();
781
876
  const storage = makeMockStorage();
@@ -838,6 +933,35 @@ describe("injectAttachments — vision selection", () => {
838
933
  expect(injected.visionDegraded).toBe("type_mismatch");
839
934
  });
840
935
 
936
+ it("labels a duplicate-renamed image's vision payload with the on-disk name", async () => {
937
+ // Coherence pin: the prompt lists the renamed path and the vision
938
+ // disclosure lists image filenames — if the offer used the original
939
+ // attachment.filename, the agent would see two images with one
940
+ // indistinguishable name.
941
+ const backend = mockWorkspaceBackend();
942
+ const storage = makeMockStorage();
943
+ await storage.upload("attachments/a/photo.png", PNG_BYTES);
944
+ await storage.upload("attachments/b/photo.png", PNG_BYTES);
945
+
946
+ const injected = await injectAttachments({
947
+ backend,
948
+ attachments: [
949
+ makeAttachment({ filename: "photo.png", storageKey: "attachments/a/photo.png", contentType: "image/png" }),
950
+ makeAttachment({ filename: "photo.png", storageKey: "attachments/b/photo.png", contentType: "image/png" }),
951
+ ],
952
+ storage,
953
+ isLocalMode: false,
954
+ visionBudget: new VisionBudget(DEEP_AGENT_VISION_PROFILE),
955
+ });
956
+
957
+ expect(injected[0].vision?.filename).toBe("photo.png");
958
+ expect(injected[1]).toMatchObject({
959
+ filename: "photo-2.png",
960
+ renamedFrom: "photo.png",
961
+ });
962
+ expect(injected[1].vision?.filename).toBe("photo-2.png");
963
+ });
964
+
841
965
  it("never offers an extract archive to the budget — extracted files carry no vision fields", async () => {
842
966
  // A PNG inside a ZIP has no attachment-level bytes; the archive rides the
843
967
  // normal extraction story with no vision involvement or disclosure.