@stigmer/runner 3.4.1 → 3.5.1
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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/attach-session.d.ts +34 -0
- package/dist/activities/attach-session.js +95 -0
- package/dist/activities/attach-session.js.map +1 -0
- package/dist/activities/discover-mcp-server.d.ts +9 -0
- package/dist/activities/discover-mcp-server.js +6 -1
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/connect-backfill.d.ts +2 -1
- package/dist/activities/execute-cursor/connect-backfill.js +2 -2
- package/dist/activities/execute-cursor/connect-backfill.js.map +1 -1
- package/dist/activities/execute-cursor/fetch-interceptor.js +31 -0
- package/dist/activities/execute-cursor/fetch-interceptor.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +70 -3
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/mcp-resolver.d.ts +7 -1
- package/dist/activities/execute-cursor/mcp-resolver.js +15 -2
- package/dist/activities/execute-cursor/mcp-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/sdk-warmup.d.ts +44 -0
- package/dist/activities/execute-cursor/sdk-warmup.js +61 -0
- package/dist/activities/execute-cursor/sdk-warmup.js.map +1 -0
- package/dist/activities/execute-deep-agent/setup.js +48 -5
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +9 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.js +19 -5
- package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +13 -1
- package/dist/client/stigmer-client.js +40 -5
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/client/token-claims.d.ts +9 -6
- package/dist/client/token-claims.js +22 -6
- package/dist/client/token-claims.js.map +1 -1
- package/dist/main.d.ts +8 -1
- package/dist/main.js +122 -2
- package/dist/main.js.map +1 -1
- package/dist/middleware/index.d.ts +0 -1
- package/dist/middleware/index.js +0 -1
- package/dist/middleware/index.js.map +1 -1
- package/dist/otel-metrics.d.ts +10 -1
- package/dist/otel-metrics.js +22 -1
- package/dist/otel-metrics.js.map +1 -1
- package/dist/pool-member.d.ts +61 -0
- package/dist/pool-member.js +51 -0
- package/dist/pool-member.js.map +1 -0
- package/dist/runner-manager.d.ts +7 -0
- package/dist/runner-manager.js +39 -1
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +15 -1
- package/dist/runner.js.map +1 -1
- package/dist/shared/cold-start-timing.d.ts +89 -0
- package/dist/shared/cold-start-timing.js +166 -0
- package/dist/shared/cold-start-timing.js.map +1 -0
- package/dist/shared/connect-backfill.d.ts +2 -1
- package/dist/shared/connect-backfill.js +4 -2
- package/dist/shared/connect-backfill.js.map +1 -1
- package/dist/shared/mcp-manager.d.ts +5 -15
- package/dist/shared/mcp-manager.js +5 -32
- package/dist/shared/mcp-manager.js.map +1 -1
- package/dist/shared/mcp-resolver.d.ts +15 -5
- package/dist/shared/mcp-resolver.js +23 -6
- package/dist/shared/mcp-resolver.js.map +1 -1
- package/dist/shared/mcp-transport-guard.d.ts +53 -0
- package/dist/shared/mcp-transport-guard.js +70 -0
- package/dist/shared/mcp-transport-guard.js.map +1 -0
- package/dist/shared/tool-kind.js +6 -0
- package/dist/shared/tool-kind.js.map +1 -1
- package/dist/tools/index.d.ts +27 -0
- package/dist/tools/index.js +27 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/think-tool.js.map +1 -0
- package/dist/tools/url-guard.d.ts +64 -0
- package/dist/tools/url-guard.js +211 -0
- package/dist/tools/url-guard.js.map +1 -0
- package/dist/tools/web-fetch-tool.d.ts +53 -0
- package/dist/tools/web-fetch-tool.js +216 -0
- package/dist/tools/web-fetch-tool.js.map +1 -0
- package/package.json +4 -2
- package/src/__tests__/otel-metrics.test.ts +6 -0
- package/src/__tests__/pool-member.test.ts +76 -0
- package/src/activities/__tests__/attach-session.test.ts +133 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +34 -11
- package/src/activities/attach-session.ts +112 -0
- package/src/activities/discover-mcp-server.ts +19 -1
- package/src/activities/execute-cursor/__tests__/fetch-interceptor.test.ts +77 -0
- package/src/activities/execute-cursor/__tests__/sdk-warmup.test.ts +45 -0
- package/src/activities/execute-cursor/connect-backfill.ts +3 -0
- package/src/activities/execute-cursor/fetch-interceptor.ts +39 -0
- package/src/activities/execute-cursor/index.ts +72 -3
- package/src/activities/execute-cursor/mcp-resolver.ts +21 -2
- package/src/activities/execute-cursor/sdk-warmup.ts +68 -0
- package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +24 -0
- package/src/activities/execute-deep-agent/setup.ts +48 -7
- package/src/activities/execute-deep-agent/subagent-transformer.ts +27 -2
- package/src/client/__tests__/stigmer-client.test.ts +77 -2
- package/src/client/__tests__/token-claims.test.ts +15 -1
- package/src/client/stigmer-client.ts +51 -6
- package/src/client/token-claims.ts +29 -6
- package/src/main.ts +139 -2
- package/src/middleware/index.ts +0 -1
- package/src/otel-metrics.ts +25 -1
- package/src/pool-member.ts +99 -0
- package/src/runner-manager.ts +52 -0
- package/src/runner.ts +15 -0
- package/src/shared/__tests__/cold-start-timing.test.ts +271 -0
- package/src/shared/__tests__/connect-backfill.test.ts +11 -11
- package/src/shared/__tests__/mcp-manager.test.ts +2 -73
- package/src/shared/__tests__/mcp-resolver.test.ts +108 -0
- package/src/shared/__tests__/mcp-transport-guard.test.ts +71 -0
- package/src/shared/cold-start-timing.ts +197 -0
- package/src/shared/connect-backfill.ts +5 -1
- package/src/shared/mcp-manager.ts +4 -41
- package/src/shared/mcp-resolver.ts +29 -6
- package/src/shared/mcp-transport-guard.ts +81 -0
- package/src/shared/tool-kind.ts +6 -0
- package/src/tools/__tests__/url-guard.test.ts +137 -0
- package/src/tools/__tests__/web-fetch-tool.test.ts +193 -0
- package/src/tools/index.ts +28 -0
- package/src/tools/url-guard.ts +232 -0
- package/src/tools/web-fetch-tool.ts +275 -0
- package/dist/middleware/think-tool.js.map +0 -1
- /package/dist/{middleware → tools}/think-tool.d.ts +0 -0
- /package/dist/{middleware → tools}/think-tool.js +0 -0
- /package/src/{middleware → tools}/__tests__/think-tool.test.ts +0 -0
- /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
|
+
});
|
|
@@ -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
|
-
|
|
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
|
|
3
|
+
* ResolvedMcpServer format.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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)
|
|
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
|
+
}
|
package/src/shared/tool-kind.ts
CHANGED
|
@@ -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
|
}
|