diff --git a/packages/sdk/src/authored-flow-executor.ts b/packages/sdk/src/authored-flow-executor.ts
index 3382e88db..8205e261d 100644
--- a/packages/sdk/src/authored-flow-executor.ts
+++ b/packages/sdk/src/authored-flow-executor.ts
@@ -185,6 +185,8 @@ export interface ExecuteAuthoredFlowOptions {
readonly onWait?: RunLifecycleOptions['onWait'];
readonly onProgress?: (event: ProgressEvent) => void;
readonly localAgentStream?: string;
+ /** Environment reserved for provider probes and local agent subprocesses. */
+ readonly agentEnvironment?: NodeJS.ProcessEnv;
/**
* How many agent (and, separately, LLM) dispatches the attached local
* workers hold at once. Set, it caps this body's concurrent `f.agent` /
@@ -320,7 +322,7 @@ export async function executeAuthoredFlow(
const worker = authoredWorkerRunner(
definition, journal, flowPath, journalSteps, waitOptions,
localAgentStream, budget, definition.header.budget, options.rootRunId, options.workerCapacity, stepEdges,
- options.workerSlots,
+ options.workerSlots, options.agentEnvironment,
);
/**
@@ -685,6 +687,7 @@ export async function executeAuthoredFlow(
dataDir: options.dataDir,
flowPath: child.path,
...(localAgentStream === undefined ? {} : { localAgentStream }),
+ ...(options.agentEnvironment === undefined ? {} : { agentEnvironment: options.agentEnvironment }),
...(options.workerCapacity === undefined ? {} : { workerCapacity: options.workerCapacity }),
...(options.rootRunId === undefined ? {} : { rootRunId: options.rootRunId }),
flowGraph: options.flowGraph,
diff --git a/packages/sdk/src/authored-node-entry.ts b/packages/sdk/src/authored-node-entry.ts
index 30b7a9770..ac6f6fdab 100644
--- a/packages/sdk/src/authored-node-entry.ts
+++ b/packages/sdk/src/authored-node-entry.ts
@@ -8,6 +8,7 @@ import { assertAuthoredNodeVersion, parseAuthoredParentPid } from './authored-ru
import { AuthoredFlowExecutionError, AuthoredHumanParked } from './authored-flow-error.js';
import { isAgentCapacity } from './worker-slots.js';
import type { AuthoredRootMetadata } from './authored-root.js';
+import { validatedLocalAgentEnvironment } from './local-agent-environment.js';
let channelKey: string | undefined, sequence = 0;
// Capture writers before loading authored modules; credentials never enter env.
@@ -49,7 +50,8 @@ try {
send({ type: 'ready', runtime: { kind: 'node', version: process.versions.node,
executableSha256: hash(process.execPath), payloadSha256: hash(process.argv[1]!) } });
const request = await new Promise<{ channelKey: string; metadata: AuthoredRootMetadata; socketPath: string;
- rootRunId: string; dataDir: string; localAgentStream?: string; workerCapacity?: number }>((resolve, reject) => {
+ rootRunId: string; dataDir: string; localAgentStream?: string; workerCapacity?: number;
+ agentEnvironment?: unknown }>((resolve, reject) => {
let buffer = '';
process.stdin.setEncoding('utf8');
const onData = (chunk: string): void => {
@@ -65,6 +67,10 @@ try {
if (typeof request.channelKey !== 'string' || !/^[a-f0-9]{64}$/.test(request.channelKey)) throw new Error('invalid authored channel key');
channelKey = request.channelKey;
controller.signal.throwIfAborted();
+ // Validate the parent handoff before evaluating any authored module. Only
+ // provider credentials may cross this channel; controls such as PATH and
+ // NODE_OPTIONS remain the child's inherited values.
+ const agentEnvironment = validatedLocalAgentEnvironment(request.agentEnvironment);
const loaded = await loadPinnedAuthoredSource(request.metadata, true);
if (request.localAgentStream !== request.metadata.localAgentStream) throw new Error('authored root local agent surface mismatch');
if (request.workerCapacity !== undefined && !isAgentCapacity(request.workerCapacity)) throw new Error('invalid authored worker capacity');
@@ -78,6 +84,7 @@ try {
flowGraph: loaded.graph,
localAgentStream: request.localAgentStream, signal: controller.signal,
...(request.workerCapacity === undefined ? {} : { workerCapacity: request.workerCapacity }),
+ ...(agentEnvironment === undefined ? {} : { agentEnvironment: { ...process.env, ...agentEnvironment } }),
onProgress: event => send({ type: 'progress', event }),
onWait: event => send({ type: 'wait', event }),
});
diff --git a/packages/sdk/src/authored-node-runner.ts b/packages/sdk/src/authored-node-runner.ts
index a561fb2ef..9ad762cbf 100644
--- a/packages/sdk/src/authored-node-runner.ts
+++ b/packages/sdk/src/authored-node-runner.ts
@@ -134,7 +134,8 @@ export async function runAuthoredInNode(
// Keep stdin open: EOF tells the child its lease-owning parent died.
child.stdin!.write(JSON.stringify({ channelKey, metadata, socketPath, rootRunId,
dataDir: options.dataDir, localAgentStream: options.localAgentStream,
- workerCapacity: options.workerCapacity }) + '\n');
+ workerCapacity: options.workerCapacity,
+ agentEnvironment: options.agentEnvironment }) + '\n');
} else if (!ready || result) throw new Error('unexpected authored runtime message');
else if (message.type === 'progress') options.onProgress?.(message.event);
else if (message.type === 'wait') options.onWait?.(message.event);
diff --git a/packages/sdk/src/authored-preflight.ts b/packages/sdk/src/authored-preflight.ts
index aa5bc09bd..27298045b 100644
--- a/packages/sdk/src/authored-preflight.ts
+++ b/packages/sdk/src/authored-preflight.ts
@@ -6,7 +6,7 @@ import { cliProbeKey, CliProbeError, resolvePreflight, type CliProbeOutcome } fr
import type { FlowSpec } from './spec.js';
/** Auth/model facts belong to one execution, including concurrent cold callers. */
-export function authoredPreflight(path: string) {
+export function authoredPreflight(path: string, environment?: NodeJS.ProcessEnv) {
const probeCache = new Map();
const pending = new Map>();
const directory = dirname(resolve(path));
@@ -30,7 +30,7 @@ export function authoredPreflight(path: string) {
try {
probeCache.set(key, { result: await probeCliAsync(resolution.cli,
resolution.source === 'project' ? config.directory : directory,
- resolution.model, managed ? 'managed' : undefined) });
+ resolution.model, managed ? 'managed' : undefined, environment) });
} catch (error) {
probeCache.set(key, { failure: error instanceof CliProbeError ? error.detail : null });
}
diff --git a/packages/sdk/src/authored-root.ts b/packages/sdk/src/authored-root.ts
index 052aa4479..547016e7d 100644
--- a/packages/sdk/src/authored-root.ts
+++ b/packages/sdk/src/authored-root.ts
@@ -21,6 +21,7 @@ import { AuthoredFlowExecutionError, AuthoredHumanParked } from './authored-flow
import { readOpenHumanWaits } from './authored-human.js';
import { isSurfaceCompletionReason } from './authored-step-output.js';
import { readSubscriptionPark } from './authored-subscription-park.js';
+import { localAgentCredentialEnvironment } from './local-agent-environment.js';
export type DurableAuthoredFlowResult =
| (AuthoredFlowExecutionResult & { readonly rootRunId: string })
@@ -67,6 +68,8 @@ export interface DurableAuthoredOptions {
readonly localAgentStream?: string;
/** Concurrency of the attached local workers; see `ExecuteAuthoredFlowOptions.workerCapacity`. */
readonly workerCapacity?: number;
+ /** Provider-only environment consumed by preflight and local workers. */
+ readonly agentEnvironment?: NodeJS.ProcessEnv;
readonly lifecycle?: RunLifecycleOptions;
}
@@ -226,6 +229,9 @@ async function driveRoot(
if (process.versions['bun'] !== undefined) {
return runAuthoredInNode(metadata, journal.socketPath, dispatch.run_id, {
dataDir: options.dataDir, localAgentStream: options.localAgentStream,
+ ...(options.agentEnvironment === undefined ? {} : {
+ agentEnvironment: localAgentCredentialEnvironment(options.agentEnvironment),
+ }),
...(options.workerCapacity === undefined ? {} : { workerCapacity: options.workerCapacity }),
...options.lifecycle, signal,
});
@@ -239,6 +245,7 @@ async function driveRoot(
dataDir: options.dataDir,
flowPath: metadata.flowPath,
localAgentStream: options.localAgentStream,
+ ...(options.agentEnvironment === undefined ? {} : { agentEnvironment: options.agentEnvironment }),
...(options.workerCapacity === undefined ? {} : { workerCapacity: options.workerCapacity }),
rootRunId: dispatch.run_id,
extensions: loaded.extensions,
diff --git a/packages/sdk/src/authored-worker-step.ts b/packages/sdk/src/authored-worker-step.ts
index 005190786..2daed1e81 100644
--- a/packages/sdk/src/authored-worker-step.ts
+++ b/packages/sdk/src/authored-worker-step.ts
@@ -27,6 +27,7 @@ export function authoredWorkerRunner(
localAgentStream?: string, budget?: AuthoredBudget, headerBudget?: unknown,
rootRunId?: string, workerCapacity?: number, stepEdges?: AuthoredStepContext['stepEdges'],
sharedSlots?: AuthoredWorkerSlots,
+ agentEnvironment?: NodeJS.ProcessEnv,
) {
// Sized to the attached local workers, so concurrent calls wait here for a
// slot instead of being admitted and parked for want of a free worker.
@@ -36,7 +37,7 @@ export function authoredWorkerRunner(
agent: slots.agent.scope(),
llm: slots.llm.scope(),
};
- const check = authoredPreflight(flowPath);
+ const check = authoredPreflight(flowPath, agentEnvironment);
const context: AuthoredStepContext = {
...(rootRunId === undefined ? {} : { rootRunId }),
...(waitOptions.dataDir === undefined ? {} : { dataDir: waitOptions.dataDir }),
diff --git a/packages/sdk/src/cli/check.ts b/packages/sdk/src/cli/check.ts
index 24176a3db..5db44d940 100644
--- a/packages/sdk/src/cli/check.ts
+++ b/packages/sdk/src/cli/check.ts
@@ -123,6 +123,8 @@ type CliProbeOutcomeMap = Map;
* anything that depends on how the flow is about to be invoked opts in here.
*/
export interface CheckInvocation {
+ /** Environment used only by provider executable/auth/model probes. */
+ environment?: NodeJS.ProcessEnv;
/**
* Report `agent_worker_unresolved` when the spec has `agent` steps.
*
@@ -202,7 +204,7 @@ export function checkAuthoredFlow(
: projectConfigOrOptions as ProjectConfig | undefined;
const effectiveProbeCache = cliProbeCache ?? options?.probeCache;
const config = projectConfig ?? readProjectConfig(dirname(absolutePath));
- const probes = systemProbes(dirname(absolutePath), config);
+ const probes = systemProbes(dirname(absolutePath), config, invocation.environment);
const result = preflight(authoring, {
...(effectiveProbeCache === undefined ? {} : { cliProbeCache: effectiveProbeCache }),
projectCli: config.cli,
@@ -450,10 +452,11 @@ function findConfig(start: string): string | undefined {
}
}
-function systemProbes(flowDirectory: string, config: ProjectConfig): PreflightProbes {
+function systemProbes(flowDirectory: string, config: ProjectConfig, environment?: NodeJS.ProcessEnv): PreflightProbes {
return {
helper: helperReady,
- cli: (cli, source, model, execution) => probeCli(cli, source === 'project' ? config.directory : flowDirectory, model, execution),
+ cli: (cli, source, model, execution) => probeCli(cli,
+ source === 'project' ? config.directory : flowDirectory, model, execution, environment),
executor: (trigger) => config.executors.includes(trigger.executor),
// A deterministic step runs in the daemon's working directory — the
// directory `flows run` was invoked from, or Cloud's code mount — not in
diff --git a/packages/sdk/src/cli/cli-probe.ts b/packages/sdk/src/cli/cli-probe.ts
index 9666a8545..c477f110f 100644
--- a/packages/sdk/src/cli/cli-probe.ts
+++ b/packages/sdk/src/cli/cli-probe.ts
@@ -27,8 +27,8 @@ export async function probeCliAsync(...args: Parameters):
return next.value;
}
-export function resolveExecutable(command: string, directory: string): string | undefined {
- return driveSync(executableSequence(command, directory));
+export function resolveExecutable(command: string, directory: string, environment: NodeJS.ProcessEnv = process.env): string | undefined {
+ return driveSync(executableSequence(command, directory, environment));
}
function driveSync(sequence: Generator): T {
@@ -79,8 +79,9 @@ function* probeSequence(
directory: string,
model?: string,
execution?: 'managed',
+ sourceEnvironment: NodeJS.ProcessEnv = process.env,
): Generator {
- const executable = yield* executableSequence(cli, directory);
+ const executable = yield* executableSequence(cli, directory, sourceEnvironment);
if (executable === undefined) return { exists: false, authenticated: false };
const kind = cliAdapterKind(executable);
// Relay owns interactive CLI launch/injection. Its generic PTY path is not
@@ -90,7 +91,8 @@ function* probeSequence(
return { exists: true, supported: true, authenticated: 'unverified' };
}
const environment = execution === 'managed'
- ? { ...brokerEnvironment(process.env), ...agentEnvironment(executable) } : process.env;
+ ? { ...brokerEnvironment(sourceEnvironment), ...agentEnvironment(executable, sourceEnvironment) }
+ : sourceEnvironment;
const probe = (invocation: CliInvocation): ProbeRequest => ({ executable, directory, invocation, environment });
const identification = adapterIdentification(kind);
const identified = yield probe(identification.invocation);
@@ -176,7 +178,7 @@ function redactProbeOutput(text: string): string {
.replace(/\b[A-Fa-f0-9]{32,}\b/g, '');
}
-function* executableSequence(command: string, directory: string): Generator {
+function* executableSequence(command: string, directory: string, environment: NodeJS.ProcessEnv): Generator {
if (command.includes('/') || isAbsolute(command)) {
const path = isAbsolute(command) ? command : resolve(directory, command);
try {
@@ -187,7 +189,7 @@ function* executableSequence(command: string, directory: string): Generator {
+ // Consume and close the agent-only descriptor before authored source is
+ // imported by trigger preflight. Authored code never receives this object.
+ const agentEnvironment = localAgentEnvironment();
let input: unknown;
try {
input = parseDirectInput(inputArgument);
@@ -78,13 +82,14 @@ export async function runDirectFlow(
if (options.localAgent) {
localAgent = await attachLocalAgent(
client, dataDir, options.onPtyReady, authoredLocalAgentStream(admissionIdentity), workerCapacity,
+ undefined, [], agentEnvironment,
);
// A session owns one worker registration. Keep the workspace-free LLM
// worker on its own connection so it cannot replace the agent worker.
llmClient = new JournalClient(socketPath);
await llmClient.connect();
await llmClient.hello('flows-local-llm');
- localLlm = new LlmWorker(llmClient, `${localAgent.stream}-llm`, workerCapacity);
+ localLlm = new LlmWorker(llmClient, `${localAgent.stream}-llm`, workerCapacity, agentEnvironment);
localLlm.on('error', onWorkerFailure('local-llm', error => { llmFailure = error; client.close(); }));
await localLlm.attach();
}
@@ -96,6 +101,7 @@ export async function runDirectFlow(
onAdmitted: runId => { base.rootRunId = runId; },
localAgentStream: localAgent?.stream,
...(localAgent === undefined ? {} : { workerCapacity }),
+ ...(agentEnvironment === undefined ? {} : { agentEnvironment }),
lifecycle: {
onProgress: options.onProgress,
...(options.onRunStarted !== undefined ? { onRunStarted: options.onRunStarted } : {}),
diff --git a/packages/sdk/src/cli/run.ts b/packages/sdk/src/cli/run.ts
index 8f00cc0fa..716504e1b 100644
--- a/packages/sdk/src/cli/run.ts
+++ b/packages/sdk/src/cli/run.ts
@@ -39,6 +39,7 @@ import {
type LoweredCompletionReason,
} from '../authored-completion.js';
import { DEFAULT_LOCAL_AGENT_CAPACITY } from '../worker-slots.js';
+import { localAgentEnvironment } from '../local-agent-environment.js';
import {
checkFlow,
type CheckReport,
@@ -176,20 +177,22 @@ export async function runFlow(
dataDir: string,
options: RunLifecycleOptions = {},
): Promise {
+ const agentEnvironment = localAgentEnvironment();
const prepared = parseDigestReference(path) ? await prepareDigestRun(path, options.bucket) : undefined;
if (prepared && 'exitCode' in prepared) return prepared;
- const checked = prepared ?? checkFlow(path);
+ const checked = prepared ?? checkFlow(path, { environment: agentEnvironment });
if (!checked.report.ok || checked.flow === undefined) {
return { exitCode: 2, report: fromCheckReport('run', checked.report) };
}
- return executeCheckedFlow(checked, dataDir, options);
+ return executeCheckedFlow(checked, dataDir, options, agentEnvironment);
}
async function executeCheckedFlow(
checked: ReturnType,
dataDir: string,
options: RunLifecycleOptions,
+ agentEnvironment?: NodeJS.ProcessEnv,
): Promise {
const socketPath = socketFor(dataDir);
// Carry the preflight's diagnostics as a RunReport from here on, so the
@@ -213,12 +216,12 @@ async function executeCheckedFlow(
if (options.localAgent) {
localAgent = await attachLocalAgent(
client, dataDir, options.onPtyReady, undefined, options.agentCapacity,
- undefined, declaredLocalAgentStreams(spec),
+ undefined, declaredLocalAgentStreams(spec), agentEnvironment,
);
}
if (options.localAgent && spec.steps.some(step => step.type === 'agent' && communicationInstruction(step.instruction))) {
const { attachCommunicationWorkers } = await import('../communication/local.js');
- communicationWorkers = await attachCommunicationWorkers(spec, socketPath, dataDir);
+ communicationWorkers = await attachCommunicationWorkers(spec, socketPath, dataDir, agentEnvironment);
}
if (options.onJournalEntry !== undefined) client.on('entry', options.onJournalEntry);
const outcome = await startWatched(client, spec, options);
@@ -281,6 +284,9 @@ export async function resumeFlow(
dataDir: string,
options: RunLifecycleOptions = {},
): Promise {
+ // Resume can load authored source; consume the descriptor before reading the
+ // journal so no source evaluation can race credential pickup.
+ const agentEnvironment = localAgentEnvironment();
const socketPath = socketFor(dataDir);
const base = emptyReport('resume');
const client = new JournalClient(socketPath);
@@ -323,11 +329,12 @@ export async function resumeFlow(
if (options.localAgent) {
authoredAgent = await attachLocalAgent(
client, dataDir, options.onPtyReady, authoredRoot.localAgentStream, workerCapacity,
+ undefined, [], agentEnvironment,
);
authoredLlmClient = new JournalClient(socketPath);
await authoredLlmClient.connect();
await authoredLlmClient.hello('flows-authored-resume-llm');
- authoredLlm = new LlmWorker(authoredLlmClient, `${authoredAgent.stream}-llm`, workerCapacity);
+ authoredLlm = new LlmWorker(authoredLlmClient, `${authoredAgent.stream}-llm`, workerCapacity, agentEnvironment);
authoredLlm.on('error', onWorkerFailure('resume-llm', error => {
llmFailure = error;
client.close();
@@ -338,6 +345,7 @@ export async function resumeFlow(
dataDir,
localAgentStream: authoredAgent?.stream,
...(authoredAgent === undefined ? {} : { workerCapacity }),
+ ...(agentEnvironment === undefined ? {} : { agentEnvironment }),
lifecycle: options,
});
if (result === undefined) throw new Error('authored root disappeared during resume');
@@ -356,11 +364,11 @@ export async function resumeFlow(
if (!spec) throw new Error('Cannot attach a local worker: the journaled run spec is missing');
authoredAgent = await attachLocalAgent(
client, dataDir, options.onPtyReady, undefined, options.agentCapacity,
- undefined, declaredLocalAgentStreams(spec),
+ undefined, declaredLocalAgentStreams(spec), agentEnvironment,
);
if (spec?.steps.some(step => step.type === 'agent' && communicationInstruction(step.instruction))) {
const { attachCommunicationWorkers } = await import('../communication/local.js');
- communicationWorkers = await attachCommunicationWorkers(spec, socketPath, dataDir);
+ communicationWorkers = await attachCommunicationWorkers(spec, socketPath, dataDir, agentEnvironment);
}
}
const onEntry = options.onJournalEntry;
diff --git a/packages/sdk/src/communication/local.ts b/packages/sdk/src/communication/local.ts
index d382c3442..60b7f4440 100644
--- a/packages/sdk/src/communication/local.ts
+++ b/packages/sdk/src/communication/local.ts
@@ -8,7 +8,8 @@ import { loadRelayModules } from './relay.js';
import { checkCommunicationEnvironment, CommunicationEnvironmentError } from './preflight.js';
import { requireCommunicationCli } from './worker.js';
-export async function attachCommunicationWorkers(spec: KernelRunSpec, socketPath: string, dataDir: string) {
+export async function attachCommunicationWorkers(spec: KernelRunSpec, socketPath: string, dataDir: string,
+ environment?: NodeJS.ProcessEnv) {
const steps = spec.steps.filter(step => step.type === 'agent' && communicationInstruction(step.instruction));
checkCommunicationEnvironment(spec);
try { await loadRelayModules(); }
@@ -28,7 +29,7 @@ export async function attachCommunicationWorkers(spec: KernelRunSpec, socketPath
requireCommunicationCli(step.cli ?? spec.cli);
if (step.surfaces?.workspace?.length) throw new Error('Local communication workers support stream surfaces only');
const client = new JournalClient(socketPath);
- const worker = new AgentWorker(client, { workerId: `communication-${randomUUID()}`, dataDir,
+ const worker = new AgentWorker(client, { workerId: `communication-${randomUUID()}`, dataDir, environment,
requiredStreams: [channelName(step.id, '$receipts')],
pins: { workspace: [], streams: step.surfaces?.streams?.map(({ stream }) => ({ stream, read_offset: 0 })) ?? [] } });
workers.push({ client, worker });
diff --git a/packages/sdk/src/communication/worker.ts b/packages/sdk/src/communication/worker.ts
index 2e56c5c9e..b41733544 100644
--- a/packages/sdk/src/communication/worker.ts
+++ b/packages/sdk/src/communication/worker.ts
@@ -18,13 +18,15 @@ export function requireCommunicationCli(cli: string | undefined): void {
if (!cli?.trim()) throw new Error('Agent communication requires a declared CLI executable');
}
export async function completeCommunicationDispatch(client: JournalClient, dispatch: StepDispatchEvent,
- instruction: CommunicationInstruction, dataDir: string, runRoot?: string): Promise {
+ instruction: CommunicationInstruction, dataDir: string, runRoot?: string,
+ environment?: NodeJS.ProcessEnv): Promise {
const spec = dispatch.spec as KernelAgentStep;
requireCommunicationCli(spec.cli);
let output: unknown;
let completionReason: 'success' | 'worker_error' = 'success';
try {
- output = await withWorkerLease(client, dispatch, signal => run(client, dispatch, instruction, spec, dataDir, signal, runRoot));
+ output = await withWorkerLease(client, dispatch, signal =>
+ run(client, dispatch, instruction, spec, dataDir, signal, runRoot, environment));
} catch (error) {
completionReason = 'worker_error';
output = { error: error instanceof Error ? error.message : String(error) };
@@ -34,7 +36,8 @@ export async function completeCommunicationDispatch(client: JournalClient, dispa
started_pins: dispatch.pins, end_pins: dispatch.pins });
}
async function run(client: JournalClient, dispatch: StepDispatchEvent, instruction: CommunicationInstruction,
- spec: KernelAgentStep, dataDir: string, lease: AbortSignal, runRoot?: string): Promise {
+ spec: KernelAgentStep, dataDir: string, lease: AbortSignal, runRoot?: string,
+ environment?: NodeJS.ProcessEnv): Promise {
// Same contract as the CLI worker: a declared directory is resolved and held
// inside the same run root the CLI worker measures against, before anything
// is spawned. Thrown, not reported, because `completeCommunicationDispatch`
@@ -80,7 +83,7 @@ async function run(client: JournalClient, dispatch: StepDispatchEvent, instructi
handle = await relay.broker.spawnPty({ name, cli: basename(spec.cli!).replace(/\.exe$/i, ''), task: prompt, channels: [], skipRelayPrompt: true,
model: resolveCliModel(spec.cli!, spec.model), cwd: directory,
harnessConfig: { runtime: 'pty', command: quote(spec.cli!), args: [],
- cwd: directory, env: { ...agentEnvironment(spec.cli!),
+ cwd: directory, env: { ...agentEnvironment(spec.cli!, environment ?? process.env),
RELAYFLOW_COMMUNICATION_SOCKET: tools.path, RELAYFLOW_COMMUNICATION_TOKEN: tools.token },
delivery: { mode: 'pty-injection', format: 'relay-block' } } });
const ready = await handle.waitForReady(Math.min(instruction.timeoutMs, 90_000));
diff --git a/packages/sdk/src/llm-worker.ts b/packages/sdk/src/llm-worker.ts
index 3890aab05..9350e5dbf 100644
--- a/packages/sdk/src/llm-worker.ts
+++ b/packages/sdk/src/llm-worker.ts
@@ -22,6 +22,7 @@ export class LlmWorker extends EventEmitter {
private readonly client: JournalClient,
private readonly workerId: string,
private readonly capacity: number = DEFAULT_LOCAL_AGENT_CAPACITY,
+ private readonly environment?: NodeJS.ProcessEnv,
) {
super();
}
@@ -59,7 +60,8 @@ export class LlmWorker extends EventEmitter {
const effectiveModel = typeof spec.cli === 'string' ? resolveCliModel(spec.cli, spec.model) : spec.model;
const completed: WorkerCliResult = await withWorkerLease(this.client, dispatch, signal =>
typeof spec.cli === 'string' && typeof spec.prompt === 'string'
- ? runAgentCli(spec.cli, workerInstruction(prompt, dispatch), dispatch.wake_context, effectiveModel, undefined, signal, 'llm')
+ ? runAgentCli(spec.cli, workerInstruction(prompt, dispatch), dispatch.wake_context, effectiveModel, undefined, signal, 'llm',
+ undefined, undefined, 'direct', undefined, this.environment)
: Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'llm step has no declared CLI' }));
const { result, usage } = workerSpend(completed, effectiveModel);
let reason: CompletionReason = result.exit_code === 0 ? 'success' : 'worker_error';
diff --git a/packages/sdk/src/local-agent-environment.ts b/packages/sdk/src/local-agent-environment.ts
new file mode 100644
index 000000000..8eacf84fe
--- /dev/null
+++ b/packages/sdk/src/local-agent-environment.ts
@@ -0,0 +1,92 @@
+import { closeSync, readSync } from 'node:fs';
+
+/**
+ * Inherited descriptor carrying environment values that only local agent/LLM
+ * subprocesses may receive. The flows process consumes and closes it before
+ * loading authored code, so that code cannot inherit or read the credentials.
+ */
+export const LOCAL_AGENT_ENV_FD = 'FLOWS_LOCAL_AGENT_ENV_FD';
+
+const MAX_BYTES = 64 * 1024;
+const AGENT_CREDENTIAL_NAMES = new Set([
+ 'ANTHROPIC_API_KEY',
+ 'ANTHROPIC_BASE_URL',
+ 'OPENAI_API_KEY',
+ 'OPENAI_BASE_URL',
+]);
+
+export function localAgentEnvironment(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv | undefined {
+ const environment = { ...base };
+ const rawFd = environment[LOCAL_AGENT_ENV_FD];
+ delete environment[LOCAL_AGENT_ENV_FD];
+ // Without the descriptor, provider launches keep their historical live
+ // process.env behavior instead of freezing an early snapshot.
+ if (rawFd === undefined) return undefined;
+ // Every later child — especially the authored runtime — inherits process.env.
+ // Remove the capability marker before any authored module can be imported.
+ if (base === process.env) delete process.env[LOCAL_AGENT_ENV_FD];
+
+ if (!/^[0-9]+$/.test(rawFd)) throw new Error(`${LOCAL_AGENT_ENV_FD} must name an inherited file descriptor`);
+ const fd = Number(rawFd);
+ if (!Number.isSafeInteger(fd) || fd < 3 || fd > 255) {
+ throw new Error(`${LOCAL_AGENT_ENV_FD} must name an inherited file descriptor`);
+ }
+
+ const chunks: Buffer[] = [];
+ let bytes = 0;
+ try {
+ for (;;) {
+ const chunk = Buffer.allocUnsafe(Math.min(8 * 1024, MAX_BYTES + 1 - bytes));
+ const read = readSync(fd, chunk, 0, chunk.length, null);
+ if (read === 0) break;
+ bytes += read;
+ if (bytes > MAX_BYTES) throw new Error('local agent environment payload exceeds 64 KiB');
+ chunks.push(chunk.subarray(0, read));
+ }
+ } catch (error) {
+ if (error instanceof Error && error.message === 'local agent environment payload exceeds 64 KiB') throw error;
+ throw new Error('local agent environment payload could not be read');
+ } finally {
+ try { closeSync(fd); } catch { /* The descriptor is unusable either way. */ }
+ }
+
+ let parsed: unknown;
+ try { parsed = JSON.parse(Buffer.concat(chunks, bytes).toString('utf8')); }
+ catch { throw new Error('local agent environment payload is not valid JSON'); }
+ if (!plainObject(parsed)) throw new Error('local agent environment payload must be an object of strings');
+ const agentEnvironment: NodeJS.ProcessEnv = {};
+ for (const [key, value] of Object.entries(parsed)) {
+ if (!AGENT_CREDENTIAL_NAMES.has(key) || typeof value !== 'string' || value.includes('\0')) {
+ throw new Error('local agent environment payload must be an object of valid environment strings');
+ }
+ if (key !== LOCAL_AGENT_ENV_FD) agentEnvironment[key] = value;
+ }
+ return { ...environment, ...agentEnvironment };
+}
+
+/** Reduce a composed provider environment to the credential overlay that is
+ * safe to serialize across the authored Node runtime boundary. */
+export function localAgentCredentialEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
+ return Object.fromEntries([...AGENT_CREDENTIAL_NAMES]
+ .flatMap(key => environment[key] === undefined ? [] : [[key, environment[key]!]]));
+}
+
+/** Validate the parent-to-authored-runtime handoff without touching a descriptor. */
+export function validatedLocalAgentEnvironment(value: unknown): NodeJS.ProcessEnv | undefined {
+ if (value === undefined) return undefined;
+ if (!plainObject(value)) throw new Error('local agent environment payload must be an object of strings');
+ const environment: NodeJS.ProcessEnv = {};
+ for (const [key, entry] of Object.entries(value)) {
+ if (!AGENT_CREDENTIAL_NAMES.has(key) || typeof entry !== 'string' || entry.includes('\0')) {
+ throw new Error('local agent environment payload must be an object of valid environment strings');
+ }
+ environment[key] = entry;
+ }
+ return environment;
+}
+
+function plainObject(value: unknown): value is Record {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
+ const prototype = Object.getPrototypeOf(value) as unknown;
+ return prototype === Object.prototype || prototype === null;
+}
diff --git a/packages/sdk/src/local-agent.ts b/packages/sdk/src/local-agent.ts
index 4433a4673..9007ab930 100644
--- a/packages/sdk/src/local-agent.ts
+++ b/packages/sdk/src/local-agent.ts
@@ -15,6 +15,7 @@ export async function attachLocalAgent(
capacity: number = DEFAULT_LOCAL_AGENT_CAPACITY,
runRoot?: string,
declaredStreams: readonly string[] = [],
+ environment?: NodeJS.ProcessEnv,
): Promise<{
stream: string;
readonly failure: unknown;
@@ -31,7 +32,7 @@ export async function attachLocalAgent(
// second parking behind the first. Authored bodies size their admission to
// this same number (worker-slots.ts), so they never ask for more.
capacity,
- dataDir, onPtyReady, runRoot,
+ dataDir, onPtyReady, runRoot, environment,
pins: { workspace: [], streams: [...new Set([stream, ...declaredStreams])]
.map(stream => ({ stream, read_offset: 0 })) },
});
diff --git a/packages/sdk/src/worker-cli.ts b/packages/sdk/src/worker-cli.ts
index 321cc38c3..0a7532d83 100644
--- a/packages/sdk/src/worker-cli.ts
+++ b/packages/sdk/src/worker-cli.ts
@@ -91,6 +91,7 @@ export async function runAgentCli(
cwd?: string,
transport: AgentTransport = 'direct',
relayContext?: AgentRelayContext,
+ processEnvironment: NodeJS.ProcessEnv = process.env,
): Promise {
signal?.throwIfAborted();
if (signal !== undefined && process.platform === 'win32') {
@@ -134,7 +135,7 @@ export async function runAgentCli(
if (kind === 'relayflows-wrapper-v1') {
// The closed allowlist admits no ambient RELAYFLOW_* value; the four
// discovery names are set from this dispatch, exactly as for a direct spawn.
- const wrapperEnv = wrapperEnvironment(process.env);
+ const wrapperEnv = wrapperEnvironment(processEnvironment);
applyStepEnvironment(wrapperEnv, sidechannel);
return requirePricedUsage(decodeWrapperResult(await runWrapperSession(
cli,
@@ -148,7 +149,7 @@ export async function runAgentCli(
)), effectiveModel);
}
- const env: NodeJS.ProcessEnv = { ...process.env };
+ const env: NodeJS.ProcessEnv = { ...processEnvironment };
delete env[WAKE_CONTEXT_ENV];
delete env[MODEL_ENV];
// Where this attempt's journal is, so the agent can run `flows status` on
diff --git a/packages/sdk/src/worker.ts b/packages/sdk/src/worker.ts
index b9991bd73..61881d018 100644
--- a/packages/sdk/src/worker.ts
+++ b/packages/sdk/src/worker.ts
@@ -31,6 +31,8 @@ export interface AgentWorkerOptions {
* the root a dispatch is measured against is a value, not an assumption.
*/
runRoot?: string;
+ /** Environment inherited by provider CLIs, including agent-only secrets. */
+ environment?: NodeJS.ProcessEnv;
}
/**
@@ -122,7 +124,8 @@ export class AgentWorker extends EventEmitter {
if (communication) {
if (!this.options.dataDir) throw new Error('Agent communication requires a worker data directory');
const { completeCommunicationDispatch } = await import('./communication/worker.js');
- await completeCommunicationDispatch(this.client, dispatch, communication, this.options.dataDir, this.options.runRoot);
+ await completeCommunicationDispatch(this.client, dispatch, communication, this.options.dataDir,
+ this.options.runRoot, this.options.environment);
return;
}
let humanIntervention = false;
@@ -142,7 +145,8 @@ export class AgentWorker extends EventEmitter {
}, cwd.directory ?? (spec.transport === 'relay' ? undefined : this.options.runRoot),
spec.transport === 'relay' ? 'relay' : 'direct',
{ runId: dispatch.run_id, stepId: dispatch.step_id, idempotencyKey: dispatch.idempotency_key,
- dataDir: this.options.dataDir, resultSchema: spec.verification?.json_schema })
+ dataDir: this.options.dataDir, resultSchema: spec.verification?.json_schema },
+ this.options.environment)
: Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'agent step has no declared CLI' }));
const { result, usage } = workerSpend(completed, effectiveModel);
const completionReason = result.exit_code === 0 ? 'success' : 'worker_error';
diff --git a/packages/sdk/tests/authored-preflight.test.ts b/packages/sdk/tests/authored-preflight.test.ts
index a577999f7..24dd01262 100644
--- a/packages/sdk/tests/authored-preflight.test.ts
+++ b/packages/sdk/tests/authored-preflight.test.ts
@@ -7,7 +7,7 @@ import { SPEC_SCHEMA_VERSION, type FlowSpec } from '../src/spec.js';
const directories: string[] = [];
afterEach(() => { for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); });
-function setup() {
+function setup(environment?: NodeJS.ProcessEnv) {
const directory = mkdtempSync(join(tmpdir(), 'authored-preflight-'));
directories.push(directory);
const calls = join(directory, 'calls');
@@ -16,11 +16,11 @@ function setup() {
import { appendFileSync } from 'node:fs';
appendFileSync(${JSON.stringify(calls)}, 'probe\\n');
if (process.argv[2] === '--relayflows-adapter-v1') console.log('relayflows-agent-cli-v1');
-else process.exit(1);
+else process.exit(process.env.ANTHROPIC_API_KEY === 'house-key' ? 0 : 1);
`);
chmodSync(cli, 0o755);
writeFileSync(join(directory, 'flows.json'), JSON.stringify({ cli, models: ['allowed'] }));
- return { calls, check: authoredPreflight(join(directory, 'test.flow.ts')) };
+ return { calls, check: authoredPreflight(join(directory, 'test.flow.ts'), environment) };
}
function spec(id: string, model = 'allowed'): FlowSpec {
return { version: SPEC_SCHEMA_VERSION, name: 'test', steps: [{ id, type: 'llm', prompt: 'hello', model }] };
@@ -52,3 +52,8 @@ it('shares failed facts across callers but retains each step identity', async ()
// One identification, one exact-model probe, one auth classification.
expect(readFileSync(calls, 'utf8').trim().split('\n')).toHaveLength(3);
});
+
+it('uses the isolated provider environment during authored preflight', async () => {
+ const { check } = setup({ ...process.env, ANTHROPIC_API_KEY: 'house-key' });
+ expect((await check(spec('one'))).report.ok).toBe(true);
+});
diff --git a/packages/sdk/tests/cli-probe.test.ts b/packages/sdk/tests/cli-probe.test.ts
index 694832ff6..5deb14c51 100644
--- a/packages/sdk/tests/cli-probe.test.ts
+++ b/packages/sdk/tests/cli-probe.test.ts
@@ -42,6 +42,18 @@ it('keeps missing executables and unsupported identification fail closed', async
expect(await probeCliAsync('relayflows-no-such-executable', directory)).toEqual({ exists: false, authenticated: false });
});
+it('uses an explicit provider environment for every probe process', async () => {
+ const { path, directory } = wrapper(identify +
+ "process.exit(process.env.ANTHROPIC_API_KEY === 'house-key' ? 0 : 1)");
+ const environment = { ...process.env, ANTHROPIC_API_KEY: 'house-key' };
+ expect(probeCli(path, directory, 'test-model', undefined, environment)).toMatchObject({
+ authenticated: true, modelAvailable: true,
+ });
+ expect(await probeCliAsync(path, directory, 'test-model', undefined, environment)).toMatchObject({
+ authenticated: true, modelAvailable: true,
+ });
+});
+
it('reports timeout in both drivers while the async driver leaves the loop free', async () => {
const { path, directory } = wrapper(identify + 'setTimeout(() => {}, 10_000);');
const original = adapters.modelReadinessProbe;
diff --git a/packages/sdk/tests/communication-worker.test.ts b/packages/sdk/tests/communication-worker.test.ts
index 0bc5ae717..feb3f674f 100644
--- a/packages/sdk/tests/communication-worker.test.ts
+++ b/packages/sdk/tests/communication-worker.test.ts
@@ -23,13 +23,14 @@ beforeEach(() => {
return { name: 'managed-agent', generation: 'generation', release: mocks.release, waitForReady: mocks.ready };
});
});
-function fixture(cli = 'claude') {
+function fixture(cli = 'claude', environment?: NodeJS.ProcessEnv) {
const client = { stepHeartbeat: vi.fn(async () => ({ lease_deadline_ms: Date.now() + 30000 })),
stepComplete: vi.fn(async () => ({})), channelReceive: vi.fn(async () => null) };
const dispatch = { run_id: 'run', step_id: 'agent', attempt: 1, idempotency_key: 'key', pins: {},
lease_id: 'lease', lease_deadline_ms: Date.now() + 30000, spec: { type: 'agent', cli, instruction: '' } } as StepDispatchEvent;
return { client, execute: () => completeCommunicationDispatch(client as unknown as JournalClient, dispatch,
- { type: 'relayflows.communication.v1', instruction: 'test', incoming: ['peer'], outgoing: [], timeoutMs: 1000 }, '/tmp/data') };
+ { type: 'relayflows.communication.v1', instruction: 'test', incoming: ['peer'], outgoing: [], timeoutMs: 1000 },
+ '/tmp/data', undefined, environment) };
}
it('releases the exact owned identity and runtime before successful completion; never fabricates measured usage', async () => {
const f = fixture(); await f.execute();
@@ -63,3 +64,9 @@ it('quotes an executable path as one command without losing spaces or apostrophe
const f = fixture("/opt/agent tools/tool's cli"); await f.execute();
expect(mocks.spawn.mock.calls[0]![0].harnessConfig.command).toBe("'/opt/agent tools/tool'\"'\"'s cli'");
});
+
+it('launches communication agents with the isolated provider credential', async () => {
+ const f = fixture('codex', { ...process.env, OPENAI_API_KEY: 'house-key' });
+ await f.execute();
+ expect(mocks.spawn.mock.calls[0]![0].harnessConfig.env).toMatchObject({ OPENAI_API_KEY: 'house-key' });
+});
diff --git a/packages/sdk/tests/local-agent-environment.test.ts b/packages/sdk/tests/local-agent-environment.test.ts
new file mode 100644
index 000000000..77cba83d0
--- /dev/null
+++ b/packages/sdk/tests/local-agent-environment.test.ts
@@ -0,0 +1,61 @@
+import { closeSync, openSync, unlinkSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { randomUUID } from 'node:crypto';
+import { describe, expect, it } from 'vitest';
+import { LOCAL_AGENT_ENV_FD, localAgentCredentialEnvironment, localAgentEnvironment } from '../src/local-agent-environment.js';
+
+function payloadFd(value: string): number {
+ const path = join(tmpdir(), `flows-agent-env-${randomUUID()}.json`);
+ writeFileSync(path, value, { mode: 0o600 });
+ const fd = openSync(path, 'r');
+ unlinkSync(path);
+ return fd;
+}
+
+describe('local agent environment', () => {
+ it('merges descriptor values, removes the marker, and closes the descriptor', () => {
+ const fd = payloadFd(JSON.stringify({ ANTHROPIC_API_KEY: 'house-key' }));
+ const environment = localAgentEnvironment({ [LOCAL_AGENT_ENV_FD]: String(fd), SAFE: 'yes' });
+ expect(environment).toEqual({ SAFE: 'yes', ANTHROPIC_API_KEY: 'house-key' });
+ expect(() => closeSync(fd)).toThrow();
+ });
+
+ it('preserves live process environment behavior without a descriptor', () => {
+ expect(localAgentEnvironment({ SAFE: 'yes' })).toBeUndefined();
+ });
+
+ it('serializes only provider credentials to the authored runtime', () => {
+ expect(localAgentCredentialEnvironment({ PATH: '/usr/bin', NODE_OPTIONS: '--inspect',
+ ANTHROPIC_API_KEY: 'house-key' })).toEqual({ ANTHROPIC_API_KEY: 'house-key' });
+ });
+
+ it('removes the inherited descriptor capability from process.env', () => {
+ const fd = payloadFd(JSON.stringify({ ANTHROPIC_API_KEY: 'secret' }));
+ const prior = process.env[LOCAL_AGENT_ENV_FD];
+ process.env[LOCAL_AGENT_ENV_FD] = String(fd);
+ try {
+ const environment = localAgentEnvironment();
+ expect(environment?.ANTHROPIC_API_KEY).toBe('secret');
+ expect(environment?.[LOCAL_AGENT_ENV_FD]).toBeUndefined();
+ expect(process.env[LOCAL_AGENT_ENV_FD]).toBeUndefined();
+ } finally {
+ if (prior !== undefined) process.env[LOCAL_AGENT_ENV_FD] = prior;
+ else delete process.env[LOCAL_AGENT_ENV_FD];
+ }
+ });
+
+ it.each([
+ ['an invalid descriptor', { [LOCAL_AGENT_ENV_FD]: 'nope' }],
+ ['malformed JSON', undefined],
+ ['non-string values', undefined],
+ ['an execution-setting key', undefined],
+ ])('fails closed for %s', (_name, preset) => {
+ const fd = preset === undefined
+ ? payloadFd(_name === 'malformed JSON' ? '{'
+ : _name === 'an execution-setting key' ? JSON.stringify({ PATH: '/host-controlled' })
+ : JSON.stringify({ ANTHROPIC_API_KEY: 1 }))
+ : undefined;
+ expect(() => localAgentEnvironment(preset ?? { [LOCAL_AGENT_ENV_FD]: String(fd) })).toThrow();
+ });
+});
diff --git a/packages/sdk/tests/worker-cli.test.ts b/packages/sdk/tests/worker-cli.test.ts
index 748f07725..11d504db3 100644
--- a/packages/sdk/tests/worker-cli.test.ts
+++ b/packages/sdk/tests/worker-cli.test.ts
@@ -77,6 +77,28 @@ process.stdout.write(JSON.stringify({ type: 'result', result: 'default-model-ok'
'--output-format', 'stream-json', '--verbose', 'do the task',
]);
});
+
+ it('uses the explicitly supplied agent environment for the provider subprocess', async () => {
+ const directory = makeDirectory();
+ const observed = join(directory, 'environment.json');
+ const claude = makeWrapper(directory, 'claude', `
+const fs = require('node:fs');
+fs.writeFileSync(${JSON.stringify(observed)}, JSON.stringify({
+ secret: process.env.HOUSE_PROVIDER_KEY ?? null,
+ ambient: process.env.AMBIENT_ONLY ?? null,
+}));
+process.stdout.write(JSON.stringify({ type: 'result', result: 'ok',
+ usage: { input_tokens: 2, output_tokens: 1 } }) + '\\n');
+`);
+
+ const result = await withEnvironment({ AMBIENT_ONLY: 'must-not-leak' }, () =>
+ runAgentCli(claude, 'do the task', undefined, undefined, undefined, undefined,
+ 'agent', undefined, undefined, 'direct', undefined,
+ { PATH: process.env.PATH, HOUSE_PROVIDER_KEY: 'house-secret' }));
+
+ expect(result.exit_code).toBe(0);
+ expect(JSON.parse(readFileSync(observed, 'utf8'))).toEqual({ secret: 'house-secret', ambient: null });
+ });
});
describe('step discovery environment', () => {
diff --git a/scripts/cloud-artifact.mjs b/scripts/cloud-artifact.mjs
index 67d084eb6..197af63ed 100644
--- a/scripts/cloud-artifact.mjs
+++ b/scripts/cloud-artifact.mjs
@@ -22,6 +22,7 @@ import { pathToFileURL } from 'node:url';
const SHA256 = /^[a-f0-9]{64}$/u;
const SOURCE_COMMIT = /^[a-f0-9]{40}$/u;
const REQUIRED_EXECUTABLES = ['bin/flows', 'bin/relayflowd'];
+const RUNTIME_CAPABILITIES = ['local-agent-env-fd-v1'];
export async function buildCloudArtifact(options) {
assertSourceCommit(options.sourceCommit);
@@ -125,6 +126,7 @@ async function createManifest(root, sourceCommit) {
return {
schemaVersion: 1,
protocolVersion: '0',
+ capabilities: RUNTIME_CAPABILITIES,
sourceCommit,
platform: 'linux',
arch: 'x64',
@@ -144,6 +146,9 @@ function parseManifest(value) {
typeof value !== 'object' ||
value.schemaVersion !== 1 ||
value.protocolVersion !== '0' ||
+ !Array.isArray(value.capabilities) ||
+ value.capabilities.length !== RUNTIME_CAPABILITIES.length ||
+ !RUNTIME_CAPABILITIES.every((capability, index) => value.capabilities[index] === capability) ||
value.platform !== 'linux' ||
value.arch !== 'x64' ||
!SOURCE_COMMIT.test(value.sourceCommit) ||
diff --git a/scripts/cloud-artifact.test.mjs b/scripts/cloud-artifact.test.mjs
index 749a763fa..173e7b57f 100644
--- a/scripts/cloud-artifact.test.mjs
+++ b/scripts/cloud-artifact.test.mjs
@@ -24,6 +24,7 @@ test('builds and verifies the exact Linux x64 Cloud artifact contract', async ()
});
const verified = await verifyCloudArtifact(built.archivePath, built.archiveSha256);
assert.equal(verified.sourceCommit, 'a'.repeat(40));
+ assert.deepEqual(verified.capabilities, ['local-agent-env-fd-v1']);
assert.deepEqual(
verified.files.map((file) => file.path),
['bin/flows', 'bin/relayflowd'],
@@ -183,6 +184,18 @@ test('manifest pins journal protocol version zero', async () => {
}
});
+test('verification rejects an artifact without the agent credential isolation capability', async () => {
+ await withUnpackedArtifact('7', async (unpacked) => {
+ const manifest = await readManifest(unpacked);
+ manifest.capabilities = [];
+ await writeManifest(unpacked, manifest);
+ await assert.rejects(
+ verifyArtifactDirectory(unpacked),
+ /unsupported schema or target/u,
+ );
+ });
+});
+
test('CLI rejects a misspelled build option in artifact vocabulary', () => {
const result = spawnSync(process.execPath, [
fileURLToPath(new URL('./cloud-artifact.mjs', import.meta.url)),