@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
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Characterization test for the ChatAnthropic `createClient` -> AnthropicVertex
3
+ * seam — the integration T02's vertex backend adapter will be built on.
4
+ *
5
+ * This is NOT a unit test of our code (there is no production vertex code
6
+ * yet). It pins the exact cross-package behavior production will rely on:
7
+ * the REAL `ChatAnthropic` (@langchain/anthropic, bundling @anthropic-ai/sdk
8
+ * 0.95.x) driving the REAL `AnthropicVertex` client (@anthropic-ai/vertex-sdk,
9
+ * bundling its own nested @anthropic-ai/sdk >=0.115). If a future bump of
10
+ * either side changes request shaping, streaming event handling, tool-call
11
+ * assembly, or usage accounting across this seam, this suite fails in CI
12
+ * instead of production. See scripts/check-langchain-deps.sh for why two
13
+ * @anthropic-ai/sdk copies coexist and when they collapse to one.
14
+ *
15
+ * Determinism: zero credentials, zero network. Google auth is bypassed by
16
+ * injecting a fake `authClient` (the SDK's supported constructor option —
17
+ * `accessToken` alone does NOT skip the auth client, adaptRequest always
18
+ * awaits it), and transport is a recording `fetch` injected through the
19
+ * SDK's own `fetch` option. No global patching, no SDK mocks.
20
+ */
21
+
22
+ import { describe, it, expect } from "vitest";
23
+ import { ChatAnthropic } from "@langchain/anthropic";
24
+ import { AnthropicVertex } from "@anthropic-ai/vertex-sdk";
25
+ import type { AuthClient } from "google-auth-library";
26
+ import { HumanMessage, AIMessage, AIMessageChunk } from "@langchain/core/messages";
27
+
28
+ // ─── Fixtures ────────────────────────────────────────────────────────────────
29
+
30
+ /** Dated pre-4.6 id in Vertex's `@date` form (see llm-backend.ts translation). */
31
+ const VERTEX_MODEL_ID = "claude-sonnet-4-5@20250929";
32
+ const PROJECT_ID = "test-project";
33
+ const REGION = "asia-south1";
34
+
35
+ const WEATHER_TOOL = {
36
+ name: "get_weather",
37
+ description: "Get the current weather for a city.",
38
+ input_schema: {
39
+ type: "object" as const,
40
+ properties: { city: { type: "string" } },
41
+ required: ["city"],
42
+ },
43
+ };
44
+
45
+ /** Non-streaming (`:rawPredict`) response: text + tool_use + usage. */
46
+ const MESSAGE_RESPONSE = {
47
+ id: "msg_vertex_test_01",
48
+ type: "message",
49
+ role: "assistant",
50
+ model: VERTEX_MODEL_ID,
51
+ content: [
52
+ { type: "text", text: "I'll check the weather." },
53
+ { type: "tool_use", id: "toolu_test_01", name: "get_weather", input: { city: "Chennai" } },
54
+ ],
55
+ stop_reason: "tool_use",
56
+ stop_sequence: null,
57
+ usage: { input_tokens: 25, output_tokens: 17 },
58
+ };
59
+
60
+ /**
61
+ * Streaming (`:streamRawPredict`) response as Anthropic SSE: a text block,
62
+ * a tool_use block assembled from input_json_delta, then cumulative usage in
63
+ * message_delta — the exact event grammar the nested SDK parses.
64
+ */
65
+ const SSE_EVENTS: ReadonlyArray<[string, object]> = [
66
+ ["message_start", {
67
+ type: "message_start",
68
+ message: {
69
+ id: "msg_vertex_test_02", type: "message", role: "assistant",
70
+ model: VERTEX_MODEL_ID, content: [], stop_reason: null, stop_sequence: null,
71
+ usage: { input_tokens: 25, output_tokens: 1 },
72
+ },
73
+ }],
74
+ ["content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }],
75
+ ["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "I'll check the weather." } }],
76
+ ["content_block_stop", { type: "content_block_stop", index: 0 }],
77
+ ["content_block_start", { type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "toolu_test_02", name: "get_weather", input: {} } }],
78
+ ["content_block_delta", { type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: '{"city":"Chennai"}' } }],
79
+ ["content_block_stop", { type: "content_block_stop", index: 1 }],
80
+ ["message_delta", { type: "message_delta", delta: { stop_reason: "tool_use", stop_sequence: null }, usage: { output_tokens: 17 } }],
81
+ ["message_stop", { type: "message_stop" }],
82
+ ];
83
+
84
+ function sseBody(): string {
85
+ return SSE_EVENTS
86
+ .map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
87
+ .join("");
88
+ }
89
+
90
+ // ─── Harness ─────────────────────────────────────────────────────────────────
91
+
92
+ interface RecordedRequest {
93
+ url: string;
94
+ method: string;
95
+ headers: Record<string, string>;
96
+ body: Record<string, unknown>;
97
+ }
98
+
99
+ interface SeamHarness {
100
+ model: ChatAnthropic;
101
+ requests: RecordedRequest[];
102
+ /** Options ChatAnthropic passed to the createClient factory. */
103
+ factoryOptions: Array<{ maxRetries?: number }>;
104
+ /** The AnthropicVertex instances the factory constructed. */
105
+ clients: AnthropicVertex[];
106
+ }
107
+
108
+ /**
109
+ * Build a real ChatAnthropic wired to a real AnthropicVertex through the
110
+ * `createClient` seam, with transport replaced by a recording fetch.
111
+ *
112
+ * The factory honors `maxRetries` from the incoming options: LangChain owns
113
+ * retrying (its AsyncCaller wraps every request) and passes `maxRetries: 0`
114
+ * so the underlying SDK must not retry underneath it. A factory that ignored
115
+ * this would nest the Vertex SDK's default 2 retries inside LangChain's loop,
116
+ * multiplying every transient failure — the T02 adapter must preserve this.
117
+ */
118
+ function buildSeamHarness(): SeamHarness {
119
+ const requests: RecordedRequest[] = [];
120
+ const factoryOptions: SeamHarness["factoryOptions"] = [];
121
+ const clients: AnthropicVertex[] = [];
122
+
123
+ const recordingFetch: typeof fetch = async (input, init) => {
124
+ const headers: Record<string, string> = {};
125
+ new Headers(init?.headers).forEach((value, key) => {
126
+ headers[key] = value;
127
+ });
128
+ requests.push({
129
+ url: String(input),
130
+ method: init?.method ?? "GET",
131
+ headers,
132
+ body: JSON.parse(String(init?.body)) as Record<string, unknown>,
133
+ });
134
+ const streaming = String(input).includes(":streamRawPredict");
135
+ return streaming
136
+ ? new Response(sseBody(), {
137
+ status: 200,
138
+ headers: { "Content-Type": "text/event-stream" },
139
+ })
140
+ : new Response(JSON.stringify(MESSAGE_RESPONSE), {
141
+ status: 200,
142
+ headers: { "Content-Type": "application/json" },
143
+ });
144
+ };
145
+
146
+ // Minimal structural fake of google-auth-library's AuthClient — the Vertex
147
+ // SDK's adaptRequest calls exactly `getRequestHeaders()` (merged into the
148
+ // outbound request) and reads `projectId`. The cast is test-only: building
149
+ // a real AuthClient would require credentials, defeating determinism.
150
+ const fakeAuthClient = {
151
+ projectId: PROJECT_ID,
152
+ getRequestHeaders: async () => new Headers({ authorization: "Bearer test-token" }),
153
+ } as unknown as AuthClient;
154
+
155
+ const model = new ChatAnthropic({
156
+ model: VERTEX_MODEL_ID,
157
+ temperature: 0,
158
+ maxTokens: 1024,
159
+ createClient: (options) => {
160
+ factoryOptions.push({ maxRetries: options.maxRetries });
161
+ const client = new AnthropicVertex({
162
+ projectId: PROJECT_ID,
163
+ region: REGION,
164
+ authClient: fakeAuthClient,
165
+ fetch: recordingFetch,
166
+ maxRetries: options.maxRetries,
167
+ });
168
+ clients.push(client);
169
+ return client;
170
+ },
171
+ });
172
+
173
+ return { model, requests, factoryOptions, clients };
174
+ }
175
+
176
+ const expectedUrl = (specifier: "rawPredict" | "streamRawPredict") =>
177
+ `https://${REGION}-aiplatform.googleapis.com/v1/projects/${PROJECT_ID}` +
178
+ `/locations/${REGION}/publishers/anthropic/models/${VERTEX_MODEL_ID}:${specifier}`;
179
+
180
+ // ─── Tests ───────────────────────────────────────────────────────────────────
181
+
182
+ describe("ChatAnthropic createClient -> AnthropicVertex seam", () => {
183
+ it("constructs without an Anthropic API key when createClient is provided", () => {
184
+ // chat_models.js waives the "Anthropic API key not found" check for
185
+ // factory-constructed clients — the waiver the vertex backend depends on.
186
+ expect(() => buildSeamHarness()).not.toThrow();
187
+ });
188
+
189
+ it("shapes a non-streaming request into Vertex wire form (:rawPredict)", async () => {
190
+ const h = buildSeamHarness();
191
+
192
+ const result = await h.model.invoke([new HumanMessage("Weather in Chennai?")]);
193
+
194
+ expect(h.requests).toHaveLength(1);
195
+ const req = h.requests[0];
196
+
197
+ // The model id rides in the URL path — with `@` intact — not in the body.
198
+ expect(req.url).toBe(expectedUrl("rawPredict"));
199
+ expect(req.method).toBe("POST");
200
+ expect(req.body).not.toHaveProperty("model");
201
+ expect(req.body.anthropic_version).toBe("vertex-2023-10-16");
202
+ expect(req.body.max_tokens).toBe(1024);
203
+
204
+ // The fake auth client's OAuth header reached the wire.
205
+ expect(req.headers.authorization).toBe("Bearer test-token");
206
+
207
+ expect(result).toBeInstanceOf(AIMessage);
208
+ });
209
+
210
+ it("round-trips tool definitions and tool_use blocks into tool_calls", async () => {
211
+ const h = buildSeamHarness();
212
+ const withTools = h.model.bindTools([WEATHER_TOOL]);
213
+
214
+ const result = (await withTools.invoke([
215
+ new HumanMessage("Weather in Chennai?"),
216
+ ])) as AIMessage;
217
+
218
+ // Tool definition survived request shaping through the Vertex adapter.
219
+ const tools = h.requests[0].body.tools as Array<{ name: string }>;
220
+ expect(tools).toHaveLength(1);
221
+ expect(tools[0].name).toBe("get_weather");
222
+
223
+ // The tool_use response block became a LangChain tool_call with its id —
224
+ // the identity the HITL approval flow keys on.
225
+ expect(result.tool_calls).toHaveLength(1);
226
+ expect(result.tool_calls?.[0]).toMatchObject({
227
+ id: "toolu_test_01",
228
+ name: "get_weather",
229
+ args: { city: "Chennai" },
230
+ });
231
+ });
232
+
233
+ it("reports usage_metadata on non-streaming responses (what billing reads)", async () => {
234
+ const h = buildSeamHarness();
235
+
236
+ const result = (await h.model.invoke([
237
+ new HumanMessage("Weather in Chennai?"),
238
+ ])) as AIMessage;
239
+
240
+ expect(result.usage_metadata).toMatchObject({
241
+ input_tokens: 25,
242
+ output_tokens: 17,
243
+ total_tokens: 42,
244
+ });
245
+ });
246
+
247
+ it("streams via :streamRawPredict, assembling tool_calls and usage from SSE", async () => {
248
+ const h = buildSeamHarness();
249
+
250
+ let final: AIMessageChunk | undefined;
251
+ for await (const chunk of await h.model.stream([
252
+ new HumanMessage("Weather in Chennai?"),
253
+ ])) {
254
+ final = final === undefined ? chunk : final.concat(chunk);
255
+ }
256
+
257
+ expect(h.requests).toHaveLength(1);
258
+ expect(h.requests[0].url).toBe(expectedUrl("streamRawPredict"));
259
+ expect(h.requests[0].body.stream).toBe(true);
260
+ expect(h.requests[0].body).not.toHaveProperty("model");
261
+
262
+ expect(final).toBeDefined();
263
+ expect(final?.text).toBe("I'll check the weather.");
264
+ expect(final?.tool_calls).toHaveLength(1);
265
+ expect(final?.tool_calls?.[0]).toMatchObject({
266
+ id: "toolu_test_02",
267
+ name: "get_weather",
268
+ args: { city: "Chennai" },
269
+ });
270
+
271
+ // input from message_start; output accumulated across message events.
272
+ // Pinned to the observed accumulation so a LangChain bump that changes
273
+ // usage math (billing input) fails here first.
274
+ expect(final?.usage_metadata).toMatchObject({
275
+ input_tokens: 25,
276
+ output_tokens: 18,
277
+ });
278
+ });
279
+
280
+ it("passes maxRetries: 0 to the factory and the honored value reaches the client", async () => {
281
+ const h = buildSeamHarness();
282
+
283
+ await h.model.invoke([new HumanMessage("hi")]);
284
+ for await (const chunk of await h.model.stream([new HumanMessage("hi")])) {
285
+ void chunk;
286
+ }
287
+
288
+ // LangChain owns retrying: it must hand the factory maxRetries: 0, once
289
+ // per cached client (batch + streaming are constructed independently).
290
+ expect(h.factoryOptions).toHaveLength(2);
291
+ expect(h.factoryOptions.every((o) => o.maxRetries === 0)).toBe(true);
292
+ expect(h.clients).toHaveLength(2);
293
+ expect(h.clients.every((c) => c.maxRetries === 0)).toBe(true);
294
+ });
295
+ });
@@ -16,7 +16,6 @@
16
16
  */
17
17
 
18
18
  import type { ToolApprovalPolicy } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/spec_pb";
19
- import type { ToolApprovalOverride } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
20
19
  import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
21
20
  import { ApprovalAction, ApprovalMode, ApprovalPolicySource } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
22
21
  import { toolApprovalCategory, type ToolApprovalCategory } from "./tool-kind.js";
@@ -321,9 +320,16 @@ export interface MergedToolPolicy {
321
320
  * Policy chain (each level overrides the previous):
322
321
  * 1. status.toolApprovals — system-generated defaults; presence = requires approval
323
322
  * 2. spec.pinnedToolApprovals — manual overrides; presence = requires approval
324
- * 3. agent tool_approval_overrides — explicit boolean per tool (enable OR disable)
323
+ * 3. usage tool_approval_overrides — explicit boolean per tool (enable OR disable)
325
324
  * 4. active leases — runtime bypass (highest priority), now scoped
326
325
  *
326
+ * Layer 3 is read from {@link ResolvedMcpServer.toolApprovalOverrides} — the
327
+ * overrides ride each server from the usage that resolved it (issue #349), so
328
+ * an override is STRUCTURALLY scoped to its own server. There is deliberately
329
+ * no cross-server override input: a flat list applied inside this per-server
330
+ * loop is how an override once leaked onto — or silently un-gated — a
331
+ * same-named tool on another server.
332
+ *
327
333
  * The map carries ONLY the tools that require approval — a tool's absence means
328
334
  * "auto-approved". Leases shape that absence:
329
335
  * - On a global pre-arm ({@link ActiveLeases.global}) the map is empty.
@@ -341,7 +347,6 @@ export interface MergedToolPolicy {
341
347
  */
342
348
  export function mergeApprovalPolicies(
343
349
  resolvedServers: ResolvedMcpServer[],
344
- agentOverrides: ToolApprovalOverride[],
345
350
  leases: ActiveLeases,
346
351
  ): Map<string, MergedToolPolicy> {
347
352
  const merged = new Map<string, MergedToolPolicy>();
@@ -382,10 +387,12 @@ export function mergeApprovalPolicies(
382
387
  });
383
388
  }
384
389
 
385
- // Layer 3: per-agent overrides (explicit boolean, can enable or disable).
386
- // Touching a tool here makes the per-agent layer the responsible source,
387
- // whether it enables, disables, or re-messages the gate.
388
- for (const override of agentOverrides) {
390
+ // Layer 3: this usage's overrides (explicit boolean, can enable or
391
+ // disable) scoped to THIS server because they arrived on it (see the
392
+ // function doc). Touching a tool here makes the per-agent layer the
393
+ // responsible source, whether it enables, disables, or re-messages the
394
+ // gate.
395
+ for (const override of server.toolApprovalOverrides) {
389
396
  if (!override.toolName) continue;
390
397
  const existing = serverPolicies.get(override.toolName);
391
398
  if (existing) {
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Naming policy for execution attachments — the single owner of the rule that
3
+ * resolves duplicate attachment filenames (issue #364).
4
+ *
5
+ * Both harnesses materialize attachments into the platform inputs namespace
6
+ * keyed by filename, so two attachments carrying the same name contend for
7
+ * one on-disk path. Before this module existed each harness had an accidental
8
+ * answer: the deep-agent injector failed the whole execution on the collision
9
+ * and the Cursor resolver silently overwrote the earlier file. The platform
10
+ * answer is neither — a duplicate is mechanically renamed (`report.pdf`,
11
+ * `report-2.pdf`, ...) and the rename is disclosed to the agent through the
12
+ * input-files prompt section, so no execution is ever burned over a
13
+ * resolvable name and no user file silently vanishes.
14
+ *
15
+ * The `stem-2.ext` semantics deliberately mirror the React SDK composer's
16
+ * client-side rename (sdk/react/src/attachment/attachment-utils.ts,
17
+ * `uniquifyFilename` — kept in sync by hand, the packages share no
18
+ * dependency), so a user sees the same rename shape whether the client or the
19
+ * runner performed it.
20
+ *
21
+ * Scope: this module owns NAMES only. What constitutes a collision is the
22
+ * caller's business — the deep-agent injector keys on full mount paths
23
+ * (explicit `mountPath` values participate), the Cursor resolver keys on bare
24
+ * filenames under `inputs/`. Callers that consider a collision a user
25
+ * contradiction (two attachments explicitly pinning the same mount path)
26
+ * keep rejecting; only mechanically derived names are renamed.
27
+ */
28
+
29
+ /** The outcome of allocating a unique name for one attachment. */
30
+ export interface AllocatedName {
31
+ /** The final name — unchanged when it was free, `stem-N.ext` otherwise. */
32
+ readonly name: string;
33
+ /**
34
+ * The original requested name, present only when a rename happened — the
35
+ * disclosure payload the prompt builders render so the agent can still
36
+ * connect "the two report.pdfs" in the user's message to distinct files.
37
+ */
38
+ readonly renamedFrom?: string;
39
+ }
40
+
41
+ /**
42
+ * Returns `name` unchanged when it is not in `taken`, otherwise the first
43
+ * free `stem-2.ext`, `stem-3.ext`, … variant.
44
+ *
45
+ * Byte-for-byte twin of the React SDK's `uniquifyFilename`
46
+ * (sdk/react/src/attachment/attachment-utils.ts) so client-side and
47
+ * runner-side renames are indistinguishable to the user.
48
+ */
49
+ export function uniquifyFilename(
50
+ name: string,
51
+ taken: ReadonlySet<string>,
52
+ ): string {
53
+ if (!taken.has(name)) return name;
54
+
55
+ const dotIndex = name.lastIndexOf(".");
56
+ // A leading dot (".env") is a hidden-file prefix, not an extension.
57
+ const stem = dotIndex > 0 ? name.slice(0, dotIndex) : name;
58
+ const ext = dotIndex > 0 ? name.slice(dotIndex) : "";
59
+
60
+ for (let n = 2; ; n++) {
61
+ const candidate = `${stem}-${n}${ext}`;
62
+ if (!taken.has(candidate)) return candidate;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Allocate a unique name against `taken`, claiming the result in the set so
68
+ * sequential allocations see each other. Returns the disclosure payload
69
+ * (`renamedFrom`) when the name had to change.
70
+ */
71
+ export function allocateUniqueName(
72
+ name: string,
73
+ taken: Set<string>,
74
+ ): AllocatedName {
75
+ const unique = uniquifyFilename(name, taken);
76
+ taken.add(unique);
77
+ return unique === name ? { name: unique } : { name: unique, renamedFrom: name };
78
+ }
@@ -16,6 +16,14 @@
16
16
  * authoritative. A declared image whose bytes are not a recognizable image
17
17
  * degrades to the file-pointer story instead of shipping a mislabeled payload.
18
18
  *
19
+ * Model capability gate: eligibility also consults the execution model's
20
+ * vision capability, sourced from the model registry's `capabilities.vision`
21
+ * flag (model-registry.ts, `getModelVisionCapability`) and passed in at
22
+ * budget construction. The gate fails OPEN on unknown — only an explicit
23
+ * `vision: false` degrades — because most registry entries have never been
24
+ * capability-assessed, and blocking images on missing data would regress
25
+ * behavior that works today (issue #370 has the full evidence trail).
26
+ *
19
27
  * Degradation is always non-fatal and always disclosed: an image the model
20
28
  * cannot see is announced in the prompt (see {@link visionDisclosureLines}) so
21
29
  * the agent can tell the user instead of silently ignoring a photo the user
@@ -83,7 +91,14 @@ export type VisionDegradedReason =
83
91
  /** A real image type the current harness cannot display (e.g. WebP on Cursor). */
84
92
  | "unsupported_format"
85
93
  /** Declared as an image but the bytes are not a recognizable image (HEIC named .jpg, corrupt file). */
86
- | "type_mismatch";
94
+ | "type_mismatch"
95
+ /**
96
+ * The model registry explicitly flags the execution's model as unable to
97
+ * see images (`capabilities.vision: false`). Unlike every other reason,
98
+ * this one is not resend-fixable — no smaller or re-encoded image can help
99
+ * — so the disclosure gives it its own honest wording.
100
+ */
101
+ | "model_no_vision";
87
102
 
88
103
  export type VisionOutcome =
89
104
  | { readonly kind: "accepted"; readonly image: VisionImage }
@@ -187,17 +202,32 @@ export class VisionBudget {
187
202
  private readonly maxImageBytes: number;
188
203
  private readonly maxTotalBytes: number;
189
204
  private readonly maxImages: number;
205
+ /**
206
+ * Tri-state model capability from the registry (model-registry.ts,
207
+ * `getModelVisionCapability`). Only an explicit `false` gates: `undefined`
208
+ * means the capability was never assessed (or the registry was
209
+ * unreachable, or the model is the Cursor "default" Auto pool), and the
210
+ * policy fails OPEN on unknown — degrading every image because a flag is
211
+ * missing would regress behavior that works today.
212
+ */
213
+ private readonly modelVision?: boolean;
190
214
  private totalBytes = 0;
191
215
  private imageCount = 0;
192
216
 
193
217
  constructor(
194
218
  profile: VisionProfile,
195
- limits?: { maxImageBytes?: number; maxTotalBytes?: number; maxImages?: number },
219
+ options?: {
220
+ maxImageBytes?: number;
221
+ maxTotalBytes?: number;
222
+ maxImages?: number;
223
+ modelVision?: boolean;
224
+ },
196
225
  ) {
197
226
  this.profile = profile;
198
- this.maxImageBytes = limits?.maxImageBytes ?? MAX_VISION_IMAGE_BYTES;
199
- this.maxTotalBytes = limits?.maxTotalBytes ?? MAX_VISION_TOTAL_BYTES;
200
- this.maxImages = limits?.maxImages ?? MAX_VISION_IMAGES;
227
+ this.maxImageBytes = options?.maxImageBytes ?? MAX_VISION_IMAGE_BYTES;
228
+ this.maxTotalBytes = options?.maxTotalBytes ?? MAX_VISION_TOTAL_BYTES;
229
+ this.maxImages = options?.maxImages ?? MAX_VISION_IMAGES;
230
+ this.modelVision = options?.modelVision;
201
231
  }
202
232
 
203
233
  /** Evaluate one attachment's bytes against every eligibility and budget rule. */
@@ -205,6 +235,17 @@ export class VisionBudget {
205
235
  const sniffed = sniffImageMime(bytes);
206
236
  const declaredIsImage = declaredType.toLowerCase().startsWith("image/");
207
237
 
238
+ // The blind-model gate comes before every other rule: for a model that
239
+ // cannot see images, format/size/budget reasons would be irrelevant and
240
+ // their "resend smaller" advice actively misleading. Anything
241
+ // image-shaped (recognizable bytes OR a declared image type) is
242
+ // disclosed; everything else stays on the silent file story.
243
+ if (this.modelVision === false) {
244
+ return sniffed !== undefined || declaredIsImage
245
+ ? { kind: "degraded", reason: "model_no_vision" }
246
+ : { kind: "skipped" };
247
+ }
248
+
208
249
  if (sniffed === undefined) {
209
250
  // Declared an image but isn't one we can recognize — the user plausibly
210
251
  // expects it to be seen (iPhone HEIC renamed .jpg is the common case),
@@ -250,6 +291,28 @@ export class VisionBudget {
250
291
  offerOversized(): VisionOutcome {
251
292
  return { kind: "degraded", reason: "too_large" };
252
293
  }
294
+
295
+ /**
296
+ * True when the model is explicitly flagged as unable to see images, so no
297
+ * candidate can ever be accepted. Callers on a no-read path (the Cursor
298
+ * local-file fast branch) check this BEFORE stat/size logic — a blind
299
+ * model's oversized image must report {@link offerBlind}'s honest reason,
300
+ * never `too_large`'s "resend smaller" advice — then record the outcome
301
+ * via {@link offerBlind}, mirroring the exceedsImageCap/offerOversized
302
+ * pattern.
303
+ */
304
+ modelCannotSee(): boolean {
305
+ return this.modelVision === false;
306
+ }
307
+
308
+ /**
309
+ * Record a vision candidate the caller chose not to read because
310
+ * {@link modelCannotSee} was true — the model's blindness alone settles
311
+ * the outcome.
312
+ */
313
+ offerBlind(): VisionOutcome {
314
+ return { kind: "degraded", reason: "model_no_vision" };
315
+ }
253
316
  }
254
317
 
255
318
  // ---------------------------------------------------------------------------
@@ -331,6 +394,8 @@ function reasonLabel(reason: VisionDegradedReason): string {
331
394
  return "unsupported format";
332
395
  case "type_mismatch":
333
396
  return "unreadable image format";
397
+ case "model_no_vision":
398
+ return "model cannot view images";
334
399
  }
335
400
  }
336
401
 
@@ -358,10 +423,28 @@ export function visionDisclosureLines(
358
423
  .map((e) => `\`${e.path}\` (${reasonLabel(e.reason)})`)
359
424
  .join(", ");
360
425
  lines.push(`NOT VIEWABLE INLINE: ${entries}.`);
361
- lines.push(
362
- "You cannot see these files; if you need one, ask the user to resend it " +
363
- "as a smaller PNG or JPEG.",
364
- );
426
+ // The advice must match the reason. Resending helps only when the image
427
+ // itself was the problem; for a blind model that advice would send the
428
+ // user on a pointless resize-and-resend errand, so that arm gets its own
429
+ // honest wording. (In practice a blind model degrades EVERY image, so
430
+ // the two lines rarely co-occur — but the wording stays reason-accurate
431
+ // either way.)
432
+ const resendFixable = notViewable.filter((e) => e.reason !== "model_no_vision");
433
+ const modelBlind = notViewable.filter((e) => e.reason === "model_no_vision");
434
+ if (resendFixable.length > 0) {
435
+ lines.push(
436
+ "You cannot see these files; if you need one, ask the user to resend it " +
437
+ "as a smaller PNG or JPEG.",
438
+ );
439
+ }
440
+ if (modelBlind.length > 0) {
441
+ lines.push(
442
+ "The current model does not support image input, so no resend will " +
443
+ "help. The files are saved on disk at the paths above; if the user " +
444
+ "needs an image understood, suggest switching to a vision-capable " +
445
+ "model.",
446
+ );
447
+ }
365
448
  }
366
449
  if (inlineFilenames.length > 0) {
367
450
  lines.push(
@@ -10,8 +10,10 @@
10
10
  * 2. The session creator (`stigmer_user`) from the Session resource's
11
11
  * audit actor — console/CLI sessions have no channel sender, but the
12
12
  * platform knows exactly who created the session.
13
- * 3. The anonymous sentinel — discovery (no session exists) and sessions
14
- * with no readable creator. Consumers must treat anonymous as a
13
+ * 3. The anonymous sentinel — discovery (no session exists), sessions
14
+ * with no readable creator, and sessions whose creator is the
15
+ * backend's "system" audit placeholder (not a principal — see
16
+ * SYSTEM_CREATOR_SENTINEL). Consumers must treat anonymous as a
15
17
  * first-class caller: answer tools/list, refuse tool calls.
16
18
  *
17
19
  * Injection is opt-in by construction: the values enter the env map used
@@ -54,6 +56,20 @@ export const STIGMER_USER_KIND = "stigmer_user";
54
56
  */
55
57
  export const ANONYMOUS_KIND = "anonymous";
56
58
 
59
+ /**
60
+ * Audit-actor id that backends stamp when NO caller identity exists —
61
+ * the OSS server writes it on every create (no local auth), and the
62
+ * cloud's AuditActorBuilder falls back to it for caller-less internal
63
+ * writes. It names "nobody in particular": unrelated sessions from
64
+ * unrelated people all carry it, so presenting it as a caller identity
65
+ * would make the one string a grantable value that silently covers ALL
66
+ * such traffic in an MCP server's binding sheet. A creator matching this
67
+ * sentinel (and carrying no email) is therefore unresolvable and falls
68
+ * to anonymous — the deny-by-default the docs guide already promises
69
+ * for self-hosted backends.
70
+ */
71
+ export const SYSTEM_CREATOR_SENTINEL = "system";
72
+
57
73
  /** The resolved caller identity. */
58
74
  export interface CallerIdentity {
59
75
  kind: string;
@@ -79,6 +95,12 @@ export function anonymousCallerIdentity(): CallerIdentity {
79
95
  * humans, and the audit actor's `id` field is historically mixed
80
96
  * (identity-account id vs email — see the proto's own @internal note).
81
97
  * Binding matchers should compare emails case-insensitively.
98
+ *
99
+ * A creator with no email whose id is the "system" audit placeholder is
100
+ * NOT a principal (see SYSTEM_CREATOR_SENTINEL) and resolves to
101
+ * anonymous. Email-first is deliberate here too: a real account that
102
+ * merely has "system" somewhere in its id is never demoted, because its
103
+ * email wins before the sentinel check runs.
82
104
  */
83
105
  export function resolveCallerIdentity(
84
106
  sessionMetadata: Record<string, string> | undefined,
@@ -90,10 +112,23 @@ export function resolveCallerIdentity(
90
112
  }
91
113
 
92
114
  const email = creator?.email?.trim();
115
+ if (email) {
116
+ return { kind: STIGMER_USER_KIND, value: email };
117
+ }
118
+
93
119
  const id = creator?.id?.trim();
94
- const value = email || id;
95
- if (value) {
96
- return { kind: STIGMER_USER_KIND, value };
120
+ if (id === SYSTEM_CREATOR_SENTINEL) {
121
+ // Operator tripwire: identity-gated MCP tools will refuse this
122
+ // session; the fix is real caller attribution, never a "system" grant.
123
+ console.info(
124
+ `Session creator is the "${SYSTEM_CREATOR_SENTINEL}" audit placeholder ` +
125
+ `(no email) — not a resolvable principal; presenting the anonymous ` +
126
+ `caller identity to MCP servers`,
127
+ );
128
+ return anonymousCallerIdentity();
129
+ }
130
+ if (id) {
131
+ return { kind: STIGMER_USER_KIND, value: id };
97
132
  }
98
133
 
99
134
  return anonymousCallerIdentity();
@@ -128,6 +128,7 @@ export function synthesizeChannelAttachment(
128
128
  slug: CHANNEL_ATTACHMENT_SLUG,
129
129
  toolApprovals: [],
130
130
  pinnedToolApprovals: [],
131
+ toolApprovalOverrides: [],
131
132
  discoveredCapabilitiesEmpty: false,
132
133
  };
133
134