@relayflows/sdk 2.0.50 → 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 (100) 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/cloud-http.d.ts +1 -0
  17. package/dist/cloud-http.d.ts.map +1 -1
  18. package/dist/cloud-http.js +1 -1
  19. package/dist/cloud-http.js.map +1 -1
  20. package/dist/cloud-run.d.ts +1 -1
  21. package/dist/cloud-run.d.ts.map +1 -1
  22. package/dist/cloud-run.js +45 -6
  23. package/dist/cloud-run.js.map +1 -1
  24. package/dist/cloud-signal.d.ts +12 -0
  25. package/dist/cloud-signal.d.ts.map +1 -0
  26. package/dist/cloud-signal.js +117 -0
  27. package/dist/cloud-signal.js.map +1 -0
  28. package/dist/cloud-sync.d.ts +8 -1
  29. package/dist/cloud-sync.d.ts.map +1 -1
  30. package/dist/cloud-sync.js +36 -4
  31. package/dist/cloud-sync.js.map +1 -1
  32. package/dist/communication/environment.d.ts +1 -1
  33. package/dist/communication/environment.d.ts.map +1 -1
  34. package/dist/communication/environment.js +26 -3
  35. package/dist/communication/environment.js.map +1 -1
  36. package/dist/communication/worker.js +1 -1
  37. package/dist/communication/worker.js.map +1 -1
  38. package/dist/compile.d.ts.map +1 -1
  39. package/dist/compile.js +5 -2
  40. package/dist/compile.js.map +1 -1
  41. package/dist/index.d.ts +1 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +1 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/journal-client.d.ts +2 -0
  46. package/dist/journal-client.d.ts.map +1 -1
  47. package/dist/journal-client.js +26 -3
  48. package/dist/journal-client.js.map +1 -1
  49. package/dist/llm-worker.d.ts.map +1 -1
  50. package/dist/llm-worker.js +1 -1
  51. package/dist/llm-worker.js.map +1 -1
  52. package/dist/preflight.d.ts +2 -2
  53. package/dist/preflight.d.ts.map +1 -1
  54. package/dist/preflight.js +12 -7
  55. package/dist/preflight.js.map +1 -1
  56. package/dist/spec.d.ts +4 -0
  57. package/dist/spec.d.ts.map +1 -1
  58. package/dist/spec.js.map +1 -1
  59. package/dist/step-fields.d.ts +1 -1
  60. package/dist/step-fields.d.ts.map +1 -1
  61. package/dist/step-fields.js +1 -0
  62. package/dist/step-fields.js.map +1 -1
  63. package/dist/step-secrets.d.ts +20 -0
  64. package/dist/step-secrets.d.ts.map +1 -0
  65. package/dist/step-secrets.js +146 -0
  66. package/dist/step-secrets.js.map +1 -0
  67. package/dist/validate.d.ts.map +1 -1
  68. package/dist/validate.js +10 -0
  69. package/dist/validate.js.map +1 -1
  70. package/dist/worker-cli.d.ts +5 -1
  71. package/dist/worker-cli.d.ts.map +1 -1
  72. package/dist/worker-cli.js +43 -14
  73. package/dist/worker-cli.js.map +1 -1
  74. package/dist/worker.d.ts +1 -1
  75. package/dist/worker.d.ts.map +1 -1
  76. package/dist/worker.js +1 -1
  77. package/dist/worker.js.map +1 -1
  78. package/package.json +2 -2
  79. package/src/authored-flow-executor.ts +4 -2
  80. package/src/authored-preflight.ts +4 -4
  81. package/src/authored-worker-step.ts +17 -6
  82. package/src/cli/check.ts +3 -3
  83. package/src/cli/cli-probe.ts +9 -6
  84. package/src/cloud-http.ts +1 -1
  85. package/src/cloud-run.ts +45 -8
  86. package/src/cloud-signal.ts +110 -0
  87. package/src/cloud-sync.ts +39 -4
  88. package/src/communication/environment.ts +28 -3
  89. package/src/communication/worker.ts +2 -1
  90. package/src/compile.ts +5 -2
  91. package/src/index.ts +1 -0
  92. package/src/journal-client.ts +29 -2
  93. package/src/llm-worker.ts +2 -1
  94. package/src/preflight.ts +13 -7
  95. package/src/spec.ts +4 -0
  96. package/src/step-fields.ts +1 -0
  97. package/src/step-secrets.ts +145 -0
  98. package/src/validate.ts +11 -0
  99. package/src/worker-cli.ts +41 -13
  100. package/src/worker.ts +3 -2
@@ -20,6 +20,7 @@ import { alsoRecord, recordAuthoredChild } from './authored-step-index.js';
20
20
  import type { StepFailedDetails } from './failure-kinds.js';
21
21
  import { LOCAL_AGENT_WORKSPACE } from './local-agent-pins.js';
22
22
  import { authoredWorkerSlots, type AuthoredWorkerSlots } from './worker-slots.js';
23
+ import { validateStepSecrets } from './step-secrets.js';
23
24
 
24
25
  const AGENT_TIMEOUT = Symbol('declared agent timeout');
25
26
  const WORKSPACE_PERMISSION_ANNOTATION = /:\s*(readonly|readwrite)\s*$/i;
@@ -177,6 +178,8 @@ export function authoredWorkerRunner(
177
178
  }
178
179
  },
179
180
  async agent(id: string, options: AgentOptions, verification?: NamedGate): Promise<AgentResult> {
181
+ const secrets = validateStepSecrets((options as AgentOptions & { secrets?: unknown }).secrets,
182
+ agentEnvironment ?? process.env);
180
183
  if (options.workspace !== undefined && localAgentStream !== undefined) {
181
184
  throw new AuthoredFlowExecutionError('unsupported_workspace_permission',
182
185
  'The local agent worker accepts stream-only steps. Remove workspace or attach a worker that holds its revision pins.');
@@ -277,6 +280,7 @@ export function authoredWorkerRunner(
277
280
  : snapshotJsonValue(permissions, 'f.agent options.permissions') as unknown as PermissionsSpec;
278
281
  const output = await run({
279
282
  id, type: 'agent', instruction: options.task,
283
+ ...(secrets.length ? { secrets } : {}),
280
284
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
281
285
  ...(permissionsSnapshot === undefined ? {} : { permissions: permissionsSnapshot }),
282
286
  ...(localAgentStream === undefined ? {} : { surfaces: {
@@ -323,11 +327,14 @@ export function authoredWorkerRunner(
323
327
  }
324
328
  const snapshot = options === undefined ? undefined : snapshotJsonValue(options, 'f.llm options');
325
329
  if (snapshot !== undefined && (typeof snapshot !== 'object' || snapshot === null || Array.isArray(snapshot)
326
- || Object.keys(snapshot).some(key => !['output', 'cli', 'model'].includes(key)))) {
327
- throw new AuthoredFlowExecutionError('llm_cli_unresolved', 'f.llm options accepts only output, cli, and model.');
330
+ || Object.keys(snapshot).some(key => !['output', 'cli', 'model', 'secrets'].includes(key)))) {
331
+ throw new AuthoredFlowExecutionError('llm_cli_unresolved', 'f.llm options accepts only output, cli, model, and secrets.');
328
332
  }
333
+ const secrets = validateStepSecrets((snapshot as { secrets?: unknown } | undefined)?.secrets,
334
+ agentEnvironment ?? process.env);
329
335
  const output = await run({
330
336
  id, type: 'llm', prompt, ...snapshot as unknown as LlmOptions,
337
+ ...(secrets.length ? { secrets } : {}),
331
338
  ...(verification === undefined ? {} : { verification }),
332
339
  });
333
340
  if (options === undefined && typeof output !== 'string') {
@@ -358,6 +365,7 @@ export function authoredDeterministicRunner(
358
365
  function lower<T>(
359
366
  id: string, command: string, terminal: boolean, leaseMs: number | undefined,
360
367
  verification: NamedGate | undefined, onNonZero: 'record' | undefined,
368
+ secrets: string[] | undefined,
361
369
  read: (outcome: import('./protocol.js').RunOutcome) => Promise<T>,
362
370
  ): Promise<T> {
363
371
  const spec = toKernelSpec(compileSpec({
@@ -365,6 +373,7 @@ export function authoredDeterministicRunner(
365
373
  name: `${name}/${id}`,
366
374
  steps: [{
367
375
  id, type: 'deterministic', command,
376
+ ...(secrets?.length ? { secrets } : {}),
368
377
  ...(leaseMs === undefined ? {} : { lease_ms: leaseMs }),
369
378
  ...(onNonZero === undefined ? {} : { onNonZero }),
370
379
  ...(verification === undefined ? {} : { verification }),
@@ -380,12 +389,14 @@ export function authoredDeterministicRunner(
380
389
  if (terminal) return journal.runStart(spec, undefined, admissionKey).then(consume);
381
390
  return budget.execute(journal, spec, consume, admissionKey);
382
391
  }
383
- const lowerRun = (id: string, command: string, terminal = false, leaseMs?: number, verification?: NamedGate): Promise<string> =>
384
- lower(id, command, terminal, leaseMs, verification, undefined,
392
+ const lowerRun = (id: string, command: string, terminal = false, leaseMs?: number, verification?: NamedGate,
393
+ secrets?: string[]): Promise<string> =>
394
+ lower(id, command, terminal, leaseMs, verification, undefined, secrets,
385
395
  outcome => readSuccessfulOutput(journal, outcome, id, journalSteps, context));
386
396
  /** `f.run(..., { onNonZero: 'record' })`: the exit code is the result, not a throw. */
387
- lowerRun.recording = (id: string, command: string, leaseMs?: number, verification?: NamedGate): Promise<RecordedRunOutcome> =>
388
- lower(id, command, false, leaseMs, verification, 'record',
397
+ lowerRun.recording = (id: string, command: string, leaseMs?: number, verification?: NamedGate,
398
+ secrets?: string[]): Promise<RecordedRunOutcome> =>
399
+ lower(id, command, false, leaseMs, verification, 'record', secrets,
389
400
  outcome => readRecordedOutcome(journal, outcome, id, journalSteps, context));
390
401
  return lowerRun;
391
402
  }
package/src/cli/check.ts CHANGED
@@ -492,8 +492,8 @@ function findConfig(start: string): string | undefined {
492
492
  function systemProbes(flowDirectory: string, config: ProjectConfig, environment?: NodeJS.ProcessEnv): PreflightProbes {
493
493
  return {
494
494
  helper: helperReady,
495
- cli: (cli, source, model, execution) => probeCli(cli,
496
- source === 'project' ? config.directory : flowDirectory, model, execution, environment),
495
+ cli: (cli, source, model, execution, secrets) => probeCli(cli,
496
+ source === 'project' ? config.directory : flowDirectory, model, execution, environment, secrets),
497
497
  executor: (trigger) => config.executors.includes(trigger.executor),
498
498
  // A deterministic step runs in the daemon's working directory — the
499
499
  // directory `flows run` was invoked from, or Cloud's code mount — not in
@@ -521,7 +521,7 @@ function bindResolvedCliPaths(
521
521
  if (resolution === undefined) return step;
522
522
  const directory = resolution.source === 'project' ? configDirectory : flowDirectory;
523
523
  const managed = step.type === 'agent' && communicationInstruction(step.instruction) !== undefined;
524
- const outcome = probeCache.get(cliProbeKey(resolution, managed));
524
+ const outcome = probeCache.get(cliProbeKey(resolution, managed, step.secrets));
525
525
  const executable = outcome !== undefined && 'result' in outcome
526
526
  ? outcome.result.executable
527
527
  : undefined;
@@ -7,6 +7,7 @@ import { adapterIdentification, authenticationProbe, classifyModelProbeFailure,
7
7
  import { MODEL_ENV } from '../worker-cli.js';
8
8
  import { CliProbeError, type CliProbeResult } from '../preflight.js';
9
9
  import { pinCliAlias, pinCliAliasSync } from './pinned-cli-alias.js';
10
+ import { agentProbeEnvironment } from '../step-secrets.js';
10
11
 
11
12
  interface ProbeRequest {
12
13
  argv0?: string;
@@ -30,7 +31,8 @@ export async function probeCliAsync(...args: Parameters<typeof probeSequence>):
30
31
  }
31
32
 
32
33
  export function resolveExecutable(command: string, directory: string, environment: NodeJS.ProcessEnv = process.env): string | undefined {
33
- return driveSync(executableSequence(command, directory, environment));
34
+ return driveSync(executableSequence(command, directory,
35
+ agentProbeEnvironment(environment, 'relayflows-wrapper-v1')));
34
36
  }
35
37
 
36
38
  function driveSync<T>(sequence: Generator<ProbeRequest, T, ProbeOutput>): T {
@@ -94,23 +96,24 @@ function* probeSequence(
94
96
  model?: string,
95
97
  execution?: 'managed',
96
98
  sourceEnvironment: NodeJS.ProcessEnv = process.env,
99
+ grants?: string[],
97
100
  ): Generator<ProbeRequest, CliProbeResult, ProbeOutput> {
98
- const executable = yield* executableSequence(cli, directory, sourceEnvironment);
101
+ const kind = cliAdapterKind(cli);
102
+ const environment = execution === 'managed'
103
+ ? { ...brokerEnvironment(sourceEnvironment), ...agentEnvironment(cli, sourceEnvironment, grants) }
104
+ : agentProbeEnvironment(sourceEnvironment, kind, grants);
105
+ const executable = yield* executableSequence(cli, directory, environment);
99
106
  if (executable === undefined) return { exists: false, authenticated: false };
100
107
  // The declaration selects the adapter; the canonical executable selects
101
108
  // the bytes. Package-manager links commonly name `claude` or `codex` while
102
109
  // targeting a generic `cli.js`, whose basename must not rewrite the adapter
103
110
  // contract after resolution.
104
- const kind = cliAdapterKind(cli);
105
111
  // Relay owns interactive CLI launch/injection. Its generic PTY path is not
106
112
  // the headless wrapper protocol; do not demand that protocol from Gemini,
107
113
  // Cursor, OpenCode, or other interactive tools. Never invent an auth pass.
108
114
  if (execution === 'managed' && kind === 'relayflows-wrapper-v1') {
109
115
  return { exists: true, supported: true, authenticated: 'unverified', executable };
110
116
  }
111
- const environment = execution === 'managed'
112
- ? { ...brokerEnvironment(sourceEnvironment), ...agentEnvironment(cli, sourceEnvironment) }
113
- : sourceEnvironment;
114
117
  const probe = (invocation: CliInvocation): ProbeRequest => ({
115
118
  executable,
116
119
  argv0: basename(cli),
package/src/cloud-http.ts CHANGED
@@ -290,7 +290,7 @@ export function isCloudRecord(value: unknown): value is Record<string, unknown>
290
290
  return typeof value === 'object' && value !== null && !Array.isArray(value);
291
291
  }
292
292
 
293
- function transportError(error: unknown): CloudFlowError {
293
+ export function transportError(error: unknown): CloudFlowError {
294
294
  const cause = error instanceof Error ? error.cause : undefined;
295
295
  const code = isCloudRecord(cause) ? cause.code : undefined;
296
296
  const transient = (error instanceof Error && error.name === 'TimeoutError')
package/src/cloud-run.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  type CloudConnectionOptions,
15
15
  } from './cloud-http.js';
16
16
  import { cloudRunState, isCloudRunActive, type CloudRunState } from './cloud-run-record.js';
17
- import { packWorkingTree, prepareCloudSync, uploadCloudCode } from './cloud-sync.js';
17
+ import { canUseCodeCache, packWorkingTree, prepareCloudSync, sha256PackedTree, uploadCloudCode } from './cloud-sync.js';
18
18
 
19
19
  export type CloudFlowSource = FlowSpec | { path: string };
20
20
  export const MIN_CLOUD_RUN_BUDGET_MINUTES = 30;
@@ -65,7 +65,7 @@ export interface CloudRunReceipt {
65
65
  specHash: string;
66
66
  /** Authenticated run API resource; this is not a public sharing URL. */
67
67
  apiUrl: string;
68
- /** Present when a working tree was synced: what was uploaded, by count and size. */
68
+ /** Present when a working tree was synced: its file count and uncompressed size. */
69
69
  synced?: { files: number; bytes: number; skippedLinks: string[] };
70
70
  }
71
71
  export interface CloudAuthoredAuthority {
@@ -119,6 +119,31 @@ function snapshotEnvSecrets(value: unknown): Record<string, string> | undefined
119
119
  return Object.keys(copy).length === 0 ? undefined : copy;
120
120
  }
121
121
 
122
+ /**
123
+ * A Cloud refusal is Cloud's own text. Should any of it ever quote an
124
+ * envSecrets value — the message, or a field such as `expected`/`received` —
125
+ * keep only a safe refusal code and drop the rest, so the value never reaches
126
+ * an error message, a log line or a caller's error report.
127
+ */
128
+ function withheldIfQuotingSecret(error: unknown, envSecrets: Record<string, string> | undefined): unknown {
129
+ if (envSecrets === undefined || !(error instanceof CloudFlowError) || error.refusal === undefined) return error;
130
+ const values = Object.values(envSecrets).filter(value => value.length > 0);
131
+ const quotes = (text: string): boolean => values.some(value => text.includes(value));
132
+ if (!quotes(error.message) && !refusalStrings(error.refusal).some(quotes)) return error;
133
+ const code = quotes(error.refusal.code) ? 'withheld' : error.refusal.code;
134
+ return new CloudFlowError(error.code,
135
+ `Cloud refused (${code}); its message was withheld because it quoted an envSecrets value.`,
136
+ error.status, { code, error: 'withheld: quoted an envSecrets value' });
137
+ }
138
+
139
+ /** Every string anywhere in a refusal, so a field added later is checked too. */
140
+ function refusalStrings(value: unknown): string[] {
141
+ if (typeof value === 'string') return [value];
142
+ if (Array.isArray(value)) return value.flatMap(refusalStrings);
143
+ if (value !== null && typeof value === 'object') return Object.values(value).flatMap(refusalStrings);
144
+ return [];
145
+ }
146
+
122
147
  /**
123
148
  * Submit a declarative flow to Cloud's pinned v2 runtime. Compilation only
124
149
  * happens here; Cloud owns provisioning and the Rust engine owns execution.
@@ -275,32 +300,44 @@ export async function runInCloud(
275
300
  // under it, so the run request below names code Cloud already holds. A
276
301
  // refused backend or failed upload therefore never leaves a launched run
277
302
  // pointing at a tree that is not there.
278
- let synced: { runId: string; codeKey: string; files: number; bytes: number; skippedLinks: string[] } | undefined;
303
+ let synced: { runId: string; codeKey: string; codeCacheHit: boolean; files: number; bytes: number; skippedLinks: string[] } | undefined;
279
304
  if (options.syncCode !== undefined) {
280
- const prepared = await prepareCloudSync(options);
281
305
  options.signal?.throwIfAborted();
282
306
  const packed = await packWorkingTree(options.syncCode.root);
307
+ let prepared: Awaited<ReturnType<typeof prepareCloudSync>>;
283
308
  try {
284
309
  options.signal?.throwIfAborted();
285
- await uploadCloudCode(prepared, packed, options);
310
+ const digest = canUseCodeCache(packed) ? await sha256PackedTree(packed) : undefined;
311
+ prepared = await prepareCloudSync(options, digest);
312
+ options.signal?.throwIfAborted();
313
+ if (!prepared.codeCacheHit) await uploadCloudCode(prepared, packed, options);
286
314
  } finally {
287
315
  packed.dispose();
288
316
  }
289
- synced = { runId: prepared.runId, codeKey: prepared.codeKey, files: packed.files.length, bytes: packed.bytes,
317
+ synced = { runId: prepared.runId, codeKey: prepared.codeKey, codeCacheHit: prepared.codeCacheHit === true,
318
+ files: packed.files.length, bytes: packed.bytes,
290
319
  skippedLinks: packed.skippedLinks };
291
320
  }
292
321
  options.signal?.throwIfAborted();
293
322
  options.onSubmit?.();
294
323
  let result: unknown;
295
324
  try {
296
- result = await cloudFetch('/api/v1/workflows/run', options, { method: 'POST', detail: true, body: JSON.stringify({
325
+ // A cache hit is copied into the run prefix after admission. The R2 copy
326
+ // may outlast the normal 30s request budget; explicit caller limits win.
327
+ const submitOptions = synced?.codeCacheHit
328
+ ? { ...options, requestTimeoutMs: options.requestTimeoutMs ?? 300_000 }
329
+ : options;
330
+ result = await cloudFetch('/api/v1/workflows/run', submitOptions, { method: 'POST', detail: true, body: JSON.stringify({
297
331
  ...cloudSubmissionBody(submission),
298
332
  ...(options.workspaceId === undefined ? {} : { workspaceId: options.workspaceId }),
299
333
  ...(options.runBudgetMinutes === undefined ? {} : { relayflowV2RunBudgetMinutes: options.runBudgetMinutes }),
300
334
  ...(synced === undefined ? {} : { runId: synced.runId, s3CodeKey: synced.codeKey }),
301
335
  ...(envSecrets === undefined ? {} : { envSecrets }),
302
336
  }) });
303
- } catch (error) {
337
+ } catch (caught) {
338
+ // Withhold first: every error thrown from here, including the rewrite
339
+ // below, carries only a refusal that quotes no envSecrets value.
340
+ const error = withheldIfQuotingSecret(caught, envSecrets);
304
341
  // Cloud accepts exactly one authored Surface (the one its sandbox runs).
305
342
  // A CLI on another release is refused with this code; say which side is
306
343
  // which instead of leaving the user with a bare 400 (flows#461).
@@ -0,0 +1,110 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { CloudFlowError, cloudFetch, cloudRunId, isCloudRecord, resolveCloudConnection,
3
+ transportError, type CloudConnectionOptions } from './cloud-http.js';
4
+
5
+ const SIGNAL_NAME = /^[A-Za-z0-9_-]{1,80}$/u;
6
+ const MAX_BYTES = 4 * 1024;
7
+ const HELD_GET_MS = 25_000;
8
+ const HOSTED_SIGNAL_READ_TOKEN_ENV = 'RELAYFLOW_V2_SIGNAL_READ_TOKEN';
9
+
10
+ function signalPath(runId: string, name: string): string {
11
+ cloudRunId(runId);
12
+ if (!SIGNAL_NAME.test(name)) {
13
+ throw new CloudFlowError('invalid_input', 'Signal name must contain only letters, digits, underscores, or hyphens (1–80 characters).');
14
+ }
15
+ return `/api/v1/workflows/runs/${runId}/signals/${name}`;
16
+ }
17
+
18
+ /** Store a run-scoped completion. Retrying the same JSON payload is safe. */
19
+ export async function postCloudSignal(
20
+ runId: string, name: string, payload: unknown, options: CloudConnectionOptions = {},
21
+ ): Promise<{ accepted: true; duplicate: boolean }> {
22
+ const path = signalPath(runId, name);
23
+ if (payload === undefined) throw new CloudFlowError('invalid_input', 'Signal payload must be present.');
24
+ let body: string;
25
+ try { body = JSON.stringify({ payload }); }
26
+ catch { throw new CloudFlowError('invalid_input', 'Signal payload must be JSON serializable.'); }
27
+ // JSON.stringify omits a function, symbol, or toJSON() result of undefined.
28
+ // This envelope has only one field, so {} means the payload was lost.
29
+ if (body === '{}') throw new CloudFlowError('invalid_input', 'Signal payload must be JSON serializable.');
30
+ if (new TextEncoder().encode(body).byteLength > MAX_BYTES) {
31
+ throw new CloudFlowError('invalid_input', 'Signal payload exceeds 4 KiB.');
32
+ }
33
+ const result = await cloudFetch(path, options, { method: 'POST', body, detail: true });
34
+ if (!isCloudRecord(result) || result.accepted !== true || typeof result.duplicate !== 'boolean') {
35
+ throw new CloudFlowError('invalid_response', 'Cloud returned an invalid signal receipt.');
36
+ }
37
+ return { accepted: true, duplicate: result.duplicate };
38
+ }
39
+
40
+ /**
41
+ * Wait on Cloud's held GET. A normally held 204 re-arms immediately. A fast
42
+ * 204 backs off so a proxy or broken server cannot turn this into a hot loop.
43
+ */
44
+ export async function waitForCloudSignal(
45
+ runId: string, name: string, options: CloudConnectionOptions = {},
46
+ ): Promise<unknown> {
47
+ const path = signalPath(runId, name);
48
+ const requestTimeout = options.requestTimeoutMs ?? 30_000;
49
+ if (!Number.isSafeInteger(requestTimeout) || requestTimeout <= HELD_GET_MS) {
50
+ throw new CloudFlowError('invalid_input', 'Signal requestTimeoutMs must exceed 25 seconds.');
51
+ }
52
+ let fast204BackoffMs = 250;
53
+ for (;;) {
54
+ options.signal?.throwIfAborted();
55
+ let connection: { baseUrl: string; token: string };
56
+ try {
57
+ connection = await resolveCloudConnection(options);
58
+ } catch (error) {
59
+ // The hosted run's read-only credential is only a fallback for waits.
60
+ // Explicit Cloud credentials and stored logins retain precedence, and
61
+ // postCloudSignal never sees this token.
62
+ const hostedToken = process.env[HOSTED_SIGNAL_READ_TOKEN_ENV];
63
+ if (!(error instanceof CloudFlowError) || error.code !== 'configuration'
64
+ || error.reason !== 'auth_missing' || options.token !== undefined
65
+ || process.env['FLOWS_CLOUD_TOKEN'] !== undefined || hostedToken === undefined) throw error;
66
+ connection = await resolveCloudConnection({ ...options, token: hostedToken });
67
+ }
68
+ const { baseUrl, token } = connection;
69
+ const deadline = AbortSignal.timeout(requestTimeout);
70
+ const requestStartedAt = performance.now();
71
+ let response: Response;
72
+ try {
73
+ response = await fetch(`${baseUrl}${path}?waitMs=${HELD_GET_MS}`, {
74
+ method: 'GET',
75
+ headers: { authorization: `Bearer ${token}` },
76
+ signal: options.signal ? AbortSignal.any([options.signal, deadline]) : deadline,
77
+ redirect: 'error',
78
+ });
79
+ } catch (error) {
80
+ options.signal?.throwIfAborted();
81
+ throw transportError(deadline.aborted ? deadline.reason : error);
82
+ }
83
+ if (response.status === 204) {
84
+ if (performance.now() - requestStartedAt < 1_000) {
85
+ try {
86
+ await delay(fast204BackoffMs, undefined, { signal: options.signal });
87
+ } catch (error) {
88
+ options.signal?.throwIfAborted();
89
+ throw error;
90
+ }
91
+ fast204BackoffMs = Math.min(fast204BackoffMs * 2, 5_000);
92
+ } else {
93
+ fast204BackoffMs = 250;
94
+ }
95
+ continue;
96
+ }
97
+ if (!response.ok) throw new CloudFlowError('http_error', `Cloud signal wait failed with HTTP ${response.status}.`, response.status);
98
+ let body: unknown;
99
+ try { body = await response.json(); }
100
+ catch (error) {
101
+ options.signal?.throwIfAborted();
102
+ if (!(error instanceof SyntaxError)) throw transportError(deadline.aborted ? deadline.reason : error);
103
+ throw new CloudFlowError('invalid_response', 'Cloud returned an invalid signal.');
104
+ }
105
+ if (!isCloudRecord(body) || !Object.hasOwn(body, 'payload') || typeof body.acceptedAt !== 'number') {
106
+ throw new CloudFlowError('invalid_response', 'Cloud returned an invalid signal.');
107
+ }
108
+ return body.payload;
109
+ }
110
+ }
package/src/cloud-sync.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { createReadStream, createWriteStream, existsSync, lstatSync, mkdtempSync, readdirSync, readlinkSync, rmSync } from 'node:fs';
3
- import { readFile } from 'node:fs/promises';
2
+ import { createReadStream, createWriteStream, existsSync, lstatSync, mkdtempSync, readdirSync, readlinkSync, rmSync, statSync } from 'node:fs';
3
+ import { open, readFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
6
  import { Readable } from 'node:stream';
7
7
  import { pipeline } from 'node:stream/promises';
8
8
  import { createGzip } from 'node:zlib';
9
+ import { createHash } from 'node:crypto';
9
10
  import {
10
11
  CloudFlowError, cloudFetch, cloudRequest, cloudRunId, isCloudRecord, type CloudConnectionOptions,
11
12
  } from './cloud-http.js';
@@ -25,6 +26,8 @@ import {
25
26
 
26
27
  /** Uncompressed bytes. Matches the sandbox's extraction budget with headroom. */
27
28
  export const MAX_SYNC_BYTES = 256 * 1024 * 1024;
29
+ /** Above this compressed size, use the unchanged legacy upload path. */
30
+ export const MAX_CACHED_CODE_BYTES = 64 * 1024 * 1024;
28
31
  const ALWAYS_SKIPPED = new Set(['.git', 'node_modules']);
29
32
  const CODE_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
30
33
 
@@ -33,11 +36,14 @@ export interface PreparedCloudSync {
33
36
  codeKey: string;
34
37
  /** Run-scoped upload credential from `prepare`; falls back to the session token. */
35
38
  uploadToken?: string;
39
+ /** Cloud confirmed the immutable archive already exists in this workspace. */
40
+ codeCacheHit?: boolean;
36
41
  }
37
42
 
38
43
  /** `POST /api/v1/workflows/prepare`, refusing every non-Cloud-API storage backend. */
39
- export async function prepareCloudSync(options: CloudConnectionOptions): Promise<PreparedCloudSync> {
40
- const receipt = await cloudFetch('/api/v1/workflows/prepare', options, { method: 'POST' });
44
+ export async function prepareCloudSync(options: CloudConnectionOptions, codeSha256?: string): Promise<PreparedCloudSync> {
45
+ const receipt = await cloudFetch('/api/v1/workflows/prepare', options,
46
+ { method: 'POST', ...(codeSha256 ? { body: JSON.stringify({ codeSha256 }) } : {}) });
41
47
  if (!isCloudRecord(receipt)) throw new CloudFlowError('invalid_response', 'Cloud prepare returned a non-object.');
42
48
  const storage = isCloudRecord(receipt.workflowStorage) ? receipt.workflowStorage : undefined;
43
49
  const credentials = isCloudRecord(receipt.s3Credentials) ? receipt.s3Credentials : undefined;
@@ -49,6 +55,15 @@ export async function prepareCloudSync(options: CloudConnectionOptions): Promise
49
55
  }
50
56
  const codeKey = typeof receipt.s3CodeKey === 'string' ? receipt.s3CodeKey : '';
51
57
  if (!CODE_KEY.test(codeKey)) throw new CloudFlowError('invalid_response', 'Cloud prepare returned an unusable code key.');
58
+ const codeCache = isCloudRecord(receipt.codeCache) ? receipt.codeCache : undefined;
59
+ let codeCacheHit: boolean | undefined;
60
+ if (codeSha256 && (codeCache !== undefined || codeKey.startsWith('code-sha256-'))) {
61
+ if (!codeCache || codeCache.sha256 !== codeSha256 || typeof codeCache.hit !== 'boolean'
62
+ || codeKey !== `code-sha256-${codeSha256}.tar.gz`) {
63
+ throw new CloudFlowError('invalid_response', 'Cloud prepare returned an inconsistent code cache receipt.');
64
+ }
65
+ codeCacheHit = codeCache.hit;
66
+ }
52
67
  const uploadToken = credentials?.cloudApiAccessToken;
53
68
  if (uploadToken !== undefined && typeof uploadToken !== 'string') {
54
69
  throw new CloudFlowError('invalid_response', 'Cloud prepare returned an unusable storage credential.');
@@ -56,9 +71,17 @@ export async function prepareCloudSync(options: CloudConnectionOptions): Promise
56
71
  return {
57
72
  runId: cloudRunId(receipt.runId), codeKey,
58
73
  ...(uploadToken === undefined ? {} : { uploadToken }),
74
+ ...(codeCacheHit === undefined ? {} : { codeCacheHit }),
59
75
  };
60
76
  }
61
77
 
78
+ /** Hash the deterministic compressed archive without loading it into memory. */
79
+ export async function sha256PackedTree(packed: PackedTree): Promise<string> {
80
+ const hash = createHash('sha256');
81
+ for await (const chunk of createReadStream(packed.archivePath)) hash.update(chunk);
82
+ return hash.digest('hex');
83
+ }
84
+
62
85
  export interface PackedTree {
63
86
  /** The gzip'd ustar, spooled to a temporary file; `dispose()` removes it. */
64
87
  archivePath: string;
@@ -125,6 +148,14 @@ export async function packWorkingTree(root: string): Promise<PackedTree> {
125
148
  }
126
149
  try {
127
150
  await pipeline(Readable.from(flatten(entries())), createGzip({ level: 6 }), createWriteStream(archivePath));
151
+ // zlib's OS byte can vary by platform while the tar contents are equal.
152
+ // 255 means unknown OS and does not affect decompression.
153
+ const archive = await open(archivePath, 'r+');
154
+ try {
155
+ await archive.write(Buffer.from([255]), 0, 1, 9);
156
+ } finally {
157
+ await archive.close();
158
+ }
128
159
  } catch (error) {
129
160
  dispose();
130
161
  throw error;
@@ -132,6 +163,10 @@ export async function packWorkingTree(root: string): Promise<PackedTree> {
132
163
  return { archivePath, files: members, bytes, skippedLinks, dispose };
133
164
  }
134
165
 
166
+ export function canUseCodeCache(packed: PackedTree): boolean {
167
+ return statSync(packed.archivePath).size <= MAX_CACHED_CODE_BYTES;
168
+ }
169
+
135
170
  /** Yield file streams chunk by chunk so `Readable.from` never buffers a whole file. */
136
171
  async function* flatten(source: AsyncGenerator<Buffer | NodeJS.ReadableStream>): AsyncGenerator<Buffer> {
137
172
  for await (const part of source) {
@@ -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
  }