@stigmer/runner 3.12.2 → 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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/call-http.js +12 -0
- package/dist/activities/call-http.js.map +1 -1
- package/dist/activities/call-llm.d.ts +18 -0
- package/dist/activities/call-llm.js +56 -2
- package/dist/activities/call-llm.js.map +1 -1
- package/dist/activities/execute-cursor/agent-session-cache.d.ts +72 -0
- package/dist/activities/execute-cursor/agent-session-cache.js +186 -0
- package/dist/activities/execute-cursor/agent-session-cache.js.map +1 -0
- package/dist/activities/execute-cursor/index.js +61 -28
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/service-tier.d.ts +5 -15
- package/dist/activities/execute-cursor/service-tier.js +5 -21
- package/dist/activities/execute-cursor/service-tier.js.map +1 -1
- package/dist/activities/execute-cursor/skill-resolver.d.ts +15 -0
- package/dist/activities/execute-cursor/skill-resolver.js +45 -7
- package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.d.ts +9 -1
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js +13 -2
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +14 -0
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/shell-env.d.ts +8 -5
- package/dist/activities/execute-deep-agent/shell-env.js +10 -7
- package/dist/activities/execute-deep-agent/shell-env.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +5 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.js +9 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +9 -1
- package/dist/client/stigmer-client.js +10 -0
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/config.js +10 -5
- package/dist/config.js.map +1 -1
- package/dist/encryption/config.js +7 -2
- package/dist/encryption/config.js.map +1 -1
- package/dist/main.js +12 -6
- package/dist/main.js.map +1 -1
- package/dist/middleware/index.d.ts +6 -5
- package/dist/middleware/index.js +8 -5
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/tool-intent.d.ts +57 -0
- package/dist/middleware/tool-intent.js +152 -0
- package/dist/middleware/tool-intent.js.map +1 -0
- package/dist/payload-codecs.js +2 -1
- package/dist/payload-codecs.js.map +1 -1
- package/dist/runner-manager.js +20 -7
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +19 -6
- package/dist/runner.js.map +1 -1
- package/dist/shared/fingerprint-secret.d.ts +3 -2
- package/dist/shared/fingerprint-secret.js +5 -3
- package/dist/shared/fingerprint-secret.js.map +1 -1
- package/dist/shared/llm-backend.js +8 -1
- package/dist/shared/llm-backend.js.map +1 -1
- package/dist/shared/model-client.d.ts +15 -0
- package/dist/shared/model-client.js +57 -13
- package/dist/shared/model-client.js.map +1 -1
- package/dist/shared/registry-endpoint.d.ts +5 -0
- package/dist/shared/registry-endpoint.js +7 -1
- package/dist/shared/registry-endpoint.js.map +1 -1
- package/dist/shared/runner-credential-keys.d.ts +26 -1
- package/dist/shared/runner-credential-keys.js +34 -1
- package/dist/shared/runner-credential-keys.js.map +1 -1
- package/dist/shared/runner-credential-store.d.ts +77 -0
- package/dist/shared/runner-credential-store.js +111 -0
- package/dist/shared/runner-credential-store.js.map +1 -0
- package/dist/shared/service-tier.d.ts +55 -0
- package/dist/shared/service-tier.js +67 -0
- package/dist/shared/service-tier.js.map +1 -0
- package/dist/shared/skill-writer.js +2 -2
- package/dist/shared/skill-writer.js.map +1 -1
- package/dist/shared/zip-extract.d.ts +10 -3
- package/dist/shared/zip-extract.js +10 -3
- package/dist/shared/zip-extract.js.map +1 -1
- package/dist/workflow-engine/tasks/call-function.d.ts +14 -0
- package/dist/workflow-engine/tasks/call-function.js +49 -5
- package/dist/workflow-engine/tasks/call-function.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +6 -0
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/engine-core.js +36 -8
- package/dist/workflows/engine-core.js.map +1 -1
- package/package.json +2 -2
- package/src/activities/__tests__/call-http.test.ts +36 -0
- package/src/activities/__tests__/call-llm.test.ts +77 -0
- package/src/activities/call-http.ts +17 -0
- package/src/activities/call-llm.ts +78 -2
- package/src/activities/execute-cursor/__tests__/agent-session-cache.test.ts +220 -0
- package/src/activities/execute-cursor/__tests__/service-tier.test.ts +1 -1
- package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +104 -1
- package/src/activities/execute-cursor/agent-session-cache.ts +229 -0
- package/src/activities/execute-cursor/index.ts +66 -20
- package/src/activities/execute-cursor/service-tier.ts +5 -29
- package/src/activities/execute-cursor/skill-resolver.ts +52 -8
- package/src/activities/execute-deep-agent/__test-utils__/scripted-model.ts +13 -2
- package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +21 -20
- package/src/activities/execute-deep-agent/setup.ts +15 -0
- package/src/activities/execute-deep-agent/shell-env.ts +10 -7
- package/src/activities/execute-deep-agent/subagent-wiring.ts +10 -1
- package/src/client/stigmer-client.ts +12 -1
- package/src/config.ts +10 -5
- package/src/encryption/config.ts +8 -2
- package/src/main.ts +16 -6
- package/src/middleware/__tests__/tool-intent.test.ts +266 -0
- package/src/middleware/index.ts +9 -5
- package/src/middleware/tool-intent.ts +174 -0
- package/src/payload-codecs.ts +2 -1
- package/src/runner-manager.ts +29 -6
- package/src/runner.ts +25 -6
- package/src/shared/__tests__/model-client.test.ts +99 -0
- package/src/shared/__tests__/runner-credential-store.test.ts +155 -0
- package/src/shared/__tests__/zip-extract.test.ts +46 -11
- package/src/shared/fingerprint-secret.ts +5 -3
- package/src/shared/llm-backend.ts +7 -1
- package/src/shared/model-client.ts +76 -13
- package/src/shared/registry-endpoint.ts +9 -1
- package/src/shared/runner-credential-keys.ts +36 -1
- package/src/shared/runner-credential-store.ts +115 -0
- package/src/shared/service-tier.ts +78 -0
- package/src/shared/skill-writer.ts +2 -2
- package/src/shared/zip-extract.ts +14 -7
- package/src/workflow-engine/__tests__/golden-execution.test.ts +20 -1
- package/src/workflow-engine/__tests__/tasks/call-function.test.ts +105 -0
- package/src/workflow-engine/tasks/call-function.ts +74 -13
- package/src/workflow-engine/types.ts +6 -0
- package/src/workflows/engine-core.ts +39 -8
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-intent middleware — model-authored intent titles for shell tool calls
|
|
3
|
+
* (issue #276).
|
|
4
|
+
*
|
|
5
|
+
* The thread UI titles every shell row with the bare category label ("Shell");
|
|
6
|
+
* only the model knows *why* it is running a command, so a host-side rename
|
|
7
|
+
* can never close that gap. This middleware lets the model author the title:
|
|
8
|
+
* at model-call time it presents a schema-extended clone of the shell tool
|
|
9
|
+
* that adds one optional `description` argument. The model fills it in, the
|
|
10
|
+
* argument rides the tool call's args verbatim through checkpoints and the
|
|
11
|
+
* persisted ToolCall proto (no new state, no proto change), and the React SDK
|
|
12
|
+
* renders it as the row title with the command as secondary text.
|
|
13
|
+
*
|
|
14
|
+
* Why this seam:
|
|
15
|
+
* - The `execute` tool is owned by the deepagents library; its schema is not
|
|
16
|
+
* ours to edit. `wrapModelCall` is the framework's intended point for
|
|
17
|
+
* reshaping the model-visible tool list — langchain's own llmToolSelector
|
|
18
|
+
* middleware swaps `request.tools` through exactly this hook.
|
|
19
|
+
* - Execution is untouched by construction: the agent's ToolNode is built
|
|
20
|
+
* once from the ORIGINAL tools, and the original schema parses with strip
|
|
21
|
+
* semantics, so the extra argument is dropped before the backend's
|
|
22
|
+
* `execute(command)` ever runs. Approval fingerprints are equally
|
|
23
|
+
* unaffected (`description` is not a salient arg field).
|
|
24
|
+
* - The argument name deliberately matches the Cursor harness, whose built-in
|
|
25
|
+
* Shell tool already carries a model-authored `description` — both
|
|
26
|
+
* harnesses converge on one wire key and the SDK reads a single field.
|
|
27
|
+
*
|
|
28
|
+
* The swapped-in declaration is a `StructuredToolParams` object — langchain's
|
|
29
|
+
* first-class shape for a non-executable, bind-time-only tool definition
|
|
30
|
+
* ("the most minimal interface … to be passed to a LLM for tool calling").
|
|
31
|
+
* A same-name RUNNABLE replacement is rejected by the agent's wrapModelCall
|
|
32
|
+
* validation (it would threaten ToolNode execution identity); a params
|
|
33
|
+
* object is exactly the declaration-without-execution the validation exists
|
|
34
|
+
* to protect, and the graph keeps executing the untouched original. Schema
|
|
35
|
+
* extension happens at the JSON-schema level via @langchain/core's interop
|
|
36
|
+
* serializer — the runner's zod (v3) must never construct fields inside the
|
|
37
|
+
* library's zod (v4) schema object.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { toJsonSchema } from "@langchain/core/utils/json_schema";
|
|
41
|
+
import { ToolKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
42
|
+
import { classifyTool } from "../shared/tool-kind.js";
|
|
43
|
+
import type { StigmerMiddleware } from "./types.js";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The wire name of the intent argument. Shared with the Cursor harness's
|
|
47
|
+
* built-in Shell tool and read by the SDK's tool presentation layer — the
|
|
48
|
+
* three surfaces must agree on this key.
|
|
49
|
+
*/
|
|
50
|
+
export const INTENT_ARG = "description";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The behavior-shaping prompt for the intent argument (owner-approved
|
|
54
|
+
* wording, issue #276). This is prompt engineering, not documentation:
|
|
55
|
+
* changing it changes what the model writes into every shell row title.
|
|
56
|
+
*/
|
|
57
|
+
export const INTENT_ARG_PROMPT =
|
|
58
|
+
"A short present-tense phrase describing what this command does and why, " +
|
|
59
|
+
"shown to the user as the title of this action (5-10 words, e.g. " +
|
|
60
|
+
"'Run unit tests for the parser'). Do not restate the command syntax.";
|
|
61
|
+
|
|
62
|
+
/** Structural shape of a bindable structured tool, checked at runtime. */
|
|
63
|
+
interface StructuredToolLike {
|
|
64
|
+
readonly name: string;
|
|
65
|
+
readonly description: string;
|
|
66
|
+
readonly schema: unknown;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isStructuredToolLike(candidate: unknown): candidate is StructuredToolLike {
|
|
70
|
+
if (candidate == null || typeof candidate !== "object") return false;
|
|
71
|
+
const t = candidate as Record<string, unknown>;
|
|
72
|
+
return (
|
|
73
|
+
typeof t.name === "string" &&
|
|
74
|
+
typeof t.description === "string" &&
|
|
75
|
+
"schema" in t
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface JsonObjectSchema {
|
|
80
|
+
readonly type: "object";
|
|
81
|
+
readonly properties?: Record<string, unknown>;
|
|
82
|
+
readonly [key: string]: unknown;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isJsonObjectSchema(schema: unknown): schema is JsonObjectSchema {
|
|
86
|
+
return (
|
|
87
|
+
schema != null &&
|
|
88
|
+
typeof schema === "object" &&
|
|
89
|
+
(schema as Record<string, unknown>).type === "object"
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns a bind-time `StructuredToolParams` declaration for `original`
|
|
95
|
+
* whose schema carries the optional intent argument, or `original` itself
|
|
96
|
+
* when the tool is not a shell tool, is not schema-extendable, or already
|
|
97
|
+
* defines an argument with that name (a real argument must never be
|
|
98
|
+
* shadowed by presentation metadata).
|
|
99
|
+
*
|
|
100
|
+
* Unexpected schemas pass through unchanged on purpose: a missing intent
|
|
101
|
+
* title degrades to today's rendering, while a mangled schema would break
|
|
102
|
+
* the tool for the whole execution.
|
|
103
|
+
*/
|
|
104
|
+
function maybeExtendShellTool(original: unknown): unknown {
|
|
105
|
+
if (!isStructuredToolLike(original)) return original;
|
|
106
|
+
if (classifyTool(original.name) !== ToolKind.SHELL) return original;
|
|
107
|
+
|
|
108
|
+
let jsonSchema: unknown;
|
|
109
|
+
try {
|
|
110
|
+
jsonSchema = toJsonSchema(original.schema as Parameters<typeof toJsonSchema>[0]);
|
|
111
|
+
} catch {
|
|
112
|
+
return original;
|
|
113
|
+
}
|
|
114
|
+
if (!isJsonObjectSchema(jsonSchema)) return original;
|
|
115
|
+
|
|
116
|
+
const properties = jsonSchema.properties ?? {};
|
|
117
|
+
if (INTENT_ARG in properties) return original;
|
|
118
|
+
|
|
119
|
+
// A plain frozen declaration, deliberately NOT an executable tool: the
|
|
120
|
+
// graph's ToolNode executes the ORIGINAL registered tool (it is built from
|
|
121
|
+
// the registered tools, not from the model request), and the agent's
|
|
122
|
+
// wrapModelCall validation only forbids swapping same-name EXECUTABLE
|
|
123
|
+
// instances. `isStructuredToolParams` recognizes this shape, so every
|
|
124
|
+
// provider's bindTools converts it exactly like a structured tool.
|
|
125
|
+
return Object.freeze({
|
|
126
|
+
name: original.name,
|
|
127
|
+
description: original.description,
|
|
128
|
+
schema: {
|
|
129
|
+
...jsonSchema,
|
|
130
|
+
properties: {
|
|
131
|
+
...properties,
|
|
132
|
+
[INTENT_ARG]: { type: "string", description: INTENT_ARG_PROMPT },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Creates the middleware. Install on the parent stack AND on every sub-agent
|
|
140
|
+
* stack (subagent-wiring.ts) — sub-agent shell rows render in the same
|
|
141
|
+
* thread and must carry the same titles.
|
|
142
|
+
*/
|
|
143
|
+
export function createToolIntentMiddleware(): StigmerMiddleware {
|
|
144
|
+
// One clone per original tool instance: repeated model calls (and repeated
|
|
145
|
+
// turns on the same graph) bind a referentially stable clone instead of
|
|
146
|
+
// re-serializing the schema every round.
|
|
147
|
+
const cloneCache = new WeakMap<object, unknown>();
|
|
148
|
+
|
|
149
|
+
const extendCached = (candidate: unknown): unknown => {
|
|
150
|
+
if (candidate == null || typeof candidate !== "object") return candidate;
|
|
151
|
+
const cached = cloneCache.get(candidate);
|
|
152
|
+
if (cached !== undefined) return cached;
|
|
153
|
+
const extended = maybeExtendShellTool(candidate);
|
|
154
|
+
cloneCache.set(candidate, extended);
|
|
155
|
+
return extended;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
name: "StigmerToolIntentMiddleware",
|
|
160
|
+
async wrapModelCall(request, handler) {
|
|
161
|
+
const tools = request.tools;
|
|
162
|
+
if (!tools || tools.length === 0) return handler(request);
|
|
163
|
+
|
|
164
|
+
let changed = false;
|
|
165
|
+
const mapped = tools.map((t) => {
|
|
166
|
+
const extended = extendCached(t);
|
|
167
|
+
if (extended !== t) changed = true;
|
|
168
|
+
return extended;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return handler(changed ? { ...request, tools: mapped } : request);
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
package/src/payload-codecs.ts
CHANGED
|
@@ -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 =
|
|
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}` +
|
package/src/runner-manager.ts
CHANGED
|
@@ -32,6 +32,11 @@ import type { WorkerActivities } from "./worker.js";
|
|
|
32
32
|
import { resolveWorkflowSource, OTEL_WORKFLOW_INTERCEPTOR_MODULE } from "./workflow-source.js";
|
|
33
33
|
import { resolveRunnerBootstrap, refreshRunnerAccessToken } from "./bootstrap.js";
|
|
34
34
|
import { assertLlmBackendsPreflight } from "./preflight.js";
|
|
35
|
+
import {
|
|
36
|
+
captureRunnerSecrets,
|
|
37
|
+
getRunnerSecret,
|
|
38
|
+
setRunnerSecret,
|
|
39
|
+
} from "./shared/runner-credential-store.js";
|
|
35
40
|
import { createRunnerTokenCoordinator } from "./runner-token-coordinator.js";
|
|
36
41
|
// Per-task-queue in-flight activity tracking lives in ./in-flight.ts so the
|
|
37
42
|
// activity interceptor (no manager-closure handle) and unit tests can reach it.
|
|
@@ -214,6 +219,12 @@ export async function createStigmerRunnerManager(
|
|
|
214
219
|
): Promise<StigmerRunnerManager> {
|
|
215
220
|
validateManagerOptions(options);
|
|
216
221
|
|
|
222
|
+
// Take custody of runner secrets BEFORE anything else can read them from
|
|
223
|
+
// env — and before any agent code could spawn with them (#508). Runs here
|
|
224
|
+
// (not only in main.ts) because this factory is a public library boot door:
|
|
225
|
+
// in-process embedders like the desktop never execute main.ts.
|
|
226
|
+
captureRunnerSecrets();
|
|
227
|
+
|
|
217
228
|
const { registerStigmerDeepagentsProfiles } = await import(
|
|
218
229
|
"./activities/execute-deep-agent/deepagents-profiles.js"
|
|
219
230
|
);
|
|
@@ -365,7 +376,7 @@ export async function createStigmerRunnerManager(
|
|
|
365
376
|
if (
|
|
366
377
|
!bootstrap.payloadEncryption &&
|
|
367
378
|
bootstrap.runnerAccessToken &&
|
|
368
|
-
!
|
|
379
|
+
!getRunnerSecret("STIGMER_PAYLOAD_ENCRYPTION_KEY")
|
|
369
380
|
) {
|
|
370
381
|
console.warn(
|
|
371
382
|
"[runner-manager] Server minted a runner token but returned no payload " +
|
|
@@ -530,6 +541,13 @@ export async function createStigmerRunnerManager(
|
|
|
530
541
|
await removeManaged(
|
|
531
542
|
sessions, sessionId, SESSION_QUEUE_PREFIX + sessionId, "session",
|
|
532
543
|
);
|
|
544
|
+
// The session is done on this host — release its parked agent (and
|
|
545
|
+
// the executor + MCP subprocesses the lease pins) immediately rather
|
|
546
|
+
// than waiting out the idle TTL (#215).
|
|
547
|
+
const { evictSessionAgent } = await import(
|
|
548
|
+
"./activities/execute-cursor/agent-session-cache.js"
|
|
549
|
+
);
|
|
550
|
+
evictSessionAgent(sessionId);
|
|
533
551
|
},
|
|
534
552
|
|
|
535
553
|
activeSessions(): string[] {
|
|
@@ -585,12 +603,11 @@ export async function createStigmerRunnerManager(
|
|
|
585
603
|
// proxy credential follows it: only when no token has been minted (so the
|
|
586
604
|
// pre-mint lockstep is preserved), never once the runner owns a minted
|
|
587
605
|
// token. See runner-token-coordinator.ts and the staleness changelogs.
|
|
606
|
+
// The credential store is the second leg of the pair (it replaced the
|
|
607
|
+
// process.env.STIGMER_TOKEN write, #508): per-call readers like
|
|
608
|
+
// call-llm and the registry headers resolve the current token there.
|
|
588
609
|
tokenRef.current = token;
|
|
589
|
-
|
|
590
|
-
process.env.STIGMER_TOKEN = token;
|
|
591
|
-
} else {
|
|
592
|
-
delete process.env.STIGMER_TOKEN;
|
|
593
|
-
}
|
|
610
|
+
setRunnerSecret("STIGMER_TOKEN", token);
|
|
594
611
|
tokenCoordinator.onControlPlaneTokenChanged(token);
|
|
595
612
|
console.log("[runner-manager] Auth token updated");
|
|
596
613
|
},
|
|
@@ -629,6 +646,12 @@ export async function createStigmerRunnerManager(
|
|
|
629
646
|
}
|
|
630
647
|
|
|
631
648
|
await Promise.all(shutdownPromises);
|
|
649
|
+
// Workers are drained — release every parked session agent so their
|
|
650
|
+
// executor leases dispose (stdio MCP subprocesses die with them) (#215).
|
|
651
|
+
const { closeAllCachedAgents } = await import(
|
|
652
|
+
"./activities/execute-cursor/agent-session-cache.js"
|
|
653
|
+
);
|
|
654
|
+
closeAllCachedAgents();
|
|
632
655
|
sessions.clear();
|
|
633
656
|
workflowExecutions.clear();
|
|
634
657
|
poolControl = null;
|
package/src/runner.ts
CHANGED
|
@@ -20,6 +20,10 @@ import { DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS, DEFAULT_CURSOR_STREAM_STALL_TI
|
|
|
20
20
|
import type { WorkerActivities } from "./worker.js";
|
|
21
21
|
import { resolveRunnerBootstrap } from "./bootstrap.js";
|
|
22
22
|
import { assertLlmBackendsPreflight } from "./preflight.js";
|
|
23
|
+
import {
|
|
24
|
+
captureRunnerSecrets,
|
|
25
|
+
setRunnerSecret,
|
|
26
|
+
} from "./shared/runner-credential-store.js";
|
|
23
27
|
import { markBoot, emitRunnerBootTiming } from "./shared/cold-start-timing.js";
|
|
24
28
|
|
|
25
29
|
/**
|
|
@@ -118,11 +122,12 @@ export interface StigmerRunner {
|
|
|
118
122
|
* Wire up self-renewal for a static cloud sandbox's control-plane credential
|
|
119
123
|
* (see sandbox-token-renewal.ts for the model). The applied token reaches
|
|
120
124
|
* every consumer: activity gRPC clients read {@code tokenRef} per request,
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* ref per call, and the two
|
|
124
|
-
* a static runner has no
|
|
125
|
-
* proxy credential, so its
|
|
125
|
+
* per-call sites (call-llm, registry-endpoint headers) resolve it through
|
|
126
|
+
* the runner credential store (which replaced the process.env.STIGMER_TOKEN
|
|
127
|
+
* channel, #508), artifact storage resolves the ref per call, and the two
|
|
128
|
+
* Cursor SDK interceptors are updated directly — a static runner has no
|
|
129
|
+
* {@code RunnerTokenCoordinator} minting a separate proxy credential, so its
|
|
130
|
+
* x-stigmer-auth IS this token.
|
|
126
131
|
*/
|
|
127
132
|
async function startStaticSandboxTokenRenewal(
|
|
128
133
|
config: Config,
|
|
@@ -157,7 +162,9 @@ async function startStaticSandboxTokenRenewal(
|
|
|
157
162
|
client.getRunnerScopedToken({ renewal: true }, currentToken),
|
|
158
163
|
applyToken: (token) => {
|
|
159
164
|
tokenRef.current = token;
|
|
160
|
-
process.env.STIGMER_TOKEN
|
|
165
|
+
// Store write replaced the process.env.STIGMER_TOKEN write (#508) —
|
|
166
|
+
// same per-call readers (call-llm, registry headers), no env exposure.
|
|
167
|
+
setRunnerSecret("STIGMER_TOKEN", token);
|
|
161
168
|
updateInterceptorToken(token);
|
|
162
169
|
updateHttp2InterceptorToken(token);
|
|
163
170
|
},
|
|
@@ -190,6 +197,12 @@ export async function createStigmerRunner(
|
|
|
190
197
|
): Promise<StigmerRunner> {
|
|
191
198
|
validateOptions(options);
|
|
192
199
|
|
|
200
|
+
// Take custody of runner secrets BEFORE anything else can read them from
|
|
201
|
+
// env — and before any agent code could spawn with them (#508). Runs here
|
|
202
|
+
// (not only in main.ts) because this factory is a public library boot door
|
|
203
|
+
// for in-process embedders.
|
|
204
|
+
captureRunnerSecrets();
|
|
205
|
+
|
|
193
206
|
const { registerStigmerDeepagentsProfiles } = await import(
|
|
194
207
|
"./activities/execute-deep-agent/deepagents-profiles.js"
|
|
195
208
|
);
|
|
@@ -327,6 +340,12 @@ export async function createStigmerRunner(
|
|
|
327
340
|
emitRunnerBootTiming({ task_queue: config.taskQueue, mode: config.mode });
|
|
328
341
|
await worker.run();
|
|
329
342
|
console.log("Worker stopped");
|
|
343
|
+
// The worker has drained — release any parked session agent so its
|
|
344
|
+
// executor lease disposes with the process (#215).
|
|
345
|
+
const { closeAllCachedAgents } = await import(
|
|
346
|
+
"./activities/execute-cursor/agent-session-cache.js"
|
|
347
|
+
);
|
|
348
|
+
closeAllCachedAgents();
|
|
330
349
|
},
|
|
331
350
|
shutdown() {
|
|
332
351
|
tokenRenewal?.stop();
|
|
@@ -23,6 +23,7 @@ vi.mock("@langchain/openai", () => ({
|
|
|
23
23
|
|
|
24
24
|
import { buildChatModel } from "../model-client.js";
|
|
25
25
|
import { _resetRegistryCache } from "../model-registry.js";
|
|
26
|
+
import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
26
27
|
|
|
27
28
|
interface MockModel {
|
|
28
29
|
id: string;
|
|
@@ -306,6 +307,18 @@ describe("buildChatModel", () => {
|
|
|
306
307
|
expect(lastAnthropicArgs().apiKey).toBe("");
|
|
307
308
|
});
|
|
308
309
|
|
|
310
|
+
it("does NOT send service_tier on a backend adapter — tiers are Anthropic-first-party billing, and Vertex/Bedrock/Foundry have no tier dimension", async () => {
|
|
311
|
+
mockRegistryResponse([
|
|
312
|
+
{ id: "claude-sonnet-4.6", apiModelId: "claude-sonnet-4-6", provider: "anthropic" },
|
|
313
|
+
]);
|
|
314
|
+
|
|
315
|
+
await buildChatModel({ modelName: "claude-sonnet-4.6", serviceTier: ServiceTier.STANDARD });
|
|
316
|
+
|
|
317
|
+
const args = lastAnthropicArgs();
|
|
318
|
+
expect(typeof args.createClient).toBe("function");
|
|
319
|
+
expect(args).not.toHaveProperty("invocationKwargs");
|
|
320
|
+
});
|
|
321
|
+
|
|
309
322
|
it("yields to the proxy: a proxied call never consults the backend var", async () => {
|
|
310
323
|
mockRegistryResponse([
|
|
311
324
|
{ id: "claude-sonnet-4.6", apiModelId: "claude-sonnet-4-6", provider: "anthropic" },
|
|
@@ -359,4 +372,90 @@ describe("buildChatModel", () => {
|
|
|
359
372
|
await expect(buildChatModel({ modelName: "claude-sonnet-4.6" }))
|
|
360
373
|
.rejects.toThrow(/STIGMER_ANTHROPIC_BACKEND="verteks" is not a supported backend/);
|
|
361
374
|
});
|
|
375
|
+
|
|
376
|
+
describe("service tier (stigmer/stigmer#361)", () => {
|
|
377
|
+
// The #357 contract on the native harness: when the caller passes the
|
|
378
|
+
// execution's effective tier, every provider request pins it
|
|
379
|
+
// explicitly, so the provider ACCOUNT's default can never pick the
|
|
380
|
+
// price. Each provider has its own spelling; both are pinned here so
|
|
381
|
+
// a wrapper upgrade that moves the slot fails loudly.
|
|
382
|
+
|
|
383
|
+
it("OpenAI: STANDARD pins service_tier 'default' — never 'auto', which would re-open the account-default hole", async () => {
|
|
384
|
+
mockRegistryResponse([
|
|
385
|
+
{ id: "gpt-4.1", apiModelId: "gpt-4.1", provider: "openai" },
|
|
386
|
+
]);
|
|
387
|
+
|
|
388
|
+
await buildChatModel({ modelName: "gpt-4.1", serviceTier: ServiceTier.STANDARD });
|
|
389
|
+
|
|
390
|
+
const args = mockOpenAICtor.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
|
391
|
+
expect(args).toMatchObject({ service_tier: "default" });
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("OpenAI: FAST pins service_tier 'priority'", async () => {
|
|
395
|
+
mockRegistryResponse([
|
|
396
|
+
{ id: "gpt-4.1", apiModelId: "gpt-4.1", provider: "openai" },
|
|
397
|
+
]);
|
|
398
|
+
|
|
399
|
+
await buildChatModel({ modelName: "gpt-4.1", serviceTier: ServiceTier.FAST });
|
|
400
|
+
|
|
401
|
+
const args = mockOpenAICtor.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
|
402
|
+
expect(args).toMatchObject({ service_tier: "priority" });
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("Anthropic: STANDARD rides invocationKwargs as 'standard_only' — priority capacity is never consumed implicitly", async () => {
|
|
406
|
+
mockRegistryResponse([
|
|
407
|
+
{ id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic" },
|
|
408
|
+
]);
|
|
409
|
+
|
|
410
|
+
await buildChatModel({ modelName: "claude-haiku-4.5", serviceTier: ServiceTier.STANDARD });
|
|
411
|
+
|
|
412
|
+
expect(lastAnthropicArgs()).toMatchObject({
|
|
413
|
+
invocationKwargs: { service_tier: "standard_only" },
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("Anthropic: FAST rides invocationKwargs as 'auto' — use purchased priority capacity when available", async () => {
|
|
418
|
+
mockRegistryResponse([
|
|
419
|
+
{ id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic" },
|
|
420
|
+
]);
|
|
421
|
+
|
|
422
|
+
await buildChatModel({ modelName: "claude-haiku-4.5", serviceTier: ServiceTier.FAST });
|
|
423
|
+
|
|
424
|
+
expect(lastAnthropicArgs()).toMatchObject({
|
|
425
|
+
invocationKwargs: { service_tier: "auto" },
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("an omitted tier sends NO tier parameter on either provider — platform-internal utility calls stay untiered", async () => {
|
|
430
|
+
mockRegistryResponse([
|
|
431
|
+
{ id: "gpt-4.1", apiModelId: "gpt-4.1", provider: "openai" },
|
|
432
|
+
]);
|
|
433
|
+
await buildChatModel({ modelName: "gpt-4.1" });
|
|
434
|
+
const openAiArgs = mockOpenAICtor.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
|
435
|
+
expect(openAiArgs).not.toHaveProperty("service_tier");
|
|
436
|
+
|
|
437
|
+
mockRegistryResponse([
|
|
438
|
+
{ id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic" },
|
|
439
|
+
]);
|
|
440
|
+
await buildChatModel({ modelName: "claude-haiku-4.5" });
|
|
441
|
+
expect(lastAnthropicArgs()).not.toHaveProperty("invocationKwargs");
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("the tier rides the proxy path too — the request body parameter is what the proxy meters", async () => {
|
|
445
|
+
mockRegistryResponse([
|
|
446
|
+
{ id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic" },
|
|
447
|
+
]);
|
|
448
|
+
|
|
449
|
+
await buildChatModel({
|
|
450
|
+
modelName: "claude-haiku-4.5",
|
|
451
|
+
proxyEndpoint: "https://api.stigmer.ai",
|
|
452
|
+
stigmerToken: "token",
|
|
453
|
+
serviceTier: ServiceTier.STANDARD,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
expect(lastAnthropicArgs()).toMatchObject({
|
|
457
|
+
invocationKwargs: { service_tier: "standard_only" },
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
});
|
|
362
461
|
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
captureRunnerSecrets,
|
|
4
|
+
getRunnerSecret,
|
|
5
|
+
setRunnerSecret,
|
|
6
|
+
runnerSecretsEnvView,
|
|
7
|
+
resetRunnerSecretsForTests,
|
|
8
|
+
} from "../runner-credential-store.js";
|
|
9
|
+
import {
|
|
10
|
+
RUNNER_CREDENTIAL_ENV_KEYS,
|
|
11
|
+
RUNNER_ENCRYPTION_ENV_KEYS,
|
|
12
|
+
RUNNER_SECRET_ENV_KEYS,
|
|
13
|
+
} from "../runner-credential-keys.js";
|
|
14
|
+
|
|
15
|
+
/** Snapshot/restore of every env slot these tests touch. */
|
|
16
|
+
const savedEnv = new Map<string, string | undefined>();
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
resetRunnerSecretsForTests();
|
|
20
|
+
for (const key of RUNNER_SECRET_ENV_KEYS) {
|
|
21
|
+
savedEnv.set(key, process.env[key]);
|
|
22
|
+
delete process.env[key];
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
resetRunnerSecretsForTests();
|
|
28
|
+
for (const [key, value] of savedEnv) {
|
|
29
|
+
if (value === undefined) {
|
|
30
|
+
delete process.env[key];
|
|
31
|
+
} else {
|
|
32
|
+
process.env[key] = value;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
savedEnv.clear();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("captureRunnerSecrets", () => {
|
|
39
|
+
it("moves every listed secret out of process.env and into the store", () => {
|
|
40
|
+
// Plant a value for every name in the source-of-truth list, so a key
|
|
41
|
+
// added to runner-credential-keys.ts is covered here automatically —
|
|
42
|
+
// the #385 test idiom, applied to custody.
|
|
43
|
+
for (const key of RUNNER_SECRET_ENV_KEYS) {
|
|
44
|
+
process.env[key] = `boot-${key}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
captureRunnerSecrets();
|
|
48
|
+
|
|
49
|
+
for (const key of RUNNER_SECRET_ENV_KEYS) {
|
|
50
|
+
expect(
|
|
51
|
+
process.env[key],
|
|
52
|
+
`'${key}' must not remain in process.env after capture — anything ` +
|
|
53
|
+
`left there is readable by every agent shell command (issue #508)`,
|
|
54
|
+
).toBeUndefined();
|
|
55
|
+
expect(getRunnerSecret(key)).toBe(`boot-${key}`);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("is idempotent — a second capture cannot re-freeze rotated values", () => {
|
|
60
|
+
process.env.STIGMER_TOKEN = "boot-token";
|
|
61
|
+
captureRunnerSecrets();
|
|
62
|
+
setRunnerSecret("STIGMER_TOKEN", "rotated-token");
|
|
63
|
+
|
|
64
|
+
// A late (buggy or racing) boot-door call must be a no-op.
|
|
65
|
+
process.env.STIGMER_TOKEN = "stale-replant";
|
|
66
|
+
captureRunnerSecrets();
|
|
67
|
+
|
|
68
|
+
expect(getRunnerSecret("STIGMER_TOKEN")).toBe("rotated-token");
|
|
69
|
+
// The replant stays in env (capture did not consume it) — the live-env
|
|
70
|
+
// fallback below deliberately does NOT apply when the store holds a
|
|
71
|
+
// value, so rotation always wins.
|
|
72
|
+
expect(process.env.STIGMER_TOKEN).toBe("stale-replant");
|
|
73
|
+
delete process.env.STIGMER_TOKEN;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("scrubs the encryption keys, not only the #385 credentials (issue #508)", () => {
|
|
77
|
+
// Pins the #508 scope widening by name. The parameterized test above
|
|
78
|
+
// would pass even if the combined list regressed to the 8 credentials;
|
|
79
|
+
// this one cannot.
|
|
80
|
+
for (const key of [
|
|
81
|
+
"STIGMER_PAYLOAD_ENCRYPTION_KEY",
|
|
82
|
+
"STIGMER_PAYLOAD_ENCRYPTION_SECONDARY_KEY",
|
|
83
|
+
]) {
|
|
84
|
+
expect(RUNNER_ENCRYPTION_ENV_KEYS, `'${key}' must be a captured secret`).toContain(key);
|
|
85
|
+
}
|
|
86
|
+
for (const key of [...RUNNER_CREDENTIAL_ENV_KEYS, ...RUNNER_ENCRYPTION_ENV_KEYS]) {
|
|
87
|
+
expect(RUNNER_SECRET_ENV_KEYS, `'${key}' must be in the scrub list`).toContain(key);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("getRunnerSecret", () => {
|
|
93
|
+
it("falls back to a live process.env value the capture never saw", () => {
|
|
94
|
+
captureRunnerSecrets();
|
|
95
|
+
// Custody rule 2: a value planted after boot behaves like the env read
|
|
96
|
+
// the store replaced (test/embedder compatibility) — production only
|
|
97
|
+
// sets these at process start, so this path is dead there.
|
|
98
|
+
process.env.STIGMER_TOKEN = "late-planted";
|
|
99
|
+
|
|
100
|
+
expect(getRunnerSecret("STIGMER_TOKEN")).toBe("late-planted");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns undefined for an absent secret", () => {
|
|
104
|
+
captureRunnerSecrets();
|
|
105
|
+
expect(getRunnerSecret("STIGMER_TOKEN")).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("setRunnerSecret", () => {
|
|
110
|
+
it("carries the rotation channel: set updates, null clears", () => {
|
|
111
|
+
captureRunnerSecrets();
|
|
112
|
+
|
|
113
|
+
setRunnerSecret("STIGMER_TOKEN", "minted-1");
|
|
114
|
+
expect(getRunnerSecret("STIGMER_TOKEN")).toBe("minted-1");
|
|
115
|
+
|
|
116
|
+
setRunnerSecret("STIGMER_TOKEN", "minted-2");
|
|
117
|
+
expect(getRunnerSecret("STIGMER_TOKEN")).toBe("minted-2");
|
|
118
|
+
|
|
119
|
+
setRunnerSecret("STIGMER_TOKEN", null);
|
|
120
|
+
expect(getRunnerSecret("STIGMER_TOKEN")).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("never lets a rotated value sit in process.env, even if a writer beats the boot capture", () => {
|
|
124
|
+
process.env.STIGMER_TOKEN = "boot-token";
|
|
125
|
+
|
|
126
|
+
// No captureRunnerSecrets() call yet — the writer forces it.
|
|
127
|
+
setRunnerSecret("STIGMER_TOKEN", "rotated-early");
|
|
128
|
+
|
|
129
|
+
expect(process.env.STIGMER_TOKEN).toBeUndefined();
|
|
130
|
+
expect(getRunnerSecret("STIGMER_TOKEN")).toBe("rotated-early");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("runnerSecretsEnvView", () => {
|
|
135
|
+
it("merges captured secrets over process.env without writing them back", () => {
|
|
136
|
+
process.env.STIGMER_TOKEN = "boot-token";
|
|
137
|
+
captureRunnerSecrets();
|
|
138
|
+
|
|
139
|
+
const view = runnerSecretsEnvView();
|
|
140
|
+
|
|
141
|
+
expect(view.STIGMER_TOKEN).toBe("boot-token");
|
|
142
|
+
expect(view.PATH).toBe(process.env.PATH);
|
|
143
|
+
expect(
|
|
144
|
+
process.env.STIGMER_TOKEN,
|
|
145
|
+
"building the view must not re-plant secrets into process.env",
|
|
146
|
+
).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("reflects rotation", () => {
|
|
150
|
+
captureRunnerSecrets();
|
|
151
|
+
setRunnerSecret("STIGMER_TOKEN", "rotated");
|
|
152
|
+
|
|
153
|
+
expect(runnerSecretsEnvView().STIGMER_TOKEN).toBe("rotated");
|
|
154
|
+
});
|
|
155
|
+
});
|