@relayflows/sdk 2.0.50 → 2.0.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authored-budget.d.ts +1 -1
- package/dist/authored-budget.d.ts.map +1 -1
- package/dist/authored-budget.js +3 -3
- package/dist/authored-budget.js.map +1 -1
- package/dist/authored-flow-error.d.ts +3 -1
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-flow-executor.d.ts +2 -0
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +97 -14
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-flow-lifecycle.d.ts +28 -3
- package/dist/authored-flow-lifecycle.d.ts.map +1 -1
- package/dist/authored-flow-lifecycle.js +206 -10
- package/dist/authored-flow-lifecycle.js.map +1 -1
- package/dist/authored-node-entry.js +2 -0
- package/dist/authored-node-entry.js.map +1 -1
- package/dist/authored-parallel-body.d.ts +10 -0
- package/dist/authored-parallel-body.d.ts.map +1 -0
- package/dist/authored-parallel-body.js +68 -0
- package/dist/authored-parallel-body.js.map +1 -0
- package/dist/authored-parallel-eligibility.d.ts +8 -0
- package/dist/authored-parallel-eligibility.d.ts.map +1 -0
- package/dist/authored-parallel-eligibility.js +287 -0
- package/dist/authored-parallel-eligibility.js.map +1 -0
- package/dist/authored-preflight.js +4 -4
- package/dist/authored-preflight.js.map +1 -1
- package/dist/authored-promise-graph.d.ts +16 -0
- package/dist/authored-promise-graph.d.ts.map +1 -1
- package/dist/authored-promise-graph.js +36 -0
- package/dist/authored-promise-graph.js.map +1 -1
- package/dist/authored-root-readback.d.ts +1 -2
- package/dist/authored-root-readback.d.ts.map +1 -1
- package/dist/authored-root-readback.js +1 -1
- package/dist/authored-root-readback.js.map +1 -1
- package/dist/authored-root.d.ts +6 -1
- package/dist/authored-root.d.ts.map +1 -1
- package/dist/authored-root.js +55 -8
- package/dist/authored-root.js.map +1 -1
- package/dist/authored-step-graph.d.ts +19 -4
- package/dist/authored-step-graph.d.ts.map +1 -1
- package/dist/authored-step-graph.js +49 -6
- package/dist/authored-step-graph.js.map +1 -1
- package/dist/authored-step-index.d.ts +2 -1
- package/dist/authored-step-index.d.ts.map +1 -1
- package/dist/authored-step-index.js +2 -1
- package/dist/authored-step-index.js.map +1 -1
- package/dist/authored-verdict.d.ts +2 -0
- package/dist/authored-verdict.d.ts.map +1 -1
- package/dist/authored-verdict.js +4 -1
- package/dist/authored-verdict.js.map +1 -1
- package/dist/authored-worker-step.d.ts +4 -3
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +23 -10
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/cli/check.js +2 -2
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cli-probe.d.ts +1 -1
- package/dist/cli/cli-probe.d.ts.map +1 -1
- package/dist/cli/cli-probe.js +8 -7
- package/dist/cli/cli-probe.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +4 -2
- package/dist/cli/run.js.map +1 -1
- package/dist/cloud-http.d.ts +1 -0
- package/dist/cloud-http.d.ts.map +1 -1
- package/dist/cloud-http.js +1 -1
- package/dist/cloud-http.js.map +1 -1
- package/dist/cloud-run.d.ts +1 -1
- package/dist/cloud-run.d.ts.map +1 -1
- package/dist/cloud-run.js +45 -6
- package/dist/cloud-run.js.map +1 -1
- package/dist/cloud-signal.d.ts +12 -0
- package/dist/cloud-signal.d.ts.map +1 -0
- package/dist/cloud-signal.js +117 -0
- package/dist/cloud-signal.js.map +1 -0
- package/dist/cloud-sync.d.ts +8 -1
- package/dist/cloud-sync.d.ts.map +1 -1
- package/dist/cloud-sync.js +36 -4
- package/dist/cloud-sync.js.map +1 -1
- package/dist/communication/environment.d.ts +1 -1
- package/dist/communication/environment.d.ts.map +1 -1
- package/dist/communication/environment.js +26 -3
- package/dist/communication/environment.js.map +1 -1
- package/dist/communication/worker.js +1 -1
- package/dist/communication/worker.js.map +1 -1
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +5 -2
- package/dist/compile.js.map +1 -1
- package/dist/failure-kinds.d.ts +1 -1
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +2 -0
- package/dist/failure-kinds.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/journal-client.d.ts +5 -1
- package/dist/journal-client.d.ts.map +1 -1
- package/dist/journal-client.js +44 -4
- package/dist/journal-client.js.map +1 -1
- package/dist/llm-worker.d.ts.map +1 -1
- package/dist/llm-worker.js +1 -1
- package/dist/llm-worker.js.map +1 -1
- package/dist/preflight.d.ts +2 -2
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +12 -7
- package/dist/preflight.js.map +1 -1
- package/dist/spec.d.ts +4 -0
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js.map +1 -1
- package/dist/step-fields.d.ts +1 -1
- package/dist/step-fields.d.ts.map +1 -1
- package/dist/step-fields.js +1 -0
- package/dist/step-fields.js.map +1 -1
- package/dist/step-secrets.d.ts +20 -0
- package/dist/step-secrets.d.ts.map +1 -0
- package/dist/step-secrets.js +146 -0
- package/dist/step-secrets.js.map +1 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +10 -0
- package/dist/validate.js.map +1 -1
- package/dist/worker-cli.d.ts +5 -1
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +43 -14
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker.d.ts +1 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +1 -1
- package/dist/worker.js.map +1 -1
- package/package.json +3 -2
- package/src/authored-budget.ts +4 -3
- package/src/authored-flow-error.ts +2 -0
- package/src/authored-flow-executor.ts +89 -13
- package/src/authored-flow-lifecycle.ts +215 -9
- package/src/authored-node-entry.ts +2 -0
- package/src/authored-parallel-body.ts +67 -0
- package/src/authored-parallel-eligibility.ts +245 -0
- package/src/authored-preflight.ts +4 -4
- package/src/authored-promise-graph.ts +35 -0
- package/src/authored-root-readback.ts +1 -2
- package/src/authored-root.ts +62 -9
- package/src/authored-step-graph.ts +50 -5
- package/src/authored-step-index.ts +2 -1
- package/src/authored-verdict.ts +4 -1
- package/src/authored-worker-step.ts +29 -9
- package/src/cli/check.ts +3 -3
- package/src/cli/cli-probe.ts +9 -6
- package/src/cli/run.ts +4 -2
- package/src/cloud-http.ts +1 -1
- package/src/cloud-run.ts +45 -8
- package/src/cloud-signal.ts +110 -0
- package/src/cloud-sync.ts +39 -4
- package/src/communication/environment.ts +28 -3
- package/src/communication/worker.ts +2 -1
- package/src/compile.ts +5 -2
- package/src/failure-kinds.ts +2 -0
- package/src/index.ts +1 -0
- package/src/journal-client.ts +48 -2
- package/src/llm-worker.ts +2 -1
- package/src/preflight.ts +13 -7
- package/src/spec.ts +4 -0
- package/src/step-fields.ts +1 -0
- package/src/step-secrets.ts +145 -0
- package/src/validate.ts +11 -0
- package/src/worker-cli.ts +41 -13
- package/src/worker.ts +3 -2
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:
|
|
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
|
|
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,
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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
|
|
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,
|
|
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/failure-kinds.ts
CHANGED
|
@@ -134,6 +134,8 @@ export const RUN_FAILURE_KINDS = [
|
|
|
134
134
|
'run_parked',
|
|
135
135
|
'run_declined',
|
|
136
136
|
'run_unavailable',
|
|
137
|
+
/** A pinned parallel root is still active, but this process cannot safely dispatch it. */
|
|
138
|
+
'unsafe_parallel_runtime',
|
|
137
139
|
/** A predicate `.gate(fn)` judged false; the verdict is journaled as `<step>.gate`. */
|
|
138
140
|
'gate_failed',
|
|
139
141
|
/** `flows answer` named a wait the run is not asking: unknown, or already answered. */
|
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,
|
package/src/journal-client.ts
CHANGED
|
@@ -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
|
|
@@ -130,7 +135,22 @@ export class JournalClient extends JournalConnection {
|
|
|
130
135
|
reuseFromRunId?: string,
|
|
131
136
|
admissionKey?: string,
|
|
132
137
|
watch = false,
|
|
138
|
+
isolated = false,
|
|
133
139
|
): Promise<VerbContract['run.start']['result']> {
|
|
140
|
+
if (isolated) {
|
|
141
|
+
if (watch) throw new Error('an isolated run.start cannot keep a watch on its temporary session');
|
|
142
|
+
return this.runStartIsolated(spec, reuseFromRunId, admissionKey);
|
|
143
|
+
}
|
|
144
|
+
for (const step of spec.steps) {
|
|
145
|
+
for (const name of validateStepSecrets(step.secrets, process.env)) {
|
|
146
|
+
if (this.withheld.has(name)) {
|
|
147
|
+
throw new Error(`step secret ${JSON.stringify(name)} is withheld by the daemon`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!this.features.has('step_secret_scoping') && spec.steps.some(step => step.secrets?.length)) {
|
|
152
|
+
throw new Error('per-step secret grants require a daemon with step_secret_scoping support');
|
|
153
|
+
}
|
|
134
154
|
return this.request('run.start', {
|
|
135
155
|
spec,
|
|
136
156
|
...(reuseFromRunId === undefined ? {} : { reuse_from_run_id: reuseFromRunId }),
|
|
@@ -140,6 +160,20 @@ export class JournalClient extends JournalConnection {
|
|
|
140
160
|
}, null);
|
|
141
161
|
}
|
|
142
162
|
|
|
163
|
+
/** Drive a blocking deterministic child on its own socket, then close it. */
|
|
164
|
+
private async runStartIsolated(
|
|
165
|
+
spec: KernelRunSpec, reuseFromRunId?: string, admissionKey?: string,
|
|
166
|
+
): Promise<VerbContract['run.start']['result']> {
|
|
167
|
+
const peer = this.createPeer();
|
|
168
|
+
try {
|
|
169
|
+
await peer.connect();
|
|
170
|
+
await peer.hello('flows-authored-deterministic');
|
|
171
|
+
return await peer.runStart(spec, reuseFromRunId, admissionKey);
|
|
172
|
+
} finally {
|
|
173
|
+
peer.close();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
143
177
|
/** §3 memoized resume. */
|
|
144
178
|
runResume(runId: string, allowHumanInfluenced = false): Promise<VerbContract['run.resume']['result']> {
|
|
145
179
|
return this.request('run.resume', {
|
|
@@ -158,10 +192,22 @@ export class JournalClient extends JournalConnection {
|
|
|
158
192
|
* alike, so their values never cross the socket from this client.
|
|
159
193
|
*/
|
|
160
194
|
private stepEnv(): { env?: Record<string, string> } {
|
|
161
|
-
if (!this.features.has('step_env'))
|
|
195
|
+
if (!this.features.has('step_env')) {
|
|
196
|
+
// A client with no declared grants keeps compatibility with the
|
|
197
|
+
// currently pinned hosted runtime until Cloud upgrades the artifact.
|
|
198
|
+
return {};
|
|
199
|
+
}
|
|
162
200
|
const env: Record<string, string> = {};
|
|
201
|
+
const delivered = deliveredNames(process.env);
|
|
202
|
+
if (delivered !== undefined && this.withheld.has(SECRET_ENV_NAMES)) {
|
|
203
|
+
throw new Error(`${SECRET_ENV_NAMES} is withheld by the daemon; delivered secrets cannot be scoped`);
|
|
204
|
+
}
|
|
163
205
|
for (const [key, value] of Object.entries(process.env)) {
|
|
164
|
-
if (value !== undefined &&
|
|
206
|
+
if (value !== undefined && key !== SIGNAL_READ_TOKEN_ENV && key !== PLATFORM_AGENT_ENV_NAMES
|
|
207
|
+
&& !this.withheld.has(key)) {
|
|
208
|
+
env[key] = key === SECRET_ENV_NAMES && delivered !== undefined
|
|
209
|
+
? JSON.stringify([...delivered].filter(name => !this.withheld.has(name))) : value;
|
|
210
|
+
}
|
|
165
211
|
}
|
|
166
212
|
return { env };
|
|
167
213
|
}
|
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);
|
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
|
-
?
|
|
555
|
-
|
|
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;
|