@stigmer/runner 3.12.3 → 3.12.4

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 (101) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/call-http.js +12 -0
  3. package/dist/activities/call-http.js.map +1 -1
  4. package/dist/activities/call-llm.d.ts +18 -0
  5. package/dist/activities/call-llm.js +56 -2
  6. package/dist/activities/call-llm.js.map +1 -1
  7. package/dist/activities/execute-cursor/agent-session-cache.d.ts +72 -0
  8. package/dist/activities/execute-cursor/agent-session-cache.js +186 -0
  9. package/dist/activities/execute-cursor/agent-session-cache.js.map +1 -0
  10. package/dist/activities/execute-cursor/index.js +61 -28
  11. package/dist/activities/execute-cursor/index.js.map +1 -1
  12. package/dist/activities/execute-cursor/service-tier.d.ts +5 -15
  13. package/dist/activities/execute-cursor/service-tier.js +5 -21
  14. package/dist/activities/execute-cursor/service-tier.js.map +1 -1
  15. package/dist/activities/execute-cursor/skill-resolver.js +1 -1
  16. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  17. package/dist/activities/execute-deep-agent/setup.js +14 -0
  18. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  19. package/dist/activities/execute-deep-agent/shell-env.d.ts +8 -5
  20. package/dist/activities/execute-deep-agent/shell-env.js +10 -7
  21. package/dist/activities/execute-deep-agent/shell-env.js.map +1 -1
  22. package/dist/config.js +10 -5
  23. package/dist/config.js.map +1 -1
  24. package/dist/encryption/config.js +7 -2
  25. package/dist/encryption/config.js.map +1 -1
  26. package/dist/main.js +12 -6
  27. package/dist/main.js.map +1 -1
  28. package/dist/payload-codecs.js +2 -1
  29. package/dist/payload-codecs.js.map +1 -1
  30. package/dist/runner-manager.js +20 -7
  31. package/dist/runner-manager.js.map +1 -1
  32. package/dist/runner.js +19 -6
  33. package/dist/runner.js.map +1 -1
  34. package/dist/shared/fingerprint-secret.d.ts +3 -2
  35. package/dist/shared/fingerprint-secret.js +5 -3
  36. package/dist/shared/fingerprint-secret.js.map +1 -1
  37. package/dist/shared/llm-backend.js +8 -1
  38. package/dist/shared/llm-backend.js.map +1 -1
  39. package/dist/shared/model-client.d.ts +15 -0
  40. package/dist/shared/model-client.js +57 -13
  41. package/dist/shared/model-client.js.map +1 -1
  42. package/dist/shared/registry-endpoint.d.ts +5 -0
  43. package/dist/shared/registry-endpoint.js +7 -1
  44. package/dist/shared/registry-endpoint.js.map +1 -1
  45. package/dist/shared/runner-credential-keys.d.ts +26 -1
  46. package/dist/shared/runner-credential-keys.js +34 -1
  47. package/dist/shared/runner-credential-keys.js.map +1 -1
  48. package/dist/shared/runner-credential-store.d.ts +77 -0
  49. package/dist/shared/runner-credential-store.js +111 -0
  50. package/dist/shared/runner-credential-store.js.map +1 -0
  51. package/dist/shared/service-tier.d.ts +55 -0
  52. package/dist/shared/service-tier.js +67 -0
  53. package/dist/shared/service-tier.js.map +1 -0
  54. package/dist/shared/skill-writer.js +2 -2
  55. package/dist/shared/skill-writer.js.map +1 -1
  56. package/dist/shared/zip-extract.d.ts +10 -3
  57. package/dist/shared/zip-extract.js +10 -3
  58. package/dist/shared/zip-extract.js.map +1 -1
  59. package/dist/workflow-engine/tasks/call-function.d.ts +14 -0
  60. package/dist/workflow-engine/tasks/call-function.js +49 -5
  61. package/dist/workflow-engine/tasks/call-function.js.map +1 -1
  62. package/dist/workflow-engine/types.d.ts +6 -0
  63. package/dist/workflow-engine/types.js.map +1 -1
  64. package/dist/workflows/engine-core.js +36 -8
  65. package/dist/workflows/engine-core.js.map +1 -1
  66. package/package.json +2 -2
  67. package/src/activities/__tests__/call-http.test.ts +36 -0
  68. package/src/activities/__tests__/call-llm.test.ts +77 -0
  69. package/src/activities/call-http.ts +17 -0
  70. package/src/activities/call-llm.ts +78 -2
  71. package/src/activities/execute-cursor/__tests__/agent-session-cache.test.ts +220 -0
  72. package/src/activities/execute-cursor/__tests__/service-tier.test.ts +1 -1
  73. package/src/activities/execute-cursor/agent-session-cache.ts +229 -0
  74. package/src/activities/execute-cursor/index.ts +66 -20
  75. package/src/activities/execute-cursor/service-tier.ts +5 -29
  76. package/src/activities/execute-cursor/skill-resolver.ts +1 -1
  77. package/src/activities/execute-deep-agent/setup.ts +15 -0
  78. package/src/activities/execute-deep-agent/shell-env.ts +10 -7
  79. package/src/config.ts +10 -5
  80. package/src/encryption/config.ts +8 -2
  81. package/src/main.ts +16 -6
  82. package/src/payload-codecs.ts +2 -1
  83. package/src/runner-manager.ts +29 -6
  84. package/src/runner.ts +25 -6
  85. package/src/shared/__tests__/model-client.test.ts +99 -0
  86. package/src/shared/__tests__/runner-credential-store.test.ts +155 -0
  87. package/src/shared/__tests__/zip-extract.test.ts +46 -11
  88. package/src/shared/fingerprint-secret.ts +5 -3
  89. package/src/shared/llm-backend.ts +7 -1
  90. package/src/shared/model-client.ts +76 -13
  91. package/src/shared/registry-endpoint.ts +9 -1
  92. package/src/shared/runner-credential-keys.ts +36 -1
  93. package/src/shared/runner-credential-store.ts +115 -0
  94. package/src/shared/service-tier.ts +78 -0
  95. package/src/shared/skill-writer.ts +2 -2
  96. package/src/shared/zip-extract.ts +14 -7
  97. package/src/workflow-engine/__tests__/golden-execution.test.ts +20 -1
  98. package/src/workflow-engine/__tests__/tasks/call-function.test.ts +105 -0
  99. package/src/workflow-engine/tasks/call-function.ts +74 -13
  100. package/src/workflow-engine/types.ts +6 -0
  101. package/src/workflows/engine-core.ts +39 -8
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Session-keyed Cursor agent cache — keeps the SDK executor (and its stdio
3
+ * MCP server processes) alive across turns of the SAME session (#215).
4
+ *
5
+ * Why this exists: the SDK's local executor cache is refcounted and keyed
6
+ * by the full acquisition config (workingDirectory, hashed apiKey,
7
+ * settingSources, mcpServers, customSubagents). `agent.close()` releases
8
+ * the agent's lease; at refcount zero the executor is DISPOSED and every
9
+ * stdio MCP server is killed. The activity used to close on every terminal
10
+ * path, so each turn re-acquired the executor and re-spawned every MCP
11
+ * server inside `agent.send()` — a measured 2.2–3.2s per-turn tax
12
+ * (`turn_first_event`'s `send_returned` segment).
13
+ *
14
+ * Ownership model — exclusive checkout, explicit lifetime:
15
+ * - A finishing turn PARKS its healthy agent here (`cacheSessionAgent`);
16
+ * the next activity for the session CHECKS IT OUT (`takeCachedAgent`),
17
+ * removing it from the cache, so two concurrent activities can never
18
+ * share one Agent handle — the loser of the race resolves its own.
19
+ * - The cached agent is reused only when the acquisition FINGERPRINT
20
+ * matches. Any config drift (rotated credential, edited MCP servers,
21
+ * model change, different workspace) closes the parked agent and forces
22
+ * a fresh resolve — correctness by construction: a reused executor would
23
+ * otherwise keep serving the OLD config.
24
+ * - Failure paths never park: a suspect agent is closed where it failed
25
+ * (the pre-existing close sites), and `evictSessionAgent` clears any
26
+ * parked entry when a session's state is replaced out from under it.
27
+ * - Idle TTL + LRU cap bound memory on multi-session hosts (the desktop
28
+ * runner-manager hosts many sessions per process; a cloud sandbox is
29
+ * session-pinned and holds at most one entry). Worker shutdown closes
30
+ * everything (`closeAllCachedAgents`).
31
+ */
32
+
33
+ import { createHash } from "node:crypto";
34
+
35
+ /** The slice of SDKAgent this cache needs — close() releases the executor lease. */
36
+ export interface CacheableAgent {
37
+ readonly agentId: string;
38
+ close(): void;
39
+ }
40
+
41
+ interface CachedSessionAgent {
42
+ readonly agent: CacheableAgent;
43
+ readonly fingerprint: string;
44
+ readonly evictTimer: NodeJS.Timeout;
45
+ /** Insertion-order tiebreaker for the LRU cap. */
46
+ readonly parkedAt: number;
47
+ }
48
+
49
+ /**
50
+ * Default idle lifetime for a parked agent. Long enough to cover human
51
+ * think-time between turns and approval round-trips; short enough that an
52
+ * abandoned session does not pin MCP subprocesses for hours.
53
+ */
54
+ const DEFAULT_IDLE_TTL_MS = 30 * 60 * 1000;
55
+
56
+ /**
57
+ * Ceiling on concurrently parked agents (each holds an executor + its MCP
58
+ * subprocesses). Cloud sandboxes never approach it (one session per pod);
59
+ * it protects long-lived desktop runner-managers.
60
+ */
61
+ const MAX_PARKED_AGENTS = 32;
62
+
63
+ function resolveIdleTtlMs(): number {
64
+ const parsed = Number.parseInt(process.env.STIGMER_CURSOR_AGENT_CACHE_TTL_MS ?? "", 10);
65
+ return parsed > 0 ? parsed : DEFAULT_IDLE_TTL_MS;
66
+ }
67
+
68
+ const parkedAgents = new Map<string, CachedSessionAgent>();
69
+
70
+ function closeQuietly(agent: CacheableAgent): void {
71
+ try {
72
+ agent.close();
73
+ } catch {
74
+ /* best effort — the lease release is advisory on an already-dead agent */
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Fingerprint of everything that determines whether a parked agent can
80
+ * serve the next turn as-is: the SDK executor cache key inputs PLUS the
81
+ * per-agent options resume() re-supplies (model selection, sub-agents).
82
+ * The API key contributes only as a hash — the fingerprint must never be
83
+ * a secret-bearing value (it appears in no logs, but defense in depth).
84
+ */
85
+ export function computeAgentFingerprint(createOptions: Record<string, unknown>): string {
86
+ const { apiKey, ...rest } = createOptions;
87
+ const material = {
88
+ ...rest,
89
+ apiKeyHash:
90
+ typeof apiKey === "string" && apiKey.length > 0
91
+ ? createHash("sha256").update(apiKey).digest("hex")
92
+ : undefined,
93
+ };
94
+ return createHash("sha256").update(stableStringify(material)).digest("hex");
95
+ }
96
+
97
+ /** Deterministic JSON: object keys sorted recursively (the SDK's own idiom). */
98
+ function stableStringify(value: unknown): string {
99
+ return JSON.stringify(sortKeys(value));
100
+ }
101
+
102
+ function sortKeys(value: unknown): unknown {
103
+ if (Array.isArray(value)) return value.map(sortKeys);
104
+ if (value !== null && typeof value === "object" && value.constructor === Object) {
105
+ return Object.fromEntries(
106
+ Object.keys(value as Record<string, unknown>)
107
+ .sort()
108
+ .map((k) => [k, sortKeys((value as Record<string, unknown>)[k])]),
109
+ );
110
+ }
111
+ return value;
112
+ }
113
+
114
+ /**
115
+ * Exclusive checkout: returns the parked agent for the session and removes
116
+ * it from the cache, or undefined when there is nothing reusable.
117
+ *
118
+ * A parked agent is reusable only when BOTH hold:
119
+ * - `fingerprint` matches (config identical to what the agent was built with);
120
+ * - `expectedAgentId`, when non-empty, matches the parked agent (the
121
+ * session's harnessStateId is the source of truth — a recovery in another
122
+ * activity may have replaced the agent since this one was parked).
123
+ *
124
+ * A mismatch on either closes the parked agent: it can never serve this
125
+ * session again, and holding it would only pin dead MCP processes.
126
+ */
127
+ export function takeCachedAgent(
128
+ sessionId: string,
129
+ fingerprint: string,
130
+ expectedAgentId: string,
131
+ ): CacheableAgent | undefined {
132
+ const entry = parkedAgents.get(sessionId);
133
+ if (!entry) return undefined;
134
+
135
+ parkedAgents.delete(sessionId);
136
+ clearTimeout(entry.evictTimer);
137
+
138
+ const agentMatches = expectedAgentId === "" || entry.agent.agentId === expectedAgentId;
139
+ if (entry.fingerprint !== fingerprint || !agentMatches) {
140
+ console.log(
141
+ `agent-session-cache: parked agent for session=${sessionId} not reusable ` +
142
+ `(fingerprintMatch=${entry.fingerprint === fingerprint}, agentIdMatch=${agentMatches}) — closing`,
143
+ );
144
+ closeQuietly(entry.agent);
145
+ return undefined;
146
+ }
147
+
148
+ return entry.agent;
149
+ }
150
+
151
+ /**
152
+ * Parks a healthy agent for the session's next turn. Replaces (and closes)
153
+ * any agent already parked for the session; evicts the oldest entry when
154
+ * the cap is reached.
155
+ */
156
+ export function cacheSessionAgent(
157
+ sessionId: string,
158
+ agent: CacheableAgent,
159
+ fingerprint: string,
160
+ ): void {
161
+ if (!sessionId) {
162
+ // No stable key to reuse by — release the lease as before the cache.
163
+ closeQuietly(agent);
164
+ return;
165
+ }
166
+
167
+ const displaced = parkedAgents.get(sessionId);
168
+ if (displaced) {
169
+ clearTimeout(displaced.evictTimer);
170
+ closeQuietly(displaced.agent);
171
+ }
172
+
173
+ if (parkedAgents.size >= MAX_PARKED_AGENTS) {
174
+ let oldestKey: string | undefined;
175
+ let oldestAt = Infinity;
176
+ for (const [key, entry] of parkedAgents) {
177
+ if (entry.parkedAt < oldestAt) {
178
+ oldestAt = entry.parkedAt;
179
+ oldestKey = key;
180
+ }
181
+ }
182
+ if (oldestKey !== undefined) evictSessionAgent(oldestKey);
183
+ }
184
+
185
+ const ttlMs = resolveIdleTtlMs();
186
+ const evictTimer = setTimeout(() => evictSessionAgent(sessionId), ttlMs);
187
+ // Never keep the process alive just to evict an idle agent.
188
+ evictTimer.unref?.();
189
+
190
+ parkedAgents.set(sessionId, {
191
+ agent,
192
+ fingerprint,
193
+ evictTimer,
194
+ parkedAt: Date.now(),
195
+ });
196
+ }
197
+
198
+ /** Closes and forgets the parked agent for a session, if any. */
199
+ export function evictSessionAgent(sessionId: string): void {
200
+ const entry = parkedAgents.get(sessionId);
201
+ if (!entry) return;
202
+ parkedAgents.delete(sessionId);
203
+ clearTimeout(entry.evictTimer);
204
+ closeQuietly(entry.agent);
205
+ console.log(`agent-session-cache: evicted parked agent for session=${sessionId}`);
206
+ }
207
+
208
+ /** Worker shutdown: release every parked lease so executors dispose cleanly. */
209
+ export function closeAllCachedAgents(): void {
210
+ for (const [sessionId, entry] of parkedAgents) {
211
+ clearTimeout(entry.evictTimer);
212
+ closeQuietly(entry.agent);
213
+ console.log(`agent-session-cache: closed parked agent for session=${sessionId} (shutdown)`);
214
+ }
215
+ parkedAgents.clear();
216
+ }
217
+
218
+ /** Test seam. */
219
+ export function _resetAgentSessionCacheForTests(): void {
220
+ for (const entry of parkedAgents.values()) {
221
+ clearTimeout(entry.evictTimer);
222
+ }
223
+ parkedAgents.clear();
224
+ }
225
+
226
+ /** Test seam. */
227
+ export function _parkedAgentCountForTests(): number {
228
+ return parkedAgents.size;
229
+ }
@@ -41,6 +41,7 @@ import type { Config } from "../../config.js";
41
41
  import { StigmerClient } from "../../client/stigmer-client.js";
42
42
  import { describeExecutionError } from "../../shared/model-error.js";
43
43
  import { resolveAgentWithTransportRecovery } from "./session-lifecycle.js";
44
+ import { cacheSessionAgent, computeAgentFingerprint, takeCachedAgent } from "./agent-session-cache.js";
44
45
  import type { AgentResolution, AgentResolutionReason, CreateAgentOptions, CreateCloudAgentOptions } from "./session-lifecycle.js";
45
46
  import { CursorMode } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
46
47
  import { determineCursorMode, isCloudMode } from "./cursor-mode.js";
@@ -134,7 +135,8 @@ import { statusProtoWriter } from "../../shared/execution-status-writer.js";
134
135
  import { setInterceptorExecutionId, runWithExecutionContext } from "./fetch-interceptor.js";
135
136
  import { closeProxySessions } from "./http2-interceptor.js";
136
137
  import { resolveModelId, ensureLoaded as ensurePricingLoaded } from "./model-pricing.js";
137
- import { resolveEffectiveServiceTier, resolveServiceTierParams } from "./service-tier.js";
138
+ import { resolveEffectiveServiceTier } from "../../shared/service-tier.js";
139
+ import { resolveServiceTierParams } from "./service-tier.js";
138
140
  import { UsageAccumulator } from "./usage-accumulator.js";
139
141
  import { StreamingUsageSummarySchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/usage_pb";
140
142
  import { activityStarted, activityFinished } from "../../idle-watchdog.js";
@@ -1052,21 +1054,52 @@ async function executeCursorInner(
1052
1054
  // is the largest user-visible setup segment; this split keeps its
1053
1055
  // historical meaning — the SDK call was already 98%+ of it).
1054
1056
  setupTiming.mark("prepare_agent");
1055
- let resolution: AgentResolution = await resolveAgentWithTransportRecovery({
1056
- harnessStateId: threadId,
1057
- createOptions,
1058
- mode: agentMode,
1059
- timeoutMs: config.agentResolveTimeoutMs,
1060
- buildTimeoutMessage: (finalAttempt) =>
1061
- `Cursor agent ${threadId ? "resume" : "create"} timed out after ${resolveTimeoutSeconds}s ` +
1062
- `(${config.proxyEndpoint ? `via proxy ${config.proxyEndpoint}` : "direct Cursor API connection"}). ` +
1063
- `The transport connection is likely dead. ` +
1064
- (finalAttempt
1065
- ? `An automatic retry on a fresh transport connection also timed out. ` +
1066
- `Retry the message later; if this persists, check proxy and network health.`
1067
- : `Resetting the transport and retrying automatically.`),
1068
- resetTransport: closeProxySessions,
1069
- });
1057
+
1058
+ // Phase 8a: Reuse the previous turn's agent when this session parked one
1059
+ // (#215). A checkout hit skips Agent.resume() AND — the real win — keeps
1060
+ // the SDK executor lease alive, so agent.send() below re-acquires the
1061
+ // warm executor instead of re-spawning every stdio MCP server (the
1062
+ // measured 2.2–3.2s `send_returned` tax). The fingerprint covers the
1063
+ // full acquisition config, so any drift (rotated credential, edited MCP
1064
+ // servers, model change) falls through to a fresh resolve.
1065
+ const agentFingerprint = computeAgentFingerprint(
1066
+ createOptions as unknown as Record<string, unknown>,
1067
+ );
1068
+ const parkedAgent = takeCachedAgent(sessionId, agentFingerprint, threadId ?? "");
1069
+ let resolution: AgentResolution;
1070
+ if (parkedAgent) {
1071
+ console.log(
1072
+ `ExecuteCursor reusing parked session agent: execution=${executionId}, ` +
1073
+ `session=${sessionId}, agentId=${parkedAgent.agentId}`,
1074
+ );
1075
+ resolution = {
1076
+ agent: parkedAgent as AgentResolution["agent"],
1077
+ agentId: parkedAgent.agentId,
1078
+ isNew: false,
1079
+ resumed: true,
1080
+ mode: agentMode,
1081
+ // The parked handle IS the live conversation — every consumer of
1082
+ // "resumed_successfully" (prompt selection, poisoned-handle
1083
+ // recovery eligibility) wants exactly those semantics.
1084
+ reason: "resumed_successfully",
1085
+ };
1086
+ } else {
1087
+ resolution = await resolveAgentWithTransportRecovery({
1088
+ harnessStateId: threadId,
1089
+ createOptions,
1090
+ mode: agentMode,
1091
+ timeoutMs: config.agentResolveTimeoutMs,
1092
+ buildTimeoutMessage: (finalAttempt) =>
1093
+ `Cursor agent ${threadId ? "resume" : "create"} timed out after ${resolveTimeoutSeconds}s ` +
1094
+ `(${config.proxyEndpoint ? `via proxy ${config.proxyEndpoint}` : "direct Cursor API connection"}). ` +
1095
+ `The transport connection is likely dead. ` +
1096
+ (finalAttempt
1097
+ ? `An automatic retry on a fresh transport connection also timed out. ` +
1098
+ `Retry the message later; if this persists, check proxy and network health.`
1099
+ : `Resetting the transport and retrying automatically.`),
1100
+ resetTransport: closeProxySessions,
1101
+ });
1102
+ }
1070
1103
 
1071
1104
  console.log(
1072
1105
  `ExecuteCursor agent resolved: execution=${executionId}, ` +
@@ -1462,7 +1495,9 @@ async function executeCursorInner(
1462
1495
  timestamp: utcTimestamp(),
1463
1496
  }));
1464
1497
  await persist(status);
1465
- try { resolution.agent.close(); } catch { /* best effort */ }
1498
+ // Clean terminal: the conversation continues on the next message,
1499
+ // so park the healthy agent for that turn (#215).
1500
+ cacheSessionAgent(sessionId ?? "", resolution.agent, agentFingerprint);
1466
1501
  console.warn(
1467
1502
  `ExecuteCursor terminated (cost cap): execution=${executionId}, ` +
1468
1503
  `estimatedCostUsd=${estimated.toFixed(4)}, maxCostUsd=${maxCostUsd.toFixed(2)}`,
@@ -1527,7 +1562,8 @@ async function executeCursorInner(
1527
1562
  timestamp: utcTimestamp(),
1528
1563
  }));
1529
1564
  await persist(status);
1530
- try { resolution.agent.close(); } catch { /* best effort */ }
1565
+ // Clean terminal park for the session's next turn (#215).
1566
+ cacheSessionAgent(sessionId ?? "", resolution.agent, agentFingerprint);
1531
1567
  console.log(`ExecuteCursor completed (platform stop): execution=${executionId}`);
1532
1568
  return { kind: "return" };
1533
1569
  }
@@ -1573,6 +1609,12 @@ async function executeCursorInner(
1573
1609
  const enterApprovalPause = async (boundary: TurnBoundaryResult) => {
1574
1610
  status.phase = ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL;
1575
1611
  await persist(status);
1612
+ // The approval-resume reinvocation is the cache's best case: park the
1613
+ // agent so the resumed turn skips the full executor rebuild (#215).
1614
+ // (This path previously dropped the handle without close() — the
1615
+ // lease leaked; parking makes the lifetime explicit.) An absent
1616
+ // sessionId falls back to "" — the cache closes the lease immediately.
1617
+ cacheSessionAgent(sessionId ?? "", resolution.agent, agentFingerprint);
1576
1618
  console.log(
1577
1619
  `ExecuteCursor returning WAITING_FOR_APPROVAL: ${boundary.deniedToolCallCount} gated tool(s), ` +
1578
1620
  `${boundary.capturedChangeCount} file card(s) pending`,
@@ -2088,8 +2130,12 @@ async function executeCursorInner(
2088
2130
  (status.error ? `, error=${status.error}` : ""),
2089
2131
  );
2090
2132
 
2091
- // Release SDK executor lease to prevent cache buildup across workflow tasks
2092
- try { resolution.agent.close(); } catch { /* best effort */ }
2133
+ // Park the agent (with its executor lease) for the session's next turn
2134
+ // instead of closing it the idle TTL / shutdown hooks in
2135
+ // agent-session-cache own the eventual release, so cache buildup across
2136
+ // sessions stays bounded while turns of ONE session stop paying the
2137
+ // executor + MCP re-spawn tax (#215).
2138
+ cacheSessionAgent(sessionId ?? "", resolution.agent, agentFingerprint);
2093
2139
 
2094
2140
  const slim = slimStatus(status) as Record<string, unknown>;
2095
2141
  if (finalText !== undefined) {
@@ -20,19 +20,17 @@
20
20
  * rides the same proxy fetch-interceptor as every other SDK call, so it
21
21
  * works identically in proxy and direct modes.
22
22
  *
23
- * UNSPECIFIED resolves to STANDARD here and ONLY here every upstream
24
- * layer preserves the caller's raw enum so "user chose standard" stays
25
- * distinguishable from "platform default" all the way to the ledger.
23
+ * The harness-neutral halves the tier enum semantics, and the single
24
+ * UNSPECIFIED→STANDARD resolution point live in
25
+ * `shared/service-tier.ts` since #361 extended tiers to the native
26
+ * harness; this module keeps only the Cursor-catalog translation.
26
27
  */
27
28
 
28
29
  import { Cursor } from "@cursor/sdk";
29
30
  import type { ModelListItem, ModelParameterValue } from "@cursor/sdk";
30
31
  import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
31
32
 
32
- /**
33
- * The effective tier after platform-default resolution: never UNSPECIFIED.
34
- */
35
- export type EffectiveServiceTier = ServiceTier.STANDARD | ServiceTier.FAST;
33
+ import { serviceTierLabel, type EffectiveServiceTier } from "../../shared/service-tier.js";
36
34
 
37
35
  /**
38
36
  * Catalog ids that mean "Cursor picks the model" (Auto). Auto's single
@@ -72,28 +70,6 @@ export function resetCatalogCacheForTests(): void {
72
70
  inflightCatalogFetch = null;
73
71
  }
74
72
 
75
- /**
76
- * Resolve the configured tier to its effective value. The single place in
77
- * the platform where UNSPECIFIED becomes STANDARD.
78
- */
79
- export function resolveEffectiveServiceTier(
80
- configured: ServiceTier | undefined,
81
- ): EffectiveServiceTier {
82
- return configured === ServiceTier.FAST ? ServiceTier.FAST : ServiceTier.STANDARD;
83
- }
84
-
85
- /** Human-readable tier label for logs and error messages. */
86
- export function serviceTierLabel(tier: ServiceTier): string {
87
- switch (tier) {
88
- case ServiceTier.FAST:
89
- return "fast";
90
- case ServiceTier.STANDARD:
91
- return "standard";
92
- default:
93
- return "unspecified";
94
- }
95
- }
96
-
97
73
  async function listCatalogModels(apiKey: string): Promise<readonly ModelListItem[]> {
98
74
  const now = Date.now();
99
75
  if (catalogCache && catalogCache.apiKey === apiKey && catalogCache.expiresAt > now) {
@@ -232,7 +232,7 @@ async function writeSkillMount(
232
232
  for (const entry of entries) {
233
233
  const filePath = join(skillDir, entry.path);
234
234
  await mkdir(dirname(filePath), { recursive: true });
235
- await writeFile(filePath, entry.content, "utf-8");
235
+ await writeFile(filePath, entry.content);
236
236
  }
237
237
  }
238
238
 
@@ -72,6 +72,7 @@ import { getRunnerHitlMasterSecret } from "../../shared/fingerprint-secret.js";
72
72
  import { getModelPricing, ensureLoaded as ensurePricingLoaded } from "../../shared/model-pricing.js";
73
73
  import { getDefaultModel, getModelVisionCapability } from "../../shared/model-registry.js";
74
74
  import { buildChatModel } from "../../shared/model-client.js";
75
+ import { resolveEffectiveServiceTier } from "../../shared/service-tier.js";
75
76
  import {
76
77
  loadArtifactStorageConfig,
77
78
  resolveUsableArtifactStorage,
@@ -580,11 +581,22 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
580
581
  // The operator's STIGMER_LLM_REQUEST_TIMEOUT_MS bound is applied inside
581
582
  // buildChatModel (#468) — the sub-agent modelFactory below silently
582
583
  // dropped it when each caller parsed the env itself.
584
+ //
585
+ // The service tier resolves ONCE here (UNSPECIFIED → explicit
586
+ // STANDARD, shared/service-tier.ts) and rides every model this
587
+ // execution constructs — the primary below AND the sub-agent factory —
588
+ // so the provider account's default can never pick the price of any
589
+ // turn (#361, the native half of #357's contract). Sub-agents inherit
590
+ // it because the tier is an attribute of the EXECUTION's bill, and
591
+ // sub-agent calls land on the same ledger.
592
+ const serviceTier = resolveEffectiveServiceTier(
593
+ execution.spec!.executionConfig?.serviceTier);
583
594
  const { model } = await buildChatModel({
584
595
  modelName,
585
596
  proxyEndpoint: config.proxyEndpoint ?? undefined,
586
597
  stigmerToken: config.stigmerToken ?? undefined,
587
598
  headerScope: { executionId },
599
+ serviceTier,
588
600
  });
589
601
  timing.mark("build_model");
590
602
 
@@ -776,6 +788,9 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
776
788
  proxyEndpoint: config.proxyEndpoint ?? undefined,
777
789
  stigmerToken: config.stigmerToken ?? undefined,
778
790
  headerScope: { executionId },
791
+ // The execution's tier, inherited: sub-agent calls bill to the
792
+ // same execution (#361 — see the Step 9 resolution comment).
793
+ serviceTier,
779
794
  })).model,
780
795
  // Presence of shellEnv is the shell-capability switch for sub-agent
781
796
  // backends too (undefined in plan mode; see buildShellEnv above).
@@ -1,19 +1,22 @@
1
1
  /**
2
2
  * Shell environment for the native harness `execute` tool.
3
3
  *
4
- * Per-execution snapshot: runner-manager rotates `STIGMER_TOKEN` in
5
- * `process.env` at runtime, so this must run inside setup for each execution,
6
- * never once at process start.
4
+ * Since #508's boot capture, runner secrets never LIVE in `process.env`, so
5
+ * the denylist below is defense-in-depth: it keeps this surface safe even if
6
+ * something re-plants a secret in the environment after boot (a test, an
7
+ * embedder, a future regression). Still built per execution — the snapshot
8
+ * must reflect the env as it is now, not at process start.
7
9
  */
8
10
 
9
- import { RUNNER_CREDENTIAL_ENV_KEYS } from "../../shared/runner-credential-keys.js";
11
+ import { RUNNER_SECRET_ENV_KEYS } from "../../shared/runner-credential-keys.js";
10
12
 
11
13
  /**
12
14
  * Runner-internal keys that must never reach agent shell commands: every
13
- * credential the runner holds for its own outbound calls (issue #385). The
14
- * names and the rule for adding onelive in runner-credential-keys.ts.
15
+ * credential the runner holds for its own outbound calls (issue #385) plus
16
+ * the runner's encryption keys (issue #508). The namesand the rules for
17
+ * adding one — live in runner-credential-keys.ts.
15
18
  */
16
- export const SHELL_ENV_DENYLIST: readonly string[] = RUNNER_CREDENTIAL_ENV_KEYS;
19
+ export const SHELL_ENV_DENYLIST: readonly string[] = RUNNER_SECRET_ENV_KEYS;
17
20
 
18
21
  /**
19
22
  * Build the environment map passed to deepagents' LocalShellBackend.
package/src/config.ts CHANGED
@@ -53,6 +53,7 @@ export const DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS = 120_000;
53
53
  // three construction sites — mirrors DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS).
54
54
  export { DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./shared/workspace/workspace-lock.js";
55
55
  import { DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./shared/workspace/workspace-lock.js";
56
+ import { getRunnerSecret } from "./shared/runner-credential-store.js";
56
57
 
57
58
  export interface Config {
58
59
  readonly taskQueue: string;
@@ -158,9 +159,13 @@ export function loadConfig(): Config {
158
159
  : requireEnv("STIGMER_BACKEND_ENDPOINT"),
159
160
  );
160
161
 
161
- const stigmerToken = (mode === "cloud" || proxyActive)
162
- ? requireEnv("STIGMER_TOKEN")
163
- : (process.env.STIGMER_TOKEN ?? null);
162
+ // Secrets resolve through the credential store, not process.env — the
163
+ // boot capture has already moved them out of the environment (#508).
164
+ const stigmerTokenValue = getRunnerSecret("STIGMER_TOKEN");
165
+ if ((mode === "cloud" || proxyActive) && !stigmerTokenValue) {
166
+ throw new Error("Required environment variable STIGMER_TOKEN is not set");
167
+ }
168
+ const stigmerToken = stigmerTokenValue ?? null;
164
169
 
165
170
  const mcpBridgeEndpoint = process.env.STIGMER_MCP_BRIDGE_ENDPOINT ?? null;
166
171
 
@@ -170,8 +175,8 @@ export function loadConfig(): Config {
170
175
  // the SDK's authorization header (Cursor access token) passes through to
171
176
  // api2.cursor.sh unchanged.
172
177
  const cursorApiKey = proxyActive
173
- ? (process.env.CURSOR_API_KEY ?? stigmerToken ?? "proxy-managed")
174
- : (process.env.CURSOR_API_KEY ?? "");
178
+ ? (getRunnerSecret("CURSOR_API_KEY") ?? stigmerToken ?? "proxy-managed")
179
+ : (getRunnerSecret("CURSOR_API_KEY") ?? "");
175
180
 
176
181
  const workspaceRootDir = resolveWorkspaceRootDir();
177
182
 
@@ -25,6 +25,8 @@
25
25
  * on the next runner boot — there is no live re-key.
26
26
  */
27
27
 
28
+ import { getRunnerSecret } from "../shared/runner-credential-store.js";
29
+
28
30
  export interface EncryptionKey {
29
31
  readonly keyId: string;
30
32
  /** 32-byte AES-256 key. */
@@ -75,14 +77,18 @@ const AES_256_KEY_BYTES = 32;
75
77
  export function loadPayloadEncryptionConfig(
76
78
  bootstrap?: BootstrapKeyMaterial,
77
79
  ): PayloadEncryptionConfig | undefined {
78
- const rawKey = process.env[KEY_ENV];
80
+ // Key VALUES resolve through the credential store (the #508 boot capture
81
+ // moves them out of process.env — agent shells must not read them); the
82
+ // *_KEY_ID companions are rotation bookkeeping, not secrets, and stay
83
+ // plain env reads.
84
+ const rawKey = getRunnerSecret(KEY_ENV);
79
85
  if (rawKey) {
80
86
  const primary: EncryptionKey = {
81
87
  keyId: requireKeyId(KEY_ID_ENV),
82
88
  key: parseKey(rawKey, KEY_ENV),
83
89
  };
84
90
 
85
- const rawSecondary = process.env[SECONDARY_KEY_ENV];
91
+ const rawSecondary = getRunnerSecret(SECONDARY_KEY_ENV);
86
92
  const secondary: EncryptionKey | undefined = rawSecondary
87
93
  ? {
88
94
  keyId: requireKeyId(SECONDARY_KEY_ID_ENV),
package/src/main.ts CHANGED
@@ -32,6 +32,10 @@ import { createInterface } from "node:readline";
32
32
  import { preflightNodeRuntime } from "./preflight.js";
33
33
  import { markBoot, emitRunnerBootTiming } from "./shared/cold-start-timing.js";
34
34
  import { loadConfig } from "./config.js";
35
+ import {
36
+ captureRunnerSecrets,
37
+ getRunnerSecret,
38
+ } from "./shared/runner-credential-store.js";
35
39
  import { initTracing, initMetrics } from "./otel.js";
36
40
  import { createStigmerRunner } from "./runner.js";
37
41
  import { createStigmerRunnerManager } from "./runner-manager.js";
@@ -214,11 +218,12 @@ async function runPoolMode(
214
218
  });
215
219
 
216
220
  // Sandbox credential self-renewal (see sandbox-token-renewal.ts). Watches
217
- // the env var because manager.updateToken keeps it in lockstep with the
218
- // manager's internal ref: a blank member's pool_sandbox token parks the
219
- // loop, the claim's updateToken swaps in a renewable session token, and
220
- // from then on renewal applies fresh tokens back through the same
221
- // updateToken (which also cascades to the proxy-credential coordinator).
221
+ // the credential store because manager.updateToken keeps it in lockstep
222
+ // with the manager's internal ref: a blank member's pool_sandbox token
223
+ // parks the loop, the claim's updateToken swaps in a renewable session
224
+ // token, and from then on renewal applies fresh tokens back through the
225
+ // same updateToken (which also cascades to the proxy-credential
226
+ // coordinator).
222
227
  const { startSandboxTokenRenewal } = await import("./sandbox-token-renewal.js");
223
228
  const { StigmerClient } = await import("./client/stigmer-client.js");
224
229
  const renewalClient = new StigmerClient({
@@ -226,7 +231,7 @@ async function runPoolMode(
226
231
  token: null,
227
232
  });
228
233
  const tokenRenewal = startSandboxTokenRenewal({
229
- getToken: () => process.env.STIGMER_TOKEN ?? null,
234
+ getToken: () => getRunnerSecret("STIGMER_TOKEN") ?? null,
230
235
  renew: (currentToken) =>
231
236
  renewalClient.getRunnerScopedToken({ renewal: true }, currentToken),
232
237
  applyToken: (token) => manager.updateToken(token),
@@ -449,6 +454,11 @@ async function main(): Promise<void> {
449
454
 
450
455
  checkBuildFreshness();
451
456
 
457
+ // Take custody of runner secrets before the config load reads them (#508).
458
+ // The factories capture too (they are the library boot doors); doing it
459
+ // here as well keeps main.ts's own late readers store-only from the start.
460
+ captureRunnerSecrets();
461
+
452
462
  const config = loadConfig();
453
463
  markBoot("config_loaded");
454
464
 
@@ -15,6 +15,7 @@
15
15
  import type { PayloadCodec } from "@temporalio/common";
16
16
  import type { Config } from "./config.js";
17
17
  import type { BootstrapKeyMaterial } from "./encryption/config.js";
18
+ import { getRunnerSecret } from "./shared/runner-credential-store.js";
18
19
 
19
20
  export async function createPayloadCodecs(
20
21
  config: Config,
@@ -30,7 +31,7 @@ export async function createPayloadCodecs(
30
31
  const encryptionConfig = loadPayloadEncryptionConfig(bootstrapKeys);
31
32
  if (encryptionConfig) {
32
33
  codecs.push(new EncryptionPayloadCodec(encryptionConfig));
33
- const source = process.env.STIGMER_PAYLOAD_ENCRYPTION_KEY ? "env" : "bootstrap";
34
+ const source = getRunnerSecret("STIGMER_PAYLOAD_ENCRYPTION_KEY") ? "env" : "bootstrap";
34
35
  console.log(
35
36
  `[runner] Payload encryption enabled (source=${source}, ` +
36
37
  `key_id=${encryptionConfig.primary.keyId}` +