@stigmer/runner 3.12.2 → 3.12.4

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 (125) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/call-http.js +12 -0
  3. package/dist/activities/call-http.js.map +1 -1
  4. package/dist/activities/call-llm.d.ts +18 -0
  5. package/dist/activities/call-llm.js +56 -2
  6. package/dist/activities/call-llm.js.map +1 -1
  7. package/dist/activities/execute-cursor/agent-session-cache.d.ts +72 -0
  8. package/dist/activities/execute-cursor/agent-session-cache.js +186 -0
  9. package/dist/activities/execute-cursor/agent-session-cache.js.map +1 -0
  10. package/dist/activities/execute-cursor/index.js +61 -28
  11. package/dist/activities/execute-cursor/index.js.map +1 -1
  12. package/dist/activities/execute-cursor/service-tier.d.ts +5 -15
  13. package/dist/activities/execute-cursor/service-tier.js +5 -21
  14. package/dist/activities/execute-cursor/service-tier.js.map +1 -1
  15. package/dist/activities/execute-cursor/skill-resolver.d.ts +15 -0
  16. package/dist/activities/execute-cursor/skill-resolver.js +45 -7
  17. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  18. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.d.ts +9 -1
  19. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js +13 -2
  20. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/setup.js +14 -0
  22. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/shell-env.d.ts +8 -5
  24. package/dist/activities/execute-deep-agent/shell-env.js +10 -7
  25. package/dist/activities/execute-deep-agent/shell-env.js.map +1 -1
  26. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +5 -1
  27. package/dist/activities/execute-deep-agent/subagent-wiring.js +9 -1
  28. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  29. package/dist/client/stigmer-client.d.ts +9 -1
  30. package/dist/client/stigmer-client.js +10 -0
  31. package/dist/client/stigmer-client.js.map +1 -1
  32. package/dist/config.js +10 -5
  33. package/dist/config.js.map +1 -1
  34. package/dist/encryption/config.js +7 -2
  35. package/dist/encryption/config.js.map +1 -1
  36. package/dist/main.js +12 -6
  37. package/dist/main.js.map +1 -1
  38. package/dist/middleware/index.d.ts +6 -5
  39. package/dist/middleware/index.js +8 -5
  40. package/dist/middleware/index.js.map +1 -1
  41. package/dist/middleware/tool-intent.d.ts +57 -0
  42. package/dist/middleware/tool-intent.js +152 -0
  43. package/dist/middleware/tool-intent.js.map +1 -0
  44. package/dist/payload-codecs.js +2 -1
  45. package/dist/payload-codecs.js.map +1 -1
  46. package/dist/runner-manager.js +20 -7
  47. package/dist/runner-manager.js.map +1 -1
  48. package/dist/runner.js +19 -6
  49. package/dist/runner.js.map +1 -1
  50. package/dist/shared/fingerprint-secret.d.ts +3 -2
  51. package/dist/shared/fingerprint-secret.js +5 -3
  52. package/dist/shared/fingerprint-secret.js.map +1 -1
  53. package/dist/shared/llm-backend.js +8 -1
  54. package/dist/shared/llm-backend.js.map +1 -1
  55. package/dist/shared/model-client.d.ts +15 -0
  56. package/dist/shared/model-client.js +57 -13
  57. package/dist/shared/model-client.js.map +1 -1
  58. package/dist/shared/registry-endpoint.d.ts +5 -0
  59. package/dist/shared/registry-endpoint.js +7 -1
  60. package/dist/shared/registry-endpoint.js.map +1 -1
  61. package/dist/shared/runner-credential-keys.d.ts +26 -1
  62. package/dist/shared/runner-credential-keys.js +34 -1
  63. package/dist/shared/runner-credential-keys.js.map +1 -1
  64. package/dist/shared/runner-credential-store.d.ts +77 -0
  65. package/dist/shared/runner-credential-store.js +111 -0
  66. package/dist/shared/runner-credential-store.js.map +1 -0
  67. package/dist/shared/service-tier.d.ts +55 -0
  68. package/dist/shared/service-tier.js +67 -0
  69. package/dist/shared/service-tier.js.map +1 -0
  70. package/dist/shared/skill-writer.js +2 -2
  71. package/dist/shared/skill-writer.js.map +1 -1
  72. package/dist/shared/zip-extract.d.ts +10 -3
  73. package/dist/shared/zip-extract.js +10 -3
  74. package/dist/shared/zip-extract.js.map +1 -1
  75. package/dist/workflow-engine/tasks/call-function.d.ts +14 -0
  76. package/dist/workflow-engine/tasks/call-function.js +49 -5
  77. package/dist/workflow-engine/tasks/call-function.js.map +1 -1
  78. package/dist/workflow-engine/types.d.ts +6 -0
  79. package/dist/workflow-engine/types.js.map +1 -1
  80. package/dist/workflows/engine-core.js +36 -8
  81. package/dist/workflows/engine-core.js.map +1 -1
  82. package/package.json +2 -2
  83. package/src/activities/__tests__/call-http.test.ts +36 -0
  84. package/src/activities/__tests__/call-llm.test.ts +77 -0
  85. package/src/activities/call-http.ts +17 -0
  86. package/src/activities/call-llm.ts +78 -2
  87. package/src/activities/execute-cursor/__tests__/agent-session-cache.test.ts +220 -0
  88. package/src/activities/execute-cursor/__tests__/service-tier.test.ts +1 -1
  89. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +104 -1
  90. package/src/activities/execute-cursor/agent-session-cache.ts +229 -0
  91. package/src/activities/execute-cursor/index.ts +66 -20
  92. package/src/activities/execute-cursor/service-tier.ts +5 -29
  93. package/src/activities/execute-cursor/skill-resolver.ts +52 -8
  94. package/src/activities/execute-deep-agent/__test-utils__/scripted-model.ts +13 -2
  95. package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +21 -20
  96. package/src/activities/execute-deep-agent/setup.ts +15 -0
  97. package/src/activities/execute-deep-agent/shell-env.ts +10 -7
  98. package/src/activities/execute-deep-agent/subagent-wiring.ts +10 -1
  99. package/src/client/stigmer-client.ts +12 -1
  100. package/src/config.ts +10 -5
  101. package/src/encryption/config.ts +8 -2
  102. package/src/main.ts +16 -6
  103. package/src/middleware/__tests__/tool-intent.test.ts +266 -0
  104. package/src/middleware/index.ts +9 -5
  105. package/src/middleware/tool-intent.ts +174 -0
  106. package/src/payload-codecs.ts +2 -1
  107. package/src/runner-manager.ts +29 -6
  108. package/src/runner.ts +25 -6
  109. package/src/shared/__tests__/model-client.test.ts +99 -0
  110. package/src/shared/__tests__/runner-credential-store.test.ts +155 -0
  111. package/src/shared/__tests__/zip-extract.test.ts +46 -11
  112. package/src/shared/fingerprint-secret.ts +5 -3
  113. package/src/shared/llm-backend.ts +7 -1
  114. package/src/shared/model-client.ts +76 -13
  115. package/src/shared/registry-endpoint.ts +9 -1
  116. package/src/shared/runner-credential-keys.ts +36 -1
  117. package/src/shared/runner-credential-store.ts +115 -0
  118. package/src/shared/service-tier.ts +78 -0
  119. package/src/shared/skill-writer.ts +2 -2
  120. package/src/shared/zip-extract.ts +14 -7
  121. package/src/workflow-engine/__tests__/golden-execution.test.ts +20 -1
  122. package/src/workflow-engine/__tests__/tasks/call-function.test.ts +105 -0
  123. package/src/workflow-engine/tasks/call-function.ts +74 -13
  124. package/src/workflow-engine/types.ts +6 -0
  125. package/src/workflows/engine-core.ts +39 -8
@@ -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 { getRunnerSecret } from "../shared/runner-credential-store.js";
34
35
  import { checkDirectCredentials } from "../shared/llm-backend.js";
35
36
  import { classifyModelCallError } from "../shared/model-error.js";
36
37
 
@@ -41,7 +42,25 @@ export interface LlmCallConfig {
41
42
  readonly response_schema?: Record<string, unknown>;
42
43
  readonly temperature?: number;
43
44
  readonly max_tokens?: number;
45
+ /**
46
+ * Author-declared call budget in seconds (proto: LlmCallTaskConfig.timeout,
47
+ * 1-600). Bounds the provider request via buildChatModel's timeout seam;
48
+ * a breach fails non-retryably with LLM_TIMEOUT (#686). The engine widens
49
+ * the activity's startToClose to fit values above the default 5m.
50
+ */
44
51
  readonly timeout?: number;
52
+ /**
53
+ * Schema-validation policy (proto: LlmCallTaskConfig.on_invalid). The
54
+ * retry/fallback ORCHESTRATION lives in the workflow engine
55
+ * (call-function.ts, mirroring call-agent.ts); this activity only reads
56
+ * it to pick the failure channel: soft policies get a `parse_error`
57
+ * result the engine can act on, the default throws LLM_SCHEMA_VALIDATION.
58
+ */
59
+ readonly on_invalid?: string;
60
+ /** Engine-owned (see on_invalid); accepted here so config passes through. */
61
+ readonly max_retries?: number;
62
+ /** Engine-owned (see on_invalid); accepted here so config passes through. */
63
+ readonly fallback_task?: string;
45
64
  }
46
65
 
47
66
  export interface LlmCallResult {
@@ -142,6 +161,18 @@ function classifyAndThrowLlmError(
142
161
  );
143
162
  }
144
163
 
164
+ /**
165
+ * Matches the request-timeout errors the provider SDKs raise when
166
+ * buildChatModel's timeout bound fires (OpenAI: APIConnectionTimeoutError
167
+ * "Request timed out."; Anthropic mirrors the shape). Only consulted when
168
+ * the task declared an explicit `timeout`, so the loose message match
169
+ * cannot reclassify errors on unbudgeted calls.
170
+ */
171
+ function isRequestTimeoutError(err: unknown): boolean {
172
+ if (!(err instanceof Error)) return false;
173
+ return err.name === "APIConnectionTimeoutError" || /timed?\s*out/i.test(err.message);
174
+ }
175
+
145
176
  export async function callLlmAction(
146
177
  config: LlmCallConfig,
147
178
  runtimeEnv: Record<string, unknown>,
@@ -162,7 +193,10 @@ export async function callLlmAction(
162
193
  await ensurePricingLoaded().catch(() => {});
163
194
 
164
195
  const proxyEndpoint = process.env.STIGMER_PROXY_ENDPOINT;
165
- const stigmerToken = process.env.STIGMER_TOKEN;
196
+ // Per-call store read: rotation (manager updateToken / static renewal)
197
+ // must be visible to in-flight workers, exactly like the env read it
198
+ // replaced (#508).
199
+ const stigmerToken = getRunnerSecret("STIGMER_TOKEN");
166
200
  const proxyActive = !!(proxyEndpoint && stigmerToken);
167
201
 
168
202
  console.log(
@@ -185,6 +219,12 @@ export async function callLlmAction(
185
219
  // Anthropic requires an explicit maxTokens; preserve the 4096 default here
186
220
  // (buildChatModel intentionally imposes none). resolvedModel is already an
187
221
  // API id, so buildChatModel's resolve step is a no-op for it.
222
+ //
223
+ // maxRetries: 0 — Temporal owns retries for this activity (callProxy
224
+ // retries up to 5x). LangChain's default retry loop underneath would
225
+ // multiply that, and it also blind-retries schema-validation failures:
226
+ // before this was pinned to 0, one non-conforming structured response
227
+ // burned ~7 identical model calls before surfacing (#686).
188
228
  const { model } = await buildChatModel({
189
229
  modelName: resolvedModel,
190
230
  proxyEndpoint: proxyActive ? proxyEndpoint : undefined,
@@ -192,6 +232,10 @@ export async function callLlmAction(
192
232
  headerScope: { workflowExecutionId: executionId },
193
233
  temperature: config.temperature,
194
234
  maxTokens: provider === "anthropic" ? (config.max_tokens ?? 4096) : config.max_tokens,
235
+ // The author's per-call budget rides the same seam as the operator's
236
+ // STIGMER_LLM_REQUEST_TIMEOUT_MS (#468); an explicit value wins there.
237
+ timeoutMs: config.timeout ? config.timeout * 1000 : undefined,
238
+ maxRetries: 0,
195
239
  });
196
240
 
197
241
  const messages: (HumanMessage | SystemMessage)[] = [];
@@ -200,6 +244,14 @@ export async function callLlmAction(
200
244
  }
201
245
  messages.push(new HumanMessage(config.prompt));
202
246
 
247
+ // Soft schema-failure channel: when the engine will orchestrate
248
+ // ON_INVALID_RETRY / ON_INVALID_FALLBACK, a validation miss is a signal
249
+ // (parse_error result), not a failure — the engine re-prompts or
250
+ // branches. ON_INVALID_FAIL (and unset) keeps the throwing contract.
251
+ const softSchemaFailure =
252
+ config.on_invalid === "ON_INVALID_RETRY" ||
253
+ config.on_invalid === "ON_INVALID_FALLBACK";
254
+
203
255
  let result: LlmCallResult;
204
256
 
205
257
  try {
@@ -231,7 +283,31 @@ export async function callLlmAction(
231
283
  };
232
284
  }
233
285
  } catch (err) {
234
- classifyAndThrowLlmError(err, modelId, provider, proxyActive);
286
+ if (softSchemaFailure && err instanceof z.ZodError) {
287
+ // Usage is unrecoverable here — the structured runnable throws
288
+ // before exposing the raw response (same loss as the throwing path).
289
+ result = {
290
+ input_tokens: 0,
291
+ output_tokens: 0,
292
+ result: undefined,
293
+ model: modelId,
294
+ provider,
295
+ parse_error: err.errors
296
+ .map((e) => `${e.path.join(".")}: ${e.message}`)
297
+ .join("; "),
298
+ };
299
+ } else if (config.timeout && isRequestTimeoutError(err)) {
300
+ // The author declared this budget in the task config; breaching it is
301
+ // a task failure they can catch, not a transient to retry — Temporal
302
+ // re-running the same over-budget call 5x would multiply the wait.
303
+ throw ApplicationFailure.nonRetryable(
304
+ `LLM call for model "${modelId}" timed out after ${config.timeout}s (task timeout)`,
305
+ "LLM_TIMEOUT",
306
+ { timeoutSeconds: config.timeout },
307
+ );
308
+ } else {
309
+ classifyAndThrowLlmError(err, modelId, provider, proxyActive);
310
+ }
235
311
  }
236
312
 
237
313
  const costMicros = computeLlmCostMicros(config.model, result.input_tokens, result.output_tokens);
@@ -0,0 +1,220 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ cacheSessionAgent,
4
+ closeAllCachedAgents,
5
+ computeAgentFingerprint,
6
+ evictSessionAgent,
7
+ takeCachedAgent,
8
+ _parkedAgentCountForTests,
9
+ _resetAgentSessionCacheForTests,
10
+ type CacheableAgent,
11
+ } from "../agent-session-cache.js";
12
+
13
+ function makeAgent(agentId = "agent-1"): CacheableAgent & { close: ReturnType<typeof vi.fn> } {
14
+ return { agentId, close: vi.fn() };
15
+ }
16
+
17
+ const FP = "fingerprint-a";
18
+ const OTHER_FP = "fingerprint-b";
19
+
20
+ describe("agent-session-cache", () => {
21
+ beforeEach(() => {
22
+ _resetAgentSessionCacheForTests();
23
+ delete process.env.STIGMER_CURSOR_AGENT_CACHE_TTL_MS;
24
+ });
25
+
26
+ afterEach(() => {
27
+ _resetAgentSessionCacheForTests();
28
+ vi.useRealTimers();
29
+ });
30
+
31
+ describe("checkout semantics", () => {
32
+ it("park then take returns the same agent without closing it", () => {
33
+ const agent = makeAgent();
34
+ cacheSessionAgent("ses-1", agent, FP);
35
+
36
+ const taken = takeCachedAgent("ses-1", FP, "agent-1");
37
+
38
+ expect(taken).toBe(agent);
39
+ expect(agent.close).not.toHaveBeenCalled();
40
+ });
41
+
42
+ it("checkout is exclusive — a second take misses", () => {
43
+ cacheSessionAgent("ses-1", makeAgent(), FP);
44
+
45
+ expect(takeCachedAgent("ses-1", FP, "agent-1")).toBeDefined();
46
+ expect(takeCachedAgent("ses-1", FP, "agent-1")).toBeUndefined();
47
+ });
48
+
49
+ it("an empty expectedAgentId (first-execution shape) still matches", () => {
50
+ const agent = makeAgent();
51
+ cacheSessionAgent("ses-1", agent, FP);
52
+
53
+ expect(takeCachedAgent("ses-1", FP, "")).toBe(agent);
54
+ });
55
+
56
+ it("misses for a different session", () => {
57
+ cacheSessionAgent("ses-1", makeAgent(), FP);
58
+
59
+ expect(takeCachedAgent("ses-2", FP, "agent-1")).toBeUndefined();
60
+ expect(_parkedAgentCountForTests()).toBe(1);
61
+ });
62
+ });
63
+
64
+ describe("reuse guards", () => {
65
+ it("fingerprint drift closes the parked agent and misses", () => {
66
+ const agent = makeAgent();
67
+ cacheSessionAgent("ses-1", agent, FP);
68
+
69
+ const taken = takeCachedAgent("ses-1", OTHER_FP, "agent-1");
70
+
71
+ expect(taken).toBeUndefined();
72
+ expect(agent.close).toHaveBeenCalledTimes(1);
73
+ expect(_parkedAgentCountForTests()).toBe(0);
74
+ });
75
+
76
+ it("agentId mismatch (harnessStateId replaced elsewhere) closes and misses", () => {
77
+ const agent = makeAgent("agent-old");
78
+ cacheSessionAgent("ses-1", agent, FP);
79
+
80
+ const taken = takeCachedAgent("ses-1", FP, "agent-new");
81
+
82
+ expect(taken).toBeUndefined();
83
+ expect(agent.close).toHaveBeenCalledTimes(1);
84
+ });
85
+ });
86
+
87
+ describe("lifetime bounds", () => {
88
+ it("re-parking a session closes the displaced agent", () => {
89
+ const first = makeAgent("agent-1");
90
+ const second = makeAgent("agent-2");
91
+ cacheSessionAgent("ses-1", first, FP);
92
+ cacheSessionAgent("ses-1", second, FP);
93
+
94
+ expect(first.close).toHaveBeenCalledTimes(1);
95
+ expect(takeCachedAgent("ses-1", FP, "agent-2")).toBe(second);
96
+ });
97
+
98
+ it("idle TTL evicts and closes", () => {
99
+ vi.useFakeTimers();
100
+ process.env.STIGMER_CURSOR_AGENT_CACHE_TTL_MS = "1000";
101
+ const agent = makeAgent();
102
+ cacheSessionAgent("ses-1", agent, FP);
103
+
104
+ vi.advanceTimersByTime(1001);
105
+
106
+ expect(agent.close).toHaveBeenCalledTimes(1);
107
+ expect(_parkedAgentCountForTests()).toBe(0);
108
+ });
109
+
110
+ it("checkout before the TTL fires disarms the eviction timer", () => {
111
+ vi.useFakeTimers();
112
+ process.env.STIGMER_CURSOR_AGENT_CACHE_TTL_MS = "1000";
113
+ const agent = makeAgent();
114
+ cacheSessionAgent("ses-1", agent, FP);
115
+
116
+ const taken = takeCachedAgent("ses-1", FP, "agent-1");
117
+ vi.advanceTimersByTime(5000);
118
+
119
+ expect(taken).toBe(agent);
120
+ expect(agent.close).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it("the LRU cap closes the oldest parked agent", () => {
124
+ vi.useFakeTimers();
125
+ const first = makeAgent("agent-0");
126
+ cacheSessionAgent("ses-0", first, FP);
127
+ for (let i = 1; i < 32; i++) {
128
+ vi.advanceTimersByTime(1);
129
+ cacheSessionAgent(`ses-${i}`, makeAgent(`agent-${i}`), FP);
130
+ }
131
+
132
+ vi.advanceTimersByTime(1);
133
+ cacheSessionAgent("ses-32", makeAgent("agent-32"), FP);
134
+
135
+ expect(first.close).toHaveBeenCalledTimes(1);
136
+ expect(_parkedAgentCountForTests()).toBe(32);
137
+ });
138
+
139
+ it("an empty sessionId is never parked — the lease releases immediately", () => {
140
+ const agent = makeAgent();
141
+ cacheSessionAgent("", agent, FP);
142
+
143
+ expect(agent.close).toHaveBeenCalledTimes(1);
144
+ expect(_parkedAgentCountForTests()).toBe(0);
145
+ });
146
+ });
147
+
148
+ describe("explicit release", () => {
149
+ it("evictSessionAgent closes the parked agent", () => {
150
+ const agent = makeAgent();
151
+ cacheSessionAgent("ses-1", agent, FP);
152
+
153
+ evictSessionAgent("ses-1");
154
+
155
+ expect(agent.close).toHaveBeenCalledTimes(1);
156
+ expect(_parkedAgentCountForTests()).toBe(0);
157
+ });
158
+
159
+ it("closeAllCachedAgents closes everything (worker shutdown)", () => {
160
+ const a = makeAgent("agent-a");
161
+ const b = makeAgent("agent-b");
162
+ cacheSessionAgent("ses-a", a, FP);
163
+ cacheSessionAgent("ses-b", b, FP);
164
+
165
+ closeAllCachedAgents();
166
+
167
+ expect(a.close).toHaveBeenCalledTimes(1);
168
+ expect(b.close).toHaveBeenCalledTimes(1);
169
+ expect(_parkedAgentCountForTests()).toBe(0);
170
+ });
171
+
172
+ it("a close() that throws never breaks eviction", () => {
173
+ const agent = makeAgent();
174
+ agent.close.mockImplementation(() => {
175
+ throw new Error("already disposed");
176
+ });
177
+ cacheSessionAgent("ses-1", agent, FP);
178
+
179
+ expect(() => evictSessionAgent("ses-1")).not.toThrow();
180
+ expect(_parkedAgentCountForTests()).toBe(0);
181
+ });
182
+ });
183
+
184
+ describe("computeAgentFingerprint", () => {
185
+ const baseOptions = {
186
+ apiKey: "sk-secret-key",
187
+ model: "gpt-5",
188
+ modelParams: [{ key: "tier", value: "standard" }],
189
+ workspaceDirs: ["/workspace/app"],
190
+ mcpServers: { github: { command: "npx", env: { TOKEN: "t-1" } } },
191
+ };
192
+
193
+ it("is stable across key ordering", () => {
194
+ const reordered = {
195
+ mcpServers: { github: { env: { TOKEN: "t-1" }, command: "npx" } },
196
+ workspaceDirs: ["/workspace/app"],
197
+ modelParams: [{ key: "tier", value: "standard" }],
198
+ model: "gpt-5",
199
+ apiKey: "sk-secret-key",
200
+ };
201
+ expect(computeAgentFingerprint(baseOptions)).toBe(computeAgentFingerprint(reordered));
202
+ });
203
+
204
+ it("changes when any acquisition input changes", () => {
205
+ const base = computeAgentFingerprint(baseOptions);
206
+ expect(computeAgentFingerprint({ ...baseOptions, apiKey: "sk-rotated" })).not.toBe(base);
207
+ expect(computeAgentFingerprint({ ...baseOptions, model: "gpt-5-mini" })).not.toBe(base);
208
+ expect(
209
+ computeAgentFingerprint({
210
+ ...baseOptions,
211
+ mcpServers: { github: { command: "npx", env: { TOKEN: "t-2" } } },
212
+ }),
213
+ ).not.toBe(base);
214
+ });
215
+
216
+ it("never embeds the raw api key", () => {
217
+ expect(computeAgentFingerprint(baseOptions)).not.toContain("sk-secret-key");
218
+ });
219
+ });
220
+ });
@@ -19,10 +19,10 @@ vi.mock("@cursor/sdk", () => ({
19
19
  }));
20
20
 
21
21
  import {
22
- resolveEffectiveServiceTier,
23
22
  resolveServiceTierParams,
24
23
  resetCatalogCacheForTests,
25
24
  } from "../service-tier.js";
25
+ import { resolveEffectiveServiceTier } from "../../../shared/service-tier.js";
26
26
 
27
27
  const CATALOG = [
28
28
  {
@@ -2,9 +2,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { resolveSkills } from "../skill-resolver.js";
5
+ import { ConnectError, Code } from "@connectrpc/connect";
6
+ import { resolveSkills, downloadArtifact } from "../skill-resolver.js";
6
7
  import { buildZip } from "../../../__test-utils__/zip-fixtures.js";
7
8
 
9
+ /** A server that predates the transfer lane (#675) answers the mint RPC
10
+ * with UNIMPLEMENTED — the default posture for these tests, which keeps
11
+ * every pre-existing case pinned to the unary getSkillArtifact fallback. */
12
+ function unimplementedMint() {
13
+ return vi.fn().mockRejectedValue(new ConnectError("unimplemented", Code.Unimplemented));
14
+ }
15
+
8
16
  // ─── Helpers ─────────────────────────────────────────────────────────────
9
17
 
10
18
  function makeTempDir(prefix: string): string {
@@ -73,6 +81,7 @@ describe("resolveSkills — artifact extraction", () => {
73
81
  const client = {
74
82
  getSkillByReference: vi.fn(),
75
83
  getSkillArtifact: vi.fn().mockResolvedValue({ artifact: new Uint8Array(0) }),
84
+ getSkillArtifactDownloadUrl: unimplementedMint(),
76
85
  ...clientOverrides,
77
86
  } as any;
78
87
 
@@ -380,3 +389,97 @@ describe("resolveSkills — artifact extraction", () => {
380
389
  }
381
390
  });
382
391
  });
392
+
393
+ // ─── downloadArtifact — transfer lane routing (#675) ─────────────────────
394
+
395
+ describe("downloadArtifact — transfer lane routing", () => {
396
+ afterEach(() => {
397
+ vi.unstubAllGlobals();
398
+ });
399
+
400
+ function makeClient(overrides: Record<string, any> = {}) {
401
+ return {
402
+ getSkillArtifact: vi.fn().mockResolvedValue({ artifact: new Uint8Array(0) }),
403
+ getSkillArtifactDownloadUrl: unimplementedMint(),
404
+ ...overrides,
405
+ } as any;
406
+ }
407
+
408
+ it("fetches bytes over HTTP when the server mints a download URL", async () => {
409
+ const bytes = new Uint8Array([1, 2, 3, 4, 5]);
410
+ const client = makeClient({
411
+ getSkillArtifactDownloadUrl: vi.fn().mockResolvedValue({
412
+ url: "http://localhost:7234/v1/skill-artifacts/skills/abc.zip",
413
+ sizeBytes: 5n,
414
+ ttlSeconds: 0,
415
+ }),
416
+ });
417
+ const fetchMock = vi.fn().mockResolvedValue({
418
+ ok: true,
419
+ arrayBuffer: async () => bytes.buffer,
420
+ });
421
+ vi.stubGlobal("fetch", fetchMock);
422
+
423
+ const got = await downloadArtifact(client, "skills/abc.zip");
424
+
425
+ expect(got).toEqual(bytes);
426
+ expect(fetchMock).toHaveBeenCalledWith("http://localhost:7234/v1/skill-artifacts/skills/abc.zip");
427
+ // The unary lane (10MB-capped) must not be touched when the URL lane works.
428
+ expect(client.getSkillArtifact).not.toHaveBeenCalled();
429
+ });
430
+
431
+ it("falls back to the unary RPC when the server predates the lane", async () => {
432
+ const bytes = new Uint8Array([9, 9]);
433
+ const client = makeClient({
434
+ getSkillArtifact: vi.fn().mockResolvedValue({ artifact: bytes }),
435
+ });
436
+ vi.stubGlobal("fetch", vi.fn()); // must never be called
437
+
438
+ const got = await downloadArtifact(client, "skills/abc.zip");
439
+
440
+ expect(got).toEqual(bytes);
441
+ expect(client.getSkillArtifact).toHaveBeenCalledWith("skills/abc.zip");
442
+ expect(fetch).not.toHaveBeenCalled();
443
+ });
444
+
445
+ it("does NOT fall back on non-Unimplemented mint failures", async () => {
446
+ const client = makeClient({
447
+ getSkillArtifactDownloadUrl: vi.fn().mockRejectedValue(
448
+ new ConnectError("boom", Code.Internal),
449
+ ),
450
+ });
451
+
452
+ await expect(downloadArtifact(client, "skills/abc.zip")).rejects.toThrow("boom");
453
+ // Falling back here would mask real server faults behind the capped lane.
454
+ expect(client.getSkillArtifact).not.toHaveBeenCalled();
455
+ });
456
+
457
+ it("rejects truncated fetches via the minted size", async () => {
458
+ const client = makeClient({
459
+ getSkillArtifactDownloadUrl: vi.fn().mockResolvedValue({
460
+ url: "http://localhost:7234/v1/skill-artifacts/skills/abc.zip",
461
+ sizeBytes: 100n,
462
+ ttlSeconds: 0,
463
+ }),
464
+ });
465
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
466
+ ok: true,
467
+ arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
468
+ }));
469
+
470
+ await expect(downloadArtifact(client, "skills/abc.zip")).rejects.toThrow(/truncated/);
471
+ });
472
+
473
+ it("surfaces HTTP failures with the status code", async () => {
474
+ const client = makeClient({
475
+ getSkillArtifactDownloadUrl: vi.fn().mockResolvedValue({
476
+ url: "http://localhost:7234/v1/skill-artifacts/skills/gone.zip",
477
+ sizeBytes: 0n,
478
+ ttlSeconds: 0,
479
+ }),
480
+ });
481
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404 }));
482
+
483
+ await expect(downloadArtifact(client, "skills/gone.zip")).rejects.toThrow(/HTTP 404/);
484
+ });
485
+ });