From c9d93aeaa6ea5c8957f88cce393afd35fcd57dd7 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sat, 17 Oct 2026 21:21:11 -0700 Subject: [PATCH 1/4] feat(broker): run-token mode for system-held runs (rt_live_ via RELAY_RUN_TOKEN_FILE) When RELAY_RUN_TOKEN_FILE is set and no rk_ workspace key is configured, the broker runs without any static workspace key: - Registers itself and spawned workers via POST /v1/agents, mints its node via POST /v1/nodes, and releases workers via DELETE /v1/agents/:name, all with the run token read from the file on every request; a 401 re-reads the file and retries once. - Never creates a workspace (fresh-workspace path and create_workspace fail closed whenever a run token file is configured). - Avoids GET /v1/agents/:name with the run token: channel-scope and presence lookups use agent tokens; name collisions fail closed instead of takeover. - Workers get only their own at_live_ token: no workspace key, run token, or RELAY_RUN_TOKEN_FILE in worker env or MCP config. harness-driver: RuntimeSpawnOptions.runTokenFile (or RELAY_RUN_TOKEN_FILE) passes only the file path to the broker and strips RELAY_API_KEY, RELAY_WORKSPACE_KEY, AGENT_RELAY_WORKSPACE_KEY and RELAY_WORKSPACES_JSON. Legacy workspace-key behavior is unchanged. Redaction helpers (Rust and TS) now cover rt_live_. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 11 +- crates/broker/src/node_control.rs | 172 +++++- crates/broker/src/redact.rs | 15 +- crates/broker/src/relaycast/auth.rs | 482 ++++++++++++++++ crates/broker/src/relaycast/mod.rs | 1 + crates/broker/src/relaycast/run_token.rs | 416 ++++++++++++++ crates/broker/src/relaycast/workspace.rs | 23 +- crates/broker/src/relaycast/ws.rs | 514 +++++++++++++++++- crates/broker/src/runtime/fleet.rs | 4 +- crates/broker/src/runtime/init.rs | 115 +++- crates/broker/src/runtime/mod.rs | 2 +- crates/broker/src/runtime/session.rs | 4 + crates/broker/src/spawner.rs | 71 ++- crates/broker/src/worker.rs | 76 +++ crates/relay-pty/src/credentials.rs | 3 + packages/cli/src/cli/lib/redact.test.ts | 1 + packages/cli/src/cli/lib/redact.ts | 2 +- packages/cloud/src/redact.test.ts | 6 + packages/cloud/src/redact.ts | 2 +- .../harness-driver/src/spawn-config.test.ts | 81 +++ packages/harness-driver/src/spawn-config.ts | 45 +- 21 files changed, 1975 insertions(+), 71 deletions(-) create mode 100644 crates/broker/src/relaycast/run_token.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index cd3e446790..5b5bce4fa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,16 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Minor] + +### Added + +- Broker run-token mode: with `RELAY_RUN_TOKEN_FILE` set and no `rk_` workspace key, the broker registers itself and its workers, mints its node, and releases workers with a short-lived Relaycast run token (`rt_live_`) that it reads from the file on each request. It never creates a workspace in this mode, and workers get only their own agent token. +- `HarnessDriverClient.spawn({ runTokenFile })` (or `RELAY_RUN_TOKEN_FILE`) starts the broker in run-token mode and passes it the file path only, with no workspace-key variables. + +### Security + +- Spawned workers no longer inherit `RELAY_RUN_TOKEN_FILE`, and log redaction now masks `rt_live_` run tokens. ## [14.0.0] - 2026-10-13 diff --git a/crates/broker/src/node_control.rs b/crates/broker/src/node_control.rs index 7bd58aba1e..89d7070f0e 100644 --- a/crates/broker/src/node_control.rs +++ b/crates/broker/src/node_control.rs @@ -147,6 +147,10 @@ pub(crate) struct FleetControlConfig { #[derive(Clone)] pub(crate) struct NodeTokenMinter { pub(crate) workspace_key: String, + /// Run-token mode: `workspace_key` is empty and `POST /v1/nodes` is + /// authenticated with the run token read from this file per mint (one + /// re-read and retry on 401). + pub(crate) run_token: Option, pub(crate) workspace_id: String, pub(crate) base_url: Option, pub(crate) node_id: String, @@ -195,19 +199,54 @@ impl NodeTokenMinter { idempotency_key: Option<&str>, ) -> std::result::Result { let request = create_node_request(&self.node_id, &self.node_name, &self.broker_version); - match mint_node_token( - &self.workspace_key, - self.base_url.as_deref(), - request, - current_node_token, - idempotency_key, - MintNodeTokenLogContext { - node_id: &self.node_id, - workspace_id: &self.workspace_id, - }, - ) - .await - { + let context = MintNodeTokenLogContext { + node_id: &self.node_id, + workspace_id: &self.workspace_id, + }; + let minted = match self.run_token.as_ref() { + Some(source) => crate::relaycast::run_token::call_with_run_token( + source, + |error: &CreateNodeMintError| error.status() == Some(401), + |token| { + let request = request.clone(); + async move { + mint_node_token( + &token, + self.base_url.as_deref(), + request, + current_node_token, + idempotency_key, + context, + ) + .await + } + }, + ) + .await + .map_err(|error| match error { + crate::relaycast::run_token::RunTokenCallError::Call(error) => error, + crate::relaycast::run_token::RunTokenCallError::Read(error) => { + CreateNodeMintError::Api { + status: 401, + code: "run_token_unavailable".to_string(), + message: format!("run token unavailable: {error:#}"), + body: String::new(), + } + } + }), + None => { + mint_node_token( + &self.workspace_key, + self.base_url.as_deref(), + request, + current_node_token, + idempotency_key, + context, + ) + .await + } + }; + match minted { Ok(token) => { tracing::info!( target = "relay_broker::fleet", @@ -436,6 +475,7 @@ fn is_node_token_proof_conflict(error: &CreateNodeMintError) -> bool { error.status() == Some(409) && error.code() == Some("node_token_proof_required") } +#[derive(Clone, Copy)] pub(crate) struct MintNodeTokenLogContext<'a> { pub(crate) node_id: &'a str, pub(crate) workspace_id: &'a str, @@ -647,7 +687,7 @@ fn redact_relaycast_secrets(input: &str) -> String { let mut redacted = input.to_string(); let bearer_re = BEARER_SECRET_RE.get_or_init(|| { - regex::Regex::new(r#"(rk_live_|at_live_|nt_live_|br_|Bearer\s+)[A-Za-z0-9._-]+"#) + regex::Regex::new(r#"(rk_live_|rt_live_|at_live_|nt_live_|br_|Bearer\s+)[A-Za-z0-9._-]+"#) .expect("bearer secret redaction regex must compile") }); redacted = bearer_re @@ -8167,6 +8207,7 @@ mod tests { broker_version: "broker/test".to_string(), token_minter: Some(NodeTokenMinter { workspace_key: "rk_live_test".to_string(), + run_token: None, workspace_id: "ws_test".to_string(), base_url: Some(mint_server.base_url()), node_id: "node-test".to_string(), @@ -8251,6 +8292,7 @@ mod tests { broker_version: "broker/test".to_string(), token_minter: Some(NodeTokenMinter { workspace_key: "rk_live_test".to_string(), + run_token: None, workspace_id: "ws_test".to_string(), base_url: Some(mint_server.base_url()), node_id: "node-test".to_string(), @@ -8339,6 +8381,7 @@ mod tests { broker_version: "broker/test".to_string(), token_minter: Some(NodeTokenMinter { workspace_key: "rk_live_test".to_string(), + run_token: None, workspace_id: "ws_test".to_string(), base_url: Some(mint_server.base_url()), node_id: "node-test".to_string(), @@ -8450,6 +8493,7 @@ mod tests { broker_version: "broker/test".to_string(), token_minter: Some(NodeTokenMinter { workspace_key: "rk_live_test".to_string(), + run_token: None, workspace_id: "ws_test".to_string(), base_url: Some(mint_server.base_url()), node_id: "node-test".to_string(), @@ -8590,6 +8634,7 @@ mod tests { broker_version: "broker/test".to_string(), token_minter: Some(NodeTokenMinter { workspace_key: "rk_live_test".to_string(), + run_token: None, workspace_id: "ws_test".to_string(), base_url: Some(mint_base_url.clone()), node_id: "node-test".to_string(), @@ -8705,6 +8750,7 @@ mod tests { .unwrap(); let minter = NodeTokenMinter { workspace_key: "rk_live_test".to_string(), + run_token: None, workspace_id: "ws_test".to_string(), base_url: Some(mint_base_url.clone()), node_id: "node-test".to_string(), @@ -8773,6 +8819,7 @@ mod tests { .unwrap(); let minter = NodeTokenMinter { workspace_key: "rk_live_test".to_string(), + run_token: None, workspace_id: "ws_test".to_string(), base_url: Some(mint_base_url.clone()), node_id: "node-test".to_string(), @@ -8833,6 +8880,7 @@ mod tests { .unwrap(); let minter = NodeTokenMinter { workspace_key: "rk_live_test".to_string(), + run_token: None, workspace_id: "ws_test".to_string(), base_url: Some(mint_base_url.clone()), node_id: "node-test".to_string(), @@ -8889,6 +8937,7 @@ mod tests { .unwrap(); let minter = NodeTokenMinter { workspace_key: "rk_live_test".to_string(), + run_token: None, workspace_id: "ws_test".to_string(), base_url: Some(mint_base_url.clone()), node_id: "node-test".to_string(), @@ -10923,3 +10972,98 @@ mod tests { #[cfg(test)] mod registration_tests; + +#[cfg(test)] +mod run_token_mint_tests { + use httpmock::{Method::POST, MockServer}; + use serde_json::json; + + use super::NodeTokenMinter; + use crate::relaycast::run_token::{ + test_support::{future_expiry, write_run_token_file}, + RunTokenSource, + }; + + fn node_created(token: &str) -> serde_json::Value { + json!({ + "ok": true, + "data": { + "id": "node_run", "name": "run-node", "kind": "ws", "role": "broker", + "version": "relay-broker/test", "status": "offline", "live": false, + "handlers_live": false, "load": null, "active_agents": 0, "max_agents": 0, + "created_at": "2026-10-17T00:00:00Z", "token": token + } + }) + } + + fn run_token_minter(base_url: String, source: RunTokenSource) -> NodeTokenMinter { + NodeTokenMinter { + // Run-token mode: no workspace key at all. + workspace_key: String::new(), + run_token: Some(source), + workspace_id: "ws_run".to_string(), + base_url: Some(base_url), + node_id: "node_run".to_string(), + node_name: "run-node".to_string(), + broker_version: "relay-broker/test".to_string(), + token_path: None, + adopt_cached_token_after_conflict: true, + } + } + + #[tokio::test] + async fn node_is_minted_with_the_current_run_token() { + let server = MockServer::start(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("relaycast-run.json"); + write_run_token_file(&path, "rt_live_mint_one", &future_expiry()); + let minter = run_token_minter(server.base_url(), RunTokenSource::new(&path)); + + let first = server.mock(|when, then| { + when.method(POST) + .path("/v1/nodes") + .header("authorization", "Bearer rt_live_mint_one"); + then.status(201).json_body(node_created("nt_live_first")); + }); + assert_eq!(minter.mint(None).await.unwrap(), "nt_live_first"); + first.assert_hits(1); + + // A rotated file is picked up by the very next mint. + write_run_token_file(&path, "rt_live_mint_two", &future_expiry()); + let second = server.mock(|when, then| { + when.method(POST) + .path("/v1/nodes") + .header("authorization", "Bearer rt_live_mint_two"); + then.status(201).json_body(node_created("nt_live_second")); + }); + assert_eq!(minter.mint(None).await.unwrap(), "nt_live_second"); + second.assert_hits(1); + first.assert_hits(1); + } + + #[tokio::test] + async fn a_rejected_run_token_is_retried_once_then_surfaces_401() { + let server = MockServer::start(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("relaycast-run.json"); + write_run_token_file(&path, "rt_live_revoked", &future_expiry()); + let minter = run_token_minter(server.base_url(), RunTokenSource::new(&path)); + + let rejected = server.mock(|when, then| { + when.method(POST).path("/v1/nodes"); + then.status(401).json_body(json!({"ok": false, "error": { + "code": "run_authority_revoked", "message": "revoked"}})); + }); + let error = minter.mint(None).await.unwrap_err(); + assert_eq!(error.status(), Some(401)); + assert!(!error.to_string().contains("rt_live_revoked")); + rejected.assert_hits(2); + } + + #[test] + fn mint_error_redaction_covers_run_tokens() { + let redacted = super::redact_relaycast_secrets("echo rt_live_abc.def and rk_live_x"); + assert!(!redacted.contains("abc.def"), "{redacted}"); + assert!(!redacted.contains("rk_live_x"), "{redacted}"); + } +} diff --git a/crates/broker/src/redact.rs b/crates/broker/src/redact.rs index d78e9c3001..7daa4fa199 100644 --- a/crates/broker/src/redact.rs +++ b/crates/broker/src/redact.rs @@ -2,8 +2,11 @@ use std::sync::LazyLock; use regex::Regex; -static PATTERNS: LazyLock<[Regex; 3]> = LazyLock::new(|| { +static PATTERNS: LazyLock<[Regex; 4]> = LazyLock::new(|| { [ + // Bare Relaycast credentials, wherever they appear: workspace keys, + // run tokens, agent tokens, and node tokens. + Regex::new(r#"((?:rk|rt|at|nt)_(?:live|test)_)[A-Za-z0-9._-]+"#).expect("valid regex"), Regex::new(r#"(?i)(api[_-]?key\s*[:=]\s*)([^\s"']+)"#).expect("valid regex"), Regex::new(r#"(?i)(token\s*[:=]\s*)([^\s"']+)"#).expect("valid regex"), Regex::new(r#"(?i)(authorization:\s*bearer\s+)([^\s]+)"#).expect("valid regex"), @@ -34,4 +37,14 @@ mod tests { assert!(!redacted.contains("abc")); assert!(!redacted.contains("xyz")); } + + #[test] + fn redacts_bare_relaycast_credentials_including_run_tokens() { + let line = "using rt_live_abc123 and rk_live_def456 then at_live_x.y nt_live_z"; + let redacted = redact(line); + for secret in ["abc123", "def456", "x.y", "nt_live_z"] { + assert!(!redacted.contains(secret), "{redacted}"); + } + assert!(redacted.contains("rt_live_[REDACTED]"), "{redacted}"); + } } diff --git a/crates/broker/src/relaycast/auth.rs b/crates/broker/src/relaycast/auth.rs index 3f76316c2d..fa6e1514c5 100644 --- a/crates/broker/src/relaycast/auth.rs +++ b/crates/broker/src/relaycast/auth.rs @@ -11,6 +11,10 @@ use serde_json::Value; use sha2::{Digest, Sha256}; use uuid::Uuid; +use super::run_token::{ + relay_error_is_unauthorized, RunTokenSource, RUN_TOKEN_NO_WORKSPACE_CREATION, +}; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkspaceCredential { pub workspace_id: String, @@ -47,6 +51,10 @@ pub struct CredentialSet { pub struct AuthSessionSet { pub memberships: Vec, pub default_workspace_id: Option, + /// Set in run-token mode: the session holds no workspace key + /// (`credentials.api_key` is empty) and run-token-capable Relaycast calls + /// read the token from this file per request. + pub run_token: Option, } #[derive(Debug, Clone)] @@ -403,6 +411,18 @@ impl AuthClient { waiter_id: Option<&str>, startup_deadline: Option, ) -> Result { + if let Some(source) = RunTokenSource::from_env() { + return self + .startup_run_token_session_set( + &source, + requested_name, + agent_type, + identity_key, + waiter_id, + startup_deadline, + ) + .await; + } if let Some((sources, default_hint)) = self.load_workspace_sources_from_env()? { let preferred_name = requested_name; // Validate every source before starting requests so malformed @@ -522,6 +542,7 @@ impl AuthClient { &memberships, ), memberships, + run_token: None, }); } @@ -682,6 +703,7 @@ impl AuthClient { return Ok(AuthSessionSet { default_workspace_id: Some(session.credentials.workspace_id.clone()), memberships: vec![session], + run_token: None, }); } Err(error) if is_workspace_busy_anyhow(&error) => { @@ -747,6 +769,9 @@ impl AuthClient { waiter_id: Option<&str>, startup_deadline: Option, ) -> Result { + if RunTokenSource::configured_path_from_env().is_some() { + anyhow::bail!(RUN_TOKEN_NO_WORKSPACE_CREATION); + } let ws_name = deterministic_workspace_name(); let (workspace_id, api_key) = match startup_deadline { Some(deadline) => tokio::time::timeout_at(deadline, self.create_workspace(&ws_name)) @@ -796,6 +821,54 @@ impl AuthClient { Ok(AuthSessionSet { default_workspace_id: Some(session.credentials.workspace_id.clone()), memberships: vec![session], + run_token: None, + }) + } + + /// Run-token mode startup: register the broker itself via + /// `POST /v1/agents` with the run token read from `source`. No workspace + /// key is involved, no workspace is ever created, and a name collision + /// fails closed because the reclaim path (`GET /v1/agents/:name` plus + /// takeover) is not open to run tokens. + pub(crate) async fn startup_run_token_session_set( + &self, + source: &RunTokenSource, + requested_name: Option<&str>, + agent_type: Option<&str>, + identity_key: Option<&str>, + waiter_id: Option<&str>, + startup_deadline: Option, + ) -> Result { + let name = requested_name + .map(ToOwned::to_owned) + .unwrap_or_else(|| format!("agent-{}", Uuid::new_v4().simple())); + tracing::info!( + target = "relay_broker::auth", + run_token_file = %source.path().display(), + preferred_name = %name, + "attempting registration with run token (run-token mode)" + ); + let registration = register_agent_with_run_token( + self.base_url.as_deref(), + source, + &name, + agent_type, + identity_key, + waiter_id, + startup_deadline, + ) + .await?; + let workspace_id_hint = std::env::var("RELAY_WORKSPACE_ID") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + // `api_key` stays empty: run-token mode holds no workspace key, and an + // empty key keeps every legacy key-injection path inert. + let session = self.finish_session(String::new(), workspace_id_hint, registration)?; + Ok(AuthSessionSet { + default_workspace_id: Some(session.credentials.workspace_id.clone()), + memberships: vec![session], + run_token: Some(source.clone()), }) } @@ -887,6 +960,12 @@ impl AuthClient { } async fn create_workspace(&self, name: &str) -> Result<(String, String)> { + // Defense in depth for the guard in + // `startup_fresh_workspace_session_set`: no path may mint a workspace + // while a run token is configured. + if RunTokenSource::configured_path_from_env().is_some() { + anyhow::bail!(RUN_TOKEN_NO_WORKSPACE_CREATION); + } // Workspace creation is an unkeyed POST, so a 5xx is ambiguous: // Relaycast may have committed the workspace before failing to answer. // Replaying is still right — `workspace_storage_unavailable` is a @@ -1864,6 +1943,123 @@ async fn admit_agent_registration( } } +/// Register `name` via `POST /v1/agents` authenticated with the run token. +/// +/// Reads the token from `source` per attempt and, on a 401, re-reads the file +/// and retries once. Transient/`workspace_busy` retries reuse the same typed +/// startup budget as the legacy path. A 409 is terminal: run tokens cannot +/// read (`GET /v1/agents/:name`) or take over an existing agent, so there is +/// no safe reclaim and the broker fails closed. +async fn register_agent_with_run_token( + base_url: Option<&str>, + source: &RunTokenSource, + name: &str, + agent_type: Option<&str>, + identity_key: Option<&str>, + waiter_id: Option<&str>, + startup_deadline: Option, +) -> Result<(String, String, String, Option, bool)> { + let metadata = identity_key.map(|key| { + let mut map = serde_json::Map::new(); + map.insert( + IDENTITY_METADATA_KEY.to_string(), + Value::String(hash_identity_key(key)), + ); + map + }); + let request = CreateAgentRequest { + name: name.to_string(), + agent_type: Some(agent_type.unwrap_or("agent").to_string()), + persona: None, + metadata, + }; + let waiter_id = waiter_id + .map(str::to_owned) + .unwrap_or_else(|| format!("relay-register:{}", Uuid::new_v4())); + + let result = + super::run_token::call_with_run_token(source, relay_error_is_unauthorized, |token| { + let request = &request; + let waiter_id = &waiter_id; + async move { + let client = build_relay_client_with_bearer(&token, base_url)?; + let http = client.http_client(); + retry_typed_startup_registration_error( + "registering the broker agent with the run token", + startup_deadline, + || { + http.post::( + "/v1/agents", + Some(request), + Some(relaycast::RequestOptions { + headers: Some(vec![( + "X-Workspace-Write-Waiter".to_string(), + waiter_id.clone(), + )]), + idempotency_key: None, + }), + ) + }, + ) + .await + } + }) + .await; + + match result { + Ok(response) if response.token.trim().is_empty() => { + anyhow::bail!("run-token registration for '{name}' returned a blank agent token") + } + Ok(response) => Ok(( + response.id, + response.name, + response.token, + response.workspace_id, + true, + )), + Err(super::run_token::RunTokenCallError::Read(error)) => { + Err(error).context("run-token mode could not read the run token file") + } + Err(super::run_token::RunTokenCallError::Call(RelayError::Api { + code, + status, + request_id, + attempts, + .. + })) if is_conflict_code(&code) || status == 409 => { + Err(relay_error_to_anyhow(RelayError::Api { + code: "agent_identity_mismatch".to_string(), + status: 409, + retry_after_ms: None, + message: format!( + "agent name '{name}' is already registered and run-token mode cannot look up \ + or take over an existing agent (GET /v1/agents/:name and takeover are not \ + open to run tokens); use a name unique to this run" + ), + request_id, + attempts, + })) + } + Err(super::run_token::RunTokenCallError::Call(error)) => Err(relay_error_to_anyhow(error)) + .context("registering the broker agent with the run token failed"), + } +} + +/// An SDK agent-scoped client whose bearer is `bearer`. Used for run-token +/// requests, where the bearer is read fresh per request and never stored. +pub(crate) fn build_relay_client_with_bearer( + bearer: &str, + base_url: Option<&str>, +) -> std::result::Result { + let mut opts = RelayCastOptions::new(bearer) + .with_base_url(resolve_relaycast_base_url(base_url)) + .with_origin_actor(crate::telemetry::BROKER_ORIGIN_ACTOR); + if let Some(distinct_id) = crate::telemetry::agent_relay_distinct_id() { + opts = opts.with_agent_relay_distinct_id(distinct_id); + } + RelayCast::new(opts)?.as_agent(bearer) +} + /// Outcome of a successful [`reclaim_legacy_identity`] call, returned as a /// value (not just formatted text) so CLI output and tests can assert on it /// precisely. @@ -2314,6 +2510,8 @@ mod tests { std::env::remove_var("RELAY_DEFAULT_WORKSPACE"); std::env::remove_var("RELAY_AGENT_IDENTITY_KEY"); std::env::remove_var("RELAY_NODE_ID"); + std::env::remove_var("RELAY_RUN_TOKEN_FILE"); + std::env::remove_var("RELAY_WORKSPACE_ID"); } guard } @@ -5522,4 +5720,288 @@ mod tests { std::env::remove_var("RELAY_API_KEY"); } } + + // ── Run-token mode (RELAY_RUN_TOKEN_FILE, no rk_ key) ────────────────── + + /// Removes run-token env on drop so other test modules never observe it. + struct RunTokenEnvGuard(std::path::PathBuf); + + impl Drop for RunTokenEnvGuard { + fn drop(&mut self) { + unsafe { + std::env::remove_var("RELAY_RUN_TOKEN_FILE"); + std::env::remove_var("RELAY_WORKSPACE_ID"); + std::env::remove_var("AGENT_RELAY_WORKSPACE_KEY"); + } + } + } + + fn set_run_token_file(dir: &tempfile::TempDir, token: &str) -> RunTokenEnvGuard { + let path = dir.path().join("relaycast-run.json"); + crate::relaycast::run_token::test_support::write_run_token_file( + &path, + token, + &crate::relaycast::run_token::test_support::future_expiry(), + ); + unsafe { + std::env::set_var("RELAY_RUN_TOKEN_FILE", &path); + } + RunTokenEnvGuard(path) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn run_token_mode_registers_the_broker_and_never_creates_a_workspace() { + let _env_guard = clear_relay_env(); + let dir = tempfile::tempdir().unwrap(); + let _run_token_env = set_run_token_file(&dir, "rt_live_startup"); + unsafe { + std::env::set_var("RELAY_WORKSPACE_ID", "ws_run"); + } + let server = MockServer::start(); + let workspace = server.mock(|when, then| { + when.method(POST).path("/v1/workspaces"); + then.status(200).header("content-type", "application/json").body( + r#"{"ok":true,"data":{"workspace_id":"ws_bad","api_key":"rk_live_bad","created_at":"2025-01-01T00:00:00Z"}}"#, + ); + }); + let register = server.mock(|when, then| { + when.method(POST) + .path("/v1/agents") + .header("authorization", "Bearer rt_live_startup"); + then.status(201).header("content-type", "application/json").body( + r#"{"ok":true,"data":{"id":"a_rt","workspace_id":"ws_run","run_id":"run_1","name":"lead","token":"at_live_rt","status":"online","created_at":"2025-01-01T00:00:00Z"}}"#, + ); + }); + + let sessions = AuthClient::new(Some(server.base_url())) + .startup_session_set(Some("lead")) + .await + .expect("run-token startup registers with the run token"); + + assert!(sessions.run_token.is_some()); + let session = sessions.default_session().unwrap(); + assert_eq!(session.token, "at_live_rt"); + assert_eq!(session.credentials.workspace_id, "ws_run"); + assert!( + session.credentials.api_key.is_empty(), + "run-token mode must hold no workspace key" + ); + register.assert_hits(1); + workspace.assert_hits(0); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn run_token_mode_rereads_the_file_after_a_401_and_retries_once() { + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + + use axum::{ + extract::State, + http::{HeaderMap, StatusCode as AxumStatusCode}, + routing::post, + Json, Router, + }; + + #[derive(Clone)] + struct RotatingState { + path: std::path::PathBuf, + bearers: Arc>>, + workspace_attempts: Arc, + } + + async fn register( + State(state): State, + headers: HeaderMap, + ) -> (AxumStatusCode, Json) { + let bearer = headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + state.bearers.lock().unwrap().push(bearer.clone()); + if bearer == "Bearer rt_live_old" { + // Cloud rotated the token after the broker read the file. + crate::relaycast::run_token::test_support::write_run_token_file( + &state.path, + "rt_live_new", + &crate::relaycast::run_token::test_support::future_expiry(), + ); + return ( + AxumStatusCode::UNAUTHORIZED, + Json( + json!({"ok": false, "error": {"code": "run_token_expired", "message": "expired"}}), + ), + ); + } + ( + AxumStatusCode::CREATED, + Json(json!({"ok": true, "data": { + "id": "a_rt", "workspace_id": "ws_run", "run_id": "run_1", + "name": "lead", "token": "at_live_rt", "status": "online", + "created_at": "2025-01-01T00:00:00Z" + }})), + ) + } + + async fn create_workspace(State(state): State) -> AxumStatusCode { + state.workspace_attempts.fetch_add(1, Ordering::SeqCst); + AxumStatusCode::INTERNAL_SERVER_ERROR + } + + let _env_guard = clear_relay_env(); + let dir = tempfile::tempdir().unwrap(); + let run_token_env = set_run_token_file(&dir, "rt_live_old"); + let state = RotatingState { + path: run_token_env.0.clone(), + bearers: Arc::new(std::sync::Mutex::new(Vec::new())), + workspace_attempts: Arc::new(AtomicUsize::new(0)), + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server_state = state.clone(); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new() + .route("/v1/agents", post(register)) + .route("/v1/workspaces", post(create_workspace)) + .with_state(server_state), + ) + .await + }); + + let session = AuthClient::new(Some(format!("http://{address}"))) + .startup_session(Some("lead")) + .await + .expect("a 401 re-reads the rotated file and succeeds"); + assert_eq!(session.token, "at_live_rt"); + assert_eq!( + *state.bearers.lock().unwrap(), + vec![ + "Bearer rt_live_old".to_string(), + "Bearer rt_live_new".to_string() + ] + ); + assert_eq!(state.workspace_attempts.load(Ordering::SeqCst), 0); + server.abort(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn run_token_mode_rejected_token_fails_closed_without_creating_a_workspace() { + let _env_guard = clear_relay_env(); + let dir = tempfile::tempdir().unwrap(); + let _run_token_env = set_run_token_file(&dir, "rt_live_revoked"); + let server = MockServer::start(); + let workspace = server.mock(|when, then| { + when.method(POST).path("/v1/workspaces"); + then.status(200).header("content-type", "application/json").body( + r#"{"ok":true,"data":{"workspace_id":"ws_bad","api_key":"rk_live_bad","created_at":"2025-01-01T00:00:00Z"}}"#, + ); + }); + let register = + server.mock(|when, then| { + when.method(POST).path("/v1/agents"); + then.status(401).header("content-type", "application/json").body( + r#"{"ok":false,"error":{"code":"run_authority_revoked","message":"revoked"}}"#, + ); + }); + + let error = AuthClient::new(Some(server.base_url())) + .startup_session(Some("lead")) + .await + .unwrap_err(); + let message = format!("{error:#}"); + assert!(!message.contains("rt_live_revoked"), "{message}"); + // One request plus exactly one re-read retry. + register.assert_hits(2); + workspace.assert_hits(0); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn run_token_mode_fresh_workspace_path_is_refused() { + let _env_guard = clear_relay_env(); + let dir = tempfile::tempdir().unwrap(); + let _run_token_env = set_run_token_file(&dir, "rt_live_any"); + let server = MockServer::start(); + let workspace = server.mock(|when, then| { + when.method(POST).path("/v1/workspaces"); + then.status(200).header("content-type", "application/json").body( + r#"{"ok":true,"data":{"workspace_id":"ws_bad","api_key":"rk_live_bad","created_at":"2025-01-01T00:00:00Z"}}"#, + ); + }); + + let error = AuthClient::new(Some(server.base_url())) + .startup_fresh_workspace_session_set(Some("lead"), false, None, None, None, None) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("refusing to create a Relaycast workspace"), + "{error:#}" + ); + workspace.assert_hits(0); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn run_token_mode_name_conflict_fails_closed_without_agent_lookup() { + let _env_guard = clear_relay_env(); + let dir = tempfile::tempdir().unwrap(); + let _run_token_env = set_run_token_file(&dir, "rt_live_cur"); + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/agents"); + then.status(409) + .header("content-type", "application/json") + .body(r#"{"ok":false,"error":{"code":"agent_already_exists","message":"taken"}}"#); + }); + let lookup = server.mock(|when, then| { + when.method(GET).path("/v1/agents/lead"); + then.status(200).header("content-type", "application/json").body( + r#"{"ok":true,"data":{"id":"a","name":"lead","status":"offline","metadata":{}}}"#, + ); + }); + + let error = AuthClient::new(Some(server.base_url())) + .startup_session(Some("lead")) + .await + .unwrap_err(); + assert!( + format!("{error:#}").contains("run-token mode cannot look up"), + "{error:#}" + ); + lookup.assert_hits(0); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn rk_workspace_key_keeps_legacy_mode_even_with_a_run_token_file() { + let _env_guard = clear_relay_env(); + let dir = tempfile::tempdir().unwrap(); + let _run_token_env = set_run_token_file(&dir, "rt_live_ignored"); + unsafe { + std::env::set_var("AGENT_RELAY_WORKSPACE_KEY", "rk_live_env"); + } + let server = MockServer::start(); + let legacy = server.mock(|when, then| { + when.method(POST) + .path("/v1/agents") + .header("authorization", "Bearer rk_live_env"); + then.status(200).header("content-type", "application/json").body( + r#"{"ok":true,"data":{"id":"a2","workspace_id":"ws_env","name":"lead","token":"at_live_2","status":"online","created_at":"2025-01-01T00:00:00Z"}}"#, + ); + }); + + let sessions = AuthClient::new(Some(server.base_url())) + .startup_session_set(Some("lead")) + .await + .unwrap(); + assert!(sessions.run_token.is_none()); + assert_eq!( + sessions.default_session().unwrap().credentials.api_key, + "rk_live_env" + ); + legacy.assert_hits(1); + } } diff --git a/crates/broker/src/relaycast/mod.rs b/crates/broker/src/relaycast/mod.rs index 9b8494a879..92760544a0 100644 --- a/crates/broker/src/relaycast/mod.rs +++ b/crates/broker/src/relaycast/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod auth; pub(crate) mod bridge; pub(crate) mod dm_participants; +pub(crate) mod run_token; pub(crate) mod wire; pub(crate) mod workspace; pub(crate) mod ws; diff --git a/crates/broker/src/relaycast/run_token.rs b/crates/broker/src/relaycast/run_token.rs new file mode 100644 index 0000000000..cf2e6964d7 --- /dev/null +++ b/crates/broker/src/relaycast/run_token.rs @@ -0,0 +1,416 @@ +//! Run-scoped Relaycast credential (`rt_live_…`, "run token"). +//! +//! A system-held flow sandbox gets no static workspace key (`rk_live_…`). +//! Instead the launcher (Cloud) points `RELAY_RUN_TOKEN_FILE` at an +//! access-only JSON file `{"token":"rt_live_…","expiresAt":""}` and +//! atomically replaces it before the token expires. +//! +//! Contract for this module (relay-briefs/run-token-contract.md §2, §4): +//! +//! - The file is read on **every** request. The token value is never cached +//! beyond one request and never copied into env, argv, or other files. +//! - A 401 re-reads the file and retries exactly once (the launcher may have +//! rotated the token between our read and the server's check). +//! - Relaycast accepts a run token only on `POST /v1/agents`, +//! `DELETE /v1/agents/:name`, and `POST /v1/nodes` (relaycast #559). Every +//! other route, including `GET /v1/agents/:name` and the WebSocket +//! upgrades, rejects it with `401 run_token_forbidden`. +//! - Run-token mode never creates a workspace; there is no `rk_` key to fall +//! back to and minting one would escape the run's authority. +//! +//! The token value never appears in `Debug` output or error messages. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +/// Env var naming the run token access file. +pub const RUN_TOKEN_FILE_ENV: &str = "RELAY_RUN_TOKEN_FILE"; + +/// Every Relaycast run token kind starts with this prefix (`rt_live_`, +/// `rt_test_`). +pub const RUN_TOKEN_PREFIX: &str = "rt_"; + +/// Static workspace credentials whose presence (with an `rk_` value) keeps the +/// broker in legacy workspace-key mode, and which must never be handed to +/// workers in run-token mode. +pub const WORKSPACE_KEY_ENV_VARS: [&str; 4] = [ + "AGENT_RELAY_WORKSPACE_KEY", + "RELAY_WORKSPACE_KEY", + "RELAY_API_KEY", + "RELAY_WORKSPACES_JSON", +]; + +/// Non-secret bearer the broker's workspace-scoped SDK client is built with in +/// run-token mode. The SDK requires a non-empty key; run-token mode routes the +/// run-token-capable endpoints explicitly through [`RunTokenSource`] and the +/// rest through agent tokens, so any workspace-scoped call that still reaches +/// the server with this placeholder fails as a plain 401 without disclosing a +/// credential. +pub const RUN_TOKEN_MODE_PLACEHOLDER_BEARER: &str = "relay-run-token-mode-no-workspace-key"; + +/// Error text returned whenever run-token mode would otherwise mint a +/// workspace. +pub const RUN_TOKEN_NO_WORKSPACE_CREATION: &str = + "refusing to create a Relaycast workspace: RELAY_RUN_TOKEN_FILE is set (run-token mode). \ + Run-token mode joins the run's existing workspace only; check that the launcher wrote a \ + valid run token file"; + +/// Location of the run token access file. Holds only the path, never the +/// token value, so it is safe to clone, store, and debug-print. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RunTokenSource { + path: PathBuf, +} + +/// One read of the run token file. Lives for a single request. +pub struct RunToken { + token: String, + expires_at: Option>, +} + +impl std::fmt::Debug for RunToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RunToken") + .field("token", &"rt_[REDACTED]") + .field("expires_at", &self.expires_at) + .finish() + } +} + +impl RunToken { + /// The bearer value. Use it for exactly one request and drop it. + pub fn secret(&self) -> &str { + &self.token + } + + pub fn expires_at(&self) -> Option> { + self.expires_at + } +} + +#[derive(Deserialize)] +struct RunTokenFile { + token: Option, + #[serde(default, alias = "expires_at")] + #[serde(rename = "expiresAt")] + expires_at: Option, +} + +/// True when `raw` holds a static Relaycast workspace key. +fn holds_workspace_key(raw: &str) -> bool { + let trimmed = raw.trim(); + if trimmed.starts_with("rk_") { + return true; + } + // RELAY_WORKSPACES_JSON embeds keys inside JSON. + trimmed.contains("\"rk_") +} + +impl RunTokenSource { + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// The configured run token file path, if any, regardless of whether a + /// workspace key is also present. Used by the fail-closed workspace + /// creation guard: when a launcher configured a run token, the broker must + /// never mint a workspace, even if it ends up in legacy mode. + pub fn configured_path_from_env() -> Option { + std::env::var(RUN_TOKEN_FILE_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + } + + /// Run-token mode is active when `RELAY_RUN_TOKEN_FILE` is set and no + /// static `rk_` workspace key is configured. A configured `rk_` key keeps + /// the legacy path unchanged. + pub fn from_env() -> Option { + let path = Self::configured_path_from_env()?; + let has_workspace_key = WORKSPACE_KEY_ENV_VARS.iter().any(|name| { + std::env::var(name) + .ok() + .is_some_and(|value| holds_workspace_key(&value)) + }); + if has_workspace_key { + tracing::warn!( + target = "relay_broker::auth", + "RELAY_RUN_TOKEN_FILE is set but a static workspace key is also configured; \ + staying in legacy workspace-key mode" + ); + return None; + } + Some(Self::new(path)) + } + + /// Read and validate the file. Called once per request; the returned value + /// must not outlive that request. + pub fn read(&self) -> Result { + let raw = std::fs::read_to_string(&self.path) + .with_context(|| format!("failed to read run token file {}", self.path.display()))?; + // Deliberately discard serde's detail: its messages can quote input. + let parsed: RunTokenFile = serde_json::from_str(&raw).map_err(|_| { + anyhow::anyhow!( + "run token file {} is not valid JSON of the form {{\"token\":\"rt_live_...\",\"expiresAt\":\"\"}}", + self.path.display() + ) + })?; + let token = parsed + .token + .map(|token| token.trim().to_string()) + .filter(|token| !token.is_empty()) + .with_context(|| format!("run token file {} has no token", self.path.display()))?; + anyhow::ensure!( + token.starts_with(RUN_TOKEN_PREFIX), + "run token file {} does not hold a Relaycast run token (expected an rt_ prefix)", + self.path.display() + ); + let expires_at = match parsed + .expires_at + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + Some(value) => Some( + DateTime::parse_from_rfc3339(&value) + .with_context(|| { + format!( + "run token file {} has an invalid expiresAt (expected ISO8601)", + self.path.display() + ) + })? + .with_timezone(&Utc), + ), + None => None, + }; + Ok(RunToken { token, expires_at }) + } + + /// [`Self::read`], refusing a token the file itself reports as expired: + /// sending it would only earn a 401, and the clear error tells an operator + /// the launcher stopped refreshing. + pub fn read_fresh(&self) -> Result { + let token = self.read()?; + if let Some(expires_at) = token.expires_at { + anyhow::ensure!( + expires_at > Utc::now(), + "run token in {} expired at {}; the launcher has not refreshed it", + self.path.display(), + expires_at.to_rfc3339() + ); + } + Ok(token) + } +} + +/// Failure of a run-token-authenticated call: either the file could not be +/// read, or the request itself failed (after the single 401 retry). +#[derive(Debug)] +pub enum RunTokenCallError { + Read(anyhow::Error), + Call(E), +} + +impl std::fmt::Display for RunTokenCallError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Read(error) => write!(f, "{error:#}"), + Self::Call(error) => write!(f, "{error}"), + } + } +} + +/// Run `op` with a freshly read run token. When `is_unauthorized` classifies +/// the failure as a 401, re-read the file and retry exactly once. +pub async fn call_with_run_token( + source: &RunTokenSource, + is_unauthorized: impl Fn(&E) -> bool, + mut op: F, +) -> std::result::Result> +where + F: FnMut(String) -> Fut, + Fut: std::future::Future>, +{ + let token = source.read_fresh().map_err(RunTokenCallError::Read)?; + match op(token.secret().to_string()).await { + Err(error) if is_unauthorized(&error) => { + drop(token); + tracing::info!( + target = "relay_broker::auth", + path = %source.path().display(), + "run token rejected with 401; re-reading the run token file and retrying once" + ); + let token = source.read_fresh().map_err(RunTokenCallError::Read)?; + op(token.secret().to_string()) + .await + .map_err(RunTokenCallError::Call) + } + other => other.map_err(RunTokenCallError::Call), + } +} + +/// 401 classifier for SDK errors. +pub fn relay_error_is_unauthorized(error: &relaycast::RelayError) -> bool { + matches!(error, relaycast::RelayError::Api { status: 401, .. }) +} + +#[cfg(test)] +pub(crate) mod test_support { + use std::path::Path; + + /// Atomically replace a run token file the way the launcher does + /// (write a temp file, then rename). + pub(crate) fn write_run_token_file(path: &Path, token: &str, expires_at: &str) { + let tmp = path.with_extension("tmp"); + std::fs::write( + &tmp, + serde_json::json!({ "token": token, "expiresAt": expires_at }).to_string(), + ) + .expect("write run token temp file"); + std::fs::rename(&tmp, path).expect("rename run token file"); + } + + pub(crate) fn future_expiry() -> String { + (chrono::Utc::now() + chrono::Duration::minutes(15)).to_rfc3339() + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use super::test_support::{future_expiry, write_run_token_file}; + use super::*; + + #[test] + fn read_returns_the_current_file_contents_every_time() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("relaycast-run.json"); + let source = RunTokenSource::new(&path); + + write_run_token_file(&path, "rt_live_first", &future_expiry()); + assert_eq!(source.read_fresh().unwrap().secret(), "rt_live_first"); + + write_run_token_file(&path, "rt_live_second", &future_expiry()); + assert_eq!(source.read_fresh().unwrap().secret(), "rt_live_second"); + } + + #[test] + fn read_rejects_non_run_tokens_without_echoing_them() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("relaycast-run.json"); + write_run_token_file(&path, "rk_live_should_never_be_here", &future_expiry()); + let error = RunTokenSource::new(&path).read().unwrap_err().to_string(); + assert!(error.contains("rt_ prefix"), "{error}"); + assert!(!error.contains("rk_live_should_never_be_here"), "{error}"); + } + + #[test] + fn read_rejects_malformed_json_without_echoing_input() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("relaycast-run.json"); + std::fs::write(&path, "{\"token\": rt_live_secret_value}").unwrap(); + let error = RunTokenSource::new(&path).read().unwrap_err().to_string(); + assert!(!error.contains("rt_live_secret_value"), "{error}"); + } + + #[test] + fn read_fresh_refuses_an_expired_token() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("relaycast-run.json"); + write_run_token_file(&path, "rt_live_old", "2020-01-01T00:00:00Z"); + let error = RunTokenSource::new(&path) + .read_fresh() + .unwrap_err() + .to_string(); + assert!(error.contains("expired"), "{error}"); + assert!(!error.contains("rt_live_old"), "{error}"); + } + + #[test] + fn debug_output_never_contains_the_token() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("relaycast-run.json"); + write_run_token_file(&path, "rt_live_debug_secret", &future_expiry()); + let token = RunTokenSource::new(&path).read().unwrap(); + assert!(!format!("{token:?}").contains("rt_live_debug_secret")); + } + + #[tokio::test] + async fn a_401_rereads_the_file_and_retries_exactly_once() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("relaycast-run.json"); + write_run_token_file(&path, "rt_live_stale", &future_expiry()); + let source = RunTokenSource::new(&path); + let seen = Arc::new(std::sync::Mutex::new(Vec::::new())); + let calls = Arc::new(AtomicUsize::new(0)); + + let result = call_with_run_token( + &source, + |status: &u16| *status == 401, + |token| { + let seen = Arc::clone(&seen); + let calls = Arc::clone(&calls); + let path = path.clone(); + async move { + seen.lock().unwrap().push(token.clone()); + if calls.fetch_add(1, Ordering::SeqCst) == 0 { + // The launcher rotates the file while the first + // request is in flight. + write_run_token_file(&path, "rt_live_rotated", &future_expiry()); + Err(401u16) + } else { + Ok("done") + } + } + }, + ) + .await; + + assert_eq!(result.unwrap(), "done"); + assert_eq!( + *seen.lock().unwrap(), + vec!["rt_live_stale".to_string(), "rt_live_rotated".to_string()] + ); + } + + #[tokio::test] + async fn a_second_401_is_returned_without_another_retry() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("relaycast-run.json"); + write_run_token_file(&path, "rt_live_dead", &future_expiry()); + let calls = Arc::new(AtomicUsize::new(0)); + let result: std::result::Result<(), _> = call_with_run_token( + &RunTokenSource::new(&path), + |status: &u16| *status == 401, + |_token| { + let calls = Arc::clone(&calls); + async move { + calls.fetch_add(1, Ordering::SeqCst); + Err(401u16) + } + }, + ) + .await; + assert!(matches!(result, Err(RunTokenCallError::Call(401)))); + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + #[test] + fn workspace_key_detection_covers_bare_and_json_keys() { + assert!(holds_workspace_key("rk_live_abc")); + assert!(holds_workspace_key(" rk_test_abc ")); + assert!(holds_workspace_key(r#"[{"api_key":"rk_live_abc"}]"#)); + assert!(!holds_workspace_key("")); + assert!(!holds_workspace_key("rt_live_abc")); + assert!(!holds_workspace_key(r#"[{"api_key":""}]"#)); + } +} diff --git a/crates/broker/src/relaycast/workspace.rs b/crates/broker/src/relaycast/workspace.rs index c6ed10e5e9..eebfc4a664 100644 --- a/crates/broker/src/relaycast/workspace.rs +++ b/crates/broker/src/relaycast/workspace.rs @@ -51,6 +51,7 @@ impl MultiWorkspaceSession { ) -> Self { let (merged_tx, inbound_rx) = mpsc::channel(1024); let mut handles = Vec::with_capacity(sessions.memberships.len()); + let run_token = sessions.run_token.clone(); for session in sessions.memberships { let workspace_id = WorkspaceId::new(session.credentials.workspace_id.clone()); @@ -90,12 +91,22 @@ impl MultiWorkspaceSession { let mut self_agent_ids = HashSet::new(); self_agent_ids.insert(self_agent_id.clone()); - let http_client = RelaycastHttpClient::new( - http_base.clone(), - relay_workspace_key.clone(), - self_name.clone(), - "claude", - ); + let http_client = match run_token.as_ref() { + // Run-token mode: no workspace key exists. Run-token-capable + // calls read the token from the file per request. + Some(source) => RelaycastHttpClient::new_with_run_token( + http_base.clone(), + source.clone(), + self_name.clone(), + "claude", + ), + None => RelaycastHttpClient::new( + http_base.clone(), + relay_workspace_key.clone(), + self_name.clone(), + "claude", + ), + }; http_client.seed_agent_token(&self_name, &self_token); // Node-only delivery (v5.0.1): messages flow over /v1/node/ws and are diff --git a/crates/broker/src/relaycast/ws.rs b/crates/broker/src/relaycast/ws.rs index 17fca37165..30470164f3 100644 --- a/crates/broker/src/relaycast/ws.rs +++ b/crates/broker/src/relaycast/ws.rs @@ -20,6 +20,11 @@ use uuid::Uuid; use crate::{fleet_wire::AgentRegistrationMetadata, protocol::MessageInjectionMode}; +use super::run_token::{ + call_with_run_token, relay_error_is_unauthorized, RunTokenCallError, RunTokenSource, + RUN_TOKEN_MODE_PLACEHOLDER_BEARER, +}; + #[cfg(test)] use relaycast::registration_is_retryable; @@ -66,6 +71,10 @@ pub struct RelaycastHttpClient { takeover_locks: Arc>>>>, pub agent_name: String, pub default_cli: String, + /// Run-token mode (`RELAY_RUN_TOKEN_FILE`, no `rk_` key): `api_key` is + /// empty and the run-token-capable endpoints (`POST /v1/agents`, + /// `DELETE /v1/agents/:name`) read the token from this file per request. + run_token: Option, } pub type RelaycastRegistrationError = AgentRegistrationError; @@ -346,6 +355,7 @@ impl RelaycastHttpClient { takeover_locks: Arc::new(StdMutex::new(HashMap::new())), agent_name: agent_name.into(), default_cli: String::new(), + run_token: None, } } @@ -372,6 +382,196 @@ impl RelaycastHttpClient { takeover_locks: Arc::new(StdMutex::new(HashMap::new())), agent_name: agent_name.into(), default_cli, + run_token: None, + } + } + + /// A client for run-token mode. It holds no workspace key: the SDK's + /// workspace-scoped client carries a non-secret placeholder bearer (so + /// agent-token clients can still be derived from it), registration and + /// release read the run token from `source` per request, and lookups use + /// agent tokens because `GET /v1/agents/:name` is not open to run tokens. + pub fn new_with_run_token( + base_url: Option, + source: RunTokenSource, + agent_name: impl Into, + default_cli: impl Into, + ) -> Self { + let mut client = Self::new( + base_url, + RUN_TOKEN_MODE_PLACEHOLDER_BEARER, + agent_name, + default_cli, + ); + client.api_key = String::new(); + client.run_token = Some(source); + client + } + + /// The run token file this client reads, when in run-token mode. + pub(crate) fn run_token_source(&self) -> Option<&RunTokenSource> { + self.run_token.as_ref() + } + + /// Bearer for workspace-scoped reads that agents may also perform + /// (attachment downloads): the workspace key in legacy mode, the broker's + /// own agent token in run-token mode. Never the run token. + pub(crate) fn workspace_read_bearer(&self) -> String { + if self.run_token.is_some() { + self.cached_agent_token(&self.agent_name) + .unwrap_or_default() + } else { + self.api_key.clone() + } + } + + /// `POST /v1/agents` with the broker's registration credential: the + /// workspace key in legacy mode, or the run token (read per request, one + /// re-read and retry on 401) in run-token mode. + async fn post_create_agent( + &self, + relay: &RelayCast, + body: &Value, + options: RequestOptions, + ) -> std::result::Result { + let Some(source) = self.run_token.as_ref() else { + let client = relay.as_agent(&self.api_key)?; + return client + .http_client() + .post::("/v1/agents", Some(body), Some(options)) + .await; + }; + call_with_run_token(source, relay_error_is_unauthorized, |token| { + let options = options.clone(); + async move { + let client = relay.as_agent(token)?; + client + .http_client() + .post::("/v1/agents", Some(body), Some(options)) + .await + } + }) + .await + .map_err(run_token_call_error_to_relay_error) + } + + /// Run-token mode release: `DELETE /v1/agents/:name` with the run token. + /// Relaycast only deletes agents created under the same run (another run's + /// agent answers 404), so a 404 means there is nothing of ours to delete. + async fn delete_agent_with_run_token( + &self, + source: &RunTokenSource, + agent_name: &str, + expected_token_hash: Option<&str>, + ) -> Result<()> { + let relay = (*self.relay) + .as_ref() + .context("SDK relay client not initialized")?; + // The legacy owned-cleanup route compares the credential hash + // server-side. DELETE has no such guard, so refuse locally when the + // cache proves the name now belongs to a replacement identity. + if let Some(expected) = expected_token_hash { + if let Some(cached) = self.cached_agent_token(agent_name) { + if format!("{:x}", Sha256::digest(cached.as_bytes())) != expected { + tracing::info!( + agent = %agent_name, + "run-token release skipped: the name was re-registered by a replacement" + ); + return Ok(()); + } + } + } + // Invalidate first, as the legacy path does: an ambiguous response + // must never leave a released token cached for reuse. + self.invalidate_cached_registration(agent_name); + let path = format!("/v1/agents/{}", urlencoding::encode(agent_name)); + let result = call_with_run_token(source, relay_error_is_unauthorized, |token| { + let path = path.clone(); + async move { + let client = relay.as_agent(token)?; + client.http_client().delete(&path, None).await + } + }) + .await; + match result { + Ok(()) => { + tracing::info!(agent = %agent_name, "released agent identity with run token"); + Ok(()) + } + Err(RunTokenCallError::Call(error)) if error.is_not_found() => { + tracing::info!( + agent = %agent_name, + "run-token release found no agent of this run to delete" + ); + Ok(()) + } + Err(error) => Err(anyhow::anyhow!( + "failed to release agent '{agent_name}' with run token: {error}" + )), + } + } + + /// Run-token mode registration of a spawned worker (or the broker's own + /// name after a cache miss). A collision fails closed: run tokens cannot + /// look up or take over an existing identity. + async fn register_agent_token_with_run_token( + &self, + agent_name: &str, + cli_hint: Option<&str>, + waiter_id: Option<&str>, + ) -> std::result::Result { + let relay = + self.relay + .as_ref() + .as_ref() + .ok_or_else(|| RelaycastRegistrationError::Transport { + agent_name: agent_name.to_string(), + detail: "SDK relay client not initialized".to_string(), + })?; + if let Some(cached) = self.cached_agent_token(agent_name) { + return Ok(cached); + } + let body = serde_json::json!({ + "name": agent_name, + "type": "agent", + "auto_join_general": false, + "metadata": {"cli": cli_hint.unwrap_or(&self.default_cli)}, + }); + let headers = waiter_id.map(|waiter_id| { + vec![ + ("Idempotency-Key".to_string(), waiter_id.to_string()), + ( + "X-Workspace-Write-Waiter".to_string(), + waiter_id.to_string(), + ), + ] + }); + match self + .post_create_agent( + relay, + &body, + RequestOptions { + headers, + idempotency_key: None, + }, + ) + .await + { + Ok(response) if response.token.trim().is_empty() => { + Err(RelaycastRegistrationError::MissingToken { + agent_name: agent_name.to_string(), + }) + } + Ok(response) => { + self.seed_agent_token(agent_name, &response.token); + Ok(response.token) + } + Err(RelayError::Api { status: 409, .. }) => { + Err(RelaycastRegistrationError::AlreadyExists { + agent_name: agent_name.to_string(), + }) + } + Err(error) => Err(registration_metadata_error(agent_name, error)), } } @@ -411,8 +611,31 @@ impl RelaycastHttpClient { return RecipientReachability::unknown(); }; - match tokio::time::timeout(SEND_RECIPIENT_PROBE_TIMEOUT, relay.get_agent(agent_name)).await - { + // Run tokens cannot read `GET /v1/agents/:name`; probe with the + // broker's own agent token instead. + let lookup = async { + if self.run_token.is_some() { + let token = self.cached_agent_token(&self.agent_name).ok_or_else(|| { + RelayError::api( + "agent_token_unavailable", + "broker agent token is not cached", + 401, + ) + })?; + relay + .as_agent(token)? + .http_client() + .get::( + &format!("/v1/agents/{}", urlencoding::encode(agent_name)), + None, + None, + ) + .await + } else { + relay.get_agent(agent_name).await + } + }; + match tokio::time::timeout(SEND_RECIPIENT_PROBE_TIMEOUT, lookup).await { Ok(Ok(agent)) => { let live = if agent_is_reachable(&agent) { Some(true) @@ -519,6 +742,14 @@ impl RelaycastHttpClient { cli_hint: Option<&str>, ) -> std::result::Result { let trimmed_name = agent_name.trim(); + if self.run_token.is_some() { + if trimmed_name.is_empty() { + return Err(RelaycastRegistrationError::InvalidAgentName); + } + return self + .register_agent_token_with_run_token(trimmed_name, cli_hint, None) + .await; + } let registration = self.registration.as_ref().as_ref().ok_or_else(|| { RelaycastRegistrationError::Transport { agent_name: trimmed_name.to_string(), @@ -558,6 +789,11 @@ impl RelaycastHttpClient { waiter_id: &str, ) -> std::result::Result { let trimmed_name = agent_name.trim(); + if self.run_token.is_some() { + return self + .register_agent_token_with_run_token(trimmed_name, cli_hint, Some(waiter_id)) + .await; + } let relay = self.relay .as_ref() @@ -627,6 +863,12 @@ impl RelaycastHttpClient { known_agent_id: Option<&str>, waiter_id: Option<&str>, ) -> std::result::Result { + // Takeover needs the workspace key; run tokens cannot reclaim a name. + if self.run_token.is_some() { + return Err(RelaycastRegistrationError::AlreadyExists { + agent_name: agent_name.to_string(), + }); + } // Singleflight per agent name. Two concurrent cache misses would // otherwise both take the name over, and the second response would // invalidate the token already returned to the first caller — @@ -844,6 +1086,16 @@ impl RelaycastHttpClient { .register_agent_token(trimmed_name, cli_hint) .await .map_err(ImpersonationAwareRegistrationError::Sdk), + RegisterIntent::ImpersonateExisting if self.run_token.is_some() => { + // The presence probe is `GET /v1/agents/:name`, which run + // tokens cannot call, and without a probe this intent refuses. + Err(ImpersonationAwareRegistrationError::ProbeFailed { + agent_name: trimmed_name.to_string(), + detail: "run-token mode cannot probe or take over an agent it did not \ + register in this run" + .to_string(), + }) + } RegisterIntent::ImpersonateExisting => { let relay = self .relay_client() @@ -1342,6 +1594,19 @@ impl RelaycastHttpClient { visibility_backoffs_ms: &[u64], total_budget: Duration, ) -> Result<()> { + if let Some(source) = self.run_token.as_ref() { + // Run tokens may only release via `DELETE /v1/agents/:name`. + let _ = ( + reason, + delete_identity, + expected_agent_id, + visibility_backoffs_ms, + total_budget, + ); + return self + .delete_agent_with_run_token(source, agent_name, expected_token_hash) + .await; + } if let Some(relay) = (*self.relay).as_ref() { let reason = reason .map(str::trim) @@ -1932,7 +2197,16 @@ impl RelaycastHttpClient { .as_ref() .as_ref() .context("SDK relay client not initialized")?; - let client = relay.as_agent(&self.api_key)?; + // Run tokens cannot read `GET /v1/agents/:name`; the worker's own + // agent token can. + let client = if self.run_token.is_some() { + let token = self.cached_agent_token(name).with_context(|| { + format!("run-token mode has no cached agent token for worker '{name}'") + })?; + relay.as_agent(token)? + } else { + relay.as_agent(&self.api_key)? + }; let path = format!("/v1/agents/{name}"); let mut agent: Option = None; let total_budget = @@ -2390,6 +2664,21 @@ async fn mute_self_channel(agent_client: &AgentClient, channel: &str) { } } +/// Collapse a run-token call failure into the SDK error type the registration +/// paths classify. An unreadable/expired run token file is reported as a 401 +/// with a dedicated code: there is no usable credential, and retrying the +/// request cannot fix that. +fn run_token_call_error_to_relay_error(error: RunTokenCallError) -> RelayError { + match error { + RunTokenCallError::Call(error) => error, + RunTokenCallError::Read(error) => RelayError::api( + "run_token_unavailable", + format!("run token unavailable: {error:#}"), + 401, + ), + } +} + /// Build a `RelayCast` workspace client from an API key and optional base URL. /// When `base_url` is `None`, the SDK applies its own default. fn build_relay_client(api_key: &str, base_url: Option<&str>) -> Option { @@ -3033,9 +3322,8 @@ async fn register_new_spawn_identity_inner( })?; // Use the SDK transport with the extended registration contract. Do not // consult the identity cache: success must prove a newly created identity. - let client = relay - .as_agent(&http.api_key) - .map_err(|error| RegRetryOutcome::Fatal(registration_metadata_error(name, error)))?; + // The credential (workspace key, or the run token read per request) is + // resolved by `post_create_agent` on every attempt. let body = serde_json::json!({ "name": name, "type": "agent", "auto_join_general": false, "metadata": {"cli": cli.unwrap_or(&http.default_cli)} @@ -3075,13 +3363,8 @@ async fn register_new_spawn_identity_inner( // cancels it mid-flight still reports it; a terminal error below // corrects the total to what the SDK actually sent. total_attempts.fetch_add(1, Ordering::Relaxed); - match client - .http_client() - .post::( - "/v1/agents", - Some(&body), - Some(request_options()), - ) + match http + .post_create_agent(relay, &body, request_options()) .await { Ok(agent) if !agent.token.trim().is_empty() => { @@ -7010,3 +7293,208 @@ mod tests { rotate.assert_hits(1); } } + +#[cfg(test)] +mod run_token_mode_tests { + use httpmock::{ + Method::{DELETE, GET, POST}, + MockServer, + }; + use serde_json::json; + + use super::{register_new_spawn_identity, RelaycastHttpClient, RelaycastRegistrationError}; + use crate::relaycast::run_token::{ + test_support::{future_expiry, write_run_token_file}, + RunTokenSource, + }; + + fn agent_created(name: &str, token: &str) -> serde_json::Value { + json!({"ok": true, "data": { + "id": format!("agent-{name}"), + "workspace_id": "ws_run", + "run_id": "run_1", + "name": name, + "token": token, + "status": "online", + "created_at": "2026-10-17T00:00:00Z" + }}) + } + + fn run_token_client(server: &MockServer, source: RunTokenSource) -> RelaycastHttpClient { + let client = RelaycastHttpClient::new_with_run_token( + Some(server.base_url()), + source, + "broker", + "codex", + ); + client.seed_agent_token("broker", "at_live_broker"); + client + } + + #[test] + fn run_token_client_holds_no_workspace_key() { + let server = MockServer::start(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("relaycast-run.json"); + let client = run_token_client(&server, RunTokenSource::new(&path)); + assert!(client.api_key.is_empty()); + assert_eq!(client.run_token_source().unwrap().path(), path.as_path()); + // Workspace-scoped reads use the broker's own agent token, never the + // run token. + assert_eq!(client.workspace_read_bearer(), "at_live_broker"); + } + + #[tokio::test] + async fn spawned_workers_register_with_the_run_token_read_fresh_per_request() { + let server = MockServer::start(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("relaycast-run.json"); + write_run_token_file(&path, "rt_live_first", &future_expiry()); + let client = run_token_client(&server, RunTokenSource::new(&path)); + + let first = server.mock(|when, then| { + when.method(POST) + .path("/v1/agents") + .header("authorization", "Bearer rt_live_first") + .json_body_partial(r#"{"name":"worker-1"}"#); + then.status(201) + .json_body(agent_created("worker-1", "at_live_w1")); + }); + let token = register_new_spawn_identity(&client, "worker-1", Some("codex")) + .await + .expect("worker registers with the run token"); + assert_eq!(token, "at_live_w1"); + first.assert_hits(1); + + // The launcher rotates the file; the very next request must use it. + write_run_token_file(&path, "rt_live_rotated", &future_expiry()); + let stale = server.mock(|when, then| { + when.method(POST) + .path("/v1/agents") + .header("authorization", "Bearer rt_live_first") + .json_body_partial(r#"{"name":"worker-2"}"#); + then.status(401).json_body(json!({"ok": false, "error": { + "code": "run_token_invalid", "message": "stale"}})); + }); + let rotated = server.mock(|when, then| { + when.method(POST) + .path("/v1/agents") + .header("authorization", "Bearer rt_live_rotated") + .json_body_partial(r#"{"name":"worker-2"}"#); + then.status(201) + .json_body(agent_created("worker-2", "at_live_w2")); + }); + let token = client + .register_agent_token("worker-2", Some("codex")) + .await + .expect("second worker registers with the rotated token"); + assert_eq!(token, "at_live_w2"); + rotated.assert_hits(1); + stale.assert_hits(0); + assert_eq!( + client.cached_agent_token("worker-2").as_deref(), + Some("at_live_w2") + ); + } + + #[tokio::test] + async fn a_name_collision_fails_closed_without_lookup_or_takeover() { + let server = MockServer::start(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("relaycast-run.json"); + write_run_token_file(&path, "rt_live_cur", &future_expiry()); + let client = run_token_client(&server, RunTokenSource::new(&path)); + + let create = server.mock(|when, then| { + when.method(POST).path("/v1/agents"); + then.status(409).json_body(json!({"ok": false, "error": { + "code": "agent_already_exists", "message": "taken"}})); + }); + let lookup = server.mock(|when, then| { + when.method(GET).path("/v1/agents/worker-1"); + then.status(200).json_body(json!({"ok": true, "data": {}})); + }); + let takeover = server.mock(|when, then| { + when.method(POST).path("/v1/agents/worker-1/takeover"); + then.status(200).json_body(json!({"ok": true, "data": {}})); + }); + + let error = client + .register_agent_token("worker-1", None) + .await + .unwrap_err(); + assert!( + matches!(error, RelaycastRegistrationError::AlreadyExists { .. }), + "{error:?}" + ); + create.assert_hits(1); + lookup.assert_hits(0); + takeover.assert_hits(0); + } + + #[tokio::test] + async fn workers_are_released_with_delete_and_the_run_token() { + let server = MockServer::start(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("relaycast-run.json"); + write_run_token_file(&path, "rt_live_cur", &future_expiry()); + let client = run_token_client(&server, RunTokenSource::new(&path)); + client.seed_agent_token("worker-1", "at_live_w1"); + + let delete = server.mock(|when, then| { + when.method(DELETE) + .path("/v1/agents/worker-1") + .header("authorization", "Bearer rt_live_cur"); + then.status(204); + }); + let legacy_release = server.mock(|when, then| { + when.method(POST).path_contains("/v1/agents/release"); + then.status(200).json_body(json!({"ok": true, "data": {}})); + }); + + client + .release_agent_identity("worker-1", Some("done"), true) + .await + .expect("release succeeds via DELETE"); + delete.assert_hits(1); + legacy_release.assert_hits(0); + assert!(client.cached_agent_token("worker-1").is_none()); + + // An agent that belongs to another run (404) is nothing of ours. + client.seed_agent_token("worker-2", "at_live_w2"); + let other_run = server.mock(|when, then| { + when.method(DELETE).path("/v1/agents/worker-2"); + then.status(404).json_body(json!({"ok": false, "error": { + "code": "agent_not_found", "message": "not in this run"}})); + }); + client + .release_agent_identity("worker-2", None, false) + .await + .expect("a 404 release is terminal success"); + other_run.assert_hits(1); + } + + #[tokio::test] + async fn channel_scope_lookup_uses_the_workers_own_agent_token() { + let server = MockServer::start(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("relaycast-run.json"); + write_run_token_file(&path, "rt_live_cur", &future_expiry()); + let client = run_token_client(&server, RunTokenSource::new(&path)); + client.seed_agent_token("worker-a", "at_live_worker_a"); + + let detail = server.mock(|when, then| { + when.method(GET) + .path("/v1/agents/worker-a") + .header("authorization", "Bearer at_live_worker_a"); + then.status(200).json_body(json!({"ok": true, "data": { + "channels": [{"name": "general"}] + }})); + }); + client + .verify_agent_channel_scope("worker-a", &["general".into()]) + .await + .expect("lookup with the worker's own token"); + detail.assert_hits(1); + } +} diff --git a/crates/broker/src/runtime/fleet.rs b/crates/broker/src/runtime/fleet.rs index a7d45bd07d..a1182d4994 100644 --- a/crates/broker/src/runtime/fleet.rs +++ b/crates/broker/src/runtime/fleet.rs @@ -1129,7 +1129,9 @@ impl BrokerRuntime { .base_url .as_deref() .unwrap_or(crate::relaycast::auth::DEFAULT_RELAYCAST_BASE_URL), - &self.relaycast_http.api_key, + // The workspace key in legacy mode; the broker's own agent token + // in run-token mode (run tokens cannot read attachments). + &self.relaycast_http.workspace_read_bearer(), ); tracing::info!( target = "relay_broker::fleet", diff --git a/crates/broker/src/runtime/init.rs b/crates/broker/src/runtime/init.rs index e6208ab079..cc33668fec 100644 --- a/crates/broker/src/runtime/init.rs +++ b/crates/broker/src/runtime/init.rs @@ -245,6 +245,11 @@ pub(crate) async fn run_init(cmd: InitCommand, telemetry: TelemetryClient) -> Re let self_names = default_workspace.self_names.clone(); let ws_control_tx = default_workspace.ws_control_tx.clone(); let relaycast_http = default_workspace.http_client.clone(); + // Run-token mode (RELAY_RUN_TOKEN_FILE, no `rk_` key): the broker holds no + // workspace key. Node minting reads the run token per request and workers + // receive no workspace credential at all. + let run_token_source = relaycast_http.run_token_source().cloned(); + let run_token_mode = run_token_source.is_some(); let (hosted_agent_event_tx, hosted_agent_event_rx) = mpsc::channel::(10_000); let hosted_event_client = relaycast_http.clone(); let hosted_event_clients = workspaces @@ -366,6 +371,7 @@ pub(crate) async fn run_init(cmd: InitCommand, telemetry: TelemetryClient) -> Re // client is available (then a 401 surfaces a hard error rather than recovering). let token_minter = Some(crate::node_control::NodeTokenMinter { workspace_key: relay_workspace_key.clone(), + run_token: run_token_source.clone(), workspace_id: node_workspace_id.clone(), base_url: node_base_url.clone(), node_id: node_id.clone(), @@ -501,7 +507,8 @@ pub(crate) async fn run_init(cmd: InitCommand, telemetry: TelemetryClient) -> Re tx: api_tx.clone(), events_tx: events_tx.clone(), replay_buffer: replay_buffer.clone(), - workspace_key: (!local_only).then(|| relay_workspace_key.clone()), + workspace_key: (!local_only && !relay_workspace_key.is_empty()) + .then(|| relay_workspace_key.clone()), relay_base_url: configured_base.clone(), memberships: if local_only { vec![] @@ -605,25 +612,12 @@ pub(crate) async fn run_init(cmd: InitCommand, telemetry: TelemetryClient) -> Re } let callback_host = callback_host_for_url(&cmd.api_bind, local_addr); - let mut worker_env = vec![ - ( - "AGENT_RELAY_WORKSPACE_KEY".to_string(), - relay_workspace_key.clone(), - ), - ( - "RELAY_WORKSPACE_KEY".to_string(), - relay_workspace_key.clone(), - ), - ("RELAY_API_KEY".to_string(), relay_workspace_key.clone()), - ( - "AGENT_RELAY_RESULT_URL".to_string(), - format!("http://{}:{}/api/agent-result", callback_host, actual_port), - ), - ( - "RELAY_WORKSPACES_JSON".to_string(), - relay_workspaces_json.clone(), - ), - ]; + let mut worker_env = base_worker_relay_env( + &relay_workspace_key, + &relay_workspaces_json, + format!("http://{}:{}/api/agent-result", callback_host, actual_port), + run_token_mode, + ); // Pass RELAY_BASE_URL to workers only when an override is configured; when // unset, workers inherit the SDK default. if let Some(base) = configured_base.as_deref() { @@ -1353,3 +1347,84 @@ mod tests { ); } } + +/// The relay entries of every worker's environment. Legacy mode delegates the +/// workspace key to workers (unchanged behavior). Run-token mode delegates no +/// workspace credential and never the run token: each worker holds only its +/// own `at_live_` agent token, injected per spawn as `RELAY_AGENT_TOKEN`. +pub(crate) fn base_worker_relay_env( + relay_workspace_key: &str, + relay_workspaces_json: &str, + result_url: String, + run_token_mode: bool, +) -> Vec<(String, String)> { + if run_token_mode { + return vec![("AGENT_RELAY_RESULT_URL".to_string(), result_url)]; + } + vec![ + ( + "AGENT_RELAY_WORKSPACE_KEY".to_string(), + relay_workspace_key.to_string(), + ), + ( + "RELAY_WORKSPACE_KEY".to_string(), + relay_workspace_key.to_string(), + ), + ("RELAY_API_KEY".to_string(), relay_workspace_key.to_string()), + ("AGENT_RELAY_RESULT_URL".to_string(), result_url), + ( + "RELAY_WORKSPACES_JSON".to_string(), + relay_workspaces_json.to_string(), + ), + ] +} + +#[cfg(test)] +mod run_token_worker_env_tests { + use super::base_worker_relay_env; + + const KEY_VARS: [&str; 5] = [ + "AGENT_RELAY_WORKSPACE_KEY", + "RELAY_WORKSPACE_KEY", + "RELAY_API_KEY", + "RELAY_WORKSPACES_JSON", + "RELAY_RUN_TOKEN_FILE", + ]; + + #[test] + fn run_token_mode_worker_env_holds_no_workspace_credential() { + let env = base_worker_relay_env( + "", + r#"[{"workspace_id":"ws_run","api_key":""}]"#, + "http://127.0.0.1:1/api/agent-result".to_string(), + true, + ); + for (key, value) in &env { + assert!(!KEY_VARS.contains(&key.as_str()), "{key} leaked to workers"); + assert!(!value.contains("rk_") && !value.contains("rt_"), "{key}"); + } + assert_eq!( + env, + vec![( + "AGENT_RELAY_RESULT_URL".to_string(), + "http://127.0.0.1:1/api/agent-result".to_string() + )] + ); + } + + #[test] + fn legacy_worker_env_is_unchanged() { + let env = base_worker_relay_env("rk_live_legacy", "[]", "http://cb".to_string(), false); + let expected: Vec<(String, String)> = [ + ("AGENT_RELAY_WORKSPACE_KEY", "rk_live_legacy"), + ("RELAY_WORKSPACE_KEY", "rk_live_legacy"), + ("RELAY_API_KEY", "rk_live_legacy"), + ("AGENT_RELAY_RESULT_URL", "http://cb"), + ("RELAY_WORKSPACES_JSON", "[]"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + assert_eq!(env, expected); + } +} diff --git a/crates/broker/src/runtime/mod.rs b/crates/broker/src/runtime/mod.rs index 3b2fafd0ce..c19015fc6f 100644 --- a/crates/broker/src/runtime/mod.rs +++ b/crates/broker/src/runtime/mod.rs @@ -79,7 +79,7 @@ mod fleet; mod headless; mod identity_cleanup; mod identity_recovery; -mod init; +pub(crate) mod init; mod io; mod maintenance; mod messages; diff --git a/crates/broker/src/runtime/session.rs b/crates/broker/src/runtime/session.rs index 3ef778cd92..65c986ce52 100644 --- a/crates/broker/src/runtime/session.rs +++ b/crates/broker/src/runtime/session.rs @@ -267,6 +267,10 @@ where fn startup_has_preconfigured_workspace_key() -> bool { [ + // Run-token mode joins the run's existing workspace; it must take the + // join path (which never mints a workspace), not the fresh-workspace + // path below. + crate::relaycast::run_token::RUN_TOKEN_FILE_ENV, "RELAY_WORKSPACES_JSON", "AGENT_RELAY_WORKSPACE_KEY", "RELAY_WORKSPACE_KEY", diff --git a/crates/broker/src/spawner.rs b/crates/broker/src/spawner.rs index 376a05aa92..3f10379e75 100644 --- a/crates/broker/src/spawner.rs +++ b/crates/broker/src/spawner.rs @@ -527,14 +527,16 @@ pub fn spawn_env_vars( default_workspace: Option<&str>, harness: Option<&str>, ) -> Vec<(String, String)> { - let mut env = vec![ - ("RELAY_AGENT_NAME".to_string(), name.to_string()), - ("AGENT_RELAY_WORKSPACE_KEY".to_string(), api_key.to_string()), - ("RELAY_WORKSPACE_KEY".to_string(), api_key.to_string()), - ("RELAY_API_KEY".to_string(), api_key.to_string()), - ("RELAY_CHANNELS".to_string(), channels.to_string()), - ("RELAY_STRICT_AGENT_NAME".to_string(), "1".to_string()), - ]; + let mut env = vec![("RELAY_AGENT_NAME".to_string(), name.to_string())]; + // An empty key means run-token mode: no workspace credential is delegated + // and the child relies solely on its own agent token. + if !api_key.trim().is_empty() { + env.push(("AGENT_RELAY_WORKSPACE_KEY".to_string(), api_key.to_string())); + env.push(("RELAY_WORKSPACE_KEY".to_string(), api_key.to_string())); + env.push(("RELAY_API_KEY".to_string(), api_key.to_string())); + } + env.push(("RELAY_CHANNELS".to_string(), channels.to_string())); + env.push(("RELAY_STRICT_AGENT_NAME".to_string(), "1".to_string())); // Pass RELAY_BASE_URL to the child only when an override is configured; when // unset, the child inherits the SDK default. if let Some(base_url) = base_url.map(str::trim).filter(|value| !value.is_empty()) { @@ -553,6 +555,7 @@ pub fn spawn_env_vars( if let Some(workspaces_json) = workspaces_json .map(str::trim) .filter(|value| !value.is_empty()) + .filter(|_| !api_key.trim().is_empty()) { env.push(( "RELAY_WORKSPACES_JSON".to_string(), @@ -740,6 +743,58 @@ mod tests { assert!(env.iter().all(|(k, _)| k != "AGENT_RELAY_ORIGIN_ACTOR")); } + #[test] + fn spawn_env_vars_in_run_token_mode_delegates_no_workspace_credential() { + // Run-token mode hands the child an empty workspace key. + let env = spawn_env_vars( + "a", + "", + Some("https://gw"), + "#c", + Some(r#"[{"workspace_id":"ws_1","api_key":""}]"#), + Some("ws_1"), + Some("codex"), + ); + for (key, value) in &env { + assert!( + !matches!( + key.as_str(), + "RELAY_API_KEY" + | "RELAY_WORKSPACE_KEY" + | "AGENT_RELAY_WORKSPACE_KEY" + | "RELAY_WORKSPACES_JSON" + | "RELAY_RUN_TOKEN_FILE" + ), + "{key} must not be delegated in run-token mode" + ); + assert!(!value.contains("rk_") && !value.contains("rt_"), "{key}"); + } + assert!(env + .iter() + .any(|(k, v)| k == "RELAY_WORKSPACE_ID" && v == "ws_1")); + } + + #[test] + fn spawn_env_vars_legacy_mode_still_delegates_the_workspace_key() { + let env = spawn_env_vars("a", "rk_live_x", None, "#c", Some("[]"), None, None); + for key in [ + "RELAY_API_KEY", + "RELAY_WORKSPACE_KEY", + "AGENT_RELAY_WORKSPACE_KEY", + ] { + assert!( + env.iter().any(|(k, v)| k == key && v == "rk_live_x"), + "{key}" + ); + } + assert!(env.iter().any(|(k, _)| k == "RELAY_WORKSPACES_JSON")); + } + + #[test] + fn inherited_relay_credentials_cover_the_run_token_file() { + assert!(INHERITED_RELAY_CREDENTIAL_ENV_KEYS.contains(&"RELAY_RUN_TOKEN_FILE")); + } + #[test] fn absent_attestation_metadata_is_a_complete_no_op() { let original = vec![("RELAY_AGENT_NAME".to_string(), "worker".to_string())]; diff --git a/crates/broker/src/worker.rs b/crates/broker/src/worker.rs index 53a40cd440..5e6be6b2e4 100644 --- a/crates/broker/src/worker.rs +++ b/crates/broker/src/worker.rs @@ -3501,6 +3501,82 @@ mod tests { assert_eq!(observed, vec!["RELAY_AGENT_TOKEN", "RELAY_API_KEY"]); } + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[tokio::test] + async fn run_token_mode_worker_holds_only_its_own_agent_token() { + let dir = tempfile::tempdir().expect("worker cwd"); + // Record credential variable NAMES the worker sees, plus how many env + // values look like a workspace key or run token (never the values). + let keys = format!( + "{} RELAY_NODE_ID AGENT_RELAY_ENROLLED_NODE_ID", + crate::spawner::INHERITED_RELAY_CREDENTIAL_ENV_KEYS.join(" ") + ); + let script = format!( + "for k in {keys}; do if printenv \"$k\" >/dev/null; then echo \"$k\"; fi; done > names.tmp; \ + env | grep -cE '(rk|rt)_(live|test)_' > leaks.tmp; \ + mv leaks.tmp leaks.txt; mv names.tmp names.txt; sleep 30" + ); + let mut spec = sleeping_native_worker("run-token-worker", None); + spec.harness_config = Some(ResolvedHarnessConfig::Native(NativeHarnessConfig { + command: "sh".to_string(), + args: vec!["-c".to_string(), script], + cwd: Some(dir.path().to_string_lossy().into_owned()), + env: None, + session_id: "session-run-token-worker".to_string(), + metadata: None, + })); + // Exactly what the broker hands workers in run-token mode. + let mut registry = make_registry(crate::runtime::init::base_worker_relay_env( + "", + r#"[{"workspace_id":"ws_run","api_key":""}]"#, + "http://127.0.0.1:1/api/agent-result".to_string(), + true, + )); + + registry + .spawn( + spec, + None, + None, + Some("at_live_worker_own".to_string()), + false, + None, + None, + None, + None, + ) + .await + .expect("run-token worker should spawn"); + + let names_path = dir.path().join("names.txt"); + let mut names = None; + for _ in 0..50 { + if let Ok(value) = std::fs::read_to_string(&names_path) { + names = Some(value); + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + let leaks = std::fs::read_to_string(dir.path().join("leaks.txt")); + registry + .release("run-token-worker") + .await + .expect("release run-token worker"); + + let mut observed: Vec = names + .expect("worker recorded its credential names") + .lines() + .map(str::to_string) + .collect(); + observed.sort(); + assert_eq!(observed, vec!["RELAY_AGENT_TOKEN"]); + assert_eq!( + leaks.expect("worker recorded its leak count").trim(), + "0", + "no rk_/rt_ value may reach a run-token-mode worker" + ); + } + #[cfg(any(target_os = "linux", target_os = "macos"))] #[tokio::test] async fn spawned_worker_process_uses_requested_cwd() { diff --git a/crates/relay-pty/src/credentials.rs b/crates/relay-pty/src/credentials.rs index ce5ec6526b..8168c8b891 100644 --- a/crates/relay-pty/src/credentials.rs +++ b/crates/relay-pty/src/credentials.rs @@ -30,6 +30,9 @@ pub const INHERITED_RELAY_CREDENTIAL_ENV_KEYS: &[&str] = &[ "RELAY_WORKSPACE_KEY", "AGENT_RELAY_WORKSPACE_KEY", "RELAY_WORKSPACES_JSON", + // Run-scoped Relaycast credential file (`rt_live_`). Only the broker reads + // it; workers hold their own agent token instead. + "RELAY_RUN_TOKEN_FILE", ]; /// Remove [`INHERITED_RELAY_CREDENTIAL_ENV_KEYS`] from a command's inherited diff --git a/packages/cli/src/cli/lib/redact.test.ts b/packages/cli/src/cli/lib/redact.test.ts index 6d2e46e6ca..fd7c733ac4 100644 --- a/packages/cli/src/cli/lib/redact.test.ts +++ b/packages/cli/src/cli/lib/redact.test.ts @@ -72,6 +72,7 @@ describe('redactSecrets', () => { describe('maskSecret', () => { it('keeps a known prefix and the last four characters', () => { expect(maskSecret('rk_live_0123456789abcdef')).toBe('rk_live_…cdef'); + expect(maskSecret('rt_live_0123456789abcdef')).toBe('rt_live_…cdef'); expect(maskSecret('rjt_live_0123456789abcdef')).toBe('rjt_live_…cdef'); expect(maskSecret('at_live_0123456789abcdef')).toBe('at_live_…cdef'); expect(maskSecret('nt_live_0123456789abcdef')).toBe('nt_live_…cdef'); diff --git a/packages/cli/src/cli/lib/redact.ts b/packages/cli/src/cli/lib/redact.ts index 39d32c0981..34df4e7fa0 100644 --- a/packages/cli/src/cli/lib/redact.ts +++ b/packages/cli/src/cli/lib/redact.ts @@ -12,7 +12,7 @@ const SECRET_KEY = /token|secret|password|api[_-]?key|workspace[_-]?key|authorization/i; /** Known live-credential prefixes, kept visible so a masked value still identifies its kind. */ -const SECRET_PREFIX = /^(rk_live_|rjt_live_|at_live_|nt_live_|ot_live_|br_|rth_at_|cld_at_|ocl_node_enr_)/; +const SECRET_PREFIX = /^(rk_live_|rt_live_|rjt_live_|at_live_|nt_live_|ot_live_|br_|rth_at_|cld_at_|ocl_node_enr_)/; /** * Mask a credential for display: the known prefix (if any) and the last four diff --git a/packages/cloud/src/redact.test.ts b/packages/cloud/src/redact.test.ts index 3a980dc883..37adfe41db 100644 --- a/packages/cloud/src/redact.test.ts +++ b/packages/cloud/src/redact.test.ts @@ -27,6 +27,12 @@ describe('redactCredentialValues', () => { ).toBe('denied for at_live_…cdef with session cld_at_…cdef and join rjt_live_…cdef'); }); + it('masks a Relaycast run token', () => { + expect(redactCredentialValues('run token rt_live_0123456789abcdef rejected')).toBe( + 'run token rt_live_…cdef rejected' + ); + }); + it('masks a short-bodied credential entirely instead of leaking most of it', () => { expect(redactCredentialValues('bad key rk_live_abcd rejected')).toBe('bad key rk_live_… rejected'); expect(redactCredentialValues('bad key rk_live_abcde rejected')).toBe('bad key rk_live_… rejected'); diff --git a/packages/cloud/src/redact.ts b/packages/cloud/src/redact.ts index 03e8f02b00..f6b3924d17 100644 --- a/packages/cloud/src/redact.ts +++ b/packages/cloud/src/redact.ts @@ -9,7 +9,7 @@ * a usable secret. */ const LIVE_CREDENTIAL = - /(rk_live_|rjt_live_|at_live_|nt_live_|ot_live_|cld_at_|rth_at_|ocl_node_enr_|br_)([A-Za-z0-9_%-]+(?:\.[A-Za-z0-9_%-]+)*)/g; + /(rk_live_|rt_live_|rjt_live_|at_live_|nt_live_|ot_live_|cld_at_|rth_at_|ocl_node_enr_|br_)([A-Za-z0-9_%-]+(?:\.[A-Za-z0-9_%-]+)*)/g; /** * Replace every embedded live credential in `text` with its masked form — diff --git a/packages/harness-driver/src/spawn-config.test.ts b/packages/harness-driver/src/spawn-config.test.ts index b26576a645..6247b9502e 100644 --- a/packages/harness-driver/src/spawn-config.test.ts +++ b/packages/harness-driver/src/spawn-config.test.ts @@ -149,4 +149,85 @@ describe('buildBrokerSpawnConfig', () => { expect(config.env.RELAY_WORKSPACE_KEY).toBe('rk_live_primary'); expect(config.env.AGENT_RELAY_WORKSPACE_KEY).toBe('rk_live_primary'); }); + describe('run-token mode', () => { + const RUN_FILE = '/home/agent/.relay/run-access/relaycast-run.json'; + const KEY_VARS = ['RELAY_API_KEY', 'RELAY_WORKSPACE_KEY', 'AGENT_RELAY_WORKSPACE_KEY', 'RELAY_WORKSPACES_JSON']; + + function expectNoWorkspaceCredentials(config: ReturnType) { + for (const name of KEY_VARS) { + expect(config.env).not.toHaveProperty(name); + } + expect(config.workspaceKey).toBeUndefined(); + const serialized = JSON.stringify({ env: config.env, args: config.args }); + expect(serialized).not.toMatch(/rk_(live|test)_/); + expect(serialized).not.toMatch(/rt_(live|test)_/); + } + + it('passes only the run token file path to the broker for an explicit runTokenFile', () => { + const config = buildBrokerSpawnConfig({ cwd: '/tmp/my-project', runTokenFile: RUN_FILE }, 'br_test', {}); + + expect(config.runTokenFile).toBe(RUN_FILE); + expect(config.env.RELAY_RUN_TOKEN_FILE).toBe(RUN_FILE); + expect(config.args).not.toContain(RUN_FILE); + expectNoWorkspaceCredentials(config); + }); + + it('prefers the run token file over an explicit workspaceKey and strips inherited keys', () => { + const config = buildBrokerSpawnConfig( + { + cwd: '/tmp/my-project', + runTokenFile: RUN_FILE, + workspaceKey: 'rk_live_explicit', + env: { RELAY_WORKSPACE_KEY: 'rk_live_caller', RELAY_WORKSPACES_JSON: '{"k":"rk_live_json"}' }, + }, + 'br_test', + { RELAY_API_KEY: 'rk_live_parent', AGENT_RELAY_WORKSPACE_KEY: 'rk_live_parent2', PATH: '/bin' } + ); + + expect(config.env.RELAY_RUN_TOKEN_FILE).toBe(RUN_FILE); + expect(config.env.PATH).toBe('/bin'); + expectNoWorkspaceCredentials(config); + }); + + it('picks RELAY_RUN_TOKEN_FILE up from the caller env or the parent env', () => { + const fromCaller = buildBrokerSpawnConfig( + { cwd: '/tmp/p', env: { RELAY_RUN_TOKEN_FILE: RUN_FILE, RELAY_API_KEY: 'rk_live_x' } }, + 'br_test', + {} + ); + expect(fromCaller.env.RELAY_RUN_TOKEN_FILE).toBe(RUN_FILE); + expectNoWorkspaceCredentials(fromCaller); + + const fromParent = buildBrokerSpawnConfig({ cwd: '/tmp/p' }, 'br_test', { + RELAY_RUN_TOKEN_FILE: RUN_FILE, + RELAY_WORKSPACE_KEY: 'rk_live_parent', + }); + expect(fromParent.env.RELAY_RUN_TOKEN_FILE).toBe(RUN_FILE); + expectNoWorkspaceCredentials(fromParent); + }); + + it('allows a run token file in an isolated explicit environment', () => { + const config = buildBrokerSpawnConfig( + { cwd: '/tmp/p', inheritParentEnv: false, env: { PATH: '/safe/bin', RELAY_RUN_TOKEN_FILE: RUN_FILE } }, + 'br_test', + { RELAY_API_KEY: 'rk_live_parent' } + ); + expect(config.env).toEqual({ + PATH: '/safe/bin', + RELAY_RUN_TOKEN_FILE: RUN_FILE, + AGENT_RELAY_STARTUP_DEBUG: '1', + RELAY_BROKER_API_KEY: 'br_test', + AGENT_RELAY_BROKER_NAME: 'p', + }); + }); + + it('leaves the legacy workspace-key path unchanged when no run token file is set', () => { + const config = buildBrokerSpawnConfig({ cwd: '/tmp/p', workspaceKey: 'rk_live_legacy' }, 'br_test', {}); + expect(config.runTokenFile).toBeUndefined(); + expect(config.env).not.toHaveProperty('RELAY_RUN_TOKEN_FILE'); + expect(config.env.RELAY_API_KEY).toBe('rk_live_legacy'); + expect(config.env.RELAY_WORKSPACE_KEY).toBe('rk_live_legacy'); + expect(config.env.AGENT_RELAY_WORKSPACE_KEY).toBe('rk_live_legacy'); + }); + }); }); diff --git a/packages/harness-driver/src/spawn-config.ts b/packages/harness-driver/src/spawn-config.ts index b188e45ca4..8e79a6061e 100644 --- a/packages/harness-driver/src/spawn-config.ts +++ b/packages/harness-driver/src/spawn-config.ts @@ -21,6 +21,15 @@ export interface RuntimeSpawnOptions { binaryArgs?: BrokerInitArgs; /** Existing Relay workspace key to join. Defaults to env when omitted. */ workspaceKey?: string; + /** + * Absolute path to a run-scoped Relaycast access file + * (`{"token":"rt_live_...","expiresAt":""}`), refreshed outside the + * sandbox. Defaults to `RELAY_RUN_TOKEN_FILE` from env when omitted. When a + * run token file is in effect it wins over any workspace key: the broker + * receives only `RELAY_RUN_TOKEN_FILE` (the path, never the token value) and + * every workspace-key variable is removed from its environment. + */ + runTokenFile?: string; /** Broker name. Defaults to cwd basename. */ brokerName?: string; /** Default channels for spawned agents. */ @@ -76,6 +85,8 @@ export interface BrokerSpawnConfig { cwd: string; brokerName: string; workspaceKey?: string; + /** Run token file path handed to the broker, when run-token mode is in effect. */ + runTokenFile?: string; channels: string[]; timeoutMs: number; args: string[]; @@ -119,6 +130,17 @@ const UNSAFE_ISOLATED_ENV_KEYS = [ 'RELAY_API_KEY', ] as const; +/** + * Static workspace credentials that must never reach a broker started in + * run-token mode, whether they come from the caller's env or the parent env. + */ +const WORKSPACE_CREDENTIAL_ENV_KEYS = [ + 'RELAY_API_KEY', + 'RELAY_WORKSPACE_KEY', + 'AGENT_RELAY_WORKSPACE_KEY', + 'RELAY_WORKSPACES_JSON', +] as const; + /** @internal */ export function buildBrokerSpawnConfig( options: RuntimeSpawnOptions | undefined, @@ -143,8 +165,13 @@ export function buildBrokerSpawnConfig( nonEmptyString(options?.env?.AGENT_RELAY_BROKER_NAME) ?? nonEmptyString(inheritedEnv.AGENT_RELAY_BROKER_NAME) ?? (path.basename(cwd) || 'project'); - const workspaceKey = - nonEmptyString(options?.workspaceKey) ?? + const runTokenFile = + nonEmptyString(options?.runTokenFile) ?? + nonEmptyString(options?.env?.RELAY_RUN_TOKEN_FILE) ?? + nonEmptyString(inheritedEnv.RELAY_RUN_TOKEN_FILE); + const workspaceKey = runTokenFile + ? undefined + : nonEmptyString(options?.workspaceKey) ?? nonEmptyString(options?.env?.RELAY_WORKSPACE_KEY) ?? nonEmptyString(options?.env?.AGENT_RELAY_WORKSPACE_KEY) ?? nonEmptyString(inheritedEnv.RELAY_WORKSPACE_KEY) ?? @@ -153,7 +180,7 @@ export function buildBrokerSpawnConfig( const timeoutMs = options?.startupTimeoutMs ?? 45_000; const userArgs = buildBrokerInitArgs(options?.binaryArgs); - const env = { + const env: NodeJS.ProcessEnv = { ...inheritedEnv, ...options?.env, AGENT_RELAY_STARTUP_DEBUG: @@ -169,11 +196,21 @@ export function buildBrokerSpawnConfig( AGENT_RELAY_BROKER_NAME: brokerName, }; + if (runTokenFile) { + // Run-token mode: the broker reads the short-lived `rt_live_` from this + // file on every Relaycast request. Strip every static workspace + // credential so the broker can never fall back to (or leak) an `rk_` key. + for (const name of WORKSPACE_CREDENTIAL_ENV_KEYS) { + delete env[name]; + } + env.RELAY_RUN_TOKEN_FILE = runTokenFile; + } + // The workspace key travels only in the env block above — the broker's // `resolved_workspace_key()` falls back to AGENT_RELAY_WORKSPACE_KEY — so it // never appears on the child argv (visible in `ps`) or in startup error // strings that embed the spawn command line. const args = ['init', '--instance-name', brokerName, '--channels', channels.join(','), ...userArgs]; - return { cwd, brokerName, workspaceKey, channels, timeoutMs, args, env }; + return { cwd, brokerName, workspaceKey, runTokenFile, channels, timeoutMs, args, env }; } From 4a4fda8c2b531553d8a502bf61b3d61fbe279767 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 18 Oct 2026 04:23:06 +0000 Subject: [PATCH 2/4] style: auto-format with Prettier --- packages/cli/src/cli/lib/redact.ts | 3 ++- .../harness-driver/src/spawn-config.test.ts | 19 ++++++++++++++++--- packages/harness-driver/src/spawn-config.ts | 10 +++++----- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/cli/lib/redact.ts b/packages/cli/src/cli/lib/redact.ts index 34df4e7fa0..d5db1913ca 100644 --- a/packages/cli/src/cli/lib/redact.ts +++ b/packages/cli/src/cli/lib/redact.ts @@ -12,7 +12,8 @@ const SECRET_KEY = /token|secret|password|api[_-]?key|workspace[_-]?key|authorization/i; /** Known live-credential prefixes, kept visible so a masked value still identifies its kind. */ -const SECRET_PREFIX = /^(rk_live_|rt_live_|rjt_live_|at_live_|nt_live_|ot_live_|br_|rth_at_|cld_at_|ocl_node_enr_)/; +const SECRET_PREFIX = + /^(rk_live_|rt_live_|rjt_live_|at_live_|nt_live_|ot_live_|br_|rth_at_|cld_at_|ocl_node_enr_)/; /** * Mask a credential for display: the known prefix (if any) and the last four diff --git a/packages/harness-driver/src/spawn-config.test.ts b/packages/harness-driver/src/spawn-config.test.ts index 6247b9502e..dc987903ef 100644 --- a/packages/harness-driver/src/spawn-config.test.ts +++ b/packages/harness-driver/src/spawn-config.test.ts @@ -151,7 +151,12 @@ describe('buildBrokerSpawnConfig', () => { }); describe('run-token mode', () => { const RUN_FILE = '/home/agent/.relay/run-access/relaycast-run.json'; - const KEY_VARS = ['RELAY_API_KEY', 'RELAY_WORKSPACE_KEY', 'AGENT_RELAY_WORKSPACE_KEY', 'RELAY_WORKSPACES_JSON']; + const KEY_VARS = [ + 'RELAY_API_KEY', + 'RELAY_WORKSPACE_KEY', + 'AGENT_RELAY_WORKSPACE_KEY', + 'RELAY_WORKSPACES_JSON', + ]; function expectNoWorkspaceCredentials(config: ReturnType) { for (const name of KEY_VARS) { @@ -164,7 +169,11 @@ describe('buildBrokerSpawnConfig', () => { } it('passes only the run token file path to the broker for an explicit runTokenFile', () => { - const config = buildBrokerSpawnConfig({ cwd: '/tmp/my-project', runTokenFile: RUN_FILE }, 'br_test', {}); + const config = buildBrokerSpawnConfig( + { cwd: '/tmp/my-project', runTokenFile: RUN_FILE }, + 'br_test', + {} + ); expect(config.runTokenFile).toBe(RUN_FILE); expect(config.env.RELAY_RUN_TOKEN_FILE).toBe(RUN_FILE); @@ -208,7 +217,11 @@ describe('buildBrokerSpawnConfig', () => { it('allows a run token file in an isolated explicit environment', () => { const config = buildBrokerSpawnConfig( - { cwd: '/tmp/p', inheritParentEnv: false, env: { PATH: '/safe/bin', RELAY_RUN_TOKEN_FILE: RUN_FILE } }, + { + cwd: '/tmp/p', + inheritParentEnv: false, + env: { PATH: '/safe/bin', RELAY_RUN_TOKEN_FILE: RUN_FILE }, + }, 'br_test', { RELAY_API_KEY: 'rk_live_parent' } ); diff --git a/packages/harness-driver/src/spawn-config.ts b/packages/harness-driver/src/spawn-config.ts index 8e79a6061e..7c1b8d86f1 100644 --- a/packages/harness-driver/src/spawn-config.ts +++ b/packages/harness-driver/src/spawn-config.ts @@ -171,11 +171,11 @@ export function buildBrokerSpawnConfig( nonEmptyString(inheritedEnv.RELAY_RUN_TOKEN_FILE); const workspaceKey = runTokenFile ? undefined - : nonEmptyString(options?.workspaceKey) ?? - nonEmptyString(options?.env?.RELAY_WORKSPACE_KEY) ?? - nonEmptyString(options?.env?.AGENT_RELAY_WORKSPACE_KEY) ?? - nonEmptyString(inheritedEnv.RELAY_WORKSPACE_KEY) ?? - nonEmptyString(inheritedEnv.AGENT_RELAY_WORKSPACE_KEY); + : (nonEmptyString(options?.workspaceKey) ?? + nonEmptyString(options?.env?.RELAY_WORKSPACE_KEY) ?? + nonEmptyString(options?.env?.AGENT_RELAY_WORKSPACE_KEY) ?? + nonEmptyString(inheritedEnv.RELAY_WORKSPACE_KEY) ?? + nonEmptyString(inheritedEnv.AGENT_RELAY_WORKSPACE_KEY)); const channels = options?.channels ?? ['general']; const timeoutMs = options?.startupTimeoutMs ?? 45_000; const userArgs = buildBrokerInitArgs(options?.binaryArgs); From bbdcfbae4f31444e42e56109eea137ec3535dee2 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sat, 17 Oct 2026 21:39:51 -0700 Subject: [PATCH 3/4] feat(broker): rotate short-lived agent tokens before expiry in run-token mode Relaycast #550 agent tokens expire after 15 minutes and run-token mode has no workspace key to take an identity over, so the broker now rotates every agent token it owns via POST /v1/agents/:name/rotate-token (authenticated by the agent's own at_live_), never via takeover or delete-and-recreate: - Rotation is scheduled from token_expires_at (registration or rotation response) at the earlier of 2/3 of the lifetime or 5 minutes before expiry. Transient failures retry within the previous-token grace window; a 401/403 fails closed and is logged without the token value. Unknown expiry is probe-rotated once after 60s. - Broker self: the startup registration's token_expires_at seeds rotation. The node connection uses the node token, so nothing needs reconnecting. - Workers: get RELAY_AGENT_TOKEN_FILE (no static RELAY_AGENT_TOKEN), a per-worker file in a broker-owned 0700 directory, replaced atomically (0600 temp file + rename) on every rotation and removed on release. MCP configs for every CLI carry the file path. Rotation lineage keeps owned cleanup working after the token changes. - Agent Relay MCP server and CLI honor RELAY_AGENT_TOKEN_FILE: stat-cached re-read before every request, no RELAY_API_KEY needed, realtime bridge re-established on rotation with subscriptions kept. Legacy (rk_) mode is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + crates/broker/src/devin.rs | 10 +- .../src/relaycast/agent_token_rotation.rs | 366 ++++++++++++++ crates/broker/src/relaycast/auth.rs | 52 +- crates/broker/src/relaycast/mod.rs | 1 + crates/broker/src/relaycast/workspace.rs | 13 + crates/broker/src/relaycast/ws.rs | 450 +++++++++++++++++- crates/broker/src/runtime/api.rs | 28 ++ crates/broker/src/runtime/init.rs | 11 + crates/broker/src/runtime/maintenance.rs | 15 + crates/broker/src/snippets.rs | 76 ++- crates/broker/src/worker.rs | 60 ++- crates/relay-pty/src/credentials.rs | 1 + .../src/cli/agent-relay-mcp.startup.test.ts | 71 +++ packages/cli/src/cli/agent-relay-mcp.ts | 57 ++- .../cli/src/cli/lib/agent-token-file.test.ts | 54 +++ packages/cli/src/cli/lib/agent-token-file.ts | 66 +++ packages/cli/src/cli/lib/sdk-client.ts | 9 +- packages/cli/src/cli/mcp/types.ts | 6 + 19 files changed, 1309 insertions(+), 38 deletions(-) create mode 100644 crates/broker/src/relaycast/agent_token_rotation.rs create mode 100644 packages/cli/src/cli/lib/agent-token-file.test.ts create mode 100644 packages/cli/src/cli/lib/agent-token-file.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b5bce4fa9..45886e5052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Broker run-token mode: with `RELAY_RUN_TOKEN_FILE` set and no `rk_` workspace key, the broker registers itself and its workers, mints its node, and releases workers with a short-lived Relaycast run token (`rt_live_`) that it reads from the file on each request. It never creates a workspace in this mode, and workers get only their own agent token. +- In run-token mode the broker rotates its own and every worker's 15-minute Relaycast agent token before it expires. Workers get a `RELAY_AGENT_TOKEN_FILE` that the broker replaces atomically on each rotation, and the Agent Relay MCP server and CLI read that file on every request, with no workspace key required. - `HarnessDriverClient.spawn({ runTokenFile })` (or `RELAY_RUN_TOKEN_FILE`) starts the broker in run-token mode and passes it the file path only, with no workspace-key variables. ### Security diff --git a/crates/broker/src/devin.rs b/crates/broker/src/devin.rs index 1d78af38ae..5f6ce14b2f 100644 --- a/crates/broker/src/devin.rs +++ b/crates/broker/src/devin.rs @@ -164,11 +164,19 @@ pub(crate) async fn prepare_worker_config(cli: &str) -> Result"}`) in a broker-owned 0700 +//! directory. The broker replaces that file atomically (0600 temp file, then +//! rename) on every rotation, and the worker's MCP server/CLI reads it per +//! request. +//! +//! Token values never appear in logs or error messages. + +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::time::Duration; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +/// Env var naming a worker's agent token file (replaces `RELAY_AGENT_TOKEN` +/// in run-token mode). +pub const AGENT_TOKEN_FILE_ENV: &str = "RELAY_AGENT_TOKEN_FILE"; + +/// Rotate no later than this long before expiry. +pub const ROTATE_BEFORE_EXPIRY: Duration = Duration::from_secs(5 * 60); + +/// A token whose expiry is unknown (supplied by a caller, or issued by an +/// engine that predates #550) is rotated once after this delay so the +/// rotation response reveals its real expiry. +pub const UNKNOWN_EXPIRY_PROBE_DELAY: Duration = Duration::from_secs(60); + +/// Backoff between attempts after a transient rotation failure. Relaycast +/// keeps the previous token valid for a 60-second grace window (never past its +/// own expiry), so a rotation whose response was lost can be retried with the +/// token we still hold. +pub const ROTATION_RETRY_BACKOFFS: [Duration; 5] = [ + Duration::from_secs(2), + Duration::from_secs(5), + Duration::from_secs(10), + Duration::from_secs(20), + Duration::from_secs(30), +]; + +/// Internal marker that lets the existing MCP-config builders, which take the +/// worker's credential as `Option<&str>`, carry a token *file* reference +/// instead of a token value. It never leaves the broker: [`agent_token_env`] +/// turns it into `RELAY_AGENT_TOKEN_FILE=` at every emission point. +const AGENT_TOKEN_FILE_REF_PREFIX: &str = "relay-agent-token-file:"; + +/// Encode a worker token file path as a credential reference for the MCP +/// config builders. +pub fn agent_token_file_ref(path: &Path) -> String { + format!("{AGENT_TOKEN_FILE_REF_PREFIX}{}", path.display()) +} + +/// The env entry a worker credential becomes: `RELAY_AGENT_TOKEN_FILE=` +/// for a file reference, otherwise `RELAY_AGENT_TOKEN=` (legacy). +pub fn agent_token_env(credential: &str) -> (&'static str, String) { + match credential.strip_prefix(AGENT_TOKEN_FILE_REF_PREFIX) { + Some(path) => (AGENT_TOKEN_FILE_ENV, path.to_string()), + None => ("RELAY_AGENT_TOKEN", credential.to_string()), + } +} + +/// Clock seam so rotation scheduling can be tested with a fake clock. +pub trait RotationClock: Send + Sync { + fn now(&self) -> DateTime; + fn sleep(&self, duration: Duration) -> Pin + Send>>; +} + +/// Wall clock plus tokio timers. +pub struct SystemRotationClock; + +impl RotationClock for SystemRotationClock { + fn now(&self) -> DateTime { + Utc::now() + } + + fn sleep(&self, duration: Duration) -> Pin + Send>> { + Box::pin(tokio::time::sleep(duration)) + } +} + +/// When to rotate a token that expires at `expires_at`: at two thirds of the +/// remaining lifetime or [`ROTATE_BEFORE_EXPIRY`] before expiry, whichever is +/// earlier. A token already inside that window rotates immediately. +pub fn rotation_delay(now: DateTime, expires_at: DateTime) -> Duration { + let Ok(lifetime) = (expires_at - now).to_std() else { + return Duration::ZERO; + }; + let two_thirds = lifetime.mul_f64(2.0 / 3.0); + let before_expiry = lifetime.saturating_sub(ROTATE_BEFORE_EXPIRY); + two_thirds.min(before_expiry) +} + +/// `POST /v1/agents/:name/rotate-token` response, including the #550 expiry +/// the SDK type does not yet carry. +#[derive(Debug, Deserialize)] +pub struct RotateTokenResponse { + pub token: String, + #[serde(default)] + pub token_expires_at: Option, +} + +/// Parse a Relaycast `token_expires_at` value. Malformed values are treated as +/// unknown (and therefore rotated soon), never as "no expiry". +pub fn parse_token_expiry(raw: Option<&str>) -> Option> { + raw.map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|value| value.with_timezone(&Utc)) +} + +/// Deterministic per-worker file path inside the broker-owned token +/// directory. The name is sanitized for the filesystem and suffixed with a +/// hash so distinct agent names never collide. +pub fn worker_agent_token_file(dir: &Path, agent_name: &str) -> PathBuf { + let sanitized: String = agent_name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .take(64) + .collect(); + let digest = format!("{:x}", Sha256::digest(agent_name.as_bytes())); + dir.join(format!("{sanitized}-{}.json", &digest[..12])) +} + +/// Create (or tighten) the broker-owned token directory to mode 0700. +pub fn ensure_agent_token_dir(dir: &Path) -> Result<()> { + std::fs::create_dir_all(dir) + .with_context(|| format!("failed to create agent token directory {}", dir.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).with_context( + || { + format!( + "failed to set mode 0700 on agent token directory {}", + dir.display() + ) + }, + )?; + } + Ok(()) +} + +/// Atomically replace a worker's access-only token file: write a 0600 temp +/// file in the same directory, fsync it, then rename it over the target. A +/// reader sees either the previous complete file or the new one, never a +/// partial write. +pub fn write_agent_token_file( + path: &Path, + token: &str, + expires_at: Option>, +) -> Result<()> { + use std::io::Write; + + let dir = path + .parent() + .context("agent token file path has no parent directory")?; + ensure_agent_token_dir(dir)?; + let mut body = serde_json::Map::new(); + body.insert("token".into(), serde_json::Value::String(token.to_string())); + if let Some(expires_at) = expires_at { + body.insert( + "expiresAt".into(), + serde_json::Value::String(expires_at.to_rfc3339()), + ); + } + let tmp = dir.join(format!( + ".{}.{}.tmp", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("agent-token"), + uuid::Uuid::new_v4().simple() + )); + let write = || -> Result<()> { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&tmp) + .with_context(|| format!("failed to create {}", tmp.display()))?; + file.write_all(serde_json::Value::Object(body).to_string().as_bytes()) + .context("failed to write agent token file")?; + file.sync_all().context("failed to sync agent token file")?; + std::fs::rename(&tmp, path).with_context(|| { + format!( + "failed to move agent token file into place at {}", + path.display() + ) + })?; + Ok(()) + }; + let result = write(); + if result.is_err() { + let _ = std::fs::remove_file(&tmp); + } + result +} + +#[cfg(test)] +pub(crate) mod test_support { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Mutex; + + use super::*; + + /// Fake clock: `sleep` advances virtual time instantly for the first + /// `allowed` sleeps, records every requested duration, and parks forever + /// afterwards so a rotation loop cannot spin. + pub(crate) struct FakeRotationClock { + now: Mutex>, + pub(crate) sleeps: Mutex>, + allowed: AtomicUsize, + } + + impl FakeRotationClock { + pub(crate) fn new(start: DateTime, allowed: usize) -> Self { + Self { + now: Mutex::new(start), + sleeps: Mutex::new(Vec::new()), + allowed: AtomicUsize::new(allowed), + } + } + } + + impl RotationClock for FakeRotationClock { + fn now(&self) -> DateTime { + *self.now.lock().unwrap() + } + + fn sleep(&self, duration: Duration) -> Pin + Send>> { + self.sleeps.lock().unwrap().push(duration); + let permitted = self + .allowed + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| { + left.checked_sub(1) + }) + .is_ok(); + if permitted { + let mut now = self.now.lock().unwrap(); + *now += chrono::Duration::from_std(duration).unwrap(); + Box::pin(std::future::ready(())) + } else { + Box::pin(std::future::pending()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(minutes: i64) -> DateTime { + DateTime::parse_from_rfc3339("2026-10-17T12:00:00Z") + .unwrap() + .with_timezone(&Utc) + + chrono::Duration::minutes(minutes) + } + + #[test] + fn rotation_happens_at_the_earlier_of_two_thirds_or_five_minutes_before_expiry() { + // 15 minutes: both rules give 10 minutes. + assert_eq!(rotation_delay(at(0), at(15)), Duration::from_secs(600)); + // 60 minutes (policy cap): two thirds (40m) is earlier than 55m. + assert_eq!(rotation_delay(at(0), at(60)), Duration::from_secs(2400)); + // 6 minutes: five minutes before expiry (1m) is earlier than 4m. + assert_eq!(rotation_delay(at(0), at(6)), Duration::from_secs(60)); + // Inside the window or already expired: rotate now. + assert_eq!(rotation_delay(at(0), at(3)), Duration::ZERO); + assert_eq!(rotation_delay(at(10), at(5)), Duration::ZERO); + } + + #[test] + fn file_refs_become_token_file_env_and_tokens_stay_legacy() { + let path = Path::new("/run/agent-tokens/w-abc.json"); + assert_eq!( + agent_token_env(&agent_token_file_ref(path)), + ( + AGENT_TOKEN_FILE_ENV, + "/run/agent-tokens/w-abc.json".to_string() + ) + ); + assert_eq!( + agent_token_env("at_live_x"), + ("RELAY_AGENT_TOKEN", "at_live_x".to_string()) + ); + } + + #[test] + fn worker_file_names_are_sanitized_and_distinct() { + let dir = Path::new("/tmp/tokens"); + let a = worker_agent_token_file(dir, "../evil name"); + let b = worker_agent_token_file(dir, "../evil_name"); + assert_eq!(a.parent(), Some(dir)); + assert!(!a.file_name().unwrap().to_str().unwrap().contains('/')); + assert_ne!(a, b); + } + + #[cfg(unix)] + #[test] + fn token_file_is_replaced_atomically_with_mode_0600_in_a_0700_dir() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("agent-tokens"); + let path = worker_agent_token_file(&dir, "worker-1"); + + write_agent_token_file(&path, "at_live_first", Some(at(15))).unwrap(); + let first_inode = std::fs::metadata(&path).unwrap().ino(); + write_agent_token_file(&path, "at_live_second", Some(at(30))).unwrap(); + let meta = std::fs::metadata(&path).unwrap(); + + assert_eq!(meta.permissions().mode() & 0o777, 0o600); + assert_eq!( + std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, + 0o700 + ); + // Rename, not in-place rewrite: a reader never sees a torn file. + assert_ne!(meta.ino(), first_inode); + let body: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(body["token"], "at_live_second"); + assert_eq!(body["expiresAt"], at(30).to_rfc3339()); + assert!(body.get("refreshToken").is_none()); + // No temp files left behind. + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp")) + .collect(); + assert!(leftovers.is_empty()); + } + + #[test] + fn malformed_expiry_is_unknown() { + assert_eq!(parse_token_expiry(Some("not a date")), None); + assert_eq!(parse_token_expiry(None), None); + assert_eq!( + parse_token_expiry(Some("2026-10-17T12:15:00.000Z")), + Some(at(15)) + ); + } +} diff --git a/crates/broker/src/relaycast/auth.rs b/crates/broker/src/relaycast/auth.rs index fa6e1514c5..5c0ce83b5e 100644 --- a/crates/broker/src/relaycast/auth.rs +++ b/crates/broker/src/relaycast/auth.rs @@ -62,6 +62,9 @@ pub struct AuthSession { pub credentials: CredentialCache, pub token: String, pub created_new: bool, + /// `token_expires_at` of `token` when the server reported one (#550). + /// Run-token mode rotates the broker's own token before this deadline. + pub token_expires_at: Option>, } #[derive(Debug, Clone, Deserialize)] @@ -636,6 +639,7 @@ impl AuthClient { credentials: creds, token, created_new: false, + token_expires_at: None, }) } @@ -848,7 +852,7 @@ impl AuthClient { preferred_name = %name, "attempting registration with run token (run-token mode)" ); - let registration = register_agent_with_run_token( + let (registration, token_expires_at) = register_agent_with_run_token( self.base_url.as_deref(), source, &name, @@ -864,7 +868,8 @@ impl AuthClient { .filter(|value| !value.is_empty()); // `api_key` stays empty: run-token mode holds no workspace key, and an // empty key keeps every legacy key-injection path inert. - let session = self.finish_session(String::new(), workspace_id_hint, registration)?; + let mut session = self.finish_session(String::new(), workspace_id_hint, registration)?; + session.token_expires_at = token_expires_at; Ok(AuthSessionSet { default_workspace_id: Some(session.credentials.workspace_id.clone()), memberships: vec![session], @@ -956,6 +961,7 @@ impl AuthClient { credentials: creds, token, created_new, + token_expires_at: None, }) } @@ -1958,7 +1964,19 @@ async fn register_agent_with_run_token( identity_key: Option<&str>, waiter_id: Option<&str>, startup_deadline: Option, -) -> Result<(String, String, String, Option, bool)> { +) -> Result<( + (String, String, String, Option, bool), + Option>, +)> { + /// The #550 expiry rides alongside the SDK's registration shape. + #[derive(Deserialize)] + struct CreatedWithExpiry { + #[serde(flatten)] + agent: relaycast::CreateAgentResponse, + #[serde(default)] + token_expires_at: Option, + } + let metadata = identity_key.map(|key| { let mut map = serde_json::Map::new(); map.insert( @@ -1988,7 +2006,7 @@ async fn register_agent_with_run_token( "registering the broker agent with the run token", startup_deadline, || { - http.post::( + http.post::( "/v1/agents", Some(request), Some(relaycast::RequestOptions { @@ -2007,16 +2025,19 @@ async fn register_agent_with_run_token( .await; match result { - Ok(response) if response.token.trim().is_empty() => { + Ok(response) if response.agent.token.trim().is_empty() => { anyhow::bail!("run-token registration for '{name}' returned a blank agent token") } - Ok(response) => Ok(( - response.id, - response.name, - response.token, - response.workspace_id, - true, - )), + Ok(response) => { + let expires_at = super::agent_token_rotation::parse_token_expiry( + response.token_expires_at.as_deref(), + ); + let agent = response.agent; + Ok(( + (agent.id, agent.name, agent.token, agent.workspace_id, true), + expires_at, + )) + } Err(super::run_token::RunTokenCallError::Read(error)) => { Err(error).context("run-token mode could not read the run token file") } @@ -5769,7 +5790,7 @@ mod tests { .path("/v1/agents") .header("authorization", "Bearer rt_live_startup"); then.status(201).header("content-type", "application/json").body( - r#"{"ok":true,"data":{"id":"a_rt","workspace_id":"ws_run","run_id":"run_1","name":"lead","token":"at_live_rt","status":"online","created_at":"2025-01-01T00:00:00Z"}}"#, + r#"{"ok":true,"data":{"id":"a_rt","workspace_id":"ws_run","run_id":"run_1","name":"lead","token":"at_live_rt","token_expires_at":"2026-10-17T12:15:00.000Z","status":"online","created_at":"2025-01-01T00:00:00Z"}}"#, ); }); @@ -5782,6 +5803,11 @@ mod tests { let session = sessions.default_session().unwrap(); assert_eq!(session.token, "at_live_rt"); assert_eq!(session.credentials.workspace_id, "ws_run"); + // The broker rotates its own token against this deadline. + assert_eq!( + session.token_expires_at.map(|at| at.to_rfc3339()), + Some("2026-10-17T12:15:00+00:00".to_string()) + ); assert!( session.credentials.api_key.is_empty(), "run-token mode must hold no workspace key" diff --git a/crates/broker/src/relaycast/mod.rs b/crates/broker/src/relaycast/mod.rs index 92760544a0..97e532197b 100644 --- a/crates/broker/src/relaycast/mod.rs +++ b/crates/broker/src/relaycast/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod agent_token_rotation; pub(crate) mod auth; pub(crate) mod bridge; pub(crate) mod dm_participants; diff --git a/crates/broker/src/relaycast/workspace.rs b/crates/broker/src/relaycast/workspace.rs index eebfc4a664..975b3e0d09 100644 --- a/crates/broker/src/relaycast/workspace.rs +++ b/crates/broker/src/relaycast/workspace.rs @@ -108,6 +108,19 @@ impl MultiWorkspaceSession { ), }; http_client.seed_agent_token(&self_name, &self_token); + if run_token.is_some() { + // Run-token mode: rotate the broker's own short-lived agent + // token before it expires. Its node connection authenticates + // with the node token, so no session needs reconnecting. + http_client.record_token_expiry(&self_name, session.token_expires_at); + if let Err(error) = http_client.ensure_agent_token_rotation(&self_name, None) { + tracing::error!( + agent = %self_name, + error = %error, + "failed to start broker agent token rotation" + ); + } + } // Node-only delivery (v5.0.1): messages flow over /v1/node/ws and are // injected by the fleet handlers. The legacy `/v1/ws` workspace-stream diff --git a/crates/broker/src/relaycast/ws.rs b/crates/broker/src/relaycast/ws.rs index 30470164f3..80581a4d0e 100644 --- a/crates/broker/src/relaycast/ws.rs +++ b/crates/broker/src/relaycast/ws.rs @@ -20,6 +20,10 @@ use uuid::Uuid; use crate::{fleet_wire::AgentRegistrationMetadata, protocol::MessageInjectionMode}; +use super::agent_token_rotation::{ + parse_token_expiry, rotation_delay, write_agent_token_file, RotateTokenResponse, RotationClock, + SystemRotationClock, ROTATION_RETRY_BACKOFFS, UNKNOWN_EXPIRY_PROBE_DELAY, +}; use super::run_token::{ call_with_run_token, relay_error_is_unauthorized, RunTokenCallError, RunTokenSource, RUN_TOKEN_MODE_PLACEHOLDER_BEARER, @@ -75,6 +79,54 @@ pub struct RelaycastHttpClient { /// empty and the run-token-capable endpoints (`POST /v1/agents`, /// `DELETE /v1/agents/:name`) read the token from this file per request. run_token: Option, + /// Run-token mode: agent token rotation state shared by every clone. + rotation: Arc, +} + +/// Proactive agent token rotation bookkeeping (run-token mode only). +struct AgentTokenRotationState { + /// Known `token_expires_at` per agent name; `None` means issued without a + /// known expiry (probe-rotated once to learn it). + expiries: StdMutex>>>, + /// Running rotation task per agent name, plus the worker token file it + /// maintains (removed when rotation stops). + tasks: StdMutex)>>, + /// SHA-256 hashes of tokens this broker superseded by rotating them. Lets + /// owned-identity cleanup recognize a token hash captured before a + /// rotation as still ours. + lineage: StdMutex>>, + clock: Arc, +} + +impl Default for AgentTokenRotationState { + fn default() -> Self { + Self { + expiries: StdMutex::new(HashMap::new()), + tasks: StdMutex::new(HashMap::new()), + lineage: StdMutex::new(HashMap::new()), + clock: Arc::new(SystemRotationClock), + } + } +} + +/// `POST /v1/agents` response including the #550 `token_expires_at` the SDK +/// type does not yet carry. +#[derive(serde::Deserialize)] +struct CreateAgentWithExpiry { + #[serde(flatten)] + agent: CreateAgentResponse, + #[serde(default)] + token_expires_at: Option, +} + +fn lock_unpoisoned(mutex: &StdMutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn token_hash(token: &str) -> String { + format!("{:x}", Sha256::digest(token.as_bytes())) } pub type RelaycastRegistrationError = AgentRegistrationError; @@ -356,6 +408,7 @@ impl RelaycastHttpClient { agent_name: agent_name.into(), default_cli: String::new(), run_token: None, + rotation: Arc::new(AgentTokenRotationState::default()), } } @@ -383,6 +436,7 @@ impl RelaycastHttpClient { agent_name: agent_name.into(), default_cli, run_token: None, + rotation: Arc::new(AgentTokenRotationState::default()), } } @@ -441,18 +495,224 @@ impl RelaycastHttpClient { .post::("/v1/agents", Some(body), Some(options)) .await; }; - call_with_run_token(source, relay_error_is_unauthorized, |token| { + let created = call_with_run_token(source, relay_error_is_unauthorized, |token| { let options = options.clone(); async move { let client = relay.as_agent(token)?; client .http_client() - .post::("/v1/agents", Some(body), Some(options)) + .post::("/v1/agents", Some(body), Some(options)) .await } }) .await - .map_err(run_token_call_error_to_relay_error) + .map_err(run_token_call_error_to_relay_error)?; + // A fresh identity: record its expiry for proactive rotation and drop + // any rotation lineage left by an earlier identity under this name. + self.record_token_expiry( + &created.agent.name, + parse_token_expiry(created.token_expires_at.as_deref()), + ); + lock_unpoisoned(&self.rotation.lineage).remove(&created.agent.name); + Ok(created.agent) + } + + /// Record a token's expiry (run-token mode rotation input). + pub(crate) fn record_token_expiry( + &self, + agent_name: &str, + expires_at: Option>, + ) { + lock_unpoisoned(&self.rotation.expiries).insert(agent_name.to_string(), expires_at); + } + + fn token_expiry(&self, agent_name: &str) -> Option> { + lock_unpoisoned(&self.rotation.expiries) + .get(agent_name) + .copied() + .flatten() + } + + #[cfg(test)] + pub(crate) fn set_rotation_clock_for_test(&mut self, clock: Arc) { + let mut state = AgentTokenRotationState::default(); + state.clock = clock; + self.rotation = Arc::new(state); + } + + /// Run-token mode: start proactive rotation of `agent_name`'s agent token + /// (idempotent while a rotation task is running). With `token_file`, the + /// worker's access-only file is written now from the cached token and + /// atomically replaced on every rotation. Legacy mode is a no-op. + pub(crate) fn ensure_agent_token_rotation( + &self, + agent_name: &str, + token_file: Option, + ) -> Result<()> { + if self.run_token.is_none() { + return Ok(()); + } + let mut tasks = lock_unpoisoned(&self.rotation.tasks); + if let Some((handle, _)) = tasks.get(agent_name) { + if !handle.is_finished() { + // The running task owns the file; never write a possibly + // stale cached token over a fresher rotation. + return Ok(()); + } + } + let token = self.cached_agent_token(agent_name).with_context(|| { + format!("cannot rotate agent '{agent_name}': no cached agent token") + })?; + if let Some(path) = token_file.as_deref() { + write_agent_token_file(path, &token, self.token_expiry(agent_name)) + .with_context(|| format!("failed to write agent token file for '{agent_name}'"))?; + } + drop(token); + let client = self.clone(); + let name = agent_name.to_string(); + let file = token_file.clone(); + let handle = + tokio::spawn(async move { client.agent_token_rotation_loop(name, file).await }); + tasks.insert(agent_name.to_string(), (handle.abort_handle(), token_file)); + Ok(()) + } + + /// Stop rotating `agent_name` and remove its worker token file. + pub(crate) fn stop_agent_token_rotation(&self, agent_name: &str) { + if let Some((handle, file)) = lock_unpoisoned(&self.rotation.tasks).remove(agent_name) { + handle.abort(); + if let Some(path) = file { + let _ = std::fs::remove_file(path); + } + } + lock_unpoisoned(&self.rotation.expiries).remove(agent_name); + } + + async fn agent_token_rotation_loop(self, name: String, token_file: Option) { + let clock = Arc::clone(&self.rotation.clock); + loop { + let delay = match self.token_expiry(&name) { + Some(expires_at) => rotation_delay(clock.now(), expires_at), + None => UNKNOWN_EXPIRY_PROBE_DELAY, + }; + clock.sleep(delay).await; + // Read the current token at rotation time: a re-registration + // since the last rotation reseeds the cache. + let Some(current) = self.cached_agent_token(&name) else { + tracing::info!(agent = %name, "agent token no longer cached; stopping rotation"); + return; + }; + let Some(rotated) = self.rotate_agent_token_with_retries(&name, ¤t).await else { + return; + }; + let expires_at = parse_token_expiry(rotated.token_expires_at.as_deref()); + lock_unpoisoned(&self.rotation.lineage) + .entry(name.clone()) + .or_default() + .insert(token_hash(¤t)); + drop(current); + // Publish to the broker's cache first, then the worker's file. + // The previous token stays valid for Relaycast's 60-second grace + // window, so requests in flight with it still succeed. + self.seed_agent_token(&name, &rotated.token); + self.record_token_expiry(&name, expires_at); + if let Some(path) = token_file.as_deref() { + if let Err(error) = write_agent_token_file(path, &rotated.token, expires_at) { + tracing::error!( + agent = %name, + path = %path.display(), + error = %error, + "failed to publish rotated agent token to the worker's token file" + ); + } + } + tracing::info!( + agent = %name, + expires_at = ?expires_at, + "rotated agent token before expiry" + ); + if expires_at.is_none() { + tracing::info!( + agent = %name, + "rotated token carries no expiry (engine predates token expiry); stopping rotation" + ); + return; + } + } + } + + /// One logical rotation via `POST /v1/agents/:name/rotate-token`, + /// authenticated by the agent's own current token. Transient failures are + /// retried while the token can still authenticate; a 401/403 fails closed + /// (no takeover, no re-registration), as does a vanished agent. + async fn rotate_agent_token_with_retries( + &self, + name: &str, + current: &str, + ) -> Option { + let clock = Arc::clone(&self.rotation.clock); + let relay = (*self.relay).as_ref()?; + let path = format!("/v1/agents/{}/rotate-token", urlencoding::encode(name)); + // One more attempt than there are backoffs; the last returns instead. + #[allow(clippy::needless_range_loop)] + for attempt in 0..=ROTATION_RETRY_BACKOFFS.len() { + let result = match relay.as_agent(current) { + Ok(client) => { + client + .http_client() + .post::(&path, Some(serde_json::json!({})), None) + .await + } + Err(error) => Err(error), + }; + match result { + Ok(rotated) if !rotated.token.trim().is_empty() => return Some(rotated), + Ok(_) => { + tracing::error!(agent = %name, "agent token rotation returned a blank token; failing closed"); + return None; + } + Err(RelayError::Api { + status: status @ (401 | 403), + ref code, + .. + }) => { + tracing::error!( + agent = %name, + http_status = status, + code = %code, + "agent token rotation was rejected; failing closed (run-token mode never \ + takes over or re-registers an identity)" + ); + return None; + } + Err(RelayError::Api { status: 404, .. }) => { + tracing::warn!(agent = %name, "agent no longer exists; stopping token rotation"); + return None; + } + Err(error) => { + let expired = self + .token_expiry(name) + .is_some_and(|expires_at| expires_at <= clock.now()); + if attempt >= ROTATION_RETRY_BACKOFFS.len() || expired { + tracing::error!( + agent = %name, + attempts = attempt + 1, + error = %error, + "agent token rotation failed; giving up" + ); + return None; + } + tracing::warn!( + agent = %name, + attempt = attempt + 1, + error = %error, + "agent token rotation failed; retrying" + ); + clock.sleep(ROTATION_RETRY_BACKOFFS[attempt]).await; + } + } + } + None } /// Run-token mode release: `DELETE /v1/agents/:name` with the run token. @@ -471,8 +731,11 @@ impl RelaycastHttpClient { // server-side. DELETE has no such guard, so refuse locally when the // cache proves the name now belongs to a replacement identity. if let Some(expected) = expected_token_hash { + let rotated_from_ours = lock_unpoisoned(&self.rotation.lineage) + .get(agent_name) + .is_some_and(|hashes| hashes.contains(expected)); if let Some(cached) = self.cached_agent_token(agent_name) { - if format!("{:x}", Sha256::digest(cached.as_bytes())) != expected { + if token_hash(&cached) != expected && !rotated_from_ours { tracing::info!( agent = %agent_name, "run-token release skipped: the name was re-registered by a replacement" @@ -482,7 +745,10 @@ impl RelaycastHttpClient { } } // Invalidate first, as the legacy path does: an ambiguous response - // must never leave a released token cached for reuse. + // must never leave a released token cached for reuse. A released + // identity is no longer rotated, and its token file is removed. + self.stop_agent_token_rotation(agent_name); + lock_unpoisoned(&self.rotation.lineage).remove(agent_name); self.invalidate_cached_registration(agent_name); let path = format!("/v1/agents/{}", urlencoding::encode(agent_name)); let result = call_with_run_token(source, relay_error_is_unauthorized, |token| { @@ -7497,4 +7763,178 @@ mod run_token_mode_tests { .expect("lookup with the worker's own token"); detail.assert_hits(1); } + + // ── Proactive agent token rotation ────────────────────────────────── + + use std::sync::Arc; + use std::time::Duration; + + use crate::relaycast::agent_token_rotation::test_support::FakeRotationClock; + + fn start_instant() -> chrono::DateTime { + // Whole seconds so RFC 3339 round trips exactly. + let now = chrono::Utc::now().timestamp(); + chrono::DateTime::from_timestamp(now, 0).unwrap() + } + + async fn wait_for(mut condition: impl FnMut() -> bool) { + for _ in 0..200 { + if condition() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("condition not met in time"); + } + + fn file_token(path: &std::path::Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + let body: serde_json::Value = serde_json::from_str(&raw).ok()?; + body["token"].as_str().map(str::to_string) + } + + #[tokio::test] + async fn worker_token_is_rotated_before_expiry_and_its_file_replaced() { + let server = MockServer::start(); + let dir = tempfile::tempdir().unwrap(); + let run_file = dir.path().join("relaycast-run.json"); + write_run_token_file(&run_file, "rt_live_cur", &future_expiry()); + let start = start_instant(); + let clock = Arc::new(FakeRotationClock::new(start, 1)); + let mut client = run_token_client(&server, RunTokenSource::new(&run_file)); + client.set_rotation_clock_for_test(clock.clone()); + client.seed_agent_token("broker", "at_live_broker"); + + let expires = start + chrono::Duration::minutes(15); + let mut created = agent_created("worker-1", "at_live_w1"); + created["data"]["token_expires_at"] = json!(expires.to_rfc3339()); + server.mock(|when, then| { + when.method(POST) + .path("/v1/agents") + .header("authorization", "Bearer rt_live_cur"); + then.status(201).json_body(created.clone()); + }); + let rotated_expiry = start + chrono::Duration::minutes(25); + let rotate = server.mock(|when, then| { + when.method(POST) + .path("/v1/agents/worker-1/rotate-token") + .header("authorization", "Bearer at_live_w1"); + then.status(200).json_body(json!({"ok": true, "data": { + "name": "worker-1", + "token": "at_live_w1_rotated", + "token_expires_at": rotated_expiry.to_rfc3339() + }})); + }); + let token = register_new_spawn_identity(&client, "worker-1", Some("codex")) + .await + .unwrap(); + assert_eq!(token, "at_live_w1"); + + let token_file = dir.path().join("agent-tokens").join("worker-1.json"); + client + .ensure_agent_token_rotation("worker-1", Some(token_file.clone())) + .unwrap(); + assert_eq!(file_token(&token_file).as_deref(), Some("at_live_w1")); + + wait_for(|| file_token(&token_file).as_deref() == Some("at_live_w1_rotated")).await; + rotate.assert_hits(1); + // Rotation was scheduled 10 minutes in: before the 15-minute expiry. + let first_sleep = clock.sleeps.lock().unwrap()[0]; + assert_eq!(first_sleep, Duration::from_secs(600)); + assert!(first_sleep < Duration::from_secs(15 * 60)); + assert_eq!( + client.cached_agent_token("worker-1").as_deref(), + Some("at_live_w1_rotated") + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&token_file).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + // The next rotation is scheduled against the new expiry. + wait_for(|| clock.sleeps.lock().unwrap().len() >= 2).await; + assert!(clock.sleeps.lock().unwrap()[1] <= Duration::from_secs(10 * 60)); + + // Owned cleanup captured the pre-rotation token hash; the rotation + // lineage still proves the identity is ours, so DELETE proceeds and + // the token file is removed. + let original_hash = super::token_hash("at_live_w1"); + let delete = server.mock(|when, then| { + when.method(DELETE) + .path("/v1/agents/worker-1") + .header("authorization", "Bearer rt_live_cur"); + then.status(204); + }); + client + .release_agent_identity_guarded("worker-1", None, true, Some(&original_hash)) + .await + .unwrap(); + delete.assert_hits(1); + assert!(!token_file.exists()); + } + + #[tokio::test] + async fn a_rejected_rotation_fails_closed_without_takeover_or_reregistration() { + let server = MockServer::start(); + let dir = tempfile::tempdir().unwrap(); + let run_file = dir.path().join("relaycast-run.json"); + write_run_token_file(&run_file, "rt_live_cur", &future_expiry()); + let start = start_instant(); + let clock = Arc::new(FakeRotationClock::new(start, 1)); + let mut client = run_token_client(&server, RunTokenSource::new(&run_file)); + client.set_rotation_clock_for_test(clock.clone()); + client.seed_agent_token("broker", "at_live_broker"); + client.record_token_expiry("broker", Some(start + chrono::Duration::minutes(15))); + + let rotate = server.mock(|when, then| { + when.method(POST).path("/v1/agents/broker/rotate-token"); + then.status(401).json_body(json!({"ok": false, "error": { + "code": "agent_token_expired", "message": "expired"}})); + }); + let register = server.mock(|when, then| { + when.method(POST).path("/v1/agents"); + then.status(201) + .json_body(agent_created("broker", "at_live_new")); + }); + let takeover = server.mock(|when, then| { + when.method(POST).path("/v1/agents/broker/takeover"); + then.status(200).json_body(json!({"ok": true, "data": {}})); + }); + + client.ensure_agent_token_rotation("broker", None).unwrap(); + wait_for(|| rotate.hits() == 1).await; + tokio::time::sleep(Duration::from_millis(50)).await; + rotate.assert_hits(1); + register.assert_hits(0); + takeover.assert_hits(0); + // The loop exited instead of sleeping for another rotation. + assert_eq!(clock.sleeps.lock().unwrap().len(), 1); + assert_eq!( + client.cached_agent_token("broker").as_deref(), + Some("at_live_broker") + ); + } + + #[tokio::test] + async fn legacy_mode_never_starts_rotation() { + let server = MockServer::start(); + let client = + RelaycastHttpClient::new(Some(server.base_url()), "rk_live_test", "broker", "codex"); + client.seed_agent_token("worker-1", "at_live_w1"); + let dir = tempfile::tempdir().unwrap(); + let token_file = dir.path().join("worker-1.json"); + client + .ensure_agent_token_rotation("worker-1", Some(token_file.clone())) + .unwrap(); + assert!(!token_file.exists()); + let rotate = server.mock(|when, then| { + when.method(POST).path_contains("rotate-token"); + then.status(200); + }); + tokio::time::sleep(Duration::from_millis(50)).await; + rotate.assert_hits(0); + } } diff --git a/crates/broker/src/runtime/api.rs b/crates/broker/src/runtime/api.rs index ee2bd9d919..2b06bf6894 100644 --- a/crates/broker/src/runtime/api.rs +++ b/crates/broker/src/runtime/api.rs @@ -777,6 +777,34 @@ impl BrokerRuntime { // agent_spawned broadcast would erase valid current-generation // history. A duplicate live name is not a replacement attempt // and must retain its existing history. + // Run-token mode: the worker reads its short-lived agent token + // from a broker-rotated file. Start rotation (writing the file) + // before the worker can launch; a failure is an admission failure. + if let (Some(_), Some(token_file)) = ( + worker_relay_key.as_deref(), + workers.agent_token_file_for(&name), + ) { + if let Err(error) = + relaycast_http.ensure_agent_token_rotation(&name, Some(token_file)) + { + super::identity_cleanup::schedule_identity_cleanup( + workers, + fleet_control_tx, + fleet_delivery_book, + fleet_inventory, + relaycast_http, + &name, + owns_identity, + Some(super::identity_cleanup::CleanupCompletion::Api( + reply, + Err(format!( + "worker agent token file could not be prepared: {error:#}" + )), + )), + ); + return; + } + } if !workers.has_worker(&name) { replay_buffer.reset_agent_event_history(&name).await; } diff --git a/crates/broker/src/runtime/init.rs b/crates/broker/src/runtime/init.rs index cc33668fec..bff3780101 100644 --- a/crates/broker/src/runtime/init.rs +++ b/crates/broker/src/runtime/init.rs @@ -679,6 +679,17 @@ pub(crate) async fn run_init(cmd: InitCommand, telemetry: TelemetryClient) -> Re .state .parent() .map(|parent| parent.join("owned-cleanups.json")); + if run_token_mode { + // Broker-owned 0700 directory of per-worker agent token files; the + // broker rotates each worker's short-lived token into its file. + let dir = paths + .state + .parent() + .expect("state path should always have a parent") + .join("agent-tokens"); + crate::relaycast::agent_token_rotation::ensure_agent_token_dir(&dir)?; + workers.agent_token_dir = Some(dir); + } // Load crash insights from previous session let crash_insights_path = paths.state.parent().unwrap().join("crash-insights.json"); diff --git a/crates/broker/src/runtime/maintenance.rs b/crates/broker/src/runtime/maintenance.rs index cd4c4b0ab3..9caaa07738 100644 --- a/crates/broker/src/runtime/maintenance.rs +++ b/crates/broker/src/runtime/maintenance.rs @@ -678,6 +678,21 @@ impl BrokerRuntime { } } + if let (Some(_), Some(token_file)) = ( + worker_relay_key.as_deref(), + workers.agent_token_file_for(&name), + ) { + if let Err(error) = + relaycast_http.ensure_agent_token_rotation(&name, Some(token_file)) + { + tracing::error!( + worker = %name, + error = %error, + "failed to prepare worker agent token file before restart" + ); + continue; + } + } match workers .spawn( rst.payload.spec.clone(), diff --git a/crates/broker/src/snippets.rs b/crates/broker/src/snippets.rs index c50354c3d9..65ed61c5ef 100644 --- a/crates/broker/src/snippets.rs +++ b/crates/broker/src/snippets.rs @@ -658,7 +658,9 @@ fn agent_relay_mcp_server_config( ); } if let Some(token) = relay_agent_token.map(str::trim).filter(|s| !s.is_empty()) { - env.insert("RELAY_AGENT_TOKEN".into(), Value::String(token.to_string())); + let (token_key, token_value) = + crate::relaycast::agent_token_rotation::agent_token_env(token); + env.insert(token_key.into(), Value::String(token_value)); // Skip bootstrap when the broker has already pre-registered the agent — // prevents blocking HTTP calls during MCP initialize handshake. env.insert( @@ -838,7 +840,8 @@ pub fn ensure_opencode_config_with_result( ); } if let Some(v) = relay_agent_token.map(str::trim).filter(|s| !s.is_empty()) { - env.insert("RELAY_AGENT_TOKEN".into(), Value::String(v.to_string())); + let (token_key, token_value) = crate::relaycast::agent_token_rotation::agent_token_env(v); + env.insert(token_key.into(), Value::String(token_value)); // Skip bootstrap when the broker has already pre-registered the agent — // prevents blocking HTTP calls during MCP initialize handshake. env.insert( @@ -1713,13 +1716,14 @@ pub async fn configure_agent_relay_mcp_with_result( "mcp_servers.agent-relay.env.RELAY_STRICT_AGENT_NAME=\"1\"".to_string(), ]); if let Some(token) = agent_token.map(str::trim).filter(|s| !s.is_empty()) { - args.extend([ - "--config".to_string(), + args.extend(["--config".to_string(), { + let (token_key, token_value) = + crate::relaycast::agent_token_rotation::agent_token_env(token); format!( - "mcp_servers.agent-relay.env.RELAY_AGENT_TOKEN=\"{}\"", - escape_toml_basic_string(token) - ), - ]); + "mcp_servers.agent-relay.env.{token_key}=\"{}\"", + escape_toml_basic_string(&token_value) + ) + }]); // Skip bootstrap when the broker has already pre-registered the agent. args.extend([ "--config".to_string(), @@ -1970,7 +1974,9 @@ fn gemini_droid_mcp_add_args_with_result( } if let Some(token) = agent_token.map(str::trim).filter(|s| !s.is_empty()) { args.push(env_flag.to_string()); - args.push(format!("RELAY_AGENT_TOKEN={token}")); + let (token_key, token_value) = + crate::relaycast::agent_token_rotation::agent_token_env(token); + args.push(format!("{token_key}={token_value}")); // Skip bootstrap when the broker has already pre-registered the agent. args.push(env_flag.to_string()); args.push("RELAY_SKIP_BOOTSTRAP=1".to_string()); @@ -2030,7 +2036,9 @@ fn grok_mcp_add_args( } if let Some(token) = agent_token.map(str::trim).filter(|s| !s.is_empty()) { args.push("--env".to_string()); - args.push(format!("RELAY_AGENT_TOKEN={token}")); + let (token_key, token_value) = + crate::relaycast::agent_token_rotation::agent_token_env(token); + args.push(format!("{token_key}={token_value}")); args.push("--env".to_string()); args.push("RELAY_SKIP_BOOTSTRAP=1".to_string()); } @@ -3803,6 +3811,54 @@ exit 0 ); } + #[tokio::test] + async fn run_token_mode_mcp_configs_carry_the_token_file_not_a_token() { + let temp = tempdir().expect("tempdir"); + let file_ref = crate::relaycast::agent_token_rotation::agent_token_file_ref( + std::path::Path::new("/run/agent-tokens/worker.json"), + ); + let args = super::configure_agent_relay_mcp_with_token( + "claude", + "Worker", + None, + None, + &[], + temp.path(), + Some(&file_ref), + None, + None, + ) + .await + .expect("configure claude mcp with token file"); + let json: Value = serde_json::from_str(&args[1]).expect("parse mcp-config JSON"); + let env = &json["mcpServers"]["agent-relay"]["env"]; + assert_eq!( + env["RELAY_AGENT_TOKEN_FILE"].as_str(), + Some("/run/agent-tokens/worker.json") + ); + assert!(env["RELAY_AGENT_TOKEN"].is_null()); + assert!(env["RELAY_API_KEY"].is_null()); + + let codex = super::configure_agent_relay_mcp_with_token( + "codex", + "Agent", + None, + None, + &[], + temp.path(), + Some(&file_ref), + None, + None, + ) + .await + .expect("configure codex mcp with token file"); + assert!(codex.iter().any(|a| a + == "mcp_servers.agent-relay.env.RELAY_AGENT_TOKEN_FILE=\"/run/agent-tokens/worker.json\"")); + assert!(!codex + .iter() + .any(|a| a.contains("RELAY_AGENT_TOKEN=") || a.contains("relay-agent-token-file:"))); + } + #[tokio::test] async fn codex_includes_agent_token_when_provided() { let temp = tempdir().expect("tempdir"); diff --git a/crates/broker/src/worker.rs b/crates/broker/src/worker.rs index 5e6be6b2e4..2d9a9a1004 100644 --- a/crates/broker/src/worker.rs +++ b/crates/broker/src/worker.rs @@ -272,6 +272,10 @@ pub(crate) struct WorkerRegistry { pub(crate) supervisor: Supervisor, pub(crate) metrics: MetricsCollector, native_delivery_custody: NativeDeliveryCustodyHub, + /// Run-token mode: broker-owned 0700 directory of per-worker agent token + /// files. Workers get `RELAY_AGENT_TOKEN_FILE` (rotated by the broker) + /// instead of a static `RELAY_AGENT_TOKEN`. `None` keeps legacy behavior. + pub(crate) agent_token_dir: Option, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -565,9 +569,18 @@ impl WorkerRegistry { supervisor: Supervisor::new(), metrics: MetricsCollector::new(broker_start), native_delivery_custody: NativeDeliveryCustodyHub::default(), + agent_token_dir: None, } } + /// The agent token file a worker reads in run-token mode, or `None` in + /// legacy mode. + pub(crate) fn agent_token_file_for(&self, name: &str) -> Option { + self.agent_token_dir + .as_deref() + .map(|dir| crate::relaycast::agent_token_rotation::worker_agent_token_file(dir, name)) + } + fn commit_hooks_dir(&mut self) -> Result<&Path> { resolve_commit_hooks_dir(&mut self.commit_hooks_dir) } @@ -903,6 +916,23 @@ impl WorkerRegistry { if self.identity_cleanups.contains_key(&spec.name) { anyhow::bail!("agent '{}' has pending owned cleanup", spec.name); } + // Run-token mode: the worker's credential is a reference to its + // broker-rotated token file, never the static token. The broker's + // rotation task normally created the file already; write it here only + // if it is missing so a spawn can never point at a nonexistent file. + let worker_relay_api_key = + match (worker_relay_api_key, self.agent_token_file_for(&spec.name)) { + (Some(token), Some(path)) => { + if !path.exists() { + crate::relaycast::agent_token_rotation::write_agent_token_file( + &path, &token, None, + ) + .context("failed to write worker agent token file")?; + } + Some(crate::relaycast::agent_token_rotation::agent_token_file_ref(&path)) + } + (credential, _) => credential, + }; if self.workers.contains_key(&spec.name) { anyhow::bail!("agent '{}' already exists", spec.name); } @@ -1630,7 +1660,11 @@ impl WorkerRegistry { skip_relay_prompt, ) { if let Some(relay_key) = worker_relay_api_key { - command.env("RELAY_AGENT_TOKEN", relay_key); + // `RELAY_AGENT_TOKEN_FILE` in run-token mode, otherwise the + // legacy static `RELAY_AGENT_TOKEN`. + let (key, value) = + crate::relaycast::agent_token_rotation::agent_token_env(&relay_key); + command.env(key, value); } command.env("RELAY_AGENT_NAME", &spec.name); command.env("RELAY_AGENT_TYPE", "agent"); @@ -3513,7 +3547,7 @@ mod tests { ); let script = format!( "for k in {keys}; do if printenv \"$k\" >/dev/null; then echo \"$k\"; fi; done > names.tmp; \ - env | grep -cE '(rk|rt)_(live|test)_' > leaks.tmp; \ + env | grep -cE '(rk|rt|at)_(live|test)_' > leaks.tmp; \ mv leaks.tmp leaks.txt; mv names.tmp names.txt; sleep 30" ); let mut spec = sleeping_native_worker("run-token-worker", None); @@ -3532,6 +3566,11 @@ mod tests { "http://127.0.0.1:1/api/agent-result".to_string(), true, )); + let token_root = tempfile::tempdir().expect("token dir"); + registry.agent_token_dir = Some(token_root.path().join("agent-tokens")); + let token_file = registry + .agent_token_file_for("run-token-worker") + .expect("run-token mode token file"); registry .spawn( @@ -3569,12 +3608,25 @@ mod tests { .map(str::to_string) .collect(); observed.sort(); - assert_eq!(observed, vec!["RELAY_AGENT_TOKEN"]); + // The worker gets a token FILE, never a static token, workspace key, + // or run token. + assert_eq!(observed, vec!["RELAY_AGENT_TOKEN_FILE"]); assert_eq!( leaks.expect("worker recorded its leak count").trim(), "0", - "no rk_/rt_ value may reach a run-token-mode worker" + "no rk_/rt_/at_ value may reach a run-token-mode worker's env" ); + let body: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&token_file).unwrap()).unwrap(); + assert_eq!(body["token"], "at_live_worker_own"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&token_file).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } } #[cfg(any(target_os = "linux", target_os = "macos"))] diff --git a/crates/relay-pty/src/credentials.rs b/crates/relay-pty/src/credentials.rs index 8168c8b891..97ec2c09fa 100644 --- a/crates/relay-pty/src/credentials.rs +++ b/crates/relay-pty/src/credentials.rs @@ -22,6 +22,7 @@ pub const INHERITED_RELAY_CREDENTIAL_ENV_KEYS: &[&str] = &[ "RELAY_AGENT_IDENTITY_KEY", // Whoever launched the broker; each worker gets its own. "RELAY_AGENT_TOKEN", + "RELAY_AGENT_TOKEN_FILE", "AGENT_RELAY_RESULT_TOKEN", // Workspace credentials. Re-added from the broker's explicit worker // environment when the broker delegates them; never taken from ambient diff --git a/packages/cli/src/cli/agent-relay-mcp.startup.test.ts b/packages/cli/src/cli/agent-relay-mcp.startup.test.ts index e8a7acdac7..955bbcf62b 100644 --- a/packages/cli/src/cli/agent-relay-mcp.startup.test.ts +++ b/packages/cli/src/cli/agent-relay-mcp.startup.test.ts @@ -2451,3 +2451,74 @@ describe('startAgentRelayMcpStdio', () => { }); }); }); + +describe('run-token mode agent token file', () => { + beforeEach(() => { + // An earlier entrypoint test may have left a partial node:fs mock behind. + vi.doUnmock('node:fs'); + }); + + async function tokenFile(token: string) { + const fs = await vi.importActual('node:fs'); + const os = await vi.importActual('node:os'); + const path = await vi.importActual('node:path'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-token-file-')); + const file = path.join(dir, 'worker.json'); + const write = (value: string) => { + // The broker's atomic replacement: 0600 temp file, then rename. + const tmp = path.join(dir, `.worker.${Math.random().toString(16).slice(2)}.tmp`); + fs.writeFileSync(tmp, JSON.stringify({ token: value, expiresAt: '2099-01-01T00:00:00Z' }), { + mode: 0o600, + }); + fs.renameSync(tmp, file); + }; + write(token); + return { file, write }; + } + + it('reads the token from RELAY_AGENT_TOKEN_FILE without a workspace key', async () => { + const { mod, mocks } = await loadAgentRelayMcpModule({ + persistedWorkspaceKey: 'rk_live_unrelated_persisted', + }); + const { file } = await tokenFile('at_live_from_file'); + vi.stubEnv('RELAY_WORKSPACE_KEY', ''); + vi.stubEnv('AGENT_RELAY_WORKSPACE_KEY', ''); + vi.stubEnv('RELAY_API_KEY', ''); + vi.stubEnv('RELAY_AGENT_TOKEN', ''); + vi.stubEnv('RELAY_AGENT_TOKEN_FILE', file); + vi.stubEnv('RELAY_AGENT_NAME', 'TokenWorker'); + + const options = mod.optionsFromEnv(); + expect(options).toMatchObject({ + workspaceKey: undefined, + agentToken: 'at_live_from_file', + agentTokenFile: file, + agentName: 'TokenWorker', + }); + // A token file never pairs with a persisted project workspace. + expect(mocks.resolveWorkspaceSessionKey).not.toHaveBeenCalled(); + // Bootstrap is skipped: the file already holds a Relaycast agent token. + await expect(mod.resolveStdioBootstrapOptions(options)).resolves.toBe(options); + }); + + it('uses a rotated token on the very next request', async () => { + const { mod, mocks } = await loadAgentRelayMcpModule(); + const { file, write } = await tokenFile('at_live_file_one'); + + mod.createAgentRelayMcpServer({ agentTokenFile: file, agentName: 'TokenWorker' }); + const server = mocks.serverInstances[0]; + const send = () => server.tools.get('send_dm')?.handler({ to: 'chief', text: 'hello' }); + + await send(); + expect(mocks.relayInstances.some((instance) => instance.config.apiKey === 'at_live_file_one')).toBe( + true + ); + + write('at_live_file_two'); + const before = mocks.relayInstances.length; + await send(); + const used = mocks.relayInstances.slice(before).map((instance) => instance.config.apiKey); + expect(used).toContain('at_live_file_two'); + expect(used).not.toContain('at_live_file_one'); + }); +}); diff --git a/packages/cli/src/cli/agent-relay-mcp.ts b/packages/cli/src/cli/agent-relay-mcp.ts index 61ea2fbfa4..6e866d324e 100644 --- a/packages/cli/src/cli/agent-relay-mcp.ts +++ b/packages/cli/src/cli/agent-relay-mcp.ts @@ -40,6 +40,7 @@ import { withDeadline, } from './lib/agent-registration.js'; import { attributableReleaseReason } from './lib/release-reason.js'; +import { AGENT_TOKEN_FILE_ENV, AgentTokenFile } from './lib/agent-token-file.js'; import { sanitizedSpawnReceipt, spawnPlacementReceipt, @@ -1684,9 +1685,14 @@ export function createAgentRelayMcpServer(options: AgentRelayMcpServerOptions): requireCompleteSharedSessionToolset(options.sharedSessionTools ?? []); } + // Run-token mode: the broker rotates this agent's short-lived token into a + // file. The Relaycast SDK takes a static token string, so the adapter is to + // re-read the file (stat-cached) before every request and rebuild the + // per-request agent client with whatever token is current. + const agentTokenFile = options.agentTokenFile ? new AgentTokenFile(options.agentTokenFile) : null; const session = createInitialSession({ workspaceKey: options.workspaceKey ?? options.apiKey ?? null, - agentToken: options.agentToken ?? null, + agentToken: options.agentToken ?? agentTokenFile?.read() ?? null, agentName: options.agentName ?? null, }); const actionToolNames = new Set(); @@ -1714,7 +1720,10 @@ export function createAgentRelayMcpServer(options: AgentRelayMcpServerOptions): return mcpServer; } - const getSession = (): SessionState => session; + const getSession = (): SessionState => { + refreshAgentTokenFromFile(); + return session; + }; const getRelay = (): RelayCastLike => { const workspaceKey = session.workspaceKey; if (!workspaceKey) { @@ -1771,6 +1780,38 @@ export function createAgentRelayMcpServer(options: AgentRelayMcpServerOptions): } }; + /** + * Adopt a rotated token from the agent token file, if it changed. The + * identity is unchanged, so resource subscriptions are kept and only the + * realtime connection is re-established with the new token. + */ + function refreshAgentTokenFromFile(): void { + if (!agentTokenFile) return; + const identity = session.agentName ?? options.agentName ?? null; + if (!identity) return; + const latest = agentTokenFile.read(); + if (!latest || latest === session.agentToken) return; + const nextAgents = new Map(session.agents); + nextAgents.set(identity, createRegisteredAgent(identity, latest)); + Object.assign(session, { agentToken: latest, agentName: identity, agents: nextAgents }); + if (session.wsBridge && session.subscriptions) { + session.wsBridge.stop(); + try { + const wsBridge = new RealtimeResourceBridge( + createRealtimeClient({ agentToken: latest, baseUrl: options.baseUrl }), + session.subscriptions, + (uri) => { + mcpServer.server.sendResourceUpdated({ uri }).catch(() => undefined); + } + ); + wsBridge.start(); + session.wsBridge = wsBridge; + } catch { + session.wsBridge = null; + } + } + } + const invalidateAgentToken = (asIdentity?: string): void => { const partial: Partial = {}; const targetName = asIdentity ?? session.agentName ?? null; @@ -1796,6 +1837,7 @@ export function createAgentRelayMcpServer(options: AgentRelayMcpServerOptions): }; const resolveAgentToken = (asIdentity?: string): string => { + refreshAgentTokenFromFile(); if (asIdentity) { const registered = session.agents.get(asIdentity); if (!registered) { @@ -1996,7 +2038,10 @@ export function optionsFromEnv(): AgentRelayMcpServerOptions { process.env.RELAY_API_KEY, ].some((value) => value !== undefined && isUnresolvedEnvTemplate(value)); - if (!workspaceKey && !hasUnresolvedWorkspacePlaceholder) { + const agentTokenFile = resolveEnv(AGENT_TOKEN_FILE_ENV); + // A broker-rotated token file binds this process to the broker's workspace; + // never pair it with a persisted project workspace. + if (!workspaceKey && !hasUnresolvedWorkspacePlaceholder && !agentTokenFile) { try { workspaceKey = resolveWorkspaceSessionKey(); resumedPersistedWorkspace = Boolean(workspaceKey); @@ -2016,7 +2061,11 @@ export function optionsFromEnv(): AgentRelayMcpServerOptions { // when the workspace was selected alongside it through the environment; // a persisted project/store fallback must register into that workspace // instead of silently pairing it with a possibly stale ambient token. - agentToken: resumedPersistedWorkspace ? undefined : resolveEnv('RELAY_AGENT_TOKEN'), + agentToken: resumedPersistedWorkspace + ? undefined + : (resolveEnv('RELAY_AGENT_TOKEN') ?? + (agentTokenFile ? (new AgentTokenFile(agentTokenFile).read() ?? undefined) : undefined)), + agentTokenFile, agentName, agentType: normalizeAgentType(resolveEnv('RELAY_AGENT_TYPE')), strictAgentName: envFlagEnabled(resolveEnv('RELAY_STRICT_AGENT_NAME')), diff --git a/packages/cli/src/cli/lib/agent-token-file.test.ts b/packages/cli/src/cli/lib/agent-token-file.test.ts new file mode 100644 index 0000000000..e9aea00f9d --- /dev/null +++ b/packages/cli/src/cli/lib/agent-token-file.test.ts @@ -0,0 +1,54 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { AgentTokenFile, readAgentTokenFile } from './agent-token-file.js'; + +function setup() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-token-file-')); + const file = path.join(dir, 'worker.json'); + const write = (body: unknown) => { + const tmp = path.join(dir, `.tmp-${Math.random().toString(16).slice(2)}`); + fs.writeFileSync(tmp, typeof body === 'string' ? body : JSON.stringify(body), { mode: 0o600 }); + fs.renameSync(tmp, file); + }; + return { file, write }; +} + +describe('AgentTokenFile', () => { + it('picks up an atomically replaced token on the next read', () => { + const { file, write } = setup(); + write({ token: 'at_live_one', expiresAt: '2099-01-01T00:00:00Z' }); + const source = new AgentTokenFile(file); + expect(source.read()).toBe('at_live_one'); + expect(source.read()).toBe('at_live_one'); + write({ token: 'at_live_two', expiresAt: '2099-01-01T00:15:00Z' }); + expect(source.read()).toBe('at_live_two'); + }); + + it('returns null once the file is removed (identity released)', () => { + const { file, write } = setup(); + write({ token: 'at_live_one' }); + const source = new AgentTokenFile(file); + expect(source.read()).toBe('at_live_one'); + fs.rmSync(file); + expect(source.read()).toBeNull(); + }); + + it('refuses anything that is not an agent token', () => { + const { file, write } = setup(); + write({ token: 'rt_live_run_token' }); + expect(new AgentTokenFile(file).read()).toBeNull(); + write({ token: 'rk_live_workspace' }); + expect(new AgentTokenFile(file).read()).toBeNull(); + write('not json'); + expect(new AgentTokenFile(file).read()).toBeNull(); + }); + + it('one-shot reads ignore blank paths', () => { + expect(readAgentTokenFile(undefined)).toBeUndefined(); + expect(readAgentTokenFile(' ')).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/cli/lib/agent-token-file.ts b/packages/cli/src/cli/lib/agent-token-file.ts new file mode 100644 index 0000000000..5448df4071 --- /dev/null +++ b/packages/cli/src/cli/lib/agent-token-file.ts @@ -0,0 +1,66 @@ +import fs from 'node:fs'; + +/** + * Env var naming a broker-rotated agent token file. In run-token mode the + * broker gives workers this instead of a static `RELAY_AGENT_TOKEN`: Relaycast + * agent tokens expire after 15 minutes, and the broker atomically replaces the + * file (`{"token":"at_live_...","expiresAt":""}`) before each expiry. + */ +export const AGENT_TOKEN_FILE_ENV = 'RELAY_AGENT_TOKEN_FILE'; + +type CachedToken = { mtimeMs: number; size: number; ino: number; token: string }; + +function parseAgentToken(raw: string): string | null { + try { + const parsed = JSON.parse(raw) as { token?: unknown }; + const token = typeof parsed?.token === 'string' ? parsed.token.trim() : ''; + // Agent tokens only; never accept a workspace key or run token here. + return token.startsWith('at_') ? token : null; + } catch { + return null; + } +} + +/** + * Per-request reader for a broker-rotated agent token file. Each `read()` + * stats the file and reparses it only when its inode, size, or mtime changed, + * so a rotation (atomic rename) is picked up by the very next request while + * unchanged files cost one `stat`. Token values are never logged. + */ +export class AgentTokenFile { + private cached: CachedToken | null = null; + + constructor(readonly path: string) {} + + /** The current token, or null when the file is missing (identity released) or invalid. */ + read(): string | null { + let stat: fs.Stats; + try { + stat = fs.statSync(this.path); + } catch { + this.cached = null; + return null; + } + const cached = this.cached; + if (cached && cached.ino === stat.ino && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) { + return cached.token; + } + let raw: string; + try { + raw = fs.readFileSync(this.path, 'utf8'); + } catch { + return cached?.token ?? null; + } + const token = parseAgentToken(raw); + if (!token) return null; + this.cached = { mtimeMs: stat.mtimeMs, size: stat.size, ino: stat.ino, token }; + return token; + } +} + +/** One-shot read for short-lived CLI processes. */ +export function readAgentTokenFile(path: string | undefined): string | undefined { + const trimmed = path?.trim(); + if (!trimmed) return undefined; + return new AgentTokenFile(trimmed).read() ?? undefined; +} diff --git a/packages/cli/src/cli/lib/sdk-client.ts b/packages/cli/src/cli/lib/sdk-client.ts index 235e3b13ae..9231ca3af4 100644 --- a/packages/cli/src/cli/lib/sdk-client.ts +++ b/packages/cli/src/cli/lib/sdk-client.ts @@ -13,6 +13,7 @@ import { type WorkspaceSelection, type WorkspaceKeySource, } from '@agent-relay/cloud/workspace-key'; +import { AGENT_TOKEN_FILE_ENV, readAgentTokenFile } from './agent-token-file.js'; /** Options shared by the SDK-backed (Relaycast) CLI command groups. */ export interface SdkClientOptions { @@ -234,7 +235,13 @@ export function persistWorkspaceRelaycastTarget( } export function resolveAgentToken(options: SdkClientOptions = {}): string | undefined { - return trimOrUndefined(options.token) ?? trimOrUndefined(env(options).RELAY_AGENT_TOKEN); + const environment = env(options); + return ( + trimOrUndefined(options.token) ?? + trimOrUndefined(environment.RELAY_AGENT_TOKEN) ?? + // Run-token mode: the broker hands workers a rotated token file instead. + readAgentTokenFile(environment[AGENT_TOKEN_FILE_ENV]) + ); } /** Workspace-scoped client (no agent token). */ diff --git a/packages/cli/src/cli/mcp/types.ts b/packages/cli/src/cli/mcp/types.ts index 0ea0fecf97..343f350224 100644 --- a/packages/cli/src/cli/mcp/types.ts +++ b/packages/cli/src/cli/mcp/types.ts @@ -15,6 +15,12 @@ export interface AgentRelayMcpServerOptions { apiKey?: string; baseUrl?: string; agentToken?: string; + /** + * Broker-rotated agent token file (`RELAY_AGENT_TOKEN_FILE`, run-token mode). + * Re-read on every request so a rotated token is used immediately; no + * workspace key is required. + */ + agentTokenFile?: string; agentName?: string; agentType?: AgentType; strictAgentName?: boolean; From 04f115c2c6d0aca46d51debbace5f1baddc14262 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sat, 17 Oct 2026 21:40:13 -0700 Subject: [PATCH 4/4] style: prettier for agent token file tests Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/cli/src/cli/agent-relay-mcp.startup.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli/src/cli/agent-relay-mcp.startup.test.ts b/packages/cli/src/cli/agent-relay-mcp.startup.test.ts index 955bbcf62b..bf41fc3659 100644 --- a/packages/cli/src/cli/agent-relay-mcp.startup.test.ts +++ b/packages/cli/src/cli/agent-relay-mcp.startup.test.ts @@ -2510,9 +2510,7 @@ describe('run-token mode agent token file', () => { const send = () => server.tools.get('send_dm')?.handler({ to: 'chief', text: 'hello' }); await send(); - expect(mocks.relayInstances.some((instance) => instance.config.apiKey === 'at_live_file_one')).toBe( - true - ); + expect(mocks.relayInstances.some((instance) => instance.config.apiKey === 'at_live_file_one')).toBe(true); write('at_live_file_two'); const before = mocks.relayInstances.length;