@stigmer/runner 3.4.0 → 3.5.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 (128) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/attach-session.d.ts +34 -0
  3. package/dist/activities/attach-session.js +95 -0
  4. package/dist/activities/attach-session.js.map +1 -0
  5. package/dist/activities/discover-mcp-server.d.ts +9 -0
  6. package/dist/activities/discover-mcp-server.js +6 -1
  7. package/dist/activities/discover-mcp-server.js.map +1 -1
  8. package/dist/activities/execute-cursor/connect-backfill.d.ts +2 -1
  9. package/dist/activities/execute-cursor/connect-backfill.js +2 -2
  10. package/dist/activities/execute-cursor/connect-backfill.js.map +1 -1
  11. package/dist/activities/execute-cursor/fetch-interceptor.js +31 -0
  12. package/dist/activities/execute-cursor/fetch-interceptor.js.map +1 -1
  13. package/dist/activities/execute-cursor/index.js +70 -3
  14. package/dist/activities/execute-cursor/index.js.map +1 -1
  15. package/dist/activities/execute-cursor/mcp-resolver.d.ts +7 -1
  16. package/dist/activities/execute-cursor/mcp-resolver.js +15 -2
  17. package/dist/activities/execute-cursor/mcp-resolver.js.map +1 -1
  18. package/dist/activities/execute-cursor/sdk-warmup.d.ts +44 -0
  19. package/dist/activities/execute-cursor/sdk-warmup.js +61 -0
  20. package/dist/activities/execute-cursor/sdk-warmup.js.map +1 -0
  21. package/dist/activities/execute-deep-agent/setup.js +48 -5
  22. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +9 -1
  24. package/dist/activities/execute-deep-agent/subagent-transformer.js +19 -5
  25. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  26. package/dist/client/stigmer-client.d.ts +13 -1
  27. package/dist/client/stigmer-client.js +40 -5
  28. package/dist/client/stigmer-client.js.map +1 -1
  29. package/dist/client/token-claims.d.ts +9 -6
  30. package/dist/client/token-claims.js +22 -6
  31. package/dist/client/token-claims.js.map +1 -1
  32. package/dist/main.d.ts +8 -1
  33. package/dist/main.js +122 -2
  34. package/dist/main.js.map +1 -1
  35. package/dist/middleware/index.d.ts +0 -1
  36. package/dist/middleware/index.js +0 -1
  37. package/dist/middleware/index.js.map +1 -1
  38. package/dist/otel-metrics.d.ts +10 -1
  39. package/dist/otel-metrics.js +22 -1
  40. package/dist/otel-metrics.js.map +1 -1
  41. package/dist/pool-member.d.ts +61 -0
  42. package/dist/pool-member.js +51 -0
  43. package/dist/pool-member.js.map +1 -0
  44. package/dist/runner-manager.d.ts +7 -0
  45. package/dist/runner-manager.js +39 -1
  46. package/dist/runner-manager.js.map +1 -1
  47. package/dist/runner.js +15 -1
  48. package/dist/runner.js.map +1 -1
  49. package/dist/shared/cold-start-timing.d.ts +89 -0
  50. package/dist/shared/cold-start-timing.js +166 -0
  51. package/dist/shared/cold-start-timing.js.map +1 -0
  52. package/dist/shared/connect-backfill.d.ts +2 -1
  53. package/dist/shared/connect-backfill.js +4 -2
  54. package/dist/shared/connect-backfill.js.map +1 -1
  55. package/dist/shared/mcp-manager.d.ts +5 -15
  56. package/dist/shared/mcp-manager.js +5 -32
  57. package/dist/shared/mcp-manager.js.map +1 -1
  58. package/dist/shared/mcp-resolver.d.ts +15 -5
  59. package/dist/shared/mcp-resolver.js +23 -6
  60. package/dist/shared/mcp-resolver.js.map +1 -1
  61. package/dist/shared/mcp-transport-guard.d.ts +53 -0
  62. package/dist/shared/mcp-transport-guard.js +70 -0
  63. package/dist/shared/mcp-transport-guard.js.map +1 -0
  64. package/dist/shared/model-registry.d.ts +4 -6
  65. package/dist/shared/model-registry.js +4 -6
  66. package/dist/shared/model-registry.js.map +1 -1
  67. package/dist/shared/tool-kind.js +6 -0
  68. package/dist/shared/tool-kind.js.map +1 -1
  69. package/dist/tools/index.d.ts +27 -0
  70. package/dist/tools/index.js +27 -0
  71. package/dist/tools/index.js.map +1 -0
  72. package/dist/tools/think-tool.js.map +1 -0
  73. package/dist/tools/url-guard.d.ts +64 -0
  74. package/dist/tools/url-guard.js +211 -0
  75. package/dist/tools/url-guard.js.map +1 -0
  76. package/dist/tools/web-fetch-tool.d.ts +53 -0
  77. package/dist/tools/web-fetch-tool.js +216 -0
  78. package/dist/tools/web-fetch-tool.js.map +1 -0
  79. package/package.json +4 -2
  80. package/src/__tests__/otel-metrics.test.ts +6 -0
  81. package/src/__tests__/pool-member.test.ts +76 -0
  82. package/src/activities/__tests__/attach-session.test.ts +133 -0
  83. package/src/activities/__tests__/discover-mcp-server.test.ts +34 -11
  84. package/src/activities/attach-session.ts +112 -0
  85. package/src/activities/discover-mcp-server.ts +19 -1
  86. package/src/activities/execute-cursor/__tests__/fetch-interceptor.test.ts +77 -0
  87. package/src/activities/execute-cursor/__tests__/sdk-warmup.test.ts +45 -0
  88. package/src/activities/execute-cursor/connect-backfill.ts +3 -0
  89. package/src/activities/execute-cursor/fetch-interceptor.ts +39 -0
  90. package/src/activities/execute-cursor/index.ts +72 -3
  91. package/src/activities/execute-cursor/mcp-resolver.ts +21 -2
  92. package/src/activities/execute-cursor/sdk-warmup.ts +68 -0
  93. package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +24 -0
  94. package/src/activities/execute-deep-agent/setup.ts +48 -7
  95. package/src/activities/execute-deep-agent/subagent-transformer.ts +27 -2
  96. package/src/client/__tests__/stigmer-client.test.ts +77 -2
  97. package/src/client/__tests__/token-claims.test.ts +15 -1
  98. package/src/client/stigmer-client.ts +51 -6
  99. package/src/client/token-claims.ts +29 -6
  100. package/src/main.ts +139 -2
  101. package/src/middleware/index.ts +0 -1
  102. package/src/otel-metrics.ts +25 -1
  103. package/src/pool-member.ts +99 -0
  104. package/src/runner-manager.ts +52 -0
  105. package/src/runner.ts +15 -0
  106. package/src/shared/__tests__/cold-start-timing.test.ts +271 -0
  107. package/src/shared/__tests__/connect-backfill.test.ts +11 -11
  108. package/src/shared/__tests__/mcp-manager.test.ts +2 -73
  109. package/src/shared/__tests__/mcp-resolver.test.ts +108 -0
  110. package/src/shared/__tests__/mcp-transport-guard.test.ts +71 -0
  111. package/src/shared/__tests__/model-registry.test.ts +6 -4
  112. package/src/shared/cold-start-timing.ts +197 -0
  113. package/src/shared/connect-backfill.ts +5 -1
  114. package/src/shared/mcp-manager.ts +4 -41
  115. package/src/shared/mcp-resolver.ts +29 -6
  116. package/src/shared/mcp-transport-guard.ts +81 -0
  117. package/src/shared/model-registry.ts +4 -6
  118. package/src/shared/tool-kind.ts +6 -0
  119. package/src/tools/__tests__/url-guard.test.ts +137 -0
  120. package/src/tools/__tests__/web-fetch-tool.test.ts +193 -0
  121. package/src/tools/index.ts +28 -0
  122. package/src/tools/url-guard.ts +232 -0
  123. package/src/tools/web-fetch-tool.ts +275 -0
  124. package/dist/middleware/think-tool.js.map +0 -1
  125. /package/dist/{middleware → tools}/think-tool.d.ts +0 -0
  126. /package/dist/{middleware → tools}/think-tool.js +0 -0
  127. /package/src/{middleware → tools}/__tests__/think-tool.test.ts +0 -0
  128. /package/src/{middleware → tools}/think-tool.ts +0 -0
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { resolveMcpServers } from "../mcp-resolver.js";
3
+ import { McpTransportError } from "../mcp-transport-guard.js";
4
+
5
+ function makeUsage(slug: string, org = "test-org") {
6
+ return {
7
+ mcpServerRef: { slug, org, kind: 0 },
8
+ toolApprovalOverrides: [],
9
+ } as any;
10
+ }
11
+
12
+ function stdioMcpServer(slug: string) {
13
+ return {
14
+ metadata: { id: `id-${slug}`, slug },
15
+ spec: {
16
+ serverType: { case: "stdio", value: { command: "npx", args: [] } },
17
+ env: {},
18
+ },
19
+ status: undefined,
20
+ } as any;
21
+ }
22
+
23
+ function httpMcpServer(slug: string) {
24
+ return {
25
+ metadata: { id: `id-${slug}`, slug },
26
+ spec: {
27
+ serverType: { case: "http", value: { url: "https://mcp.example.com/mcp", headers: {} } },
28
+ env: {},
29
+ },
30
+ status: undefined,
31
+ } as any;
32
+ }
33
+
34
+ function clientReturning(serversBySlug: Record<string, unknown>) {
35
+ return {
36
+ getMcpServerByReference: vi.fn(async (ref: { slug: string }) => {
37
+ const server = serversBySlug[ref.slug];
38
+ if (!server) throw new Error(`not found: ${ref.slug}`);
39
+ return server;
40
+ }),
41
+ } as any;
42
+ }
43
+
44
+ describe("resolveMcpServers — transport guard integration", () => {
45
+ beforeEach(() => {
46
+ vi.restoreAllMocks();
47
+ vi.spyOn(console, "warn").mockImplementation(() => {});
48
+ vi.spyOn(console, "error").mockImplementation(() => {});
49
+ });
50
+
51
+ it("throws McpTransportError for stdio under a forbidding posture — never degraded to a skipped server", async () => {
52
+ // The per-server catch swallows resolution hiccups into console.warn;
53
+ // a policy rejection must escape it and fail the whole resolution.
54
+ const client = clientReturning({ filesystem: stdioMcpServer("filesystem") });
55
+
56
+ await expect(
57
+ resolveMcpServers(client, [makeUsage("filesystem")], {}, "stdio-forbidden"),
58
+ ).rejects.toThrow(McpTransportError);
59
+ });
60
+
61
+ it("fails the whole resolution even when other servers are resolvable", async () => {
62
+ const client = clientReturning({
63
+ github: httpMcpServer("github"),
64
+ filesystem: stdioMcpServer("filesystem"),
65
+ });
66
+
67
+ await expect(
68
+ resolveMcpServers(
69
+ client,
70
+ [makeUsage("github"), makeUsage("filesystem")],
71
+ {},
72
+ "stdio-forbidden",
73
+ ),
74
+ ).rejects.toThrow(McpTransportError);
75
+ });
76
+
77
+ it("resolves http servers under a forbidding posture", async () => {
78
+ const client = clientReturning({ github: httpMcpServer("github") });
79
+
80
+ const result = await resolveMcpServers(
81
+ client, [makeUsage("github")], {}, "stdio-forbidden",
82
+ );
83
+
84
+ expect(result.resolvedServers).toHaveLength(1);
85
+ expect(result.resolvedServers[0].connectionType).toBe("http");
86
+ });
87
+
88
+ it("resolves stdio servers under an allowing posture", async () => {
89
+ const client = clientReturning({ filesystem: stdioMcpServer("filesystem") });
90
+
91
+ const result = await resolveMcpServers(
92
+ client, [makeUsage("filesystem")], {}, "stdio-allowed",
93
+ );
94
+
95
+ expect(result.resolvedServers).toHaveLength(1);
96
+ expect(result.resolvedServers[0].connectionType).toBe("stdio");
97
+ });
98
+
99
+ it("still degrades gracefully for ordinary resolution failures", async () => {
100
+ const client = clientReturning({});
101
+
102
+ const result = await resolveMcpServers(
103
+ client, [makeUsage("ghost")], {}, "stdio-forbidden",
104
+ );
105
+
106
+ expect(result.resolvedServers).toHaveLength(0);
107
+ });
108
+ });
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ assertTransportAllowed,
4
+ McpTransportError,
5
+ resolveMcpTransportPosture,
6
+ } from "../mcp-transport-guard.js";
7
+
8
+ describe("resolveMcpTransportPosture", () => {
9
+ it("forbids stdio on cloud runners", () => {
10
+ expect(resolveMcpTransportPosture("cloud", {})).toBe("stdio-forbidden");
11
+ });
12
+
13
+ it("allows stdio on local runners", () => {
14
+ expect(resolveMcpTransportPosture("local", {})).toBe("stdio-allowed");
15
+ });
16
+
17
+ it("honors the explicit allow override on a cloud runner (kill-switch)", () => {
18
+ expect(
19
+ resolveMcpTransportPosture("cloud", { STIGMER_MCP_ALLOW_STDIO: "true" }),
20
+ ).toBe("stdio-allowed");
21
+ });
22
+
23
+ it("honors the explicit forbid override on a local runner", () => {
24
+ expect(
25
+ resolveMcpTransportPosture("local", { STIGMER_MCP_ALLOW_STDIO: "false" }),
26
+ ).toBe("stdio-forbidden");
27
+ });
28
+
29
+ it("ignores malformed override values", () => {
30
+ expect(
31
+ resolveMcpTransportPosture("cloud", { STIGMER_MCP_ALLOW_STDIO: "yes" }),
32
+ ).toBe("stdio-forbidden");
33
+ });
34
+ });
35
+
36
+ describe("assertTransportAllowed", () => {
37
+ it("throws McpTransportError for stdio under a forbidding posture", () => {
38
+ expect(() =>
39
+ assertTransportAllowed("filesystem", "stdio", "stdio-forbidden"),
40
+ ).toThrow(McpTransportError);
41
+ });
42
+
43
+ it("names the server and both remediations in the error message", () => {
44
+ try {
45
+ assertTransportAllowed("filesystem", "stdio", "stdio-forbidden");
46
+ expect.unreachable("should have thrown");
47
+ } catch (err) {
48
+ expect(err).toBeInstanceOf(McpTransportError);
49
+ const message = (err as Error).message;
50
+ expect(message).toContain("'filesystem'");
51
+ expect(message).toContain("local runner");
52
+ expect(message).toContain("execution_target: local");
53
+ expect(message).toContain("HTTP");
54
+ }
55
+ });
56
+
57
+ it("allows stdio under an allowing posture", () => {
58
+ expect(() =>
59
+ assertTransportAllowed("filesystem", "stdio", "stdio-allowed"),
60
+ ).not.toThrow();
61
+ });
62
+
63
+ it("allows http and sse regardless of posture", () => {
64
+ expect(() =>
65
+ assertTransportAllowed("github", "http", "stdio-forbidden"),
66
+ ).not.toThrow();
67
+ expect(() =>
68
+ assertTransportAllowed("sse-server", "sse", "stdio-forbidden"),
69
+ ).not.toThrow();
70
+ });
71
+ });
@@ -37,13 +37,15 @@ describe("getSummarizationModel", () => {
37
37
  expect(result).toBe("claude-haiku-4.5");
38
38
  });
39
39
 
40
- it("returns the primary model itself for Ollama (no economy tier)", async () => {
40
+ it("resolves to the primary model itself for a single-model provider", async () => {
41
+ // Fabricated provider: exercises generic same-provider economy resolution
42
+ // for providers with exactly one (economy) model.
41
43
  mockRegistryResponse([
42
- { id: "qwen2.5-coder:7b", provider: "ollama", costTier: "economy", harness: "native" },
44
+ { id: "solo-model-1", provider: "someprovider", costTier: "economy", harness: "native" },
43
45
  ]);
44
46
 
45
- const result = await getSummarizationModel("qwen2.5-coder:7b");
46
- expect(result).toBe("qwen2.5-coder:7b");
47
+ const result = await getSummarizationModel("solo-model-1");
48
+ expect(result).toBe("solo-model-1");
47
49
  });
48
50
 
49
51
  it("falls back to cross-provider economy model when primary not found", async () => {
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Cold-start timing instrumentation.
3
+ *
4
+ * Measures the two previously unmeasured segments of the user-perceived cold
5
+ * start (warm-agent-surfaces Phase 0): the runner PROCESS BOOT (Node start →
6
+ * worker polling its Temporal queue) and PER-EXECUTION SETUP (activity start →
7
+ * agent ready to stream), broken down by named phase.
8
+ *
9
+ * Emission is a single structured JSON log line per timeline (selector field
10
+ * `stigmer_timing`), joined to control-plane timestamps by `execution_id`.
11
+ * Deliberately logs, not proto/status payloads: the status-update path is
12
+ * size-guarded and UI-coupled, and Temporal history must never carry
13
+ * telemetry. Cluster logging picks these lines up as-is.
14
+ *
15
+ * Failure posture: instrumentation must never affect an execution. All
16
+ * emission is wrapped; recorders are plain arithmetic and cannot throw in
17
+ * practice, but callers still treat every function here as best-effort.
18
+ */
19
+
20
+ // Static on purpose: the registry module is itself lazy (it dynamic-imports
21
+ // @opentelemetry/api inside getInstruments), so this adds nothing to boot,
22
+ // while per-call dynamic imports raced under concurrent emissions.
23
+ import { getInstruments } from "../otel-metrics.js";
24
+
25
+ /** One closed segment on a timeline: [startMs, startMs + durationMs). */
26
+ export interface TimingSegment {
27
+ readonly name: string;
28
+ /** Offset from the recorder's origin when this segment began (ms). */
29
+ readonly startMs: number;
30
+ readonly durationMs: number;
31
+ }
32
+
33
+ /**
34
+ * Records a linear sequence of named segments.
35
+ *
36
+ * `mark(name)` closes the span from the previous mark (or the origin) and
37
+ * names it — so instrumentation reads as one line placed AFTER the work it
38
+ * measures, mirroring the existing `reportSetupProgress` phase idiom:
39
+ *
40
+ * ```ts
41
+ * const timing = new TimingRecorder();
42
+ * const execution = await client.getExecution(executionId);
43
+ * timing.mark("fetch_execution");
44
+ * ```
45
+ */
46
+ export class TimingRecorder {
47
+ private readonly originMs: number;
48
+ private readonly segments: TimingSegment[] = [];
49
+ private lastMarkMs: number;
50
+
51
+ /**
52
+ * @param originMs absolute `performance.now()`-scale origin. Defaults to
53
+ * "now" (per-execution timelines). Pass 0 for a process-lifetime timeline
54
+ * whose offsets are milliseconds since Node start (`performance.now()`
55
+ * is measured from `performance.timeOrigin`, i.e. process start).
56
+ */
57
+ constructor(originMs: number = performance.now()) {
58
+ this.originMs = originMs;
59
+ this.lastMarkMs = originMs;
60
+ }
61
+
62
+ /** Close the span since the previous mark (or origin) and name it. */
63
+ mark(name: string): void {
64
+ const now = performance.now();
65
+ this.segments.push({
66
+ name,
67
+ startMs: round1(this.lastMarkMs - this.originMs),
68
+ durationMs: round1(now - this.lastMarkMs),
69
+ });
70
+ this.lastMarkMs = now;
71
+ }
72
+
73
+ /** Total elapsed time from the origin to the latest mark (ms). */
74
+ totalMs(): number {
75
+ return round1(this.lastMarkMs - this.originMs);
76
+ }
77
+
78
+ snapshot(): readonly TimingSegment[] {
79
+ return this.segments;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Emit one timeline as a single structured log line.
85
+ *
86
+ * Shape (stable — the baseline report and log-based metrics query it):
87
+ * `{"stigmer_timing":"<event>", ...context, "total_ms":N, "segments":[{"name","start_ms","duration_ms"}]}`
88
+ *
89
+ * Each timeline's total is additionally mirrored onto its OTel histogram
90
+ * (see {@link recordTimingMetric}): the stdout line stays the source of
91
+ * truth for per-segment forensics (it is the only place segments exist —
92
+ * cluster workload logs are not ingested anywhere queryable), while the
93
+ * histogram gives dashboards the aggregate without scraping pods.
94
+ */
95
+ export function emitTimingLog(
96
+ event: string,
97
+ context: Record<string, string | number | boolean | null | undefined>,
98
+ recorder: TimingRecorder,
99
+ ): void {
100
+ try {
101
+ console.log(JSON.stringify({
102
+ stigmer_timing: event,
103
+ ...context,
104
+ total_ms: recorder.totalMs(),
105
+ segments: recorder.snapshot().map((s) => ({
106
+ name: s.name,
107
+ start_ms: s.startMs,
108
+ duration_ms: s.durationMs,
109
+ })),
110
+ }));
111
+ } catch {
112
+ // Telemetry must never break the runner.
113
+ }
114
+ recordTimingMetric(event, context, recorder.totalMs());
115
+ }
116
+
117
+ /**
118
+ * Mirror a timeline's total onto its OTel histogram, fire-and-forget.
119
+ *
120
+ * Attributes are a per-event WHITELIST (`mode`, `harness`) — never ids or
121
+ * per-pod values. Unbounded attribute values multiply time series without
122
+ * limit (the JVM JFR thread_name incident, 2026-07-29: 5k+ series in
123
+ * 15 minutes), so new attributes here require the same closed-set argument
124
+ * these two carry. Events without a mapped instrument are control-plane
125
+ * timelines or future additions and are deliberately not recorded.
126
+ *
127
+ * Exported for tests; production callers go through {@link emitTimingLog}.
128
+ */
129
+ export function recordTimingMetric(
130
+ event: string,
131
+ context: Record<string, string | number | boolean | null | undefined>,
132
+ totalMs: number,
133
+ ): void {
134
+ void (async () => {
135
+ const instruments = await getInstruments();
136
+ switch (event) {
137
+ case "runner_boot":
138
+ instruments.runnerBootDuration.record(totalMs, pickAttrs(context, "mode"));
139
+ break;
140
+ case "execution_setup":
141
+ instruments.executionSetupDuration.record(totalMs, pickAttrs(context, "harness"));
142
+ break;
143
+ case "pool_attach":
144
+ instruments.poolAttachDuration.record(totalMs);
145
+ break;
146
+ default:
147
+ break;
148
+ }
149
+ })().catch(() => {
150
+ // Telemetry must never break the runner.
151
+ });
152
+ }
153
+
154
+ /** Copy only the whitelisted string-valued keys into metric attributes. */
155
+ function pickAttrs(
156
+ context: Record<string, string | number | boolean | null | undefined>,
157
+ ...keys: string[]
158
+ ): Record<string, string> {
159
+ const attrs: Record<string, string> = {};
160
+ for (const key of keys) {
161
+ const value = context[key];
162
+ if (typeof value === "string" && value.length > 0) {
163
+ attrs[key] = value;
164
+ }
165
+ }
166
+ return attrs;
167
+ }
168
+
169
+ // ─── Process boot timeline ───────────────────────────────────────────────────
170
+
171
+ // Origin 0 = process start, so the implicit first segment (everything before
172
+ // the first markBoot call) covers Node startup + entrypoint module loading.
173
+ const bootRecorder = new TimingRecorder(0);
174
+ let bootEmitted = false;
175
+
176
+ /** Record a boot milestone (closes the span since the previous milestone). */
177
+ export function markBoot(name: string): void {
178
+ if (bootEmitted) return;
179
+ bootRecorder.mark(name);
180
+ }
181
+
182
+ /**
183
+ * Emit the boot timeline once — called when the worker starts polling, the
184
+ * moment the sandbox can actually receive work. Later calls are no-ops so
185
+ * worker restarts within one process never emit a bogus second timeline.
186
+ */
187
+ export function emitRunnerBootTiming(
188
+ context: Record<string, string | number | boolean | null | undefined>,
189
+ ): void {
190
+ if (bootEmitted) return;
191
+ bootEmitted = true;
192
+ emitTimingLog("runner_boot", context, bootRecorder);
193
+ }
194
+
195
+ function round1(ms: number): number {
196
+ return Math.round(ms * 10) / 10;
197
+ }
@@ -20,6 +20,7 @@
20
20
 
21
21
  import type { ResolvedMcpServer } from "./mcp-resolver.js";
22
22
  import { resolveMcpServers } from "./mcp-resolver.js";
23
+ import type { McpTransportPosture } from "./mcp-transport-guard.js";
23
24
  import type { StigmerClient } from "../client/stigmer-client.js";
24
25
  import type { McpServerUsage } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
25
26
 
@@ -64,6 +65,7 @@ export async function backfillMcpServersIfNeeded(
64
65
  usages: McpServerUsage[],
65
66
  envVars: Record<string, string>,
66
67
  org: string,
68
+ transportPosture: McpTransportPosture,
67
69
  onHeartbeat?: () => void,
68
70
  secretKeys?: ReadonlySet<string>,
69
71
  ): Promise<ResolvedMcpServer[]> {
@@ -129,7 +131,9 @@ export async function backfillMcpServersIfNeeded(
129
131
  return currentServers;
130
132
  }
131
133
 
132
- const refreshed = await resolveMcpServers(client, usages, envVars);
134
+ // Same posture as the initial resolution: every server here already
135
+ // passed the transport guard once, so re-resolving cannot newly reject.
136
+ const refreshed = await resolveMcpServers(client, usages, envVars, transportPosture);
133
137
  return refreshed.resolvedServers;
134
138
  }
135
139
 
@@ -4,9 +4,12 @@
4
4
  * Wraps @langchain/mcp-adapters MultiServerMCPClient with:
5
5
  * - Conversion from ResolvedMcpServer[] to the SDK's Connection config
6
6
  * - Proper async cleanup via close()
7
- * - Cloud compatibility validation (warn on non-installable stdio commands)
8
7
  * - Tool filtering based on discovered capabilities
9
8
  *
9
+ * Transport policy (stdio is local-runner-only) is enforced upstream at
10
+ * resolution time by shared/mcp-transport-guard.ts — servers reaching
11
+ * this manager have already passed it.
12
+ *
10
13
  * The Cursor execution path does NOT use this manager — it passes MCP
11
14
  * configs directly to the Cursor SDK via toCursorMcpConfig(). This
12
15
  * manager is exclusively for LangGraph-based deep agent executions.
@@ -17,41 +20,6 @@ import type { Connection } from "@langchain/mcp-adapters";
17
20
  import type { DynamicStructuredTool } from "@langchain/core/tools";
18
21
  import type { ResolvedMcpServer } from "./mcp-resolver.js";
19
22
 
20
- const CLOUD_SAFE_COMMANDS = new Set(["npx", "node", "uvx", "python", "python3"]);
21
-
22
- /**
23
- * Classifies a stdio command's compatibility with cloud execution.
24
- * Returns true for commands that are installable/available in typical
25
- * cloud environments (npx, node, uvx, python).
26
- */
27
- export function isCloudCompatibleCommand(command: string): boolean {
28
- const base = command.split("/").pop() ?? command;
29
- return CLOUD_SAFE_COMMANDS.has(base);
30
- }
31
-
32
- /**
33
- * Log warnings for MCP servers that may not work in cloud mode.
34
- * Does not filter or reject — just provides operator visibility.
35
- */
36
- export function warnCloudIncompatibleServers(
37
- servers: ResolvedMcpServer[],
38
- isCloudMode: boolean,
39
- ): void {
40
- if (!isCloudMode) return;
41
-
42
- for (const server of servers) {
43
- if (server.connectionType === "stdio" && server.command) {
44
- if (!isCloudCompatibleCommand(server.command)) {
45
- console.warn(
46
- `[MCP] Server '${server.slug}' uses stdio command '${server.command}' ` +
47
- `which may not be available in cloud execution environments. ` +
48
- `Consider using an HTTP MCP server or an npx-installable package.`,
49
- );
50
- }
51
- }
52
- }
53
- }
54
-
55
23
  /**
56
24
  * Convert harness-agnostic ResolvedMcpServer[] into the
57
25
  * MultiServerMCPClient connection config format.
@@ -98,12 +66,7 @@ export interface McpConnectionResult {
98
66
  */
99
67
  export async function connectMcpServers(
100
68
  servers: ResolvedMcpServer[],
101
- options?: { isCloudMode?: boolean },
102
69
  ): Promise<McpConnectionResult> {
103
- if (options?.isCloudMode) {
104
- warnCloudIncompatibleServers(servers, true);
105
- }
106
-
107
70
  const connectionConfig = toMcpClientConfig(servers);
108
71
 
109
72
  if (Object.keys(connectionConfig).length === 0) {
@@ -1,16 +1,25 @@
1
1
  /**
2
2
  * Resolves Stigmer McpServerUsage references into an intermediate
3
- * ResolvedMcpServer format that is harness-agnostic.
3
+ * ResolvedMcpServer format.
4
4
  *
5
- * Both ExecuteCursor and ExecuteDeepAgent need MCP server connection info
6
- * and tool approval policies. This module provides the common resolution
7
- * logic; each harness maps ResolvedMcpServer into its SDK-specific format.
5
+ * Consumers: the deep-agent harness (execute-deep-agent/setup.ts), the
6
+ * connect backfill (shared/connect-backfill.ts), and the discovery
7
+ * activity (activities/discover-mcp-server.ts, via mcpServerToResolved).
8
+ * NOTE: the Cursor harness does NOT use this module — it has its own
9
+ * near-duplicate resolver at activities/execute-cursor/mcp-resolver.ts.
10
+ * A behavioral change here (like the transport guard) must be mirrored
11
+ * there until the two are consolidated.
8
12
  */
9
13
 
10
14
  import type { McpServerUsage } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
11
15
  import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
12
16
  import type { ToolApprovalPolicy } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/spec_pb";
13
17
  import type { StigmerClient } from "../client/stigmer-client.js";
18
+ import {
19
+ assertTransportAllowed,
20
+ McpTransportError,
21
+ type McpTransportPosture,
22
+ } from "./mcp-transport-guard.js";
14
23
  import {
15
24
  resolveHeaders,
16
25
  resolvePlaceholders,
@@ -44,11 +53,17 @@ export interface McpResolutionResult {
44
53
 
45
54
  /**
46
55
  * Fetch McpServer resources and resolve into intermediate format.
56
+ *
57
+ * @param transportPosture Whether stdio servers may run here (derive via
58
+ * resolveMcpTransportPosture(config.mode)). A stdio server under a
59
+ * forbidding posture throws {@link McpTransportError} and fails the
60
+ * whole resolution — never degraded to a skipped server.
47
61
  */
48
62
  export async function resolveMcpServers(
49
63
  client: StigmerClient,
50
64
  usages: McpServerUsage[],
51
- envVars: Record<string, string> = {},
65
+ envVars: Record<string, string>,
66
+ transportPosture: McpTransportPosture,
52
67
  ): Promise<McpResolutionResult> {
53
68
  const resolved: ResolvedMcpServer[] = [];
54
69
 
@@ -64,8 +79,16 @@ export async function resolveMcpServers(
64
79
  ref.slug,
65
80
  );
66
81
  const server = mcpServerToResolved(mcpServer, ref.slug, serverEnv);
67
- if (server) resolved.push(server);
82
+ if (server) {
83
+ assertTransportAllowed(server.slug, server.connectionType, transportPosture);
84
+ resolved.push(server);
85
+ }
68
86
  } catch (err) {
87
+ if (err instanceof McpTransportError) {
88
+ // Policy rejection, not a resolution hiccup: swallowing it here
89
+ // would mean the agent silently loses tools. Fail the execution.
90
+ throw err;
91
+ }
69
92
  if (err instanceof PlaceholderResolutionError) {
70
93
  console.error(
71
94
  `MCP server ${ref.org}/${ref.slug}: ${err.message}`,
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Transport guard for MCP servers — stdio is local-runner-only.
3
+ *
4
+ * Why this is load-bearing: a stdio MCP server means "download a package
5
+ * from a public registry at session start and run it as a subprocess with
6
+ * the execution's secrets in its environment". On a local runner that
7
+ * machine belongs to the user — their trust decision. On a managed cloud
8
+ * runner it is a supply-chain exposure the platform owns, so hosted
9
+ * execution is HTTP-only. The control plane refuses such executions at
10
+ * create time (stigmer-cloud's McpTransportPolicy); this guard is the
11
+ * defense-in-depth backstop at the moment a subprocess would actually be
12
+ * spawned — it must hold even if a stdio usage reaches a cloud runner
13
+ * through a path the control plane did not see.
14
+ *
15
+ * The posture derives from Config.mode ("cloud" → forbid stdio), NOT from
16
+ * cloudModeEnabled — that is the Cursor cloud-agent feature flag and says
17
+ * nothing about where this process runs. STIGMER_MCP_ALLOW_STDIO exists as
18
+ * an explicit override so operators can roll the policy back without a
19
+ * redeploy (the runner-side counterpart of the control plane's
20
+ * stigmer.mcp.stdio-cloud-block kill-switch). Managed cloud deployments
21
+ * never set it.
22
+ *
23
+ * A rejection is thrown as {@link McpTransportError} and must FAIL the
24
+ * execution loudly: the resolvers' per-server catch blocks swallow
25
+ * resolution failures into console.warn and continue, which would turn a
26
+ * policy rejection into an agent silently losing tools. Both resolvers
27
+ * rethrow this error type for exactly that reason.
28
+ *
29
+ * Mirrors the posture/override/error shape of tools/url-guard.ts (the
30
+ * web_fetch SSRF boundary), the runner's established idiom for
31
+ * mode-keyed guards.
32
+ */
33
+
34
+ export type McpTransportPosture = "stdio-allowed" | "stdio-forbidden";
35
+
36
+ /** Thrown for every guard rejection; message is safe to surface to the user. */
37
+ export class McpTransportError extends Error {
38
+ constructor(message: string) {
39
+ super(message);
40
+ this.name = "McpTransportError";
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Derive the transport posture from runner mode plus the explicit override.
46
+ *
47
+ * @param mode Config.mode — "cloud" means a managed runner on shared infrastructure.
48
+ * @param env Environment to read STIGMER_MCP_ALLOW_STDIO from.
49
+ */
50
+ export function resolveMcpTransportPosture(
51
+ mode: "local" | "cloud",
52
+ env: NodeJS.ProcessEnv = process.env,
53
+ ): McpTransportPosture {
54
+ const override = env.STIGMER_MCP_ALLOW_STDIO;
55
+ if (override === "true") return "stdio-allowed";
56
+ if (override === "false") return "stdio-forbidden";
57
+ return mode === "cloud" ? "stdio-forbidden" : "stdio-allowed";
58
+ }
59
+
60
+ /**
61
+ * Assert that a resolved MCP server's transport is allowed under the given
62
+ * posture. Applies only to user-defined McpServer resources flowing through
63
+ * the resolvers — the internal datastore attachment is synthesized
64
+ * separately and is already HTTP in cloud mode.
65
+ *
66
+ * @throws McpTransportError when the posture forbids stdio and the server uses it
67
+ */
68
+ export function assertTransportAllowed(
69
+ slug: string,
70
+ connectionType: "stdio" | "http" | "sse",
71
+ posture: McpTransportPosture,
72
+ ): void {
73
+ if (posture === "stdio-forbidden" && connectionType === "stdio") {
74
+ throw new McpTransportError(
75
+ `MCP server '${slug}' uses the stdio transport, which runs only on local ` +
76
+ `runners — this cloud runner refuses to spawn it. Run the session on a ` +
77
+ `local runner (session execution_target: local), or replace '${slug}' ` +
78
+ `with a remote (HTTP) MCP server.`,
79
+ );
80
+ }
81
+ }
@@ -96,12 +96,10 @@ export async function isModelRegistered(modelId: string): Promise<boolean> {
96
96
 
97
97
  /**
98
98
  * Derive the recommended economy-tier model for summarization/classification
99
- * tasks, given a primary model name.
100
- *
101
- * Resolution:
102
- * 1. Look up the primary model's provider in the registry
103
- * 2. Map provider → economy model (anthropic→claude-haiku-4.5, openai→gpt-4o-mini)
104
- * 3. Fall back to the primary model itself if provider is unknown or unmapped
99
+ * tasks, given a primary model name. Thin alias over getEconomyModel — the
100
+ * registry (costTier=economy, harness=native, same provider preferred) drives
101
+ * the choice; no model names are hardcoded here. See getEconomyModel for the
102
+ * resolution order.
105
103
  */
106
104
  export async function getSummarizationModel(primaryModel: string): Promise<string> {
107
105
  return getEconomyModel(primaryModel);
@@ -59,6 +59,7 @@ const TOOL_NAME_TO_KIND: ReadonlyMap<string, ToolKind> = new Map([
59
59
  ["ls", ToolKind.LIST],
60
60
  ["list_directory", ToolKind.LIST],
61
61
 
62
+ ["web_fetch", ToolKind.FETCH],
62
63
  ["WebFetch", ToolKind.FETCH],
63
64
  ["WebSearch", ToolKind.WEB_SEARCH],
64
65
 
@@ -118,6 +119,11 @@ export function toolApprovalCategory(name: string): ToolApprovalCategory | undef
118
119
  return "delete";
119
120
  case ToolKind.SHELL:
120
121
  return "shell";
122
+ // FETCH is deliberately uncategorized — i.e. auto-approved. This mirrors
123
+ // the Cursor harness (WebFetch is never gated) and is what makes the
124
+ // native web_fetch usable in unattended runs, where a gated tool is
125
+ // silently skipped. The safety boundary for web_fetch is its URL guard
126
+ // (tools/url-guard.ts), not the approval gate.
121
127
  default:
122
128
  return undefined;
123
129
  }