From f773a418fb0412f66854ec289c2c88f8e9dec8ba Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 16 Oct 2026 07:18:03 +0200 Subject: [PATCH 01/11] feat: scope delivered secrets to declared steps --- docs/CLOUD.md | 22 ++++- docs/SURFACE.md | 18 +++- kernel/relayflowd-core/src/spec.rs | 18 ++++ kernel/relayflowd-core/src/spec/tests.rs | 20 ++++ kernel/relayflowd/src/exec_det.rs | 38 +++++++- kernel/relayflowd/src/server.rs | 2 +- kernel/relayflowd/src/step_env.rs | 48 +++++++++- packages/sdk/src/authored-flow-executor.ts | 6 +- packages/sdk/src/authored-worker-step.ts | 23 +++-- packages/sdk/src/communication/environment.ts | 19 +++- packages/sdk/src/communication/worker.ts | 3 +- packages/sdk/src/compile.ts | 2 + packages/sdk/src/journal-client.ts | 14 ++- packages/sdk/src/llm-worker.ts | 3 +- packages/sdk/src/spec.ts | 4 + packages/sdk/src/step-fields.ts | 1 + packages/sdk/src/step-secrets.ts | 79 +++++++++++++++ packages/sdk/src/validate.ts | 11 +++ packages/sdk/src/worker-cli.ts | 23 ++++- packages/sdk/src/worker.ts | 5 +- .../sdk/tests/authored-step-secrets.test.ts | 54 +++++++++++ .../sdk/tests/communication-review.test.ts | 22 +++++ .../sdk/tests/journal-client-step-env.test.ts | 95 +++++++++++++++++++ packages/sdk/tests/step-secrets.test.ts | 49 ++++++++++ .../sdk/tests/worker-cli-step-secrets.test.ts | 52 ++++++++++ packages/sdk/tests/worker-cli.test.ts | 10 +- packages/surface/src/context.ts | 6 ++ 27 files changed, 621 insertions(+), 26 deletions(-) create mode 100644 packages/sdk/src/step-secrets.ts create mode 100644 packages/sdk/tests/authored-step-secrets.test.ts create mode 100644 packages/sdk/tests/step-secrets.test.ts create mode 100644 packages/sdk/tests/worker-cli-step-secrets.test.ts diff --git a/docs/CLOUD.md b/docs/CLOUD.md index 6e6ee23d0..fc6a35c8e 100644 --- a/docs/CLOUD.md +++ b/docs/CLOUD.md @@ -45,6 +45,21 @@ await runInCloud({ path: './onboard.flow.ts' }, { }); ``` +Each step must grant a delivered secret by name. An omitted `secrets` option +grants none: + +```ts +await f.run('node call-native.mjs', { secrets: ['NATIVE_API_TOKEN'] }); +await f.agent('review', { task: 'Review the result', secrets: [] }); +``` + +The same `secrets: ['NATIVE_API_TOKEN']` declaration is accepted on a YAML +step. Unknown or unavailable names refuse the run. A direct agent step starts +with a small system and selected CLI-login environment, plus only its declared +run secrets; a Relay-transport agent cannot request run secrets. On a +self-hosted run, set `RELAY_SECRET_ENV_NAMES` to a JSON array of the secret +names before `flows run` so deterministic steps can scope them too. + They travel only with that run's submission: not in the flow source, its inputs, the receipt, or a schedule (`scheduleInCloud` cannot carry them), and errors never quote them: a Cloud refusal that contains a value anywhere (its @@ -55,9 +70,12 @@ refuses names it owns (run identity, Relay/Relayfile auth, provider keys such as step rows, transcripts) by name, regardless of what the variable is called, once AgentWorkforce/cloud#4469 is deployed; before that, only names containing TOKEN, SECRET, KEY, AUTH or similar are masked, so prefer a name like -`NATIVE_API_TOKEN`. Steps can still read the values, so keep the token +`NATIVE_API_TOKEN`. A same-user step can still inspect the CLI/bootstrap +environment through `/proc//environ` on Linux or read agent login files +under `HOME`; per-step env filtering is not process isolation. Keep tokens short-lived and narrowly scoped. A run that parks on `f.human` and is resumed -does not get them back. +does not get them back. UID separation for step processes is tracked in +AgentWorkforce/cloud#4715 and needs SOC 2 / penetration-test review. ```sh flows run --cloud examples/cloud-gates/cloud-gates.flow.yaml diff --git a/docs/SURFACE.md b/docs/SURFACE.md index c25d0451e..71cf7b662 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -1002,11 +1002,27 @@ that environment, plus `FLOWS_INPUT` and `RELAYFLOW_MEMORY`. Two runs on one daemon each see their own variables. `flows resume` sends the resuming CLI's environment, which replaces the run's from then on. +When `RELAY_SECRET_ENV_NAMES` lists delivered secrets, each deterministic +step gets only the names in its `secrets` declaration. For example, +`f.run('node call-api.mjs', { secrets: ['NATIVE_API_TOKEN'] })` receives that +value, while the next `f.run` without a grant does not. The same `secrets` +option is available on `f.agent` and `f.llm` and in declarative steps. Agent +CLIs start from an allowlist of system variables and their selected CLI login, +then receive declared secrets. A misspelled or unavailable name refuses the +step. Without a secret-name manifest, self-hosted deterministic steps retain +the legacy run environment; set the manifest when supplying secrets. + The environment is held in daemon memory only. It is never written to the journal, run records, step rows or traces. If the daemon restarts before the run finishes, the run's steps fall back to the daemon's own environment until a `flows resume` supplies one again. A CLI older than this feature sends no -environment, and its steps get the daemon's too. `kernel/DAEMON-LIFECYCLE.md` +environment, and its steps get the daemon's too. A step that declares a +secret grant refuses a daemon without the `step_secret_scoping` capability. +Until that daemon upgrade, a plain agent/LLM step keeps its legacy environment +so an SDK upgrade alone does not change a hosted run's existing behavior. +Upgrading the daemon changes plain agent/LLM steps to the clean environment +even when no step declares a grant; grant any needed delivered secrets by name. +`kernel/DAEMON-LIFECYCLE.md` §3 has the protocol. A step with no placed workspace still runs in the daemon's working directory, diff --git a/kernel/relayflowd-core/src/spec.rs b/kernel/relayflowd-core/src/spec.rs index c6e124624..8b3bc98d1 100644 --- a/kernel/relayflowd-core/src/spec.rs +++ b/kernel/relayflowd-core/src/spec.rs @@ -143,6 +143,13 @@ impl RunSpec { if !ids.insert(step.id.clone()) { return Err(SpecError::DuplicateStep(step.id.clone())); } + if step.secrets.iter().any(|name| { + name == "RELAY_SECRET_ENV_NAMES" + || !name.as_bytes().first().is_some_and(|c| c.is_ascii_alphabetic() || *c == b'_') + || !name.as_bytes().iter().all(|c| c.is_ascii_alphanumeric() || *c == b'_') + }) || step.secrets.iter().collect::>().len() != step.secrets.len() { + return Err(SpecError::Malformed(format!("step {}: secrets must be distinct environment variable names", step.id))); + } if step.max_iterations == 0 { return Err(SpecError::ZeroIterations(step.id.clone())); } @@ -163,6 +170,13 @@ impl RunSpec { "step {}: timeout_ms must be positive and fit in i64", step.id ))); } + if let StepKind::Agent { transport: Some(AgentTransport::Relay), .. } = &step.kind + && !step.secrets.is_empty() + { + return Err(SpecError::Malformed(format!( + "step {}: relay transport cannot deliver per-step secrets", step.id + ))); + } let cli = match &step.kind { StepKind::Llm { cli, .. } | StepKind::Agent { cli, .. } => cli, StepKind::Deterministic { .. } => &None, @@ -371,6 +385,7 @@ pub fn external_surface_contains(declared: &str, target: &str) -> bool { const STEP_COMMON_FIELDS: &[&str] = &[ "id", "type", + "secrets", "depends_on", "input", "max_iterations", @@ -458,6 +473,9 @@ fn reject_unknown_step_fields(value: &Value) -> Result<(), SpecError> { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct StepSpec { pub id: String, + /// Names only. Values stay in the run's in-memory step environment. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub secrets: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub input: Option, #[serde(default)] diff --git a/kernel/relayflowd-core/src/spec/tests.rs b/kernel/relayflowd-core/src/spec/tests.rs index 64dc7509b..e81506e31 100644 --- a/kernel/relayflowd-core/src/spec/tests.rs +++ b/kernel/relayflowd-core/src/spec/tests.rs @@ -12,6 +12,26 @@ fn zero_agent_flow_is_valid() { assert!(spec.validate().is_ok()); } +#[test] +fn secret_grants_are_names_only_and_relay_transport_cannot_receive_them() { + let spec = RunSpec::parse(&json!({"steps": [{ + "id": "shell", "type": "deterministic", "command": "true", "secrets": ["NATIVE_API_TOKEN"] + }]})).unwrap(); + assert!(spec.validate().is_ok()); + assert_eq!(spec.steps[0].secrets, vec!["NATIVE_API_TOKEN"]); + for names in [json!(["BAD=NAME"]), json!(["NATIVE_API_TOKEN", "NATIVE_API_TOKEN"])] { + let invalid = RunSpec::parse(&json!({"steps": [{ + "id": "shell", "type": "deterministic", "command": "true", "secrets": names + }]})).unwrap(); + assert!(invalid.validate().is_err()); + } + let relay = RunSpec::parse(&json!({"steps": [{ + "id": "agent", "type": "agent", "instruction": "review", "transport": "relay", + "secrets": ["NATIVE_API_TOKEN"] + }]})).unwrap(); + assert!(relay.validate().is_err()); +} + #[test] fn cycles_are_rejected() { let spec = RunSpec::parse(&json!({ diff --git a/kernel/relayflowd/src/exec_det.rs b/kernel/relayflowd/src/exec_det.rs index d58ad3da2..c12c81392 100644 --- a/kernel/relayflowd/src/exec_det.rs +++ b/kernel/relayflowd/src/exec_det.rs @@ -76,7 +76,24 @@ pub(crate) fn execute_placed_with_input( if let Some(workspace) = workspace { process.current_dir(workspace); } - if let Some(env) = env { + let step_env = match env { + Some(env) => match env.for_step(&step.secrets) { + Ok(selected) => Some(selected), + Err(error) => return worker_error(&error), + }, + None if !step.secrets.is_empty() => return worker_error("step secrets are unavailable without a run environment"), + None if std::env::var_os("RELAY_SECRET_ENV_NAMES").is_some() => { + let inherited = crate::step_env::StepEnv::from( + std::env::vars().collect::>(), + ); + match inherited.for_step(&[]) { + Ok(selected) => Some(selected), + Err(error) => return worker_error(&error), + } + } + None => None, + }; + if let Some(env) = &step_env { process.env_clear(); process.envs(env.vars()); } @@ -228,10 +245,29 @@ fn worker_error(detail: &str) -> AttemptResult { #[cfg(test)] mod tests { + use std::collections::BTreeMap; use serde_json::json; use super::*; + #[test] + fn a_shell_step_sees_only_its_granted_delivered_secret() { + let env = crate::step_env::StepEnv::from(BTreeMap::from([ + ("PATH".into(), "/bin:/usr/bin".into()), + ("NATIVE_API_TOKEN".into(), "native-secret".into()), + ("OTHER_TOKEN".into(), "other-secret".into()), + ("RELAY_SECRET_ENV_NAMES".into(), "[\"NATIVE_API_TOKEN\",\"OTHER_TOKEN\"]".into()), + ])); + for (grant, expected) in [(None, "unset:unset"), (Some("NATIVE_API_TOKEN"), "native-secret:unset")] { + let mut value = json!({"id": "shell", "type": "deterministic", + "command": "printf '%s:%s' \"${NATIVE_API_TOKEN-unset}\" \"${OTHER_TOKEN-unset}\""}); + if let Some(name) = grant { value["secrets"] = json!([name]); } + let step: StepSpec = serde_json::from_value(value).unwrap(); + let result = execute_placed_with_input(&step, None, None, None, Some(&env), None); + assert_eq!(result.output["stdout_tail"], expected); + } + } + #[test] fn captures_deterministic_output() { let step: StepSpec = serde_json::from_value(json!({ diff --git a/kernel/relayflowd/src/server.rs b/kernel/relayflowd/src/server.rs index ff5a5f443..15a3755d6 100644 --- a/kernel/relayflowd/src/server.rs +++ b/kernel/relayflowd/src/server.rs @@ -169,7 +169,7 @@ fn handle_request( let mut hello = json!({ "protocol": PROTOCOL_VERSION, "server": "relayflowd", - "features": ["reported_cost", "step_env"], + "features": ["reported_cost", "step_env", "step_secret_scoping"], }); // Names only, so a current SDK omits their values from the env it // sends; the strip at spawn is what enforces it. diff --git a/kernel/relayflowd/src/step_env.rs b/kernel/relayflowd/src/step_env.rs index d38891c1c..eff72ea6c 100644 --- a/kernel/relayflowd/src/step_env.rs +++ b/kernel/relayflowd/src/step_env.rs @@ -10,7 +10,7 @@ //! journaled, registered, or traced. A daemon restart therefore forgets them; //! the next `run.resume` that carries `env` supplies them again. -use std::{collections::BTreeMap, fmt}; +use std::{collections::{BTreeMap, BTreeSet}, fmt}; use serde::Deserialize; @@ -37,6 +37,33 @@ impl StepEnv { .iter() .map(|(key, value)| (key.as_str(), value.as_str())) } + + /// Select only this step's declared delivered secrets. Other ordinary + /// variables remain available. The manifest and values never reach a + /// deterministic child, and malformed metadata refuses the spawn. + pub fn for_step(&self, grants: &[String]) -> Result { + const MANIFEST: &str = "RELAY_SECRET_ENV_NAMES"; + let delivered: BTreeSet = match self.0.get(MANIFEST) { + Some(raw) => { + let names: Vec = serde_json::from_str(raw) + .map_err(|_| "secret name manifest is invalid".to_owned())?; + let set: BTreeSet = names.iter().cloned().collect(); + if set.len() != names.len() || names.iter().any(|name| name == MANIFEST || !self.0.contains_key(name)) { + return Err("secret name manifest contains duplicate, reserved or unavailable names".into()); + } + set + } + None => BTreeSet::new(), + }; + for name in grants { + if !self.0.contains_key(name) || (self.0.contains_key(MANIFEST) && !delivered.contains(name)) { + return Err(format!("step secret {name:?} is not available to this run")); + } + } + Ok(Self(self.0.iter() + .filter(|(name, _)| name.as_str() != MANIFEST && (!delivered.contains(*name) || grants.contains(name))) + .map(|(name, value)| (name.clone(), value.clone())).collect())) + } } impl From> for StepEnv { @@ -78,4 +105,23 @@ mod tests { let error = env(&[("K", "s3cret\0value")]).validate().unwrap_err(); assert!(!error.contains("s3cret"), "{error}"); } + + #[test] + fn only_granted_delivered_secrets_enter_each_step() { + let source = env(&[ + ("PATH", "/bin"), + ("NATIVE_API_TOKEN", "native-secret"), + ("OTHER_TOKEN", "other-secret"), + ("RELAY_SECRET_ENV_NAMES", "[\"NATIVE_API_TOKEN\",\"OTHER_TOKEN\"]"), + ]); + let none = source.for_step(&[]).unwrap(); + assert_eq!(none.vars().collect::>(), vec![("PATH", "/bin")]); + let granted = source.for_step(&["NATIVE_API_TOKEN".to_owned()]).unwrap(); + assert_eq!(granted.vars().collect::>(), vec![("NATIVE_API_TOKEN", "native-secret"), ("PATH", "/bin")]); + let error = source.for_step(&["MISSING".to_owned()]).unwrap_err(); + assert!(!error.contains("native-secret")); + let error = source.for_step(&["PATH".to_owned()]).unwrap_err(); + assert!(error.contains("PATH")); + assert!(env(&[("RELAY_SECRET_ENV_NAMES", "{invalid")]).for_step(&[]).is_err()); + } } diff --git a/packages/sdk/src/authored-flow-executor.ts b/packages/sdk/src/authored-flow-executor.ts index 9ba457558..20ddb5d03 100644 --- a/packages/sdk/src/authored-flow-executor.ts +++ b/packages/sdk/src/authored-flow-executor.ts @@ -12,6 +12,7 @@ import { buildMcpProxy, runMcpEffect } from './authored-mcp.js'; import { AuthoredBudget } from './authored-budget.js'; import { assertMemoryReachable, authoredMemory, journalLearnLedger, MemoryWriteTracker, scriptMemoryScope } from './authored-memory.js'; import { authoredDeterministicRunner, authoredWorkerRunner } from './authored-worker-step.js'; +import { validateStepSecrets } from './step-secrets.js'; import { isSurfaceFlowCompletionReason, isSurfaceRunCompletionReason } from './authored-step-output.js'; import { completionMarker, @@ -463,6 +464,7 @@ export async function executeAuthoredFlow( function runOperation(command: string, runOptions?: RunOptions): Step | Step { assertOperationAllowed('run', definition.name, requestedCompletion); const leaseMs = runOptions?.timeout === undefined ? undefined : parseStepTimeout(runOptions.timeout); + const secrets = validateStepSecrets((runOptions as RunOptions & { secrets?: unknown } | undefined)?.secrets, process.env); // Refused before an ordinal is consumed or anything is journaled. A // misspelled policy must not quietly fall back to the fatal default and // take away the very branch the author wrote below this line. @@ -480,7 +482,7 @@ export async function executeAuthoredFlow( id, 'run', () => assertOperationAllowed('run', definition.name, requestedCompletion), () => observeStep(id, 'deterministic', - () => lowerDeterministic.recording(id, command, leaseMs, recordOp.namedGate), options.onProgress), + () => lowerDeterministic.recording(id, command, leaseMs, recordOp.namedGate, secrets), options.onProgress), lifecycle, // No label: a command is not a display name. It carries literal // tokens and URLs, and no prefix of it is safe to show (displayLabel). @@ -492,7 +494,7 @@ export async function executeAuthoredFlow( runOp = new AuthoredFlowOperation( id, 'run', () => assertOperationAllowed('run', definition.name, requestedCompletion), - () => observeStep(id, 'deterministic', () => lowerDeterministic(id, command, false, leaseMs, runOp.namedGate), options.onProgress), + () => observeStep(id, 'deterministic', () => lowerDeterministic(id, command, false, leaseMs, runOp.namedGate, secrets), options.onProgress), lifecycle, // No label: a command is not a display name. It carries literal // tokens and URLs, and no prefix of it is safe to show (displayLabel). diff --git a/packages/sdk/src/authored-worker-step.ts b/packages/sdk/src/authored-worker-step.ts index 878281292..31f0b8a6f 100644 --- a/packages/sdk/src/authored-worker-step.ts +++ b/packages/sdk/src/authored-worker-step.ts @@ -20,6 +20,7 @@ import { alsoRecord, recordAuthoredChild } from './authored-step-index.js'; import type { StepFailedDetails } from './failure-kinds.js'; import { LOCAL_AGENT_WORKSPACE } from './local-agent-pins.js'; import { authoredWorkerSlots, type AuthoredWorkerSlots } from './worker-slots.js'; +import { validateStepSecrets } from './step-secrets.js'; const AGENT_TIMEOUT = Symbol('declared agent timeout'); const WORKSPACE_PERMISSION_ANNOTATION = /:\s*(readonly|readwrite)\s*$/i; @@ -177,6 +178,8 @@ export function authoredWorkerRunner( } }, async agent(id: string, options: AgentOptions, verification?: NamedGate): Promise { + const secrets = validateStepSecrets((options as AgentOptions & { secrets?: unknown }).secrets, + agentEnvironment ?? process.env); if (options.workspace !== undefined && localAgentStream !== undefined) { throw new AuthoredFlowExecutionError('unsupported_workspace_permission', 'The local agent worker accepts stream-only steps. Remove workspace or attach a worker that holds its revision pins.'); @@ -277,6 +280,7 @@ export function authoredWorkerRunner( : snapshotJsonValue(permissions, 'f.agent options.permissions') as unknown as PermissionsSpec; const output = await run({ id, type: 'agent', instruction: options.task, + ...(secrets.length ? { secrets } : {}), ...(timeoutMs === undefined ? {} : { timeoutMs }), ...(permissionsSnapshot === undefined ? {} : { permissions: permissionsSnapshot }), ...(localAgentStream === undefined ? {} : { surfaces: { @@ -323,11 +327,14 @@ export function authoredWorkerRunner( } const snapshot = options === undefined ? undefined : snapshotJsonValue(options, 'f.llm options'); if (snapshot !== undefined && (typeof snapshot !== 'object' || snapshot === null || Array.isArray(snapshot) - || Object.keys(snapshot).some(key => !['output', 'cli', 'model'].includes(key)))) { - throw new AuthoredFlowExecutionError('llm_cli_unresolved', 'f.llm options accepts only output, cli, and model.'); + || Object.keys(snapshot).some(key => !['output', 'cli', 'model', 'secrets'].includes(key)))) { + throw new AuthoredFlowExecutionError('llm_cli_unresolved', 'f.llm options accepts only output, cli, model, and secrets.'); } + const secrets = validateStepSecrets((snapshot as { secrets?: unknown } | undefined)?.secrets, + agentEnvironment ?? process.env); const output = await run({ id, type: 'llm', prompt, ...snapshot as unknown as LlmOptions, + ...(secrets.length ? { secrets } : {}), ...(verification === undefined ? {} : { verification }), }); if (options === undefined && typeof output !== 'string') { @@ -358,6 +365,7 @@ export function authoredDeterministicRunner( function lower( id: string, command: string, terminal: boolean, leaseMs: number | undefined, verification: NamedGate | undefined, onNonZero: 'record' | undefined, + secrets: string[] | undefined, read: (outcome: import('./protocol.js').RunOutcome) => Promise, ): Promise { const spec = toKernelSpec(compileSpec({ @@ -365,6 +373,7 @@ export function authoredDeterministicRunner( name: `${name}/${id}`, steps: [{ id, type: 'deterministic', command, + ...(secrets?.length ? { secrets } : {}), ...(leaseMs === undefined ? {} : { lease_ms: leaseMs }), ...(onNonZero === undefined ? {} : { onNonZero }), ...(verification === undefined ? {} : { verification }), @@ -380,12 +389,14 @@ export function authoredDeterministicRunner( if (terminal) return journal.runStart(spec, undefined, admissionKey).then(consume); return budget.execute(journal, spec, consume, admissionKey); } - const lowerRun = (id: string, command: string, terminal = false, leaseMs?: number, verification?: NamedGate): Promise => - lower(id, command, terminal, leaseMs, verification, undefined, + const lowerRun = (id: string, command: string, terminal = false, leaseMs?: number, verification?: NamedGate, + secrets?: string[]): Promise => + lower(id, command, terminal, leaseMs, verification, undefined, secrets, outcome => readSuccessfulOutput(journal, outcome, id, journalSteps, context)); /** `f.run(..., { onNonZero: 'record' })`: the exit code is the result, not a throw. */ - lowerRun.recording = (id: string, command: string, leaseMs?: number, verification?: NamedGate): Promise => - lower(id, command, false, leaseMs, verification, 'record', + lowerRun.recording = (id: string, command: string, leaseMs?: number, verification?: NamedGate, + secrets?: string[]): Promise => + lower(id, command, false, leaseMs, verification, 'record', secrets, outcome => readRecordedOutcome(journal, outcome, id, journalSteps, context)); return lowerRun; } diff --git a/packages/sdk/src/communication/environment.ts b/packages/sdk/src/communication/environment.ts index a31d04990..a2784584e 100644 --- a/packages/sdk/src/communication/environment.ts +++ b/packages/sdk/src/communication/environment.ts @@ -1,5 +1,6 @@ import { basename } from 'node:path'; import { wrapperEnvironment } from '../wrapper-runtime.js'; +import { deliveredNames, validateStepSecrets } from '../step-secrets.js'; const terminalNames = ['TERM', 'COLORTERM', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME']; const relayNames = ['RELAY_API_KEY', 'RELAY_WORKSPACE_KEY', 'AGENT_RELAY_WORKSPACE_KEY', @@ -32,9 +33,23 @@ export function brokerEnvironment(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv return { ...Object.fromEntries(Object.keys(source).map(name => [name, undefined])), ...wrapperEnvironment(source), ...selected(source, [...terminalNames, 'RELAY_BASE_URL']) }; } -export function agentEnvironment(cli: string, source: NodeJS.ProcessEnv = process.env): Record { +export function agentEnvironment(cli: string, source: NodeJS.ProcessEnv = process.env, + grants?: string[], scopeSecrets = true): Record { // Broker transport credentials must not become CLI credentials. The harness // protocol accepts string values, so clear those inherited names explicitly. + const credentials = selected(source, providerNames[basename(cli).replace(/\.exe$/i, '')] ?? []); + if (!scopeSecrets) { + if (grants?.length) throw new Error('per-step secret grants require a daemon with step_secret_scoping support'); + return { ...Object.fromEntries(relayNames.map(name => [name, ''])), ...credentials } as Record; + } + const names = deliveredNames(source); + const allowed = validateStepSecrets(grants, source); + if (allowed.some(name => relayNames.includes(name))) { + throw new Error('Relay broker credentials cannot be granted to a communication agent'); + } + for (const name of names ?? []) { + if (!allowed.includes(name)) delete credentials[name]; + } return { ...Object.fromEntries(relayNames.map(name => [name, ''])), - ...selected(source, providerNames[basename(cli).replace(/\.exe$/i, '')] ?? []) } as Record; + ...credentials, ...selected(source, allowed) } as Record; } diff --git a/packages/sdk/src/communication/worker.ts b/packages/sdk/src/communication/worker.ts index a485e9863..29efe934b 100644 --- a/packages/sdk/src/communication/worker.ts +++ b/packages/sdk/src/communication/worker.ts @@ -103,7 +103,8 @@ async function run(client: JournalClient, dispatch: StepDispatchEvent, instructi handle = await relay.broker.spawnPty({ name, cli: basename(cliIdentity).replace(/\.exe$/i, ''), task: prompt, channels: [], skipRelayPrompt: true, model: resolveCliModel(cliIdentity, spec.model), cwd: directory, harnessConfig: { runtime: 'pty', command: quote(command), args: [], - cwd: directory, env: { ...agentEnvironment(cliIdentity, environment ?? process.env), + cwd: directory, env: { ...agentEnvironment(cliIdentity, environment ?? process.env, + spec.secrets, client.supportsFeature?.('step_secret_scoping') ?? true), 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/compile.ts b/packages/sdk/src/compile.ts index eca9e55ad..e6f45ebd2 100644 --- a/packages/sdk/src/compile.ts +++ b/packages/sdk/src/compile.ts @@ -192,6 +192,7 @@ function compileStep(step: StepSpec): StepSpec { const base = { id: step.id, type: step.type, + ...(step.secrets?.length ? { secrets: step.secrets } : {}), ...((step.dependsOn !== undefined || step.input !== undefined) ? { dependsOn: step.input === undefined ? step.dependsOn : [...new Set([...(step.dependsOn ?? []), ...bindingDependencies(step.input)])] } : {}), ...(step.input !== undefined ? { input: step.input } : {}), @@ -636,6 +637,7 @@ function isObject(value: unknown): value is Record { function toKernelStep(step: StepSpec, cliIdentity?: string): ResolvedKernelStepSpec { const common: ResolvedKernelStepCommon = { id: step.id, + ...(step.secrets?.length ? { secrets: step.secrets } : {}), depends_on: step.input === undefined ? step.dependsOn ?? [] : [...new Set([...(step.dependsOn ?? []), ...bindingDependencies(step.input)])], ...(step.input !== undefined ? { input: step.input } : {}), diff --git a/packages/sdk/src/journal-client.ts b/packages/sdk/src/journal-client.ts index b8e09144c..7de1ff351 100644 --- a/packages/sdk/src/journal-client.ts +++ b/packages/sdk/src/journal-client.ts @@ -25,6 +25,7 @@ import { type StepUsage, } from './protocol.js'; import type { KernelRunSpec, StepType } from './spec.js'; +import { validateStepSecrets } from './step-secrets.js'; export interface JournalClientOptions { /** Opt-in total budget for read-only requests; interactive clients stay single-shot. */ @@ -78,6 +79,9 @@ export class JournalClient extends JournalConnection { }); } + /** Additive daemon capability negotiated by this session's hello. */ + supportsFeature(name: string): boolean { return this.features.has(name); } + private request(verb: V, params: VerbContract[V]['params'], timeoutMs: number | null = this.requestTimeoutMs, signal?: AbortSignal): Promise { // Every allowlisted read takes the policy, even after the primary dropped: its @@ -131,6 +135,10 @@ export class JournalClient extends JournalConnection { admissionKey?: string, watch = false, ): Promise { + for (const step of spec.steps) validateStepSecrets(step.secrets, process.env); + if (!this.features.has('step_secret_scoping') && spec.steps.some(step => step.secrets?.length)) { + throw new Error('per-step secret grants require a daemon with step_secret_scoping support'); + } return this.request('run.start', { spec, ...(reuseFromRunId === undefined ? {} : { reuse_from_run_id: reuseFromRunId }), @@ -158,7 +166,11 @@ export class JournalClient extends JournalConnection { * alike, so their values never cross the socket from this client. */ private stepEnv(): { env?: Record } { - if (!this.features.has('step_env')) return {}; + if (!this.features.has('step_env')) { + // A client with no declared grants keeps compatibility with the + // currently pinned hosted runtime until Cloud upgrades the artifact. + return {}; + } const env: Record = {}; for (const [key, value] of Object.entries(process.env)) { if (value !== undefined && !this.withheld.has(key)) env[key] = value; diff --git a/packages/sdk/src/llm-worker.ts b/packages/sdk/src/llm-worker.ts index 2b0e65bfe..b37632c7a 100644 --- a/packages/sdk/src/llm-worker.ts +++ b/packages/sdk/src/llm-worker.ts @@ -64,7 +64,8 @@ export class LlmWorker extends EventEmitter { 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', - undefined, undefined, 'direct', undefined, this.environment, spec.cli_identity) + undefined, undefined, 'direct', undefined, this.environment, spec.cli_identity, + undefined, undefined, spec.secrets, this.client.supportsFeature?.('step_secret_scoping') ?? true) : Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'llm step has no declared CLI' })); const { result, usage } = workerSpend(completed, effectiveModel); const cost = reportedCost(completed, effectiveModel); diff --git a/packages/sdk/src/spec.ts b/packages/sdk/src/spec.ts index acc7783b3..2e792327b 100644 --- a/packages/sdk/src/spec.ts +++ b/packages/sdk/src/spec.ts @@ -217,6 +217,8 @@ export interface KernelPlacementRequirements extends Omit; requirements?: PlacementRequirements; @@ -478,6 +480,8 @@ export interface KernelVerificationSpec { } export interface KernelStepCommon { + /** Names only; the worker/kernel selects values from the run environment. */ + secrets?: string[]; input?: Record; requirements?: KernelPlacementRequirements; memory?: KernelMemorySpec; diff --git a/packages/sdk/src/step-fields.ts b/packages/sdk/src/step-fields.ts index 01d8e2258..5f018a227 100644 --- a/packages/sdk/src/step-fields.ts +++ b/packages/sdk/src/step-fields.ts @@ -21,6 +21,7 @@ export const AGENT_DECLARATION_FIELDS = ['cli', 'model'] as const; export const STEP_COMMON_FIELDS = [ 'id', 'type', + 'secrets', 'dependsOn', 'input', 'verification', diff --git a/packages/sdk/src/step-secrets.ts b/packages/sdk/src/step-secrets.ts new file mode 100644 index 000000000..e9e432e94 --- /dev/null +++ b/packages/sdk/src/step-secrets.ts @@ -0,0 +1,79 @@ +import type { CliAdapterKind } from './cli-adapter.js'; + +/** Cloud supplies this name-only manifest alongside delivered envSecrets. */ +export const SECRET_ENV_NAMES = 'RELAY_SECRET_ENV_NAMES'; +const VALID_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; + +const BASE_ENV = [ + 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TERM', 'LANG', 'LANGUAGE', + 'LC_ALL', 'LC_CTYPE', 'TZ', 'TMPDIR', 'TMP', 'TEMP', 'XDG_RUNTIME_DIR', + 'XDG_CACHE_HOME', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'HOSTNAME', + 'NODE_PATH', 'NODE_ENV', 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', + 'SSL_CERT_DIR', 'NVM_DIR', 'NVM_BIN', 'NVM_INC', + 'NVM_SYMLINK_CURRENT', 'PIPX_HOME', 'PIPX_BIN_DIR', 'PYTHON_PATH', + 'PYTHON_VERSION', 'PYTHON_SHA256', 'RELAYFILE_MOUNT_PATH', + 'RELAYFILE_MOUNT_PATHS', 'RELAYFILE_MOUNT_ROOT', 'WORKSPACE_ROOT', + 'SystemRoot', 'ComSpec', 'PATHEXT', 'WINDIR', +] as const; + +// Model login is an intrinsic capability of the selected CLI. It is not a +// run's delivered envSecret; only that CLI gets its own login variables. +const CLI_LOGIN: Record = { + claude: [ + 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CONFIG_DIR', 'ANTHROPIC_API_KEY', + 'ANTHROPIC_BASE_URL', 'CLAUDE_CODE_USE_BEDROCK', + 'AWS_BEARER_TOKEN_BEDROCK', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN', 'AWS_REGION', 'AWS_DEFAULT_REGION', + ], + codex: ['CODEX_HOME', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_API_BASE'], + 'relayflows-wrapper-v1': [], +}; + +export function deliveredNames(source: NodeJS.ProcessEnv): ReadonlySet | undefined { + const raw = source[SECRET_ENV_NAMES]; + if (raw === undefined) return undefined; // self-hosted runs may grant present env names + let parsed: unknown; + try { parsed = JSON.parse(raw); } catch { throw new Error(`${SECRET_ENV_NAMES} must be a JSON array of names`); } + if (!Array.isArray(parsed) || !parsed.every(name => typeof name === 'string' && VALID_NAME.test(name))) { + throw new Error(`${SECRET_ENV_NAMES} must be a JSON array of valid names`); + } + if (new Set(parsed).size !== parsed.length || parsed.includes(SECRET_ENV_NAMES)) { + throw new Error(`${SECRET_ENV_NAMES} contains a duplicate or reserved name`); + } + return new Set(parsed); +} + +/** Refuse a misspelled, unavailable or platform-owned name before a child runs. */ +export function validateStepSecrets(declaration: unknown, source: NodeJS.ProcessEnv): string[] { + const known = deliveredNames(source); + if (declaration === undefined) return []; + if (!Array.isArray(declaration) || !declaration.every(name => typeof name === 'string' && VALID_NAME.test(name))) { + throw new Error('step secrets must be an array of environment variable names'); + } + const names = declaration as string[]; + if (new Set(names).size !== names.length) throw new Error('step secrets contain duplicate names'); + for (const name of names) { + if (name === SECRET_ENV_NAMES || !Object.hasOwn(source, name) || typeof source[name] !== 'string' + || (known !== undefined && !known.has(name))) { + throw new Error(`step secret ${JSON.stringify(name)} is not available to this run`); + } + } + return [...names]; +} + +/** One provider CLI starts from an allowlist, then receives only its grants. */ +export function agentStepEnvironment( + source: NodeJS.ProcessEnv, + kind: CliAdapterKind, + declaration?: unknown, +): NodeJS.ProcessEnv { + const known = deliveredNames(source); + const grants = validateStepSecrets(declaration, source); + const env: NodeJS.ProcessEnv = {}; + for (const name of [...BASE_ENV, ...CLI_LOGIN[kind]]) { + if (known?.has(name) && !grants.includes(name)) continue; + if (source[name] !== undefined) env[name] = source[name]; + } + for (const name of grants) env[name] = source[name]; + return env; +} diff --git a/packages/sdk/src/validate.ts b/packages/sdk/src/validate.ts index 5458efd81..76385e9dd 100644 --- a/packages/sdk/src/validate.ts +++ b/packages/sdk/src/validate.ts @@ -388,6 +388,14 @@ class Validator { const type = st['type'] as StepType; this.checkKeys(st, [...STEP_COMMON_FIELDS, ...STEP_FIELDS_BY_TYPE[type]], at); + if (st['secrets'] !== undefined) { + const names = st['secrets']; + if (!Array.isArray(names) || !names.every(name => typeof name === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) + || new Set(names).size !== names.length || names.includes('RELAY_SECRET_ENV_NAMES')) { + this.fail(`${at}.secrets: expected distinct environment variable names`); + } + } + if (st['requirements'] !== undefined) this.validateRequirements(st['requirements'], `${at}.requirements`); if (st['memory'] !== undefined) this.validateMemory(st['memory'], `${at}.memory`); @@ -491,6 +499,9 @@ class Validator { } private validateAgent(st: AgentStepSpec, at: string): void { + if (st.transport === 'relay' && st.secrets?.length) { + this.fail(`${at}.secrets: relay transport cannot deliver per-step secrets`); + } if (st.timeoutMs !== undefined) { if (!isPosInt(st.timeoutMs) || st.timeoutMs > 60 * 60_000) { this.fail(`${at}.timeoutMs: expected a positive integer at most 3600000 (60 minutes)`); diff --git a/packages/sdk/src/worker-cli.ts b/packages/sdk/src/worker-cli.ts index edc4bc23e..3ac61e944 100644 --- a/packages/sdk/src/worker-cli.ts +++ b/packages/sdk/src/worker-cli.ts @@ -31,6 +31,7 @@ import { } from './wrapper-session.js'; import { wrapperEnvironment } from './wrapper-runtime.js'; import { applyStepEnvironment } from './step-env.js'; +import { agentStepEnvironment } from './step-secrets.js'; import { TAIL_CLOSE_TIMEOUT_MS, openTranscriptTail, type TranscriptTailWriter } from './transcript-tail.js'; import { isRetryableSpawnError, @@ -139,12 +140,29 @@ export async function runAgentCli( stepTimeoutMs?: number, /** A recorded CLI session to continue (`f.agent` `resume`); agent mode only. */ resume?: string, + /** Names of run secrets this step was granted by its journaled spec. */ + secrets?: string[], + /** A featureless daemon retains its legacy child environment for plain runs. */ + scopeSecrets = true, ): Promise { signal?.throwIfAborted(); if (signal !== undefined && process.platform === 'win32') { throw new Error('Lease-bound agent execution requires macOS or Linux process-group cancellation; Windows is unsupported.'); } const kind = cliAdapterKind(cliIdentity ?? cli); + if (!scopeSecrets && secrets?.length) { + return { exit_code: null, stdout_tail: '', + stderr_tail: 'per-step secret grants require a daemon with step_secret_scoping support' }; + } + let childEnvironment: NodeJS.ProcessEnv; + try { + childEnvironment = scopeSecrets + ? agentStepEnvironment(processEnvironment, kind, secrets) + : { ...processEnvironment }; + } catch (error) { + return { exit_code: null, stdout_tail: '', + stderr_tail: error instanceof Error ? error.message : 'step secret grant is invalid' }; + } const effectiveModel = resolveCliModel(cliIdentity ?? cli, model); const argv0 = cliInvocationArgv0(cli, cliIdentity); @@ -206,7 +224,8 @@ 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(processEnvironment); + const wrapperEnv = wrapperEnvironment(childEnvironment); + for (const name of secrets ?? []) wrapperEnv[name] = childEnvironment[name]; applyStepEnvironment(wrapperEnv, sidechannel); const session = await runWrapperSession( cli, @@ -225,7 +244,7 @@ export async function runAgentCli( return requirePricedUsage(session.transport?.cause === 'timeout' ? { ...decoded, stdout_tail: '' } : decoded, effectiveModel); } - const env: NodeJS.ProcessEnv = { ...processEnvironment }; + const env: NodeJS.ProcessEnv = { ...childEnvironment }; 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 a06cf34f4..7c8d511d2 100644 --- a/packages/sdk/src/worker.ts +++ b/packages/sdk/src/worker.ts @@ -39,7 +39,7 @@ 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. */ + /** Source environment; each provider CLI receives only its allowlist and step grants. */ environment?: NodeJS.ProcessEnv; } @@ -156,7 +156,8 @@ export class AgentWorker extends EventEmitter { 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 }, - this.options.environment, spec.cli_identity, spec.timeout_ms, spec.resume) + this.options.environment, spec.cli_identity, spec.timeout_ms, spec.resume, spec.secrets, + this.client.supportsFeature?.('step_secret_scoping') ?? true) : Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'agent step has no declared CLI' })); const { result, usage } = workerSpend(completed, effectiveModel); const cost = reportedCost(completed, effectiveModel); diff --git a/packages/sdk/tests/authored-step-secrets.test.ts b/packages/sdk/tests/authored-step-secrets.test.ts new file mode 100644 index 000000000..5ffa019c8 --- /dev/null +++ b/packages/sdk/tests/authored-step-secrets.test.ts @@ -0,0 +1,54 @@ +import { rmSync } from 'node:fs'; +import { flow } from '@relayflows/surface'; +import { expect, it } from 'vitest'; +import { executeAuthoredFlow } from '../src/authored-flow-executor.js'; +import { JournalClient } from '../src/journal-client.js'; +import { sendResult, sockPath, startLoopback } from './journal-client-loopback.js'; + +it('lowers an authored f.run grant by name and leaves the next step ungranted', async () => { + const path = sockPath(); + const submitted: Array> = []; + const steps = new Map>(); + const server = startLoopback(path, { + hello: ctx => sendResult(ctx, { protocol: 0, server: 'relayflowd', + features: ['step_env', 'step_secret_scoping'] }), + 'run.start': (ctx, params) => { + const step = (params.spec as { steps: Array> }).steps[0]!; + const runId = `child-${steps.size + 1}`; + submitted.push(step); + steps.set(runId, step); + sendResult(ctx, { run_id: runId, status: 'completed', completion_reason: 'success', completed_steps: 1 }); + }, + 'journal.read': (ctx, params) => sendResult(ctx, { entries: [{ + entry_type: 'step.completed', step_id: steps.get(params.run_id as string)!.id, + payload: { completionReason: 'success', output: { exit_code: 0, stdout_tail: 'ok', stderr_tail: '' } }, + }] }), + }); + const client = new JournalClient(path); + const priorToken = process.env.NATIVE_API_TOKEN; + const priorManifest = process.env.RELAY_SECRET_ENV_NAMES; + process.env.NATIVE_API_TOKEN = 'private-token'; + process.env.RELAY_SECRET_ENV_NAMES = '["NATIVE_API_TOKEN"]'; + try { + await client.connect(); + await client.hello('authored-secret-test'); + await executeAuthoredFlow(flow('authored-secret', async f => { + await f.run('printf first', { secrets: ['NATIVE_API_TOKEN'] }); + await f.run('printf second'); + f.done('success'); + }), client); + expect(submitted[0]).toMatchObject({ type: 'deterministic', command: 'printf first', + secrets: ['NATIVE_API_TOKEN'] }); + expect(submitted[1]).toMatchObject({ type: 'deterministic', command: 'printf second' }); + expect(submitted[1]).not.toHaveProperty('secrets'); + expect(JSON.stringify(submitted)).not.toContain('private-token'); + } finally { + if (priorToken === undefined) delete process.env.NATIVE_API_TOKEN; + else process.env.NATIVE_API_TOKEN = priorToken; + if (priorManifest === undefined) delete process.env.RELAY_SECRET_ENV_NAMES; + else process.env.RELAY_SECRET_ENV_NAMES = priorManifest; + client.close(); + await new Promise(resolve => server.close(() => resolve())); + rmSync(path, { force: true }); + } +}); diff --git a/packages/sdk/tests/communication-review.test.ts b/packages/sdk/tests/communication-review.test.ts index 9b500bff6..6ac5e7eb9 100644 --- a/packages/sdk/tests/communication-review.test.ts +++ b/packages/sdk/tests/communication-review.test.ts @@ -51,6 +51,28 @@ it('does not carry ambient secrets into the broker, and scopes provider credenti expect(agentEnvironment('/custom/cli', source)).not.toHaveProperty('GITHUB_TOKEN'); }); +it('communication agents drop delivered secrets by default on a scoped runtime', () => { + const source = { + ANTHROPIC_API_KEY: 'delivered-provider-key', + CLAUDE_CODE_OAUTH_TOKEN: 'own-login', + NATIVE_API_TOKEN: 'delivered-native-key', + RELAY_API_KEY: 'broker-key', + RELAY_SECRET_ENV_NAMES: JSON.stringify(['ANTHROPIC_API_KEY', 'NATIVE_API_TOKEN']), + }; + const plain = agentEnvironment('claude', source); + expect(plain).not.toHaveProperty('ANTHROPIC_API_KEY'); + expect(plain).not.toHaveProperty('NATIVE_API_TOKEN'); + expect(plain.CLAUDE_CODE_OAUTH_TOKEN).toBe('own-login'); + const granted = agentEnvironment('claude', source, ['NATIVE_API_TOKEN']); + expect(granted.NATIVE_API_TOKEN).toBe('delivered-native-key'); + expect(granted).not.toHaveProperty('ANTHROPIC_API_KEY'); + expect(granted.RELAY_API_KEY).toBe(''); + expect(agentEnvironment('claude', source, undefined, false).ANTHROPIC_API_KEY).toBe('delivered-provider-key'); + expect(() => agentEnvironment('claude', { + ...source, RELAY_SECRET_ENV_NAMES: JSON.stringify(['RELAY_API_KEY']), + }, ['RELAY_API_KEY'])).toThrow('Relay broker credentials'); +}); + it('passes provider-specific proxy routing only to Cursor and Grok children', () => { const cursorProxy = 'https://proxy.test/v1/house/cursor/runs/run-1'; const source = { diff --git a/packages/sdk/tests/journal-client-step-env.test.ts b/packages/sdk/tests/journal-client-step-env.test.ts index 5c255c1a7..c0dccac46 100644 --- a/packages/sdk/tests/journal-client-step-env.test.ts +++ b/packages/sdk/tests/journal-client-step-env.test.ts @@ -37,3 +37,98 @@ it.each([ rmSync(path, { force: true }); } }); + +it('keeps plain runs with delivered secrets working on an older daemon', async () => { + const path = sockPath(); + const received: Record[] = []; + const server = startLoopback(path, { + hello: ctx => sendResult(ctx, { protocol: 0, server: 'relayflowd', features: ['step_env'] }), + 'run.start': (ctx, params) => { received.push(params); sendResult(ctx, { run_id: 'run-01', status: 'completed' }); }, + 'run.resume': (ctx, params) => { received.push(params); sendResult(ctx, { run_id: 'run-01', status: 'completed' }); }, + }); + const client = new JournalClient(path); + const priorToken = process.env.NATIVE_API_TOKEN; + const priorManifest = process.env.RELAY_SECRET_ENV_NAMES; + process.env.NATIVE_API_TOKEN = 'native-secret'; + process.env.RELAY_SECRET_ENV_NAMES = '["NATIVE_API_TOKEN"]'; + try { + await client.connect(); + await client.hello('secret-feature-gate'); + expect(client.supportsFeature('step_secret_scoping')).toBe(false); + await client.runStart(HELLO_SPEC); + await client.runResume('run-01'); + expect(received).toHaveLength(2); + for (const params of received) { + expect((params['env'] as Record)['NATIVE_API_TOKEN']).toBe('native-secret'); + } + } finally { + if (priorToken === undefined) delete process.env.NATIVE_API_TOKEN; + else process.env.NATIVE_API_TOKEN = priorToken; + if (priorManifest === undefined) delete process.env.RELAY_SECRET_ENV_NAMES; + else process.env.RELAY_SECRET_ENV_NAMES = priorManifest; + client.close(); + await new Promise(resolve => server.close(() => resolve())); + rmSync(path, { force: true }); + } +}); + +it('refuses a declared secret grant on an older daemon before run.start', async () => { + const path = sockPath(); + let starts = 0; + const server = startLoopback(path, { + hello: ctx => sendResult(ctx, { protocol: 0, server: 'relayflowd', features: ['step_env'] }), + 'run.start': ctx => { starts++; sendResult(ctx, { run_id: 'run-01', status: 'completed' }); }, + }); + const client = new JournalClient(path); + const priorToken = process.env.NATIVE_API_TOKEN; + const priorManifest = process.env.RELAY_SECRET_ENV_NAMES; + process.env.NATIVE_API_TOKEN = 'native-secret'; + process.env.RELAY_SECRET_ENV_NAMES = '["NATIVE_API_TOKEN"]'; + try { + await client.connect(); + await client.hello('declared-secret-feature-gate'); + expect(() => client.runStart({ ...HELLO_SPEC, steps: [ + { ...HELLO_SPEC.steps[0]!, secrets: ['NATIVE_API_TOKEN'] }, + ] })).toThrow('step_secret_scoping'); + expect(starts).toBe(0); + } finally { + if (priorToken === undefined) delete process.env.NATIVE_API_TOKEN; + else process.env.NATIVE_API_TOKEN = priorToken; + if (priorManifest === undefined) delete process.env.RELAY_SECRET_ENV_NAMES; + else process.env.RELAY_SECRET_ENV_NAMES = priorManifest; + client.close(); + await new Promise(resolve => server.close(() => resolve())); + rmSync(path, { force: true }); + } +}); + +it('refuses a grant absent from the delivered-name manifest before run.start', async () => { + const path = sockPath(); + let starts = 0; + const server = startLoopback(path, { + hello: ctx => sendResult(ctx, { protocol: 0, server: 'relayflowd', features: ['step_env', 'step_secret_scoping'] }), + 'run.start': ctx => { starts++; sendResult(ctx, { run_id: 'run-01', status: 'completed' }); }, + }); + const client = new JournalClient(path); + const priorToken = process.env.NATIVE_API_TOKEN; + const priorManifest = process.env.RELAY_SECRET_ENV_NAMES; + process.env.NATIVE_API_TOKEN = 'native-secret'; + process.env.RELAY_SECRET_ENV_NAMES = '["NATIVE_API_TOKEN"]'; + try { + await client.connect(); + await client.hello('unknown-secret-gate'); + expect(client.supportsFeature('step_secret_scoping')).toBe(true); + expect(() => client.runStart({ ...HELLO_SPEC, steps: [ + { ...HELLO_SPEC.steps[0]!, secrets: ['OTHER_TOKEN'] }, + ] })).toThrow('OTHER_TOKEN'); + expect(starts).toBe(0); + } finally { + if (priorToken === undefined) delete process.env.NATIVE_API_TOKEN; + else process.env.NATIVE_API_TOKEN = priorToken; + if (priorManifest === undefined) delete process.env.RELAY_SECRET_ENV_NAMES; + else process.env.RELAY_SECRET_ENV_NAMES = priorManifest; + client.close(); + await new Promise(resolve => server.close(() => resolve())); + rmSync(path, { force: true }); + } +}); diff --git a/packages/sdk/tests/step-secrets.test.ts b/packages/sdk/tests/step-secrets.test.ts new file mode 100644 index 000000000..8ac8a4524 --- /dev/null +++ b/packages/sdk/tests/step-secrets.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { agentStepEnvironment, validateStepSecrets } from '../src/step-secrets.js'; +import { compileSpec, toKernelSpec } from '../src/compile.js'; + +const source = { + PATH: '/usr/bin', HOME: '/tmp/home', LANG: 'C.UTF-8', + CLAUDE_CODE_OAUTH_TOKEN: 'runtime-login', + NATIVE_API_TOKEN: 'native-secret', OTHER_TOKEN: 'other-secret', + GH_TOKEN: 'repository-grant', + RELAY_SECRET_ENV_NAMES: JSON.stringify(['NATIVE_API_TOKEN', 'OTHER_TOKEN']), +}; + +describe('per-step secret grants', () => { + it('records only grant names in compiled shell and agent steps', () => { + const compiled = toKernelSpec(compileSpec({ version: '0.1.0', name: 'scoped', steps: [ + { id: 'call', type: 'deterministic', command: 'echo ok', secrets: ['NATIVE_API_TOKEN'] }, + { id: 'review', type: 'agent', instruction: 'review', cli: 'claude', secrets: ['OTHER_TOKEN'] }, + ] })); + expect(compiled.steps.map(step => step.secrets)).toEqual([['NATIVE_API_TOKEN'], ['OTHER_TOKEN']]); + expect(JSON.stringify(compiled)).not.toContain('native-secret'); + }); + it('gives an agent only the secret it declared, plus its own CLI login', () => { + const env = agentStepEnvironment(source, 'claude', ['NATIVE_API_TOKEN']); + expect(env).toMatchObject({ PATH: '/usr/bin', HOME: '/tmp/home', + CLAUDE_CODE_OAUTH_TOKEN: 'runtime-login', NATIVE_API_TOKEN: 'native-secret' }); + expect(env).not.toHaveProperty('OTHER_TOKEN'); + expect(env).not.toHaveProperty('GH_TOKEN'); + expect(env).not.toHaveProperty('RELAY_SECRET_ENV_NAMES'); + }); + + it('starts undeclared agent steps without either delivered secret', () => { + const env = agentStepEnvironment(source, 'claude'); + expect(env).not.toHaveProperty('NATIVE_API_TOKEN'); + expect(env).not.toHaveProperty('OTHER_TOKEN'); + expect(env).not.toHaveProperty('GH_TOKEN'); + }); + + it('refuses unknown, duplicate, malformed and unavailable grants without values', () => { + for (const declaration of [['GH_TOKEN'], ['MISSING'], ['NATIVE_API_TOKEN', 'NATIVE_API_TOKEN'], + ['bad=name'], 'NATIVE_API_TOKEN', null]) { + expect(() => validateStepSecrets(declaration, source)).toThrow(); + } + expect(() => validateStepSecrets(['OTHER_TOKEN'], { ...source, OTHER_TOKEN: undefined })).toThrow(); + }); + + it('fails closed on malformed delivered-name metadata', () => { + expect(() => agentStepEnvironment({ ...source, RELAY_SECRET_ENV_NAMES: '{' }, 'claude')).toThrow(); + }); +}); diff --git a/packages/sdk/tests/worker-cli-step-secrets.test.ts b/packages/sdk/tests/worker-cli-step-secrets.test.ts new file mode 100644 index 000000000..fe1162750 --- /dev/null +++ b/packages/sdk/tests/worker-cli-step-secrets.test.ts @@ -0,0 +1,52 @@ +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, it } from 'vitest'; +import { runAgentCli } from '../src/worker-cli.js'; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); + +it('a real agent child defaults to a clean env on a scoped runtime and sees only its grant', async () => { + const root = mkdtempSync(join(tmpdir(), 'flows-agent-secrets-')); + roots.push(root); + const observed = join(root, 'observed.json'); + const cli = join(root, 'claude'); + writeFileSync(cli, `#!/usr/bin/env node +const fs = require('node:fs'); +fs.writeFileSync(${JSON.stringify(observed)}, JSON.stringify({ + native: process.env.NATIVE_API_TOKEN ?? null, + other: process.env.OTHER_TOKEN ?? null, + login: process.env.CLAUDE_CODE_OAUTH_TOKEN ?? null, + grant: process.env.GH_TOKEN ?? null, +})); +process.stdout.write(JSON.stringify({ type: 'result', result: 'ok', + usage: { input_tokens: 2, output_tokens: 1 } }) + '\\n'); +`); + chmodSync(cli, 0o755); + const source = { + PATH: process.env.PATH, HOME: root, CLAUDE_CODE_OAUTH_TOKEN: 'cli-login', + NATIVE_API_TOKEN: 'native-secret', OTHER_TOKEN: 'other-secret', GH_TOKEN: 'grant-token', + RELAY_SECRET_ENV_NAMES: JSON.stringify(['NATIVE_API_TOKEN', 'OTHER_TOKEN']), + }; + const without = await runAgentCli(cli, 'task', undefined, undefined, undefined, + undefined, 'agent', undefined, root, 'direct', undefined, source); + expect(without.exit_code).toBe(0); + expect(JSON.parse(readFileSync(observed, 'utf8'))).toEqual({ + native: null, other: null, login: 'cli-login', grant: null, + }); + const withGrant = await runAgentCli(cli, 'task', undefined, undefined, undefined, + undefined, 'agent', undefined, root, 'direct', undefined, source, + undefined, undefined, undefined, ['NATIVE_API_TOKEN']); + expect(withGrant.exit_code).toBe(0); + expect(JSON.parse(readFileSync(observed, 'utf8'))).toEqual({ + native: 'native-secret', other: null, login: 'cli-login', grant: null, + }); + const legacy = await runAgentCli(cli, 'task', undefined, undefined, undefined, + undefined, 'agent', undefined, root, 'direct', undefined, source, + undefined, undefined, undefined, undefined, false); + expect(legacy.exit_code).toBe(0); + expect(JSON.parse(readFileSync(observed, 'utf8'))).toEqual({ + native: 'native-secret', other: 'other-secret', login: 'cli-login', grant: 'grant-token', + }); +}); diff --git a/packages/sdk/tests/worker-cli.test.ts b/packages/sdk/tests/worker-cli.test.ts index ba7f439d1..6389e0fa1 100644 --- a/packages/sdk/tests/worker-cli.test.ts +++ b/packages/sdk/tests/worker-cli.test.ts @@ -95,7 +95,9 @@ process.stdout.write(JSON.stringify({ type: 'result', result: 'ok', 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' })); + { PATH: process.env.PATH, HOUSE_PROVIDER_KEY: 'house-secret', + RELAY_SECRET_ENV_NAMES: '["HOUSE_PROVIDER_KEY"]' }, + undefined, undefined, undefined, ['HOUSE_PROVIDER_KEY'])); expect(result.exit_code).toBe(0); expect(JSON.parse(readFileSync(observed, 'utf8'))).toEqual({ secret: 'house-secret', ambient: null }); @@ -206,8 +208,10 @@ process.exit(1); }); const rejected = makeWrapper(directory, 'claude', `process.stderr.write('bad request ' + process.env.TRANSPORT_TEST_TOKEN); process.exit(7);`); - const rejectedResult = await withEnvironment({ TRANSPORT_TEST_TOKEN: 'transport-secret-123' }, () => - runAgentCli(rejected, 'task', undefined, 'unpriced-test-model')); + const rejectedResult = await runAgentCli(rejected, 'task', undefined, 'unpriced-test-model', + undefined, undefined, 'agent', undefined, undefined, 'direct', undefined, + { PATH: process.env.PATH, TRANSPORT_TEST_TOKEN: 'transport-secret-123' }, + undefined, undefined, undefined, ['TRANSPORT_TEST_TOKEN']); expect(rejectedResult).toMatchObject({ exit_code: 7, transport: { phase: 'close', cause: 'nonzero_exit', signal: null, retryable: false }, diff --git a/packages/surface/src/context.ts b/packages/surface/src/context.ts index 70489bdc3..bad3cd7af 100644 --- a/packages/surface/src/context.ts +++ b/packages/surface/src/context.ts @@ -39,6 +39,8 @@ export interface AgentOptions { * Resolves with completionReason: timeout. No default; direct transport only, maxIterations 1. */ timeout?: string | number; task: string; + /** Run environment secrets this agent may receive by name. Default: none. */ + secrets?: string[]; workspace?: string; /** Validated declaration only; not currently enforced (gate 8 / #442). */ permissions?: PermissionsSpec; @@ -108,6 +110,8 @@ export interface RunResult { } export interface RunOptions { + /** Run environment secrets this command may receive by name. Default: none. */ + secrets?: string[]; /** Command lease: milliseconds or a duration such as "5m"; default 10m, maximum 15m. */ timeout?: string | number; /** @@ -121,6 +125,8 @@ export interface RunOptions { } export interface LlmOptions { + /** Run environment secrets this model step may receive by name. Default: none. */ + secrets?: string[]; /** JSON Schema checked before the result is accepted by the journal. */ output: Record; cli?: string; From a7bfb44440cce4b7e73d1e22b8bcf903f43b7211 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 16 Oct 2026 07:28:52 +0200 Subject: [PATCH 02/11] test: verify authored shell secret grants against live daemon --- .../tests/authored-step-secrets-live.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 packages/sdk/tests/authored-step-secrets-live.test.ts diff --git a/packages/sdk/tests/authored-step-secrets-live.test.ts b/packages/sdk/tests/authored-step-secrets-live.test.ts new file mode 100644 index 000000000..be7bd6b5c --- /dev/null +++ b/packages/sdk/tests/authored-step-secrets-live.test.ts @@ -0,0 +1,37 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { afterEach, expect, it } from 'vitest'; +import { chainFixture } from './flow-chain-fixture.js'; + +const cleanup: Array<() => Promise> = []; +afterEach(async () => { for (const close of cleanup.splice(0)) await close(); }); + +it('an authored shell child sees a delivered token only when its own f.run grants it', () => { + const fixture = chainFixture(); + cleanup.push(() => fixture.close()); + writeFileSync(fixture.flowPath, `import { flow } from '@relayflows/surface'; +import { writeFileSync } from 'node:fs'; +export default flow('scoped-shell', async f => { + const plain = await f.run('printf %s "$NATIVE_API_TOKEN"'); + const granted = await f.run('printf %s "$NATIVE_API_TOKEN"', { secrets: ['NATIVE_API_TOKEN'] }); + writeFileSync('observed.json', JSON.stringify({ plain, granted })); + f.done('success'); +}); +`); + const previousToken = process.env.NATIVE_API_TOKEN; + const previousManifest = process.env.RELAY_SECRET_ENV_NAMES; + process.env.NATIVE_API_TOKEN = 'native-secret'; + process.env.RELAY_SECRET_ENV_NAMES = '["NATIVE_API_TOKEN"]'; + let result; + try { + result = fixture.invoke('run', '--no-observer-link', '--json', '--data-dir', fixture.data, + fixture.flowPath, '--input', '{}'); + } finally { + if (previousToken === undefined) delete process.env.NATIVE_API_TOKEN; + else process.env.NATIVE_API_TOKEN = previousToken; + if (previousManifest === undefined) delete process.env.RELAY_SECRET_ENV_NAMES; + else process.env.RELAY_SECRET_ENV_NAMES = previousManifest; + } + expect(result.status, result.stderr + result.stdout).toBe(0); + expect(JSON.parse(readFileSync(`${fixture.root}/observed.json`, 'utf8'))) + .toEqual({ plain: '', granted: 'native-secret' }); +}, 60_000); From dbfefbd90656e31cf40dbb543cabde234da272ec Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 16 Oct 2026 07:33:28 +0200 Subject: [PATCH 03/11] chore: regenerate schema for step secret grants --- packages/schema/flows.schema.json | 80 +++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/packages/schema/flows.schema.json b/packages/schema/flows.schema.json index ca4b4f003..8e6d1b8e3 100644 --- a/packages/schema/flows.schema.json +++ b/packages/schema/flows.schema.json @@ -1007,6 +1007,16 @@ "description": "Fields shared by every step on the ladder.", "type": "object", "properties": { + "secrets": { + "title": "secrets", + "description": "Names of run secrets granted to this step; values never enter the spec.", + "type": "array", + "items": { + "type": "string", + "title": "secrets items", + "description": "secrets items value." + } + }, "input": { "title": "input", "description": "Named values selected from earlier steps' declared, verified outputs.", @@ -1080,6 +1090,16 @@ "description": "Rung 1 — a pure script. Executed by the `relayflowd` binary: spawn command,\ncapture stdout/exit code. Output = `{exit_code, stdout_tail}`. Gate-1\ndeterministic steps with placement requirements pin their worktree base commit.", "type": "object", "properties": { + "secrets": { + "title": "secrets", + "description": "Names of run secrets granted to this step; values never enter the spec.", + "type": "array", + "items": { + "type": "string", + "title": "secrets items", + "description": "secrets items value." + } + }, "input": { "title": "input", "description": "Named values selected from earlier steps' declared, verified outputs.", @@ -1201,6 +1221,16 @@ "description": "Rung 2 — a bare model call. No workspace, output is a value. The kernel\nnever calls a model: it dispatches to an attached SDK worker (§5) which\nreturns `{output, usage}`; the kernel then runs the verification gate.", "type": "object", "properties": { + "secrets": { + "title": "secrets", + "description": "Names of run secrets granted to this step; values never enter the spec.", + "type": "array", + "items": { + "type": "string", + "title": "secrets items", + "description": "secrets items value." + } + }, "input": { "title": "input", "description": "Named values selected from earlier steps' declared, verified outputs.", @@ -1327,6 +1357,16 @@ "description": "Rung 3 — a harnessed agent in a workspace. Dispatched like `llm`, plus\nAppendix A in full: pins declared workspace revisions and stream offsets;\nevery writeback is a journaled `effect.recorded` deduped by\n`(step_id, idempotency_key, surface_path)`.", "type": "object", "properties": { + "secrets": { + "title": "secrets", + "description": "Names of run secrets granted to this step; values never enter the spec.", + "type": "array", + "items": { + "type": "string", + "title": "secrets items", + "description": "secrets items value." + } + }, "input": { "title": "input", "description": "Named values selected from earlier steps' declared, verified outputs.", @@ -2050,6 +2090,16 @@ "description": "KernelStepCommon in the Relayflows spec.", "type": "object", "properties": { + "secrets": { + "title": "secrets", + "description": "Names only; the worker/kernel selects values from the run environment.", + "type": "array", + "items": { + "type": "string", + "title": "secrets items", + "description": "secrets items value." + } + }, "input": { "title": "input", "description": "input in the Relayflows spec.", @@ -2115,6 +2165,16 @@ "description": "KernelDeterministicStep in the Relayflows spec.", "type": "object", "properties": { + "secrets": { + "title": "secrets", + "description": "Names only; the worker/kernel selects values from the run environment.", + "type": "array", + "items": { + "type": "string", + "title": "secrets items", + "description": "secrets items value." + } + }, "input": { "title": "input", "description": "input in the Relayflows spec.", @@ -2208,6 +2268,16 @@ "description": "KernelLlmStep in the Relayflows spec.", "type": "object", "properties": { + "secrets": { + "title": "secrets", + "description": "Names only; the worker/kernel selects values from the run environment.", + "type": "array", + "items": { + "type": "string", + "title": "secrets items", + "description": "secrets items value." + } + }, "input": { "title": "input", "description": "input in the Relayflows spec.", @@ -2393,6 +2463,16 @@ "description": "KernelAgentStep in the Relayflows spec.", "type": "object", "properties": { + "secrets": { + "title": "secrets", + "description": "Names only; the worker/kernel selects values from the run environment.", + "type": "array", + "items": { + "type": "string", + "title": "secrets items", + "description": "secrets items value." + } + }, "input": { "title": "input", "description": "input in the Relayflows spec.", From db4a890f11e704d6f9b30506ed643f55ecbcaa75 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 16 Oct 2026 08:06:38 +0200 Subject: [PATCH 04/11] fix: withhold private signal credential from step environments --- packages/sdk/src/journal-client.ts | 4 +-- packages/sdk/src/step-secrets.ts | 8 +++-- packages/sdk/src/worker-cli.ts | 32 ++++++++++++------- .../tests/authored-step-secrets-live.test.ts | 8 +++-- packages/sdk/tests/det-withhold-live.test.ts | 3 +- .../sdk/tests/journal-client-step-env.test.ts | 30 +++++++++++++++++ packages/sdk/tests/live-kernel.test.ts | 2 +- packages/sdk/tests/step-secrets.test.ts | 7 ++++ packages/sdk/tests/verb-field-lint.test.ts | 1 + .../sdk/tests/worker-cli-step-secrets.test.ts | 13 +++++--- 10 files changed, 84 insertions(+), 24 deletions(-) diff --git a/packages/sdk/src/journal-client.ts b/packages/sdk/src/journal-client.ts index 7de1ff351..916479480 100644 --- a/packages/sdk/src/journal-client.ts +++ b/packages/sdk/src/journal-client.ts @@ -25,7 +25,7 @@ import { type StepUsage, } from './protocol.js'; import type { KernelRunSpec, StepType } from './spec.js'; -import { validateStepSecrets } from './step-secrets.js'; +import { SIGNAL_READ_TOKEN_ENV, validateStepSecrets } from './step-secrets.js'; export interface JournalClientOptions { /** Opt-in total budget for read-only requests; interactive clients stay single-shot. */ @@ -173,7 +173,7 @@ export class JournalClient extends JournalConnection { } const env: Record = {}; for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined && !this.withheld.has(key)) env[key] = value; + if (value !== undefined && key !== SIGNAL_READ_TOKEN_ENV && !this.withheld.has(key)) env[key] = value; } return { env }; } diff --git a/packages/sdk/src/step-secrets.ts b/packages/sdk/src/step-secrets.ts index e9e432e94..098a10129 100644 --- a/packages/sdk/src/step-secrets.ts +++ b/packages/sdk/src/step-secrets.ts @@ -2,6 +2,8 @@ import type { CliAdapterKind } from './cli-adapter.js'; /** Cloud supplies this name-only manifest alongside delivered envSecrets. */ export const SECRET_ENV_NAMES = 'RELAY_SECRET_ENV_NAMES'; +/** CLI-only hosted signal read capability. Never a run or step secret. */ +export const SIGNAL_READ_TOKEN_ENV = 'RELAYFLOW_V2_SIGNAL_READ_TOKEN'; const VALID_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; const BASE_ENV = [ @@ -37,7 +39,8 @@ export function deliveredNames(source: NodeJS.ProcessEnv): ReadonlySet | if (!Array.isArray(parsed) || !parsed.every(name => typeof name === 'string' && VALID_NAME.test(name))) { throw new Error(`${SECRET_ENV_NAMES} must be a JSON array of valid names`); } - if (new Set(parsed).size !== parsed.length || parsed.includes(SECRET_ENV_NAMES)) { + if (new Set(parsed).size !== parsed.length || parsed.includes(SECRET_ENV_NAMES) + || parsed.includes(SIGNAL_READ_TOKEN_ENV)) { throw new Error(`${SECRET_ENV_NAMES} contains a duplicate or reserved name`); } return new Set(parsed); @@ -53,7 +56,8 @@ export function validateStepSecrets(declaration: unknown, source: NodeJS.Process const names = declaration as string[]; if (new Set(names).size !== names.length) throw new Error('step secrets contain duplicate names'); for (const name of names) { - if (name === SECRET_ENV_NAMES || !Object.hasOwn(source, name) || typeof source[name] !== 'string' + if (name === SECRET_ENV_NAMES || name === SIGNAL_READ_TOKEN_ENV + || !Object.hasOwn(source, name) || typeof source[name] !== 'string' || (known !== undefined && !known.has(name))) { throw new Error(`step secret ${JSON.stringify(name)} is not available to this run`); } diff --git a/packages/sdk/src/worker-cli.ts b/packages/sdk/src/worker-cli.ts index 3ac61e944..7b2ead1eb 100644 --- a/packages/sdk/src/worker-cli.ts +++ b/packages/sdk/src/worker-cli.ts @@ -31,7 +31,7 @@ import { } from './wrapper-session.js'; import { wrapperEnvironment } from './wrapper-runtime.js'; import { applyStepEnvironment } from './step-env.js'; -import { agentStepEnvironment } from './step-secrets.js'; +import { SIGNAL_READ_TOKEN_ENV, agentStepEnvironment } from './step-secrets.js'; import { TAIL_CLOSE_TIMEOUT_MS, openTranscriptTail, type TranscriptTailWriter } from './transcript-tail.js'; import { isRetryableSpawnError, @@ -159,6 +159,9 @@ export async function runAgentCli( childEnvironment = scopeSecrets ? agentStepEnvironment(processEnvironment, kind, secrets) : { ...processEnvironment }; + // The old-daemon compatibility path retains other legacy variables, but + // this CLI-only signal capability must never enter an agent subprocess. + delete childEnvironment[SIGNAL_READ_TOKEN_ENV]; } catch (error) { return { exit_code: null, stdout_tail: '', stderr_tail: error instanceof Error ? error.message : 'step secret grant is invalid' }; @@ -275,9 +278,13 @@ export async function runAgentCli( const args = [...invocation.args]; args.splice(args.length - promptOperand('').length, 0, ...(kind === 'claude' ? ['--output-format', 'stream-json', '--verbose'] : ['--json'])); const completion = kind === 'claude' ? claudeResultOutcome : undefined; + // Retain the source values for output redaction even though the child + // receives only its filtered environment. A provider can echo a value from + // its prompt or fixture that was not granted as an environment variable. + const redactionEnvironment = { ...processEnvironment, ...env }; return requirePricedUsage(decodeProviderResult(await spawnInvocation( - cli, { ...invocation, args }, env, signal, sidechannel, cwd, completion, argv0, kind, - ), kind, env), effectiveModel); + cli, { ...invocation, args }, env, signal, sidechannel, cwd, completion, argv0, kind, redactionEnvironment, + ), kind, redactionEnvironment), effectiveModel); } } @@ -418,6 +425,7 @@ async function spawnInvocation( completion?: (line: string) => { failed: boolean } | undefined, argv0?: string, kind?: CliAdapterKind, + redactionEnvironment: NodeJS.ProcessEnv = env, ): Promise { const pendingInput: Buffer[] = []; let acceptingDrive = true; @@ -455,7 +463,7 @@ async function spawnInvocation( }; try { writer = sidechannel?.attempt === undefined ? undefined - : await openTranscriptWriter(transcriptPath(sidechannel, sidechannel.attempt), env); + : await openTranscriptWriter(transcriptPath(sidechannel, sidechannel.attempt), redactionEnvironment); channel = sidechannel === undefined ? undefined : await openSidechannel({ ...sidechannel, onDrive() { driven = true; sidechannel.onDrive(); }, @@ -616,7 +624,7 @@ async function spawnInvocation( const transport = transportEvidence({ phase: 'abort', cause: 'lease_lost', exitCode: null, signal: null, stderr: 'Agent execution aborted: lease ownership lost.', retryable: false, - }, env); + }, redactionEnvironment); finishAfterStop( { exit_code: null, stdout_tail: '', stderr_tail: 'Agent execution aborted: lease ownership lost.', transport }, 'kill', @@ -654,11 +662,11 @@ async function spawnInvocation( phase: 'result_exit_grace', cause: 'result_exit_timeout', exitCode, signal: null, stderr: stderrText, retryable: false, - }, env); + }, redactionEnvironment); finishAfterStop({ exit_code: exitCode, stdout_tail: Buffer.concat(stdout).toString('utf8'), - stderr_tail: redactText(stderrText, env), + stderr_tail: redactText(stderrText, redactionEnvironment), transport, }, 'terminate'); }, RESULT_EXIT_GRACE_MS); @@ -690,11 +698,11 @@ async function spawnInvocation( const transport = transportEvidence({ phase: 'spawn', cause: child.pid === undefined ? 'spawn_error' : 'process_error', exitCode: null, signal: null, stderr: error.message, errorCode: code, retryable, - }, env); + }, redactionEnvironment); finishOnChildExit({ exit_code: null, stdout_tail: Buffer.concat(stdout).toString('utf8'), - stderr_tail: redactText(error.message, env), + stderr_tail: redactText(error.message, redactionEnvironment), transport, }); }); @@ -711,11 +719,11 @@ async function spawnInvocation( const retryable = codexStdinLifecycle || signalName !== null || code === null; const transport = transportEvidence({ phase: 'close', cause, exitCode: code, signal: signalName, stderr: stderrText, retryable, - }, env); + }, redactionEnvironment); finishOnChildExit({ exit_code: code, stdout_tail: Buffer.concat(stdout).toString('utf8'), - stderr_tail: redactText(stderrText, env), + stderr_tail: redactText(stderrText, redactionEnvironment), transport, }); }); @@ -729,7 +737,7 @@ async function spawnInvocation( phase: 'timeout', cause: 'timeout', exitCode: null, signal: null, stderr: `${Buffer.concat(stderr).toString('utf8')}\n${timeoutMessage}`, retryable: false, - }, env); + }, redactionEnvironment); finishAfterStop({ exit_code: null, stdout_tail: Buffer.concat(stdout).toString('utf8'), diff --git a/packages/sdk/tests/authored-step-secrets-live.test.ts b/packages/sdk/tests/authored-step-secrets-live.test.ts index be7bd6b5c..3c504aa9d 100644 --- a/packages/sdk/tests/authored-step-secrets-live.test.ts +++ b/packages/sdk/tests/authored-step-secrets-live.test.ts @@ -11,7 +11,7 @@ it('an authored shell child sees a delivered token only when its own f.run grant writeFileSync(fixture.flowPath, `import { flow } from '@relayflows/surface'; import { writeFileSync } from 'node:fs'; export default flow('scoped-shell', async f => { - const plain = await f.run('printf %s "$NATIVE_API_TOKEN"'); + const plain = await f.run('printf "%s:%s" "$NATIVE_API_TOKEN" "$RELAYFLOW_V2_SIGNAL_READ_TOKEN"'); const granted = await f.run('printf %s "$NATIVE_API_TOKEN"', { secrets: ['NATIVE_API_TOKEN'] }); writeFileSync('observed.json', JSON.stringify({ plain, granted })); f.done('success'); @@ -19,8 +19,10 @@ export default flow('scoped-shell', async f => { `); const previousToken = process.env.NATIVE_API_TOKEN; const previousManifest = process.env.RELAY_SECRET_ENV_NAMES; + const previousPrivate = process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN; process.env.NATIVE_API_TOKEN = 'native-secret'; process.env.RELAY_SECRET_ENV_NAMES = '["NATIVE_API_TOKEN"]'; + process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN = 'private-signal-token'; let result; try { result = fixture.invoke('run', '--no-observer-link', '--json', '--data-dir', fixture.data, @@ -30,8 +32,10 @@ export default flow('scoped-shell', async f => { else process.env.NATIVE_API_TOKEN = previousToken; if (previousManifest === undefined) delete process.env.RELAY_SECRET_ENV_NAMES; else process.env.RELAY_SECRET_ENV_NAMES = previousManifest; + if (previousPrivate === undefined) delete process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN; + else process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN = previousPrivate; } expect(result.status, result.stderr + result.stdout).toBe(0); expect(JSON.parse(readFileSync(`${fixture.root}/observed.json`, 'utf8'))) - .toEqual({ plain: '', granted: 'native-secret' }); + .toEqual({ plain: ':', granted: 'native-secret' }); }, 60_000); diff --git a/packages/sdk/tests/det-withhold-live.test.ts b/packages/sdk/tests/det-withhold-live.test.ts index f0e99caab..b4eb6b334 100644 --- a/packages/sdk/tests/det-withhold-live.test.ts +++ b/packages/sdk/tests/det-withhold-live.test.ts @@ -108,7 +108,8 @@ it('a withheld agent key is absent from the det step and present in the agent st } expect(det.get('DELIVERED_SECRET')).toBe(ORDINARY['DELIVERED_SECRET']); const agent = JSON.parse(readFileSync(join(root, 'agent.env'), 'utf8')) as Record; - for (const [name, value] of Object.entries(AGENT)) expect(agent[name], `${name} lost by the agent`).toBe(value); + expect(agent['ANTHROPIC_API_KEY']).toBe(AGENT['ANTHROPIC_API_KEY']); + expect(agent).not.toHaveProperty('OPENAI_API_KEY'); // Claude receives only Claude login credentials. }, 120_000); /** diff --git a/packages/sdk/tests/journal-client-step-env.test.ts b/packages/sdk/tests/journal-client-step-env.test.ts index c0dccac46..8b067bf44 100644 --- a/packages/sdk/tests/journal-client-step-env.test.ts +++ b/packages/sdk/tests/journal-client-step-env.test.ts @@ -72,6 +72,36 @@ it('keeps plain runs with delivered secrets working on an older daemon', async ( } }); +it('never forwards the private signal-read token on run.start or run.resume', async () => { + const path = sockPath(); + const received: Record[] = []; + const server = startLoopback(path, { + hello: ctx => sendResult(ctx, { protocol: 0, server: 'relayflowd', features: ['step_env', 'step_secret_scoping'] }), + 'run.start': (ctx, params) => { received.push(params); sendResult(ctx, { run_id: 'run-01', status: 'completed' }); }, + 'run.resume': (ctx, params) => { received.push(params); sendResult(ctx, { run_id: 'run-01', status: 'completed' }); }, + }); + const client = new JournalClient(path); + const prior = process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN; + process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN = 'private-signal-token'; + try { + await client.connect(); + await client.hello('signal-token-gate'); + await client.runStart(HELLO_SPEC); + await client.runResume('run-01'); + expect(received).toHaveLength(2); + for (const params of received) { + expect(params).toHaveProperty('env'); + expect(params['env']).not.toHaveProperty('RELAYFLOW_V2_SIGNAL_READ_TOKEN'); + } + } finally { + if (prior === undefined) delete process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN; + else process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN = prior; + client.close(); + await new Promise(resolve => server.close(() => resolve())); + rmSync(path, { force: true }); + } +}); + it('refuses a declared secret grant on an older daemon before run.start', async () => { const path = sockPath(); let starts = 0; diff --git a/packages/sdk/tests/live-kernel.test.ts b/packages/sdk/tests/live-kernel.test.ts index 4ba80d598..75f2b5768 100644 --- a/packages/sdk/tests/live-kernel.test.ts +++ b/packages/sdk/tests/live-kernel.test.ts @@ -1487,7 +1487,7 @@ describe('JournalClient wire conformance against live relayflowd', () => { await startDaemon(dataDir); const client = await connectClient(dataDir); - expect(await client.hello('live-conformance')).toEqual({ protocol: 0, server: 'relayflowd', features: ['reported_cost', 'step_env'] }); + expect(await client.hello('live-conformance')).toEqual({ protocol: 0, server: 'relayflowd', features: ['reported_cost', 'step_env', 'step_secret_scoping'] }); const deterministic = await client.runStart(toKernelSpec(compileYaml(` version: '0.1.0' steps: diff --git a/packages/sdk/tests/step-secrets.test.ts b/packages/sdk/tests/step-secrets.test.ts index 8ac8a4524..ad9d69e1d 100644 --- a/packages/sdk/tests/step-secrets.test.ts +++ b/packages/sdk/tests/step-secrets.test.ts @@ -46,4 +46,11 @@ describe('per-step secret grants', () => { it('fails closed on malformed delivered-name metadata', () => { expect(() => agentStepEnvironment({ ...source, RELAY_SECRET_ENV_NAMES: '{' }, 'claude')).toThrow(); }); + + it('never treats the private signal-read credential as a step grant', () => { + const privateSource = { ...source, RELAY_SECRET_ENV_NAMES: undefined, + RELAYFLOW_V2_SIGNAL_READ_TOKEN: 'private-signal-token' }; + expect(() => validateStepSecrets(['RELAYFLOW_V2_SIGNAL_READ_TOKEN'], privateSource)).toThrow(); + expect(agentStepEnvironment(privateSource, 'claude')).not.toHaveProperty('RELAYFLOW_V2_SIGNAL_READ_TOKEN'); + }); }); diff --git a/packages/sdk/tests/verb-field-lint.test.ts b/packages/sdk/tests/verb-field-lint.test.ts index 38c84f51f..10811c996 100644 --- a/packages/sdk/tests/verb-field-lint.test.ts +++ b/packages/sdk/tests/verb-field-lint.test.ts @@ -189,6 +189,7 @@ describe('closed per-verb step fields', () => { expect(STEP_COMMON_FIELDS).toEqual([ 'id', 'type', + 'secrets', 'dependsOn', 'input', 'verification', diff --git a/packages/sdk/tests/worker-cli-step-secrets.test.ts b/packages/sdk/tests/worker-cli-step-secrets.test.ts index fe1162750..09b87794c 100644 --- a/packages/sdk/tests/worker-cli-step-secrets.test.ts +++ b/packages/sdk/tests/worker-cli-step-secrets.test.ts @@ -19,34 +19,39 @@ fs.writeFileSync(${JSON.stringify(observed)}, JSON.stringify({ other: process.env.OTHER_TOKEN ?? null, login: process.env.CLAUDE_CODE_OAUTH_TOKEN ?? null, grant: process.env.GH_TOKEN ?? null, + signalRead: process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN ?? null, })); -process.stdout.write(JSON.stringify({ type: 'result', result: 'ok', +process.stdout.write(JSON.stringify({ type: 'result', result: 'echoed tok-0123456789abcdef', usage: { input_tokens: 2, output_tokens: 1 } }) + '\\n'); `); chmodSync(cli, 0o755); const source = { PATH: process.env.PATH, HOME: root, CLAUDE_CODE_OAUTH_TOKEN: 'cli-login', NATIVE_API_TOKEN: 'native-secret', OTHER_TOKEN: 'other-secret', GH_TOKEN: 'grant-token', + FAKE_TOKEN: 'tok-0123456789abcdef', + RELAYFLOW_V2_SIGNAL_READ_TOKEN: 'private-signal-token', RELAY_SECRET_ENV_NAMES: JSON.stringify(['NATIVE_API_TOKEN', 'OTHER_TOKEN']), }; const without = await runAgentCli(cli, 'task', undefined, undefined, undefined, undefined, 'agent', undefined, root, 'direct', undefined, source); expect(without.exit_code).toBe(0); + expect(JSON.stringify(without.transcript)).toContain('[redacted:FAKE_TOKEN]'); + expect(JSON.stringify(without.transcript)).not.toContain('tok-0123456789abcdef'); expect(JSON.parse(readFileSync(observed, 'utf8'))).toEqual({ - native: null, other: null, login: 'cli-login', grant: null, + native: null, other: null, login: 'cli-login', grant: null, signalRead: null, }); const withGrant = await runAgentCli(cli, 'task', undefined, undefined, undefined, undefined, 'agent', undefined, root, 'direct', undefined, source, undefined, undefined, undefined, ['NATIVE_API_TOKEN']); expect(withGrant.exit_code).toBe(0); expect(JSON.parse(readFileSync(observed, 'utf8'))).toEqual({ - native: 'native-secret', other: null, login: 'cli-login', grant: null, + native: 'native-secret', other: null, login: 'cli-login', grant: null, signalRead: null, }); const legacy = await runAgentCli(cli, 'task', undefined, undefined, undefined, undefined, 'agent', undefined, root, 'direct', undefined, source, undefined, undefined, undefined, undefined, false); expect(legacy.exit_code).toBe(0); expect(JSON.parse(readFileSync(observed, 'utf8'))).toEqual({ - native: 'native-secret', other: 'other-secret', login: 'cli-login', grant: 'grant-token', + native: 'native-secret', other: 'other-secret', login: 'cli-login', grant: 'grant-token', signalRead: null, }); }); From a597f7d1c90fb4c140c914191e33a21ce994ad15 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 16 Oct 2026 08:40:54 +0200 Subject: [PATCH 05/11] fix: preserve trusted agent login and repository setup --- docs/CLOUD.md | 9 ++- docs/SURFACE.md | 11 ++- kernel/relayflowd/src/exec_det.rs | 38 +++++++-- packages/sdk/src/communication/environment.ts | 15 +++- packages/sdk/src/journal-client.ts | 5 +- packages/sdk/src/step-secrets.ts | 44 +++++++++-- packages/sdk/src/worker-cli.ts | 3 +- ...mmunication-feature-secret-scoping.test.ts | 51 ++++++++++++ .../sdk/tests/communication-review.test.ts | 19 +++++ .../sdk/tests/journal-client-step-env.test.ts | 7 +- packages/sdk/tests/step-secrets.test.ts | 67 +++++++++++++++- .../sdk/tests/worker-cli-step-secrets.test.ts | 10 ++- .../worker-feature-secret-scoping.test.ts | 77 +++++++++++++++++++ 13 files changed, 321 insertions(+), 35 deletions(-) create mode 100644 packages/sdk/tests/communication-feature-secret-scoping.test.ts create mode 100644 packages/sdk/tests/worker-feature-secret-scoping.test.ts diff --git a/docs/CLOUD.md b/docs/CLOUD.md index fc6a35c8e..ad86f9145 100644 --- a/docs/CLOUD.md +++ b/docs/CLOUD.md @@ -55,10 +55,11 @@ await f.agent('review', { task: 'Review the result', secrets: [] }); The same `secrets: ['NATIVE_API_TOKEN']` declaration is accepted on a YAML step. Unknown or unavailable names refuse the run. A direct agent step starts -with a small system and selected CLI-login environment, plus only its declared -run secrets; a Relay-transport agent cannot request run secrets. On a -self-hosted run, set `RELAY_SECRET_ENV_NAMES` to a JSON array of the secret -names before `flows run` so deterministic steps can scope them too. +with a small system environment, Cloud-selected model login (including active +Claude Bedrock login), and trusted repository git/gh setup, plus only its +declared user-delivered secrets; a Relay-transport agent cannot request run +secrets. On a self-hosted run, set `RELAY_SECRET_ENV_NAMES` to a JSON array of +the secret names before `flows run` so deterministic steps can scope them too. They travel only with that run's submission: not in the flow source, its inputs, the receipt, or a schedule (`scheduleInCloud` cannot carry them), and diff --git a/docs/SURFACE.md b/docs/SURFACE.md index 71cf7b662..c653bacae 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -1008,8 +1008,9 @@ step gets only the names in its `secrets` declaration. For example, value, while the next `f.run` without a grant does not. The same `secrets` option is available on `f.agent` and `f.llm` and in declarative steps. Agent CLIs start from an allowlist of system variables and their selected CLI login, -then receive declared secrets. A misspelled or unavailable name refuses the -step. Without a secret-name manifest, self-hosted deterministic steps retain +Cloud-managed repository git/gh setup and model login selected for the CLI stay +available; user-delivered names remain grant-only. A misspelled or unavailable +name refuses the step. Without a secret-name manifest, self-hosted deterministic steps retain the legacy run environment; set the manifest when supplying secrets. The environment is held in daemon memory only. It is never written to the @@ -1021,7 +1022,11 @@ secret grant refuses a daemon without the `step_secret_scoping` capability. Until that daemon upgrade, a plain agent/LLM step keeps its legacy environment so an SDK upgrade alone does not change a hosted run's existing behavior. Upgrading the daemon changes plain agent/LLM steps to the clean environment -even when no step declares a grant; grant any needed delivered secrets by name. +even when no step declares a grant. With a delivered-name manifest, it also +removes ungranted secrets from `f.run` shell children. Grant any needed +delivered secrets by name. A same-user step can still inspect another +process's `/proc//environ` on Linux or read CLI login files under +`HOME`; environment filtering is not process isolation. `kernel/DAEMON-LIFECYCLE.md` §3 has the protocol. diff --git a/kernel/relayflowd/src/exec_det.rs b/kernel/relayflowd/src/exec_det.rs index c12c81392..deaca8d9d 100644 --- a/kernel/relayflowd/src/exec_det.rs +++ b/kernel/relayflowd/src/exec_det.rs @@ -81,11 +81,25 @@ pub(crate) fn execute_placed_with_input( Ok(selected) => Some(selected), Err(error) => return worker_error(&error), }, - None if !step.secrets.is_empty() => return worker_error("step secrets are unavailable without a run environment"), + None if !step.secrets.is_empty() => { + return worker_error("step secrets are unavailable without a run environment"); + } None if std::env::var_os("RELAY_SECRET_ENV_NAMES").is_some() => { - let inherited = crate::step_env::StepEnv::from( - std::env::vars().collect::>(), - ); + let inherited = std::env::vars_os() + .map(|(name, value)| { + let name = name + .into_string() + .map_err(|_| "run environment contains a non-Unicode name".to_owned())?; + let value = value.into_string().map_err(|_| { + format!("run environment variable {name:?} contains a non-Unicode value") + })?; + Ok((name, value)) + }) + .collect::, String>>(); + let inherited = match inherited { + Ok(vars) => crate::step_env::StepEnv::from(vars), + Err(error) => return worker_error(&error), + }; match inherited.for_step(&[]) { Ok(selected) => Some(selected), Err(error) => return worker_error(&error), @@ -245,8 +259,8 @@ fn worker_error(detail: &str) -> AttemptResult { #[cfg(test)] mod tests { - use std::collections::BTreeMap; use serde_json::json; + use std::collections::BTreeMap; use super::*; @@ -256,12 +270,20 @@ mod tests { ("PATH".into(), "/bin:/usr/bin".into()), ("NATIVE_API_TOKEN".into(), "native-secret".into()), ("OTHER_TOKEN".into(), "other-secret".into()), - ("RELAY_SECRET_ENV_NAMES".into(), "[\"NATIVE_API_TOKEN\",\"OTHER_TOKEN\"]".into()), + ( + "RELAY_SECRET_ENV_NAMES".into(), + "[\"NATIVE_API_TOKEN\",\"OTHER_TOKEN\"]".into(), + ), ])); - for (grant, expected) in [(None, "unset:unset"), (Some("NATIVE_API_TOKEN"), "native-secret:unset")] { + for (grant, expected) in [ + (None, "unset:unset"), + (Some("NATIVE_API_TOKEN"), "native-secret:unset"), + ] { let mut value = json!({"id": "shell", "type": "deterministic", "command": "printf '%s:%s' \"${NATIVE_API_TOKEN-unset}\" \"${OTHER_TOKEN-unset}\""}); - if let Some(name) = grant { value["secrets"] = json!([name]); } + if let Some(name) = grant { + value["secrets"] = json!([name]); + } let step: StepSpec = serde_json::from_value(value).unwrap(); let result = execute_placed_with_input(&step, None, None, None, Some(&env), None); assert_eq!(result.output["stdout_tail"], expected); diff --git a/packages/sdk/src/communication/environment.ts b/packages/sdk/src/communication/environment.ts index a2784584e..f2789071f 100644 --- a/packages/sdk/src/communication/environment.ts +++ b/packages/sdk/src/communication/environment.ts @@ -1,15 +1,19 @@ import { basename } from 'node:path'; import { wrapperEnvironment } from '../wrapper-runtime.js'; -import { deliveredNames, validateStepSecrets } from '../step-secrets.js'; +import { deliveredNames, platformAgentNames, repositoryEnvironmentNames, validateStepSecrets } from '../step-secrets.js'; const terminalNames = ['TERM', 'COLORTERM', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME']; +const networkNames = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', + 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy']; const relayNames = ['RELAY_API_KEY', 'RELAY_WORKSPACE_KEY', 'AGENT_RELAY_WORKSPACE_KEY', 'RELAY_BROKER_API_KEY', 'RELAY_AGENT_TOKEN', 'RELAY_NODE_TOKEN', 'RELAY_WORKSPACES_JSON']; // Multi-provider CLIs select their provider in their own configuration. const multiProvider = ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'GEMINI_API_KEY', 'GOOGLE_API_KEY', 'OPENROUTER_API_KEY'] as const; const providerNames: Readonly> = { - claude: ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CONFIG_DIR'], + claude: ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CONFIG_DIR', + 'CLAUDE_CODE_USE_BEDROCK', 'AWS_BEARER_TOKEN_BEDROCK', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN', 'AWS_REGION', 'AWS_DEFAULT_REGION'], codex: ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'CODEX_HOME'], gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION'], // AgentWorkforce/cloud#4026 passes both endpoint names unchanged as aliases @@ -37,18 +41,21 @@ export function agentEnvironment(cli: string, source: NodeJS.ProcessEnv = proces grants?: string[], scopeSecrets = true): Record { // Broker transport credentials must not become CLI credentials. The harness // protocol accepts string values, so clear those inherited names explicitly. - const credentials = selected(source, providerNames[basename(cli).replace(/\.exe$/i, '')] ?? []); + const cliName = basename(cli).replace(/\.exe$/i, ''); + const credentials = { ...selected(source, providerNames[cliName] ?? []), + ...selected(source, [...repositoryEnvironmentNames(source), ...networkNames]) }; if (!scopeSecrets) { if (grants?.length) throw new Error('per-step secret grants require a daemon with step_secret_scoping support'); return { ...Object.fromEntries(relayNames.map(name => [name, ''])), ...credentials } as Record; } const names = deliveredNames(source); + const platform = platformAgentNames(source, cliName); const allowed = validateStepSecrets(grants, source); if (allowed.some(name => relayNames.includes(name))) { throw new Error('Relay broker credentials cannot be granted to a communication agent'); } for (const name of names ?? []) { - if (!allowed.includes(name)) delete credentials[name]; + if (!allowed.includes(name) && !platform.has(name)) delete credentials[name]; } return { ...Object.fromEntries(relayNames.map(name => [name, ''])), ...credentials, ...selected(source, allowed) } as Record; diff --git a/packages/sdk/src/journal-client.ts b/packages/sdk/src/journal-client.ts index 916479480..1eeb7d609 100644 --- a/packages/sdk/src/journal-client.ts +++ b/packages/sdk/src/journal-client.ts @@ -25,7 +25,7 @@ import { type StepUsage, } from './protocol.js'; import type { KernelRunSpec, StepType } from './spec.js'; -import { SIGNAL_READ_TOKEN_ENV, validateStepSecrets } from './step-secrets.js'; +import { PLATFORM_AGENT_ENV_NAMES, SIGNAL_READ_TOKEN_ENV, validateStepSecrets } from './step-secrets.js'; export interface JournalClientOptions { /** Opt-in total budget for read-only requests; interactive clients stay single-shot. */ @@ -173,7 +173,8 @@ export class JournalClient extends JournalConnection { } const env: Record = {}; for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined && key !== SIGNAL_READ_TOKEN_ENV && !this.withheld.has(key)) env[key] = value; + if (value !== undefined && key !== SIGNAL_READ_TOKEN_ENV && key !== PLATFORM_AGENT_ENV_NAMES + && !this.withheld.has(key)) env[key] = value; } return { env }; } diff --git a/packages/sdk/src/step-secrets.ts b/packages/sdk/src/step-secrets.ts index 098a10129..38023f20e 100644 --- a/packages/sdk/src/step-secrets.ts +++ b/packages/sdk/src/step-secrets.ts @@ -2,9 +2,25 @@ import type { CliAdapterKind } from './cli-adapter.js'; /** Cloud supplies this name-only manifest alongside delivered envSecrets. */ export const SECRET_ENV_NAMES = 'RELAY_SECRET_ENV_NAMES'; +/** Cloud's name-only exception for model login it selected from delivered values. */ +export const PLATFORM_AGENT_ENV_NAMES = 'RELAY_PLATFORM_AGENT_ENV_NAMES'; /** CLI-only hosted signal read capability. Never a run or step secret. */ export const SIGNAL_READ_TOKEN_ENV = 'RELAYFLOW_V2_SIGNAL_READ_TOKEN'; const VALID_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; +const BEDROCK_LOGIN = new Set([ + 'CLAUDE_CODE_USE_BEDROCK', 'AWS_BEARER_TOKEN_BEDROCK', + 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', + 'AWS_REGION', 'AWS_DEFAULT_REGION', +]); + +const REPOSITORY_ENV = new Set([ + 'GH_TOKEN', 'GH_CONFIG_DIR', 'GH_PROMPT_DISABLED', 'GITLAB_TOKEN', + 'RELAYFLOW_V2_GITLAB_API_URL', +]); +export function repositoryEnvironmentNames(source: NodeJS.ProcessEnv): string[] { + return Object.keys(source).filter(name => REPOSITORY_ENV.has(name) || name.startsWith('GIT_') + || name.startsWith('RELAYFLOW_V2_REPOSITORY_')); +} const BASE_ENV = [ 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TERM', 'LANG', 'LANGUAGE', @@ -12,9 +28,12 @@ const BASE_ENV = [ 'XDG_CACHE_HOME', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'HOSTNAME', 'NODE_PATH', 'NODE_ENV', 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NVM_DIR', 'NVM_BIN', 'NVM_INC', + 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', + 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy', 'NVM_SYMLINK_CURRENT', 'PIPX_HOME', 'PIPX_BIN_DIR', 'PYTHON_PATH', 'PYTHON_VERSION', 'PYTHON_SHA256', 'RELAYFILE_MOUNT_PATH', 'RELAYFILE_MOUNT_PATHS', 'RELAYFILE_MOUNT_ROOT', 'WORKSPACE_ROOT', + 'RELAY_BASE_URL', 'RELAY_AGENT_TOKEN', 'SystemRoot', 'ComSpec', 'PATHEXT', 'WINDIR', ] as const; @@ -22,12 +41,12 @@ const BASE_ENV = [ // run's delivered envSecret; only that CLI gets its own login variables. const CLI_LOGIN: Record = { claude: [ - 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CONFIG_DIR', 'ANTHROPIC_API_KEY', + 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CONFIG_DIR', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'CLAUDE_CODE_USE_BEDROCK', 'AWS_BEARER_TOKEN_BEDROCK', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'AWS_REGION', 'AWS_DEFAULT_REGION', ], - codex: ['CODEX_HOME', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_API_BASE'], + codex: ['CODEX_HOME', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENROUTER_API_KEY'], 'relayflows-wrapper-v1': [], }; @@ -40,12 +59,26 @@ export function deliveredNames(source: NodeJS.ProcessEnv): ReadonlySet | throw new Error(`${SECRET_ENV_NAMES} must be a JSON array of valid names`); } if (new Set(parsed).size !== parsed.length || parsed.includes(SECRET_ENV_NAMES) + || parsed.includes(PLATFORM_AGENT_ENV_NAMES) || parsed.includes(SIGNAL_READ_TOKEN_ENV)) { throw new Error(`${SECRET_ENV_NAMES} contains a duplicate or reserved name`); } return new Set(parsed); } +/** Only Cloud-selected Claude Bedrock login may override the delivered filter. */ +export function platformAgentNames(source: NodeJS.ProcessEnv, kind: string): ReadonlySet { + const raw = source[PLATFORM_AGENT_ENV_NAMES]; + if (raw === undefined) return new Set(); + let parsed: unknown; + try { parsed = JSON.parse(raw); } catch { throw new Error(`${PLATFORM_AGENT_ENV_NAMES} must be a JSON array of names`); } + if (!Array.isArray(parsed) || !parsed.every(name => typeof name === 'string' && BEDROCK_LOGIN.has(name) + && typeof source[name] === 'string') || new Set(parsed).size !== parsed.length) { + throw new Error(`${PLATFORM_AGENT_ENV_NAMES} contains an invalid or unavailable model login name`); + } + return kind === 'claude' ? new Set(parsed) : new Set(); +} + /** Refuse a misspelled, unavailable or platform-owned name before a child runs. */ export function validateStepSecrets(declaration: unknown, source: NodeJS.ProcessEnv): string[] { const known = deliveredNames(source); @@ -56,7 +89,7 @@ export function validateStepSecrets(declaration: unknown, source: NodeJS.Process const names = declaration as string[]; if (new Set(names).size !== names.length) throw new Error('step secrets contain duplicate names'); for (const name of names) { - if (name === SECRET_ENV_NAMES || name === SIGNAL_READ_TOKEN_ENV + if (name === SECRET_ENV_NAMES || name === PLATFORM_AGENT_ENV_NAMES || name === SIGNAL_READ_TOKEN_ENV || !Object.hasOwn(source, name) || typeof source[name] !== 'string' || (known !== undefined && !known.has(name))) { throw new Error(`step secret ${JSON.stringify(name)} is not available to this run`); @@ -72,10 +105,11 @@ export function agentStepEnvironment( declaration?: unknown, ): NodeJS.ProcessEnv { const known = deliveredNames(source); + const platform = platformAgentNames(source, kind); const grants = validateStepSecrets(declaration, source); const env: NodeJS.ProcessEnv = {}; - for (const name of [...BASE_ENV, ...CLI_LOGIN[kind]]) { - if (known?.has(name) && !grants.includes(name)) continue; + for (const name of [...BASE_ENV, ...CLI_LOGIN[kind], ...repositoryEnvironmentNames(source)]) { + if (known?.has(name) && !grants.includes(name) && !platform.has(name)) continue; if (source[name] !== undefined) env[name] = source[name]; } for (const name of grants) env[name] = source[name]; diff --git a/packages/sdk/src/worker-cli.ts b/packages/sdk/src/worker-cli.ts index 7b2ead1eb..f2b74a030 100644 --- a/packages/sdk/src/worker-cli.ts +++ b/packages/sdk/src/worker-cli.ts @@ -31,7 +31,7 @@ import { } from './wrapper-session.js'; import { wrapperEnvironment } from './wrapper-runtime.js'; import { applyStepEnvironment } from './step-env.js'; -import { SIGNAL_READ_TOKEN_ENV, agentStepEnvironment } from './step-secrets.js'; +import { PLATFORM_AGENT_ENV_NAMES, SIGNAL_READ_TOKEN_ENV, agentStepEnvironment } from './step-secrets.js'; import { TAIL_CLOSE_TIMEOUT_MS, openTranscriptTail, type TranscriptTailWriter } from './transcript-tail.js'; import { isRetryableSpawnError, @@ -162,6 +162,7 @@ export async function runAgentCli( // The old-daemon compatibility path retains other legacy variables, but // this CLI-only signal capability must never enter an agent subprocess. delete childEnvironment[SIGNAL_READ_TOKEN_ENV]; + delete childEnvironment[PLATFORM_AGENT_ENV_NAMES]; } catch (error) { return { exit_code: null, stdout_tail: '', stderr_tail: error instanceof Error ? error.message : 'step secret grant is invalid' }; diff --git a/packages/sdk/tests/communication-feature-secret-scoping.test.ts b/packages/sdk/tests/communication-feature-secret-scoping.test.ts new file mode 100644 index 000000000..1fb557ed8 --- /dev/null +++ b/packages/sdk/tests/communication-feature-secret-scoping.test.ts @@ -0,0 +1,51 @@ +import { EventEmitter } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it, vi } from 'vitest'; +import { completeCommunicationDispatch } from '../src/communication/worker.js'; +import type { JournalClient } from '../src/journal-client.js'; +import type { StepDispatchEvent } from '../src/protocol.js'; + +const state = vi.hoisted(() => ({ environments: [] as Array> })); +vi.mock('../src/communication/relay.js', () => ({ acquireRelayRuntime: async () => ({ + prefix: 'flow-test', + broker: { + onEvent: () => () => {}, + spawnPty: async (input: { harnessConfig: { env: Record } }) => { + state.environments.push(input.harnessConfig.env); + return { waitForReady: async () => ({ reason: 'failed' }), release: async () => {} }; + }, + }, + close: async () => {}, +}) })); + +it('communication worker uses the negotiated capability to scope its broker child', async () => { + const root = mkdtempSync(join(tmpdir(), 'flows-communication-capability-')); + state.environments.length = 0; + try { + for (const scoped of [true, false]) { + const client = new EventEmitter() as EventEmitter & Record; + client.supportsFeature = (name: string) => name === 'step_secret_scoping' && scoped; + client.stepHeartbeat = async () => ({ lease_deadline_ms: Date.now() + 30_000 }); + client.stepComplete = async () => ({}); + const dispatch = { + run_id: 'run', step_id: 'agent', step_type: 'agent', attempt: 1, + idempotency_key: `key-${scoped}`, lease_id: 'lease', lease_deadline_ms: Date.now() + 30_000, + pins: { workspace: [], streams: [] }, + spec: { cli: 'claude', instruction: 'task' }, + } satisfies StepDispatchEvent; + await completeCommunicationDispatch(client as unknown as JournalClient, dispatch, + { type: 'relayflows.communication.v1', instruction: 'task', incoming: [], outgoing: [], timeoutMs: 1000 }, + root, root, { + ANTHROPIC_API_KEY: 'delivered-provider-key', RELAY_SECRET_ENV_NAMES: '["ANTHROPIC_API_KEY"]', + RELAY_API_KEY: 'broker-key', RELAYFLOW_V2_SIGNAL_READ_TOKEN: 'private-signal-token', + }); + const env = state.environments.at(-1)!; + expect(env.ANTHROPIC_API_KEY).toBe(scoped ? undefined : 'delivered-provider-key'); + expect(env.RELAY_API_KEY).toBe(''); + expect(env).not.toHaveProperty('RELAYFLOW_V2_SIGNAL_READ_TOKEN'); + } + expect(state.environments).toHaveLength(2); + } finally { rmSync(root, { recursive: true, force: true }); } +}, 10_000); diff --git a/packages/sdk/tests/communication-review.test.ts b/packages/sdk/tests/communication-review.test.ts index 6ac5e7eb9..dfd59c306 100644 --- a/packages/sdk/tests/communication-review.test.ts +++ b/packages/sdk/tests/communication-review.test.ts @@ -73,6 +73,25 @@ it('communication agents drop delivered secrets by default on a scoped runtime', }, ['RELAY_API_KEY'])).toThrow('Relay broker credentials'); }); +it('communication Claude keeps trusted Bedrock and repository setup without user secrets', () => { + const source = { + CLAUDE_CODE_USE_BEDROCK: '1', AWS_BEARER_TOKEN_BEDROCK: 'bedrock-login', + AWS_REGION: 'eu-west-1', GH_CONFIG_DIR: '/tmp/gh', GIT_ASKPASS: '/tmp/askpass', + GIT_CONFIG_KEY_1: 'core.hooksPath', GIT_CONFIG_VALUE_1: '/dev/null', + HTTPS_PROXY: 'https://proxy.example.test', + USER_SECRET: 'user-secret', + RELAY_SECRET_ENV_NAMES: '["CLAUDE_CODE_USE_BEDROCK","AWS_BEARER_TOKEN_BEDROCK","USER_SECRET"]', + RELAY_PLATFORM_AGENT_ENV_NAMES: '["CLAUDE_CODE_USE_BEDROCK","AWS_BEARER_TOKEN_BEDROCK","AWS_REGION"]', + }; + const env = agentEnvironment('claude', source); + expect(env).toMatchObject({ CLAUDE_CODE_USE_BEDROCK: '1', AWS_BEARER_TOKEN_BEDROCK: 'bedrock-login', + AWS_REGION: 'eu-west-1', GH_CONFIG_DIR: '/tmp/gh', GIT_ASKPASS: '/tmp/askpass', + GIT_CONFIG_KEY_1: 'core.hooksPath', GIT_CONFIG_VALUE_1: '/dev/null', + HTTPS_PROXY: 'https://proxy.example.test' }); + expect(env).not.toHaveProperty('USER_SECRET'); + expect(agentEnvironment('codex', source)).not.toHaveProperty('AWS_BEARER_TOKEN_BEDROCK'); +}); + it('passes provider-specific proxy routing only to Cursor and Grok children', () => { const cursorProxy = 'https://proxy.test/v1/house/cursor/runs/run-1'; const source = { diff --git a/packages/sdk/tests/journal-client-step-env.test.ts b/packages/sdk/tests/journal-client-step-env.test.ts index 8b067bf44..11c0c7dd5 100644 --- a/packages/sdk/tests/journal-client-step-env.test.ts +++ b/packages/sdk/tests/journal-client-step-env.test.ts @@ -72,7 +72,7 @@ it('keeps plain runs with delivered secrets working on an older daemon', async ( } }); -it('never forwards the private signal-read token on run.start or run.resume', async () => { +it('never forwards CLI-only signal or model-login metadata on run.start or run.resume', async () => { const path = sockPath(); const received: Record[] = []; const server = startLoopback(path, { @@ -82,7 +82,9 @@ it('never forwards the private signal-read token on run.start or run.resume', as }); const client = new JournalClient(path); const prior = process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN; + const priorPlatform = process.env.RELAY_PLATFORM_AGENT_ENV_NAMES; process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN = 'private-signal-token'; + process.env.RELAY_PLATFORM_AGENT_ENV_NAMES = '[]'; try { await client.connect(); await client.hello('signal-token-gate'); @@ -92,10 +94,13 @@ it('never forwards the private signal-read token on run.start or run.resume', as for (const params of received) { expect(params).toHaveProperty('env'); expect(params['env']).not.toHaveProperty('RELAYFLOW_V2_SIGNAL_READ_TOKEN'); + expect(params['env']).not.toHaveProperty('RELAY_PLATFORM_AGENT_ENV_NAMES'); } } finally { if (prior === undefined) delete process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN; else process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN = prior; + if (priorPlatform === undefined) delete process.env.RELAY_PLATFORM_AGENT_ENV_NAMES; + else process.env.RELAY_PLATFORM_AGENT_ENV_NAMES = priorPlatform; client.close(); await new Promise(resolve => server.close(() => resolve())); rmSync(path, { force: true }); diff --git a/packages/sdk/tests/step-secrets.test.ts b/packages/sdk/tests/step-secrets.test.ts index ad9d69e1d..5fb811847 100644 --- a/packages/sdk/tests/step-secrets.test.ts +++ b/packages/sdk/tests/step-secrets.test.ts @@ -19,12 +19,12 @@ describe('per-step secret grants', () => { expect(compiled.steps.map(step => step.secrets)).toEqual([['NATIVE_API_TOKEN'], ['OTHER_TOKEN']]); expect(JSON.stringify(compiled)).not.toContain('native-secret'); }); - it('gives an agent only the secret it declared, plus its own CLI login', () => { + it('gives an agent only its declared delivered secret, CLI login and repository grant', () => { const env = agentStepEnvironment(source, 'claude', ['NATIVE_API_TOKEN']); expect(env).toMatchObject({ PATH: '/usr/bin', HOME: '/tmp/home', CLAUDE_CODE_OAUTH_TOKEN: 'runtime-login', NATIVE_API_TOKEN: 'native-secret' }); expect(env).not.toHaveProperty('OTHER_TOKEN'); - expect(env).not.toHaveProperty('GH_TOKEN'); + expect(env.GH_TOKEN).toBe('repository-grant'); expect(env).not.toHaveProperty('RELAY_SECRET_ENV_NAMES'); }); @@ -32,7 +32,7 @@ describe('per-step secret grants', () => { const env = agentStepEnvironment(source, 'claude'); expect(env).not.toHaveProperty('NATIVE_API_TOKEN'); expect(env).not.toHaveProperty('OTHER_TOKEN'); - expect(env).not.toHaveProperty('GH_TOKEN'); + expect(env.GH_TOKEN).toBe('repository-grant'); }); it('refuses unknown, duplicate, malformed and unavailable grants without values', () => { @@ -53,4 +53,65 @@ describe('per-step secret grants', () => { expect(() => validateStepSecrets(['RELAYFLOW_V2_SIGNAL_READ_TOKEN'], privateSource)).toThrow(); expect(agentStepEnvironment(privateSource, 'claude')).not.toHaveProperty('RELAYFLOW_V2_SIGNAL_READ_TOKEN'); }); + + it('keeps Cloud-selected Bedrock login while stripping another delivered secret', () => { + const bedrock = { + PATH: '/usr/bin', HOME: '/tmp/home', + CLAUDE_CODE_USE_BEDROCK: '1', AWS_ACCESS_KEY_ID: 'bedrock-key', + AWS_SECRET_ACCESS_KEY: 'bedrock-secret', AWS_SESSION_TOKEN: 'bedrock-session', + AWS_REGION: 'eu-west-1', AWS_DEFAULT_REGION: 'eu-west-1', + ANTHROPIC_API_KEY: 'user-provider-key', OTHER_TOKEN: 'user-secret', + RELAY_SECRET_ENV_NAMES: JSON.stringify([ + 'CLAUDE_CODE_USE_BEDROCK', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN', 'ANTHROPIC_API_KEY', 'OTHER_TOKEN', + ]), + RELAY_PLATFORM_AGENT_ENV_NAMES: JSON.stringify([ + 'CLAUDE_CODE_USE_BEDROCK', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN', 'AWS_REGION', 'AWS_DEFAULT_REGION', + ]), + }; + const env = agentStepEnvironment(bedrock, 'claude'); + expect(env).toMatchObject({ CLAUDE_CODE_USE_BEDROCK: '1', AWS_ACCESS_KEY_ID: 'bedrock-key', + AWS_SECRET_ACCESS_KEY: 'bedrock-secret', AWS_SESSION_TOKEN: 'bedrock-session', + AWS_REGION: 'eu-west-1', AWS_DEFAULT_REGION: 'eu-west-1' }); + expect(env).not.toHaveProperty('OTHER_TOKEN'); + expect(env).not.toHaveProperty('ANTHROPIC_API_KEY'); + expect(env).not.toHaveProperty('RELAY_PLATFORM_AGENT_ENV_NAMES'); + expect(agentStepEnvironment(bedrock, 'codex')).not.toHaveProperty('AWS_ACCESS_KEY_ID'); + }); + + it('keeps repository git/gh setup and hardening but strips a user secret', () => { + const repository = { + PATH: '/repo/bin:/usr/bin', HOME: '/tmp/home', + GH_TOKEN: 'repo-grant', GH_CONFIG_DIR: '/tmp/gh', GH_PROMPT_DISABLED: '1', + GITLAB_TOKEN: 'gitlab-proxy', GIT_ASKPASS: '/tmp/askpass', + GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_COUNT: '4', GIT_CONFIG_KEY_1: 'core.hooksPath', GIT_CONFIG_VALUE_1: '/dev/null', + GIT_CONFIG_KEY_2: 'protocol.allow', GIT_CONFIG_VALUE_2: 'never', + GIT_AUTHOR_NAME: 'Relayflow', GIT_AUTHOR_EMAIL: 'noreply@example.test', + RELAYFLOW_V2_REPOSITORY_DIR: '/repo', RELAYFLOW_V2_REPOSITORY_JSON: '{}', + RELAYFLOW_V2_GITLAB_API_URL: 'https://proxy.example.test', + HTTPS_PROXY: 'https://proxy.example.test', NO_PROXY: 'localhost', + USER_SECRET: 'user-secret', RELAY_SECRET_ENV_NAMES: '["USER_SECRET"]', + }; + const env = agentStepEnvironment(repository, 'claude'); + for (const name of ['GH_TOKEN', 'GH_CONFIG_DIR', 'GH_PROMPT_DISABLED', 'GITLAB_TOKEN', + 'GIT_ASKPASS', 'GIT_CONFIG_NOSYSTEM', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_COUNT', + 'GIT_CONFIG_KEY_1', 'GIT_CONFIG_VALUE_1', 'GIT_CONFIG_KEY_2', 'GIT_CONFIG_VALUE_2', + 'GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL', 'RELAYFLOW_V2_REPOSITORY_DIR', + 'RELAYFLOW_V2_REPOSITORY_JSON', 'RELAYFLOW_V2_GITLAB_API_URL', 'HTTPS_PROXY', 'NO_PROXY']) { + expect(env[name], name).toBe(repository[name as keyof typeof repository]); + } + expect(env).not.toHaveProperty('USER_SECRET'); + const userToken = { ...repository, GH_TOKEN: 'user-gh-token', + RELAY_SECRET_ENV_NAMES: '["USER_SECRET","GH_TOKEN"]' }; + expect(agentStepEnvironment(userToken, 'claude')).not.toHaveProperty('GH_TOKEN'); + }); + + it('refuses a spoofed platform agent marker', () => { + expect(() => agentStepEnvironment({ ...source, + RELAY_PLATFORM_AGENT_ENV_NAMES: '["GH_TOKEN"]' }, 'claude')).toThrow(); + expect(() => agentStepEnvironment({ ...source, + RELAY_PLATFORM_AGENT_ENV_NAMES: '{' }, 'claude')).toThrow(); + }); }); diff --git a/packages/sdk/tests/worker-cli-step-secrets.test.ts b/packages/sdk/tests/worker-cli-step-secrets.test.ts index 09b87794c..0f965a423 100644 --- a/packages/sdk/tests/worker-cli-step-secrets.test.ts +++ b/packages/sdk/tests/worker-cli-step-secrets.test.ts @@ -20,6 +20,7 @@ fs.writeFileSync(${JSON.stringify(observed)}, JSON.stringify({ login: process.env.CLAUDE_CODE_OAUTH_TOKEN ?? null, grant: process.env.GH_TOKEN ?? null, signalRead: process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN ?? null, + platformMarker: process.env.RELAY_PLATFORM_AGENT_ENV_NAMES ?? null, })); process.stdout.write(JSON.stringify({ type: 'result', result: 'echoed tok-0123456789abcdef', usage: { input_tokens: 2, output_tokens: 1 } }) + '\\n'); @@ -30,7 +31,8 @@ process.stdout.write(JSON.stringify({ type: 'result', result: 'echoed tok-012345 NATIVE_API_TOKEN: 'native-secret', OTHER_TOKEN: 'other-secret', GH_TOKEN: 'grant-token', FAKE_TOKEN: 'tok-0123456789abcdef', RELAYFLOW_V2_SIGNAL_READ_TOKEN: 'private-signal-token', - RELAY_SECRET_ENV_NAMES: JSON.stringify(['NATIVE_API_TOKEN', 'OTHER_TOKEN']), + RELAY_PLATFORM_AGENT_ENV_NAMES: '[]', + RELAY_SECRET_ENV_NAMES: JSON.stringify(['NATIVE_API_TOKEN', 'OTHER_TOKEN', 'GH_TOKEN']), }; const without = await runAgentCli(cli, 'task', undefined, undefined, undefined, undefined, 'agent', undefined, root, 'direct', undefined, source); @@ -38,20 +40,20 @@ process.stdout.write(JSON.stringify({ type: 'result', result: 'echoed tok-012345 expect(JSON.stringify(without.transcript)).toContain('[redacted:FAKE_TOKEN]'); expect(JSON.stringify(without.transcript)).not.toContain('tok-0123456789abcdef'); expect(JSON.parse(readFileSync(observed, 'utf8'))).toEqual({ - native: null, other: null, login: 'cli-login', grant: null, signalRead: null, + native: null, other: null, login: 'cli-login', grant: null, signalRead: null, platformMarker: null, }); const withGrant = await runAgentCli(cli, 'task', undefined, undefined, undefined, undefined, 'agent', undefined, root, 'direct', undefined, source, undefined, undefined, undefined, ['NATIVE_API_TOKEN']); expect(withGrant.exit_code).toBe(0); expect(JSON.parse(readFileSync(observed, 'utf8'))).toEqual({ - native: 'native-secret', other: null, login: 'cli-login', grant: null, signalRead: null, + native: 'native-secret', other: null, login: 'cli-login', grant: null, signalRead: null, platformMarker: null, }); const legacy = await runAgentCli(cli, 'task', undefined, undefined, undefined, undefined, 'agent', undefined, root, 'direct', undefined, source, undefined, undefined, undefined, undefined, false); expect(legacy.exit_code).toBe(0); expect(JSON.parse(readFileSync(observed, 'utf8'))).toEqual({ - native: 'native-secret', other: 'other-secret', login: 'cli-login', grant: 'grant-token', signalRead: null, + native: 'native-secret', other: 'other-secret', login: 'cli-login', grant: 'grant-token', signalRead: null, platformMarker: null, }); }); diff --git a/packages/sdk/tests/worker-feature-secret-scoping.test.ts b/packages/sdk/tests/worker-feature-secret-scoping.test.ts new file mode 100644 index 000000000..c95eee9eb --- /dev/null +++ b/packages/sdk/tests/worker-feature-secret-scoping.test.ts @@ -0,0 +1,77 @@ +import { EventEmitter } from 'node:events'; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, it } from 'vitest'; +import type { JournalClient } from '../src/journal-client.js'; +import { AgentWorker } from '../src/worker.js'; +import { LlmWorker } from '../src/llm-worker.js'; +import type { StepDispatchEvent } from '../src/protocol.js'; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); + +function clientWithFeature(scoped: boolean): JournalClient { + const client = new EventEmitter() as EventEmitter & Record; + client.supportsFeature = (name: string) => name === 'step_secret_scoping' && scoped; + client.workerAttach = async () => ({}); + client.stepHeartbeat = async () => ({ lease_deadline_ms: Date.now() + 30_000 }); + client.stepComplete = async () => ({}); + return client as unknown as JournalClient; +} + +it.each(['agent', 'llm'] as const)('%s worker uses the negotiated daemon capability for its real child', async kind => { + const root = mkdtempSync(join(tmpdir(), `flows-${kind}-capability-`)); + roots.push(root); + const observed = join(root, 'observed.json'); + const claude = join(root, 'claude'); + writeFileSync(claude, `#!/usr/bin/env node +require('node:fs').writeFileSync(${JSON.stringify(observed)}, JSON.stringify({ + native: process.env.NATIVE_API_TOKEN ?? null, + signalRead: process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN ?? null, + ghConfig: process.env.GH_CONFIG_DIR ?? null, + askpass: process.env.GIT_ASKPASS ?? null, + hardeningKey: process.env.GIT_CONFIG_KEY_1 ?? null, + hardeningValue: process.env.GIT_CONFIG_VALUE_1 ?? null, + useBedrock: process.env.CLAUDE_CODE_USE_BEDROCK ?? null, + awsKey: process.env.AWS_ACCESS_KEY_ID ?? null, + awsSecret: process.env.AWS_SECRET_ACCESS_KEY ?? null, + awsRegion: process.env.AWS_REGION ?? null, +})); +process.stdout.write(JSON.stringify({ type: 'result', result: 'ok', + usage: { input_tokens: 2, output_tokens: 1 } }) + '\\n'); +`); + chmodSync(claude, 0o755); + const source = { PATH: process.env.PATH, HOME: root, NATIVE_API_TOKEN: 'native-secret', + RELAY_SECRET_ENV_NAMES: '["NATIVE_API_TOKEN","CLAUDE_CODE_USE_BEDROCK","AWS_ACCESS_KEY_ID","AWS_SECRET_ACCESS_KEY"]', + RELAY_PLATFORM_AGENT_ENV_NAMES: '["CLAUDE_CODE_USE_BEDROCK","AWS_ACCESS_KEY_ID","AWS_SECRET_ACCESS_KEY","AWS_REGION"]', + RELAYFLOW_V2_SIGNAL_READ_TOKEN: 'private-signal-token', + CLAUDE_CODE_USE_BEDROCK: '1', AWS_ACCESS_KEY_ID: 'bedrock-key', + AWS_SECRET_ACCESS_KEY: 'bedrock-secret', AWS_REGION: 'eu-west-1', + GH_CONFIG_DIR: join(root, 'gh'), GIT_ASKPASS: join(root, 'askpass'), + GIT_CONFIG_KEY_1: 'core.hooksPath', GIT_CONFIG_VALUE_1: '/dev/null' }; + for (const scoped of [true, false]) { + const client = clientWithFeature(scoped); + const worker = kind === 'agent' + ? new AgentWorker(client, { workerId: 'capability-agent', pins: { workspace: [], streams: [] }, + environment: source, runRoot: root }) + : new LlmWorker(client, 'capability-llm', 1, source); + const errors: unknown[] = []; + worker.on('error', error => errors.push(error)); + await worker.attach(); + (client as unknown as EventEmitter).emit('step.dispatch', { + run_id: 'run', step_id: kind, step_type: kind, attempt: 1, + idempotency_key: `key-${scoped}`, lease_id: 'lease', lease_deadline_ms: Date.now() + 30_000, + pins: { workspace: [], streams: [] }, + spec: { cli: claude, instruction: 'task', prompt: 'task', model: 'unpriced-test-model' }, + } satisfies StepDispatchEvent); + await worker.close(); + expect(errors).toEqual([]); + expect(JSON.parse(readFileSync(observed, 'utf8'))).toEqual({ + native: scoped ? null : 'native-secret', signalRead: null, + ghConfig: join(root, 'gh'), askpass: join(root, 'askpass'), + hardeningKey: 'core.hooksPath', hardeningValue: '/dev/null', + useBedrock: '1', awsKey: 'bedrock-key', awsSecret: 'bedrock-secret', awsRegion: 'eu-west-1', + }); + } +}, 30_000); From f22f146ed30809d31505535d4992db4831f8f7fd Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 16 Oct 2026 08:48:42 +0200 Subject: [PATCH 06/11] fix: keep relay transport token out of scoped agent children --- packages/sdk/src/step-secrets.ts | 2 +- packages/sdk/tests/worker-cli-step-secrets.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/step-secrets.ts b/packages/sdk/src/step-secrets.ts index 38023f20e..41c53fd69 100644 --- a/packages/sdk/src/step-secrets.ts +++ b/packages/sdk/src/step-secrets.ts @@ -33,7 +33,7 @@ const BASE_ENV = [ 'NVM_SYMLINK_CURRENT', 'PIPX_HOME', 'PIPX_BIN_DIR', 'PYTHON_PATH', 'PYTHON_VERSION', 'PYTHON_SHA256', 'RELAYFILE_MOUNT_PATH', 'RELAYFILE_MOUNT_PATHS', 'RELAYFILE_MOUNT_ROOT', 'WORKSPACE_ROOT', - 'RELAY_BASE_URL', 'RELAY_AGENT_TOKEN', + 'RELAY_BASE_URL', 'SystemRoot', 'ComSpec', 'PATHEXT', 'WINDIR', ] as const; diff --git a/packages/sdk/tests/worker-cli-step-secrets.test.ts b/packages/sdk/tests/worker-cli-step-secrets.test.ts index 0f965a423..42d5fe403 100644 --- a/packages/sdk/tests/worker-cli-step-secrets.test.ts +++ b/packages/sdk/tests/worker-cli-step-secrets.test.ts @@ -21,6 +21,7 @@ fs.writeFileSync(${JSON.stringify(observed)}, JSON.stringify({ grant: process.env.GH_TOKEN ?? null, signalRead: process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN ?? null, platformMarker: process.env.RELAY_PLATFORM_AGENT_ENV_NAMES ?? null, + relayAgentToken: process.env.RELAY_AGENT_TOKEN ?? null, })); process.stdout.write(JSON.stringify({ type: 'result', result: 'echoed tok-0123456789abcdef', usage: { input_tokens: 2, output_tokens: 1 } }) + '\\n'); @@ -31,6 +32,7 @@ process.stdout.write(JSON.stringify({ type: 'result', result: 'echoed tok-012345 NATIVE_API_TOKEN: 'native-secret', OTHER_TOKEN: 'other-secret', GH_TOKEN: 'grant-token', FAKE_TOKEN: 'tok-0123456789abcdef', RELAYFLOW_V2_SIGNAL_READ_TOKEN: 'private-signal-token', + RELAY_AGENT_TOKEN: 'relay-agent-token', RELAY_PLATFORM_AGENT_ENV_NAMES: '[]', RELAY_SECRET_ENV_NAMES: JSON.stringify(['NATIVE_API_TOKEN', 'OTHER_TOKEN', 'GH_TOKEN']), }; @@ -41,6 +43,7 @@ process.stdout.write(JSON.stringify({ type: 'result', result: 'echoed tok-012345 expect(JSON.stringify(without.transcript)).not.toContain('tok-0123456789abcdef'); expect(JSON.parse(readFileSync(observed, 'utf8'))).toEqual({ native: null, other: null, login: 'cli-login', grant: null, signalRead: null, platformMarker: null, + relayAgentToken: null, }); const withGrant = await runAgentCli(cli, 'task', undefined, undefined, undefined, undefined, 'agent', undefined, root, 'direct', undefined, source, @@ -48,6 +51,7 @@ process.stdout.write(JSON.stringify({ type: 'result', result: 'echoed tok-012345 expect(withGrant.exit_code).toBe(0); expect(JSON.parse(readFileSync(observed, 'utf8'))).toEqual({ native: 'native-secret', other: null, login: 'cli-login', grant: null, signalRead: null, platformMarker: null, + relayAgentToken: null, }); const legacy = await runAgentCli(cli, 'task', undefined, undefined, undefined, undefined, 'agent', undefined, root, 'direct', undefined, source, @@ -55,5 +59,6 @@ process.stdout.write(JSON.stringify({ type: 'result', result: 'echoed tok-012345 expect(legacy.exit_code).toBe(0); expect(JSON.parse(readFileSync(observed, 'utf8'))).toEqual({ native: 'native-secret', other: 'other-secret', login: 'cli-login', grant: 'grant-token', signalRead: null, platformMarker: null, + relayAgentToken: 'relay-agent-token', }); }); From dcf807d0f830bda6befa7d51e4015ce9cdfb395d Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 16 Oct 2026 10:46:08 +0200 Subject: [PATCH 07/11] fix: scope preflight and preserve secret grants through admission --- kernel/relayflowd/src/exec_det.rs | 19 +++++++ packages/sdk/src/authored-preflight.ts | 8 +-- packages/sdk/src/cli/check.ts | 6 +- packages/sdk/src/cli/cli-probe.ts | 15 +++-- packages/sdk/src/communication/environment.ts | 7 +-- packages/sdk/src/compile.ts | 5 +- packages/sdk/src/journal-client.ts | 20 ++++++- packages/sdk/src/preflight.ts | 16 +++--- packages/sdk/src/step-secrets.ts | 19 +++++++ packages/sdk/tests/bundle-preflight.test.ts | 9 +++ packages/sdk/tests/cli-probe.test.ts | 56 ++++++++++++++++++- .../sdk/tests/communication-review.test.ts | 24 +++++++- .../sdk/tests/journal-client-step-env.test.ts | 39 +++++++++++++ 13 files changed, 212 insertions(+), 31 deletions(-) diff --git a/kernel/relayflowd/src/exec_det.rs b/kernel/relayflowd/src/exec_det.rs index deaca8d9d..d1890c766 100644 --- a/kernel/relayflowd/src/exec_det.rs +++ b/kernel/relayflowd/src/exec_det.rs @@ -290,6 +290,25 @@ mod tests { } } + #[test] + fn a_shell_step_accepts_a_manifest_after_withheld_names_are_removed() { + let env = crate::step_env::StepEnv::from(BTreeMap::from([ + ("PATH".into(), "/bin:/usr/bin".into()), + ("VISIBLE_SECRET".into(), "visible-value".into()), + ( + "RELAY_SECRET_ENV_NAMES".into(), + "[\"VISIBLE_SECRET\"]".into(), + ), + ])); + let step: StepSpec = serde_json::from_value(json!({ + "id": "plain", "type": "deterministic", "command": "true" + })) + .unwrap(); + let result = execute_placed_with_input(&step, None, None, None, Some(&env), None); + assert_eq!(result.output["exit_code"], 0); + assert_eq!(result.failure_reason, None); + } + #[test] fn captures_deterministic_output() { let step: StepSpec = serde_json::from_value(json!({ diff --git a/packages/sdk/src/authored-preflight.ts b/packages/sdk/src/authored-preflight.ts index 27298045b..e9da61dbe 100644 --- a/packages/sdk/src/authored-preflight.ts +++ b/packages/sdk/src/authored-preflight.ts @@ -13,16 +13,16 @@ export function authoredPreflight(path: string, environment?: NodeJS.ProcessEnv) return async (flow: FlowSpec) => { let config: ProjectConfig; try { config = readProjectConfig(directory); } - catch { return checkAuthoredFlow(flow, path); } + catch { return checkAuthoredFlow(flow, path, undefined, { environment }); } const resolved = resolvePreflight(flow, { projectCli: config.cli, projectConfigPath: config.path, projectSearchStart: directory, models: config.models, modelRegistryPath: config.modelRegistryPath, }); - if (!resolved.ok) return checkAuthoredFlow(flow, path, config); + if (!resolved.ok) return checkAuthoredFlow(flow, path, config, { environment }); await Promise.all(resolved.resolutions.map(async resolution => { const step = resolved.compiled!.steps.find(step => step.id === resolution.stepId)!; const managed = step.type === 'agent' && communicationInstruction(step.instruction) !== undefined; - const key = cliProbeKey(resolution, managed); + const key = cliProbeKey(resolution, managed, step.secrets); if (probeCache.has(key)) return; let probing = pending.get(key); if (probing === undefined) { @@ -30,7 +30,7 @@ export function authoredPreflight(path: string, environment?: NodeJS.ProcessEnv) try { probeCache.set(key, { result: await probeCliAsync(resolution.cli, resolution.source === 'project' ? config.directory : directory, - resolution.model, managed ? 'managed' : undefined, environment) }); + resolution.model, managed ? 'managed' : undefined, environment, step.secrets) }); } catch (error) { probeCache.set(key, { failure: error instanceof CliProbeError ? error.detail : null }); } diff --git a/packages/sdk/src/cli/check.ts b/packages/sdk/src/cli/check.ts index cc08769bd..b94fd1ad9 100644 --- a/packages/sdk/src/cli/check.ts +++ b/packages/sdk/src/cli/check.ts @@ -492,8 +492,8 @@ function findConfig(start: string): string | undefined { 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, environment), + cli: (cli, source, model, execution, secrets) => probeCli(cli, + source === 'project' ? config.directory : flowDirectory, model, execution, environment, secrets), 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 @@ -521,7 +521,7 @@ function bindResolvedCliPaths( if (resolution === undefined) return step; const directory = resolution.source === 'project' ? configDirectory : flowDirectory; const managed = step.type === 'agent' && communicationInstruction(step.instruction) !== undefined; - const outcome = probeCache.get(cliProbeKey(resolution, managed)); + const outcome = probeCache.get(cliProbeKey(resolution, managed, step.secrets)); const executable = outcome !== undefined && 'result' in outcome ? outcome.result.executable : undefined; diff --git a/packages/sdk/src/cli/cli-probe.ts b/packages/sdk/src/cli/cli-probe.ts index d6220af2b..ff26d6d17 100644 --- a/packages/sdk/src/cli/cli-probe.ts +++ b/packages/sdk/src/cli/cli-probe.ts @@ -7,6 +7,7 @@ import { adapterIdentification, authenticationProbe, classifyModelProbeFailure, import { MODEL_ENV } from '../worker-cli.js'; import { CliProbeError, type CliProbeResult } from '../preflight.js'; import { pinCliAlias, pinCliAliasSync } from './pinned-cli-alias.js'; +import { agentProbeEnvironment } from '../step-secrets.js'; interface ProbeRequest { argv0?: string; @@ -30,7 +31,8 @@ export async function probeCliAsync(...args: Parameters): } export function resolveExecutable(command: string, directory: string, environment: NodeJS.ProcessEnv = process.env): string | undefined { - return driveSync(executableSequence(command, directory, environment)); + return driveSync(executableSequence(command, directory, + agentProbeEnvironment(environment, 'relayflows-wrapper-v1'))); } function driveSync(sequence: Generator): T { @@ -94,23 +96,24 @@ function* probeSequence( model?: string, execution?: 'managed', sourceEnvironment: NodeJS.ProcessEnv = process.env, + grants?: string[], ): Generator { - const executable = yield* executableSequence(cli, directory, sourceEnvironment); + const kind = cliAdapterKind(cli); + const environment = execution === 'managed' + ? { ...brokerEnvironment(sourceEnvironment), ...agentEnvironment(cli, sourceEnvironment, grants) } + : agentProbeEnvironment(sourceEnvironment, kind, grants); + const executable = yield* executableSequence(cli, directory, environment); if (executable === undefined) return { exists: false, authenticated: false }; // The declaration selects the adapter; the canonical executable selects // the bytes. Package-manager links commonly name `claude` or `codex` while // targeting a generic `cli.js`, whose basename must not rewrite the adapter // contract after resolution. - const kind = cliAdapterKind(cli); // Relay owns interactive CLI launch/injection. Its generic PTY path is not // the headless wrapper protocol; do not demand that protocol from Gemini, // Cursor, OpenCode, or other interactive tools. Never invent an auth pass. if (execution === 'managed' && kind === 'relayflows-wrapper-v1') { return { exists: true, supported: true, authenticated: 'unverified', executable }; } - const environment = execution === 'managed' - ? { ...brokerEnvironment(sourceEnvironment), ...agentEnvironment(cli, sourceEnvironment) } - : sourceEnvironment; const probe = (invocation: CliInvocation): ProbeRequest => ({ executable, argv0: basename(cli), diff --git a/packages/sdk/src/communication/environment.ts b/packages/sdk/src/communication/environment.ts index f2789071f..9b5eb284f 100644 --- a/packages/sdk/src/communication/environment.ts +++ b/packages/sdk/src/communication/environment.ts @@ -44,13 +44,12 @@ export function agentEnvironment(cli: string, source: NodeJS.ProcessEnv = proces const cliName = basename(cli).replace(/\.exe$/i, ''); const credentials = { ...selected(source, providerNames[cliName] ?? []), ...selected(source, [...repositoryEnvironmentNames(source), ...networkNames]) }; - if (!scopeSecrets) { - if (grants?.length) throw new Error('per-step secret grants require a daemon with step_secret_scoping support'); - return { ...Object.fromEntries(relayNames.map(name => [name, ''])), ...credentials } as Record; + if (!scopeSecrets && grants?.length) { + throw new Error('per-step secret grants require a daemon with step_secret_scoping support'); } const names = deliveredNames(source); const platform = platformAgentNames(source, cliName); - const allowed = validateStepSecrets(grants, source); + const allowed = scopeSecrets ? validateStepSecrets(grants, source) : []; if (allowed.some(name => relayNames.includes(name))) { throw new Error('Relay broker credentials cannot be granted to a communication agent'); } diff --git a/packages/sdk/src/compile.ts b/packages/sdk/src/compile.ts index e6f45ebd2..70be6127c 100644 --- a/packages/sdk/src/compile.ts +++ b/packages/sdk/src/compile.ts @@ -467,7 +467,7 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown { const unionKeys = [ 'id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', 'memory', 'requirements', 'input', 'command', 'timeout_ms', 'lease_ms', 'on_non_zero', 'prompt', 'model', 'cli', 'instruction', - 'cwd', 'resume', 'recovery_mode', 'surfaces', 'permissions', 'transport', 'cli_identity', + 'cwd', 'resume', 'recovery_mode', 'surfaces', 'permissions', 'transport', 'cli_identity', 'secrets', ] as const; const step = requireKernelObject(value, unionKeys, at); if (step['cli_identity'] !== undefined) { @@ -476,7 +476,7 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown { ], 'untrusted_cli_identity'); } const type = step['type']; - const commonKeys = ['id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', 'memory', 'requirements', 'input'] as const; + const commonKeys = ['id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', 'memory', 'requirements', 'input', 'secrets'] as const; const typeKeys = type === 'deterministic' ? ['command', 'timeout_ms', 'lease_ms', 'on_non_zero'] as const : type === 'llm' @@ -492,6 +492,7 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown { id: step['id'], type, ...(step['input'] !== undefined ? { input: step['input'] } : {}), + ...(step['secrets'] !== undefined ? { secrets: step['secrets'] } : {}), ...(step['requirements'] !== undefined ? { requirements: kernelRequirementsToAuthoring(step['requirements'], `${at}.requirements`) } : {}), ...(dependsOn !== undefined && (!Array.isArray(dependsOn) || dependsOn.length > 0) ? { dependsOn } diff --git a/packages/sdk/src/journal-client.ts b/packages/sdk/src/journal-client.ts index 1eeb7d609..c6e365894 100644 --- a/packages/sdk/src/journal-client.ts +++ b/packages/sdk/src/journal-client.ts @@ -25,7 +25,8 @@ import { type StepUsage, } from './protocol.js'; import type { KernelRunSpec, StepType } from './spec.js'; -import { PLATFORM_AGENT_ENV_NAMES, SIGNAL_READ_TOKEN_ENV, validateStepSecrets } from './step-secrets.js'; +import { PLATFORM_AGENT_ENV_NAMES, SECRET_ENV_NAMES, SIGNAL_READ_TOKEN_ENV, + deliveredNames, validateStepSecrets } from './step-secrets.js'; export interface JournalClientOptions { /** Opt-in total budget for read-only requests; interactive clients stay single-shot. */ @@ -135,7 +136,13 @@ export class JournalClient extends JournalConnection { admissionKey?: string, watch = false, ): Promise { - for (const step of spec.steps) validateStepSecrets(step.secrets, process.env); + for (const step of spec.steps) { + for (const name of validateStepSecrets(step.secrets, process.env)) { + if (this.withheld.has(name)) { + throw new Error(`step secret ${JSON.stringify(name)} is withheld by the daemon`); + } + } + } if (!this.features.has('step_secret_scoping') && spec.steps.some(step => step.secrets?.length)) { throw new Error('per-step secret grants require a daemon with step_secret_scoping support'); } @@ -172,9 +179,16 @@ export class JournalClient extends JournalConnection { return {}; } const env: Record = {}; + const delivered = deliveredNames(process.env); + if (delivered !== undefined && this.withheld.has(SECRET_ENV_NAMES)) { + throw new Error(`${SECRET_ENV_NAMES} is withheld by the daemon; delivered secrets cannot be scoped`); + } for (const [key, value] of Object.entries(process.env)) { if (value !== undefined && key !== SIGNAL_READ_TOKEN_ENV && key !== PLATFORM_AGENT_ENV_NAMES - && !this.withheld.has(key)) env[key] = value; + && !this.withheld.has(key)) { + env[key] = key === SECRET_ENV_NAMES && delivered !== undefined + ? JSON.stringify([...delivered].filter(name => !this.withheld.has(name))) : value; + } } return { env }; } diff --git a/packages/sdk/src/preflight.ts b/packages/sdk/src/preflight.ts index 79e8396f3..03c598890 100644 --- a/packages/sdk/src/preflight.ts +++ b/packages/sdk/src/preflight.ts @@ -91,7 +91,7 @@ export interface PreflightProbes { * model in scope, so readiness answers "can this CLI use THIS model" rather * than the weaker "is this CLI authenticated at all". */ - cli(cli: string, source: CliResolutionSource, model?: string, execution?: 'managed'): CliProbeResult; + cli(cli: string, source: CliResolutionSource, model?: string, execution?: 'managed', secrets?: string[]): CliProbeResult; executor(trigger: TriggerSpec): boolean; command(binary: string): boolean; } @@ -340,7 +340,7 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul } const resolution = resolutionByStep.get(step.id)!; probeResolvedCli(resolution, options.probes, cliProbeResults, diagnostics, - step.type === 'agent' && communicationInstruction(step.instruction) !== undefined); + step.type === 'agent' && communicationInstruction(step.instruction) !== undefined, step.secrets); } for (const trigger of compiled.triggers ?? []) { @@ -529,8 +529,9 @@ function resolveCli( return undefined; } -export function cliProbeKey(resolution: CliResolution, managed = false): string { - return JSON.stringify([resolution.cli, resolution.source, resolution.model ?? null, managed]); +export function cliProbeKey(resolution: CliResolution, managed = false, secrets?: readonly string[]): string { + return JSON.stringify([resolution.cli, resolution.source, resolution.model ?? null, managed, + ...(secrets?.length ? [[...secrets].sort()] : [])]); } function probeResolvedCli( @@ -539,6 +540,7 @@ function probeResolvedCli( cache: Map, diagnostics: PreflightDiagnostic[], managed = false, + secrets?: string[], ): void { // Source is load-bearing: the same relative CLI string resolves from the // flow directory for step/named/flow declarations and the config directory for @@ -546,13 +548,13 @@ function probeResolvedCli( // Model is part of the key: the same CLI probed with two different models // is two different questions, and caching on the CLI alone would let a // model that the CLI cannot resolve inherit an earlier model's pass. - const cacheKey = cliProbeKey(resolution, managed); + const cacheKey = cliProbeKey(resolution, managed, secrets); let outcome = cache.get(cacheKey); if (outcome === undefined) { try { outcome = { result: managed - ? probes.cli(resolution.cli, resolution.source, resolution.model, 'managed') - : probes.cli(resolution.cli, resolution.source, resolution.model) }; + ? probes.cli(resolution.cli, resolution.source, resolution.model, 'managed', secrets) + : probes.cli(resolution.cli, resolution.source, resolution.model, undefined, secrets) }; } catch (error) { const detail = error instanceof CliProbeError ? error.detail : undefined; outcome = { failure: detail ?? null }; diff --git a/packages/sdk/src/step-secrets.ts b/packages/sdk/src/step-secrets.ts index 41c53fd69..f8ce97f3a 100644 --- a/packages/sdk/src/step-secrets.ts +++ b/packages/sdk/src/step-secrets.ts @@ -115,3 +115,22 @@ export function agentStepEnvironment( for (const name of grants) env[name] = source[name]; return env; } + +/** Provider readiness probes run before worker attach, but obey the step's grants. */ +export function agentProbeEnvironment( + source: NodeJS.ProcessEnv, + kind: CliAdapterKind, + declaration?: unknown, +): NodeJS.ProcessEnv { + const env = { ...source }; + const allowed = new Set(validateStepSecrets(declaration, source)); + const platform = platformAgentNames(source, kind); + for (const name of deliveredNames(source) ?? []) { + if (!allowed.has(name) && !platform.has(name)) delete env[name]; + } + delete env[SECRET_ENV_NAMES]; + delete env[PLATFORM_AGENT_ENV_NAMES]; + delete env[SIGNAL_READ_TOKEN_ENV]; + delete env.RELAY_AGENT_TOKEN; + return env; +} diff --git a/packages/sdk/tests/bundle-preflight.test.ts b/packages/sdk/tests/bundle-preflight.test.ts index 4086d031c..50886eb87 100644 --- a/packages/sdk/tests/bundle-preflight.test.ts +++ b/packages/sdk/tests/bundle-preflight.test.ts @@ -57,4 +57,13 @@ describe('bundle execution preflight', () => { severity: 'warning', kind: 'command_unresolved', })); }); + + it('reloads a grant-bearing canonical bundle without dropping its secret names', async () => { + const f = await setup(); + await writeFile(join(f.root, 'scoped.yaml'), 'version: 0.1.0\nname: scoped\nsteps:\n - id: run\n type: deterministic\n command: "true"\n secrets: [NATIVE_API_TOKEN]\n'); + const bundle = await buildFlow(join(f.root, 'scoped.yaml'), join(f.root, 'dist/flows'), () => {}); + const checked = await checkRunnableBundle(bundle, 'scoped'); + expect(checked.report.ok).toBe(true); + expect(checked.flow?.steps[0]?.secrets).toEqual(['NATIVE_API_TOKEN']); + }); }); diff --git a/packages/sdk/tests/cli-probe.test.ts b/packages/sdk/tests/cli-probe.test.ts index 62fb5c109..aec316eab 100644 --- a/packages/sdk/tests/cli-probe.test.ts +++ b/packages/sdk/tests/cli-probe.test.ts @@ -2,9 +2,12 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, expect, it, vi } from 'vitest'; -import { probeCli, probeCliAsync } from '../src/cli/cli-probe.js'; +import { probeCli, probeCliAsync, resolveExecutable } from '../src/cli/cli-probe.js'; import * as adapters from '../src/cli-adapter.js'; import { runCli } from '../src/cli.js'; +import { checkAuthoredFlow } from '../src/cli/check.js'; +import { authoredPreflight } from '../src/authored-preflight.js'; +import type { FlowSpec } from '../src/spec.js'; const directories: string[] = []; afterEach(() => { @@ -135,6 +138,57 @@ it('uses an explicit provider environment for every probe process', async () => }); }); +it('scopes delivered secrets in PATH lookup and provider probes for checked and authored steps', async () => { + const directory = mkdtempSync(join(tmpdir(), 'scoped-probe-')); + directories.push(directory); + const observed = join(directory, 'observed.jsonl'); + const claude = join(directory, 'claude'); + const which = join(directory, 'which'); + const record = `require('node:fs').appendFileSync(${JSON.stringify(observed)}, JSON.stringify({ + command: process.argv[1].split('/').at(-1), + delivered: process.env.NATIVE_API_TOKEN ?? null, + github: process.env.GH_TOKEN ?? null, + bedrock: process.env.AWS_BEARER_TOKEN_BEDROCK ?? null, + repository: process.env.GH_CONFIG_DIR ?? null, + marker: process.env.RELAY_PLATFORM_AGENT_ENV_NAMES ?? null, +}) + '\\n');`; + writeFileSync(claude, `#!/usr/bin/env node\n${record}\nprocess.exit(0);\n`); + writeFileSync(which, `#!/usr/bin/env node\n${record}\nprocess.stdout.write(${JSON.stringify(claude + '\n')});\n`); + chmodSync(claude, 0o755); + chmodSync(which, 0o755); + writeFileSync(join(directory, 'flows.json'), '{}'); + const environment = { ...process.env, + PATH: `${directory}:${process.env.PATH ?? ''}`, + NATIVE_API_TOKEN: 'user-secret', GH_TOKEN: 'user-github-secret', + GH_CONFIG_DIR: '/trusted/gh', AWS_BEARER_TOKEN_BEDROCK: 'selected-bedrock-login', + RELAY_SECRET_ENV_NAMES: '["NATIVE_API_TOKEN","GH_TOKEN","AWS_BEARER_TOKEN_BEDROCK"]', + RELAY_PLATFORM_AGENT_ENV_NAMES: '["AWS_BEARER_TOKEN_BEDROCK"]', + }; + const plain: FlowSpec = { version: '0.1.0', steps: [ + { id: 'agent', type: 'agent', cli: 'claude', instruction: 'task' }, + ] }; + expect(checkAuthoredFlow(plain, join(directory, 'plain.yaml'), undefined, + { environment }).report.ok).toBe(true); + const runPreflight = authoredPreflight(join(directory, 'plain.ts'), environment); + expect((await runPreflight(plain)).report.ok).toBe(true); + const records = () => readFileSync(observed, 'utf8').trim().split('\n') + .map(line => JSON.parse(line) as Record); + expect(records().some(record => record.command === 'which')).toBe(true); + expect(records().some(record => record.command === 'claude')).toBe(true); + for (const record of records()) { + expect(record).toMatchObject({ delivered: null, github: null, + bedrock: 'selected-bedrock-login', repository: '/trusted/gh', marker: null }); + } + expect(resolveExecutable('claude', directory, environment)).toBe(realpathSync(claude)); + expect(records().at(-1)).toMatchObject({ command: 'which', delivered: null, + github: null, bedrock: null, marker: null }); + const granted: FlowSpec = { ...plain, steps: [{ ...plain.steps[0]!, secrets: ['NATIVE_API_TOKEN'] }] }; + expect(checkAuthoredFlow(granted, join(directory, 'granted.yaml'), undefined, + { environment }).report.ok).toBe(true); + expect((await runPreflight(granted)).report.ok).toBe(true); + expect(records().at(-1)?.delivered).toBe('user-secret'); +}); + it('reports timeout in both drivers while the async driver leaves the loop free', async () => { const observed = join(tmpdir(), `probe-alias-timeout-${process.pid}-${Date.now()}`); const { path: canonical, directory } = wrapper(identify + ` diff --git a/packages/sdk/tests/communication-review.test.ts b/packages/sdk/tests/communication-review.test.ts index dfd59c306..d13032b27 100644 --- a/packages/sdk/tests/communication-review.test.ts +++ b/packages/sdk/tests/communication-review.test.ts @@ -67,12 +67,34 @@ it('communication agents drop delivered secrets by default on a scoped runtime', expect(granted.NATIVE_API_TOKEN).toBe('delivered-native-key'); expect(granted).not.toHaveProperty('ANTHROPIC_API_KEY'); expect(granted.RELAY_API_KEY).toBe(''); - expect(agentEnvironment('claude', source, undefined, false).ANTHROPIC_API_KEY).toBe('delivered-provider-key'); + expect(agentEnvironment('claude', source, undefined, false)).not.toHaveProperty('ANTHROPIC_API_KEY'); expect(() => agentEnvironment('claude', { ...source, RELAY_SECRET_ENV_NAMES: JSON.stringify(['RELAY_API_KEY']), }, ['RELAY_API_KEY'])).toThrow('Relay broker credentials'); }); +it('legacy communication agents still strip delivered repository names', () => { + const source = { + GH_TOKEN: 'user-github-secret', GITLAB_TOKEN: 'user-gitlab-secret', + GIT_CONFIG_KEY_1: 'user-git-secret', HTTPS_PROXY: 'user-proxy-secret', + GH_CONFIG_DIR: '/trusted/gh', + CLAUDE_CODE_USE_BEDROCK: '1', AWS_BEARER_TOKEN_BEDROCK: 'selected-bedrock-login', + RELAY_SECRET_ENV_NAMES: JSON.stringify([ + 'GH_TOKEN', 'GITLAB_TOKEN', 'GIT_CONFIG_KEY_1', 'HTTPS_PROXY', + 'CLAUDE_CODE_USE_BEDROCK', 'AWS_BEARER_TOKEN_BEDROCK', + ]), + RELAY_PLATFORM_AGENT_ENV_NAMES: JSON.stringify([ + 'CLAUDE_CODE_USE_BEDROCK', 'AWS_BEARER_TOKEN_BEDROCK', + ]), + }; + const env = agentEnvironment('claude', source, undefined, false); + for (const name of ['GH_TOKEN', 'GITLAB_TOKEN', 'GIT_CONFIG_KEY_1', 'HTTPS_PROXY']) { + expect(env).not.toHaveProperty(name); + } + expect(env).toMatchObject({ GH_CONFIG_DIR: '/trusted/gh', + CLAUDE_CODE_USE_BEDROCK: '1', AWS_BEARER_TOKEN_BEDROCK: 'selected-bedrock-login' }); +}); + it('communication Claude keeps trusted Bedrock and repository setup without user secrets', () => { const source = { CLAUDE_CODE_USE_BEDROCK: '1', AWS_BEARER_TOKEN_BEDROCK: 'bedrock-login', diff --git a/packages/sdk/tests/journal-client-step-env.test.ts b/packages/sdk/tests/journal-client-step-env.test.ts index 11c0c7dd5..df95e0867 100644 --- a/packages/sdk/tests/journal-client-step-env.test.ts +++ b/packages/sdk/tests/journal-client-step-env.test.ts @@ -72,6 +72,45 @@ it('keeps plain runs with delivered secrets working on an older daemon', async ( } }); +it('removes withheld delivered names from the manifest and refuses their grants at admission', async () => { + const path = sockPath(); + const received: Record[] = []; + const server = startLoopback(path, { + hello: ctx => sendResult(ctx, { protocol: 0, server: 'relayflowd', + features: ['step_env', 'step_secret_scoping'], det_withheld: ['HIDDEN_SECRET'] }), + 'run.start': (ctx, params) => { received.push(params); sendResult(ctx, { run_id: 'run-01', status: 'completed' }); }, + 'run.resume': (ctx, params) => { received.push(params); sendResult(ctx, { run_id: 'run-01', status: 'completed' }); }, + }); + const client = new JournalClient(path); + const saved = { ...process.env }; + Object.assign(process.env, { + HIDDEN_SECRET: 'withheld-value', VISIBLE_SECRET: 'visible-value', + RELAY_SECRET_ENV_NAMES: '["HIDDEN_SECRET","VISIBLE_SECRET"]', + }); + try { + await client.connect(); + await client.hello('withheld-delivered'); + await client.runStart(HELLO_SPEC); + await client.runResume('run-01'); + expect(received).toHaveLength(2); + for (const params of received) { + const env = params['env'] as Record; + expect(env).not.toHaveProperty('HIDDEN_SECRET'); + expect(env.RELAY_SECRET_ENV_NAMES).toBe('["VISIBLE_SECRET"]'); + } + const granted = { ...HELLO_SPEC, + steps: HELLO_SPEC.steps.map(step => ({ ...step, secrets: ['HIDDEN_SECRET'] })) }; + expect(() => client.runStart(granted)).toThrow('withheld by the daemon'); + expect(received).toHaveLength(2); + } finally { + for (const key of Object.keys(process.env)) if (!(key in saved)) delete process.env[key]; + Object.assign(process.env, saved); + client.close(); + await new Promise(resolve => server.close(() => resolve())); + rmSync(path, { force: true }); + } +}); + it('never forwards CLI-only signal or model-login metadata on run.start or run.resume', async () => { const path = sockPath(); const received: Record[] = []; From 0d9b7a345c02aff6f9553cec1ad6b7ae43d1845e Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 16 Oct 2026 10:55:44 +0200 Subject: [PATCH 08/11] fix: retain selected CLI login when delivered as run secret --- docs/CLOUD.md | 6 +-- docs/SURFACE.md | 7 ++-- packages/sdk/src/communication/environment.ts | 7 +++- packages/sdk/src/step-secrets.ts | 10 ++++- packages/sdk/tests/cli-probe.test.ts | 8 +++- ...mmunication-feature-secret-scoping.test.ts | 6 ++- .../sdk/tests/communication-review.test.ts | 6 +-- packages/sdk/tests/step-secrets.test.ts | 40 ++++++++++++++++++- 8 files changed, 71 insertions(+), 19 deletions(-) diff --git a/docs/CLOUD.md b/docs/CLOUD.md index ad86f9145..be01f689e 100644 --- a/docs/CLOUD.md +++ b/docs/CLOUD.md @@ -55,9 +55,9 @@ await f.agent('review', { task: 'Review the result', secrets: [] }); The same `secrets: ['NATIVE_API_TOKEN']` declaration is accepted on a YAML step. Unknown or unavailable names refuse the run. A direct agent step starts -with a small system environment, Cloud-selected model login (including active -Claude Bedrock login), and trusted repository git/gh setup, plus only its -declared user-delivered secrets; a Relay-transport agent cannot request run +with a small system environment, its own model login (including delivered +login and active Claude Bedrock login), and trusted repository git/gh setup, +plus only its declared other user-delivered secrets; a Relay-transport agent cannot request run secrets. On a self-hosted run, set `RELAY_SECRET_ENV_NAMES` to a JSON array of the secret names before `flows run` so deterministic steps can scope them too. diff --git a/docs/SURFACE.md b/docs/SURFACE.md index c653bacae..c513d7579 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -1007,9 +1007,10 @@ step gets only the names in its `secrets` declaration. For example, `f.run('node call-api.mjs', { secrets: ['NATIVE_API_TOKEN'] })` receives that value, while the next `f.run` without a grant does not. The same `secrets` option is available on `f.agent` and `f.llm` and in declarative steps. Agent -CLIs start from an allowlist of system variables and their selected CLI login, -Cloud-managed repository git/gh setup and model login selected for the CLI stay -available; user-delivered names remain grant-only. A misspelled or unavailable +CLIs start from an allowlist of system variables and their selected CLI login. +Their own model login stays available even when delivered as a run secret; +other user-delivered names require an explicit grant. Cloud-managed repository +git/gh setup also stays available. A misspelled or unavailable name refuses the step. Without a secret-name manifest, self-hosted deterministic steps retain the legacy run environment; set the manifest when supplying secrets. diff --git a/packages/sdk/src/communication/environment.ts b/packages/sdk/src/communication/environment.ts index 9b5eb284f..fc6bd5bc6 100644 --- a/packages/sdk/src/communication/environment.ts +++ b/packages/sdk/src/communication/environment.ts @@ -1,6 +1,7 @@ import { basename } from 'node:path'; import { wrapperEnvironment } from '../wrapper-runtime.js'; -import { deliveredNames, platformAgentNames, repositoryEnvironmentNames, validateStepSecrets } from '../step-secrets.js'; +import { deliveredNames, isCliLoginName, platformAgentNames, repositoryEnvironmentNames, + validateStepSecrets } from '../step-secrets.js'; const terminalNames = ['TERM', 'COLORTERM', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME']; const networkNames = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', @@ -54,7 +55,9 @@ export function agentEnvironment(cli: string, source: NodeJS.ProcessEnv = proces throw new Error('Relay broker credentials cannot be granted to a communication agent'); } for (const name of names ?? []) { - if (!allowed.includes(name) && !platform.has(name)) delete credentials[name]; + if (!allowed.includes(name) && !platform.has(name) && !isCliLoginName(cliName, name)) { + delete credentials[name]; + } } return { ...Object.fromEntries(relayNames.map(name => [name, ''])), ...credentials, ...selected(source, allowed) } as Record; diff --git a/packages/sdk/src/step-secrets.ts b/packages/sdk/src/step-secrets.ts index f8ce97f3a..509b0c48b 100644 --- a/packages/sdk/src/step-secrets.ts +++ b/packages/sdk/src/step-secrets.ts @@ -50,6 +50,11 @@ const CLI_LOGIN: Record = { 'relayflows-wrapper-v1': [], }; +/** A selected CLI can use its own delivered model login without a step grant. */ +export function isCliLoginName(kind: string, name: string): boolean { + return Object.hasOwn(CLI_LOGIN, kind) && CLI_LOGIN[kind as CliAdapterKind].includes(name); +} + export function deliveredNames(source: NodeJS.ProcessEnv): ReadonlySet | undefined { const raw = source[SECRET_ENV_NAMES]; if (raw === undefined) return undefined; // self-hosted runs may grant present env names @@ -109,7 +114,8 @@ export function agentStepEnvironment( const grants = validateStepSecrets(declaration, source); const env: NodeJS.ProcessEnv = {}; for (const name of [...BASE_ENV, ...CLI_LOGIN[kind], ...repositoryEnvironmentNames(source)]) { - if (known?.has(name) && !grants.includes(name) && !platform.has(name)) continue; + if (known?.has(name) && !grants.includes(name) && !platform.has(name) + && !isCliLoginName(kind, name)) continue; if (source[name] !== undefined) env[name] = source[name]; } for (const name of grants) env[name] = source[name]; @@ -126,7 +132,7 @@ export function agentProbeEnvironment( const allowed = new Set(validateStepSecrets(declaration, source)); const platform = platformAgentNames(source, kind); for (const name of deliveredNames(source) ?? []) { - if (!allowed.has(name) && !platform.has(name)) delete env[name]; + if (!allowed.has(name) && !platform.has(name) && !isCliLoginName(kind, name)) delete env[name]; } delete env[SECRET_ENV_NAMES]; delete env[PLATFORM_AGENT_ENV_NAMES]; diff --git a/packages/sdk/tests/cli-probe.test.ts b/packages/sdk/tests/cli-probe.test.ts index aec316eab..b9115b748 100644 --- a/packages/sdk/tests/cli-probe.test.ts +++ b/packages/sdk/tests/cli-probe.test.ts @@ -148,6 +148,8 @@ it('scopes delivered secrets in PATH lookup and provider probes for checked and command: process.argv[1].split('/').at(-1), delivered: process.env.NATIVE_API_TOKEN ?? null, github: process.env.GH_TOKEN ?? null, + claudeLogin: process.env.ANTHROPIC_API_KEY ?? null, + codexLogin: process.env.OPENAI_API_KEY ?? null, bedrock: process.env.AWS_BEARER_TOKEN_BEDROCK ?? null, repository: process.env.GH_CONFIG_DIR ?? null, marker: process.env.RELAY_PLATFORM_AGENT_ENV_NAMES ?? null, @@ -160,8 +162,9 @@ it('scopes delivered secrets in PATH lookup and provider probes for checked and const environment = { ...process.env, PATH: `${directory}:${process.env.PATH ?? ''}`, NATIVE_API_TOKEN: 'user-secret', GH_TOKEN: 'user-github-secret', + ANTHROPIC_API_KEY: 'claude-login', OPENAI_API_KEY: 'codex-login', GH_CONFIG_DIR: '/trusted/gh', AWS_BEARER_TOKEN_BEDROCK: 'selected-bedrock-login', - RELAY_SECRET_ENV_NAMES: '["NATIVE_API_TOKEN","GH_TOKEN","AWS_BEARER_TOKEN_BEDROCK"]', + RELAY_SECRET_ENV_NAMES: '["NATIVE_API_TOKEN","GH_TOKEN","ANTHROPIC_API_KEY","OPENAI_API_KEY","AWS_BEARER_TOKEN_BEDROCK"]', RELAY_PLATFORM_AGENT_ENV_NAMES: '["AWS_BEARER_TOKEN_BEDROCK"]', }; const plain: FlowSpec = { version: '0.1.0', steps: [ @@ -177,11 +180,12 @@ it('scopes delivered secrets in PATH lookup and provider probes for checked and expect(records().some(record => record.command === 'claude')).toBe(true); for (const record of records()) { expect(record).toMatchObject({ delivered: null, github: null, + claudeLogin: 'claude-login', codexLogin: null, bedrock: 'selected-bedrock-login', repository: '/trusted/gh', marker: null }); } expect(resolveExecutable('claude', directory, environment)).toBe(realpathSync(claude)); expect(records().at(-1)).toMatchObject({ command: 'which', delivered: null, - github: null, bedrock: null, marker: null }); + github: null, claudeLogin: null, codexLogin: null, bedrock: null, marker: null }); const granted: FlowSpec = { ...plain, steps: [{ ...plain.steps[0]!, secrets: ['NATIVE_API_TOKEN'] }] }; expect(checkAuthoredFlow(granted, join(directory, 'granted.yaml'), undefined, { environment }).report.ok).toBe(true); diff --git a/packages/sdk/tests/communication-feature-secret-scoping.test.ts b/packages/sdk/tests/communication-feature-secret-scoping.test.ts index 1fb557ed8..b73a47220 100644 --- a/packages/sdk/tests/communication-feature-secret-scoping.test.ts +++ b/packages/sdk/tests/communication-feature-secret-scoping.test.ts @@ -38,11 +38,13 @@ it('communication worker uses the negotiated capability to scope its broker chil await completeCommunicationDispatch(client as unknown as JournalClient, dispatch, { type: 'relayflows.communication.v1', instruction: 'task', incoming: [], outgoing: [], timeoutMs: 1000 }, root, root, { - ANTHROPIC_API_KEY: 'delivered-provider-key', RELAY_SECRET_ENV_NAMES: '["ANTHROPIC_API_KEY"]', + ANTHROPIC_API_KEY: 'delivered-provider-key', GH_TOKEN: 'user-repository-secret', + RELAY_SECRET_ENV_NAMES: '["ANTHROPIC_API_KEY","GH_TOKEN"]', RELAY_API_KEY: 'broker-key', RELAYFLOW_V2_SIGNAL_READ_TOKEN: 'private-signal-token', }); const env = state.environments.at(-1)!; - expect(env.ANTHROPIC_API_KEY).toBe(scoped ? undefined : 'delivered-provider-key'); + expect(env.ANTHROPIC_API_KEY).toBe('delivered-provider-key'); + expect(env).not.toHaveProperty('GH_TOKEN'); expect(env.RELAY_API_KEY).toBe(''); expect(env).not.toHaveProperty('RELAYFLOW_V2_SIGNAL_READ_TOKEN'); } diff --git a/packages/sdk/tests/communication-review.test.ts b/packages/sdk/tests/communication-review.test.ts index d13032b27..560341127 100644 --- a/packages/sdk/tests/communication-review.test.ts +++ b/packages/sdk/tests/communication-review.test.ts @@ -60,14 +60,14 @@ it('communication agents drop delivered secrets by default on a scoped runtime', RELAY_SECRET_ENV_NAMES: JSON.stringify(['ANTHROPIC_API_KEY', 'NATIVE_API_TOKEN']), }; const plain = agentEnvironment('claude', source); - expect(plain).not.toHaveProperty('ANTHROPIC_API_KEY'); + expect(plain.ANTHROPIC_API_KEY).toBe('delivered-provider-key'); expect(plain).not.toHaveProperty('NATIVE_API_TOKEN'); expect(plain.CLAUDE_CODE_OAUTH_TOKEN).toBe('own-login'); const granted = agentEnvironment('claude', source, ['NATIVE_API_TOKEN']); expect(granted.NATIVE_API_TOKEN).toBe('delivered-native-key'); - expect(granted).not.toHaveProperty('ANTHROPIC_API_KEY'); + expect(granted.ANTHROPIC_API_KEY).toBe('delivered-provider-key'); expect(granted.RELAY_API_KEY).toBe(''); - expect(agentEnvironment('claude', source, undefined, false)).not.toHaveProperty('ANTHROPIC_API_KEY'); + expect(agentEnvironment('claude', source, undefined, false).ANTHROPIC_API_KEY).toBe('delivered-provider-key'); expect(() => agentEnvironment('claude', { ...source, RELAY_SECRET_ENV_NAMES: JSON.stringify(['RELAY_API_KEY']), }, ['RELAY_API_KEY'])).toThrow('Relay broker credentials'); diff --git a/packages/sdk/tests/step-secrets.test.ts b/packages/sdk/tests/step-secrets.test.ts index 5fb811847..23227cb9e 100644 --- a/packages/sdk/tests/step-secrets.test.ts +++ b/packages/sdk/tests/step-secrets.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { agentStepEnvironment, validateStepSecrets } from '../src/step-secrets.js'; +import { agentProbeEnvironment, agentStepEnvironment, validateStepSecrets } from '../src/step-secrets.js'; import { compileSpec, toKernelSpec } from '../src/compile.js'; +import { agentEnvironment } from '../src/communication/environment.js'; const source = { PATH: '/usr/bin', HOME: '/tmp/home', LANG: 'C.UTF-8', @@ -75,7 +76,7 @@ describe('per-step secret grants', () => { AWS_SECRET_ACCESS_KEY: 'bedrock-secret', AWS_SESSION_TOKEN: 'bedrock-session', AWS_REGION: 'eu-west-1', AWS_DEFAULT_REGION: 'eu-west-1' }); expect(env).not.toHaveProperty('OTHER_TOKEN'); - expect(env).not.toHaveProperty('ANTHROPIC_API_KEY'); + expect(env.ANTHROPIC_API_KEY).toBe('user-provider-key'); expect(env).not.toHaveProperty('RELAY_PLATFORM_AGENT_ENV_NAMES'); expect(agentStepEnvironment(bedrock, 'codex')).not.toHaveProperty('AWS_ACCESS_KEY_ID'); }); @@ -108,6 +109,41 @@ describe('per-step secret grants', () => { expect(agentStepEnvironment(userToken, 'claude')).not.toHaveProperty('GH_TOKEN'); }); + it('keeps only the selected CLI login and granted other secrets in every agent path', () => { + const delivered = { + ANTHROPIC_API_KEY: 'claude-login', OPENAI_API_KEY: 'codex-login', + NATIVE_API_TOKEN: 'other-secret', GH_TOKEN: 'user-repository-secret', + RELAY_SECRET_ENV_NAMES: JSON.stringify([ + 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'NATIVE_API_TOKEN', 'GH_TOKEN', + ]), + }; + for (const [kind, own, foreign] of [ + ['claude', 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY'], + ['codex', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY'], + ] as const) { + for (const env of [ + agentStepEnvironment(delivered, kind), + agentProbeEnvironment(delivered, kind), + agentEnvironment(kind, delivered), + agentEnvironment(kind, delivered, undefined, false), + ]) { + expect(env[own]).toBe(delivered[own]); + expect(env).not.toHaveProperty(foreign); + expect(env).not.toHaveProperty('NATIVE_API_TOKEN'); + expect(env).not.toHaveProperty('GH_TOKEN'); + } + for (const env of [ + agentStepEnvironment(delivered, kind, ['NATIVE_API_TOKEN']), + agentProbeEnvironment(delivered, kind, ['NATIVE_API_TOKEN']), + agentEnvironment(kind, delivered, ['NATIVE_API_TOKEN']), + ]) { + expect(env.NATIVE_API_TOKEN).toBe('other-secret'); + } + } + expect(agentStepEnvironment(delivered, 'relayflows-wrapper-v1')).not.toHaveProperty('ANTHROPIC_API_KEY'); + expect(agentProbeEnvironment(delivered, 'relayflows-wrapper-v1')).not.toHaveProperty('OPENAI_API_KEY'); + }); + it('refuses a spoofed platform agent marker', () => { expect(() => agentStepEnvironment({ ...source, RELAY_PLATFORM_AGENT_ENV_NAMES: '["GH_TOKEN"]' }, 'claude')).toThrow(); From 4a7364ed9815cf7581f758e9653e93584a3f3268 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 16 Oct 2026 11:02:36 +0200 Subject: [PATCH 09/11] fix: require Cloud selection for delivered Bedrock login --- docs/CLOUD.md | 2 +- docs/SURFACE.md | 3 +- packages/sdk/src/step-secrets.ts | 5 +-- packages/sdk/tests/step-secrets.test.ts | 41 +++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/docs/CLOUD.md b/docs/CLOUD.md index be01f689e..c4834fdc5 100644 --- a/docs/CLOUD.md +++ b/docs/CLOUD.md @@ -56,7 +56,7 @@ await f.agent('review', { task: 'Review the result', secrets: [] }); The same `secrets: ['NATIVE_API_TOKEN']` declaration is accepted on a YAML step. Unknown or unavailable names refuse the run. A direct agent step starts with a small system environment, its own model login (including delivered -login and active Claude Bedrock login), and trusted repository git/gh setup, +login and Cloud-selected Claude Bedrock login), and trusted repository git/gh setup, plus only its declared other user-delivered secrets; a Relay-transport agent cannot request run secrets. On a self-hosted run, set `RELAY_SECRET_ENV_NAMES` to a JSON array of the secret names before `flows run` so deterministic steps can scope them too. diff --git a/docs/SURFACE.md b/docs/SURFACE.md index c513d7579..8898fed5a 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -1009,7 +1009,8 @@ value, while the next `f.run` without a grant does not. The same `secrets` option is available on `f.agent` and `f.llm` and in declarative steps. Agent CLIs start from an allowlist of system variables and their selected CLI login. Their own model login stays available even when delivered as a run secret; -other user-delivered names require an explicit grant. Cloud-managed repository +delivered AWS/Bedrock names need Cloud's selected-login marker or a grant. +Other user-delivered names require an explicit grant. Cloud-managed repository git/gh setup also stays available. A misspelled or unavailable name refuses the step. Without a secret-name manifest, self-hosted deterministic steps retain the legacy run environment; set the manifest when supplying secrets. diff --git a/packages/sdk/src/step-secrets.ts b/packages/sdk/src/step-secrets.ts index 509b0c48b..c52633fff 100644 --- a/packages/sdk/src/step-secrets.ts +++ b/packages/sdk/src/step-secrets.ts @@ -50,9 +50,10 @@ const CLI_LOGIN: Record = { 'relayflows-wrapper-v1': [], }; -/** A selected CLI can use its own delivered model login without a step grant. */ +/** A selected CLI can use its own delivered login; Bedrock needs Cloud's marker. */ export function isCliLoginName(kind: string, name: string): boolean { - return Object.hasOwn(CLI_LOGIN, kind) && CLI_LOGIN[kind as CliAdapterKind].includes(name); + return !BEDROCK_LOGIN.has(name) && Object.hasOwn(CLI_LOGIN, kind) + && CLI_LOGIN[kind as CliAdapterKind].includes(name); } export function deliveredNames(source: NodeJS.ProcessEnv): ReadonlySet | undefined { diff --git a/packages/sdk/tests/step-secrets.test.ts b/packages/sdk/tests/step-secrets.test.ts index 23227cb9e..f528f1830 100644 --- a/packages/sdk/tests/step-secrets.test.ts +++ b/packages/sdk/tests/step-secrets.test.ts @@ -144,6 +144,47 @@ describe('per-step secret grants', () => { expect(agentProbeEnvironment(delivered, 'relayflows-wrapper-v1')).not.toHaveProperty('OPENAI_API_KEY'); }); + it('requires the trusted platform marker for delivered Bedrock credentials', () => { + const delivered = { + ANTHROPIC_API_KEY: 'claude-login', AWS_ACCESS_KEY_ID: 'user-app-key', + AWS_SECRET_ACCESS_KEY: 'user-app-secret', CLAUDE_CODE_USE_BEDROCK: '1', + RELAY_SECRET_ENV_NAMES: JSON.stringify([ + 'ANTHROPIC_API_KEY', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'CLAUDE_CODE_USE_BEDROCK', + ]), + }; + for (const env of [ + agentStepEnvironment(delivered, 'claude'), + agentProbeEnvironment(delivered, 'claude'), + agentEnvironment('claude', delivered), + agentEnvironment('claude', delivered, undefined, false), + ]) { + expect(env.ANTHROPIC_API_KEY).toBe('claude-login'); + expect(env).not.toHaveProperty('AWS_ACCESS_KEY_ID'); + expect(env).not.toHaveProperty('AWS_SECRET_ACCESS_KEY'); + expect(env).not.toHaveProperty('CLAUDE_CODE_USE_BEDROCK'); + } + for (const env of [ + agentStepEnvironment(delivered, 'claude', ['AWS_ACCESS_KEY_ID']), + agentProbeEnvironment(delivered, 'claude', ['AWS_ACCESS_KEY_ID']), + agentEnvironment('claude', delivered, ['AWS_ACCESS_KEY_ID']), + ]) { + expect(env.AWS_ACCESS_KEY_ID).toBe('user-app-key'); + expect(env).not.toHaveProperty('AWS_SECRET_ACCESS_KEY'); + } + const selected = { ...delivered, + RELAY_PLATFORM_AGENT_ENV_NAMES: '["CLAUDE_CODE_USE_BEDROCK","AWS_ACCESS_KEY_ID","AWS_SECRET_ACCESS_KEY"]' }; + for (const env of [ + agentStepEnvironment(selected, 'claude'), + agentProbeEnvironment(selected, 'claude'), + agentEnvironment('claude', selected), + agentEnvironment('claude', selected, undefined, false), + ]) { + expect(env.AWS_ACCESS_KEY_ID).toBe('user-app-key'); + expect(env.AWS_SECRET_ACCESS_KEY).toBe('user-app-secret'); + expect(env.CLAUDE_CODE_USE_BEDROCK).toBe('1'); + } + }); + it('refuses a spoofed platform agent marker', () => { expect(() => agentStepEnvironment({ ...source, RELAY_PLATFORM_AGENT_ENV_NAMES: '["GH_TOKEN"]' }, 'claude')).toThrow(); From 99ee82358ca655030ca1decfd1b53e498fd268d7 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 16 Oct 2026 11:21:04 +0200 Subject: [PATCH 10/11] fix: preserve probe call shape for steps without grants --- packages/sdk/src/preflight.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/preflight.ts b/packages/sdk/src/preflight.ts index 03c598890..953f51e41 100644 --- a/packages/sdk/src/preflight.ts +++ b/packages/sdk/src/preflight.ts @@ -553,8 +553,12 @@ function probeResolvedCli( if (outcome === undefined) { try { outcome = { result: managed - ? probes.cli(resolution.cli, resolution.source, resolution.model, 'managed', secrets) - : probes.cli(resolution.cli, resolution.source, resolution.model, undefined, secrets) }; + ? secrets?.length + ? probes.cli(resolution.cli, resolution.source, resolution.model, 'managed', secrets) + : probes.cli(resolution.cli, resolution.source, resolution.model, 'managed') + : secrets?.length + ? probes.cli(resolution.cli, resolution.source, resolution.model, undefined, secrets) + : probes.cli(resolution.cli, resolution.source, resolution.model) }; } catch (error) { const detail = error instanceof CliProbeError ? error.detail : undefined; outcome = { failure: detail ?? null }; From 74fb6c47f42e6a246adb5e1e0e889345e0401aff Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 16 Oct 2026 11:26:59 +0200 Subject: [PATCH 11/11] fix: retain communication provider login without generic cloud keys --- docs/SURFACE.md | 4 ++- packages/sdk/src/communication/environment.ts | 3 +- packages/sdk/src/step-secrets.ts | 10 +++--- .../sdk/tests/communication-review.test.ts | 31 +++++++++++++++++++ 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/docs/SURFACE.md b/docs/SURFACE.md index 8898fed5a..354f89a94 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -1010,7 +1010,9 @@ option is available on `f.agent` and `f.llm` and in declarative steps. Agent CLIs start from an allowlist of system variables and their selected CLI login. Their own model login stays available even when delivered as a run secret; delivered AWS/Bedrock names need Cloud's selected-login marker or a grant. -Other user-delivered names require an explicit grant. Cloud-managed repository +Generic cloud credentials such as `GOOGLE_API_KEY` and +`GOOGLE_APPLICATION_CREDENTIALS` also need a grant; Gemini's own +`GEMINI_API_KEY` remains login. Other user-delivered names require an explicit grant. Cloud-managed repository git/gh setup also stays available. A misspelled or unavailable name refuses the step. Without a secret-name manifest, self-hosted deterministic steps retain the legacy run environment; set the manifest when supplying secrets. diff --git a/packages/sdk/src/communication/environment.ts b/packages/sdk/src/communication/environment.ts index fc6bd5bc6..53aff3403 100644 --- a/packages/sdk/src/communication/environment.ts +++ b/packages/sdk/src/communication/environment.ts @@ -55,7 +55,8 @@ export function agentEnvironment(cli: string, source: NodeJS.ProcessEnv = proces throw new Error('Relay broker credentials cannot be granted to a communication agent'); } for (const name of names ?? []) { - if (!allowed.includes(name) && !platform.has(name) && !isCliLoginName(cliName, name)) { + if (!allowed.includes(name) && !platform.has(name) + && !isCliLoginName(cliName, name, providerNames[cliName])) { delete credentials[name]; } } diff --git a/packages/sdk/src/step-secrets.ts b/packages/sdk/src/step-secrets.ts index c52633fff..f385389ca 100644 --- a/packages/sdk/src/step-secrets.ts +++ b/packages/sdk/src/step-secrets.ts @@ -50,10 +50,12 @@ const CLI_LOGIN: Record = { 'relayflows-wrapper-v1': [], }; -/** A selected CLI can use its own delivered login; Bedrock needs Cloud's marker. */ -export function isCliLoginName(kind: string, name: string): boolean { - return !BEDROCK_LOGIN.has(name) && Object.hasOwn(CLI_LOGIN, kind) - && CLI_LOGIN[kind as CliAdapterKind].includes(name); +/** A selected CLI can use its own delivered login; generic cloud credentials need a grant. */ +export function isCliLoginName(kind: string, name: string, providerNames?: readonly string[]): boolean { + if (BEDROCK_LOGIN.has(name) || name.startsWith('AWS_') + || name === 'GOOGLE_APPLICATION_CREDENTIALS' || name === 'GOOGLE_API_KEY') return false; + const names = providerNames ?? (Object.hasOwn(CLI_LOGIN, kind) ? CLI_LOGIN[kind as CliAdapterKind] : []); + return names.includes(name); } export function deliveredNames(source: NodeJS.ProcessEnv): ReadonlySet | undefined { diff --git a/packages/sdk/tests/communication-review.test.ts b/packages/sdk/tests/communication-review.test.ts index 560341127..30db94ac6 100644 --- a/packages/sdk/tests/communication-review.test.ts +++ b/packages/sdk/tests/communication-review.test.ts @@ -95,6 +95,37 @@ it('legacy communication agents still strip delivered repository names', () => { CLAUDE_CODE_USE_BEDROCK: '1', AWS_BEARER_TOKEN_BEDROCK: 'selected-bedrock-login' }); }); +it('communication CLIs keep only their own delivered login, never generic cloud credentials', () => { + const source = { + GEMINI_API_KEY: 'gemini-login', CURSOR_API_KEY: 'cursor-login', + XAI_API_KEY: 'grok-login', FACTORY_API_KEY: 'droid-login', + GOOGLE_API_KEY: 'application-google-key', + GOOGLE_APPLICATION_CREDENTIALS: '/tmp/application-service-account.json', + AWS_ACCESS_KEY_ID: 'application-aws-key', GH_TOKEN: 'user-repository-secret', + RELAY_SECRET_ENV_NAMES: JSON.stringify([ + 'GEMINI_API_KEY', 'CURSOR_API_KEY', 'XAI_API_KEY', 'FACTORY_API_KEY', + 'GOOGLE_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS', 'AWS_ACCESS_KEY_ID', 'GH_TOKEN', + ]), + }; + for (const [cli, own] of [ + ['gemini', 'GEMINI_API_KEY'], ['cursor-agent', 'CURSOR_API_KEY'], + ['grok', 'XAI_API_KEY'], ['droid', 'FACTORY_API_KEY'], + ] as const) { + for (const scopeSecrets of [true, false]) { + const env = agentEnvironment(cli, source, undefined, scopeSecrets); + expect(env[own]).toBe(source[own]); + for (const foreign of ['GEMINI_API_KEY', 'CURSOR_API_KEY', 'XAI_API_KEY', 'FACTORY_API_KEY']) { + if (foreign !== own) expect(env).not.toHaveProperty(foreign); + } + for (const generic of ['GOOGLE_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS', 'AWS_ACCESS_KEY_ID', 'GH_TOKEN']) { + expect(env).not.toHaveProperty(generic); + } + } + } + expect(agentEnvironment('gemini', source, ['GOOGLE_API_KEY']).GOOGLE_API_KEY) + .toBe('application-google-key'); +}); + it('communication Claude keeps trusted Bedrock and repository setup without user secrets', () => { const source = { CLAUDE_CODE_USE_BEDROCK: '1', AWS_BEARER_TOKEN_BEDROCK: 'bedrock-login',