From 77bc0393e7806958b03a8ebb7409907de9c64948 Mon Sep 17 00:00:00 2001 From: Relayflow Lead Date: Wed, 30 Sep 2026 19:01:10 +0200 Subject: [PATCH 1/3] fix(sdk): isolate local agent credentials from authored code Session-Id: 01a0e3a9-9c75-76f1-9425-fcd47b8a081e --- packages/sdk/src/cli/direct-run.ts | 7 ++- packages/sdk/src/cli/run.ts | 15 +++-- packages/sdk/src/llm-worker.ts | 4 +- packages/sdk/src/local-agent-environment.ts | 63 +++++++++++++++++++ packages/sdk/src/local-agent.ts | 3 +- packages/sdk/src/worker-cli.ts | 5 +- packages/sdk/src/worker.ts | 5 +- .../sdk/tests/local-agent-environment.test.ts | 56 +++++++++++++++++ packages/sdk/tests/worker-cli.test.ts | 22 +++++++ 9 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 packages/sdk/src/local-agent-environment.ts create mode 100644 packages/sdk/tests/local-agent-environment.test.ts diff --git a/packages/sdk/src/cli/direct-run.ts b/packages/sdk/src/cli/direct-run.ts index 66b183b53..557313ca5 100644 --- a/packages/sdk/src/cli/direct-run.ts +++ b/packages/sdk/src/cli/direct-run.ts @@ -6,6 +6,7 @@ import { McpPreflightError } from './check-typescript.js'; import { attachLocalAgent } from '../local-agent.js'; import { LlmWorker } from '../llm-worker.js'; import { DEFAULT_LOCAL_AGENT_CAPACITY } from '../worker-slots.js'; +import { localAgentEnvironment } from '../local-agent-environment.js'; import { AuthoredFlowExecutionError, } from '../authored-flow-executor.js'; @@ -38,6 +39,9 @@ export async function runDirectFlow( dataDir: string, options: RunLifecycleOptions = {}, ): Promise { + // 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(); } diff --git a/packages/sdk/src/cli/run.ts b/packages/sdk/src/cli/run.ts index 8f00cc0fa..cb51375d0 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,6 +177,7 @@ 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); @@ -183,13 +185,14 @@ export async function runFlow( 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,7 +216,7 @@ 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))) { @@ -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(); @@ -356,7 +363,7 @@ 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'); 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..5af66b4d2 --- /dev/null +++ b/packages/sdk/src/local-agent-environment.ts @@ -0,0 +1,63 @@ +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; + +export function localAgentEnvironment(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const environment = { ...base }; + const rawFd = environment[LOCAL_AGENT_ENV_FD]; + delete environment[LOCAL_AGENT_ENV_FD]; + if (rawFd === undefined) return environment; + // 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 (key.length === 0 || key.includes('=') || key.includes('\0') || 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 }; +} + +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..c1f029d8c 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; } /** @@ -142,7 +144,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/local-agent-environment.test.ts b/packages/sdk/tests/local-agent-environment.test.ts new file mode 100644 index 000000000..a8d995734 --- /dev/null +++ b/packages/sdk/tests/local-agent-environment.test.ts @@ -0,0 +1,56 @@ +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, 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', SHARED: 'agent' })); + const environment = localAgentEnvironment({ [LOCAL_AGENT_ENV_FD]: String(fd), SAFE: 'yes', SHARED: 'base' }); + expect(environment).toEqual({ SAFE: 'yes', SHARED: 'agent', ANTHROPIC_API_KEY: 'house-key' }); + expect(() => closeSync(fd)).toThrow(); + }); + + it('returns a copy of an ordinary environment', () => { + const base = { SAFE: 'yes' }; + const environment = localAgentEnvironment(base); + expect(environment).toEqual(base); + expect(environment).not.toBe(base); + }); + + it('removes the inherited descriptor capability from process.env', () => { + const fd = payloadFd(JSON.stringify({ HOUSE_PROVIDER_KEY: 'secret' })); + const prior = process.env[LOCAL_AGENT_ENV_FD]; + process.env[LOCAL_AGENT_ENV_FD] = String(fd); + try { + const environment = localAgentEnvironment(); + expect(environment.HOUSE_PROVIDER_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], + ])('fails closed for %s', (_name, preset) => { + const fd = preset === undefined + ? payloadFd(_name === 'malformed JSON' ? '{' : JSON.stringify({ SECRET: 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', () => { From e8fb83f59a5130f9e700fa78f17af7b1e7d1f741 Mon Sep 17 00:00:00 2001 From: Relayflow Lead Date: Wed, 30 Sep 2026 19:32:55 +0200 Subject: [PATCH 2/3] fix(sdk): propagate isolated credentials to provider probes Session-Id: 01a0e3a9-9c75-76f1-9425-fcd47b8a081e --- packages/sdk/src/authored-flow-executor.ts | 5 ++- packages/sdk/src/authored-node-entry.ts | 9 ++++- packages/sdk/src/authored-node-runner.ts | 3 +- packages/sdk/src/authored-preflight.ts | 4 +-- packages/sdk/src/authored-root.ts | 7 ++++ packages/sdk/src/authored-worker-step.ts | 3 +- packages/sdk/src/cli/check.ts | 9 +++-- packages/sdk/src/cli/cli-probe.ts | 14 ++++---- packages/sdk/src/cli/direct-run.ts | 1 + packages/sdk/src/cli/run.ts | 9 ++--- packages/sdk/src/communication/local.ts | 5 +-- packages/sdk/src/communication/worker.ts | 11 +++--- packages/sdk/src/local-agent-environment.ts | 35 +++++++++++++++++-- packages/sdk/src/worker.ts | 3 +- packages/sdk/tests/authored-preflight.test.ts | 11 ++++-- packages/sdk/tests/cli-probe.test.ts | 12 +++++++ .../sdk/tests/communication-worker.test.ts | 11 ++++-- .../sdk/tests/local-agent-environment.test.ts | 31 +++++++++------- 18 files changed, 136 insertions(+), 47 deletions(-) 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 { 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 cb51375d0..716504e1b 100644 --- a/packages/sdk/src/cli/run.ts +++ b/packages/sdk/src/cli/run.ts @@ -180,7 +180,7 @@ export async function runFlow( 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) }; } @@ -192,7 +192,7 @@ async function executeCheckedFlow( checked: ReturnType, dataDir: string, options: RunLifecycleOptions, - agentEnvironment: NodeJS.ProcessEnv, + agentEnvironment?: NodeJS.ProcessEnv, ): Promise { const socketPath = socketFor(dataDir); // Carry the preflight's diagnostics as a RunReport from here on, so the @@ -221,7 +221,7 @@ async function executeCheckedFlow( } 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); @@ -345,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'); @@ -367,7 +368,7 @@ export async function resumeFlow( ); 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/local-agent-environment.ts b/packages/sdk/src/local-agent-environment.ts index 5af66b4d2..8eacf84fe 100644 --- a/packages/sdk/src/local-agent-environment.ts +++ b/packages/sdk/src/local-agent-environment.ts @@ -8,12 +8,20 @@ import { closeSync, readSync } from 'node:fs'; 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 { +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]; - if (rawFd === undefined) return environment; + // 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]; @@ -48,7 +56,7 @@ export function localAgentEnvironment(base: NodeJS.ProcessEnv = process.env): No 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 (key.length === 0 || key.includes('=') || key.includes('\0') || typeof value !== 'string' || value.includes('\0')) { + 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; @@ -56,6 +64,27 @@ export function localAgentEnvironment(base: NodeJS.ProcessEnv = process.env): No 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; diff --git a/packages/sdk/src/worker.ts b/packages/sdk/src/worker.ts index c1f029d8c..61881d018 100644 --- a/packages/sdk/src/worker.ts +++ b/packages/sdk/src/worker.ts @@ -124,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; 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 index a8d995734..77cba83d0 100644 --- a/packages/sdk/tests/local-agent-environment.test.ts +++ b/packages/sdk/tests/local-agent-environment.test.ts @@ -3,7 +3,7 @@ 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, localAgentEnvironment } from '../src/local-agent-environment.js'; +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`); @@ -15,27 +15,29 @@ function payloadFd(value: string): number { 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', SHARED: 'agent' })); - const environment = localAgentEnvironment({ [LOCAL_AGENT_ENV_FD]: String(fd), SAFE: 'yes', SHARED: 'base' }); - expect(environment).toEqual({ SAFE: 'yes', SHARED: 'agent', ANTHROPIC_API_KEY: 'house-key' }); + 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('returns a copy of an ordinary environment', () => { - const base = { SAFE: 'yes' }; - const environment = localAgentEnvironment(base); - expect(environment).toEqual(base); - expect(environment).not.toBe(base); + 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({ HOUSE_PROVIDER_KEY: 'secret' })); + 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.HOUSE_PROVIDER_KEY).toBe('secret'); - expect(environment[LOCAL_AGENT_ENV_FD]).toBeUndefined(); + 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; @@ -47,9 +49,12 @@ describe('local agent environment', () => { ['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' ? '{' : JSON.stringify({ SECRET: 1 })) + ? 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(); }); From 27a0115cb913f3b2f077f758bd8982acc9cffc75 Mon Sep 17 00:00:00 2001 From: Relayflow Lead Date: Wed, 30 Sep 2026 19:37:23 +0200 Subject: [PATCH 3/3] feat(runtime): advertise isolated agent credential capability Session-Id: 01a0e3a9-9c75-76f1-9425-fcd47b8a081e --- scripts/cloud-artifact.mjs | 5 +++++ scripts/cloud-artifact.test.mjs | 13 +++++++++++++ 2 files changed, 18 insertions(+) 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)),