@stigmer/runner 3.11.0 → 3.12.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.
Files changed (239) hide show
  1. package/README.md +2 -0
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/discover-mcp-server.d.ts +7 -0
  4. package/dist/activities/discover-mcp-server.js +6 -4
  5. package/dist/activities/discover-mcp-server.js.map +1 -1
  6. package/dist/activities/emit-event.d.ts +14 -2
  7. package/dist/activities/emit-event.js +52 -17
  8. package/dist/activities/emit-event.js.map +1 -1
  9. package/dist/activities/execute-cursor/attachment-resolver.d.ts +11 -0
  10. package/dist/activities/execute-cursor/attachment-resolver.js +8 -0
  11. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  12. package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
  13. package/dist/activities/execute-cursor/blueprint-resolver.js +0 -1
  14. package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
  15. package/dist/activities/execute-cursor/error-classifier.d.ts +19 -0
  16. package/dist/activities/execute-cursor/error-classifier.js +34 -3
  17. package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
  18. package/dist/activities/execute-cursor/index.d.ts +52 -12
  19. package/dist/activities/execute-cursor/index.js +141 -67
  20. package/dist/activities/execute-cursor/index.js.map +1 -1
  21. package/dist/activities/execute-cursor/model-pricing-data.d.ts +2 -0
  22. package/dist/activities/execute-cursor/model-pricing-data.js +13 -3
  23. package/dist/activities/execute-cursor/model-pricing-data.js.map +1 -1
  24. package/dist/activities/execute-cursor/prompt-builder.d.ts +48 -11
  25. package/dist/activities/execute-cursor/prompt-builder.js +57 -10
  26. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  27. package/dist/activities/execute-cursor/turn-recovery.d.ts +52 -0
  28. package/dist/activities/execute-cursor/turn-recovery.js +193 -0
  29. package/dist/activities/execute-cursor/turn-recovery.js.map +1 -0
  30. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +11 -0
  31. package/dist/activities/execute-deep-agent/attachment-injector.js +7 -1
  32. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/index.js +0 -15
  34. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  35. package/dist/activities/execute-deep-agent/mcp-gate.d.ts +0 -2
  36. package/dist/activities/execute-deep-agent/mcp-gate.js +0 -1
  37. package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -1
  38. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -8
  39. package/dist/activities/execute-deep-agent/prompt-builder.js +14 -7
  40. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  41. package/dist/activities/execute-deep-agent/setup.d.ts +0 -10
  42. package/dist/activities/execute-deep-agent/setup.js +22 -49
  43. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  44. package/dist/activities/execute-deep-agent/shell-env.d.ts +5 -1
  45. package/dist/activities/execute-deep-agent/shell-env.js +7 -9
  46. package/dist/activities/execute-deep-agent/shell-env.js.map +1 -1
  47. package/dist/activities/execute-deep-agent/subagent-transformer.js +7 -1
  48. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  49. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +17 -3
  50. package/dist/activities/execute-deep-agent/subagent-wiring.js +14 -2
  51. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  52. package/dist/activities/hydrate-workflow-execution.js +8 -5
  53. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  54. package/dist/activities/run-command.d.ts +5 -2
  55. package/dist/activities/run-command.js +22 -10
  56. package/dist/activities/run-command.js.map +1 -1
  57. package/dist/activities/run-env.d.ts +40 -0
  58. package/dist/activities/run-env.js +66 -0
  59. package/dist/activities/run-env.js.map +1 -0
  60. package/dist/bootstrap.js +5 -0
  61. package/dist/bootstrap.js.map +1 -1
  62. package/dist/client/stigmer-client.d.ts +83 -26
  63. package/dist/client/stigmer-client.js +88 -24
  64. package/dist/client/stigmer-client.js.map +1 -1
  65. package/dist/config.d.ts +6 -5
  66. package/dist/config.js.map +1 -1
  67. package/dist/encryption/config.d.ts +41 -9
  68. package/dist/encryption/config.js +63 -23
  69. package/dist/encryption/config.js.map +1 -1
  70. package/dist/middleware/index.d.ts +3 -0
  71. package/dist/middleware/index.js +7 -0
  72. package/dist/middleware/index.js.map +1 -1
  73. package/dist/middleware/path-normalization.d.ts +57 -0
  74. package/dist/middleware/path-normalization.js +109 -0
  75. package/dist/middleware/path-normalization.js.map +1 -0
  76. package/dist/middleware/types.d.ts +8 -0
  77. package/dist/payload-codecs.d.ts +2 -1
  78. package/dist/payload-codecs.js +7 -3
  79. package/dist/payload-codecs.js.map +1 -1
  80. package/dist/runner-manager.js +17 -3
  81. package/dist/runner-manager.js.map +1 -1
  82. package/dist/runner.js +7 -3
  83. package/dist/runner.js.map +1 -1
  84. package/dist/shared/artifact-storage.d.ts +34 -1
  85. package/dist/shared/artifact-storage.js +53 -10
  86. package/dist/shared/artifact-storage.js.map +1 -1
  87. package/dist/shared/attachment-download-urls.d.ts +62 -0
  88. package/dist/shared/attachment-download-urls.js +79 -0
  89. package/dist/shared/attachment-download-urls.js.map +1 -0
  90. package/dist/shared/channel-attachment.d.ts +1 -1
  91. package/dist/shared/channel-attachment.js +1 -1
  92. package/dist/shared/checkpointer/http-saver.d.ts +4 -20
  93. package/dist/shared/checkpointer/http-saver.js +15 -57
  94. package/dist/shared/checkpointer/http-saver.js.map +1 -1
  95. package/dist/shared/conversation-attachment.d.ts +2 -3
  96. package/dist/shared/conversation-attachment.js +2 -3
  97. package/dist/shared/conversation-attachment.js.map +1 -1
  98. package/dist/shared/conversation-catchup.d.ts +9 -2
  99. package/dist/shared/conversation-catchup.js +39 -6
  100. package/dist/shared/conversation-catchup.js.map +1 -1
  101. package/dist/shared/grpc-retry.d.ts +5 -1
  102. package/dist/shared/grpc-retry.js +5 -1
  103. package/dist/shared/grpc-retry.js.map +1 -1
  104. package/dist/shared/http-retry.d.ts +51 -9
  105. package/dist/shared/http-retry.js +60 -9
  106. package/dist/shared/http-retry.js.map +1 -1
  107. package/dist/shared/mcp-manager.d.ts +8 -4
  108. package/dist/shared/mcp-manager.js +30 -4
  109. package/dist/shared/mcp-manager.js.map +1 -1
  110. package/dist/shared/mcp-schema-sanitizer.d.ts +70 -0
  111. package/dist/shared/mcp-schema-sanitizer.js +197 -0
  112. package/dist/shared/mcp-schema-sanitizer.js.map +1 -0
  113. package/dist/shared/mcp-transport-guard.d.ts +2 -2
  114. package/dist/shared/mcp-transport-guard.js +2 -2
  115. package/dist/shared/model-client.d.ts +6 -0
  116. package/dist/shared/model-client.js +14 -3
  117. package/dist/shared/model-client.js.map +1 -1
  118. package/dist/shared/model-pricing-data.d.ts +2 -0
  119. package/dist/shared/model-pricing-data.js +13 -3
  120. package/dist/shared/model-pricing-data.js.map +1 -1
  121. package/dist/shared/model-registry.js +3 -2
  122. package/dist/shared/model-registry.js.map +1 -1
  123. package/dist/shared/plan-mode-permissions.d.ts +7 -0
  124. package/dist/shared/plan-mode-permissions.js +7 -0
  125. package/dist/shared/plan-mode-permissions.js.map +1 -1
  126. package/dist/shared/registry-endpoint.d.ts +20 -0
  127. package/dist/shared/registry-endpoint.js +25 -0
  128. package/dist/shared/registry-endpoint.js.map +1 -1
  129. package/dist/shared/runner-credential-keys.d.ts +24 -0
  130. package/dist/shared/runner-credential-keys.js +47 -0
  131. package/dist/shared/runner-credential-keys.js.map +1 -0
  132. package/dist/shared/synthesized-attachment.d.ts +4 -4
  133. package/dist/shared/synthesized-attachment.js +4 -4
  134. package/dist/workflow-engine/resolve.d.ts +19 -1
  135. package/dist/workflow-engine/resolve.js +37 -2
  136. package/dist/workflow-engine/resolve.js.map +1 -1
  137. package/dist/workflows/connect-mcp-server.js +2 -0
  138. package/dist/workflows/connect-mcp-server.js.map +1 -1
  139. package/dist/workflows/types.d.ts +8 -0
  140. package/package.json +3 -4
  141. package/src/__test-utils__/__tests__/vitest-global-setup.test.ts +37 -0
  142. package/src/__test-utils__/fake-artifact-storage.ts +10 -1
  143. package/src/__test-utils__/vitest-global-setup.ts +50 -0
  144. package/src/__tests__/bootstrap.test.ts +43 -0
  145. package/src/__tests__/encryption-codec.test.ts +53 -0
  146. package/src/__tests__/preflight.test.ts +15 -0
  147. package/src/activities/__tests__/discover-mcp-server.test.ts +39 -1
  148. package/src/activities/__tests__/error-classifier.test.ts +94 -2
  149. package/src/activities/__tests__/run-command.test.ts +221 -0
  150. package/src/activities/discover-mcp-server.ts +16 -2
  151. package/src/activities/emit-event.ts +65 -21
  152. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +63 -3
  153. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +173 -31
  154. package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +62 -1
  155. package/src/activities/execute-cursor/__tests__/turn-recovery.test.ts +160 -0
  156. package/src/activities/execute-cursor/attachment-resolver.ts +24 -0
  157. package/src/activities/execute-cursor/blueprint-resolver.ts +1 -10
  158. package/src/activities/execute-cursor/error-classifier.ts +40 -3
  159. package/src/activities/execute-cursor/index.ts +175 -74
  160. package/src/activities/execute-cursor/model-pricing-data.ts +22 -3
  161. package/src/activities/execute-cursor/prompt-builder.ts +89 -22
  162. package/src/activities/execute-cursor/turn-recovery.ts +208 -0
  163. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +105 -0
  164. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +0 -2
  165. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +0 -1
  166. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +0 -1
  167. package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +2 -7
  168. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +174 -0
  169. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +71 -0
  170. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +0 -1
  171. package/src/activities/execute-deep-agent/__tests__/shell-env.test.ts +24 -10
  172. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +22 -17
  173. package/src/activities/execute-deep-agent/attachment-injector.ts +20 -1
  174. package/src/activities/execute-deep-agent/index.ts +0 -19
  175. package/src/activities/execute-deep-agent/mcp-gate.ts +0 -3
  176. package/src/activities/execute-deep-agent/prompt-builder.ts +27 -15
  177. package/src/activities/execute-deep-agent/setup.ts +22 -73
  178. package/src/activities/execute-deep-agent/shell-env.ts +8 -9
  179. package/src/activities/execute-deep-agent/subagent-transformer.ts +7 -1
  180. package/src/activities/execute-deep-agent/subagent-wiring.ts +26 -3
  181. package/src/activities/hydrate-workflow-execution.ts +8 -5
  182. package/src/activities/run-command.ts +25 -12
  183. package/src/activities/run-env.ts +79 -0
  184. package/src/bootstrap.ts +5 -0
  185. package/src/client/__tests__/stigmer-client.test.ts +104 -5
  186. package/src/client/stigmer-client.ts +128 -29
  187. package/src/config.ts +6 -5
  188. package/src/encryption/config.ts +87 -23
  189. package/src/middleware/__tests__/path-normalization.test.ts +140 -0
  190. package/src/middleware/index.ts +8 -0
  191. package/src/middleware/path-normalization.ts +125 -0
  192. package/src/middleware/types.ts +11 -0
  193. package/src/payload-codecs.ts +8 -2
  194. package/src/runner-manager.ts +21 -3
  195. package/src/runner.ts +7 -3
  196. package/src/shared/__tests__/artifact-storage.test.ts +154 -2
  197. package/src/shared/__tests__/attachment-download-urls.test.ts +70 -0
  198. package/src/shared/__tests__/channel-attachment.test.ts +2 -2
  199. package/src/shared/__tests__/conversation-catchup.test.ts +24 -0
  200. package/src/shared/__tests__/http-retry.test.ts +5 -4
  201. package/src/shared/__tests__/mcp-manager.test.ts +53 -13
  202. package/src/shared/__tests__/mcp-schema-sanitizer.test.ts +267 -0
  203. package/src/shared/__tests__/model-client.test.ts +46 -0
  204. package/src/shared/__tests__/model-pricing.test.ts +62 -2
  205. package/src/shared/__tests__/model-registry.test.ts +22 -0
  206. package/src/shared/__tests__/synthesized-attachment.test.ts +19 -41
  207. package/src/shared/artifact-storage.ts +86 -10
  208. package/src/shared/attachment-download-urls.ts +98 -0
  209. package/src/shared/channel-attachment.ts +1 -1
  210. package/src/shared/checkpointer/http-saver.ts +15 -60
  211. package/src/shared/conversation-attachment.ts +2 -3
  212. package/src/shared/conversation-catchup.ts +39 -6
  213. package/src/shared/grpc-retry.ts +5 -1
  214. package/src/shared/http-retry.ts +98 -9
  215. package/src/shared/mcp-manager.ts +35 -4
  216. package/src/shared/mcp-schema-sanitizer.ts +224 -0
  217. package/src/shared/mcp-transport-guard.ts +2 -2
  218. package/src/shared/model-client.ts +21 -3
  219. package/src/shared/model-pricing-data.ts +22 -3
  220. package/src/shared/model-registry.ts +7 -2
  221. package/src/shared/plan-mode-permissions.ts +7 -0
  222. package/src/shared/registry-endpoint.ts +27 -0
  223. package/src/shared/runner-credential-keys.ts +46 -0
  224. package/src/shared/synthesized-attachment.ts +4 -4
  225. package/src/workflow-engine/__tests__/tasks/emit-event.test.ts +127 -49
  226. package/src/workflow-engine/resolve.ts +48 -2
  227. package/src/workflows/__tests__/connect-mcp-server.test.ts +25 -0
  228. package/src/workflows/connect-mcp-server.ts +2 -0
  229. package/src/workflows/types.ts +8 -0
  230. package/dist/activities/execute-cursor/mcp-config.d.ts +0 -30
  231. package/dist/activities/execute-cursor/mcp-config.js +0 -39
  232. package/dist/activities/execute-cursor/mcp-config.js.map +0 -1
  233. package/dist/shared/datastore-attachment.d.ts +0 -99
  234. package/dist/shared/datastore-attachment.js +0 -184
  235. package/dist/shared/datastore-attachment.js.map +0 -1
  236. package/src/activities/execute-cursor/mcp-config.ts +0 -66
  237. package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +0 -104
  238. package/src/shared/__tests__/datastore-attachment.test.ts +0 -293
  239. package/src/shared/datastore-attachment.ts +0 -209
@@ -5,8 +5,11 @@
5
5
  * - RunScript: writes inline code to a temp file, executes via node/python
6
6
  * - RunShell: executes a shell command directly
7
7
  *
8
- * Both resolve runtime placeholders (JIT secrets) before execution
9
- * and capture stdout as the activity result.
8
+ * The child environment follows the declare-to-receive contract in
9
+ * run-env.ts: a minimal base plus the task's declared `environment`,
10
+ * with runtime placeholders (JIT secrets) resolved here in the activity
11
+ * so secret values never enter Temporal history. Both activities capture
12
+ * stdout as the activity result.
10
13
  */
11
14
 
12
15
  import { execFile, exec } from "node:child_process";
@@ -16,7 +19,9 @@ import { join } from "node:path";
16
19
  import { promisify } from "node:util";
17
20
  import { ApplicationFailure } from "@temporalio/activity";
18
21
  import type { RunCommandConfig } from "../workflow-engine/types.js";
22
+ import { RuntimePlaceholderResolutionError } from "../workflow-engine/resolve.js";
19
23
  import { startHeartbeat } from "../shared/heartbeat.js";
24
+ import { buildRunEnv } from "./run-env.js";
20
25
 
21
26
  const execFileAsync = promisify(execFile);
22
27
  const execAsync = promisify(exec);
@@ -49,7 +54,7 @@ async function runScriptImpl(config: RunCommandConfig): Promise<unknown> {
49
54
 
50
55
  const language = config.language!;
51
56
  const code = config.code!;
52
- const env = buildEnv(config.environment);
57
+ const env = buildTaskEnv(config);
53
58
 
54
59
  const ext = language === "js" ? ".js" : ".py";
55
60
  const interpreter = language === "js" ? "node" : "python3";
@@ -94,7 +99,7 @@ async function runShellImpl(config: RunCommandConfig): Promise<unknown> {
94
99
  const fullCommand = args.length > 0
95
100
  ? `${command} ${args.join(" ")}`
96
101
  : command;
97
- const env = buildEnv(config.environment);
102
+ const env = buildTaskEnv(config);
98
103
 
99
104
  try {
100
105
  const { stdout } = await execAsync(fullCommand, {
@@ -116,16 +121,24 @@ async function runShellImpl(config: RunCommandConfig): Promise<unknown> {
116
121
  }
117
122
  }
118
123
 
119
- function buildEnv(
120
- taskEnv?: Record<string, string>,
121
- ): Record<string, string | undefined> {
122
- const base = { ...process.env };
123
- if (taskEnv) {
124
- for (const [k, v] of Object.entries(taskEnv)) {
125
- base[k] = v;
124
+ /**
125
+ * Build the child env per the run-env.ts contract, translating a missing
126
+ * placeholder key into a non-retryable failure — retrying cannot conjure
127
+ * a variable the workflow's runtime env does not have.
128
+ */
129
+ function buildTaskEnv(config: RunCommandConfig): Record<string, string> {
130
+ try {
131
+ return buildRunEnv(config.environment, config.runtimeEnv);
132
+ } catch (err: unknown) {
133
+ if (err instanceof RuntimePlaceholderResolutionError) {
134
+ throw ApplicationFailure.nonRetryable(
135
+ err.message,
136
+ "RUN_ENV_UNRESOLVED_PLACEHOLDER",
137
+ { variable: err.variableName },
138
+ );
126
139
  }
140
+ throw err;
127
141
  }
128
- return base;
129
142
  }
130
143
 
131
144
  function buildArgsList(args: unknown): string[] {
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Environment contract for the workflow `run` task (shell and script modes).
3
+ *
4
+ * Declare-to-receive, the same rule the stdio MCP contract adopted after
5
+ * #256 (PR #383): the child process sees ONLY
6
+ *
7
+ * 1. a minimal base copied from the runner process
8
+ * ({@link RUN_ENV_BASE_KEYS} — the six variables the MCP SDK pins),
9
+ * 2. the task's declared `environment` map, with `${.secrets.KEY}` /
10
+ * `${.env_vars.KEY}` placeholders resolved just-in-time from the
11
+ * workflow's runtime env. Resolution happens here in the activity,
12
+ * never in the deterministic workflow phase, so secret values stay
13
+ * out of Temporal history.
14
+ *
15
+ * Runner credentials (STIGMER_TOKEN, the HITL secret, provider keys) are
16
+ * structurally absent rather than denylisted — a workflow-authored
17
+ * `printenv` cannot read what was never passed (oss#384). A missing
18
+ * placeholder key fails loudly: running a command with a silently-empty
19
+ * credential produces cryptic downstream failures.
20
+ *
21
+ * Placeholders resolve only in declared environment VALUES, never in
22
+ * `command` / `code` strings — argv is visible to `ps` on the host, so
23
+ * the environment is the sanctioned channel for secrets.
24
+ */
25
+
26
+ import { resolveRuntimePlaceholdersStrict } from "../workflow-engine/resolve.js";
27
+
28
+ /**
29
+ * Base variables copied from the runner process when present — the same
30
+ * six the MCP SDK provides to stdio servers, so "undeclared subprocess
31
+ * environment" has one platform-wide answer. `PATH` keeps interpreters
32
+ * and tools findable; the rest keep shells and language runtimes sane.
33
+ */
34
+ export const RUN_ENV_BASE_KEYS: readonly string[] = [
35
+ "HOME",
36
+ "LOGNAME",
37
+ "PATH",
38
+ "SHELL",
39
+ "TERM",
40
+ "USER",
41
+ ];
42
+
43
+ /**
44
+ * Build the environment for a run task's child process: minimal base +
45
+ * declared overlay with strict placeholder resolution (declared wins on
46
+ * conflict with the base).
47
+ *
48
+ * @throws RuntimePlaceholderResolutionError when a declared value
49
+ * references a runtime key that does not exist.
50
+ */
51
+ export function buildRunEnv(
52
+ declaredEnv: Readonly<Record<string, string>> | undefined,
53
+ runtimeEnv: Readonly<Record<string, unknown>>,
54
+ baseEnv: NodeJS.ProcessEnv = process.env,
55
+ ): Record<string, string> {
56
+ const env: Record<string, string> = {};
57
+
58
+ for (const key of RUN_ENV_BASE_KEYS) {
59
+ const value = baseEnv[key];
60
+ if (value !== undefined) env[key] = value;
61
+ }
62
+
63
+ if (!declaredEnv || Object.keys(declaredEnv).length === 0) {
64
+ // The only case whose behavior changed in oss#384 (previously the
65
+ // child inherited the full runner env) — leave a trace for operators.
66
+ console.log("run task declares no environment — child receives the minimal base env only");
67
+ return env;
68
+ }
69
+
70
+ for (const [key, value] of Object.entries(declaredEnv)) {
71
+ env[key] = resolveRuntimePlaceholdersStrict(
72
+ value,
73
+ runtimeEnv,
74
+ `environment "${key}"`,
75
+ );
76
+ }
77
+
78
+ return env;
79
+ }
package/src/bootstrap.ts CHANGED
@@ -120,6 +120,11 @@ export async function resolveRunnerBootstrap(
120
120
  // (OSS / no signing key) and is handled by the runner, not treated as fatal.
121
121
  runnerAccessToken: discovered.runnerAccessToken,
122
122
  runnerAccessTokenExpiresInSeconds: discovered.runnerAccessTokenExpiresInSeconds,
123
+ // Server-managed payload-encryption keys ride the same best-effort lane:
124
+ // absent on OSS (env-configured keys) and on cloud servers predating key
125
+ // management. Note the explicit-address and localhost branches above return
126
+ // none by construction — key management only exists where discovery does.
127
+ payloadEncryption: discovered.payloadEncryption,
123
128
  };
124
129
  }
125
130
 
@@ -45,10 +45,11 @@ function makeScopedTokenExchangeRequest() {
45
45
  }
46
46
 
47
47
  /** An unsigned JWT-shaped token carrying the given token_type claim. */
48
- function fakeTokenOfType(tokenType: string): string {
48
+ function fakeTokenOfType(tokenType?: string): string {
49
49
  const b64 = (obj: Record<string, unknown>) =>
50
50
  Buffer.from(JSON.stringify(obj)).toString("base64url");
51
- return `${b64({ alg: "RS256" })}.${b64({ token_type: tokenType })}.sig`;
51
+ const claims = tokenType === undefined ? {} : { token_type: tokenType };
52
+ return `${b64({ alg: "RS256" })}.${b64(claims)}.sig`;
52
53
  }
53
54
 
54
55
  /**
@@ -310,14 +311,64 @@ describe("StigmerClient", () => {
310
311
  expect(spy).not.toHaveBeenCalled();
311
312
  });
312
313
 
313
- it("skips the exchange when no runner credential exists (OSS/local)", async () => {
314
+ // NOTE (oss#535): the no-credential arm previously pinned "skip the
315
+ // exchange" — correct while OSS neither minted nor redacted. Since
316
+ // oss#535 the OSS server redacts EC reads by default and mints
317
+ // execution-scoped tokens on this exchange, so a credential-less runner
318
+ // must now ASK, and fall back to the tokenless read only when the server
319
+ // answers not-minted (pre-oss#535 OSS) or refuses (cloud refusing a
320
+ // non-runner credential — the legacy desktop degrade).
321
+ it("exchanges best-effort when no runner credential exists and uses a minted token (oss#535)", async () => {
314
322
  const client = clientWithRunnerCredential(null);
315
- const spy = vi.spyOn(client, "getRunnerScopedToken");
323
+ vi.spyOn(client, "getRunnerScopedToken").mockResolvedValue({
324
+ token: "oss-scoped-tok",
325
+ expiresInSeconds: 3600,
326
+ });
327
+
328
+ const token = await client.acquireScopedRunnerToken({ workflowExecutionId: "wfx_1" });
329
+
330
+ expect(token).toBe("oss-scoped-tok");
331
+ expect(client.getRunnerScopedToken).toHaveBeenCalledWith({ workflowExecutionId: "wfx_1" });
332
+ });
333
+
334
+ it("proceeds tokenless when a credential-less exchange is answered not-minted (pre-oss#535 server)", async () => {
335
+ const client = clientWithRunnerCredential(null);
336
+ vi.spyOn(client, "getRunnerScopedToken").mockResolvedValue(undefined);
316
337
 
317
338
  const token = await client.acquireScopedRunnerToken({ workflowExecutionId: "wfx_1" });
318
339
 
319
340
  expect(token).toBeUndefined();
320
- expect(spy).not.toHaveBeenCalled();
341
+ });
342
+
343
+ it("proceeds tokenless when a credential-less exchange is refused (cloud refusing a non-runner credential)", async () => {
344
+ const client = clientWithRunnerCredential(null);
345
+ vi.spyOn(client, "getRunnerScopedToken").mockRejectedValue(new Error("permission denied"));
346
+
347
+ const token = await client.acquireScopedRunnerToken({ agentExecutionId: "aex_1" });
348
+
349
+ expect(token).toBeUndefined();
350
+ });
351
+
352
+ it("treats a non-JWT process token as no runner credential (the harness/proxy token shape)", async () => {
353
+ // e.g. the conformance harness boots the runner with a mock proxy
354
+ // token that is not a Stigmer-minted JWT; the best-effort arm must
355
+ // still ask the server for a scoped token.
356
+ const client = clientWithRunnerCredential("not-a-jwt-proxy-token");
357
+ vi.spyOn(client, "getRunnerScopedToken").mockResolvedValue({ token: "scoped-tok" });
358
+
359
+ const token = await client.acquireScopedRunnerToken({ agentExecutionId: "aex_1" });
360
+
361
+ expect(token).toBe("scoped-tok");
362
+ });
363
+
364
+ it("treats a claim-less user JWT as no runner credential (legacy desktop shape)", async () => {
365
+ const client = clientWithRunnerCredential(fakeTokenOfType(undefined));
366
+ vi.spyOn(client, "getRunnerScopedToken").mockResolvedValue(undefined);
367
+
368
+ const token = await client.acquireScopedRunnerToken({ agentExecutionId: "aex_1" });
369
+
370
+ expect(token).toBeUndefined();
371
+ expect(client.getRunnerScopedToken).toHaveBeenCalled();
321
372
  });
322
373
 
323
374
  it("throws when the server mints no token — the bootstrap credential no longer decrypts (stigmer-cloud#218)", async () => {
@@ -432,6 +483,54 @@ describe("StigmerClient", () => {
432
483
  });
433
484
  });
434
485
 
486
+ describe("sendWorkflowSignal", () => {
487
+ function clientWithWorkflowCommand() {
488
+ const client = new StigmerClient({ endpoint: "http://localhost", token: null });
489
+ const sendSignal = vi.fn().mockResolvedValue({});
490
+ // Reach into the private generated client: the mocked createClient()
491
+ // returned {}, so install the method it would have provided.
492
+ (client as any).workflowExecutionCommand = { sendSignal };
493
+ return { client, rpc: sendSignal };
494
+ }
495
+
496
+ it("builds SendSignalInput with execution addressing and the payload verbatim", async () => {
497
+ const { client, rpc } = clientWithWorkflowCommand();
498
+ const payload = { type: "approval.granted", data: { ok: true } };
499
+
500
+ await client.sendWorkflowSignal("wfx_1", "approval_resolved", payload);
501
+
502
+ const input = rpc.mock.calls[0]![0];
503
+ expect(input.executionId).toBe("wfx_1");
504
+ expect(input.signalName).toBe("approval_resolved");
505
+ expect(input.payload).toEqual(payload);
506
+ });
507
+
508
+ it("sends no idempotency key (oss#442: failed-delivery claims are never released)", async () => {
509
+ const { client, rpc } = clientWithWorkflowCommand();
510
+
511
+ await client.sendWorkflowSignal("wfx_1", "ping", undefined);
512
+
513
+ const input = rpc.mock.calls[0]![0];
514
+ expect(input.idempotencyKey).toBe("");
515
+ });
516
+
517
+ it("passes the per-call timeout through to the RPC", async () => {
518
+ const { client, rpc } = clientWithWorkflowCommand();
519
+
520
+ await client.sendWorkflowSignal("wfx_1", "ping", undefined, { timeoutMs: 30_000 });
521
+
522
+ expect(rpc).toHaveBeenCalledWith(expect.anything(), { timeoutMs: 30_000 });
523
+ });
524
+
525
+ it("passes an undefined timeout when no options are supplied", async () => {
526
+ const { client, rpc } = clientWithWorkflowCommand();
527
+
528
+ await client.sendWorkflowSignal("wfx_1", "ping", undefined);
529
+
530
+ expect(rpc).toHaveBeenCalledWith(expect.anything(), { timeoutMs: undefined });
531
+ });
532
+ });
533
+
435
534
  describe("updateToken", () => {
436
535
  it("does not affect tokenRef-based resolution", async () => {
437
536
  const ref: TokenRef = { current: "from-ref" };
@@ -44,7 +44,8 @@ import type { UpdateStatusResponse } from "@stigmer/protos/ai/stigmer/agentic/ag
44
44
  import { WorkflowExecutionCommandController } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/command_pb";
45
45
  import { WorkflowExecutionQueryController } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/query_pb";
46
46
  import type { WorkflowExecution, WorkflowExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/api_pb";
47
- import { WorkflowExecutionUpdateStatusInputSchema, GetEventLogRequestSchema } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/io_pb";
47
+ import { WorkflowExecutionUpdateStatusInputSchema, GetEventLogRequestSchema, SendSignalInputSchema } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/io_pb";
48
+ import type { JsonObject } from "@bufbuild/protobuf";
48
49
  import { WorkflowQueryController } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/query_pb";
49
50
  import type { Workflow } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
50
51
  import { WorkflowInstanceQueryController } from "@stigmer/protos/ai/stigmer/agentic/workflowinstance/v1/query_pb";
@@ -52,9 +53,29 @@ import type { WorkflowInstance } from "@stigmer/protos/ai/stigmer/agentic/workfl
52
53
  import { PlatformQueryController, GetRunnerScopedTokenInputSchema, TokenRenewalSchema } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
53
54
  import { ChannelMessageQueryController } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_query_pb";
54
55
  import type { ChannelTemplate, MessagingChannel } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
55
- import { isEmbeddedRunnerToken } from "./token-claims.js";
56
+ import { TOKEN_TYPE_EMBEDDED_RUNNER, tokenTypeOf } from "./token-claims.js";
56
57
  import { assertCreateRequirements, assertReferenceRequirements } from "./server-contracts.js";
57
58
 
59
+ /**
60
+ * Server-managed Temporal payload-encryption keys for this runner.
61
+ *
62
+ * Minted once per caller identity and persisted by the control plane, so
63
+ * every boot of the same identity's runners receives the SAME key — the
64
+ * persistence Temporal replay requires across runner restarts. The secondary
65
+ * pair is present only during a rotation window (decrypt-only; new payloads
66
+ * are written under the primary).
67
+ */
68
+ export interface BootstrapPayloadEncryptionKeys {
69
+ /** Base64-encoded 32-byte AES-256 key. */
70
+ key: string;
71
+ /** Key id stamped on payloads encrypted under {@link key}. */
72
+ keyId: string;
73
+ /** Previous key of this identity, present during rotation windows. */
74
+ secondaryKey?: string;
75
+ /** Key id of {@link secondaryKey}; present exactly when it is. */
76
+ secondaryKeyId?: string;
77
+ }
78
+
58
79
  /**
59
80
  * Everything an embedded runner needs to bootstrap: Temporal coordinates plus
60
81
  * its own minted access token.
@@ -62,7 +83,10 @@ import { assertCreateRequirements, assertReferenceRequirements } from "./server-
62
83
  * The Temporal coordinates are always present. The access token is optional: the
63
84
  * server only mints one when it has a Cursor proxy to authenticate against
64
85
  * (cloud) and a signing key configured — OSS and misconfigured servers omit it,
65
- * and the runner keeps using its existing token in that case.
86
+ * and the runner keeps using its existing token in that case. Payload-encryption
87
+ * keys follow the same presence contract: absent when the server does not manage
88
+ * runner keys (OSS uses env-configured keys; older cloud servers predate key
89
+ * management).
66
90
  */
67
91
  export interface RunnerBootstrapConfig {
68
92
  temporalAddress: string;
@@ -71,15 +95,18 @@ export interface RunnerBootstrapConfig {
71
95
  runnerAccessToken?: string;
72
96
  /** Lifetime of {@link runnerAccessToken} in seconds; absent/0 when no token. */
73
97
  runnerAccessTokenExpiresInSeconds?: number;
98
+ /** Server-managed payload-encryption keys; absent when not managed by the server. */
99
+ payloadEncryption?: BootstrapPayloadEncryptionKeys;
74
100
  }
75
101
 
76
102
  /**
77
103
  * A runner token scoped to one unit of dispatched work (issue #156).
78
104
  *
79
- * Minted on demand by the control plane when the runner exchanges its
80
- * bootstrap credential at task start; presented for the ExecutionContext
81
- * fetch of exactly that execution. Absent when the server does not mint
82
- * (OSS, or no signing key) the runner keeps its existing credential.
105
+ * Minted on demand by the control plane at task start a desktop runner
106
+ * exchanges its bootstrap credential, an OSS runner asks with no credential
107
+ * at all (oss#535) and presented for the ExecutionContext fetch of exactly
108
+ * that execution. Absent when the server does not mint (pre-oss#535 OSS, or
109
+ * a refused credential class) — the runner keeps its existing credential.
83
110
  */
84
111
  export interface RunnerScopedToken {
85
112
  token: string;
@@ -272,6 +299,18 @@ export class StigmerClient {
272
299
  runnerAccessToken: res.runnerAccessToken || undefined,
273
300
  runnerAccessTokenExpiresInSeconds:
274
301
  res.runnerAccessTokenExpiresInSeconds || undefined,
302
+ // Same presence contract, grouped so callers make ONE presence check.
303
+ // Key without id (or vice versa) is a server contract violation the
304
+ // encryption config loader rejects — not silently dropped here, so the
305
+ // breakage is diagnosable at the fail-closed boundary.
306
+ payloadEncryption: res.payloadEncryptionKey
307
+ ? {
308
+ key: res.payloadEncryptionKey,
309
+ keyId: res.payloadEncryptionKeyId,
310
+ secondaryKey: res.payloadEncryptionSecondaryKey || undefined,
311
+ secondaryKeyId: res.payloadEncryptionSecondaryKeyId || undefined,
312
+ }
313
+ : undefined,
275
314
  };
276
315
  }
277
316
 
@@ -313,14 +352,16 @@ export class StigmerClient {
313
352
  }
314
353
 
315
354
  /**
316
- * Exchange this runner's bootstrap credential for a token scoped to one
317
- * unit of dispatched work (issue #156).
355
+ * Exchange this runner's credential for a token scoped to one unit of
356
+ * dispatched work (issue #156).
318
357
  *
319
- * The call authenticates with the runner credential (see the interceptor);
320
- * the server verifies it is an embedded_runner token and that its identity
321
- * can view the named execution, then mints the same session/execution-scoped
322
- * sandbox token a cloud sandbox runner receives at provisioning. Returns
323
- * undefined when the server does not mint (OSS, or no signing key) —
358
+ * The call authenticates with the runner credential (see the interceptor).
359
+ * Cloud verifies it is an embedded_runner token and that its identity can
360
+ * view the named execution, then mints the same session/execution-scoped
361
+ * sandbox token a cloud sandbox runner receives at provisioning. OSS mints
362
+ * an execution-scoped token for any caller (oss#535 a lane discriminator
363
+ * on a single-user server, not a trust boundary). Returns undefined when
364
+ * the server does not mint (pre-oss#535 OSS, or an unserved scope arm) —
324
365
  * presence-based, like the bootstrap token fields.
325
366
  *
326
367
  * `callerToken` authenticates the exchange per-call instead of the
@@ -354,26 +395,44 @@ export class StigmerClient {
354
395
  * Acquire a scoped runner token for an ExecutionContext read, if this
355
396
  * runner's credential situation calls for one.
356
397
  *
357
- * The gate is the credential itself: only an unscoped embedded_runner
358
- * bootstrap token needs exchanging. A cloud sandbox runner's credential is
359
- * already scoped (skip the exchange would rightly refuse it), and an
360
- * OSS/local runner holds no runner-class credential at all (skip the
361
- * server neither mints nor redacts).
398
+ * The gate is the credential itself, three ways:
399
+ *
400
+ * 1. An unscoped embedded_runner bootstrap token MUST be exchanged, and a
401
+ * failed exchange is a hard error, not a fallback: since the #156
402
+ * item-3 flip (stigmer-cloud#218) the bootstrap credential no longer
403
+ * decrypts, so a read that "fell back" would silently receive redacted
404
+ * placeholders and the execution would run against junk secret values —
405
+ * strictly worse than failing here with the real reason. The
406
+ * secret-delivery call sites let this error fail the activity;
407
+ * opportunistic consumers that can genuinely proceed without a scoped
408
+ * token (attachment credential, channel discovery) catch it at the
409
+ * call site, where their degrade-to-empty contract lives.
410
+ *
411
+ * 2. Any other runner-class credential is already scoped (a cloud
412
+ * sandbox/pool/connect token) — skip; the exchange would rightly
413
+ * refuse it and the ambient credential decrypts on its own.
362
414
  *
363
- * A failed exchange is a hard error, not a fallback: since the #156 item-3
364
- * flip (stigmer-cloud#218) the bootstrap credential no longer decrypts, so
365
- * a read that "fell back" would silently receive redacted placeholders and
366
- * the execution would run against junk secret values strictly worse than
367
- * failing here with the real reason. The secret-delivery call sites let
368
- * this error fail the activity; opportunistic consumers that can genuinely
369
- * proceed without a scoped token (attachment credential, channel
370
- * discovery) catch it at the call site, where their degrade-to-empty
371
- * contract lives.
415
+ * 3. No runner-class credential at all (OSS/local, where the process
416
+ * token is absent or carries no token_type claim) — attempt the
417
+ * exchange best-effort (oss#535): a current OSS server mints an
418
+ * execution-scoped token here, which is the ONLY way this runner can
419
+ * read decrypted secrets from its redact-by-default EC RPCs. A server
420
+ * that answers "not minted" (pre-oss#535 OSS, which also does not
421
+ * redact) or refuses the exchange (cloud refusing a non-runner
422
+ * credential the legacy desktop degrade, which reads redacted today
423
+ * regardless) falls through to the tokenless read, preserving each
424
+ * old pairing's exact behavior.
372
425
  */
373
426
  async acquireScopedRunnerToken(
374
427
  scope: RunnerScopedTokenScope,
375
428
  ): Promise<string | undefined> {
376
- if (!isEmbeddedRunnerToken(this.runnerTokenRef?.current)) {
429
+ // Inspect the same credential chain the EC read's interceptor would use.
430
+ const ambientTokenType = tokenTypeOf(
431
+ this.runnerTokenRef?.current ?? this.tokenRef?.current ?? this.currentToken,
432
+ );
433
+ const isEmbeddedRunner = ambientTokenType === TOKEN_TYPE_EMBEDDED_RUNNER;
434
+ if (ambientTokenType !== undefined && !isEmbeddedRunner) {
435
+ // Case 2: an already-scoped runner-class credential.
377
436
  return undefined;
378
437
  }
379
438
  const scopeDescription =
@@ -386,6 +445,11 @@ export class StigmerClient {
386
445
  try {
387
446
  scoped = await this.getRunnerScopedToken(scope);
388
447
  } catch (err) {
448
+ if (!isEmbeddedRunner) {
449
+ // Case 3: best-effort — a refusal means the server does not serve
450
+ // this credential class; the tokenless read is today's behavior.
451
+ return undefined;
452
+ }
389
453
  throw new Error(
390
454
  `Scoped runner token exchange failed for ${scopeDescription}: ` +
391
455
  `${err instanceof Error ? err.message : String(err)}. ` +
@@ -395,6 +459,11 @@ export class StigmerClient {
395
459
  );
396
460
  }
397
461
  if (!scoped) {
462
+ if (!isEmbeddedRunner) {
463
+ // Case 3: a server that mints nothing for this scope also does not
464
+ // redact for this runner (pre-oss#535 OSS) — proceed tokenless.
465
+ return undefined;
466
+ }
398
467
  throw new Error(
399
468
  `Server minted no scoped runner token for ${scopeDescription}. ` +
400
469
  "The bootstrap credential cannot read ExecutionContext secrets " +
@@ -552,6 +621,36 @@ export class StigmerClient {
552
621
  return this.workflowExecutionCommand.updateStatus(input);
553
622
  }
554
623
 
624
+ /**
625
+ * Deliver a named signal to another workflow execution through the
626
+ * server's SendSignal lane (validate → phase gate → relay via the
627
+ * outer orchestrator). This is the ONLY sanctioned path for
628
+ * runner-originated signals: a direct Temporal client would bypass
629
+ * both the authorization boundary and the payload-encryption design
630
+ * (per-identity runner keys make sender-side encryption fail closed
631
+ * at receivers holding a different key — see oss#517).
632
+ *
633
+ * Deliberately sends no idempotency_key: the server's dedupe claim is
634
+ * not released when delivery fails (oss#442), so a key here would
635
+ * poison the exact retry it exists to protect — and emit envelope ids
636
+ * regenerate per activity attempt anyway. Revisit once oss#442 lands.
637
+ */
638
+ async sendWorkflowSignal(
639
+ executionId: string,
640
+ signalName: string,
641
+ payload: JsonObject | undefined,
642
+ options?: { timeoutMs?: number },
643
+ ): Promise<WorkflowExecution> {
644
+ const input = create(SendSignalInputSchema, {
645
+ executionId,
646
+ signalName,
647
+ payload,
648
+ });
649
+ return this.workflowExecutionCommand.sendSignal(input, {
650
+ timeoutMs: options?.timeoutMs,
651
+ });
652
+ }
653
+
555
654
  async getWorkflowExecution(executionId: string): Promise<WorkflowExecution> {
556
655
  return this.workflowExecutionQuery.get({ value: executionId });
557
656
  }
package/src/config.ts CHANGED
@@ -61,11 +61,12 @@ export interface Config {
61
61
  readonly stigmerBackendEndpoint: string;
62
62
  readonly stigmerToken: string | null;
63
63
  /**
64
- * The MCP bridge endpoint for the runner-synthesized datastore
65
- * records attachment (STIGMER_MCP_BRIDGE_ENDPOINT, e.g.
66
- * https://mcp.stigmer.ai). Null selects the OSS/local shape: a
67
- * spawned `stigmer mcp-server` stdio child against the local backend.
68
- * See shared/datastore-attachment.ts.
64
+ * The MCP bridge endpoint for the runner-synthesized attachments —
65
+ * channel messaging and conversation participation
66
+ * (STIGMER_MCP_BRIDGE_ENDPOINT, e.g. https://mcp.stigmer.ai). Null
67
+ * selects the OSS/local shape: a spawned `stigmer mcp-server` stdio
68
+ * child against the local backend. See shared/channel-attachment.ts
69
+ * and shared/conversation-attachment.ts.
69
70
  */
70
71
  readonly mcpBridgeEndpoint: string | null;
71
72
  /**