@stigmer/runner 3.9.0 → 3.10.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 (28) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.js +7 -0
  3. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  4. package/dist/activities/execute-cursor/index.js +8 -1
  5. package/dist/activities/execute-cursor/index.js.map +1 -1
  6. package/dist/activities/execute-deep-agent/setup.js +10 -3
  7. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  8. package/dist/shared/attachment-vision.d.ts +43 -2
  9. package/dist/shared/attachment-vision.js +72 -6
  10. package/dist/shared/attachment-vision.js.map +1 -1
  11. package/dist/shared/mcp-manager.d.ts +14 -1
  12. package/dist/shared/mcp-manager.js +20 -21
  13. package/dist/shared/mcp-manager.js.map +1 -1
  14. package/dist/shared/model-registry.d.ts +20 -2
  15. package/dist/shared/model-registry.js +37 -2
  16. package/dist/shared/model-registry.js.map +1 -1
  17. package/package.json +2 -2
  18. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +39 -0
  19. package/src/activities/execute-cursor/attachment-resolver.ts +7 -0
  20. package/src/activities/execute-cursor/index.ts +8 -1
  21. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +22 -0
  22. package/src/activities/execute-deep-agent/setup.ts +10 -3
  23. package/src/shared/__tests__/attachment-vision.test.ts +97 -0
  24. package/src/shared/__tests__/mcp-manager.test.ts +87 -1
  25. package/src/shared/__tests__/model-registry.test.ts +71 -0
  26. package/src/shared/attachment-vision.ts +92 -9
  27. package/src/shared/mcp-manager.ts +22 -22
  28. package/src/shared/model-registry.ts +50 -2
@@ -143,6 +143,71 @@ describe("VisionBudget.offer — eligibility", () => {
143
143
  });
144
144
  });
145
145
 
146
+ describe("VisionBudget — model vision capability gate", () => {
147
+ const blind = () => new VisionBudget(DEEP_AGENT_VISION_PROFILE, { modelVision: false });
148
+
149
+ it("degrades every recognizable image with model_no_vision when the model is flagged blind", () => {
150
+ expect(blind().offer("photo.png", "image/png", imageBytes("png"))).toEqual({
151
+ kind: "degraded",
152
+ reason: "model_no_vision",
153
+ });
154
+ // Sniff-authoritative acceptance is gated too: real image bytes with a
155
+ // non-image declared type are still image-shaped.
156
+ expect(blind().offer("blob.bin", "application/octet-stream", imageBytes("jpeg"))).toEqual({
157
+ kind: "degraded",
158
+ reason: "model_no_vision",
159
+ });
160
+ });
161
+
162
+ it("reports model_no_vision (not type_mismatch) for declared-but-unsniffable images", () => {
163
+ // A HEIC renamed .jpg on a blind model: "unreadable format" would invite
164
+ // a re-encode that cannot help — blindness is the whole story.
165
+ expect(blind().offer("photo.jpg", "image/jpeg", Buffer.from("not an image"))).toEqual({
166
+ kind: "degraded",
167
+ reason: "model_no_vision",
168
+ });
169
+ });
170
+
171
+ it("gates before format and size rules so their resend advice never leaks", () => {
172
+ const cursorBlind = new VisionBudget(CURSOR_VISION_PROFILE, {
173
+ maxImageBytes: 100,
174
+ modelVision: false,
175
+ });
176
+ // WebP on the Cursor profile would be unsupported_format; oversized would
177
+ // be too_large. Blindness pre-empts both.
178
+ expect(cursorBlind.offer("sticker.webp", "image/webp", imageBytes("webp"))).toEqual({
179
+ kind: "degraded",
180
+ reason: "model_no_vision",
181
+ });
182
+ expect(cursorBlind.offer("big.png", "image/png", imageBytes("png", 101))).toEqual({
183
+ kind: "degraded",
184
+ reason: "model_no_vision",
185
+ });
186
+ });
187
+
188
+ it("still skips non-image input silently on a blind model", () => {
189
+ expect(blind().offer("doc.pdf", "application/pdf", Buffer.from("%PDF-1.7"))).toEqual({
190
+ kind: "skipped",
191
+ });
192
+ });
193
+
194
+ it("treats explicit true and unknown identically: fail-open", () => {
195
+ const sighted = new VisionBudget(DEEP_AGENT_VISION_PROFILE, { modelVision: true });
196
+ const unknown = new VisionBudget(DEEP_AGENT_VISION_PROFILE, { modelVision: undefined });
197
+ expect(sighted.offer("a.png", "image/png", imageBytes("png")).kind).toBe("accepted");
198
+ expect(unknown.offer("a.png", "image/png", imageBytes("png")).kind).toBe("accepted");
199
+ });
200
+
201
+ it("modelCannotSee + offerBlind settle a candidate without reading bytes", () => {
202
+ const budget = blind();
203
+ expect(budget.modelCannotSee()).toBe(true);
204
+ expect(budget.offerBlind()).toEqual({ kind: "degraded", reason: "model_no_vision" });
205
+
206
+ const sighted = new VisionBudget(DEEP_AGENT_VISION_PROFILE);
207
+ expect(sighted.modelCannotSee()).toBe(false);
208
+ });
209
+ });
210
+
146
211
  describe("VisionBudget — size and count budgets", () => {
147
212
  it("accepts exactly at the per-image cap and degrades one byte over it", () => {
148
213
  const budget = new VisionBudget(DEEP_AGENT_VISION_PROFILE, {
@@ -317,6 +382,38 @@ describe("visionDisclosureLines", () => {
317
382
  expect(lines).toHaveLength(2);
318
383
  });
319
384
 
385
+ it("gives blind-model entries honest advice instead of the resend suggestion", () => {
386
+ const lines = visionDisclosureLines(
387
+ [],
388
+ [
389
+ { path: ".stigmer/inputs/a.png", reason: "model_no_vision" },
390
+ { path: ".stigmer/inputs/b.jpg", reason: "model_no_vision" },
391
+ ],
392
+ );
393
+ expect(lines[0]).toBe(
394
+ "NOT VIEWABLE INLINE: `.stigmer/inputs/a.png` (model cannot view images), " +
395
+ "`.stigmer/inputs/b.jpg` (model cannot view images).",
396
+ );
397
+ expect(lines[1]).toContain("does not support image input");
398
+ expect(lines[1]).toContain("no resend will help");
399
+ // The resend-smaller suggestion must never appear for a blind model.
400
+ expect(lines.join("\n")).not.toContain("resend it as a smaller PNG or JPEG");
401
+ expect(lines).toHaveLength(2);
402
+ });
403
+
404
+ it("keeps both advice lines, each scoped to its reasons, when reasons mix", () => {
405
+ const lines = visionDisclosureLines(
406
+ [],
407
+ [
408
+ { path: ".stigmer/inputs/big.png", reason: "too_large" },
409
+ { path: ".stigmer/inputs/a.png", reason: "model_no_vision" },
410
+ ],
411
+ );
412
+ expect(lines[1]).toContain("ask the user to resend");
413
+ expect(lines[2]).toContain("no resend will help");
414
+ expect(lines).toHaveLength(3);
415
+ });
416
+
320
417
  it("returns nothing when there is nothing to disclose", () => {
321
418
  expect(visionDisclosureLines([], [])).toEqual([]);
322
419
  });
@@ -1,4 +1,5 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import type { Connection } from "@langchain/mcp-adapters";
2
3
  import { toMcpClientConfig } from "../mcp-manager.js";
3
4
  import type { ResolvedMcpServer } from "../mcp-resolver.js";
4
5
 
@@ -13,6 +14,18 @@ function makeServer(overrides: Partial<ResolvedMcpServer>): ResolvedMcpServer {
13
14
  };
14
15
  }
15
16
 
17
+ /** Narrow a Connection to its stdio variant and return its env. */
18
+ function stdioEnv(
19
+ config: Record<string, Connection>,
20
+ slug: string,
21
+ ): Record<string, string> | undefined {
22
+ const conn = config[slug];
23
+ if (!conn || !("command" in conn)) {
24
+ throw new Error(`expected a stdio connection for '${slug}'`);
25
+ }
26
+ return conn.env;
27
+ }
28
+
16
29
  describe("toMcpClientConfig", () => {
17
30
  it("maps stdio servers to the client config format", () => {
18
31
  const servers = [
@@ -81,3 +94,76 @@ describe("toMcpClientConfig", () => {
81
94
  expect(toMcpClientConfig([])).toEqual({});
82
95
  });
83
96
  });
97
+
98
+ // Runner-internal credentials that must never reach an MCP stdio subprocess
99
+ // (oss#256). Names mirror what the runner actually reads: config.ts,
100
+ // fingerprint-secret.ts, model-client.ts, registry-endpoint.ts.
101
+ const RUNNER_CREDENTIAL_KEYS = [
102
+ "STIGMER_RUNNER_HITL_SECRET",
103
+ "STIGMER_TOKEN",
104
+ "STIGMER_AUTH_TOKEN",
105
+ "CURSOR_API_KEY",
106
+ "ANTHROPIC_API_KEY",
107
+ "OPENAI_API_KEY",
108
+ ] as const;
109
+
110
+ describe("toMcpClientConfig — stdio env isolation (oss#256)", () => {
111
+ afterEach(() => {
112
+ vi.unstubAllEnvs();
113
+ });
114
+
115
+ it("gives a stdio server with no declared env none of the runner's variables", () => {
116
+ for (const key of RUNNER_CREDENTIAL_KEYS) {
117
+ vi.stubEnv(key, `leaked-${key}`);
118
+ }
119
+ vi.stubEnv("STIGMER_TEST_LEAK_SENTINEL", "canary");
120
+
121
+ const config = toMcpClientConfig([
122
+ makeServer({ slug: "bare", command: "some-mcp-server", env: undefined }),
123
+ ]);
124
+
125
+ // Passing no env means the MCP SDK supplies its minimal base environment
126
+ // (HOME, LOGNAME, PATH, SHELL, TERM, USER) — the runner must add nothing.
127
+ expect(stdioEnv(config, "bare")).toBeUndefined();
128
+ });
129
+
130
+ it("never copies process.env into a stdio config (fallback-reintroduction tripwire)", () => {
131
+ vi.stubEnv("STIGMER_TEST_LEAK_SENTINEL", "canary");
132
+
133
+ const config = toMcpClientConfig([
134
+ makeServer({ slug: "bare", command: "some-mcp-server", env: undefined }),
135
+ ]);
136
+
137
+ const env = stdioEnv(config, "bare") ?? {};
138
+ for (const key of Object.keys(process.env)) {
139
+ expect(
140
+ env,
141
+ `process.env key '${key}' must not leak into an MCP stdio env`,
142
+ ).not.toHaveProperty(key);
143
+ }
144
+ });
145
+
146
+ it("passes a declared env through exactly, without merging process.env", () => {
147
+ vi.stubEnv("STIGMER_TOKEN", "runner-secret");
148
+
149
+ const config = toMcpClientConfig([
150
+ makeServer({
151
+ slug: "declared",
152
+ command: "some-mcp-server",
153
+ env: { API_KEY: "user-value" },
154
+ }),
155
+ ]);
156
+
157
+ expect(stdioEnv(config, "declared")).toEqual({ API_KEY: "user-value" });
158
+ });
159
+
160
+ it("passes an empty declared env through unchanged (same child env as undeclared)", () => {
161
+ vi.stubEnv("STIGMER_TOKEN", "runner-secret");
162
+
163
+ const config = toMcpClientConfig([
164
+ makeServer({ slug: "empty", command: "some-mcp-server", env: {} }),
165
+ ]);
166
+
167
+ expect(stdioEnv(config, "empty")).toEqual({});
168
+ });
169
+ });
@@ -3,6 +3,7 @@ import {
3
3
  getSummarizationModel,
4
4
  getEconomyModel,
5
5
  getDefaultModel,
6
+ getModelVisionCapability,
6
7
  resolveToApiModelId,
7
8
  _resetRegistryCache,
8
9
  } from "../model-registry.js";
@@ -253,6 +254,75 @@ describe("resolveToApiModelId", () => {
253
254
  });
254
255
  });
255
256
 
257
+ describe("getModelVisionCapability", () => {
258
+ beforeEach(() => {
259
+ _resetRegistryCache();
260
+ vi.restoreAllMocks();
261
+ });
262
+
263
+ afterEach(() => {
264
+ _resetRegistryCache();
265
+ });
266
+
267
+ it("returns the explicit vision flag when the capabilities block is present", async () => {
268
+ mockRegistryResponse([
269
+ { id: "claude-sonnet-4.6", provider: "anthropic", costTier: "standard", harness: "native", capabilities: { vision: true, toolUse: true } },
270
+ { id: "llama3.1", provider: "ollama", costTier: "economy", harness: "native", capabilities: { vision: false, toolUse: true } },
271
+ ]);
272
+
273
+ expect(await getModelVisionCapability("claude-sonnet-4.6")).toBe(true);
274
+ _resetRegistryCache();
275
+ mockRegistryResponse([
276
+ { id: "llama3.1", provider: "ollama", costTier: "economy", harness: "native", capabilities: { vision: false } },
277
+ ]);
278
+ expect(await getModelVisionCapability("llama3.1")).toBe(false);
279
+ });
280
+
281
+ it("returns undefined when the capabilities block is absent (never assessed ≠ blind)", async () => {
282
+ // The cursor-harness convention today: pricing + UI fields, no
283
+ // capabilities block. Must stay undefined, never coerce to false.
284
+ mockRegistryResponse([
285
+ { id: "composer-2.5", provider: "cursor", costTier: "economy", harness: "cursor" },
286
+ ]);
287
+
288
+ expect(await getModelVisionCapability("composer-2.5")).toBeUndefined();
289
+ });
290
+
291
+ it("matches by apiModelId as well as registry id", async () => {
292
+ // getDefaultModel() hands the deep-agent harness the provider API id, so
293
+ // both identifier forms must resolve to the same capability.
294
+ mockRegistryResponse([
295
+ { id: "claude-sonnet-4.6", apiModelId: "claude-sonnet-4-6", provider: "anthropic", costTier: "standard", harness: "native", capabilities: { vision: true } },
296
+ ]);
297
+
298
+ expect(await getModelVisionCapability("claude-sonnet-4-6")).toBe(true);
299
+ });
300
+
301
+ it("returns undefined for unknown names, the empty string, and the Auto pool", async () => {
302
+ mockRegistryResponse([
303
+ { id: "claude-sonnet-4.6", provider: "anthropic", costTier: "standard", harness: "native", capabilities: { vision: true } },
304
+ ]);
305
+
306
+ expect(await getModelVisionCapability("never-heard-of-it")).toBeUndefined();
307
+ expect(await getModelVisionCapability("")).toBeUndefined();
308
+ expect(await getModelVisionCapability("default")).toBeUndefined();
309
+ });
310
+
311
+ it("returns undefined when the registry fetch fails (fail-open)", async () => {
312
+ vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("network error"));
313
+
314
+ expect(await getModelVisionCapability("claude-sonnet-4.6")).toBeUndefined();
315
+ });
316
+
317
+ it("treats a malformed capabilities value as undefined", async () => {
318
+ mockRegistryResponse([
319
+ { id: "weird-model", provider: "anthropic", costTier: "standard", harness: "native", capabilities: "yes" as unknown as { vision?: boolean } },
320
+ ]);
321
+
322
+ expect(await getModelVisionCapability("weird-model")).toBeUndefined();
323
+ });
324
+ });
325
+
256
326
  interface MockModel {
257
327
  id: string;
258
328
  apiModelId?: string;
@@ -260,6 +330,7 @@ interface MockModel {
260
330
  costTier: string;
261
331
  harness: string;
262
332
  featured?: boolean;
333
+ capabilities?: { vision?: boolean; toolUse?: boolean };
263
334
  }
264
335
 
265
336
  function mockRegistryResponse(models: MockModel[]) {
@@ -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,9 +10,22 @@
10
10
  * resolution time by shared/mcp-transport-guard.ts — servers reaching
11
11
  * this manager have already passed it.
12
12
  *
13
+ * Stdio env contract (oss#256): a subprocess receives exactly the
14
+ * variables declared in the server's spec.env (resolved upstream by
15
+ * filterEnvToDeclaredKeys) plus the MCP SDK's minimal base environment
16
+ * (HOME, LOGNAME, PATH, SHELL, TERM, USER — getDefaultEnvironment in
17
+ * @modelcontextprotocol/sdk, merged under whatever we pass). The
18
+ * runner's own process env is never passed: it carries runner-internal
19
+ * credentials (STIGMER_TOKEN, STIGMER_RUNNER_HITL_SECRET,
20
+ * CURSOR_API_KEY, LLM provider keys) that no third-party MCP subprocess
21
+ * may see. A declared-empty env and an undeclared env are deliberately
22
+ * equivalent — both yield the SDK base environment.
23
+ *
13
24
  * The Cursor execution path does NOT use this manager — it passes MCP
14
25
  * configs directly to the Cursor SDK via toCursorMcpConfig(). This
15
- * manager is exclusively for LangGraph-based deep agent executions.
26
+ * manager serves LangGraph-based deep agent executions, and discovery
27
+ * (activities/discover-mcp-server.ts) builds its spawn config through
28
+ * toMcpClientConfig too.
16
29
  */
17
30
 
18
31
  import { MultiServerMCPClient } from "@langchain/mcp-adapters";
@@ -32,11 +45,18 @@ export function toMcpClientConfig(
32
45
  for (const server of servers) {
33
46
  if (server.connectionType === "stdio") {
34
47
  if (!server.command) continue;
48
+ if (!server.env || Object.keys(server.env).length === 0) {
49
+ console.log(
50
+ `[MCP] Server '${server.slug}' declares no env — its subprocess ` +
51
+ `starts with the minimal base environment only. Declare variables ` +
52
+ `in the McpServer's spec.env to pass them.`,
53
+ );
54
+ }
35
55
  config[server.slug] = {
36
56
  transport: "stdio",
37
57
  command: server.command,
38
58
  args: server.args ?? [],
39
- env: server.env ?? processEnvAsStrings(),
59
+ env: server.env,
40
60
  cwd: server.cwd,
41
61
  };
42
62
  } else if (server.connectionType === "http" || server.connectionType === "sse") {
@@ -91,23 +111,3 @@ export async function connectMcpServers(
91
111
 
92
112
  return { client, tools, serverToolMap };
93
113
  }
94
-
95
- /**
96
- * Snapshot of process.env as a string-only record (no undefined values).
97
- * Used as a fallback when an MCP server has no declared env — the
98
- * subprocess inherits the runner's full environment, matching standard
99
- * Unix child-process behavior.
100
- *
101
- * Without this, @modelcontextprotocol/sdk only passes a restricted
102
- * whitelist (HOME, PATH, USER, etc.) which drops platform variables
103
- * like STIGMER_SERVER_ADDRESS.
104
- */
105
- function processEnvAsStrings(): Record<string, string> {
106
- const env: Record<string, string> = {};
107
- for (const [key, value] of Object.entries(process.env)) {
108
- if (value !== undefined) {
109
- env[key] = value;
110
- }
111
- }
112
- return env;
113
- }
@@ -1,10 +1,13 @@
1
1
  /**
2
- * Model registry — provider lookup and economy-tier model derivation.
2
+ * Model registry — provider lookup, economy-tier model derivation, and
3
+ * model capability resolution.
3
4
  *
4
5
  * Fetches the model registry from the runner's control plane (see
5
6
  * registry-endpoint.ts for endpoint resolution — same endpoint as
6
7
  * model-pricing-data.ts) and uses `costTier` + `harness` fields to
7
- * dynamically resolve economy-tier models for extraction/summarization.
8
+ * dynamically resolve economy-tier models for extraction/summarization,
9
+ * plus `capabilities` catalog metadata for per-model capability lookups
10
+ * (getModelVisionCapability).
8
11
  */
9
12
 
10
13
  import { resolveModelRegistryUrl, buildRegistryHeaders } from "./registry-endpoint.js";
@@ -22,6 +25,14 @@ interface RegistryModel {
22
25
  costTier: string;
23
26
  harness: string;
24
27
  featured: boolean;
28
+ /**
29
+ * Tri-state vision capability from the registry's `capabilities` block.
30
+ * The registry serializes `capabilities` only for models whose capabilities
31
+ * have actually been assessed, so `undefined` means "never assessed" —
32
+ * deliberately distinct from an explicit `false` ("assessed as blind").
33
+ * Consumers gate only on the explicit `false` (see attachment-vision.ts).
34
+ */
35
+ visionCapability?: boolean;
25
36
  }
26
37
 
27
38
  let cache: { models: readonly RegistryModel[]; expiresAt: number } | null = null;
@@ -41,9 +52,20 @@ function parseRegistry(json: unknown): RegistryModel[] {
41
52
  costTier: (m.costTier as string) ?? "standard",
42
53
  harness: (m.harness as string) ?? "native",
43
54
  featured: !!m.featured,
55
+ visionCapability: parseVisionCapability(m.capabilities),
44
56
  }));
45
57
  }
46
58
 
59
+ /**
60
+ * Extract `capabilities.vision` preserving the tri-state: a missing or
61
+ * malformed `capabilities` block stays `undefined` (never coerced to false).
62
+ */
63
+ function parseVisionCapability(capabilities: unknown): boolean | undefined {
64
+ if (!capabilities || typeof capabilities !== "object") return undefined;
65
+ const vision = (capabilities as Record<string, unknown>).vision;
66
+ return typeof vision === "boolean" ? vision : undefined;
67
+ }
68
+
47
69
  async function fetchRegistry(): Promise<readonly RegistryModel[]> {
48
70
  const url = resolveModelRegistryUrl();
49
71
  const res = await fetch(url, { headers: buildRegistryHeaders() });
@@ -211,6 +233,32 @@ export async function resolveToApiModelId(registryId: string): Promise<string> {
211
233
  return entry.apiModelId ?? registryId;
212
234
  }
213
235
 
236
+ /**
237
+ * Look up a model's vision capability from the registry's `capabilities`
238
+ * catalog metadata. Returns the tri-state the vision policy expects
239
+ * (attachment-vision.ts): `false` only when the registry explicitly says the
240
+ * model cannot see images; `undefined` whenever the answer is unknown —
241
+ * capability never assessed, model not in the registry, registry
242
+ * unreachable, or no concrete model name (the Cursor harness's ""/"default"
243
+ * Auto pool). Callers gate on the explicit `false` only, so every unknown
244
+ * degrades to today's behavior instead of blocking images.
245
+ *
246
+ * Matches by registry `id` OR `apiModelId`: getDefaultModel() hands the
247
+ * deep-agent harness the provider API id, while executionConfig.modelName
248
+ * carries the registry id, so both forms arrive here.
249
+ */
250
+ export async function getModelVisionCapability(
251
+ modelName: string,
252
+ ): Promise<boolean | undefined> {
253
+ if (!modelName || modelName === "default") return undefined;
254
+
255
+ const registry = await getRegistry();
256
+ const entry = registry.find(
257
+ (m) => m.id === modelName || m.apiModelId === modelName,
258
+ );
259
+ return entry?.visionCapability;
260
+ }
261
+
214
262
  /** Exposed for testing — resets the in-memory cache. */
215
263
  export function _resetRegistryCache(): void {
216
264
  cache = null;