@stigmer/runner 3.10.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/README.md +12 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/call-llm.js +9 -10
  4. package/dist/activities/call-llm.js.map +1 -1
  5. package/dist/activities/classify-tool-approvals.d.ts +2 -1
  6. package/dist/activities/classify-tool-approvals.js +28 -2
  7. package/dist/activities/classify-tool-approvals.js.map +1 -1
  8. package/dist/activities/discover-mcp-server.d.ts +32 -0
  9. package/dist/activities/discover-mcp-server.js +162 -27
  10. package/dist/activities/discover-mcp-server.js.map +1 -1
  11. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +8 -0
  12. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
  13. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
  14. package/dist/activities/execute-cursor/approval-state.d.ts +28 -2
  15. package/dist/activities/execute-cursor/approval-state.js +7 -1
  16. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  17. package/dist/activities/execute-cursor/attachment-resolver.d.ts +14 -0
  18. package/dist/activities/execute-cursor/attachment-resolver.js +18 -4
  19. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  20. package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
  21. package/dist/activities/execute-cursor/blueprint-resolver.js +6 -22
  22. package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
  23. package/dist/activities/execute-cursor/env-resolver.js +3 -1
  24. package/dist/activities/execute-cursor/env-resolver.js.map +1 -1
  25. package/dist/activities/execute-cursor/error-classifier.d.ts +40 -3
  26. package/dist/activities/execute-cursor/error-classifier.js +81 -3
  27. package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
  28. package/dist/activities/execute-cursor/extract-structured-output.d.ts +29 -0
  29. package/dist/activities/execute-cursor/extract-structured-output.js +58 -0
  30. package/dist/activities/execute-cursor/extract-structured-output.js.map +1 -0
  31. package/dist/activities/execute-cursor/hook-script.d.ts +14 -3
  32. package/dist/activities/execute-cursor/hook-script.js +72 -10
  33. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  34. package/dist/activities/execute-cursor/index.d.ts +5 -1
  35. package/dist/activities/execute-cursor/index.js +51 -57
  36. package/dist/activities/execute-cursor/index.js.map +1 -1
  37. package/dist/activities/execute-cursor/mcp-resolver.d.ts +24 -1
  38. package/dist/activities/execute-cursor/mcp-resolver.js +5 -2
  39. package/dist/activities/execute-cursor/mcp-resolver.js.map +1 -1
  40. package/dist/activities/execute-cursor/prompt-builder.d.ts +18 -4
  41. package/dist/activities/execute-cursor/prompt-builder.js +12 -7
  42. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  43. package/dist/activities/execute-cursor/turn-stream.js +4 -1
  44. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  45. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +18 -1
  46. package/dist/activities/execute-deep-agent/attachment-injector.js +68 -23
  47. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  48. package/dist/activities/execute-deep-agent/environment.js +3 -1
  49. package/dist/activities/execute-deep-agent/environment.js.map +1 -1
  50. package/dist/activities/execute-deep-agent/index.js +15 -0
  51. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  52. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -7
  53. package/dist/activities/execute-deep-agent/prompt-builder.js +8 -2
  54. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  55. package/dist/activities/execute-deep-agent/setup.d.ts +10 -0
  56. package/dist/activities/execute-deep-agent/setup.js +55 -23
  57. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  58. package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +18 -1
  59. package/dist/activities/execute-deep-agent/subagent-transformer.js +8 -1
  60. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  61. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +11 -4
  62. package/dist/activities/execute-deep-agent/subagent-wiring.js +13 -4
  63. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  64. package/dist/activities/hydrate-workflow-execution.js +3 -1
  65. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  66. package/dist/activities/workflow-event-activities.d.ts +28 -10
  67. package/dist/activities/workflow-event-activities.js +87 -58
  68. package/dist/activities/workflow-event-activities.js.map +1 -1
  69. package/dist/claimcheck/payload-codec.js +21 -1
  70. package/dist/claimcheck/payload-codec.js.map +1 -1
  71. package/dist/client/stigmer-client.d.ts +9 -4
  72. package/dist/client/stigmer-client.js +28 -15
  73. package/dist/client/stigmer-client.js.map +1 -1
  74. package/dist/encryption/config.d.ts +32 -0
  75. package/dist/encryption/config.js +68 -0
  76. package/dist/encryption/config.js.map +1 -0
  77. package/dist/encryption/index.d.ts +3 -0
  78. package/dist/encryption/index.js +3 -0
  79. package/dist/encryption/index.js.map +1 -0
  80. package/dist/encryption/payload-codec.d.ts +41 -0
  81. package/dist/encryption/payload-codec.js +130 -0
  82. package/dist/encryption/payload-codec.js.map +1 -0
  83. package/dist/payload-codecs.d.ts +16 -0
  84. package/dist/payload-codecs.js +38 -0
  85. package/dist/payload-codecs.js.map +1 -0
  86. package/dist/preflight.d.ts +31 -0
  87. package/dist/preflight.js +43 -0
  88. package/dist/preflight.js.map +1 -1
  89. package/dist/runner-manager.js +5 -15
  90. package/dist/runner-manager.js.map +1 -1
  91. package/dist/runner.js +5 -16
  92. package/dist/runner.js.map +1 -1
  93. package/dist/shared/approval-policy.d.ts +9 -3
  94. package/dist/shared/approval-policy.js +15 -6
  95. package/dist/shared/approval-policy.js.map +1 -1
  96. package/dist/shared/attachment-naming.d.ts +53 -0
  97. package/dist/shared/attachment-naming.js +59 -0
  98. package/dist/shared/attachment-naming.js.map +1 -0
  99. package/dist/shared/caller-identity.d.ts +23 -2
  100. package/dist/shared/caller-identity.js +36 -5
  101. package/dist/shared/caller-identity.js.map +1 -1
  102. package/dist/shared/channel-attachment.js +1 -0
  103. package/dist/shared/channel-attachment.js.map +1 -1
  104. package/dist/shared/checkpointer/http-saver.d.ts +42 -1
  105. package/dist/shared/checkpointer/http-saver.js +96 -8
  106. package/dist/shared/checkpointer/http-saver.js.map +1 -1
  107. package/dist/shared/conversation-attachment.js +1 -0
  108. package/dist/shared/conversation-attachment.js.map +1 -1
  109. package/dist/shared/datastore-attachment.d.ts +50 -7
  110. package/dist/shared/datastore-attachment.js +93 -11
  111. package/dist/shared/datastore-attachment.js.map +1 -1
  112. package/dist/shared/http-retry.d.ts +43 -0
  113. package/dist/shared/http-retry.js +50 -0
  114. package/dist/shared/http-retry.js.map +1 -0
  115. package/dist/shared/llm-backend.d.ts +275 -0
  116. package/dist/shared/llm-backend.js +425 -0
  117. package/dist/shared/llm-backend.js.map +1 -0
  118. package/dist/shared/llm-proxy.d.ts +8 -0
  119. package/dist/shared/llm-proxy.js +15 -0
  120. package/dist/shared/llm-proxy.js.map +1 -1
  121. package/dist/shared/mcp-enabled-tools.d.ts +57 -0
  122. package/dist/shared/mcp-enabled-tools.js +86 -0
  123. package/dist/shared/mcp-enabled-tools.js.map +1 -0
  124. package/dist/shared/mcp-manager.d.ts +3 -1
  125. package/dist/shared/mcp-manager.js +17 -4
  126. package/dist/shared/mcp-manager.js.map +1 -1
  127. package/dist/shared/mcp-resolver.d.ts +39 -2
  128. package/dist/shared/mcp-resolver.js +38 -2
  129. package/dist/shared/mcp-resolver.js.map +1 -1
  130. package/dist/shared/model-client.d.ts +12 -5
  131. package/dist/shared/model-client.js +138 -18
  132. package/dist/shared/model-client.js.map +1 -1
  133. package/dist/shared/model-error.js +198 -5
  134. package/dist/shared/model-error.js.map +1 -1
  135. package/dist/shared/plan-mode-permissions.d.ts +26 -0
  136. package/dist/shared/plan-mode-permissions.js +28 -0
  137. package/dist/shared/plan-mode-permissions.js.map +1 -0
  138. package/dist/worker.d.ts +2 -1
  139. package/dist/worker.js +2 -4
  140. package/dist/worker.js.map +1 -1
  141. package/dist/workflow-engine/types.d.ts +18 -0
  142. package/dist/workflow-engine/types.js.map +1 -1
  143. package/dist/workflows/call-agent-orchestrator.d.ts +9 -0
  144. package/dist/workflows/call-agent-orchestrator.js +1 -0
  145. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  146. package/dist/workflows/connect-mcp-server.js +7 -0
  147. package/dist/workflows/connect-mcp-server.js.map +1 -1
  148. package/dist/workflows/engine-core.js +23 -2
  149. package/dist/workflows/engine-core.js.map +1 -1
  150. package/dist/workflows/execute-from-execution.d.ts +1 -1
  151. package/dist/workflows/execute-from-execution.js +11 -1
  152. package/dist/workflows/execute-from-execution.js.map +1 -1
  153. package/package.json +8 -2
  154. package/src/__tests__/claimcheck-codec.test.ts +36 -0
  155. package/src/__tests__/encryption-codec.test.ts +234 -0
  156. package/src/__tests__/fixtures/encrypted-payload-fixture.json +15 -0
  157. package/src/__tests__/history-encryption-e2e.test.ts +243 -0
  158. package/src/__tests__/preflight.test.ts +50 -2
  159. package/src/activities/__tests__/call-llm.test.ts +75 -0
  160. package/src/activities/__tests__/classify-tool-approvals.test.ts +117 -1
  161. package/src/activities/__tests__/discover-mcp-server.hang.test.ts +103 -0
  162. package/src/activities/__tests__/discover-mcp-server.test.ts +203 -0
  163. package/src/activities/__tests__/workflow-event-activities.test.ts +107 -8
  164. package/src/activities/call-llm.ts +9 -16
  165. package/src/activities/classify-tool-approvals.ts +34 -4
  166. package/src/activities/discover-mcp-server.ts +190 -32
  167. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +9 -0
  168. package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +14 -0
  169. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +53 -0
  170. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +40 -14
  171. package/src/activities/execute-cursor/__tests__/error-classifier-extraction.test.ts +208 -0
  172. package/src/activities/execute-cursor/__tests__/extract-structured-output.test.ts +120 -0
  173. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +93 -0
  174. package/src/activities/execute-cursor/__tests__/mcp-resolver.test.ts +125 -0
  175. package/src/activities/execute-cursor/__tests__/prompt-builder-delegation.test.ts +1 -1
  176. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +13 -0
  177. package/src/activities/execute-cursor/approval-state.ts +30 -1
  178. package/src/activities/execute-cursor/attachment-resolver.ts +31 -3
  179. package/src/activities/execute-cursor/blueprint-resolver.ts +7 -27
  180. package/src/activities/execute-cursor/env-resolver.ts +3 -1
  181. package/src/activities/execute-cursor/error-classifier.ts +91 -4
  182. package/src/activities/execute-cursor/extract-structured-output.ts +72 -0
  183. package/src/activities/execute-cursor/hook-script.ts +74 -10
  184. package/src/activities/execute-cursor/index.ts +55 -71
  185. package/src/activities/execute-cursor/mcp-resolver.ts +36 -2
  186. package/src/activities/execute-cursor/prompt-builder.ts +34 -9
  187. package/src/activities/execute-cursor/turn-stream.ts +5 -2
  188. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +110 -8
  189. package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +104 -0
  190. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +2 -0
  191. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  192. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  193. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +34 -5
  194. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  195. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +173 -0
  196. package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +12 -7
  197. package/src/activities/execute-deep-agent/attachment-injector.ts +94 -30
  198. package/src/activities/execute-deep-agent/environment.ts +3 -1
  199. package/src/activities/execute-deep-agent/index.ts +20 -0
  200. package/src/activities/execute-deep-agent/prompt-builder.ts +20 -10
  201. package/src/activities/execute-deep-agent/setup.ts +76 -28
  202. package/src/activities/execute-deep-agent/subagent-transformer.ts +23 -1
  203. package/src/activities/execute-deep-agent/subagent-wiring.ts +14 -4
  204. package/src/activities/hydrate-workflow-execution.ts +3 -1
  205. package/src/activities/workflow-event-activities.ts +96 -69
  206. package/src/claimcheck/payload-codec.ts +33 -1
  207. package/src/client/__tests__/stigmer-client.test.ts +8 -8
  208. package/src/client/stigmer-client.ts +32 -18
  209. package/src/encryption/config.ts +91 -0
  210. package/src/encryption/index.ts +3 -0
  211. package/src/encryption/payload-codec.ts +152 -0
  212. package/src/payload-codecs.ts +56 -0
  213. package/src/preflight.ts +45 -0
  214. package/src/runner-manager.ts +6 -24
  215. package/src/runner.ts +6 -25
  216. package/src/shared/__tests__/approval-policy.test.ts +82 -39
  217. package/src/shared/__tests__/attachment-naming.test.ts +159 -0
  218. package/src/shared/__tests__/bedrock-adapter.test.ts +213 -0
  219. package/src/shared/__tests__/bedrock-seam.test.ts +390 -0
  220. package/src/shared/__tests__/caller-identity.test.ts +25 -0
  221. package/src/shared/__tests__/channel-attachment.test.ts +1 -1
  222. package/src/shared/__tests__/connect-backfill.test.ts +1 -0
  223. package/src/shared/__tests__/conversation-attachment.test.ts +1 -1
  224. package/src/shared/__tests__/datastore-attachment.test.ts +129 -1
  225. package/src/shared/__tests__/foundry-adapter.test.ts +276 -0
  226. package/src/shared/__tests__/foundry-seam.test.ts +482 -0
  227. package/src/shared/__tests__/http-retry.test.ts +67 -0
  228. package/src/shared/__tests__/llm-backend.test.ts +616 -0
  229. package/src/shared/__tests__/mcp-enabled-tools.test.ts +86 -0
  230. package/src/shared/__tests__/mcp-manager.test.ts +84 -2
  231. package/src/shared/__tests__/mcp-resolver.test.ts +146 -3
  232. package/src/shared/__tests__/model-client.test.ts +154 -0
  233. package/src/shared/__tests__/model-error.test.ts +289 -1
  234. package/src/shared/__tests__/synthesized-attachment.test.ts +1 -0
  235. package/src/shared/__tests__/vertex-adapter.test.ts +169 -0
  236. package/src/shared/__tests__/vertex-seam.test.ts +295 -0
  237. package/src/shared/approval-policy.ts +14 -7
  238. package/src/shared/attachment-naming.ts +78 -0
  239. package/src/shared/caller-identity.ts +40 -5
  240. package/src/shared/channel-attachment.ts +1 -0
  241. package/src/shared/checkpointer/__tests__/http-saver.test.ts +196 -1
  242. package/src/shared/checkpointer/http-saver.ts +117 -9
  243. package/src/shared/conversation-attachment.ts +1 -0
  244. package/src/shared/datastore-attachment.ts +106 -11
  245. package/src/shared/http-retry.ts +50 -0
  246. package/src/shared/llm-backend.ts +544 -0
  247. package/src/shared/llm-proxy.ts +15 -0
  248. package/src/shared/mcp-enabled-tools.ts +105 -0
  249. package/src/shared/mcp-manager.ts +21 -4
  250. package/src/shared/mcp-resolver.ts +73 -2
  251. package/src/shared/model-client.ts +161 -19
  252. package/src/shared/model-error.ts +222 -4
  253. package/src/shared/plan-mode-permissions.ts +30 -0
  254. package/src/worker.ts +4 -5
  255. package/src/workflow-engine/types.ts +18 -0
  256. package/src/workflows/__tests__/execute-serverless-workflow.test.ts +68 -2
  257. package/src/workflows/call-agent-orchestrator.ts +10 -0
  258. package/src/workflows/connect-mcp-server.ts +7 -0
  259. package/src/workflows/engine-core.ts +23 -2
  260. package/src/workflows/execute-from-execution.ts +12 -2
@@ -7,11 +7,15 @@ import type { WorkflowEventDescriptor } from "../../workflow-engine/types.js";
7
7
 
8
8
  const mockGetEventLogHighWaterMark = vi.fn<(executionId: string) => Promise<bigint>>();
9
9
  const mockGetWorkflowExecution = vi.fn<(executionId: string) => Promise<unknown>>();
10
+ const mockUpdateStatus = vi.fn<(input: unknown) => Promise<unknown>>();
10
11
 
11
12
  vi.mock("../../client/stigmer-client.js", () => ({
12
13
  StigmerClient: vi.fn().mockImplementation(() => ({
13
14
  getEventLogHighWaterMark: (...args: unknown[]) => mockGetEventLogHighWaterMark(...(args as [string])),
14
15
  getWorkflowExecution: (...args: unknown[]) => mockGetWorkflowExecution(...(args as [string])),
16
+ workflowExecutionCommand: {
17
+ updateStatus: (...args: unknown[]) => mockUpdateStatus(args[0]),
18
+ },
15
19
  })),
16
20
  }));
17
21
 
@@ -29,8 +33,9 @@ describe("initSequenceFromEventLog", () => {
29
33
  mockGetEventLogHighWaterMark.mockReset();
30
34
  });
31
35
 
32
- it("sets counter to 0 when executionId is empty", async () => {
33
- await initSequenceFromEventLog("");
36
+ it("returns 0 and resets the legacy counter when executionId is empty", async () => {
37
+ const highWaterMark = await initSequenceFromEventLog("");
38
+ expect(highWaterMark).toBe(0);
34
39
 
35
40
  const evt = toProtoEvent({
36
41
  type: "task_started",
@@ -43,10 +48,11 @@ describe("initSequenceFromEventLog", () => {
43
48
  expect(mockGetEventLogHighWaterMark).not.toHaveBeenCalled();
44
49
  });
45
50
 
46
- it("sets counter to 0 when server returns no events", async () => {
51
+ it("returns 0 when server has no events", async () => {
47
52
  mockGetEventLogHighWaterMark.mockResolvedValue(BigInt(0));
48
53
 
49
- await initSequenceFromEventLog("wfx_test-1");
54
+ const highWaterMark = await initSequenceFromEventLog("wfx_test-1");
55
+ expect(highWaterMark).toBe(0);
50
56
 
51
57
  const evt = toProtoEvent({
52
58
  type: "task_started",
@@ -58,7 +64,15 @@ describe("initSequenceFromEventLog", () => {
58
64
  expect(evt.sequenceNumber).toBe(BigInt(1));
59
65
  });
60
66
 
61
- it("continues from high-water mark when events exist", async () => {
67
+ it("returns the high-water mark as a plain number (Temporal payload converter cannot carry BigInt)", async () => {
68
+ mockGetEventLogHighWaterMark.mockResolvedValue(BigInt(42));
69
+
70
+ const highWaterMark = await initSequenceFromEventLog("wfx_recovery-1");
71
+ expect(highWaterMark).toBe(42);
72
+ expect(typeof highWaterMark).toBe("number");
73
+ });
74
+
75
+ it("seeds the legacy counter so pre-patch replays continue from N+1", async () => {
62
76
  mockGetEventLogHighWaterMark.mockResolvedValue(BigInt(42));
63
77
 
64
78
  await initSequenceFromEventLog("wfx_recovery-1");
@@ -87,7 +101,8 @@ describe("initSequenceFromEventLog", () => {
87
101
  it("handles large sequence numbers", async () => {
88
102
  mockGetEventLogHighWaterMark.mockResolvedValue(BigInt(999999));
89
103
 
90
- await initSequenceFromEventLog("wfx_large-seq");
104
+ const highWaterMark = await initSequenceFromEventLog("wfx_large-seq");
105
+ expect(highWaterMark).toBe(999999);
91
106
 
92
107
  const evt = toProtoEvent({
93
108
  type: "task_started",
@@ -112,7 +127,52 @@ describe("toProtoEvent", () => {
112
127
  });
113
128
 
114
129
  describe("sequence numbering", () => {
115
- it("assigns monotonically increasing sequence numbers", () => {
130
+ it("honors the workflow-assigned sequenceNumber when stamped on the descriptor", () => {
131
+ const evt = toProtoEvent({
132
+ type: "task_started",
133
+ taskName: "t1",
134
+ occurredAt: NOW,
135
+ taskKind: "set",
136
+ attemptNumber: 1,
137
+ sequenceNumber: 77,
138
+ });
139
+
140
+ expect(evt.sequenceNumber).toBe(BigInt(77));
141
+ });
142
+
143
+ it("workflow-assigned sequences are stable across conversions (retry idempotency)", () => {
144
+ const desc: WorkflowEventDescriptor = {
145
+ type: "task_started",
146
+ taskName: "t1",
147
+ occurredAt: NOW,
148
+ taskKind: "set",
149
+ attemptNumber: 1,
150
+ sequenceNumber: 5,
151
+ };
152
+
153
+ // Simulates an activity retry re-converting the same descriptors:
154
+ // the sequence must not change between attempts.
155
+ expect(toProtoEvent(desc).sequenceNumber).toBe(BigInt(5));
156
+ expect(toProtoEvent(desc).sequenceNumber).toBe(BigInt(5));
157
+ });
158
+
159
+ it("a stamped descriptor does not consume the legacy counter", () => {
160
+ const unstamped: WorkflowEventDescriptor = {
161
+ type: "task_started",
162
+ taskName: "t",
163
+ occurredAt: NOW,
164
+ taskKind: "set",
165
+ attemptNumber: 1,
166
+ };
167
+
168
+ const e1 = toProtoEvent(unstamped);
169
+ toProtoEvent({ ...unstamped, sequenceNumber: 99 });
170
+ const e2 = toProtoEvent(unstamped);
171
+
172
+ expect(e2.sequenceNumber).toBe(e1.sequenceNumber + BigInt(1));
173
+ });
174
+
175
+ it("legacy path: assigns monotonically increasing sequence numbers when unstamped", () => {
116
176
  const desc: WorkflowEventDescriptor = {
117
177
  type: "task_started",
118
178
  taskName: "t1",
@@ -130,7 +190,7 @@ describe("toProtoEvent", () => {
130
190
  expect(e3.sequenceNumber).toBe(BigInt(3));
131
191
  });
132
192
 
133
- it("resets to 1 after re-initializing with empty executionId", async () => {
193
+ it("legacy path: resets to 1 after re-initializing with empty executionId", async () => {
134
194
  toProtoEvent({
135
195
  type: "task_started",
136
196
  taskName: "t",
@@ -654,8 +714,13 @@ describe("toProtoEvent", () => {
654
714
  });
655
715
 
656
716
  describe("emitWorkflowEvents", () => {
717
+ beforeEach(() => {
718
+ mockUpdateStatus.mockReset();
719
+ });
720
+
657
721
  it("returns silently for empty events array", async () => {
658
722
  await expect(emitWorkflowEvents("exec-1", [])).resolves.toBeUndefined();
723
+ expect(mockUpdateStatus).not.toHaveBeenCalled();
659
724
  });
660
725
 
661
726
  it("returns silently for empty executionId", async () => {
@@ -667,6 +732,40 @@ describe("emitWorkflowEvents", () => {
667
732
  attemptNumber: 1,
668
733
  }];
669
734
  await expect(emitWorkflowEvents("", events)).resolves.toBeUndefined();
735
+ expect(mockUpdateStatus).not.toHaveBeenCalled();
736
+ });
737
+
738
+ it("sends workflow-assigned sequence numbers through to the RPC", async () => {
739
+ mockUpdateStatus.mockResolvedValue({});
740
+
741
+ await emitWorkflowEvents("exec-1", [{
742
+ type: "task_started",
743
+ taskName: "t",
744
+ occurredAt: NOW,
745
+ taskKind: "set",
746
+ attemptNumber: 1,
747
+ sequenceNumber: 12,
748
+ }]);
749
+
750
+ expect(mockUpdateStatus).toHaveBeenCalledTimes(1);
751
+ const input = mockUpdateStatus.mock.calls[0][0] as { events: { sequenceNumber: bigint }[] };
752
+ expect(input.events).toHaveLength(1);
753
+ expect(input.events[0].sequenceNumber).toBe(BigInt(12));
754
+ });
755
+
756
+ it("propagates RPC errors so the local activity retry policy fires", async () => {
757
+ mockUpdateStatus.mockRejectedValue(new Error("server unavailable"));
758
+
759
+ const events: WorkflowEventDescriptor[] = [{
760
+ type: "task_started",
761
+ taskName: "t",
762
+ occurredAt: NOW,
763
+ taskKind: "set",
764
+ attemptNumber: 1,
765
+ sequenceNumber: 1,
766
+ }];
767
+
768
+ await expect(emitWorkflowEvents("exec-1", events)).rejects.toThrow("server unavailable");
670
769
  });
671
770
  });
672
771
 
@@ -31,6 +31,7 @@ import {
31
31
  import { computeLlmCostMicros, ensureLoaded as ensurePricingLoaded } from "../shared/model-pricing.js";
32
32
  import { resolveToApiModelId } from "../shared/model-registry.js";
33
33
  import { buildChatModel } from "../shared/model-client.js";
34
+ import { checkDirectCredentials } from "../shared/llm-backend.js";
34
35
  import { classifyModelCallError } from "../shared/model-error.js";
35
36
 
36
37
  export interface LlmCallConfig {
@@ -169,23 +170,15 @@ export async function callLlmAction(
169
170
  `structured=${!!config.response_schema} execution=${executionId}`,
170
171
  );
171
172
 
172
- // Direct mode (no proxy) requires the provider's own API key. Validated here
173
- // rather than in buildChatModel so the shared module stays free of Temporal
174
- // failure types.
173
+ // Direct mode (no proxy) requires a usable credential path the provider's
174
+ // own API key, or a configured model backend that authenticates itself
175
+ // (vertex uses Google ADC; requiring an Anthropic key there would reject a
176
+ // correctly-configured deployment). Validated here rather than in
177
+ // buildChatModel so the shared module stays free of Temporal failure types.
175
178
  if (!proxyActive) {
176
- if (provider === "openai" && !process.env.OPENAI_API_KEY) {
177
- throw ApplicationFailure.nonRetryable(
178
- `OPENAI_API_KEY is not set and no proxy is configured. ` +
179
- `Set the API key in your environment or connect to a Stigmer Cloud deployment.`,
180
- "LLM_MISSING_API_KEY",
181
- );
182
- }
183
- if (provider === "anthropic" && !process.env.ANTHROPIC_API_KEY) {
184
- throw ApplicationFailure.nonRetryable(
185
- `ANTHROPIC_API_KEY is not set and no proxy is configured. ` +
186
- `Set the API key in your environment or connect to a Stigmer Cloud deployment.`,
187
- "LLM_MISSING_API_KEY",
188
- );
179
+ const missing = checkDirectCredentials(provider);
180
+ if (missing !== null) {
181
+ throw ApplicationFailure.nonRetryable(missing, "LLM_MISSING_API_KEY");
189
182
  }
190
183
  }
191
184
 
@@ -23,6 +23,8 @@ import { SystemMessage, HumanMessage } from "@langchain/core/messages";
23
23
  import { activityStarted, activityFinished } from "../idle-watchdog.js";
24
24
  import { getSummarizationModel } from "../shared/model-registry.js";
25
25
  import { buildChatModel } from "../shared/model-client.js";
26
+ import { checkDirectCredentials } from "../shared/llm-backend.js";
27
+ import { tryInferProvider } from "../shared/llm-proxy.js";
26
28
  import type { Config } from "../config.js";
27
29
 
28
30
  const BATCH_SIZE = 40;
@@ -149,7 +151,8 @@ Output one classification per tool, maintaining the input order.`;
149
151
  // ─────────────────────────────────────────────────────────────────────────────
150
152
 
151
153
  export interface ClassifyToolsOptions {
152
- proxyEndpoint: string;
154
+ /** Null when the deployment has no proxy — the runner calls providers directly. */
155
+ proxyEndpoint: string | null;
153
156
  stigmerToken: string | null;
154
157
  primaryModel: string;
155
158
  }
@@ -166,6 +169,26 @@ export async function classifyTools(
166
169
 
167
170
  const model = await getSummarizationModel(options.primaryModel);
168
171
 
172
+ // Direct mode with no credential path fails closed up front, with one
173
+ // actionable message instead of a provider 401 per batch. The message
174
+ // names the economy model and its provider because classification's
175
+ // provider follows the economy-model resolution, not the operator's key:
176
+ // "OPENAI_API_KEY is missing" is baffling to an operator holding an
177
+ // Anthropic key unless the log says which model classification wanted.
178
+ if (!options.proxyEndpoint) {
179
+ const provider = tryInferProvider(model);
180
+ const missing = provider === null ? null : checkDirectCredentials(provider);
181
+ if (missing !== null) {
182
+ console.warn(
183
+ `[ClassifyToolApprovals] Cannot classify ${tools.length} tool(s) for ` +
184
+ `'${serverName}': the classifier model '${model}' (${provider}) has no ` +
185
+ `credential path — failing closed, every tool requires approval until ` +
186
+ `a reconnect re-classifies it. ${missing}`,
187
+ );
188
+ return fallbackApprovals(tools);
189
+ }
190
+ }
191
+
169
192
  const batches: ToolDescriptor[][] = [];
170
193
  for (let i = 0; i < tools.length; i += BATCH_SIZE) {
171
194
  batches.push(tools.slice(i, i + BATCH_SIZE));
@@ -236,7 +259,7 @@ interface ClassifyBatchParams {
236
259
  serverName: string;
237
260
  serverDescription: string;
238
261
  model: string;
239
- proxyEndpoint: string;
262
+ proxyEndpoint: string | null;
240
263
  stigmerToken: string | null;
241
264
  mcpServerId: string | null;
242
265
  batchIdx: number;
@@ -256,7 +279,7 @@ async function classifyBatch(params: ClassifyBatchParams): Promise<ToolApprovalR
256
279
  // primary this routes to Claude, not the hardcoded OpenAI path it used to.
257
280
  const { model: llm } = await buildChatModel({
258
281
  modelName: model,
259
- proxyEndpoint,
282
+ proxyEndpoint: proxyEndpoint ?? undefined,
260
283
  stigmerToken: stigmerToken ?? undefined,
261
284
  headerScope: { mcpServerId: mcpServerId ?? undefined },
262
285
  maxTokens,
@@ -388,8 +411,15 @@ export function createClassifyToolApprovalsActivities(config: Config) {
388
411
  `[ClassifyToolApprovals] Activity started: ${input.tools.length} tools for '${input.serverName}'`,
389
412
  );
390
413
 
414
+ // Only a real proxy is an LLM proxy. stigmerBackendEndpoint (the
415
+ // gRPC control plane) serves no /v1/proxy/llm route — coercing it
416
+ // into one here used to 404 every classification in unproxied OSS
417
+ // and silently disable the vertex backend for this activity. (The
418
+ // registry fetch legitimately falls back to the backend endpoint —
419
+ // see shared/registry-endpoint.ts — because the control plane DOES
420
+ // serve /v1/proxy/model-registry; that fallback does not port here.)
391
421
  return await classifyTools(input, {
392
- proxyEndpoint: config.proxyEndpoint ?? config.stigmerBackendEndpoint,
422
+ proxyEndpoint: config.proxyEndpoint,
393
423
  stigmerToken: config.stigmerToken,
394
424
  primaryModel: config.primaryModel,
395
425
  });
@@ -22,7 +22,7 @@ import { createHash } from "node:crypto";
22
22
  import { MultiServerMCPClient } from "@langchain/mcp-adapters";
23
23
  import { activityStarted, activityFinished } from "../idle-watchdog.js";
24
24
  import { StigmerClient } from "../client/stigmer-client.js";
25
- import { mcpServerToResolved } from "../shared/mcp-resolver.js";
25
+ import { mcpServerToResolved, type ResolvedMcpServer } from "../shared/mcp-resolver.js";
26
26
  import { toMcpClientConfig } from "../shared/mcp-manager.js";
27
27
  import {
28
28
  assertTransportAllowed,
@@ -31,11 +31,44 @@ import {
31
31
  } from "../shared/mcp-transport-guard.js";
32
32
  import { detectOAuthChallenge } from "../shared/mcp-oauth-detect.js";
33
33
  import { injectAnonymousCallerIdentityForDiscovery } from "../shared/caller-identity.js";
34
+ import { startHeartbeat } from "../shared/heartbeat.js";
34
35
  import { withTimeout } from "../shared/with-timeout.js";
35
36
  import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
37
+ import type { EnvVarDeclaration } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/spec_pb";
36
38
  import type { Config } from "../config.js";
37
39
 
38
- const SESSION_INIT_TIMEOUT_MS = 270_000;
40
+ /**
41
+ * MCP session-init bounds, per transport (issue #239).
42
+ *
43
+ * A remote endpoint that accepts a connection but never completes the MCP
44
+ * handshake would otherwise hang initialization forever: the MCP SDK's SSE
45
+ * transport resolves only on the server's `endpoint` event, with no timer of
46
+ * its own, and mcp-adapters silently falls back to SSE whenever the
47
+ * streamable-HTTP POST is answered with a 4xx (monday.com's endpoint did
48
+ * exactly this — 4xx on POST, then a silently-open SSE stream).
49
+ *
50
+ * HTTP endpoints get a short bound: there is nothing to install or compile,
51
+ * so a healthy endpoint completes the handshake in seconds — a short bound is
52
+ * what converts the silent hang into a fast, actionable failure (with room for
53
+ * the 10s OAuth re-probe on the failure path). stdio servers keep the generous
54
+ * bound because their first run may compile or install packages (`go run`,
55
+ * `npx`) — the cold-start case (issue #243). The OSS server's connect-workflow
56
+ * run timeout (connectTimeout, controller/connect.go) is derived from the
57
+ * stdio bound + the classification floor, so both errors stay reachable.
58
+ */
59
+ const HTTP_INIT_TIMEOUT_MS = 30_000;
60
+ const STDIO_INIT_TIMEOUT_MS = 270_000;
61
+
62
+ /**
63
+ * The server-side secret-redaction sentinel. Byte-for-byte the value both
64
+ * editions substitute for secret values a caller may not decrypt
65
+ * (`SecretEncryptionService.REDACTED_MARKER` in stigmer-cloud,
66
+ * `RedactedMarker` in the OSS Go server). Discovery dialing an
67
+ * endpoint with a redacted credential is guaranteed to fail confusingly
68
+ * (e.g. `Authorization: Bearer ***REDACTED***` → 401 → SSE-fallback limbo),
69
+ * so it is refused up front with an actionable error instead.
70
+ */
71
+ const REDACTED_MARKER = "***REDACTED***";
39
72
 
40
73
  const PLATFORM_INJECTABLE_MAP: Record<string, string> = {
41
74
  STIGMER_SERVER_ADDRESS: "STIGMER_MCP_PUBLIC_ENDPOINT",
@@ -95,6 +128,28 @@ export interface ToolApprovalDict {
95
128
  message: string;
96
129
  }
97
130
 
131
+ /**
132
+ * Raised when the credentials a server declares could not be delivered to
133
+ * discovery — the ExecutionContext read failed, came back empty, or returned
134
+ * redacted values (issue #239).
135
+ *
136
+ * Exists because the alternative is strictly worse: proceeding without the
137
+ * declared credentials either dies later as an opaque
138
+ * PlaceholderResolutionError (when a header/arg templates the missing var) or
139
+ * dials the endpoint with a garbage credential and strands the connect in the
140
+ * 4xx → SSE-fallback limbo this file's init bounds exist to contain. Failing
141
+ * here names the ROOT cause — credential delivery — instead of its downstream
142
+ * symptom. The message is user-facing and self-contained: it survives the
143
+ * Temporal boundary and the Go/Java connect wrappers include it in the
144
+ * user-facing failure text.
145
+ */
146
+ export class CredentialResolutionError extends Error {
147
+ constructor(message: string) {
148
+ super(message);
149
+ this.name = "CredentialResolutionError";
150
+ }
151
+ }
152
+
98
153
  // ─────────────────────────────────────────────────────────────────────────────
99
154
  // Tools Fingerprint (pure, deterministic, safe in workflow code)
100
155
  // ─────────────────────────────────────────────────────────────────────────────
@@ -242,14 +297,15 @@ export async function discoverMcpServer(
242
297
  const slug = mcpServer.metadata?.slug || mcpServerId;
243
298
  const previousState = extractPreviousState(mcpServer);
244
299
 
300
+ const declaredEnv = mcpServer.spec.env ?? {};
245
301
  const envVars = await resolveEnvVarsForDiscovery(
246
302
  stigmerClient,
247
303
  executionContextId ?? null,
304
+ slug,
305
+ declaredEnv,
248
306
  );
249
307
 
250
- const declaredEnvKeys = mcpServer.spec.env
251
- ? new Set(Object.keys(mcpServer.spec.env))
252
- : new Set<string>();
308
+ const declaredEnvKeys = new Set(Object.keys(declaredEnv));
253
309
  const platformEnv = injectPlatformEnv(declaredEnvKeys, envVars);
254
310
 
255
311
  // Discovery runs with no session, so every declared caller-identity key
@@ -277,6 +333,7 @@ export async function discoverMcpServer(
277
333
  const { tools, resourceTemplates } = await connectAndDiscover(
278
334
  slug,
279
335
  connectionConfig,
336
+ resolved,
280
337
  );
281
338
 
282
339
  const newFp = toolsFingerprint(tools);
@@ -300,51 +357,144 @@ export async function discoverMcpServer(
300
357
  // Env Var Resolution
301
358
  // ─────────────────────────────────────────────────────────────────────────────
302
359
 
360
+ /**
361
+ * Resolve the discovery env from the connect flow's ExecutionContext,
362
+ * failing CLOSED when a server's declared credentials cannot be delivered.
363
+ *
364
+ * The strictness is keyed on whether the server declares any NON-OPTIONAL
365
+ * env var ("credentials expected"):
366
+ *
367
+ * - Credentials expected + the EC read errors, or the EC is missing/empty →
368
+ * {@link CredentialResolutionError}. The backend only creates a connect EC
369
+ * when it resolved credentials to deliver, so an unreadable or empty EC is
370
+ * a delivery failure (auth/scope refusal, transient backend error), never a
371
+ * normal state. Limping ahead was issue #239's failure mode: discovery died
372
+ * later as an opaque PlaceholderResolutionError or a doomed dial.
373
+ * - Any delivered value equal to the redaction sentinel →
374
+ * {@link CredentialResolutionError}, regardless of optionality. A redacted
375
+ * value means the server-side decrypt gate refused THIS runner's credential
376
+ * class/scope and fell closed to redaction — dialing with the literal
377
+ * sentinel can only produce a misleading 401.
378
+ * - No non-optional declarations → the old lenient path (warn and continue):
379
+ * servers declaring nothing (or only optional/injected keys like the
380
+ * caller-identity family) legitimately discover without an EC.
381
+ */
303
382
  async function resolveEnvVarsForDiscovery(
304
383
  client: StigmerClient,
305
384
  executionContextId: string | null,
385
+ slug: string,
386
+ declaredEnv: Record<string, EnvVarDeclaration>,
306
387
  ): Promise<Record<string, string>> {
388
+ const requiredKeys = Object.entries(declaredEnv)
389
+ .filter(([, decl]) => !decl.optional)
390
+ .map(([key]) => key);
391
+ const credentialsExpected = requiredKeys.length > 0;
392
+
307
393
  if (!executionContextId) return {};
308
394
 
395
+ let execCtx: Awaited<ReturnType<typeof client.getExecutionContextByExecutionId>>;
309
396
  try {
310
- const execCtx = await client.getExecutionContextByExecutionId(
311
- executionContextId,
312
- );
313
-
314
- if (!execCtx?.spec?.data || Object.keys(execCtx.spec.data).length === 0) {
315
- console.warn(
316
- `[DiscoverMcpServer] ExecutionContext '${executionContextId}' not found ` +
317
- `or empty MCP server may not require environment variables`,
397
+ execCtx = await client.getExecutionContextByExecutionId(executionContextId);
398
+ } catch (err) {
399
+ const cause = err instanceof Error ? err.message : String(err);
400
+ if (credentialsExpected) {
401
+ throw new CredentialResolutionError(
402
+ `Could not resolve the credentials MCP server '${slug}' requires ` +
403
+ `(${requiredKeys.join(", ")}): the connect credential store was ` +
404
+ `unreadable (${cause}). This is a platform-side delivery failure, ` +
405
+ `not a problem with your credentials — retry the connect, and if it ` +
406
+ `persists, re-run the OAuth sign-in or re-enter the credentials.`,
318
407
  );
319
- return {};
320
408
  }
409
+ console.warn(
410
+ `[DiscoverMcpServer] Failed to resolve ExecutionContext '${executionContextId}': ` +
411
+ `${cause}`,
412
+ );
413
+ return {};
414
+ }
321
415
 
322
- const envVars: Record<string, string> = {};
323
- for (const [key, execValue] of Object.entries(execCtx.spec.data)) {
324
- envVars[key] = execValue.value;
416
+ const data = execCtx?.spec?.data ?? {};
417
+ if (Object.keys(data).length === 0) {
418
+ if (credentialsExpected) {
419
+ throw new CredentialResolutionError(
420
+ `MCP server '${slug}' requires ${requiredKeys.join(", ")}, but the ` +
421
+ `connect flow delivered no credentials. Re-run the OAuth sign-in ` +
422
+ `(or re-enter the credentials) and connect again.`,
423
+ );
325
424
  }
326
-
327
- console.log(
328
- `[DiscoverMcpServer] Resolved ${Object.keys(envVars).length} env var(s) ` +
329
- `from ExecutionContext '${executionContextId}'`,
330
- );
331
- return envVars;
332
- } catch (err) {
333
425
  console.warn(
334
- `[DiscoverMcpServer] Failed to resolve ExecutionContext '${executionContextId}': ` +
335
- `${err instanceof Error ? err.message : err}`,
426
+ `[DiscoverMcpServer] ExecutionContext '${executionContextId}' not found ` +
427
+ `or empty MCP server may not require environment variables`,
336
428
  );
337
429
  return {};
338
430
  }
431
+
432
+ const redactedKeys = Object.entries(data)
433
+ .filter(([, execValue]) => execValue.value === REDACTED_MARKER)
434
+ .map(([key]) => key);
435
+ if (redactedKeys.length > 0) {
436
+ throw new CredentialResolutionError(
437
+ `The credentials for MCP server '${slug}' were delivered redacted ` +
438
+ `(${redactedKeys.join(", ")}): the platform refused to decrypt them ` +
439
+ `for this runner. This is a platform-side authorization failure — ` +
440
+ `retry the connect, and report it if it persists.`,
441
+ );
442
+ }
443
+
444
+ const envVars: Record<string, string> = {};
445
+ for (const [key, execValue] of Object.entries(data)) {
446
+ envVars[key] = execValue.value;
447
+ }
448
+
449
+ console.log(
450
+ `[DiscoverMcpServer] Resolved ${Object.keys(envVars).length} env var(s) ` +
451
+ `from ExecutionContext '${executionContextId}'`,
452
+ );
453
+ return envVars;
339
454
  }
340
455
 
341
456
  // ─────────────────────────────────────────────────────────────────────────────
342
457
  // MCP Connection + Enumeration
343
458
  // ─────────────────────────────────────────────────────────────────────────────
344
459
 
460
+ /**
461
+ * Pick the session-init bound for a resolved server's transport.
462
+ *
463
+ * Exported for tests; pure so the timeout choice (the load-bearing half of
464
+ * the issue-#239 fix) can be pinned without wiring a slow clock.
465
+ */
466
+ export function initTimeoutMsFor(connectionType: ResolvedMcpServer["connectionType"]): number {
467
+ return connectionType === "stdio" ? STDIO_INIT_TIMEOUT_MS : HTTP_INIT_TIMEOUT_MS;
468
+ }
469
+
470
+ /**
471
+ * The user-facing message for a session-init timeout. Names the endpoint (or
472
+ * command) so the failure is diagnosable from the message alone, and explains
473
+ * the known silent-hang shape for HTTP endpoints — issue #239's mechanism.
474
+ */
475
+ export function initTimeoutMessageFor(slug: string, resolved: ResolvedMcpServer): string {
476
+ const seconds = Math.round(initTimeoutMsFor(resolved.connectionType) / 1000);
477
+ if (resolved.connectionType === "stdio") {
478
+ return (
479
+ `MCP server '${slug}' (command: ${resolved.command}) did not complete ` +
480
+ `MCP initialization within ${seconds}s. If this server requires ` +
481
+ `compilation or package installation on first run (e.g. go run, npx), ` +
482
+ `the cold start may have exceeded the discovery timeout.`
483
+ );
484
+ }
485
+ return (
486
+ `MCP server '${slug}' at ${resolved.url} did not complete MCP ` +
487
+ `initialization within ${seconds}s. The endpoint accepted the connection ` +
488
+ `but never finished the handshake — commonly an endpoint that rejects ` +
489
+ `streamable HTTP while leaving its SSE fallback stream silently open. ` +
490
+ `Verify the URL points at a live streamable-HTTP MCP endpoint.`
491
+ );
492
+ }
493
+
345
494
  async function connectAndDiscover(
346
495
  slug: string,
347
496
  connectionConfig: ReturnType<typeof toMcpClientConfig>,
497
+ resolved: ResolvedMcpServer,
348
498
  ): Promise<{
349
499
  tools: DiscoveredToolResult[];
350
500
  resourceTemplates: DiscoveredResourceTemplateResult[];
@@ -354,12 +504,10 @@ async function connectAndDiscover(
354
504
  const resourceTemplates: DiscoveredResourceTemplateResult[] = [];
355
505
 
356
506
  try {
357
- const timeoutMessage =
358
- `MCP server '${slug}' did not respond within ` +
359
- `${Math.round(SESSION_INIT_TIMEOUT_MS / 1000)}s. If this server requires compilation or ` +
360
- `package installation on first run (e.g. go run, npx), the cold ` +
361
- `start may have exceeded the discovery timeout.`;
362
- await withTimeout(SESSION_INIT_TIMEOUT_MS, timeoutMessage, async () => {
507
+ await withTimeout(
508
+ initTimeoutMsFor(resolved.connectionType),
509
+ () => initTimeoutMessageFor(slug, resolved),
510
+ async () => {
363
511
  await client.initializeConnections();
364
512
 
365
513
  const mcpClient = await client.getClient(slug);
@@ -461,12 +609,22 @@ export function createDiscoverMcpServerActivities(config: Config) {
461
609
  input: DiscoverMcpServerInput,
462
610
  ): Promise<DiscoverMcpServerOutput> => {
463
611
  activityStarted();
612
+ // The connect workflow proxies this activity with a 60s heartbeatTimeout,
613
+ // so it MUST heartbeat: before this loop existed, any discovery slower
614
+ // than 60s (stdio cold start — issue #243) or wedged on a silent remote
615
+ // (issue #239) was killed by Temporal with an opaque heartbeat timeout
616
+ // instead of reaching this file's actionable init-timeout errors.
617
+ const hb = startHeartbeat(15_000, () => ({
618
+ phase: "discovering_mcp_server",
619
+ mcpServerId: input.mcpServerId,
620
+ }));
464
621
  try {
465
622
  return await discoverMcpServer(input, {
466
623
  stigmerClient,
467
624
  transportPosture: resolveMcpTransportPosture(config.mode),
468
625
  });
469
626
  } finally {
627
+ hb.stop();
470
628
  activityFinished();
471
629
  }
472
630
  },
@@ -108,6 +108,14 @@ export interface CursorHookHarnessOptions {
108
108
  * non-pausing "unattended" kind and the adapt-and-explain agent message.
109
109
  */
110
110
  unattendedSkip?: boolean;
111
+ /**
112
+ * Per-server enabled_tools allow-lists (issue #350) — restricted servers
113
+ * only. The hook's manifest arm denies a beforeMCPExecution call whose
114
+ * mcp_server_name is listed here with a tool name outside its list (kind
115
+ * "disabled", ahead of every approval bypass). hookMcp payloads carry
116
+ * mcp_server_name "srv".
117
+ */
118
+ mcpServerEnabledTools?: Record<string, string[]>;
111
119
  }
112
120
 
113
121
  /**
@@ -170,6 +178,7 @@ export function setupCursorHookHarness(opts: CursorHookHarnessOptions = {}): Cur
170
178
  opts.captureIgnored ?? false,
171
179
  opts.gitWorkspace ?? true,
172
180
  opts.unattendedSkip ?? false,
181
+ opts.mcpServerEnabledTools ?? {},
173
182
  );
174
183
  writeFileSync(statePath, JSON.stringify(state), "utf-8");
175
184
  }
@@ -219,6 +219,20 @@ describe("buildApprovalState", () => {
219
219
  expect((state as unknown as Record<string, unknown>).builtInGatedList).toBeUndefined();
220
220
  });
221
221
 
222
+ it("carries the enabled_tools manifest (issue #350) and defaults it to empty", () => {
223
+ // Default: no restriction — the hook's disabled arm is inert.
224
+ const bare = buildApprovalState(mcpPolicies, false, new Set());
225
+ expect(bare.mcpServerEnabledTools).toEqual({});
226
+
227
+ // Restricted servers ride through verbatim for the hook's server-scoped
228
+ // membership check.
229
+ const restricted = buildApprovalState(
230
+ mcpPolicies, false, new Set(), undefined, false, false, true, false,
231
+ { planton: ["get_cloud_resource"] },
232
+ );
233
+ expect(restricted.mcpServerEnabledTools).toEqual({ planton: ["get_cloud_resource"] });
234
+ });
235
+
222
236
  it("emits the CONTENT token when a grant carries a content digest", () => {
223
237
  const grants = [{ toolName: "edit", mcpServerSlug: "", key: "write", salient: "/x/gated.txt", contentDigest: "deadbeef", sourceToolCallId: "consent-1" }];
224
238
  const state = buildApprovalState(mcpPolicies, false, new Set(), grants);