@stigmer/runner 3.15.3-dev.20260916211208 → 3.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/generate-session-subject.d.ts +25 -4
- package/dist/activities/generate-session-subject.js +17 -7
- package/dist/activities/generate-session-subject.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +17 -6
- package/dist/client/stigmer-client.js +47 -14
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/config.d.ts +4 -1
- package/dist/config.js +4 -1
- package/dist/config.js.map +1 -1
- package/dist/interceptors/run-credential-activity.d.ts +28 -0
- package/dist/interceptors/run-credential-activity.js +56 -0
- package/dist/interceptors/run-credential-activity.js.map +1 -0
- package/dist/runner-manager.js +9 -3
- package/dist/runner-manager.js.map +1 -1
- package/dist/shared/activity-input.d.ts +9 -0
- package/dist/shared/activity-input.js +17 -0
- package/dist/shared/activity-input.js.map +1 -1
- package/dist/shared/channel-attachment.d.ts +8 -5
- package/dist/shared/channel-attachment.js +10 -6
- package/dist/shared/channel-attachment.js.map +1 -1
- package/dist/shared/filereview/progress.d.ts +13 -0
- package/dist/shared/filereview/progress.js +13 -0
- package/dist/shared/filereview/progress.js.map +1 -1
- package/dist/shared/memory-attachment.d.ts +16 -9
- package/dist/shared/memory-attachment.js +18 -10
- package/dist/shared/memory-attachment.js.map +1 -1
- package/dist/shared/run-credential-store.d.ts +32 -0
- package/dist/shared/run-credential-store.js +39 -0
- package/dist/shared/run-credential-store.js.map +1 -0
- package/dist/shared/run-credential.d.ts +61 -0
- package/dist/shared/run-credential.js +68 -0
- package/dist/shared/run-credential.js.map +1 -0
- package/dist/shared/synthesized-attachment.d.ts +30 -3
- package/dist/shared/synthesized-attachment.js +26 -0
- package/dist/shared/synthesized-attachment.js.map +1 -1
- package/dist/worker.js +10 -3
- package/dist/worker.js.map +1 -1
- package/dist/workflow-source.d.ts +10 -0
- package/dist/workflow-source.js +12 -0
- package/dist/workflow-source.js.map +1 -1
- package/dist/workflows/execute-from-execution.d.ts +15 -0
- package/dist/workflows/execute-from-execution.js +11 -0
- package/dist/workflows/execute-from-execution.js.map +1 -1
- package/dist/workflows/index.d.ts +5 -3
- package/dist/workflows/index.js +5 -3
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/interceptors/run-credential.d.ts +34 -0
- package/dist/workflows/interceptors/run-credential.js +71 -0
- package/dist/workflows/interceptors/run-credential.js.map +1 -0
- package/package.json +4 -4
- package/src/__tests__/run-credential-e2e.test.ts +233 -0
- package/src/__tests__/run-credential-registration.test.ts +80 -0
- package/src/activities/__tests__/generate-session-subject-wire.test.ts +119 -0
- package/src/activities/generate-session-subject.ts +29 -4
- package/src/client/__tests__/stigmer-client.test.ts +92 -0
- package/src/client/stigmer-client.ts +48 -14
- package/src/config.ts +4 -1
- package/src/interceptors/__tests__/run-credential-activity.test.ts +133 -0
- package/src/interceptors/run-credential-activity.ts +67 -0
- package/src/runner-manager.ts +13 -3
- package/src/shared/__tests__/channel-attachment.test.ts +12 -0
- package/src/shared/__tests__/memory-attachment.test.ts +17 -0
- package/src/shared/__tests__/run-credential.test.ts +111 -0
- package/src/shared/activity-input.ts +21 -0
- package/src/shared/channel-attachment.ts +14 -6
- package/src/shared/filereview/progress.ts +13 -0
- package/src/shared/memory-attachment.ts +22 -10
- package/src/shared/run-credential-store.ts +45 -0
- package/src/shared/run-credential.ts +70 -0
- package/src/shared/synthesized-attachment.ts +38 -3
- package/src/worker.ts +15 -3
- package/src/workflow-source.ts +15 -0
- package/src/workflows/__tests__/run-credential-interceptor.test.ts +178 -0
- package/src/workflows/execute-from-execution.ts +17 -0
- package/src/workflows/index.ts +5 -3
- package/src/workflows/interceptors/run-credential.ts +101 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins that every place a worker is composed registers BOTH ends of the run
|
|
3
|
+
* credential (`shared/run-credential.ts`): the activity inbound interceptor
|
|
4
|
+
* in the two roots that create workers, and the workflow interceptor module
|
|
5
|
+
* in the two runtime-bundling roots and the pre-built bundle script.
|
|
6
|
+
*
|
|
7
|
+
* Why a fence and not a comment: a root that forgot the activity end would
|
|
8
|
+
* present the operator's API key on every member's run — the failure this
|
|
9
|
+
* whole design exists to end — and on the desktop (the manager root) it
|
|
10
|
+
* would be invisible, because a person's own runner works for that person
|
|
11
|
+
* either way. The roots are read off the TypeScript syntax tree
|
|
12
|
+
* (`__test-utils__/module-specifiers.ts`) for the imports and off the source
|
|
13
|
+
* for the one call each import must make; the build script is JavaScript
|
|
14
|
+
* and is read as text.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
moduleSpecifiers,
|
|
22
|
+
readSource,
|
|
23
|
+
} from "../__test-utils__/module-specifiers.js";
|
|
24
|
+
|
|
25
|
+
const SRC_ROOT = fileURLToPath(new URL("../", import.meta.url));
|
|
26
|
+
const RUNNER_ROOT = fileURLToPath(new URL("../../", import.meta.url));
|
|
27
|
+
|
|
28
|
+
const ACTIVITY_END = "./interceptors/run-credential-activity.js";
|
|
29
|
+
const WORKFLOW_END_RESOLVER = "runCredentialWorkflowInterceptorModule";
|
|
30
|
+
|
|
31
|
+
function root(file: string): {
|
|
32
|
+
file: string;
|
|
33
|
+
source: string;
|
|
34
|
+
specifiers: string[];
|
|
35
|
+
} {
|
|
36
|
+
const path = `${SRC_ROOT}${file}`;
|
|
37
|
+
const source = readSource(path);
|
|
38
|
+
return { file, source, specifiers: moduleSpecifiers(path, source) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("run-credential registration", () => {
|
|
42
|
+
for (const file of ["worker.ts", "runner-manager.ts"]) {
|
|
43
|
+
it(`${file} registers the activity end and resolves the workflow end`, () => {
|
|
44
|
+
const { specifiers, source } = root(file);
|
|
45
|
+
expect(
|
|
46
|
+
specifiers,
|
|
47
|
+
`${file} must import the activity interceptor`,
|
|
48
|
+
).toContain(ACTIVITY_END);
|
|
49
|
+
expect(
|
|
50
|
+
source,
|
|
51
|
+
`${file} must place runCredentialActivityInterceptor in its activity interceptors`,
|
|
52
|
+
).toMatch(
|
|
53
|
+
/ActivityInterceptorsFactory\[\]\s*=\s*\[runCredentialActivityInterceptor\]/,
|
|
54
|
+
);
|
|
55
|
+
expect(
|
|
56
|
+
source,
|
|
57
|
+
`${file} must resolve the workflow interceptor module`,
|
|
58
|
+
).toContain(`${WORKFLOW_END_RESOLVER}()`);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
it("scripts/bundle-slim.mjs bakes the workflow end into the pre-built bundle", () => {
|
|
63
|
+
const source = readSource(`${RUNNER_ROOT}scripts/bundle-slim.mjs`);
|
|
64
|
+
expect(source).toMatch(
|
|
65
|
+
new RegExp(
|
|
66
|
+
`workflowInterceptorModules:\\s*\\[\\s*${WORKFLOW_END_RESOLVER}\\(\\)`,
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("the workflow end is a sandbox module: it imports nothing from Node", () => {
|
|
72
|
+
const path = `${SRC_ROOT}workflows/interceptors/run-credential.ts`;
|
|
73
|
+
const nodeImports = moduleSpecifiers(path, readSource(path)).filter((s) =>
|
|
74
|
+
s.startsWith("node:"),
|
|
75
|
+
);
|
|
76
|
+
expect(nodeImports).toEqual([]);
|
|
77
|
+
const wirePath = `${SRC_ROOT}shared/run-credential.ts`;
|
|
78
|
+
expect(moduleSpecifiers(wirePath, readSource(wirePath))).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GenerateSessionSubject activity's wire contract: one argument in one of
|
|
3
|
+
* two shapes — the positional execution id every server has sent, and the
|
|
4
|
+
* typed object a server sends when the dispatch carries the run credential.
|
|
5
|
+
* Both reach the same core with the same execution id; the credential is the
|
|
6
|
+
* activity boundary's business, not this activity's (the header explains).
|
|
7
|
+
*
|
|
8
|
+
* The client is the hermetic module seam (`__test-utils__/hermetic-activity.ts`)
|
|
9
|
+
* because the factory constructs its own `StigmerClient`; the LLM seam is
|
|
10
|
+
* module-mocked to return a fixed title.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it, vi } from "vitest";
|
|
14
|
+
import type { StigmerClient } from "../../client/stigmer-client.js";
|
|
15
|
+
import { bindHermeticClient } from "../../__test-utils__/hermetic-activity.js";
|
|
16
|
+
import { mockStigmerClient } from "../../__test-utils__/mock-client.js";
|
|
17
|
+
|
|
18
|
+
vi.mock("../../client/stigmer-client.js", async () =>
|
|
19
|
+
(
|
|
20
|
+
await import("../../__test-utils__/hermetic-activity.js")
|
|
21
|
+
).hermeticStigmerClientModule(),
|
|
22
|
+
);
|
|
23
|
+
vi.mock("../../shared/model-registry.js", () => ({
|
|
24
|
+
getSummarizationModel: vi.fn(async (primary: string) => primary),
|
|
25
|
+
}));
|
|
26
|
+
vi.mock("../../shared/model-client.js", () => ({
|
|
27
|
+
buildChatModel: vi.fn(async () => ({
|
|
28
|
+
model: { invoke: vi.fn(async () => ({ content: "Indexing Basics" })) },
|
|
29
|
+
})),
|
|
30
|
+
}));
|
|
31
|
+
vi.mock("../../shared/llm-backend.js", () => ({
|
|
32
|
+
checkDirectCredentials: vi.fn(() => null),
|
|
33
|
+
}));
|
|
34
|
+
vi.mock("../../shared/llm-proxy.js", () => ({
|
|
35
|
+
tryInferProvider: vi.fn(() => "anthropic"),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
AUTO_CREATED_SUBJECT,
|
|
40
|
+
createGenerateSessionSubjectActivities,
|
|
41
|
+
} from "../generate-session-subject.js";
|
|
42
|
+
import type { Config } from "../../config.js";
|
|
43
|
+
|
|
44
|
+
const EXECUTION_ID = "aex_wire_1";
|
|
45
|
+
|
|
46
|
+
function clientReadingExecution(): {
|
|
47
|
+
client: StigmerClient;
|
|
48
|
+
reads: string[];
|
|
49
|
+
titled: string[];
|
|
50
|
+
} {
|
|
51
|
+
const reads: string[] = [];
|
|
52
|
+
const titled: string[] = [];
|
|
53
|
+
const client = mockStigmerClient({
|
|
54
|
+
getExecution: vi.fn(async (executionId: string) => {
|
|
55
|
+
reads.push(executionId);
|
|
56
|
+
return {
|
|
57
|
+
spec: {
|
|
58
|
+
sessionId: "ses_1",
|
|
59
|
+
agentId: "agt_1",
|
|
60
|
+
message: "How do indexes work?",
|
|
61
|
+
},
|
|
62
|
+
} as never;
|
|
63
|
+
}),
|
|
64
|
+
getSession: vi.fn(
|
|
65
|
+
async () =>
|
|
66
|
+
({
|
|
67
|
+
spec: { subject: AUTO_CREATED_SUBJECT, agentInstanceId: "" },
|
|
68
|
+
}) as never,
|
|
69
|
+
),
|
|
70
|
+
getAgent: vi.fn(
|
|
71
|
+
async () =>
|
|
72
|
+
({ metadata: { name: "a" }, spec: { description: "" } }) as never,
|
|
73
|
+
),
|
|
74
|
+
updateSessionSubject: vi.fn(async (_sessionId: string, subject: string) => {
|
|
75
|
+
titled.push(subject);
|
|
76
|
+
return {} as never;
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
return { client, reads, titled };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function activityUnderTest() {
|
|
83
|
+
const { client, reads, titled } = clientReadingExecution();
|
|
84
|
+
bindHermeticClient(client);
|
|
85
|
+
const config = {
|
|
86
|
+
stigmerBackendEndpoint: "http://localhost:0",
|
|
87
|
+
stigmerToken: null,
|
|
88
|
+
proxyEndpoint: null,
|
|
89
|
+
primaryModel: "claude-sonnet-4.5",
|
|
90
|
+
} as unknown as Config;
|
|
91
|
+
const { GenerateSessionSubject } =
|
|
92
|
+
createGenerateSessionSubjectActivities(config);
|
|
93
|
+
return { GenerateSessionSubject, reads, titled };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe("GenerateSessionSubject wire shapes", () => {
|
|
97
|
+
it("accepts the positional execution id — every earlier server's shape", async () => {
|
|
98
|
+
const { GenerateSessionSubject, reads, titled } = activityUnderTest();
|
|
99
|
+
await GenerateSessionSubject(EXECUTION_ID);
|
|
100
|
+
expect(reads).toEqual([EXECUTION_ID]);
|
|
101
|
+
expect(titled).toEqual(["Indexing Basics"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("accepts the typed object carrying the run credential and reads the same execution", async () => {
|
|
105
|
+
const { GenerateSessionSubject, reads, titled } = activityUnderTest();
|
|
106
|
+
await GenerateSessionSubject({
|
|
107
|
+
execution_id: EXECUTION_ID,
|
|
108
|
+
execution_context_token: "run-cred",
|
|
109
|
+
});
|
|
110
|
+
expect(reads).toEqual([EXECUTION_ID]);
|
|
111
|
+
expect(titled).toEqual(["Indexing Basics"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("accepts the typed object without a credential", async () => {
|
|
115
|
+
const { GenerateSessionSubject, reads } = activityUnderTest();
|
|
116
|
+
await GenerateSessionSubject({ execution_id: EXECUTION_ID });
|
|
117
|
+
expect(reads).toEqual([EXECUTION_ID]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -23,12 +23,24 @@
|
|
|
23
23
|
* here degrades to a heuristic title (the first words of the user message):
|
|
24
24
|
* a title is worth having even when no model is reachable.
|
|
25
25
|
*
|
|
26
|
-
* Activity contract
|
|
27
|
-
* Java signature carries a second, invokerIdentityAccountId, that never
|
|
28
|
-
* crosses this wire):
|
|
26
|
+
* Activity contract — exactly ONE argument, in one of two shapes:
|
|
29
27
|
* Name: "GenerateSessionSubject"
|
|
30
28
|
* Input: (executionId: string)
|
|
29
|
+
* | ({ execution_id, execution_context_token? }: GenerateSessionSubjectInput)
|
|
31
30
|
* Output: void
|
|
31
|
+
*
|
|
32
|
+
* The object is what a server sends when the dispatch carries the run's
|
|
33
|
+
* credential (shared/run-credential.ts), so that this activity reads the
|
|
34
|
+
* run and titles the session AS THE RUN'S HUMAN — with sign-in on, the
|
|
35
|
+
* operator's key the process holds cannot read a member's run. The string
|
|
36
|
+
* is what every server sent before, and what a server whose dispatch carries
|
|
37
|
+
* no credential still sends (the cloud's, whose desktop runners in the field
|
|
38
|
+
* must keep titling sessions). Both shapes are accepted — the dual-shape
|
|
39
|
+
* posture the turn activities take (shared/activity-input.ts); the
|
|
40
|
+
* credential itself is entered by the activity boundary
|
|
41
|
+
* (interceptors/run-credential-activity.ts), never read here. The cloud's
|
|
42
|
+
* Java signature carries a second argument, invokerIdentityAccountId, that
|
|
43
|
+
* never crosses this wire.
|
|
32
44
|
*/
|
|
33
45
|
|
|
34
46
|
import { SystemMessage, HumanMessage } from "@langchain/core/messages";
|
|
@@ -344,6 +356,16 @@ function log(msg: string): void {
|
|
|
344
356
|
// Temporal Activity Factory
|
|
345
357
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
346
358
|
|
|
359
|
+
/**
|
|
360
|
+
* The typed wire shape of the activity's argument (snake_case keys, the
|
|
361
|
+
* polyglot workflow-input convention). `execution_context_token` is the run
|
|
362
|
+
* credential, present exactly when the dispatch carried one.
|
|
363
|
+
*/
|
|
364
|
+
export interface GenerateSessionSubjectInput {
|
|
365
|
+
readonly execution_id: string;
|
|
366
|
+
readonly execution_context_token?: string;
|
|
367
|
+
}
|
|
368
|
+
|
|
347
369
|
export function createGenerateSessionSubjectActivities(config: Config) {
|
|
348
370
|
const client = new StigmerClient({
|
|
349
371
|
endpoint: config.stigmerBackendEndpoint,
|
|
@@ -353,7 +375,10 @@ export function createGenerateSessionSubjectActivities(config: Config) {
|
|
|
353
375
|
});
|
|
354
376
|
|
|
355
377
|
return {
|
|
356
|
-
GenerateSessionSubject: async (
|
|
378
|
+
GenerateSessionSubject: async (
|
|
379
|
+
input: GenerateSessionSubjectInput | string,
|
|
380
|
+
): Promise<void> => {
|
|
381
|
+
const executionId = typeof input === "string" ? input : input.execution_id;
|
|
357
382
|
activityStarted();
|
|
358
383
|
try {
|
|
359
384
|
log(`started: execution=${executionId}`);
|
|
@@ -20,6 +20,8 @@ vi.mock("@connectrpc/connect", () => ({
|
|
|
20
20
|
}));
|
|
21
21
|
|
|
22
22
|
import { StigmerClient } from "../stigmer-client.js";
|
|
23
|
+
import { withRunCredential } from "../../shared/run-credential-store.js";
|
|
24
|
+
import { AgentExecutionCommandController } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/command_pb";
|
|
23
25
|
import { ExecutionContextQueryController } from "@stigmer/protos/ai/stigmer/agentic/executioncontext/v1/query_pb";
|
|
24
26
|
import { PlatformQueryController } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
|
|
25
27
|
|
|
@@ -279,6 +281,96 @@ describe("StigmerClient", () => {
|
|
|
279
281
|
});
|
|
280
282
|
});
|
|
281
283
|
|
|
284
|
+
describe("run credential (the activity's own, shared/run-credential-store.ts)", () => {
|
|
285
|
+
// The server mints a credential for ONE execution at dispatch and, with
|
|
286
|
+
// sign-in on, admits its bearer as that run's human. The activity boundary
|
|
287
|
+
// enters it into the store; these arms pin that the client presents it on
|
|
288
|
+
// every RPC about a run, on no process-bound RPC, below an explicit
|
|
289
|
+
// per-call header and above every process credential — and that two
|
|
290
|
+
// concurrent activities never see each other's.
|
|
291
|
+
|
|
292
|
+
function clientWithEveryProcessCredential(): StigmerClient {
|
|
293
|
+
return new StigmerClient({
|
|
294
|
+
endpoint: "http://localhost",
|
|
295
|
+
token: "static-tok",
|
|
296
|
+
tokenRef: { current: "control-plane-tok" },
|
|
297
|
+
runnerTokenRef: { current: "runner-tok" },
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
it("presents the run credential on an ordinary control-plane RPC", async () => {
|
|
302
|
+
clientWithEveryProcessCredential();
|
|
303
|
+
const req = makeRequest();
|
|
304
|
+
await withRunCredential("run-cred", () => runInterceptor(req));
|
|
305
|
+
expect(req.header.get("authorization")).toBe("Bearer run-cred");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("presents it above the runner credential on the three runner-credential RPCs", async () => {
|
|
309
|
+
clientWithEveryProcessCredential();
|
|
310
|
+
for (const req of [
|
|
311
|
+
makeExecutionContextRequest(),
|
|
312
|
+
makeScopedTokenExchangeRequest(),
|
|
313
|
+
makeRequest(AgentExecutionCommandController.typeName, "create"),
|
|
314
|
+
]) {
|
|
315
|
+
await withRunCredential("run-cred", () => runInterceptor(req));
|
|
316
|
+
expect(req.header.get("authorization")).toBe("Bearer run-cred");
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("never presents it on the process-bound bootstrap read", async () => {
|
|
321
|
+
clientWithEveryProcessCredential();
|
|
322
|
+
const req = makeRequest(PlatformQueryController.typeName, "getRunnerBootstrapConfig");
|
|
323
|
+
await withRunCredential("run-cred", () => runInterceptor(req));
|
|
324
|
+
expect(req.header.get("authorization")).toBe("Bearer control-plane-tok");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("yields to an explicit per-call credential", async () => {
|
|
328
|
+
clientWithEveryProcessCredential();
|
|
329
|
+
const req = makeRequest();
|
|
330
|
+
req.header.set("authorization", "Bearer explicit-tok");
|
|
331
|
+
await withRunCredential("run-cred", () => runInterceptor(req));
|
|
332
|
+
expect(req.header.get("authorization")).toBe("Bearer explicit-tok");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("leaves every rule below untouched when no activity credential is current", async () => {
|
|
336
|
+
clientWithEveryProcessCredential();
|
|
337
|
+
const ordinary = makeRequest();
|
|
338
|
+
await runInterceptor(ordinary);
|
|
339
|
+
expect(ordinary.header.get("authorization")).toBe("Bearer control-plane-tok");
|
|
340
|
+
const ec = makeExecutionContextRequest();
|
|
341
|
+
await runInterceptor(ec);
|
|
342
|
+
expect(ec.header.get("authorization")).toBe("Bearer runner-tok");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("never crosses between two concurrent activities sharing one client", async () => {
|
|
346
|
+
clientWithEveryProcessCredential();
|
|
347
|
+
const reqA = makeRequest();
|
|
348
|
+
const reqB = makeRequest();
|
|
349
|
+
await Promise.all([
|
|
350
|
+
withRunCredential("run-cred-A", async () => {
|
|
351
|
+
await new Promise((resolve) => setTimeout(resolve, 2));
|
|
352
|
+
await runInterceptor(reqA);
|
|
353
|
+
}),
|
|
354
|
+
withRunCredential("run-cred-B", async () => {
|
|
355
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
356
|
+
await runInterceptor(reqB);
|
|
357
|
+
}),
|
|
358
|
+
]);
|
|
359
|
+
expect(reqA.header.get("authorization")).toBe("Bearer run-cred-A");
|
|
360
|
+
expect(reqB.header.get("authorization")).toBe("Bearer run-cred-B");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("acquireScopedRunnerToken answers the run credential without an exchange", async () => {
|
|
364
|
+
const client = clientWithEveryProcessCredential();
|
|
365
|
+
const exchange = vi.spyOn(client, "getRunnerScopedToken");
|
|
366
|
+
const token = await withRunCredential("run-cred", () =>
|
|
367
|
+
client.acquireScopedRunnerToken({ agentExecutionId: "aex_1" }),
|
|
368
|
+
);
|
|
369
|
+
expect(token).toBe("run-cred");
|
|
370
|
+
expect(exchange).not.toHaveBeenCalled();
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
282
374
|
describe("acquireScopedRunnerToken", () => {
|
|
283
375
|
function clientWithRunnerCredential(token: string | null): StigmerClient {
|
|
284
376
|
return new StigmerClient({
|
|
@@ -55,6 +55,7 @@ import { ChannelMessageQueryController } from "@stigmer/protos/ai/stigmer/agenti
|
|
|
55
55
|
import type { ChannelTemplate, MessagingChannel } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
|
|
56
56
|
import { TOKEN_TYPE_EMBEDDED_RUNNER, tokenTypeOf } from "./token-claims.js";
|
|
57
57
|
import { assertCreateRequirements, assertReferenceRequirements } from "./server-contracts.js";
|
|
58
|
+
import { currentRunCredential } from "../shared/run-credential-store.js";
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
61
|
* Server-managed Temporal payload-encryption keys for this runner.
|
|
@@ -223,11 +224,25 @@ export class StigmerClient {
|
|
|
223
224
|
// 1. An explicit per-call credential (an authorization header set via
|
|
224
225
|
// CallOptions) always wins. The scoped-token flow (issue #156)
|
|
225
226
|
// authenticates each ExecutionContext read with a token minted for
|
|
226
|
-
// that specific execution;
|
|
227
|
-
//
|
|
228
|
-
//
|
|
227
|
+
// that specific execution; the renewal and pool-claim exchanges
|
|
228
|
+
// present the token being exchanged; the connect lane hands its
|
|
229
|
+
// decrypt-lane token per call.
|
|
229
230
|
//
|
|
230
|
-
// 2. The
|
|
231
|
+
// 2. The run credential of the activity this request belongs to
|
|
232
|
+
// (shared/run-credential-store.ts), on every RPC that is about a
|
|
233
|
+
// run — which is every RPC but the process-bound bootstrap read.
|
|
234
|
+
// The server minted it for exactly this execution at dispatch and,
|
|
235
|
+
// with sign-in on, admits its bearer as the run's own human: the
|
|
236
|
+
// status write, the child create, the artifact, the session title
|
|
237
|
+
// are the member's, whoever's API key this process holds. It is
|
|
238
|
+
// read from the async context, never from a ref: one process serves
|
|
239
|
+
// many runs at once and a shared ref would be the wrong run's the
|
|
240
|
+
// moment two overlap — the same reason rule 1 exists. Absent
|
|
241
|
+
// outside an activity and when the dispatch carried none (an older
|
|
242
|
+
// server; the cloud, whose runner credential is provisioned), so
|
|
243
|
+
// the rules below are exactly what they were.
|
|
244
|
+
//
|
|
245
|
+
// 3. The runner credential (runnerTokenRef) authenticates the services
|
|
231
246
|
// that require a runner-class token_type claim: ExecutionContext
|
|
232
247
|
// reads carry decrypted secrets on cloud, and the server gates that
|
|
233
248
|
// decrypt on runner class + scope (stigmer-cloud#152/#155); the
|
|
@@ -239,13 +254,16 @@ export class StigmerClient {
|
|
|
239
254
|
// reserved-label guard and environment composer accept only from
|
|
240
255
|
// runner-class callers — a user-token create is rejected outright.
|
|
241
256
|
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
257
|
+
// 4. Everything else uses the control-plane token: the process
|
|
258
|
+
// credential, an operator's API key on a self-host. Falls through
|
|
259
|
+
// unchanged when no runner token exists.
|
|
245
260
|
(next) => async (req) => {
|
|
246
261
|
if (req.header.has("authorization")) {
|
|
247
262
|
return next(req);
|
|
248
263
|
}
|
|
264
|
+
const isProcessBound =
|
|
265
|
+
req.service.typeName === PlatformQueryController.typeName &&
|
|
266
|
+
req.method.name === PlatformQueryController.method.getRunnerBootstrapConfig.name;
|
|
249
267
|
const usesRunnerCredential =
|
|
250
268
|
req.service.typeName === ExecutionContextQueryController.typeName ||
|
|
251
269
|
(req.service.typeName === PlatformQueryController.typeName &&
|
|
@@ -253,7 +271,8 @@ export class StigmerClient {
|
|
|
253
271
|
(req.service.typeName === AgentExecutionCommandController.typeName &&
|
|
254
272
|
req.method.name === AgentExecutionCommandController.method.create.name);
|
|
255
273
|
const token =
|
|
256
|
-
(
|
|
274
|
+
(isProcessBound ? undefined : currentRunCredential())
|
|
275
|
+
?? (usesRunnerCredential ? this.runnerTokenRef?.current : null)
|
|
257
276
|
?? this.tokenRef?.current
|
|
258
277
|
?? this.currentToken;
|
|
259
278
|
if (token) {
|
|
@@ -363,11 +382,16 @@ export class StigmerClient {
|
|
|
363
382
|
* The call authenticates with the runner credential (see the interceptor).
|
|
364
383
|
* Cloud verifies it is an embedded_runner token and that its identity can
|
|
365
384
|
* view the named execution, then mints the same session/execution-scoped
|
|
366
|
-
* sandbox token a cloud sandbox runner receives at provisioning.
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
385
|
+
* sandbox token a cloud sandbox runner receives at provisioning. An
|
|
386
|
+
* open-source server with sign-in off mints an execution-scoped token for
|
|
387
|
+
* any caller (oss#535 — there it only unlocks the ExecutionContext decrypt
|
|
388
|
+
* lane); with sign-in on the same token admits its bearer as the run's
|
|
389
|
+
* human, so the exchange mints only for the run's own person (a missing
|
|
390
|
+
* run NOT_FOUND, anyone else PERMISSION_DENIED). Runs normally receive
|
|
391
|
+
* their credential from the dispatch itself (shared/run-credential.ts);
|
|
392
|
+
* this exchange is the fallback. Returns undefined when the server does
|
|
393
|
+
* not mint (pre-oss#535 OSS, or an unserved scope arm) — presence-based,
|
|
394
|
+
* like the bootstrap token fields.
|
|
371
395
|
*
|
|
372
396
|
* `callerToken` authenticates the exchange per-call instead of the
|
|
373
397
|
* process-wide runner credential. The warm-pool attach uses it: a pool
|
|
@@ -400,7 +424,13 @@ export class StigmerClient {
|
|
|
400
424
|
* Acquire a scoped runner token for an ExecutionContext read, if this
|
|
401
425
|
* runner's credential situation calls for one.
|
|
402
426
|
*
|
|
403
|
-
*
|
|
427
|
+
* When the activity this call belongs to carries a run credential (the
|
|
428
|
+
* server minted it for this very execution at dispatch —
|
|
429
|
+
* shared/run-credential.ts), that IS the scoped token: it is answered
|
|
430
|
+
* without an RPC, and the exchange below serves only a dispatch that
|
|
431
|
+
* carried none — an older server, or the cloud's provisioned runner.
|
|
432
|
+
*
|
|
433
|
+
* Otherwise the gate is the credential itself, three ways:
|
|
404
434
|
*
|
|
405
435
|
* 1. An unscoped embedded_runner bootstrap token MUST be exchanged, and a
|
|
406
436
|
* failed exchange is a hard error, not a fallback: since the #156
|
|
@@ -431,6 +461,10 @@ export class StigmerClient {
|
|
|
431
461
|
async acquireScopedRunnerToken(
|
|
432
462
|
scope: RunnerScopedTokenScope,
|
|
433
463
|
): Promise<string | undefined> {
|
|
464
|
+
const runCredential = currentRunCredential();
|
|
465
|
+
if (runCredential !== undefined) {
|
|
466
|
+
return runCredential;
|
|
467
|
+
}
|
|
434
468
|
// Inspect the same credential chain the EC read's interceptor would use.
|
|
435
469
|
const ambientTokenType = tokenTypeOf(
|
|
436
470
|
this.runnerTokenRef?.current ?? this.tokenRef?.current ?? this.currentToken,
|
package/src/config.ts
CHANGED
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
*
|
|
10
10
|
* 1. Direct mode (local / OSS):
|
|
11
11
|
* - CURSOR_API_KEY provided directly by the user (for Cursor harness).
|
|
12
|
-
* - STIGMER_TOKEN is optional
|
|
12
|
+
* - STIGMER_TOKEN is optional against a trusted-local server (it verifies
|
|
13
|
+
* no credential) and is the runner's own API key against a self-hosted
|
|
14
|
+
* server with sign-in on; each run's credential arrives with its
|
|
15
|
+
* dispatch (shared/run-credential.ts).
|
|
13
16
|
* - STIGMER_PROXY_ENDPOINT is NOT set.
|
|
14
17
|
*
|
|
15
18
|
* 2. Proxy mode (cloud / managed runners):
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The activity boundary's entry into the run-credential store
|
|
3
|
+
* (`run-credential-activity.ts`): the two channels a dispatch can use, their
|
|
4
|
+
* precedence, the credential-less pass-through, and the promise that the
|
|
5
|
+
* boundary never writes a log line (a credential must never reach one).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it, vi } from "vitest";
|
|
9
|
+
import { defaultPayloadConverter } from "@temporalio/common";
|
|
10
|
+
import type { ActivityExecuteInput } from "@temporalio/worker";
|
|
11
|
+
|
|
12
|
+
import { runCredentialActivityInterceptor } from "../run-credential-activity.js";
|
|
13
|
+
import {
|
|
14
|
+
RUN_CREDENTIAL_HEADER,
|
|
15
|
+
RUN_CREDENTIAL_INPUT_KEY,
|
|
16
|
+
} from "../../shared/run-credential.js";
|
|
17
|
+
import { currentRunCredential } from "../../shared/run-credential-store.js";
|
|
18
|
+
|
|
19
|
+
function execute(input: ActivityExecuteInput): Promise<string | undefined> {
|
|
20
|
+
const interceptors = runCredentialActivityInterceptor({} as never);
|
|
21
|
+
const execute = interceptors.inbound?.execute;
|
|
22
|
+
if (execute === undefined)
|
|
23
|
+
throw new Error("the interceptor must implement execute");
|
|
24
|
+
return execute(input, async () => currentRunCredential()) as Promise<
|
|
25
|
+
string | undefined
|
|
26
|
+
>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function headerOf(credential: string): ActivityExecuteInput["headers"] {
|
|
30
|
+
return {
|
|
31
|
+
[RUN_CREDENTIAL_HEADER]: defaultPayloadConverter.toPayload(credential),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("runCredentialActivityInterceptor", () => {
|
|
36
|
+
it("enters the header's credential — the runner's own dispatch", async () => {
|
|
37
|
+
expect(
|
|
38
|
+
await execute({ args: ["wex_1"], headers: headerOf("cred-header") }),
|
|
39
|
+
).toBe("cred-header");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("enters the input object's credential — the server's dispatch", async () => {
|
|
43
|
+
expect(
|
|
44
|
+
await execute({
|
|
45
|
+
args: [
|
|
46
|
+
{
|
|
47
|
+
execution_id: "aex_1",
|
|
48
|
+
thread_id: "",
|
|
49
|
+
[RUN_CREDENTIAL_INPUT_KEY]: "cred-input",
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
headers: {},
|
|
53
|
+
}),
|
|
54
|
+
).toBe("cred-input");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("prefers the header when both are present", async () => {
|
|
58
|
+
expect(
|
|
59
|
+
await execute({
|
|
60
|
+
args: [{ [RUN_CREDENTIAL_INPUT_KEY]: "cred-input" }],
|
|
61
|
+
headers: headerOf("cred-header"),
|
|
62
|
+
}),
|
|
63
|
+
).toBe("cred-header");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("runs a credential-less activity with an empty store — an older server, the cloud", async () => {
|
|
67
|
+
expect(await execute({ args: ["aex_1", ""], headers: {} })).toBeUndefined();
|
|
68
|
+
expect(
|
|
69
|
+
await execute({
|
|
70
|
+
args: [{ execution_id: "aex_1", thread_id: "" }],
|
|
71
|
+
headers: {},
|
|
72
|
+
}),
|
|
73
|
+
).toBeUndefined();
|
|
74
|
+
expect(await execute({ args: [], headers: {} })).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("ignores a header that does not decode to a non-empty string", async () => {
|
|
78
|
+
expect(
|
|
79
|
+
await execute({
|
|
80
|
+
args: [],
|
|
81
|
+
headers: {
|
|
82
|
+
[RUN_CREDENTIAL_HEADER]: defaultPayloadConverter.toPayload(""),
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
).toBeUndefined();
|
|
86
|
+
expect(
|
|
87
|
+
await execute({
|
|
88
|
+
args: [],
|
|
89
|
+
headers: {
|
|
90
|
+
[RUN_CREDENTIAL_HEADER]: defaultPayloadConverter.toPayload(7),
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does not read the connect lane's camelCase per-call token", async () => {
|
|
97
|
+
expect(
|
|
98
|
+
await execute({
|
|
99
|
+
args: [
|
|
100
|
+
{ mcpServerId: "mcp_1", executionContextToken: "connect-token" },
|
|
101
|
+
],
|
|
102
|
+
headers: {},
|
|
103
|
+
}),
|
|
104
|
+
).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("leaves the store empty once the activity has returned", async () => {
|
|
108
|
+
await execute({ args: [], headers: headerOf("cred-x") });
|
|
109
|
+
expect(currentRunCredential()).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("writes nothing to the console on any path", async () => {
|
|
113
|
+
const spies = (["log", "warn", "error", "info", "debug"] as const).map(
|
|
114
|
+
(level) => vi.spyOn(console, level).mockImplementation(() => {}),
|
|
115
|
+
);
|
|
116
|
+
try {
|
|
117
|
+
await execute({
|
|
118
|
+
args: [{ [RUN_CREDENTIAL_INPUT_KEY]: "cred-secret" }],
|
|
119
|
+
headers: {},
|
|
120
|
+
});
|
|
121
|
+
await execute({ args: ["aex_1"], headers: headerOf("cred-secret") });
|
|
122
|
+
await execute({
|
|
123
|
+
args: [],
|
|
124
|
+
headers: {
|
|
125
|
+
[RUN_CREDENTIAL_HEADER]: defaultPayloadConverter.toPayload(7),
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
for (const spy of spies) expect(spy).not.toHaveBeenCalled();
|
|
129
|
+
} finally {
|
|
130
|
+
for (const spy of spies) spy.mockRestore();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|