@stigmer/runner 3.15.3-dev.20260916211208 → 3.16.1-dev.20260917224426

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +1 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/generate-session-subject.d.ts +25 -4
  4. package/dist/activities/generate-session-subject.js +17 -7
  5. package/dist/activities/generate-session-subject.js.map +1 -1
  6. package/dist/client/stigmer-client.d.ts +17 -6
  7. package/dist/client/stigmer-client.js +47 -14
  8. package/dist/client/stigmer-client.js.map +1 -1
  9. package/dist/config.d.ts +4 -1
  10. package/dist/config.js +4 -1
  11. package/dist/config.js.map +1 -1
  12. package/dist/interceptors/run-credential-activity.d.ts +28 -0
  13. package/dist/interceptors/run-credential-activity.js +56 -0
  14. package/dist/interceptors/run-credential-activity.js.map +1 -0
  15. package/dist/runner-manager.js +9 -3
  16. package/dist/runner-manager.js.map +1 -1
  17. package/dist/shared/activity-input.d.ts +9 -0
  18. package/dist/shared/activity-input.js +17 -0
  19. package/dist/shared/activity-input.js.map +1 -1
  20. package/dist/shared/channel-attachment.d.ts +8 -5
  21. package/dist/shared/channel-attachment.js +10 -6
  22. package/dist/shared/channel-attachment.js.map +1 -1
  23. package/dist/shared/filereview/progress.d.ts +13 -0
  24. package/dist/shared/filereview/progress.js +13 -0
  25. package/dist/shared/filereview/progress.js.map +1 -1
  26. package/dist/shared/memory-attachment.d.ts +16 -9
  27. package/dist/shared/memory-attachment.js +18 -10
  28. package/dist/shared/memory-attachment.js.map +1 -1
  29. package/dist/shared/run-credential-store.d.ts +32 -0
  30. package/dist/shared/run-credential-store.js +39 -0
  31. package/dist/shared/run-credential-store.js.map +1 -0
  32. package/dist/shared/run-credential.d.ts +61 -0
  33. package/dist/shared/run-credential.js +68 -0
  34. package/dist/shared/run-credential.js.map +1 -0
  35. package/dist/shared/synthesized-attachment.d.ts +30 -3
  36. package/dist/shared/synthesized-attachment.js +26 -0
  37. package/dist/shared/synthesized-attachment.js.map +1 -1
  38. package/dist/worker.js +10 -3
  39. package/dist/worker.js.map +1 -1
  40. package/dist/workflow-source.d.ts +10 -0
  41. package/dist/workflow-source.js +12 -0
  42. package/dist/workflow-source.js.map +1 -1
  43. package/dist/workflows/execute-from-execution.d.ts +15 -0
  44. package/dist/workflows/execute-from-execution.js +11 -0
  45. package/dist/workflows/execute-from-execution.js.map +1 -1
  46. package/dist/workflows/index.d.ts +5 -3
  47. package/dist/workflows/index.js +5 -3
  48. package/dist/workflows/index.js.map +1 -1
  49. package/dist/workflows/interceptors/run-credential.d.ts +34 -0
  50. package/dist/workflows/interceptors/run-credential.js +71 -0
  51. package/dist/workflows/interceptors/run-credential.js.map +1 -0
  52. package/package.json +4 -4
  53. package/src/__tests__/run-credential-e2e.test.ts +233 -0
  54. package/src/__tests__/run-credential-registration.test.ts +80 -0
  55. package/src/activities/__tests__/generate-session-subject-wire.test.ts +119 -0
  56. package/src/activities/generate-session-subject.ts +29 -4
  57. package/src/client/__tests__/stigmer-client.test.ts +92 -0
  58. package/src/client/stigmer-client.ts +48 -14
  59. package/src/config.ts +4 -1
  60. package/src/interceptors/__tests__/run-credential-activity.test.ts +133 -0
  61. package/src/interceptors/run-credential-activity.ts +67 -0
  62. package/src/runner-manager.ts +13 -3
  63. package/src/shared/__tests__/channel-attachment.test.ts +12 -0
  64. package/src/shared/__tests__/memory-attachment.test.ts +17 -0
  65. package/src/shared/__tests__/run-credential.test.ts +111 -0
  66. package/src/shared/activity-input.ts +21 -0
  67. package/src/shared/channel-attachment.ts +14 -6
  68. package/src/shared/filereview/progress.ts +13 -0
  69. package/src/shared/memory-attachment.ts +22 -10
  70. package/src/shared/run-credential-store.ts +45 -0
  71. package/src/shared/run-credential.ts +70 -0
  72. package/src/shared/synthesized-attachment.ts +38 -3
  73. package/src/worker.ts +15 -3
  74. package/src/workflow-source.ts +15 -0
  75. package/src/workflows/__tests__/run-credential-interceptor.test.ts +178 -0
  76. package/src/workflows/execute-from-execution.ts +17 -0
  77. package/src/workflows/index.ts +5 -3
  78. 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
+ }
@@ -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 { resolveWorkflowSource, OTEL_WORKFLOW_INTERCEPTOR_MODULE } from "./workflow-source.js";
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
- const activityInterceptors: ActivityInterceptorsFactory[] = [];
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 execution's own session-scoped
17
- * credential as the Bearer token.
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. In practice OSS
20
- * answers the discovery read with an empty list (DD-006 D3), so
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 { grpcTarget, type SynthesizedAttachmentOptions } from "./synthesized-attachment.js";
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 execution's own session-scoped
18
- * credential as the Bearer token, plus the capture context as
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, plus the capture
22
- * context as STIGMER_MEMORY_* env.
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 stores it under the local
29
- * single-user trust model. The subject is never threaded — the server
30
- * derives it from the credential (DD-005 D2).
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 { grpcTarget, type SynthesizedAttachmentOptions } from "./synthesized-attachment.js";
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 execution's session-scoped credential (the sandbox token a
32
- * cloud runner holds, or the desktop runner's exchanged scoped
33
- * token). Null attaches no Authorization header (OSS/local).
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 { resolveWorkflowSource, OTEL_WORKFLOW_INTERCEPTOR_MODULE } from "./workflow-source.js";
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
- const activityInterceptors: ActivityInterceptorsFactory[] = [];
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 {
@@ -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) {