@relayflows/sdk 2.0.49 → 2.0.51

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 (118) hide show
  1. package/dist/authored-flow-executor.d.ts.map +1 -1
  2. package/dist/authored-flow-executor.js +4 -2
  3. package/dist/authored-flow-executor.js.map +1 -1
  4. package/dist/authored-preflight.js +4 -4
  5. package/dist/authored-preflight.js.map +1 -1
  6. package/dist/authored-worker-step.d.ts +2 -2
  7. package/dist/authored-worker-step.d.ts.map +1 -1
  8. package/dist/authored-worker-step.js +11 -5
  9. package/dist/authored-worker-step.js.map +1 -1
  10. package/dist/cli/check.js +2 -2
  11. package/dist/cli/check.js.map +1 -1
  12. package/dist/cli/cli-probe.d.ts +1 -1
  13. package/dist/cli/cli-probe.d.ts.map +1 -1
  14. package/dist/cli/cli-probe.js +8 -7
  15. package/dist/cli/cli-probe.js.map +1 -1
  16. package/dist/cli/cloud-run.d.ts +2 -1
  17. package/dist/cli/cloud-run.d.ts.map +1 -1
  18. package/dist/cli/cloud-run.js +6 -2
  19. package/dist/cli/cloud-run.js.map +1 -1
  20. package/dist/cli-commands.d.ts +4 -1
  21. package/dist/cli-commands.d.ts.map +1 -1
  22. package/dist/cli-commands.js +5 -1
  23. package/dist/cli-commands.js.map +1 -1
  24. package/dist/cli.d.ts +1 -0
  25. package/dist/cli.d.ts.map +1 -1
  26. package/dist/cli.js +26 -7
  27. package/dist/cli.js.map +1 -1
  28. package/dist/cloud-http.d.ts +1 -0
  29. package/dist/cloud-http.d.ts.map +1 -1
  30. package/dist/cloud-http.js +1 -1
  31. package/dist/cloud-http.js.map +1 -1
  32. package/dist/cloud-run.d.ts +6 -1
  33. package/dist/cloud-run.d.ts.map +1 -1
  34. package/dist/cloud-run.js +56 -6
  35. package/dist/cloud-run.js.map +1 -1
  36. package/dist/cloud-signal.d.ts +12 -0
  37. package/dist/cloud-signal.d.ts.map +1 -0
  38. package/dist/cloud-signal.js +117 -0
  39. package/dist/cloud-signal.js.map +1 -0
  40. package/dist/cloud-sync.d.ts +8 -1
  41. package/dist/cloud-sync.d.ts.map +1 -1
  42. package/dist/cloud-sync.js +36 -4
  43. package/dist/cloud-sync.js.map +1 -1
  44. package/dist/communication/environment.d.ts +1 -1
  45. package/dist/communication/environment.d.ts.map +1 -1
  46. package/dist/communication/environment.js +26 -3
  47. package/dist/communication/environment.js.map +1 -1
  48. package/dist/communication/worker.js +1 -1
  49. package/dist/communication/worker.js.map +1 -1
  50. package/dist/compile.d.ts.map +1 -1
  51. package/dist/compile.js +5 -2
  52. package/dist/compile.js.map +1 -1
  53. package/dist/index.d.ts +1 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +1 -0
  56. package/dist/index.js.map +1 -1
  57. package/dist/journal-client.d.ts +2 -0
  58. package/dist/journal-client.d.ts.map +1 -1
  59. package/dist/journal-client.js +26 -3
  60. package/dist/journal-client.js.map +1 -1
  61. package/dist/llm-worker.d.ts.map +1 -1
  62. package/dist/llm-worker.js +1 -1
  63. package/dist/llm-worker.js.map +1 -1
  64. package/dist/observer-link.d.ts +7 -7
  65. package/dist/observer-link.js +6 -6
  66. package/dist/preflight.d.ts +2 -2
  67. package/dist/preflight.d.ts.map +1 -1
  68. package/dist/preflight.js +12 -7
  69. package/dist/preflight.js.map +1 -1
  70. package/dist/spec.d.ts +4 -0
  71. package/dist/spec.d.ts.map +1 -1
  72. package/dist/spec.js.map +1 -1
  73. package/dist/step-fields.d.ts +1 -1
  74. package/dist/step-fields.d.ts.map +1 -1
  75. package/dist/step-fields.js +1 -0
  76. package/dist/step-fields.js.map +1 -1
  77. package/dist/step-secrets.d.ts +20 -0
  78. package/dist/step-secrets.d.ts.map +1 -0
  79. package/dist/step-secrets.js +146 -0
  80. package/dist/step-secrets.js.map +1 -0
  81. package/dist/validate.d.ts.map +1 -1
  82. package/dist/validate.js +10 -0
  83. package/dist/validate.js.map +1 -1
  84. package/dist/worker-cli.d.ts +5 -1
  85. package/dist/worker-cli.d.ts.map +1 -1
  86. package/dist/worker-cli.js +43 -14
  87. package/dist/worker-cli.js.map +1 -1
  88. package/dist/worker.d.ts +1 -1
  89. package/dist/worker.d.ts.map +1 -1
  90. package/dist/worker.js +1 -1
  91. package/dist/worker.js.map +1 -1
  92. package/package.json +2 -2
  93. package/src/authored-flow-executor.ts +4 -2
  94. package/src/authored-preflight.ts +4 -4
  95. package/src/authored-worker-step.ts +17 -6
  96. package/src/cli/check.ts +3 -3
  97. package/src/cli/cli-probe.ts +9 -6
  98. package/src/cli/cloud-run.ts +7 -2
  99. package/src/cli-commands.ts +5 -1
  100. package/src/cli.ts +26 -8
  101. package/src/cloud-http.ts +1 -1
  102. package/src/cloud-run.ts +61 -8
  103. package/src/cloud-signal.ts +110 -0
  104. package/src/cloud-sync.ts +39 -4
  105. package/src/communication/environment.ts +28 -3
  106. package/src/communication/worker.ts +2 -1
  107. package/src/compile.ts +5 -2
  108. package/src/index.ts +1 -0
  109. package/src/journal-client.ts +29 -2
  110. package/src/llm-worker.ts +2 -1
  111. package/src/observer-link.ts +7 -7
  112. package/src/preflight.ts +13 -7
  113. package/src/spec.ts +4 -0
  114. package/src/step-fields.ts +1 -0
  115. package/src/step-secrets.ts +145 -0
  116. package/src/validate.ts +11 -0
  117. package/src/worker-cli.ts +41 -13
  118. package/src/worker.ts +3 -2
@@ -1,14 +1,20 @@
1
1
  import { basename } from 'node:path';
2
2
  import { wrapperEnvironment } from '../wrapper-runtime.js';
3
+ import { deliveredNames, isCliLoginName, platformAgentNames, repositoryEnvironmentNames,
4
+ validateStepSecrets } from '../step-secrets.js';
3
5
 
4
6
  const terminalNames = ['TERM', 'COLORTERM', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME'];
7
+ const networkNames = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
8
+ 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy'];
5
9
  const relayNames = ['RELAY_API_KEY', 'RELAY_WORKSPACE_KEY', 'AGENT_RELAY_WORKSPACE_KEY',
6
10
  'RELAY_BROKER_API_KEY', 'RELAY_AGENT_TOKEN', 'RELAY_NODE_TOKEN', 'RELAY_WORKSPACES_JSON'];
7
11
  // Multi-provider CLIs select their provider in their own configuration.
8
12
  const multiProvider = ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL',
9
13
  'GEMINI_API_KEY', 'GOOGLE_API_KEY', 'OPENROUTER_API_KEY'] as const;
10
14
  const providerNames: Readonly<Record<string, readonly string[]>> = {
11
- claude: ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CONFIG_DIR'],
15
+ claude: ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CONFIG_DIR',
16
+ 'CLAUDE_CODE_USE_BEDROCK', 'AWS_BEARER_TOKEN_BEDROCK', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY',
17
+ 'AWS_SESSION_TOKEN', 'AWS_REGION', 'AWS_DEFAULT_REGION'],
12
18
  codex: ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'CODEX_HOME'],
13
19
  gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION'],
14
20
  // AgentWorkforce/cloud#4026 passes both endpoint names unchanged as aliases
@@ -32,9 +38,28 @@ export function brokerEnvironment(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv
32
38
  return { ...Object.fromEntries(Object.keys(source).map(name => [name, undefined])),
33
39
  ...wrapperEnvironment(source), ...selected(source, [...terminalNames, 'RELAY_BASE_URL']) };
34
40
  }
35
- export function agentEnvironment(cli: string, source: NodeJS.ProcessEnv = process.env): Record<string, string> {
41
+ export function agentEnvironment(cli: string, source: NodeJS.ProcessEnv = process.env,
42
+ grants?: string[], scopeSecrets = true): Record<string, string> {
36
43
  // Broker transport credentials must not become CLI credentials. The harness
37
44
  // protocol accepts string values, so clear those inherited names explicitly.
45
+ const cliName = basename(cli).replace(/\.exe$/i, '');
46
+ const credentials = { ...selected(source, providerNames[cliName] ?? []),
47
+ ...selected(source, [...repositoryEnvironmentNames(source), ...networkNames]) };
48
+ if (!scopeSecrets && grants?.length) {
49
+ throw new Error('per-step secret grants require a daemon with step_secret_scoping support');
50
+ }
51
+ const names = deliveredNames(source);
52
+ const platform = platformAgentNames(source, cliName);
53
+ const allowed = scopeSecrets ? validateStepSecrets(grants, source) : [];
54
+ if (allowed.some(name => relayNames.includes(name))) {
55
+ throw new Error('Relay broker credentials cannot be granted to a communication agent');
56
+ }
57
+ for (const name of names ?? []) {
58
+ if (!allowed.includes(name) && !platform.has(name)
59
+ && !isCliLoginName(cliName, name, providerNames[cliName])) {
60
+ delete credentials[name];
61
+ }
62
+ }
38
63
  return { ...Object.fromEntries(relayNames.map(name => [name, ''])),
39
- ...selected(source, providerNames[basename(cli).replace(/\.exe$/i, '')] ?? []) } as Record<string, string>;
64
+ ...credentials, ...selected(source, allowed) } as Record<string, string>;
40
65
  }
@@ -103,7 +103,8 @@ async function run(client: JournalClient, dispatch: StepDispatchEvent, instructi
103
103
  handle = await relay.broker.spawnPty({ name, cli: basename(cliIdentity).replace(/\.exe$/i, ''), task: prompt, channels: [], skipRelayPrompt: true,
104
104
  model: resolveCliModel(cliIdentity, spec.model), cwd: directory,
105
105
  harnessConfig: { runtime: 'pty', command: quote(command), args: [],
106
- cwd: directory, env: { ...agentEnvironment(cliIdentity, environment ?? process.env),
106
+ cwd: directory, env: { ...agentEnvironment(cliIdentity, environment ?? process.env,
107
+ spec.secrets, client.supportsFeature?.('step_secret_scoping') ?? true),
107
108
  RELAYFLOW_COMMUNICATION_SOCKET: tools.path, RELAYFLOW_COMMUNICATION_TOKEN: tools.token },
108
109
  delivery: { mode: 'pty-injection', format: 'relay-block' } } });
109
110
  const ready = await handle.waitForReady(Math.min(instruction.timeoutMs, 90_000));
package/src/compile.ts CHANGED
@@ -192,6 +192,7 @@ function compileStep(step: StepSpec): StepSpec {
192
192
  const base = {
193
193
  id: step.id,
194
194
  type: step.type,
195
+ ...(step.secrets?.length ? { secrets: step.secrets } : {}),
195
196
  ...((step.dependsOn !== undefined || step.input !== undefined)
196
197
  ? { dependsOn: step.input === undefined ? step.dependsOn : [...new Set([...(step.dependsOn ?? []), ...bindingDependencies(step.input)])] } : {}),
197
198
  ...(step.input !== undefined ? { input: step.input } : {}),
@@ -466,7 +467,7 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown {
466
467
  const unionKeys = [
467
468
  'id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', 'memory', 'requirements', 'input',
468
469
  'command', 'timeout_ms', 'lease_ms', 'on_non_zero', 'prompt', 'model', 'cli', 'instruction',
469
- 'cwd', 'resume', 'recovery_mode', 'surfaces', 'permissions', 'transport', 'cli_identity',
470
+ 'cwd', 'resume', 'recovery_mode', 'surfaces', 'permissions', 'transport', 'cli_identity', 'secrets',
470
471
  ] as const;
471
472
  const step = requireKernelObject(value, unionKeys, at);
472
473
  if (step['cli_identity'] !== undefined) {
@@ -475,7 +476,7 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown {
475
476
  ], 'untrusted_cli_identity');
476
477
  }
477
478
  const type = step['type'];
478
- const commonKeys = ['id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', 'memory', 'requirements', 'input'] as const;
479
+ const commonKeys = ['id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', 'memory', 'requirements', 'input', 'secrets'] as const;
479
480
  const typeKeys = type === 'deterministic'
480
481
  ? ['command', 'timeout_ms', 'lease_ms', 'on_non_zero'] as const
481
482
  : type === 'llm'
@@ -491,6 +492,7 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown {
491
492
  id: step['id'],
492
493
  type,
493
494
  ...(step['input'] !== undefined ? { input: step['input'] } : {}),
495
+ ...(step['secrets'] !== undefined ? { secrets: step['secrets'] } : {}),
494
496
  ...(step['requirements'] !== undefined ? { requirements: kernelRequirementsToAuthoring(step['requirements'], `${at}.requirements`) } : {}),
495
497
  ...(dependsOn !== undefined && (!Array.isArray(dependsOn) || dependsOn.length > 0)
496
498
  ? { dependsOn }
@@ -636,6 +638,7 @@ function isObject(value: unknown): value is Record<string, unknown> {
636
638
  function toKernelStep(step: StepSpec, cliIdentity?: string): ResolvedKernelStepSpec {
637
639
  const common: ResolvedKernelStepCommon = {
638
640
  id: step.id,
641
+ ...(step.secrets?.length ? { secrets: step.secrets } : {}),
639
642
  depends_on: step.input === undefined ? step.dependsOn ?? []
640
643
  : [...new Set([...(step.dependsOn ?? []), ...bindingDependencies(step.input)])],
641
644
  ...(step.input !== undefined ? { input: step.input } : {}),
package/src/index.ts CHANGED
@@ -81,6 +81,7 @@ export {
81
81
  } from './flow-extension-loader.js';
82
82
 
83
83
  export { CloudFlowError, type CloudConnectionOptions, type CloudConfigurationReason } from './cloud-http.js';
84
+ export { postCloudSignal, waitForCloudSignal } from './cloud-signal.js';
84
85
  export {
85
86
  listCloudRuns, getCloudRunDetail, getCloudRunSteps, getCloudRunLog,
86
87
  type CloudRunSummary, type CloudRunList, type CloudRunDetail, type CloudStep, type CloudStepAttempt,
@@ -25,6 +25,8 @@ import {
25
25
  type StepUsage,
26
26
  } from './protocol.js';
27
27
  import type { KernelRunSpec, StepType } from './spec.js';
28
+ import { PLATFORM_AGENT_ENV_NAMES, SECRET_ENV_NAMES, SIGNAL_READ_TOKEN_ENV,
29
+ deliveredNames, validateStepSecrets } from './step-secrets.js';
28
30
 
29
31
  export interface JournalClientOptions {
30
32
  /** Opt-in total budget for read-only requests; interactive clients stay single-shot. */
@@ -78,6 +80,9 @@ export class JournalClient extends JournalConnection {
78
80
  });
79
81
  }
80
82
 
83
+ /** Additive daemon capability negotiated by this session's hello. */
84
+ supportsFeature(name: string): boolean { return this.features.has(name); }
85
+
81
86
  private request<V extends keyof VerbContract>(verb: V, params: VerbContract[V]['params'],
82
87
  timeoutMs: number | null = this.requestTimeoutMs, signal?: AbortSignal): Promise<VerbContract[V]['result']> {
83
88
  // Every allowlisted read takes the policy, even after the primary dropped: its
@@ -131,6 +136,16 @@ export class JournalClient extends JournalConnection {
131
136
  admissionKey?: string,
132
137
  watch = false,
133
138
  ): Promise<VerbContract['run.start']['result']> {
139
+ for (const step of spec.steps) {
140
+ for (const name of validateStepSecrets(step.secrets, process.env)) {
141
+ if (this.withheld.has(name)) {
142
+ throw new Error(`step secret ${JSON.stringify(name)} is withheld by the daemon`);
143
+ }
144
+ }
145
+ }
146
+ if (!this.features.has('step_secret_scoping') && spec.steps.some(step => step.secrets?.length)) {
147
+ throw new Error('per-step secret grants require a daemon with step_secret_scoping support');
148
+ }
134
149
  return this.request('run.start', {
135
150
  spec,
136
151
  ...(reuseFromRunId === undefined ? {} : { reuse_from_run_id: reuseFromRunId }),
@@ -158,10 +173,22 @@ export class JournalClient extends JournalConnection {
158
173
  * alike, so their values never cross the socket from this client.
159
174
  */
160
175
  private stepEnv(): { env?: Record<string, string> } {
161
- if (!this.features.has('step_env')) return {};
176
+ if (!this.features.has('step_env')) {
177
+ // A client with no declared grants keeps compatibility with the
178
+ // currently pinned hosted runtime until Cloud upgrades the artifact.
179
+ return {};
180
+ }
162
181
  const env: Record<string, string> = {};
182
+ const delivered = deliveredNames(process.env);
183
+ if (delivered !== undefined && this.withheld.has(SECRET_ENV_NAMES)) {
184
+ throw new Error(`${SECRET_ENV_NAMES} is withheld by the daemon; delivered secrets cannot be scoped`);
185
+ }
163
186
  for (const [key, value] of Object.entries(process.env)) {
164
- if (value !== undefined && !this.withheld.has(key)) env[key] = value;
187
+ if (value !== undefined && key !== SIGNAL_READ_TOKEN_ENV && key !== PLATFORM_AGENT_ENV_NAMES
188
+ && !this.withheld.has(key)) {
189
+ env[key] = key === SECRET_ENV_NAMES && delivered !== undefined
190
+ ? JSON.stringify([...delivered].filter(name => !this.withheld.has(name))) : value;
191
+ }
165
192
  }
166
193
  return { env };
167
194
  }
package/src/llm-worker.ts CHANGED
@@ -64,7 +64,8 @@ export class LlmWorker extends EventEmitter {
64
64
  const completed: WorkerCliResult = await withWorkerLease(this.client, dispatch, signal =>
65
65
  typeof spec.cli === 'string' && typeof spec.prompt === 'string'
66
66
  ? runAgentCli(spec.cli, workerInstruction(prompt, dispatch), dispatch.wake_context, effectiveModel, undefined, signal, 'llm',
67
- undefined, undefined, 'direct', undefined, this.environment, spec.cli_identity)
67
+ undefined, undefined, 'direct', undefined, this.environment, spec.cli_identity,
68
+ undefined, undefined, spec.secrets, this.client.supportsFeature?.('step_secret_scoping') ?? true)
68
69
  : Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'llm step has no declared CLI' }));
69
70
  const { result, usage } = workerSpend(completed, effectiveModel);
70
71
  const cost = reportedCost(completed, effectiveModel);
@@ -11,10 +11,10 @@
11
11
  * scoped, short-lived observer token on their behalf and hand *that* to the
12
12
  * URL. The workspace admin key never leaves the operator's machine.
13
13
  *
14
- * Everything here is best-effort. A missing workspace key means "no observer
15
- * link, silently" — this is a parity feature relaying v1's convenience, not a
16
- * requirement of a local run. A mint failure emits a labeled diagnostic to
17
- * stderr and continues; a run must never fail because the observer link failed.
14
+ * Everything here is best-effort. A missing workspace key means no observer
15
+ * link; the human-facing CLI explains the alternatives for a plain local run.
16
+ * A mint failure emits a labeled diagnostic to stderr and continues; a run
17
+ * must never fail because the observer link failed.
18
18
  */
19
19
 
20
20
  import { readFileSync } from 'node:fs';
@@ -165,7 +165,7 @@ export interface ResolveObserverLinkEnvOptions {
165
165
  * 2. Active workspace key from `~/.agentworkforce/relay/workspaces.json`
166
166
  * (populated by `agent-relay cloud login` + `agent-relay workspace
167
167
  * set_key`), when readable.
168
- * 3. Nothing -- silent skip, same as an unset primary env var.
168
+ * 3. Nothing -- the caller decides whether its output mode warrants a hint.
169
169
  *
170
170
  * `FLOWS_NO_OBSERVER=1` still suppresses regardless of source.
171
171
  */
@@ -189,7 +189,7 @@ export function resolveObserverLinkEnv(
189
189
  /**
190
190
  * Read the observer-link config from the environment. Both trims and
191
191
  * emptiness-checks the workspace key so a `RELAYCAST_WORKSPACE_KEY=` with no
192
- * value reads the same as unset (no observer link, silently).
192
+ * value reads the same as unset (no observer link).
193
193
  */
194
194
  export function readObserverLinkEnv(
195
195
  env: NodeJS.ProcessEnv = process.env,
@@ -258,7 +258,7 @@ export interface MintObserverOutcome {
258
258
  /**
259
259
  * A short diagnostic to log when mint failed. `undefined` on success or on
260
260
  * "no observer link is possible" (missing key/suppressed) so the caller can
261
- * distinguish silent-skip from "warn about the miss".
261
+ * distinguish an unavailable link from "warn about a mint failure".
262
262
  */
263
263
  warning?: string;
264
264
  }
package/src/preflight.ts CHANGED
@@ -91,7 +91,7 @@ export interface PreflightProbes {
91
91
  * model in scope, so readiness answers "can this CLI use THIS model" rather
92
92
  * than the weaker "is this CLI authenticated at all".
93
93
  */
94
- cli(cli: string, source: CliResolutionSource, model?: string, execution?: 'managed'): CliProbeResult;
94
+ cli(cli: string, source: CliResolutionSource, model?: string, execution?: 'managed', secrets?: string[]): CliProbeResult;
95
95
  executor(trigger: TriggerSpec): boolean;
96
96
  command(binary: string): boolean;
97
97
  }
@@ -340,7 +340,7 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul
340
340
  }
341
341
  const resolution = resolutionByStep.get(step.id)!;
342
342
  probeResolvedCli(resolution, options.probes, cliProbeResults, diagnostics,
343
- step.type === 'agent' && communicationInstruction(step.instruction) !== undefined);
343
+ step.type === 'agent' && communicationInstruction(step.instruction) !== undefined, step.secrets);
344
344
  }
345
345
 
346
346
  for (const trigger of compiled.triggers ?? []) {
@@ -529,8 +529,9 @@ function resolveCli(
529
529
  return undefined;
530
530
  }
531
531
 
532
- export function cliProbeKey(resolution: CliResolution, managed = false): string {
533
- return JSON.stringify([resolution.cli, resolution.source, resolution.model ?? null, managed]);
532
+ export function cliProbeKey(resolution: CliResolution, managed = false, secrets?: readonly string[]): string {
533
+ return JSON.stringify([resolution.cli, resolution.source, resolution.model ?? null, managed,
534
+ ...(secrets?.length ? [[...secrets].sort()] : [])]);
534
535
  }
535
536
 
536
537
  function probeResolvedCli(
@@ -539,6 +540,7 @@ function probeResolvedCli(
539
540
  cache: Map<string, CliProbeOutcome>,
540
541
  diagnostics: PreflightDiagnostic[],
541
542
  managed = false,
543
+ secrets?: string[],
542
544
  ): void {
543
545
  // Source is load-bearing: the same relative CLI string resolves from the
544
546
  // flow directory for step/named/flow declarations and the config directory for
@@ -546,13 +548,17 @@ function probeResolvedCli(
546
548
  // Model is part of the key: the same CLI probed with two different models
547
549
  // is two different questions, and caching on the CLI alone would let a
548
550
  // model that the CLI cannot resolve inherit an earlier model's pass.
549
- const cacheKey = cliProbeKey(resolution, managed);
551
+ const cacheKey = cliProbeKey(resolution, managed, secrets);
550
552
  let outcome = cache.get(cacheKey);
551
553
  if (outcome === undefined) {
552
554
  try {
553
555
  outcome = { result: managed
554
- ? probes.cli(resolution.cli, resolution.source, resolution.model, 'managed')
555
- : probes.cli(resolution.cli, resolution.source, resolution.model) };
556
+ ? secrets?.length
557
+ ? probes.cli(resolution.cli, resolution.source, resolution.model, 'managed', secrets)
558
+ : probes.cli(resolution.cli, resolution.source, resolution.model, 'managed')
559
+ : secrets?.length
560
+ ? probes.cli(resolution.cli, resolution.source, resolution.model, undefined, secrets)
561
+ : probes.cli(resolution.cli, resolution.source, resolution.model) };
556
562
  } catch (error) {
557
563
  const detail = error instanceof CliProbeError ? error.detail : undefined;
558
564
  outcome = { failure: detail ?? null };
package/src/spec.ts CHANGED
@@ -217,6 +217,8 @@ export interface KernelPlacementRequirements extends Omit<PlacementRequirements,
217
217
 
218
218
  /** Fields shared by every step on the ladder. */
219
219
  export interface BaseStepSpec {
220
+ /** Names of run secrets granted to this step; values never enter the spec. */
221
+ secrets?: string[];
220
222
  /** Named values selected from earlier steps' declared, verified outputs. */
221
223
  input?: Record<string, OutputBinding>;
222
224
  requirements?: PlacementRequirements;
@@ -478,6 +480,8 @@ export interface KernelVerificationSpec {
478
480
  }
479
481
 
480
482
  export interface KernelStepCommon {
483
+ /** Names only; the worker/kernel selects values from the run environment. */
484
+ secrets?: string[];
481
485
  input?: Record<string, OutputBinding>;
482
486
  requirements?: KernelPlacementRequirements;
483
487
  memory?: KernelMemorySpec;
@@ -21,6 +21,7 @@ export const AGENT_DECLARATION_FIELDS = ['cli', 'model'] as const;
21
21
  export const STEP_COMMON_FIELDS = [
22
22
  'id',
23
23
  'type',
24
+ 'secrets',
24
25
  'dependsOn',
25
26
  'input',
26
27
  'verification',
@@ -0,0 +1,145 @@
1
+ import type { CliAdapterKind } from './cli-adapter.js';
2
+
3
+ /** Cloud supplies this name-only manifest alongside delivered envSecrets. */
4
+ export const SECRET_ENV_NAMES = 'RELAY_SECRET_ENV_NAMES';
5
+ /** Cloud's name-only exception for model login it selected from delivered values. */
6
+ export const PLATFORM_AGENT_ENV_NAMES = 'RELAY_PLATFORM_AGENT_ENV_NAMES';
7
+ /** CLI-only hosted signal read capability. Never a run or step secret. */
8
+ export const SIGNAL_READ_TOKEN_ENV = 'RELAYFLOW_V2_SIGNAL_READ_TOKEN';
9
+ const VALID_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
10
+ const BEDROCK_LOGIN = new Set([
11
+ 'CLAUDE_CODE_USE_BEDROCK', 'AWS_BEARER_TOKEN_BEDROCK',
12
+ 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN',
13
+ 'AWS_REGION', 'AWS_DEFAULT_REGION',
14
+ ]);
15
+
16
+ const REPOSITORY_ENV = new Set([
17
+ 'GH_TOKEN', 'GH_CONFIG_DIR', 'GH_PROMPT_DISABLED', 'GITLAB_TOKEN',
18
+ 'RELAYFLOW_V2_GITLAB_API_URL',
19
+ ]);
20
+ export function repositoryEnvironmentNames(source: NodeJS.ProcessEnv): string[] {
21
+ return Object.keys(source).filter(name => REPOSITORY_ENV.has(name) || name.startsWith('GIT_')
22
+ || name.startsWith('RELAYFLOW_V2_REPOSITORY_'));
23
+ }
24
+
25
+ const BASE_ENV = [
26
+ 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TERM', 'LANG', 'LANGUAGE',
27
+ 'LC_ALL', 'LC_CTYPE', 'TZ', 'TMPDIR', 'TMP', 'TEMP', 'XDG_RUNTIME_DIR',
28
+ 'XDG_CACHE_HOME', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'HOSTNAME',
29
+ 'NODE_PATH', 'NODE_ENV', 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE',
30
+ 'SSL_CERT_DIR', 'NVM_DIR', 'NVM_BIN', 'NVM_INC',
31
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
32
+ 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy',
33
+ 'NVM_SYMLINK_CURRENT', 'PIPX_HOME', 'PIPX_BIN_DIR', 'PYTHON_PATH',
34
+ 'PYTHON_VERSION', 'PYTHON_SHA256', 'RELAYFILE_MOUNT_PATH',
35
+ 'RELAYFILE_MOUNT_PATHS', 'RELAYFILE_MOUNT_ROOT', 'WORKSPACE_ROOT',
36
+ 'RELAY_BASE_URL',
37
+ 'SystemRoot', 'ComSpec', 'PATHEXT', 'WINDIR',
38
+ ] as const;
39
+
40
+ // Model login is an intrinsic capability of the selected CLI. It is not a
41
+ // run's delivered envSecret; only that CLI gets its own login variables.
42
+ const CLI_LOGIN: Record<CliAdapterKind, readonly string[]> = {
43
+ claude: [
44
+ 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CONFIG_DIR', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN',
45
+ 'ANTHROPIC_BASE_URL', 'CLAUDE_CODE_USE_BEDROCK',
46
+ 'AWS_BEARER_TOKEN_BEDROCK', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY',
47
+ 'AWS_SESSION_TOKEN', 'AWS_REGION', 'AWS_DEFAULT_REGION',
48
+ ],
49
+ codex: ['CODEX_HOME', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENROUTER_API_KEY'],
50
+ 'relayflows-wrapper-v1': [],
51
+ };
52
+
53
+ /** A selected CLI can use its own delivered login; generic cloud credentials need a grant. */
54
+ export function isCliLoginName(kind: string, name: string, providerNames?: readonly string[]): boolean {
55
+ if (BEDROCK_LOGIN.has(name) || name.startsWith('AWS_')
56
+ || name === 'GOOGLE_APPLICATION_CREDENTIALS' || name === 'GOOGLE_API_KEY') return false;
57
+ const names = providerNames ?? (Object.hasOwn(CLI_LOGIN, kind) ? CLI_LOGIN[kind as CliAdapterKind] : []);
58
+ return names.includes(name);
59
+ }
60
+
61
+ export function deliveredNames(source: NodeJS.ProcessEnv): ReadonlySet<string> | undefined {
62
+ const raw = source[SECRET_ENV_NAMES];
63
+ if (raw === undefined) return undefined; // self-hosted runs may grant present env names
64
+ let parsed: unknown;
65
+ try { parsed = JSON.parse(raw); } catch { throw new Error(`${SECRET_ENV_NAMES} must be a JSON array of names`); }
66
+ if (!Array.isArray(parsed) || !parsed.every(name => typeof name === 'string' && VALID_NAME.test(name))) {
67
+ throw new Error(`${SECRET_ENV_NAMES} must be a JSON array of valid names`);
68
+ }
69
+ if (new Set(parsed).size !== parsed.length || parsed.includes(SECRET_ENV_NAMES)
70
+ || parsed.includes(PLATFORM_AGENT_ENV_NAMES)
71
+ || parsed.includes(SIGNAL_READ_TOKEN_ENV)) {
72
+ throw new Error(`${SECRET_ENV_NAMES} contains a duplicate or reserved name`);
73
+ }
74
+ return new Set(parsed);
75
+ }
76
+
77
+ /** Only Cloud-selected Claude Bedrock login may override the delivered filter. */
78
+ export function platformAgentNames(source: NodeJS.ProcessEnv, kind: string): ReadonlySet<string> {
79
+ const raw = source[PLATFORM_AGENT_ENV_NAMES];
80
+ if (raw === undefined) return new Set();
81
+ let parsed: unknown;
82
+ try { parsed = JSON.parse(raw); } catch { throw new Error(`${PLATFORM_AGENT_ENV_NAMES} must be a JSON array of names`); }
83
+ if (!Array.isArray(parsed) || !parsed.every(name => typeof name === 'string' && BEDROCK_LOGIN.has(name)
84
+ && typeof source[name] === 'string') || new Set(parsed).size !== parsed.length) {
85
+ throw new Error(`${PLATFORM_AGENT_ENV_NAMES} contains an invalid or unavailable model login name`);
86
+ }
87
+ return kind === 'claude' ? new Set(parsed) : new Set();
88
+ }
89
+
90
+ /** Refuse a misspelled, unavailable or platform-owned name before a child runs. */
91
+ export function validateStepSecrets(declaration: unknown, source: NodeJS.ProcessEnv): string[] {
92
+ const known = deliveredNames(source);
93
+ if (declaration === undefined) return [];
94
+ if (!Array.isArray(declaration) || !declaration.every(name => typeof name === 'string' && VALID_NAME.test(name))) {
95
+ throw new Error('step secrets must be an array of environment variable names');
96
+ }
97
+ const names = declaration as string[];
98
+ if (new Set(names).size !== names.length) throw new Error('step secrets contain duplicate names');
99
+ for (const name of names) {
100
+ if (name === SECRET_ENV_NAMES || name === PLATFORM_AGENT_ENV_NAMES || name === SIGNAL_READ_TOKEN_ENV
101
+ || !Object.hasOwn(source, name) || typeof source[name] !== 'string'
102
+ || (known !== undefined && !known.has(name))) {
103
+ throw new Error(`step secret ${JSON.stringify(name)} is not available to this run`);
104
+ }
105
+ }
106
+ return [...names];
107
+ }
108
+
109
+ /** One provider CLI starts from an allowlist, then receives only its grants. */
110
+ export function agentStepEnvironment(
111
+ source: NodeJS.ProcessEnv,
112
+ kind: CliAdapterKind,
113
+ declaration?: unknown,
114
+ ): NodeJS.ProcessEnv {
115
+ const known = deliveredNames(source);
116
+ const platform = platformAgentNames(source, kind);
117
+ const grants = validateStepSecrets(declaration, source);
118
+ const env: NodeJS.ProcessEnv = {};
119
+ for (const name of [...BASE_ENV, ...CLI_LOGIN[kind], ...repositoryEnvironmentNames(source)]) {
120
+ if (known?.has(name) && !grants.includes(name) && !platform.has(name)
121
+ && !isCliLoginName(kind, name)) continue;
122
+ if (source[name] !== undefined) env[name] = source[name];
123
+ }
124
+ for (const name of grants) env[name] = source[name];
125
+ return env;
126
+ }
127
+
128
+ /** Provider readiness probes run before worker attach, but obey the step's grants. */
129
+ export function agentProbeEnvironment(
130
+ source: NodeJS.ProcessEnv,
131
+ kind: CliAdapterKind,
132
+ declaration?: unknown,
133
+ ): NodeJS.ProcessEnv {
134
+ const env = { ...source };
135
+ const allowed = new Set(validateStepSecrets(declaration, source));
136
+ const platform = platformAgentNames(source, kind);
137
+ for (const name of deliveredNames(source) ?? []) {
138
+ if (!allowed.has(name) && !platform.has(name) && !isCliLoginName(kind, name)) delete env[name];
139
+ }
140
+ delete env[SECRET_ENV_NAMES];
141
+ delete env[PLATFORM_AGENT_ENV_NAMES];
142
+ delete env[SIGNAL_READ_TOKEN_ENV];
143
+ delete env.RELAY_AGENT_TOKEN;
144
+ return env;
145
+ }
package/src/validate.ts CHANGED
@@ -388,6 +388,14 @@ class Validator {
388
388
  const type = st['type'] as StepType;
389
389
  this.checkKeys(st, [...STEP_COMMON_FIELDS, ...STEP_FIELDS_BY_TYPE[type]], at);
390
390
 
391
+ if (st['secrets'] !== undefined) {
392
+ const names = st['secrets'];
393
+ if (!Array.isArray(names) || !names.every(name => typeof name === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
394
+ || new Set(names).size !== names.length || names.includes('RELAY_SECRET_ENV_NAMES')) {
395
+ this.fail(`${at}.secrets: expected distinct environment variable names`);
396
+ }
397
+ }
398
+
391
399
  if (st['requirements'] !== undefined) this.validateRequirements(st['requirements'], `${at}.requirements`);
392
400
  if (st['memory'] !== undefined) this.validateMemory(st['memory'], `${at}.memory`);
393
401
 
@@ -491,6 +499,9 @@ class Validator {
491
499
  }
492
500
 
493
501
  private validateAgent(st: AgentStepSpec, at: string): void {
502
+ if (st.transport === 'relay' && st.secrets?.length) {
503
+ this.fail(`${at}.secrets: relay transport cannot deliver per-step secrets`);
504
+ }
494
505
  if (st.timeoutMs !== undefined) {
495
506
  if (!isPosInt(st.timeoutMs) || st.timeoutMs > 60 * 60_000) {
496
507
  this.fail(`${at}.timeoutMs: expected a positive integer at most 3600000 (60 minutes)`);
package/src/worker-cli.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  } from './wrapper-session.js';
32
32
  import { wrapperEnvironment } from './wrapper-runtime.js';
33
33
  import { applyStepEnvironment } from './step-env.js';
34
+ import { PLATFORM_AGENT_ENV_NAMES, SIGNAL_READ_TOKEN_ENV, agentStepEnvironment } from './step-secrets.js';
34
35
  import { TAIL_CLOSE_TIMEOUT_MS, openTranscriptTail, type TranscriptTailWriter } from './transcript-tail.js';
35
36
  import {
36
37
  isRetryableSpawnError,
@@ -139,12 +140,33 @@ export async function runAgentCli(
139
140
  stepTimeoutMs?: number,
140
141
  /** A recorded CLI session to continue (`f.agent` `resume`); agent mode only. */
141
142
  resume?: string,
143
+ /** Names of run secrets this step was granted by its journaled spec. */
144
+ secrets?: string[],
145
+ /** A featureless daemon retains its legacy child environment for plain runs. */
146
+ scopeSecrets = true,
142
147
  ): Promise<WorkerCliResult> {
143
148
  signal?.throwIfAborted();
144
149
  if (signal !== undefined && process.platform === 'win32') {
145
150
  throw new Error('Lease-bound agent execution requires macOS or Linux process-group cancellation; Windows is unsupported.');
146
151
  }
147
152
  const kind = cliAdapterKind(cliIdentity ?? cli);
153
+ if (!scopeSecrets && secrets?.length) {
154
+ return { exit_code: null, stdout_tail: '',
155
+ stderr_tail: 'per-step secret grants require a daemon with step_secret_scoping support' };
156
+ }
157
+ let childEnvironment: NodeJS.ProcessEnv;
158
+ try {
159
+ childEnvironment = scopeSecrets
160
+ ? agentStepEnvironment(processEnvironment, kind, secrets)
161
+ : { ...processEnvironment };
162
+ // The old-daemon compatibility path retains other legacy variables, but
163
+ // this CLI-only signal capability must never enter an agent subprocess.
164
+ delete childEnvironment[SIGNAL_READ_TOKEN_ENV];
165
+ delete childEnvironment[PLATFORM_AGENT_ENV_NAMES];
166
+ } catch (error) {
167
+ return { exit_code: null, stdout_tail: '',
168
+ stderr_tail: error instanceof Error ? error.message : 'step secret grant is invalid' };
169
+ }
148
170
  const effectiveModel = resolveCliModel(cliIdentity ?? cli, model);
149
171
  const argv0 = cliInvocationArgv0(cli, cliIdentity);
150
172
 
@@ -206,7 +228,8 @@ export async function runAgentCli(
206
228
  if (kind === 'relayflows-wrapper-v1') {
207
229
  // The closed allowlist admits no ambient RELAYFLOW_* value; the four
208
230
  // discovery names are set from this dispatch, exactly as for a direct spawn.
209
- const wrapperEnv = wrapperEnvironment(processEnvironment);
231
+ const wrapperEnv = wrapperEnvironment(childEnvironment);
232
+ for (const name of secrets ?? []) wrapperEnv[name] = childEnvironment[name];
210
233
  applyStepEnvironment(wrapperEnv, sidechannel);
211
234
  const session = await runWrapperSession(
212
235
  cli,
@@ -225,7 +248,7 @@ export async function runAgentCli(
225
248
  return requirePricedUsage(session.transport?.cause === 'timeout' ? { ...decoded, stdout_tail: '' } : decoded, effectiveModel);
226
249
  }
227
250
 
228
- const env: NodeJS.ProcessEnv = { ...processEnvironment };
251
+ const env: NodeJS.ProcessEnv = { ...childEnvironment };
229
252
  delete env[WAKE_CONTEXT_ENV];
230
253
  delete env[MODEL_ENV];
231
254
  // Where this attempt's journal is, so the agent can run `flows status` on
@@ -256,9 +279,13 @@ export async function runAgentCli(
256
279
  const args = [...invocation.args];
257
280
  args.splice(args.length - promptOperand('').length, 0, ...(kind === 'claude' ? ['--output-format', 'stream-json', '--verbose'] : ['--json']));
258
281
  const completion = kind === 'claude' ? claudeResultOutcome : undefined;
282
+ // Retain the source values for output redaction even though the child
283
+ // receives only its filtered environment. A provider can echo a value from
284
+ // its prompt or fixture that was not granted as an environment variable.
285
+ const redactionEnvironment = { ...processEnvironment, ...env };
259
286
  return requirePricedUsage(decodeProviderResult(await spawnInvocation(
260
- cli, { ...invocation, args }, env, signal, sidechannel, cwd, completion, argv0, kind,
261
- ), kind, env), effectiveModel);
287
+ cli, { ...invocation, args }, env, signal, sidechannel, cwd, completion, argv0, kind, redactionEnvironment,
288
+ ), kind, redactionEnvironment), effectiveModel);
262
289
  }
263
290
  }
264
291
 
@@ -399,6 +426,7 @@ async function spawnInvocation(
399
426
  completion?: (line: string) => { failed: boolean } | undefined,
400
427
  argv0?: string,
401
428
  kind?: CliAdapterKind,
429
+ redactionEnvironment: NodeJS.ProcessEnv = env,
402
430
  ): Promise<WorkerCliResult> {
403
431
  const pendingInput: Buffer[] = [];
404
432
  let acceptingDrive = true;
@@ -436,7 +464,7 @@ async function spawnInvocation(
436
464
  };
437
465
  try {
438
466
  writer = sidechannel?.attempt === undefined ? undefined
439
- : await openTranscriptWriter(transcriptPath(sidechannel, sidechannel.attempt), env);
467
+ : await openTranscriptWriter(transcriptPath(sidechannel, sidechannel.attempt), redactionEnvironment);
440
468
  channel = sidechannel === undefined ? undefined : await openSidechannel({
441
469
  ...sidechannel,
442
470
  onDrive() { driven = true; sidechannel.onDrive(); },
@@ -597,7 +625,7 @@ async function spawnInvocation(
597
625
  const transport = transportEvidence({
598
626
  phase: 'abort', cause: 'lease_lost', exitCode: null, signal: null,
599
627
  stderr: 'Agent execution aborted: lease ownership lost.', retryable: false,
600
- }, env);
628
+ }, redactionEnvironment);
601
629
  finishAfterStop(
602
630
  { exit_code: null, stdout_tail: '', stderr_tail: 'Agent execution aborted: lease ownership lost.', transport },
603
631
  'kill',
@@ -635,11 +663,11 @@ async function spawnInvocation(
635
663
  phase: 'result_exit_grace', cause: 'result_exit_timeout', exitCode, signal: null,
636
664
  stderr: stderrText,
637
665
  retryable: false,
638
- }, env);
666
+ }, redactionEnvironment);
639
667
  finishAfterStop({
640
668
  exit_code: exitCode,
641
669
  stdout_tail: Buffer.concat(stdout).toString('utf8'),
642
- stderr_tail: redactText(stderrText, env),
670
+ stderr_tail: redactText(stderrText, redactionEnvironment),
643
671
  transport,
644
672
  }, 'terminate');
645
673
  }, RESULT_EXIT_GRACE_MS);
@@ -671,11 +699,11 @@ async function spawnInvocation(
671
699
  const transport = transportEvidence({
672
700
  phase: 'spawn', cause: child.pid === undefined ? 'spawn_error' : 'process_error',
673
701
  exitCode: null, signal: null, stderr: error.message, errorCode: code, retryable,
674
- }, env);
702
+ }, redactionEnvironment);
675
703
  finishOnChildExit({
676
704
  exit_code: null,
677
705
  stdout_tail: Buffer.concat(stdout).toString('utf8'),
678
- stderr_tail: redactText(error.message, env),
706
+ stderr_tail: redactText(error.message, redactionEnvironment),
679
707
  transport,
680
708
  });
681
709
  });
@@ -692,11 +720,11 @@ async function spawnInvocation(
692
720
  const retryable = codexStdinLifecycle || signalName !== null || code === null;
693
721
  const transport = transportEvidence({
694
722
  phase: 'close', cause, exitCode: code, signal: signalName, stderr: stderrText, retryable,
695
- }, env);
723
+ }, redactionEnvironment);
696
724
  finishOnChildExit({
697
725
  exit_code: code,
698
726
  stdout_tail: Buffer.concat(stdout).toString('utf8'),
699
- stderr_tail: redactText(stderrText, env),
727
+ stderr_tail: redactText(stderrText, redactionEnvironment),
700
728
  transport,
701
729
  });
702
730
  });
@@ -710,7 +738,7 @@ async function spawnInvocation(
710
738
  phase: 'timeout', cause: 'timeout', exitCode: null, signal: null,
711
739
  stderr: `${Buffer.concat(stderr).toString('utf8')}\n${timeoutMessage}`,
712
740
  retryable: false,
713
- }, env);
741
+ }, redactionEnvironment);
714
742
  finishAfterStop({
715
743
  exit_code: null,
716
744
  stdout_tail: Buffer.concat(stdout).toString('utf8'),