@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,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, { stigmerClient });
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.