diff --git a/docs/CLOUD.md b/docs/CLOUD.md index 6e6ee23d0..c4834fdc5 100644 --- a/docs/CLOUD.md +++ b/docs/CLOUD.md @@ -45,6 +45,22 @@ 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 environment, its own model login (including delivered +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. + 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 +71,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..354f89a94 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -1002,11 +1002,36 @@ 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. +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. +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. + 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. 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. 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..d1890c766 100644 --- a/kernel/relayflowd/src/exec_det.rs +++ b/kernel/relayflowd/src/exec_det.rs @@ -76,7 +76,38 @@ 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 = 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), + } + } + None => None, + }; + if let Some(env) = &step_env { process.env_clear(); process.envs(env.vars()); } @@ -229,9 +260,55 @@ fn worker_error(detail: &str) -> AttemptResult { #[cfg(test)] mod tests { use serde_json::json; + use std::collections::BTreeMap; 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 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/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/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.", 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-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/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/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 a31d04990..53aff3403 100644 --- a/packages/sdk/src/communication/environment.ts +++ b/packages/sdk/src/communication/environment.ts @@ -1,14 +1,20 @@ import { basename } from 'node:path'; import { wrapperEnvironment } from '../wrapper-runtime.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', + '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 @@ -32,9 +38,28 @@ 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 cliName = basename(cli).replace(/\.exe$/i, ''); + const credentials = { ...selected(source, providerNames[cliName] ?? []), + ...selected(source, [...repositoryEnvironmentNames(source), ...networkNames]) }; + 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 = scopeSecrets ? 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) && !platform.has(name) + && !isCliLoginName(cliName, name, providerNames[cliName])) { + 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..70be6127c 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 } : {}), @@ -466,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) { @@ -475,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' @@ -491,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 } @@ -636,6 +638,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..c6e365894 100644 --- a/packages/sdk/src/journal-client.ts +++ b/packages/sdk/src/journal-client.ts @@ -25,6 +25,8 @@ import { type StepUsage, } from './protocol.js'; import type { KernelRunSpec, StepType } from './spec.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. */ @@ -78,6 +80,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 +136,16 @@ export class JournalClient extends JournalConnection { admissionKey?: string, watch = false, ): Promise { + 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'); + } return this.request('run.start', { spec, ...(reuseFromRunId === undefined ? {} : { reuse_from_run_id: reuseFromRunId }), @@ -158,10 +173,22 @@ 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 = {}; + 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 && !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] = key === SECRET_ENV_NAMES && delivered !== undefined + ? JSON.stringify([...delivered].filter(name => !this.withheld.has(name))) : value; + } } return { env }; } 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/preflight.ts b/packages/sdk/src/preflight.ts index 79e8396f3..953f51e41 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,17 @@ 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) }; + ? 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 }; 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..f385389ca --- /dev/null +++ b/packages/sdk/src/step-secrets.ts @@ -0,0 +1,145 @@ +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', + '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', + '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', + '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_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', 'OPENROUTER_API_KEY'], + 'relayflows-wrapper-v1': [], +}; + +/** 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 { + 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) + || 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); + 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 || 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`); + } + } + 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 platform = platformAgentNames(source, kind); + 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) + && !isCliLoginName(kind, name)) continue; + if (source[name] !== undefined) env[name] = source[name]; + } + 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) && !isCliLoginName(kind, 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/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..f2b74a030 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 { 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, @@ -139,12 +140,33 @@ 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 }; + // 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' }; + } const effectiveModel = resolveCliModel(cliIdentity ?? cli, model); const argv0 = cliInvocationArgv0(cli, cliIdentity); @@ -206,7 +228,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 +248,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 @@ -256,9 +279,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); } } @@ -399,6 +426,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; @@ -436,7 +464,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(); }, @@ -597,7 +625,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', @@ -635,11 +663,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); @@ -671,11 +699,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, }); }); @@ -692,11 +720,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, }); }); @@ -710,7 +738,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/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-live.test.ts b/packages/sdk/tests/authored-step-secrets-live.test.ts new file mode 100644 index 000000000..3c504aa9d --- /dev/null +++ b/packages/sdk/tests/authored-step-secrets-live.test.ts @@ -0,0 +1,41 @@ +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:%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'); +}); +`); + 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, + 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; + 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' }); +}, 60_000); 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/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..b9115b748 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,61 @@ 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, + 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, +}) + '\\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', + 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","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: [ + { 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, + 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, 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); + 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-feature-secret-scoping.test.ts b/packages/sdk/tests/communication-feature-secret-scoping.test.ts new file mode 100644 index 000000000..b73a47220 --- /dev/null +++ b/packages/sdk/tests/communication-feature-secret-scoping.test.ts @@ -0,0 +1,53 @@ +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', 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('delivered-provider-key'); + expect(env).not.toHaveProperty('GH_TOKEN'); + 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 9b500bff6..30db94ac6 100644 --- a/packages/sdk/tests/communication-review.test.ts +++ b/packages/sdk/tests/communication-review.test.ts @@ -51,6 +51,100 @@ 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.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.ANTHROPIC_API_KEY).toBe('delivered-provider-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('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 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', + 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/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 5c255c1a7..df95e0867 100644 --- a/packages/sdk/tests/journal-client-step-env.test.ts +++ b/packages/sdk/tests/journal-client-step-env.test.ts @@ -37,3 +37,172 @@ 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('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[] = []; + 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; + 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'); + 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'); + 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 }); + } +}); + +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/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 new file mode 100644 index 000000000..f528f1830 --- /dev/null +++ b/packages/sdk/tests/step-secrets.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest'; +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', + 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 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.GH_TOKEN).toBe('repository-grant'); + 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.GH_TOKEN).toBe('repository-grant'); + }); + + 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(); + }); + + 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'); + }); + + 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.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'); + }); + + 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('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('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(); + expect(() => agentStepEnvironment({ ...source, + RELAY_PLATFORM_AGENT_ENV_NAMES: '{' }, 'claude')).toThrow(); + }); +}); 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 new file mode 100644 index 000000000..42d5fe403 --- /dev/null +++ b/packages/sdk/tests/worker-cli-step-secrets.test.ts @@ -0,0 +1,64 @@ +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, + 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'); +`); + 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_AGENT_TOKEN: 'relay-agent-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); + 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, signalRead: null, platformMarker: null, + relayAgentToken: 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, platformMarker: null, + relayAgentToken: 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, platformMarker: null, + relayAgentToken: 'relay-agent-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/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); 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;