@stigmer/runner 3.4.1 → 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.
- 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,133 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import type { Config } from "../../config.js";
|
|
3
|
+
import type { PoolAttachTarget } from "../../pool-member.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The activity factory constructs a real StigmerClient; mock the module so no
|
|
7
|
+
* transport is opened and the exchange call is observable.
|
|
8
|
+
*/
|
|
9
|
+
const getRunnerScopedToken = vi.fn();
|
|
10
|
+
vi.mock("../../client/stigmer-client.js", () => ({
|
|
11
|
+
StigmerClient: class {
|
|
12
|
+
getRunnerScopedToken = getRunnerScopedToken;
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import { createAttachSessionActivities } from "../attach-session.js";
|
|
17
|
+
import {
|
|
18
|
+
registerPoolMemberContext,
|
|
19
|
+
clearPoolMemberContext,
|
|
20
|
+
} from "../../pool-member.js";
|
|
21
|
+
|
|
22
|
+
const config = {
|
|
23
|
+
stigmerBackendEndpoint: "http://localhost:7234",
|
|
24
|
+
stigmerToken: "pool-tok",
|
|
25
|
+
} as unknown as Config;
|
|
26
|
+
|
|
27
|
+
function fakeManager(): PoolAttachTarget & {
|
|
28
|
+
addSession: ReturnType<typeof vi.fn>;
|
|
29
|
+
updateToken: ReturnType<typeof vi.fn>;
|
|
30
|
+
} {
|
|
31
|
+
return {
|
|
32
|
+
addSession: vi.fn().mockResolvedValue(undefined),
|
|
33
|
+
updateToken: vi.fn(),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("attach-session activities", () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
getRunnerScopedToken.mockReset();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
clearPoolMemberContext();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("outside a pool member (inert state)", () => {
|
|
47
|
+
it("ProbePoolMember fails fast without a pool context", async () => {
|
|
48
|
+
const { ProbePoolMember } = createAttachSessionActivities(config);
|
|
49
|
+
await expect(ProbePoolMember()).rejects.toThrow(/outside a pool member/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("AttachSession fails fast without a pool context", async () => {
|
|
53
|
+
const { AttachSession } = createAttachSessionActivities(config);
|
|
54
|
+
await expect(AttachSession("ses_1")).rejects.toThrow(/outside a pool member/);
|
|
55
|
+
expect(getRunnerScopedToken).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("ProbePoolMember", () => {
|
|
60
|
+
it("returns the member id as proof of polling", async () => {
|
|
61
|
+
registerPoolMemberContext({
|
|
62
|
+
memberId: "pm_1", poolToken: "pool-tok", manager: fakeManager(),
|
|
63
|
+
});
|
|
64
|
+
const { ProbePoolMember } = createAttachSessionActivities(config);
|
|
65
|
+
|
|
66
|
+
await expect(ProbePoolMember()).resolves.toBe("pm_1");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("AttachSession", () => {
|
|
71
|
+
it("exchanges with the pool credential, applies the session token, then adds the session", async () => {
|
|
72
|
+
const manager = fakeManager();
|
|
73
|
+
registerPoolMemberContext({ memberId: "pm_1", poolToken: "pool-tok", manager });
|
|
74
|
+
getRunnerScopedToken.mockResolvedValue({ token: "session-tok", expiresInSeconds: 90000 });
|
|
75
|
+
const { AttachSession } = createAttachSessionActivities(config);
|
|
76
|
+
|
|
77
|
+
const ack = await AttachSession("ses_1");
|
|
78
|
+
|
|
79
|
+
expect(ack).toBe("session:ses_1");
|
|
80
|
+
expect(getRunnerScopedToken).toHaveBeenCalledWith(
|
|
81
|
+
{ poolClaimSessionId: "ses_1" },
|
|
82
|
+
"pool-tok",
|
|
83
|
+
);
|
|
84
|
+
expect(manager.updateToken).toHaveBeenCalledWith("session-tok");
|
|
85
|
+
expect(manager.addSession).toHaveBeenCalledWith("ses_1");
|
|
86
|
+
// The session worker must poll with the session credential already in
|
|
87
|
+
// place — a reversed order would serve the first activity with the
|
|
88
|
+
// powerless pool token.
|
|
89
|
+
expect(manager.updateToken.mock.invocationCallOrder[0]!)
|
|
90
|
+
.toBeLessThan(manager.addSession.mock.invocationCallOrder[0]!);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("rejects an empty session id", async () => {
|
|
94
|
+
registerPoolMemberContext({
|
|
95
|
+
memberId: "pm_1", poolToken: "pool-tok", manager: fakeManager(),
|
|
96
|
+
});
|
|
97
|
+
const { AttachSession } = createAttachSessionActivities(config);
|
|
98
|
+
|
|
99
|
+
await expect(AttachSession("")).rejects.toThrow(/requires a session id/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("fails hard when the server mints nothing — a pool token cannot serve session work", async () => {
|
|
103
|
+
const manager = fakeManager();
|
|
104
|
+
registerPoolMemberContext({ memberId: "pm_1", poolToken: "pool-tok", manager });
|
|
105
|
+
getRunnerScopedToken.mockResolvedValue(undefined);
|
|
106
|
+
const { AttachSession } = createAttachSessionActivities(config);
|
|
107
|
+
|
|
108
|
+
await expect(AttachSession("ses_1")).rejects.toThrow(/minted no session token/);
|
|
109
|
+
expect(manager.updateToken).not.toHaveBeenCalled();
|
|
110
|
+
expect(manager.addSession).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("propagates an exchange failure so the control plane discards the member", async () => {
|
|
114
|
+
const manager = fakeManager();
|
|
115
|
+
registerPoolMemberContext({ memberId: "pm_1", poolToken: "pool-tok", manager });
|
|
116
|
+
getRunnerScopedToken.mockRejectedValue(new Error("claim not found"));
|
|
117
|
+
const { AttachSession } = createAttachSessionActivities(config);
|
|
118
|
+
|
|
119
|
+
await expect(AttachSession("ses_1")).rejects.toThrow("claim not found");
|
|
120
|
+
expect(manager.addSession).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("propagates a session-worker failure after the token was applied", async () => {
|
|
124
|
+
const manager = fakeManager();
|
|
125
|
+
manager.addSession.mockRejectedValue(new Error("worker create failed"));
|
|
126
|
+
registerPoolMemberContext({ memberId: "pm_1", poolToken: "pool-tok", manager });
|
|
127
|
+
getRunnerScopedToken.mockResolvedValue({ token: "session-tok" });
|
|
128
|
+
const { AttachSession } = createAttachSessionActivities(config);
|
|
129
|
+
|
|
130
|
+
await expect(AttachSession("ses_1")).rejects.toThrow("worker create failed");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -283,7 +283,7 @@ describe("DiscoverMcpServer activity", () => {
|
|
|
283
283
|
|
|
284
284
|
const result = await discoverMcpServer(
|
|
285
285
|
{ mcpServerId: "mcp-123" },
|
|
286
|
-
{ stigmerClient: mockClient as any },
|
|
286
|
+
{ stigmerClient: mockClient as any, transportPosture: "stdio-allowed" },
|
|
287
287
|
);
|
|
288
288
|
|
|
289
289
|
expect(result.tools).toHaveLength(2);
|
|
@@ -295,6 +295,29 @@ describe("DiscoverMcpServer activity", () => {
|
|
|
295
295
|
expect(result.newToolsFingerprint).toHaveLength(64);
|
|
296
296
|
});
|
|
297
297
|
|
|
298
|
+
it("refuses to spawn a stdio server under a forbidding transport posture", async () => {
|
|
299
|
+
// Discovery spawns the same subprocess an execution would, so a cloud
|
|
300
|
+
// runner enforces the local-runner-only rule here too — nothing is
|
|
301
|
+
// spawned before the guard fires.
|
|
302
|
+
const { discoverMcpServer } = await import("../discover-mcp-server.js");
|
|
303
|
+
const { McpTransportError } = await import("../../shared/mcp-transport-guard.js");
|
|
304
|
+
|
|
305
|
+
const mockClient = makeMockStigmerClient({
|
|
306
|
+
mcpServer: makeMcpServer({
|
|
307
|
+
metadata: { slug: "filesystem" },
|
|
308
|
+
spec: makeStdioSpec("npx", ["-y", "@modelcontextprotocol/server-filesystem"]),
|
|
309
|
+
}),
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
await expect(
|
|
313
|
+
discoverMcpServer(
|
|
314
|
+
{ mcpServerId: "mcp-123" },
|
|
315
|
+
{ stigmerClient: mockClient as any, transportPosture: "stdio-forbidden" },
|
|
316
|
+
),
|
|
317
|
+
).rejects.toThrow(McpTransportError);
|
|
318
|
+
expect(mockInitializeConnections).not.toHaveBeenCalled();
|
|
319
|
+
});
|
|
320
|
+
|
|
298
321
|
it("discovers tools and resource templates from an HTTP server", async () => {
|
|
299
322
|
const { discoverMcpServer } = await import("../discover-mcp-server.js");
|
|
300
323
|
|
|
@@ -317,7 +340,7 @@ describe("DiscoverMcpServer activity", () => {
|
|
|
317
340
|
|
|
318
341
|
const result = await discoverMcpServer(
|
|
319
342
|
{ mcpServerId: "mcp-456" },
|
|
320
|
-
{ stigmerClient: mockClient as any },
|
|
343
|
+
{ stigmerClient: mockClient as any, transportPosture: "stdio-allowed" },
|
|
321
344
|
);
|
|
322
345
|
|
|
323
346
|
expect(result.tools).toHaveLength(1);
|
|
@@ -338,7 +361,7 @@ describe("DiscoverMcpServer activity", () => {
|
|
|
338
361
|
});
|
|
339
362
|
|
|
340
363
|
await expect(
|
|
341
|
-
discoverMcpServer({ mcpServerId: "mcp-bad" }, { stigmerClient: mockClient as any }),
|
|
364
|
+
discoverMcpServer({ mcpServerId: "mcp-bad" }, { stigmerClient: mockClient as any, transportPosture: "stdio-allowed" }),
|
|
342
365
|
).rejects.toThrow("not found or has no spec");
|
|
343
366
|
});
|
|
344
367
|
|
|
@@ -353,7 +376,7 @@ describe("DiscoverMcpServer activity", () => {
|
|
|
353
376
|
});
|
|
354
377
|
|
|
355
378
|
await expect(
|
|
356
|
-
discoverMcpServer({ mcpServerId: "mcp-noconfig" }, { stigmerClient: mockClient as any }),
|
|
379
|
+
discoverMcpServer({ mcpServerId: "mcp-noconfig" }, { stigmerClient: mockClient as any, transportPosture: "stdio-allowed" }),
|
|
357
380
|
).rejects.toThrow("no valid server type configured");
|
|
358
381
|
});
|
|
359
382
|
|
|
@@ -381,7 +404,7 @@ describe("DiscoverMcpServer activity", () => {
|
|
|
381
404
|
|
|
382
405
|
const result = await discoverMcpServer(
|
|
383
406
|
{ mcpServerId: "mcp-env", executionContextId: "ctx-abc" },
|
|
384
|
-
{ stigmerClient: mockClient as any },
|
|
407
|
+
{ stigmerClient: mockClient as any, transportPosture: "stdio-allowed" },
|
|
385
408
|
);
|
|
386
409
|
|
|
387
410
|
expect(result.tools).toEqual([]);
|
|
@@ -404,7 +427,7 @@ describe("DiscoverMcpServer activity", () => {
|
|
|
404
427
|
|
|
405
428
|
await discoverMcpServer(
|
|
406
429
|
{ mcpServerId: "mcp-no-env" },
|
|
407
|
-
{ stigmerClient: mockClient as any },
|
|
430
|
+
{ stigmerClient: mockClient as any, transportPosture: "stdio-allowed" },
|
|
408
431
|
);
|
|
409
432
|
|
|
410
433
|
expect(mockClient.getExecutionContextByExecutionId).not.toHaveBeenCalled();
|
|
@@ -430,7 +453,7 @@ describe("DiscoverMcpServer activity", () => {
|
|
|
430
453
|
|
|
431
454
|
const result = await discoverMcpServer(
|
|
432
455
|
{ mcpServerId: "mcp-no-res" },
|
|
433
|
-
{ stigmerClient: mockClient as any },
|
|
456
|
+
{ stigmerClient: mockClient as any, transportPosture: "stdio-allowed" },
|
|
434
457
|
);
|
|
435
458
|
|
|
436
459
|
expect(result.tools).toHaveLength(1);
|
|
@@ -464,7 +487,7 @@ describe("DiscoverMcpServer activity", () => {
|
|
|
464
487
|
|
|
465
488
|
const result = await discoverMcpServer(
|
|
466
489
|
{ mcpServerId: "mcp-existing" },
|
|
467
|
-
{ stigmerClient: mockClient as any },
|
|
490
|
+
{ stigmerClient: mockClient as any, transportPosture: "stdio-allowed" },
|
|
468
491
|
);
|
|
469
492
|
|
|
470
493
|
expect(result.previousToolsFingerprint).not.toBe("");
|
|
@@ -499,7 +522,7 @@ describe("DiscoverMcpServer activity", () => {
|
|
|
499
522
|
|
|
500
523
|
const result = await discoverMcpServer(
|
|
501
524
|
{ mcpServerId: "mcp-fp" },
|
|
502
|
-
{ stigmerClient: mockClient as any },
|
|
525
|
+
{ stigmerClient: mockClient as any, transportPosture: "stdio-allowed" },
|
|
503
526
|
);
|
|
504
527
|
|
|
505
528
|
const expected = toolsFingerprint(result.tools);
|
|
@@ -522,7 +545,7 @@ describe("DiscoverMcpServer activity", () => {
|
|
|
522
545
|
|
|
523
546
|
const result = await discoverMcpServer(
|
|
524
547
|
{ mcpServerId: "mcp-empty" },
|
|
525
|
-
{ stigmerClient: mockClient as any },
|
|
548
|
+
{ stigmerClient: mockClient as any, transportPosture: "stdio-allowed" },
|
|
526
549
|
);
|
|
527
550
|
|
|
528
551
|
expect(result.newToolsFingerprint).toBe("");
|
|
@@ -541,7 +564,7 @@ describe("DiscoverMcpServer activity", () => {
|
|
|
541
564
|
mockInitializeConnections.mockRejectedValue(new Error("Connection refused"));
|
|
542
565
|
|
|
543
566
|
await expect(
|
|
544
|
-
discoverMcpServer({ mcpServerId: "mcp-fail" }, { stigmerClient: mockClient as any }),
|
|
567
|
+
discoverMcpServer({ mcpServerId: "mcp-fail" }, { stigmerClient: mockClient as any, transportPosture: "stdio-allowed" }),
|
|
545
568
|
).rejects.toThrow("Connection refused");
|
|
546
569
|
|
|
547
570
|
expect(mockClose).toHaveBeenCalled();
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Warm-pool attach activities.
|
|
3
|
+
*
|
|
4
|
+
* Registered in the standard activity set (both runner and runner-manager
|
|
5
|
+
* copies) but functional only inside a pool member: they read the pool
|
|
6
|
+
* context registered by the pool boot path (see pool-member.ts) and fail
|
|
7
|
+
* fast everywhere else. The control plane schedules them on the member's
|
|
8
|
+
* control queue `sandbox:{memberId}`, which only a pool member ever polls.
|
|
9
|
+
*
|
|
10
|
+
* Activity contracts (invoked by the cloud ClaimPoolMemberWorkflow):
|
|
11
|
+
*
|
|
12
|
+
* Name: "ProbePoolMember"
|
|
13
|
+
* Input: none
|
|
14
|
+
* Output: string (the member id — proof the member is booted and polling)
|
|
15
|
+
*
|
|
16
|
+
* Name: "AttachSession"
|
|
17
|
+
* Input: (sessionId: string)
|
|
18
|
+
* Output: string (the session task queue now being polled)
|
|
19
|
+
*
|
|
20
|
+
* AttachSession is the claim hand-off: exchange the member's pool_sandbox
|
|
21
|
+
* credential for the session's sandbox token (the control plane authorizes
|
|
22
|
+
* against the claim record), push the session token to every credential sink
|
|
23
|
+
* via the manager, then start the session worker. From the returned ack
|
|
24
|
+
* onward the member serves `session:{sessionId}` like any provisioned
|
|
25
|
+
* sandbox. The control worker keeps polling its (now permanently idle)
|
|
26
|
+
* control queue — the claim deleted the pool row, so nothing dispatches
|
|
27
|
+
* there again, and the worker disappears with the next pod restart; tearing
|
|
28
|
+
* down the worker an activity is running on would deadlock the teardown.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { Config } from "../config.js";
|
|
32
|
+
import { StigmerClient } from "../client/stigmer-client.js";
|
|
33
|
+
import { activityStarted, activityFinished } from "../idle-watchdog.js";
|
|
34
|
+
import { getPoolMemberContext } from "../pool-member.js";
|
|
35
|
+
import { TimingRecorder, emitTimingLog } from "../shared/cold-start-timing.js";
|
|
36
|
+
|
|
37
|
+
export function createAttachSessionActivities(config: Config) {
|
|
38
|
+
const client = new StigmerClient({
|
|
39
|
+
endpoint: config.stigmerBackendEndpoint,
|
|
40
|
+
token: config.stigmerToken,
|
|
41
|
+
tokenRef: config.stigmerTokenRef,
|
|
42
|
+
runnerTokenRef: config.stigmerRunnerTokenRef,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
ProbePoolMember: async (): Promise<string> => {
|
|
47
|
+
activityStarted();
|
|
48
|
+
try {
|
|
49
|
+
const pool = getPoolMemberContext();
|
|
50
|
+
if (!pool) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
"ProbePoolMember invoked outside a pool member (no pool context registered)",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return pool.memberId;
|
|
56
|
+
} finally {
|
|
57
|
+
activityFinished();
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
AttachSession: async (sessionId: string): Promise<string> => {
|
|
62
|
+
activityStarted();
|
|
63
|
+
const timing = new TimingRecorder();
|
|
64
|
+
try {
|
|
65
|
+
const pool = getPoolMemberContext();
|
|
66
|
+
if (!pool) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
"AttachSession invoked outside a pool member (no pool context registered)",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (!sessionId) {
|
|
72
|
+
throw new Error("AttachSession requires a session id");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const scoped = await client.getRunnerScopedToken(
|
|
76
|
+
{ poolClaimSessionId: sessionId },
|
|
77
|
+
pool.poolToken,
|
|
78
|
+
);
|
|
79
|
+
timing.mark("exchange_token");
|
|
80
|
+
if (!scoped) {
|
|
81
|
+
// Unlike the desktop exchange, there is no credential to fall back
|
|
82
|
+
// to: a pool token cannot serve session work, so an empty mint is a
|
|
83
|
+
// hard failure and the control plane discards this member.
|
|
84
|
+
throw new Error(
|
|
85
|
+
"control plane minted no session token for the pool claim " +
|
|
86
|
+
`(member=${pool.memberId}, session=${sessionId})`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
pool.manager.updateToken(scoped.token);
|
|
91
|
+
timing.mark("token_applied");
|
|
92
|
+
|
|
93
|
+
await pool.manager.addSession(sessionId);
|
|
94
|
+
timing.mark("session_worker_added");
|
|
95
|
+
|
|
96
|
+
const taskQueue = `session:${sessionId}`;
|
|
97
|
+
emitTimingLog("pool_attach", {
|
|
98
|
+
pool_member_id: pool.memberId,
|
|
99
|
+
session_id: sessionId,
|
|
100
|
+
task_queue: taskQueue,
|
|
101
|
+
}, timing);
|
|
102
|
+
console.log(
|
|
103
|
+
`[attach-session] Pool member ${pool.memberId} attached to session ` +
|
|
104
|
+
`${sessionId} (queue=${taskQueue})`,
|
|
105
|
+
);
|
|
106
|
+
return taskQueue;
|
|
107
|
+
} finally {
|
|
108
|
+
activityFinished();
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -24,6 +24,11 @@ import { activityStarted, activityFinished } from "../idle-watchdog.js";
|
|
|
24
24
|
import { StigmerClient } from "../client/stigmer-client.js";
|
|
25
25
|
import { mcpServerToResolved } from "../shared/mcp-resolver.js";
|
|
26
26
|
import { toMcpClientConfig } from "../shared/mcp-manager.js";
|
|
27
|
+
import {
|
|
28
|
+
assertTransportAllowed,
|
|
29
|
+
resolveMcpTransportPosture,
|
|
30
|
+
type McpTransportPosture,
|
|
31
|
+
} from "../shared/mcp-transport-guard.js";
|
|
27
32
|
import { detectOAuthChallenge } from "../shared/mcp-oauth-detect.js";
|
|
28
33
|
import { withTimeout } from "../shared/with-timeout.js";
|
|
29
34
|
import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
|
|
@@ -205,6 +210,14 @@ export function injectPlatformEnv(
|
|
|
205
210
|
|
|
206
211
|
export interface DiscoverDeps {
|
|
207
212
|
stigmerClient: StigmerClient;
|
|
213
|
+
/**
|
|
214
|
+
* Whether stdio servers may be spawned here (derive via
|
|
215
|
+
* resolveMcpTransportPosture(config.mode)). Discovery spawns the same
|
|
216
|
+
* subprocess an execution would, so it enforces the same
|
|
217
|
+
* local-runner-only rule — a cloud runner refuses stdio even for
|
|
218
|
+
* tool enumeration.
|
|
219
|
+
*/
|
|
220
|
+
transportPosture: McpTransportPosture;
|
|
208
221
|
}
|
|
209
222
|
|
|
210
223
|
export async function discoverMcpServer(
|
|
@@ -246,6 +259,8 @@ export async function discoverMcpServer(
|
|
|
246
259
|
);
|
|
247
260
|
}
|
|
248
261
|
|
|
262
|
+
assertTransportAllowed(resolved.slug, resolved.connectionType, deps.transportPosture);
|
|
263
|
+
|
|
249
264
|
const connectionConfig = toMcpClientConfig([resolved]);
|
|
250
265
|
const { tools, resourceTemplates } = await connectAndDiscover(
|
|
251
266
|
slug,
|
|
@@ -435,7 +450,10 @@ export function createDiscoverMcpServerActivities(config: Config) {
|
|
|
435
450
|
): Promise<DiscoverMcpServerOutput> => {
|
|
436
451
|
activityStarted();
|
|
437
452
|
try {
|
|
438
|
-
return await discoverMcpServer(input, {
|
|
453
|
+
return await discoverMcpServer(input, {
|
|
454
|
+
stigmerClient,
|
|
455
|
+
transportPosture: resolveMcpTransportPosture(config.mode),
|
|
456
|
+
});
|
|
439
457
|
} finally {
|
|
440
458
|
activityFinished();
|
|
441
459
|
}
|
|
@@ -171,6 +171,83 @@ describe("fetch-interceptor", () => {
|
|
|
171
171
|
});
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
+
describe("cursor_models_fetch timing", () => {
|
|
175
|
+
beforeEach(() => {
|
|
176
|
+
installFetchInterceptor({ proxyEndpoint: PROXY_ENDPOINT, stigmerToken: STIGMER_TOKEN });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Collect emitted `cursor_models_fetch` timing lines from a console.log
|
|
181
|
+
* spy, ignoring every other log line (install banner, warnings, other
|
|
182
|
+
* timelines).
|
|
183
|
+
*/
|
|
184
|
+
function timingLines(spy: ReturnType<typeof vi.spyOn>): Array<Record<string, unknown>> {
|
|
185
|
+
const lines: Array<Record<string, unknown>> = [];
|
|
186
|
+
for (const call of spy.mock.calls) {
|
|
187
|
+
if (typeof call[0] !== "string") continue;
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(call[0]) as Record<string, unknown>;
|
|
190
|
+
if (parsed.stigmer_timing === "cursor_models_fetch") lines.push(parsed);
|
|
191
|
+
} catch {
|
|
192
|
+
// Not a JSON log line — ignore.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return lines;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
it("emits one timing line for a Cursor-domain /v1/models call, carrying execution_id", async () => {
|
|
199
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
200
|
+
const executionContext = getExecutionContext();
|
|
201
|
+
|
|
202
|
+
await executionContext.run({ executionId: "exec-models-1" }, async () => {
|
|
203
|
+
await globalThis.fetch("https://api.cursor.com/v1/models", { method: "GET" });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const lines = timingLines(spy);
|
|
207
|
+
expect(lines).toHaveLength(1);
|
|
208
|
+
expect(lines[0]!.execution_id).toBe("exec-models-1");
|
|
209
|
+
expect(lines[0]!.http_status).toBe(200);
|
|
210
|
+
expect(lines[0]!.total_ms).toBeTypeOf("number");
|
|
211
|
+
const segments = lines[0]!.segments as Array<{ name: string }>;
|
|
212
|
+
expect(segments.map((s) => s.name)).toEqual(["models_fetch"]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("emits for the proxy-endpoint-targeted /v1/models form too", async () => {
|
|
216
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
217
|
+
|
|
218
|
+
await globalThis.fetch(`${PROXY_ENDPOINT}/v1/models`, { method: "GET" });
|
|
219
|
+
|
|
220
|
+
// The fetch itself was rewritten through the proxy path AND timed.
|
|
221
|
+
expect(calls[0]!.url).toBe(
|
|
222
|
+
`${PROXY_ENDPOINT}/v1/proxy/cursor/api.cursor.com/v1/models`,
|
|
223
|
+
);
|
|
224
|
+
expect(timingLines(spy)).toHaveLength(1);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("omits execution_id (rather than fabricating one) outside an execution context", async () => {
|
|
228
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
229
|
+
|
|
230
|
+
await globalThis.fetch("https://api.cursor.com/v1/models", { method: "GET" });
|
|
231
|
+
|
|
232
|
+
const lines = timingLines(spy);
|
|
233
|
+
expect(lines).toHaveLength(1);
|
|
234
|
+
// undefined context values are dropped by JSON.stringify.
|
|
235
|
+
expect("execution_id" in lines[0]!).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("does NOT emit for other rewritten REST paths", async () => {
|
|
239
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
240
|
+
|
|
241
|
+
await globalThis.fetch(
|
|
242
|
+
"https://api2.cursor.sh/auth/exchange_user_api_key",
|
|
243
|
+
{ method: "POST" },
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
expect(calls).toHaveLength(1);
|
|
247
|
+
expect(timingLines(spy)).toHaveLength(0);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
174
251
|
describe("passthrough (no interception)", () => {
|
|
175
252
|
beforeEach(() => {
|
|
176
253
|
installFetchInterceptor({ proxyEndpoint: PROXY_ENDPOINT, stigmerToken: STIGMER_TOKEN });
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { warmCursorSdkStateStores } from "../sdk-warmup.js";
|
|
3
|
+
|
|
4
|
+
const sdkMock = vi.hoisted(() => ({
|
|
5
|
+
createAgentPlatform: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock("@cursor/sdk", () => ({
|
|
9
|
+
createAgentPlatform: sdkMock.createAgentPlatform,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
describe("warmCursorSdkStateStores", () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
sdkMock.createAgentPlatform.mockReset();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("constructs a throwaway platform on a temp-dir state root", async () => {
|
|
18
|
+
sdkMock.createAgentPlatform.mockResolvedValue({});
|
|
19
|
+
|
|
20
|
+
const result = await warmCursorSdkStateStores();
|
|
21
|
+
|
|
22
|
+
expect(result.warmed).toBe(true);
|
|
23
|
+
expect(result.error).toBeUndefined();
|
|
24
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
25
|
+
|
|
26
|
+
expect(sdkMock.createAgentPlatform).toHaveBeenCalledTimes(1);
|
|
27
|
+
const options = sdkMock.createAgentPlatform.mock.calls[0]![0] as {
|
|
28
|
+
workspaceRef: string;
|
|
29
|
+
stateRoot: string;
|
|
30
|
+
};
|
|
31
|
+
// The synthetic ref must never collide with real session platforms,
|
|
32
|
+
// which are keyed `stigmer-session:{sessionId}`.
|
|
33
|
+
expect(options.workspaceRef).toBe("stigmer-warm:boot");
|
|
34
|
+
expect(options.stateRoot).toContain("cursor-sdk-warm-");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("never throws — a failed warm-up reports instead of crashing the member", async () => {
|
|
38
|
+
sdkMock.createAgentPlatform.mockRejectedValue(new Error("sqlite binding missing"));
|
|
39
|
+
|
|
40
|
+
const result = await warmCursorSdkStateStores();
|
|
41
|
+
|
|
42
|
+
expect(result.warmed).toBe(false);
|
|
43
|
+
expect(result.error).toBe("sqlite binding missing");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import type { McpResolutionResult } from "./mcp-resolver.js";
|
|
13
13
|
import { toCursorMcpConfig } from "./mcp-resolver.js";
|
|
14
14
|
import type { StigmerClient } from "../../client/stigmer-client.js";
|
|
15
|
+
import type { McpTransportPosture } from "../../shared/mcp-transport-guard.js";
|
|
15
16
|
import type { McpServerUsage } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
|
|
16
17
|
import { backfillMcpServersIfNeeded as sharedBackfill } from "../../shared/connect-backfill.js";
|
|
17
18
|
|
|
@@ -25,6 +26,7 @@ export async function backfillMcpServersIfNeeded(
|
|
|
25
26
|
usages: McpServerUsage[],
|
|
26
27
|
envVars: Record<string, string>,
|
|
27
28
|
org: string,
|
|
29
|
+
transportPosture: McpTransportPosture,
|
|
28
30
|
onHeartbeat?: () => void,
|
|
29
31
|
secretKeys?: ReadonlySet<string>,
|
|
30
32
|
): Promise<McpResolutionResult> {
|
|
@@ -34,6 +36,7 @@ export async function backfillMcpServersIfNeeded(
|
|
|
34
36
|
usages,
|
|
35
37
|
envVars,
|
|
36
38
|
org,
|
|
39
|
+
transportPosture,
|
|
37
40
|
onHeartbeat,
|
|
38
41
|
secretKeys,
|
|
39
42
|
);
|
|
@@ -17,6 +17,18 @@
|
|
|
17
17
|
|
|
18
18
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
19
19
|
|
|
20
|
+
import { TimingRecorder, emitTimingLog } from "../../shared/cold-start-timing.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The one REST path worth timing individually: the Cursor SDK calls
|
|
24
|
+
* GET /v1/models inside Agent.create/Agent.resume purely to validate the
|
|
25
|
+
* model id, and in proxy mode that is a runner → Stigmer → Cursor double
|
|
26
|
+
* hop sitting inside the resolve_agent setup segment (issue #209). The
|
|
27
|
+
* emitted `cursor_models_fetch` timeline splits that network cost out of
|
|
28
|
+
* the segment total without touching the SDK.
|
|
29
|
+
*/
|
|
30
|
+
const MODELS_PATH = "/v1/models";
|
|
31
|
+
|
|
20
32
|
const CURSOR_DOMAINS = [
|
|
21
33
|
"api2.cursor.sh",
|
|
22
34
|
"api.cursor.com",
|
|
@@ -246,10 +258,15 @@ async function fetchWithUrlRewrite(
|
|
|
246
258
|
const rewrittenUrl = rewriteUrl(url, config.proxyEndpoint);
|
|
247
259
|
const rewrittenInit = replaceAuth(init, config);
|
|
248
260
|
const path = extractPath(url);
|
|
261
|
+
const modelsTiming = path === MODELS_PATH ? new TimingRecorder() : undefined;
|
|
249
262
|
|
|
250
263
|
try {
|
|
251
264
|
const response = await originalFetch(rewrittenUrl, rewrittenInit);
|
|
252
265
|
|
|
266
|
+
if (modelsTiming) {
|
|
267
|
+
emitModelsFetchTiming(modelsTiming, config, response.status);
|
|
268
|
+
}
|
|
269
|
+
|
|
253
270
|
if (!response.ok) {
|
|
254
271
|
if (isNonCriticalPath(path)) {
|
|
255
272
|
console.debug(
|
|
@@ -272,6 +289,28 @@ async function fetchWithUrlRewrite(
|
|
|
272
289
|
}
|
|
273
290
|
}
|
|
274
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Emit the `cursor_models_fetch` timeline for one proxied GET /v1/models.
|
|
294
|
+
*
|
|
295
|
+
* `execution_id` comes from the AsyncLocalStorage execution context — the
|
|
296
|
+
* whole ExecuteCursor activity runs inside runWithExecutionContext, so the
|
|
297
|
+
* value is correct even with concurrent activities on one runner process.
|
|
298
|
+
* `recordTimingMetric` deliberately ignores this event (no mapped OTel
|
|
299
|
+
* instrument): it is a forensic stdout line only, joined to the
|
|
300
|
+
* execution_setup timeline by execution_id in cold-start-baseline analysis.
|
|
301
|
+
*/
|
|
302
|
+
function emitModelsFetchTiming(
|
|
303
|
+
timing: TimingRecorder,
|
|
304
|
+
config: ProxyConfig,
|
|
305
|
+
httpStatus: number,
|
|
306
|
+
): void {
|
|
307
|
+
timing.mark("models_fetch");
|
|
308
|
+
emitTimingLog("cursor_models_fetch", {
|
|
309
|
+
execution_id: executionContext.getStore()?.executionId ?? config.executionId,
|
|
310
|
+
http_status: httpStatus,
|
|
311
|
+
}, timing);
|
|
312
|
+
}
|
|
313
|
+
|
|
275
314
|
/**
|
|
276
315
|
* Connect-RPC-via-fetch path: inject x-stigmer-auth for BiDi proxy
|
|
277
316
|
* authentication without rewriting the URL or replacing authorization.
|