@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,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The activity boundary's ONE entry into the run-credential store.
|
|
3
|
+
*
|
|
4
|
+
* Every activity a worker runs — the server-dispatched turn activities, the
|
|
5
|
+
* server-dispatched GenerateSessionSubject, and every local or remote
|
|
6
|
+
* activity the runner's own serverless-workflow engine schedules — passes
|
|
7
|
+
* through this inbound interceptor. It reads the run credential from
|
|
8
|
+
* whichever channel the dispatch used (`shared/run-credential.ts`: the
|
|
9
|
+
* Temporal header the runner's workflow stamps, else the input object's
|
|
10
|
+
* key the server's workflow sets) and runs the activity inside
|
|
11
|
+
* `withRunCredential`, so that every control-plane request the activity
|
|
12
|
+
* makes — however deep, however detached — is made as the run's human.
|
|
13
|
+
*
|
|
14
|
+
* Header before input: a runner-dispatched activity's header is the nearer
|
|
15
|
+
* authority; in practice a dispatch carries exactly one of the two. An
|
|
16
|
+
* activity whose dispatch carried neither runs with no credential in the
|
|
17
|
+
* store, and the client falls back to the process credential exactly as it
|
|
18
|
+
* did before the store existed — an older server, and the cloud, see no
|
|
19
|
+
* change.
|
|
20
|
+
*
|
|
21
|
+
* Both worker roots (`worker.ts`, `runner-manager.ts`) register this
|
|
22
|
+
* unconditionally; it is inert without a credential. It logs nothing: a
|
|
23
|
+
* credential is never written to a log, and the arguments it inspects may
|
|
24
|
+
* carry anything.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { ActivityInterceptorsFactory } from "@temporalio/worker";
|
|
28
|
+
import { defaultPayloadConverter } from "@temporalio/common";
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
RUN_CREDENTIAL_HEADER,
|
|
32
|
+
readRunCredentialFromInput,
|
|
33
|
+
} from "../shared/run-credential.js";
|
|
34
|
+
import { withRunCredential } from "../shared/run-credential-store.js";
|
|
35
|
+
|
|
36
|
+
/** The inbound interceptor factory both worker roots register. */
|
|
37
|
+
export const runCredentialActivityInterceptor: ActivityInterceptorsFactory =
|
|
38
|
+
() => ({
|
|
39
|
+
inbound: {
|
|
40
|
+
async execute(input, next) {
|
|
41
|
+
const credential =
|
|
42
|
+
readRunCredentialFromHeader(input.headers[RUN_CREDENTIAL_HEADER]) ??
|
|
43
|
+
readRunCredentialFromInput(input.args[0]);
|
|
44
|
+
if (credential === undefined) {
|
|
45
|
+
return next(input);
|
|
46
|
+
}
|
|
47
|
+
return withRunCredential(credential, () => next(input));
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The credential a header payload carries, or `undefined` when there is no
|
|
54
|
+
* such header or it does not decode to a non-empty string. Decoding uses the
|
|
55
|
+
* default converter, never the worker's data converter: headers bypass the
|
|
56
|
+
* payload codecs by SDK design, and the stamping side encodes with the same
|
|
57
|
+
* default converter.
|
|
58
|
+
*/
|
|
59
|
+
function readRunCredentialFromHeader(payload: unknown): string | undefined {
|
|
60
|
+
if (payload === undefined) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
const value: unknown = defaultPayloadConverter.fromPayload(
|
|
64
|
+
payload as Parameters<typeof defaultPayloadConverter.fromPayload>[0],
|
|
65
|
+
);
|
|
66
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
67
|
+
}
|
package/src/runner-manager.ts
CHANGED
|
@@ -29,7 +29,12 @@ import { DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS, DEFAULT_CURSOR_STREAM_STALL_TI
|
|
|
29
29
|
// desktop manager never calls emitRunnerBootTiming, so marks cost nothing).
|
|
30
30
|
import { markBoot } from "./shared/cold-start-timing.js";
|
|
31
31
|
import type { WorkerActivities } from "./worker.js";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
resolveWorkflowSource,
|
|
34
|
+
runCredentialWorkflowInterceptorModule,
|
|
35
|
+
OTEL_WORKFLOW_INTERCEPTOR_MODULE,
|
|
36
|
+
} from "./workflow-source.js";
|
|
37
|
+
import { runCredentialActivityInterceptor } from "./interceptors/run-credential-activity.js";
|
|
33
38
|
import { resolveRunnerBootstrap, refreshRunnerAccessToken } from "./bootstrap.js";
|
|
34
39
|
import { assertLlmBackendsPreflight } from "./preflight.js";
|
|
35
40
|
import {
|
|
@@ -816,9 +821,14 @@ async function buildInterceptorConfig(): Promise<InterceptorConfig> {
|
|
|
816
821
|
"./interceptors/workflow-metrics-sink.js"
|
|
817
822
|
);
|
|
818
823
|
|
|
819
|
-
|
|
824
|
+
// The run credential's two ends (shared/run-credential.ts), always on and
|
|
825
|
+
// inert without a credential — the same pair the static root registers
|
|
826
|
+
// (worker.ts). The workflow module reaches the bundle through
|
|
827
|
+
// `workflowInterceptorModules` on the runtime path and is baked in on the
|
|
828
|
+
// pre-built path (scripts/bundle-slim.mjs).
|
|
829
|
+
const activityInterceptors: ActivityInterceptorsFactory[] = [runCredentialActivityInterceptor];
|
|
820
830
|
let sinks: InjectedSinks<any> = { ...createWorkflowMetricsSinks() };
|
|
821
|
-
const workflowInterceptorModules: string[] = [];
|
|
831
|
+
const workflowInterceptorModules: string[] = [runCredentialWorkflowInterceptorModule()];
|
|
822
832
|
|
|
823
833
|
// In-flight activity counter: keeps a session/wfexec worker alive while one of
|
|
824
834
|
// its activities (notably the long ExecuteCursor) is running, so a view close
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
type ChannelMessagingInfo,
|
|
29
29
|
} from "../channel-attachment.js";
|
|
30
30
|
import type { ResolvedMcpServer } from "../mcp-resolver.js";
|
|
31
|
+
import { STDIO_CREDENTIAL_ENV } from "../synthesized-attachment.js";
|
|
31
32
|
|
|
32
33
|
function channel(slug: string): MessagingChannel {
|
|
33
34
|
return { channel: slug, provider: "whatsapp" } as MessagingChannel;
|
|
@@ -158,9 +159,20 @@ describe("synthesizeChannelAttachment", () => {
|
|
|
158
159
|
});
|
|
159
160
|
});
|
|
160
161
|
|
|
162
|
+
it("the stdio child receives the run's credential as its startup bearer — the memory attachment's twin, one carrier", () => {
|
|
163
|
+
const attachment = synthesizeChannelAttachment([info("isc-whatsapp", [])], {
|
|
164
|
+
bridgeEndpoint: null,
|
|
165
|
+
credential: "run-credential",
|
|
166
|
+
backendEndpoint: "http://localhost:7234",
|
|
167
|
+
});
|
|
168
|
+
expect(attachment?.env).toMatchObject({ [STDIO_CREDENTIAL_ENV]: "run-credential" });
|
|
169
|
+
});
|
|
170
|
+
|
|
161
171
|
it("pins the cross-repo strings the mcp-server side guards too", () => {
|
|
162
172
|
expect(CHANNEL_ATTACHMENT_SLUG).toBe("stigmer-channels");
|
|
163
173
|
expect(CHANNELS_ROUTE).toBe("/channels");
|
|
174
|
+
// The mcp-server's startup-credential variable (mcp-server/src/config.ts).
|
|
175
|
+
expect(STDIO_CREDENTIAL_ENV).toBe("STIGMER_API_KEY");
|
|
164
176
|
});
|
|
165
177
|
|
|
166
178
|
it("is approval-free by construction: zero entries in the merged approval map", () => {
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type MemoryCaptureContext,
|
|
31
31
|
} from "../memory-attachment.js";
|
|
32
32
|
import type { ResolvedMcpServer } from "../mcp-resolver.js";
|
|
33
|
+
import { STDIO_CREDENTIAL_ENV } from "../synthesized-attachment.js";
|
|
33
34
|
|
|
34
35
|
const context: MemoryCaptureContext = {
|
|
35
36
|
org: "acme",
|
|
@@ -117,6 +118,22 @@ describe("synthesizeMemoryAttachment", () => {
|
|
|
117
118
|
});
|
|
118
119
|
});
|
|
119
120
|
|
|
121
|
+
it("the stdio child receives the run's credential as its startup bearer — the same credential the bridge shape presents per request", () => {
|
|
122
|
+
const attachment = synthesizeMemoryAttachment(enabled, context, {
|
|
123
|
+
...ossOptions,
|
|
124
|
+
credential: "run-credential",
|
|
125
|
+
});
|
|
126
|
+
expect(attachment?.connectionType).toBe("stdio");
|
|
127
|
+
expect(attachment?.env).toMatchObject({
|
|
128
|
+
[STDIO_CREDENTIAL_ENV]: "run-credential",
|
|
129
|
+
STIGMER_MCP_ROSTER: "memory",
|
|
130
|
+
});
|
|
131
|
+
// The child's environment is byte-for-byte today's when the run carried no credential.
|
|
132
|
+
expect(synthesizeMemoryAttachment(enabled, context, ossOptions)?.env).not.toHaveProperty(
|
|
133
|
+
STDIO_CREDENTIAL_ENV,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
120
137
|
it("omits empty context fields from the carrier — best-effort attribution, never blank entries", () => {
|
|
121
138
|
const partial: MemoryCaptureContext = {
|
|
122
139
|
org: "acme",
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run credential's wire reader and its store (`run-credential.ts`,
|
|
3
|
+
* `run-credential-store.ts`).
|
|
4
|
+
*
|
|
5
|
+
* The property that matters is the last arm: two activities whose awaits
|
|
6
|
+
* interleave in one process each see their own credential and never the
|
|
7
|
+
* other's. That is what lets one shared `StigmerClient` — one per worker for
|
|
8
|
+
* the turn activities — present the right run's credential per request.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
RUN_CREDENTIAL_HEADER,
|
|
15
|
+
RUN_CREDENTIAL_INPUT_KEY,
|
|
16
|
+
readRunCredentialFromInput,
|
|
17
|
+
} from "../run-credential.js";
|
|
18
|
+
import {
|
|
19
|
+
currentRunCredential,
|
|
20
|
+
withRunCredential,
|
|
21
|
+
} from "../run-credential-store.js";
|
|
22
|
+
|
|
23
|
+
describe("readRunCredentialFromInput", () => {
|
|
24
|
+
it("reads a non-empty string under the wire key", () => {
|
|
25
|
+
expect(
|
|
26
|
+
readRunCredentialFromInput({ [RUN_CREDENTIAL_INPUT_KEY]: "cred-1" }),
|
|
27
|
+
).toBe("cred-1");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("answers undefined for the positional string shape the turn activities also accept", () => {
|
|
31
|
+
expect(readRunCredentialFromInput("aex_123")).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("answers undefined for a credential-less object — an older server's dispatch", () => {
|
|
35
|
+
expect(
|
|
36
|
+
readRunCredentialFromInput({ execution_id: "aex_123", thread_id: "" }),
|
|
37
|
+
).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("answers undefined for an empty string, null, undefined and a non-string value", () => {
|
|
41
|
+
expect(
|
|
42
|
+
readRunCredentialFromInput({ [RUN_CREDENTIAL_INPUT_KEY]: "" }),
|
|
43
|
+
).toBeUndefined();
|
|
44
|
+
expect(readRunCredentialFromInput(null)).toBeUndefined();
|
|
45
|
+
expect(readRunCredentialFromInput(undefined)).toBeUndefined();
|
|
46
|
+
expect(
|
|
47
|
+
readRunCredentialFromInput({ [RUN_CREDENTIAL_INPUT_KEY]: 42 }),
|
|
48
|
+
).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does not read the connect lane's camelCase per-call token", () => {
|
|
52
|
+
expect(
|
|
53
|
+
readRunCredentialFromInput({ executionContextToken: "connect-token" }),
|
|
54
|
+
).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("pins the wire vocabulary as bytes", () => {
|
|
58
|
+
expect(RUN_CREDENTIAL_INPUT_KEY).toBe("execution_context_token");
|
|
59
|
+
expect(RUN_CREDENTIAL_HEADER).toBe("stigmer-run-credential");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("run-credential store", () => {
|
|
64
|
+
it("is empty outside any activity", () => {
|
|
65
|
+
expect(currentRunCredential()).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("is visible to everything the wrapped function awaits, and gone after", async () => {
|
|
69
|
+
let inner: string | undefined;
|
|
70
|
+
await withRunCredential("cred-a", async () => {
|
|
71
|
+
await Promise.resolve();
|
|
72
|
+
inner = await (async () => currentRunCredential())();
|
|
73
|
+
});
|
|
74
|
+
expect(inner).toBe("cred-a");
|
|
75
|
+
expect(currentRunCredential()).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("is inherited by a promise created inside the scope and settled after it", async () => {
|
|
79
|
+
let detached: Promise<string | undefined> | undefined;
|
|
80
|
+
await withRunCredential("cred-b", async () => {
|
|
81
|
+
detached = new Promise((resolve) =>
|
|
82
|
+
setTimeout(() => resolve(currentRunCredential()), 5),
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
expect(await detached).toBe("cred-b");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("never crosses between two activities whose awaits interleave", async () => {
|
|
89
|
+
const seen: Array<[string, string | undefined]> = [];
|
|
90
|
+
const activity = async (name: string, ticks: number) => {
|
|
91
|
+
for (let i = 0; i < ticks; i++) {
|
|
92
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
93
|
+
seen.push([name, currentRunCredential()]);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
await Promise.all([
|
|
97
|
+
withRunCredential("cred-1", () => activity("one", 4)),
|
|
98
|
+
withRunCredential("cred-2", () => activity("two", 4)),
|
|
99
|
+
activity("none", 4),
|
|
100
|
+
]);
|
|
101
|
+
expect(seen.filter(([name]) => name === "one").map(([, c]) => c)).toEqual(
|
|
102
|
+
Array(4).fill("cred-1"),
|
|
103
|
+
);
|
|
104
|
+
expect(seen.filter(([name]) => name === "two").map(([, c]) => c)).toEqual(
|
|
105
|
+
Array(4).fill("cred-2"),
|
|
106
|
+
);
|
|
107
|
+
expect(seen.filter(([name]) => name === "none").map(([, c]) => c)).toEqual(
|
|
108
|
+
Array(4).fill(undefined),
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* then the control planes; once both are everywhere, drop the positional branch
|
|
16
16
|
* (and the `string` arms below).
|
|
17
17
|
*/
|
|
18
|
+
|
|
19
|
+
import type { RUN_CREDENTIAL_INPUT_KEY } from "./run-credential.js";
|
|
20
|
+
|
|
18
21
|
export interface ExecuteActivityInput {
|
|
19
22
|
readonly execution_id: string;
|
|
20
23
|
readonly thread_id: string;
|
|
@@ -27,8 +30,26 @@ export interface ExecuteActivityInput {
|
|
|
27
30
|
* from it. Absent on the legacy positional wire shape (defaults to 0).
|
|
28
31
|
*/
|
|
29
32
|
readonly turn_seq?: number;
|
|
33
|
+
/**
|
|
34
|
+
* The run credential the server minted for this execution at dispatch,
|
|
35
|
+
* handed on from the workflow input; absent when the dispatch carried none
|
|
36
|
+
* (an older server; the cloud). Not normalized here and never read by an
|
|
37
|
+
* activity: the activity boundary enters it into the run-credential store
|
|
38
|
+
* before the activity runs (`interceptors/run-credential-activity.ts`), and
|
|
39
|
+
* the control-plane client presents it per request (`run-credential.ts`).
|
|
40
|
+
*/
|
|
41
|
+
readonly execution_context_token?: string;
|
|
30
42
|
}
|
|
31
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Compile-time fence: the key the boundary reads
|
|
46
|
+
* (`RUN_CREDENTIAL_INPUT_KEY`) is a key of this input. The type keeps the
|
|
47
|
+
* literal so the wire shape stays readable; this line fails to type-check the
|
|
48
|
+
* day the two disagree.
|
|
49
|
+
*/
|
|
50
|
+
type _RunCredentialKeyIsOnTheWire =
|
|
51
|
+
ExecuteActivityInput[typeof RUN_CREDENTIAL_INPUT_KEY];
|
|
52
|
+
|
|
32
53
|
export interface NormalizedActivityInput {
|
|
33
54
|
readonly executionId: string;
|
|
34
55
|
readonly threadId: string;
|
|
@@ -11,13 +11,16 @@
|
|
|
11
11
|
* model composes template sends in context without spending a tool
|
|
12
12
|
* round (DD-003 D5).
|
|
13
13
|
*
|
|
14
|
-
* Two connection shapes, one roster (the records pattern)
|
|
14
|
+
* Two connection shapes, one roster (the records pattern), one
|
|
15
|
+
* credential — the run's own — presented on both:
|
|
15
16
|
* - Bridge endpoint configured (cloud): Streamable HTTP against the
|
|
16
|
-
* bridge's /channels route with the
|
|
17
|
-
*
|
|
17
|
+
* bridge's /channels route with the run's credential as the Bearer
|
|
18
|
+
* token.
|
|
18
19
|
* - No bridge endpoint (OSS/local): a spawned `stigmer mcp-server`
|
|
19
|
-
* stdio child with STIGMER_MCP_ROSTER=channels
|
|
20
|
-
*
|
|
20
|
+
* stdio child with STIGMER_MCP_ROSTER=channels and the run's
|
|
21
|
+
* credential as its startup credential (synthesized-attachment.ts
|
|
22
|
+
* `stdioCredentialEnv`, the memory attachment's twin). In practice
|
|
23
|
+
* OSS answers the discovery read with an empty list (DD-006 D3), so
|
|
21
24
|
* this shape only serves local deployments that grow a messaging
|
|
22
25
|
* runtime later — it exists for symmetry with the deployment
|
|
23
26
|
* topology, not for a live OSS path today.
|
|
@@ -42,7 +45,11 @@ import type {
|
|
|
42
45
|
} from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
|
|
43
46
|
import type { StigmerClient } from "../client/stigmer-client.js";
|
|
44
47
|
import type { ResolvedMcpServer } from "./mcp-resolver.js";
|
|
45
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
grpcTarget,
|
|
50
|
+
stdioCredentialEnv,
|
|
51
|
+
type SynthesizedAttachmentOptions,
|
|
52
|
+
} from "./synthesized-attachment.js";
|
|
46
53
|
|
|
47
54
|
/**
|
|
48
55
|
* The synthesized attachment's slug. Reserved: a user McpServer with
|
|
@@ -151,6 +158,7 @@ export function synthesizeChannelAttachment(
|
|
|
151
158
|
env: {
|
|
152
159
|
STIGMER_MCP_ROSTER: "channels",
|
|
153
160
|
STIGMER_SERVER_ADDRESS: grpcTarget(options.backendEndpoint),
|
|
161
|
+
...stdioCredentialEnv(options.credential),
|
|
154
162
|
},
|
|
155
163
|
};
|
|
156
164
|
}
|
|
@@ -24,6 +24,19 @@
|
|
|
24
24
|
* plus a `changed` flag, so the hybrid can merge both slices even when only one
|
|
25
25
|
* moved (a `ProgressDelta | undefined` would drop the unchanged slice).
|
|
26
26
|
*
|
|
27
|
+
* WHAT A CAPTURE MAY SEE
|
|
28
|
+
* ----------------------
|
|
29
|
+
* A capture rides a persist, and the persist that a tool's start dirtied runs
|
|
30
|
+
* while that tool may still be writing: the engine does not wait for the
|
|
31
|
+
* runtime's write to land before it runs the tool. So one snapshot can precede
|
|
32
|
+
* a tool's write (a turn behind), straddle it (the path present, the counts of
|
|
33
|
+
* a half-written file), or follow it (the next tool's file already there).
|
|
34
|
+
* That is accepted, not fixed: the strip is a latest-wins display field and the
|
|
35
|
+
* next capture converges it, while capturing only between tools would take the
|
|
36
|
+
* strip's liveness away during exactly the long-running tools it exists for.
|
|
37
|
+
* A consumer — a console, a test — reads the strip for what it converges to
|
|
38
|
+
* while the set is CAPTURING, never for what one capture happened to show.
|
|
39
|
+
*
|
|
27
40
|
* SECRET SAFETY
|
|
28
41
|
* -------------
|
|
29
42
|
* No file bodies are ever carried. A secret-like path ({@link isSecretLikePath})
|
|
@@ -12,22 +12,29 @@
|
|
|
12
12
|
* ZERO facts is a meaningful state: memory is on, nothing stored yet —
|
|
13
13
|
* the tool is offered so the first fact can be proposed.
|
|
14
14
|
*
|
|
15
|
-
* Two connection shapes, one roster (the channels pattern)
|
|
15
|
+
* Two connection shapes, one roster (the channels pattern), one
|
|
16
|
+
* credential — the run's own — presented on both:
|
|
16
17
|
* - Bridge endpoint configured (cloud): Streamable HTTP against the
|
|
17
|
-
* bridge's /memory route with the
|
|
18
|
-
*
|
|
19
|
-
* per-request headers.
|
|
18
|
+
* bridge's /memory route with the run's credential as the Bearer
|
|
19
|
+
* token, plus the capture context as per-request headers.
|
|
20
20
|
* - No bridge endpoint (OSS/local): a spawned `stigmer mcp-server`
|
|
21
|
-
* stdio child with STIGMER_MCP_ROSTER=memory,
|
|
22
|
-
*
|
|
21
|
+
* stdio child with STIGMER_MCP_ROSTER=memory, the run's credential
|
|
22
|
+
* as its startup credential (synthesized-attachment.ts
|
|
23
|
+
* `stdioCredentialEnv`), plus the capture context as
|
|
24
|
+
* STIGMER_MEMORY_* env. Without the credential a server that signs
|
|
25
|
+
* people in refuses every `remember` as nobody's (stigmer#1147);
|
|
26
|
+
* with it the memory is the run's person's, which the server derives
|
|
27
|
+
* from the credential and nothing the child says.
|
|
23
28
|
*
|
|
24
29
|
* The capture context (org + agent/session/execution ids) is
|
|
25
30
|
* attribution, never authorization (the Stage 3 provenance decision,
|
|
26
31
|
* owner-ratified 2026-08-22): the cloud create handler accepts it only
|
|
27
32
|
* from a session-sandbox credential and overrides session/org with the
|
|
28
|
-
* token's own claims; the OSS server
|
|
29
|
-
*
|
|
30
|
-
*
|
|
33
|
+
* token's own claims; the OSS server under sign-in admits it from the
|
|
34
|
+
* run's credential and proves the session off the run's row, and stores
|
|
35
|
+
* it as given under the local single-user trust model. The subject is
|
|
36
|
+
* never threaded — the server derives it from the credential (DD-005
|
|
37
|
+
* D2).
|
|
31
38
|
*
|
|
32
39
|
* Approval-free by construction (the synthesized-attachment contract):
|
|
33
40
|
* the tool only ever creates a PROPOSAL the user must confirm through
|
|
@@ -44,7 +51,11 @@
|
|
|
44
51
|
|
|
45
52
|
import type { RecalledMemories } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
|
|
46
53
|
import type { ResolvedMcpServer } from "./mcp-resolver.js";
|
|
47
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
grpcTarget,
|
|
56
|
+
stdioCredentialEnv,
|
|
57
|
+
type SynthesizedAttachmentOptions,
|
|
58
|
+
} from "./synthesized-attachment.js";
|
|
48
59
|
|
|
49
60
|
/**
|
|
50
61
|
* The synthesized attachment's slug. Reserved: a user McpServer with
|
|
@@ -149,6 +160,7 @@ export function synthesizeMemoryAttachment(
|
|
|
149
160
|
env: {
|
|
150
161
|
STIGMER_MCP_ROSTER: "memory",
|
|
151
162
|
STIGMER_SERVER_ADDRESS: grpcTarget(options.backendEndpoint),
|
|
163
|
+
...stdioCredentialEnv(options.credential),
|
|
152
164
|
...nonEmptyEntries([
|
|
153
165
|
[MEMORY_ORG_ENV, context.org],
|
|
154
166
|
[MEMORY_AGENT_ID_ENV, context.agentId],
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run credential of the activity that is running — an `AsyncLocalStorage`
|
|
3
|
+
* the activity boundary enters once and the control-plane transport reads
|
|
4
|
+
* per request.
|
|
5
|
+
*
|
|
6
|
+
* Why a store and not a parameter: the runner serves many executions in one
|
|
7
|
+
* process, and the `StigmerClient` a request goes out on is shared — one per
|
|
8
|
+
* worker for the turn activities (`harness/registry.ts`), one per call or a
|
|
9
|
+
* module singleton for the engine's activities. A credential baked into a
|
|
10
|
+
* client would be the wrong run's the moment two runs share it; a credential
|
|
11
|
+
* read from the async context of the request is always the run whose
|
|
12
|
+
* activity made the request, and a client cached across runs stays correct.
|
|
13
|
+
* `shared/execution-context.ts` is the same shape for the execution id, with
|
|
14
|
+
* a different owner (the turn runtime enters it INSIDE the turn) and a
|
|
15
|
+
* different reader (the Cursor proxy interceptors) — two facts, two stores.
|
|
16
|
+
*
|
|
17
|
+
* Exactly one writer — `interceptors/run-credential-activity.ts`, the
|
|
18
|
+
* activity inbound interceptor both worker roots register — and exactly one
|
|
19
|
+
* reader — the auth interceptor in `client/stigmer-client.ts`. Nothing else
|
|
20
|
+
* may read this: a user task's outbound HTTP (`activities/call-http.ts`) must
|
|
21
|
+
* never carry the run's credential, and the way that stays true is that no
|
|
22
|
+
* code path but the control-plane client asks for it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
26
|
+
|
|
27
|
+
const runCredential = new AsyncLocalStorage<string>();
|
|
28
|
+
|
|
29
|
+
/** Run `fn` with `credential` as the run credential of everything it awaits. */
|
|
30
|
+
export function withRunCredential<T>(
|
|
31
|
+
credential: string,
|
|
32
|
+
fn: () => Promise<T>,
|
|
33
|
+
): Promise<T> {
|
|
34
|
+
return runCredential.run(credential, fn);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The run credential of the activity this request belongs to; `undefined`
|
|
39
|
+
* outside any activity (boot, renewal loops) and inside an activity whose
|
|
40
|
+
* dispatch carried none (an older server; the cloud, whose runner credential
|
|
41
|
+
* is provisioned, not dispatched).
|
|
42
|
+
*/
|
|
43
|
+
export function currentRunCredential(): string | undefined {
|
|
44
|
+
return runCredential.getStore();
|
|
45
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run credential's wire vocabulary — the two places a dispatch can carry
|
|
3
|
+
* it, and the one reader of both.
|
|
4
|
+
*
|
|
5
|
+
* A run credential is the token the control plane mints for ONE execution at
|
|
6
|
+
* dispatch (`execution_context_token` on the workflow input, 2026-09-16) so
|
|
7
|
+
* that the runner — one long-lived process keyed with one operator's API key,
|
|
8
|
+
* serving every member's runs — can act as the run's own human on that run's
|
|
9
|
+
* RPCs. Under a server with sign-in on, the credential's bearer IS the run's
|
|
10
|
+
* person for as long as the run lives; on an older server no credential
|
|
11
|
+
* arrives and the runner behaves exactly as before. The runner never decodes
|
|
12
|
+
* or judges the credential: it presents, the server judges
|
|
13
|
+
* (`client/token-claims.ts`).
|
|
14
|
+
*
|
|
15
|
+
* Two hops carry it, one channel each:
|
|
16
|
+
*
|
|
17
|
+
* - The SERVER's dispatches — the Execute* turn activities and
|
|
18
|
+
* GenerateSessionSubject — carry it on the activity INPUT under
|
|
19
|
+
* {@link RUN_CREDENTIAL_INPUT_KEY}, the same key the workflow inputs use
|
|
20
|
+
* (a cross-component wire contract; the server's
|
|
21
|
+
* temporal/agentexecution/workflows/invoke-agent-execution.ts builds it).
|
|
22
|
+
* - The RUNNER's own dispatches — every activity the serverless-workflow
|
|
23
|
+
* engine schedules from `workflows/execute-from-execution.ts` — carry it
|
|
24
|
+
* as a Temporal activity HEADER, {@link RUN_CREDENTIAL_HEADER}, stamped by
|
|
25
|
+
* the workflow interceptor in `workflows/interceptors/run-credential.ts`.
|
|
26
|
+
* Headers, not arguments: the engine hands `execution_id` to ten local
|
|
27
|
+
* activities positionally, and a credential threaded the same way is a
|
|
28
|
+
* field every future activity has to remember. A header reaches all of
|
|
29
|
+
* them, and the next one, with no signature learning the word.
|
|
30
|
+
*
|
|
31
|
+
* One reader for both: `interceptors/run-credential-activity.ts` enters the
|
|
32
|
+
* store (`run-credential-store.ts`) at the activity boundary, and
|
|
33
|
+
* `client/stigmer-client.ts` reads it per request. No activity body touches
|
|
34
|
+
* a credential.
|
|
35
|
+
*
|
|
36
|
+
* ISOLATE-SAFE BY CONTRACT: this module is imported by workflow code that
|
|
37
|
+
* runs inside Temporal's deterministic V8 sandbox. It must import nothing
|
|
38
|
+
* from Node and hold no mutable state; the store lives in its own module for
|
|
39
|
+
* exactly that reason.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The snake_case key under which a dispatch's input object carries the run
|
|
44
|
+
* credential. Byte-pinned: the same name on both invoke workflow inputs, on
|
|
45
|
+
* `ExecuteActivityInput`, on `GenerateSessionSubjectInput` and on the connect
|
|
46
|
+
* lane's input (where it names a DIFFERENT, clocked token that this reader
|
|
47
|
+
* never sees — that lane hands its token per call).
|
|
48
|
+
*/
|
|
49
|
+
export const RUN_CREDENTIAL_INPUT_KEY = "execution_context_token" as const;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The Temporal activity header the runner's own workflow stamps on every
|
|
53
|
+
* activity it schedules for a run. Byte-pinned wire vocabulary: a rename
|
|
54
|
+
* strands every in-flight run's activities on the process credential.
|
|
55
|
+
*/
|
|
56
|
+
export const RUN_CREDENTIAL_HEADER = "stigmer-run-credential" as const;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The run credential an activity input carries, or `undefined` when the
|
|
60
|
+
* argument is not an object carrying a non-empty string under the key —
|
|
61
|
+
* positional string arguments, the connect lane's camelCase input, and an
|
|
62
|
+
* older server's credential-less object all answer `undefined`.
|
|
63
|
+
*/
|
|
64
|
+
export function readRunCredentialFromInput(arg: unknown): string | undefined {
|
|
65
|
+
if (arg === null || typeof arg !== "object") {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const value = (arg as Record<string, unknown>)[RUN_CREDENTIAL_INPUT_KEY];
|
|
69
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
70
|
+
}
|
|
@@ -28,9 +28,14 @@ export interface SynthesizedAttachmentOptions {
|
|
|
28
28
|
*/
|
|
29
29
|
bridgeEndpoint: string | null;
|
|
30
30
|
/**
|
|
31
|
-
* The
|
|
32
|
-
*
|
|
33
|
-
* token
|
|
31
|
+
* The credential of the RUN this attachment serves — the run's own
|
|
32
|
+
* credential the server minted at dispatch (the OSS shape), or the
|
|
33
|
+
* session-scoped sandbox token a cloud runner holds. Both connection
|
|
34
|
+
* shapes present it: as the Bearer on the bridge's per-request path,
|
|
35
|
+
* and as the stdio child's startup credential (`stdioCredentialEnv`),
|
|
36
|
+
* so what the child writes on the person's behalf is stamped as that
|
|
37
|
+
* person and not refused as nobody's on a server that signs people in.
|
|
38
|
+
* Null presents nothing (a run whose dispatch carried no credential).
|
|
34
39
|
*/
|
|
35
40
|
credential: string | null;
|
|
36
41
|
/**
|
|
@@ -40,6 +45,36 @@ export interface SynthesizedAttachmentOptions {
|
|
|
40
45
|
backendEndpoint: string;
|
|
41
46
|
}
|
|
42
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The mcp-server's startup-credential variable — the ONE name the stdio
|
|
50
|
+
* child reads its bearer from (mcp-server/src/config.ts `STIGMER_API_KEY`;
|
|
51
|
+
* the value is any bearer the server honours, an `stk_` key for an IDE
|
|
52
|
+
* client, the run's credential here). Cross-repo string, pinned on both
|
|
53
|
+
* sides (the TOOL_CALL_LIMIT precedent).
|
|
54
|
+
*/
|
|
55
|
+
export const STDIO_CREDENTIAL_ENV = "STIGMER_API_KEY";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The stdio child's startup-credential entry: `{ STIGMER_API_KEY }` when the
|
|
59
|
+
* attachment holds a credential, nothing when it holds none — so a
|
|
60
|
+
* credential-less run's child environment is byte-for-byte today's, and
|
|
61
|
+
* the two stdio shapes cannot disagree on the carrier.
|
|
62
|
+
*
|
|
63
|
+
* The exposure, stated once: the credential sits in a first-party child's
|
|
64
|
+
* process environment on the runner host, readable by the same OS user.
|
|
65
|
+
* That is the runner's own trust boundary (the runner process holds the
|
|
66
|
+
* same credential), and the child is `stigmer mcp-server`, never a
|
|
67
|
+
* third-party server — those receive only their declared env (oss#256,
|
|
68
|
+
* mcp-manager.ts). The alternative was the operator's API key.
|
|
69
|
+
*/
|
|
70
|
+
export function stdioCredentialEnv(
|
|
71
|
+
credential: string | null,
|
|
72
|
+
): Record<string, string> {
|
|
73
|
+
return credential !== null && credential !== ""
|
|
74
|
+
? { [STDIO_CREDENTIAL_ENV]: credential }
|
|
75
|
+
: {};
|
|
76
|
+
}
|
|
77
|
+
|
|
43
78
|
/**
|
|
44
79
|
* Inject a synthesized attachment into a resolved server list — AFTER
|
|
45
80
|
* resolve + backfill (see the module header). A user server shadowing
|
package/src/worker.ts
CHANGED
|
@@ -18,7 +18,12 @@ import { createRequire } from "node:module";
|
|
|
18
18
|
import { NativeConnection, Worker, type ActivityInterceptorsFactory, type InjectedSinks } from "@temporalio/worker";
|
|
19
19
|
import type { PayloadCodec } from "@temporalio/common";
|
|
20
20
|
import type { Config } from "./config.js";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
resolveWorkflowSource,
|
|
23
|
+
runCredentialWorkflowInterceptorModule,
|
|
24
|
+
OTEL_WORKFLOW_INTERCEPTOR_MODULE,
|
|
25
|
+
} from "./workflow-source.js";
|
|
26
|
+
import { runCredentialActivityInterceptor } from "./interceptors/run-credential-activity.js";
|
|
22
27
|
|
|
23
28
|
export interface WorkerActivities {
|
|
24
29
|
[key: string]: (...args: any[]) => Promise<unknown>;
|
|
@@ -42,9 +47,16 @@ export async function startWorker(opts: StartWorkerOptions): Promise<Worker> {
|
|
|
42
47
|
|
|
43
48
|
const workflowSource = resolveWorkflowSource();
|
|
44
49
|
|
|
45
|
-
|
|
50
|
+
// The run credential's two ends (shared/run-credential.ts): the workflow
|
|
51
|
+
// side lifts a run's credential onto every activity it schedules, the
|
|
52
|
+
// activity side enters it for the client to present. Always on; inert
|
|
53
|
+
// without a credential. Workflow-side modules must live INSIDE the
|
|
54
|
+
// workflow bundle — a pre-built bundle has it baked in
|
|
55
|
+
// (scripts/bundle-slim.mjs); only the runtime path registers it here.
|
|
56
|
+
const activityInterceptors: ActivityInterceptorsFactory[] = [runCredentialActivityInterceptor];
|
|
46
57
|
let sinks: InjectedSinks<any> = { ...createWorkflowMetricsSinks() };
|
|
47
|
-
const workflowInterceptorModules: string[] =
|
|
58
|
+
const workflowInterceptorModules: string[] =
|
|
59
|
+
workflowSource.kind === "runtime" ? [runCredentialWorkflowInterceptorModule()] : [];
|
|
48
60
|
|
|
49
61
|
if (process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
|
|
50
62
|
const {
|
package/src/workflow-source.ts
CHANGED
|
@@ -44,6 +44,21 @@ export type WorkflowSource =
|
|
|
44
44
|
export const OTEL_WORKFLOW_INTERCEPTOR_MODULE =
|
|
45
45
|
"@temporalio/interceptors-opentelemetry/lib/workflow-interceptors";
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Path of the runner's own workflow interceptor — the one that carries a
|
|
49
|
+
* run's credential from the workflow input to every activity it schedules
|
|
50
|
+
* (`workflows/interceptors/run-credential.ts`). Always registered, unlike
|
|
51
|
+
* the OTel module: it is inert without a credential and load-bearing with
|
|
52
|
+
* one. Resolved beside this module so the tsc dist and the slim artifact
|
|
53
|
+
* both find their own compiled copy; the build script names the source
|
|
54
|
+
* file itself.
|
|
55
|
+
*/
|
|
56
|
+
export function runCredentialWorkflowInterceptorModule(): string {
|
|
57
|
+
return fileURLToPath(
|
|
58
|
+
new URL("./workflows/interceptors/run-credential.js", import.meta.url),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
export function resolveWorkflowSource(): WorkflowSource {
|
|
48
63
|
const explicit = process.env.STIGMER_WORKFLOW_BUNDLE;
|
|
49
64
|
if (explicit) {
|