diff --git a/.agentworkforce/trajectories/completed/2026-06/traj_1llfpjvd9m7k/summary.md b/.agentworkforce/trajectories/completed/2026-06/traj_1llfpjvd9m7k/summary.md new file mode 100644 index 000000000..abc21c53b --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-06/traj_1llfpjvd9m7k/summary.md @@ -0,0 +1,39 @@ +# Trajectory: Review and fix PR 1055 + +> **Status:** ✅ Completed +> **Confidence:** 86% +> **Started:** June 6, 2026 at 01:49 PM +> **Completed:** June 6, 2026 at 02:05 PM + +--- + +## Summary + +Reviewed PR 1055, removed accidental rust_out artifact, restored emptied trajectory data, and fixed CLI env-var test cleanup. Verified harness-driver typecheck/build and focused broker CLI/auth tests. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Removed accidental binary artifact and restored emptied trajectory + +- **Chose:** Removed accidental binary artifact and restored emptied trajectory +- **Reasoning:** PR diff added rust_out and emptied an active tracked trajectory; AGENTS.md requires trajectories to remain tracked, and generated binaries do not belong in the PR. + +### Made CLI env-var test guard clear AGENT_RELAY_BROKER_NAME on drop + +- **Chose:** Made CLI env-var test guard clear AGENT_RELAY_BROKER_NAME on drop +- **Reasoning:** The new tests cleared the shared env var before each test but leaked it after the final test, which can affect later tests in the same process. + +--- + +## Chapters + +### 1. Work + +_Agent: default_ + +- Removed accidental binary artifact and restored emptied trajectory: Removed accidental binary artifact and restored emptied trajectory +- Made CLI env-var test guard clear AGENT_RELAY_BROKER_NAME on drop: Made CLI env-var test guard clear AGENT_RELAY_BROKER_NAME on drop diff --git a/.agentworkforce/trajectories/completed/2026-06/traj_1llfpjvd9m7k/trajectory.json b/.agentworkforce/trajectories/completed/2026-06/traj_1llfpjvd9m7k/trajectory.json new file mode 100644 index 000000000..067cacbc7 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-06/traj_1llfpjvd9m7k/trajectory.json @@ -0,0 +1,65 @@ +{ + "id": "traj_1llfpjvd9m7k", + "version": 1, + "task": { + "title": "Review and fix PR 1055" + }, + "status": "completed", + "startedAt": "2026-06-06T13:49:15.971Z", + "completedAt": "2026-06-06T14:05:43.957Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-06-06T14:05:29.768Z" + } + ], + "chapters": [ + { + "id": "chap_4iax8jv8u74r", + "title": "Work", + "agentName": "default", + "startedAt": "2026-06-06T14:05:29.768Z", + "endedAt": "2026-06-06T14:05:43.957Z", + "events": [ + { + "ts": 1780754729769, + "type": "decision", + "content": "Removed accidental binary artifact and restored emptied trajectory: Removed accidental binary artifact and restored emptied trajectory", + "raw": { + "question": "Removed accidental binary artifact and restored emptied trajectory", + "chosen": "Removed accidental binary artifact and restored emptied trajectory", + "alternatives": [], + "reasoning": "PR diff added rust_out and emptied an active tracked trajectory; AGENTS.md requires trajectories to remain tracked, and generated binaries do not belong in the PR." + }, + "significance": "high" + }, + { + "ts": 1780754730821, + "type": "decision", + "content": "Made CLI env-var test guard clear AGENT_RELAY_BROKER_NAME on drop: Made CLI env-var test guard clear AGENT_RELAY_BROKER_NAME on drop", + "raw": { + "question": "Made CLI env-var test guard clear AGENT_RELAY_BROKER_NAME on drop", + "chosen": "Made CLI env-var test guard clear AGENT_RELAY_BROKER_NAME on drop", + "alternatives": [], + "reasoning": "The new tests cleared the shared env var before each test but leaked it after the final test, which can affect later tests in the same process." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Reviewed PR 1055, removed accidental rust_out artifact, restored emptied trajectory data, and fixed CLI env-var test cleanup. Verified harness-driver typecheck/build and focused broker CLI/auth tests.", + "approach": "Standard approach", + "confidence": 0.86 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relay", + "tags": [], + "_trace": { + "startRef": "d5f7fee457c2d7a991bdb9167ad2c896aa0987e4", + "endRef": "d5f7fee457c2d7a991bdb9167ad2c896aa0987e4" + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index c056cad46..dfca5f46f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `agent-relay-broker` and `@agent-relay/harness-driver` accept explicit workspace keys and broker instance names, so local and cloud brokers can join the same Relay workspace with stable, addressable names. - `@agent-relay/harnesses` adds a `grok` PTY harness for the Grok CLI, including Relaycast MCP support for spawned agents. - `@agent-relay/harnesses` is now published to npm, so SDK consumers can install the prebuilt PTY harnesses and harness-authoring helpers. - `agent-relay drive` and `agent-relay passthrough` add adaptive predictive echo so typing stays responsive when driving a high-latency or remote agent, and stays invisible on fast local links. diff --git a/crates/broker/src/cli/mod.rs b/crates/broker/src/cli/mod.rs index 254e1fae2..1549503e3 100644 --- a/crates/broker/src/cli/mod.rs +++ b/crates/broker/src/cli/mod.rs @@ -72,9 +72,9 @@ impl Commands { let pid = std::process::id(); match self { Commands::Init(cmd) => { - let name = cmd.name.trim(); + let name = cmd.resolved_instance_name(None); if !name.is_empty() { - return name.to_string(); + return name; } std::env::current_dir() .ok() @@ -215,9 +215,18 @@ pub(crate) struct McpArgsCommand { #[derive(Debug, clap::Args)] pub(crate) struct InitCommand { - #[arg(long, default_value = "")] + /// Legacy broker instance name flag. Prefer --instance-name. + #[arg(long, default_value = "", alias = "broker-name")] pub(crate) name: String, + /// Stable broker instance name within the Relay workspace. + #[arg(long = "instance-name")] + pub(crate) instance_name: Option, + + /// Join an existing Relay workspace instead of creating a fresh one. + #[arg(long = "workspace-key")] + pub(crate) workspace_key: Option, + #[arg(long, default_value = "general")] pub(crate) channels: String, @@ -248,6 +257,135 @@ pub(crate) struct InitCommand { pub(crate) state_dir: Option, } +impl InitCommand { + pub(crate) fn resolved_instance_name(&self, fallback: Option<&str>) -> String { + fn non_empty_trimmed(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + + self.instance_name + .as_deref() + .and_then(non_empty_trimmed) + .or_else(|| non_empty_trimmed(&self.name)) + .or_else(|| { + std::env::var("AGENT_RELAY_BROKER_NAME") + .ok() + .and_then(|name| non_empty_trimmed(&name)) + }) + .or_else(|| fallback.and_then(non_empty_trimmed)) + .unwrap_or_default() + } + + pub(crate) fn resolved_workspace_key(&self) -> Option { + self.workspace_key + .clone() + .or_else(|| std::env::var("AGENT_RELAY_WORKSPACE_KEY").ok()) + .map(|key| key.trim().to_string()) + .filter(|key| !key.is_empty()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + static BROKER_NAME_ENV_MUTEX: Mutex<()> = Mutex::new(()); + + struct BrokerNameEnvGuard { + _guard: std::sync::MutexGuard<'static, ()>, + } + + impl Drop for BrokerNameEnvGuard { + fn drop(&mut self) { + std::env::remove_var("AGENT_RELAY_BROKER_NAME"); + } + } + + fn broker_name_env_guard() -> BrokerNameEnvGuard { + let guard = BROKER_NAME_ENV_MUTEX.lock().unwrap(); + std::env::remove_var("AGENT_RELAY_BROKER_NAME"); + BrokerNameEnvGuard { _guard: guard } + } + + fn init_command(name: &str, instance_name: Option<&str>) -> InitCommand { + InitCommand { + name: name.to_string(), + instance_name: instance_name.map(ToOwned::to_owned), + workspace_key: None, + channels: "general".to_string(), + api_port: 0, + api_bind: "127.0.0.1".to_string(), + persist: false, + state_dir: None, + } + } + + #[test] + fn instance_name_flag_overrides_legacy_name_and_env() { + let _guard = broker_name_env_guard(); + std::env::set_var("AGENT_RELAY_BROKER_NAME", "env-name"); + + let command = init_command("legacy-name", Some("instance-name")); + + assert_eq!( + command.resolved_instance_name(Some("fallback")), + "instance-name" + ); + } + + #[test] + fn legacy_name_flag_overrides_env_default() { + let _guard = broker_name_env_guard(); + std::env::set_var("AGENT_RELAY_BROKER_NAME", "env-name"); + + let command = init_command("legacy-name", None); + + assert_eq!( + command.resolved_instance_name(Some("fallback")), + "legacy-name" + ); + } + + #[test] + fn env_broker_name_overrides_fallback_only() { + let _guard = broker_name_env_guard(); + std::env::set_var("AGENT_RELAY_BROKER_NAME", "env-name"); + + let command = init_command("", None); + + assert_eq!(command.resolved_instance_name(Some("fallback")), "env-name"); + } + + #[test] + fn blank_instance_name_falls_through_to_legacy_name() { + let _guard = broker_name_env_guard(); + std::env::set_var("AGENT_RELAY_BROKER_NAME", "env-name"); + + let command = init_command("legacy-name", Some(" ")); + + assert_eq!( + command.resolved_instance_name(Some("fallback")), + "legacy-name" + ); + } + + #[test] + fn empty_instance_name_and_blank_env_fall_through_to_fallback() { + let _guard = broker_name_env_guard(); + std::env::set_var("AGENT_RELAY_BROKER_NAME", " "); + + let command = init_command("", Some("")); + + assert_eq!(command.resolved_instance_name(Some("fallback")), "fallback"); + } +} + #[derive(Debug, clap::Args, Clone)] pub(crate) struct PtyCommand { pub(crate) cli: String, diff --git a/crates/broker/src/relaycast/auth.rs b/crates/broker/src/relaycast/auth.rs index d49e80c31..ec51b9153 100644 --- a/crates/broker/src/relaycast/auth.rs +++ b/crates/broker/src/relaycast/auth.rs @@ -60,6 +60,12 @@ struct WorkspaceSource { api_key: String, } +struct EnvWorkspaceKey { + source: &'static str, + key: String, + explicit_join: bool, +} + impl CredentialSet { pub fn from_json(raw: &str) -> Result { let value: Value = serde_json::from_str(raw).context("invalid credential set JSON")?; @@ -444,15 +450,13 @@ impl AuthClient { strict_name: bool, agent_type: Option<&str>, ) -> Result { - let env_workspace_key = std::env::var("RELAY_API_KEY") - .ok() - .and_then(|s| normalize_workspace_key(&s)); + let env_workspace_key = env_workspace_key()?; let mut workspace_id_hint: Option = None; - let mut candidates: Vec<(&str, String)> = Vec::new(); + let mut candidates: Vec = Vec::new(); if let Some(key) = env_workspace_key { - candidates.push(("env", key)); + candidates.push(key); } let mut attempted_fresh_workspace = false; @@ -460,24 +464,33 @@ impl AuthClient { let ws_name = deterministic_workspace_name(); let (workspace_id, api_key) = self.create_workspace(&ws_name).await?; workspace_id_hint = Some(workspace_id); - candidates.push(("fresh", api_key)); + candidates.push(EnvWorkspaceKey { + source: "fresh", + key: api_key, + explicit_join: false, + }); attempted_fresh_workspace = true; } let preferred_name = requested_name; let mut auth_rejections = Vec::new(); - for (source, key) in &candidates { + for candidate in &candidates { tracing::info!( target = "relay_broker::auth", - source = %source, + source = %candidate.source, preferred_name = ?preferred_name, strict_name = %strict_name, agent_type = ?agent_type, "attempting registration with workspace key" ); match self - .register_agent_with_workspace_key(key, preferred_name, strict_name, agent_type) + .register_agent_with_workspace_key( + &candidate.key, + preferred_name, + strict_name, + agent_type, + ) .await { Ok(registration) => { @@ -487,22 +500,38 @@ impl AuthClient { returned_name = %registration.1, "registration succeeded" ); - let session = - self.finish_session(key.clone(), workspace_id_hint.clone(), registration)?; + let session = self.finish_session( + candidate.key.clone(), + workspace_id_hint.clone(), + registration, + )?; return Ok(AuthSessionSet { default_workspace_id: Some(session.credentials.workspace_id.clone()), memberships: vec![session], }); } Err(error) if is_auth_rejection(&error) => { - auth_rejections.push(format!("{source} key rejected")); + if candidate.explicit_join { + return Err(error).context(format!( + "explicit workspace key from {} was rejected", + candidate.source + )); + } + auth_rejections.push(format!("{} key rejected", candidate.source)); } Err(error) if is_rate_limited(&error) => { - auth_rejections.push(format!("{source} key rate-limited")); + if candidate.explicit_join { + return Err(error).context(format!( + "explicit workspace key from {} was rate-limited", + candidate.source + )); + } + auth_rejections.push(format!("{} key rate-limited", candidate.source)); } Err(error) => { return Err(error).context(format!( - "failed registering agent with {source} workspace key" + "failed registering agent with {} workspace key", + candidate.source )); } } @@ -787,6 +816,33 @@ fn normalize_workspace_key(raw: &str) -> Option { } } +fn env_workspace_key() -> Result> { + for name in ["AGENT_RELAY_WORKSPACE_KEY", "RELAY_WORKSPACE_KEY"] { + if let Ok(raw) = std::env::var(name) { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + let key = normalize_workspace_key(trimmed) + .with_context(|| format!("{name} is not a valid workspace key"))?; + return Ok(Some(EnvWorkspaceKey { + source: name, + key, + explicit_join: true, + })); + } + } + + Ok(std::env::var("RELAY_API_KEY") + .ok() + .and_then(|value| normalize_workspace_key(&value)) + .map(|key| EnvWorkspaceKey { + source: "RELAY_API_KEY", + key, + explicit_join: false, + })) +} + fn is_auth_rejection(err: &anyhow::Error) -> bool { auth_http_status(err) .is_some_and(|status| status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN) @@ -962,6 +1018,8 @@ mod tests { // SAFETY: test-only; Rust warns about remove_var in multi-threaded // contexts but we accept the risk in test code. unsafe { + std::env::remove_var("AGENT_RELAY_WORKSPACE_KEY"); + std::env::remove_var("RELAY_WORKSPACE_KEY"); std::env::remove_var("RELAY_API_KEY"); std::env::remove_var("RELAY_WORKSPACES_JSON"); std::env::remove_var("RELAY_DEFAULT_WORKSPACE"); @@ -1006,7 +1064,7 @@ mod tests { let _env_guard = clear_relay_env(); let server = MockServer::start(); unsafe { - std::env::set_var("RELAY_API_KEY", "rk_live_env"); + std::env::set_var("AGENT_RELAY_WORKSPACE_KEY", "rk_live_env"); } let register = server.mock(|when, then| { when.method(POST) @@ -1024,6 +1082,108 @@ mod tests { assert_eq!(session.credentials.api_key, "rk_live_env"); register.assert_hits(1); + unsafe { + std::env::remove_var("AGENT_RELAY_WORKSPACE_KEY"); + } + } + + #[tokio::test] + async fn rejected_explicit_workspace_key_does_not_create_workspace() { + let _env_guard = clear_relay_env(); + let server = MockServer::start(); + unsafe { + std::env::set_var("AGENT_RELAY_WORKSPACE_KEY", "rk_live_rejected"); + } + let rejected_register = server.mock(|when, then| { + when.method(POST) + .path("/v1/agents") + .header("authorization", "Bearer rk_live_rejected"); + then.status(401) + .header("content-type", "application/json") + .body(r#"{"ok":false,"error":{"code":"unauthorized","message":"unauthorized"}}"#); + }); + 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_new","api_key":"rk_live_new","created_at":"2025-01-01T00:00:00Z"}}"#); + }); + + let client = AuthClient::new(server.base_url()); + let error = client.startup_session(Some("lead")).await.unwrap_err(); + assert!( + error + .to_string() + .contains("explicit workspace key from AGENT_RELAY_WORKSPACE_KEY was rejected"), + "unexpected error: {error:#}" + ); + rejected_register.assert_hits(1); + workspace.assert_hits(0); + + unsafe { + std::env::remove_var("AGENT_RELAY_WORKSPACE_KEY"); + } + } + + #[tokio::test] + async fn canonical_workspace_key_takes_precedence_over_legacy_api_key() { + let _env_guard = clear_relay_env(); + let server = MockServer::start(); + unsafe { + std::env::set_var("AGENT_RELAY_WORKSPACE_KEY", "rk_live_canonical"); + std::env::set_var("RELAY_API_KEY", "rk_live_legacy"); + } + let canonical_register = server.mock(|when, then| { + when.method(POST) + .path("/v1/agents") + .header("authorization", "Bearer rk_live_canonical"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"ok":true,"data":{"id":"a2","name":"lead","token":"at_live_2","status":"online","created_at":"2025-01-01T00:00:00Z"}}"#); + }); + let legacy_register = server.mock(|when, then| { + when.method(POST) + .path("/v1/agents") + .header("authorization", "Bearer rk_live_legacy"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"ok":true,"data":{"id":"a3","name":"lead","token":"at_live_3","status":"online","created_at":"2025-01-01T00:00:00Z"}}"#); + }); + + let client = AuthClient::new(server.base_url()); + let session = client.startup_session(Some("lead")).await.unwrap(); + assert_eq!(session.credentials.api_key, "rk_live_canonical"); + canonical_register.assert_hits(1); + legacy_register.assert_hits(0); + + unsafe { + std::env::remove_var("AGENT_RELAY_WORKSPACE_KEY"); + std::env::remove_var("RELAY_API_KEY"); + } + } + + #[tokio::test] + async fn legacy_relay_api_key_still_joins_existing_workspace() { + let _env_guard = clear_relay_env(); + let server = MockServer::start(); + unsafe { + std::env::set_var("RELAY_API_KEY", "rk_live_legacy"); + } + let register = server.mock(|when, then| { + when.method(POST) + .path("/v1/agents") + .header("authorization", "Bearer rk_live_legacy"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"ok":true,"data":{"id":"a2","name":"lead","token":"at_live_2","status":"online","created_at":"2025-01-01T00:00:00Z"}}"#); + }); + + let client = AuthClient::new(server.base_url()); + + let session = client.startup_session(Some("lead")).await.unwrap(); + assert_eq!(session.credentials.api_key, "rk_live_legacy"); + register.assert_hits(1); + unsafe { std::env::remove_var("RELAY_API_KEY"); } diff --git a/crates/broker/src/runtime/init.rs b/crates/broker/src/runtime/init.rs index f2ba8345c..5e22ea82f 100644 --- a/crates/broker/src/runtime/init.rs +++ b/crates/broker/src/runtime/init.rs @@ -8,16 +8,17 @@ pub(crate) async fn run_init(cmd: InitCommand, telemetry: TelemetryClient) -> Re telemetry.track(TelemetryEvent::BrokerStart); let runtime_cwd = std::env::current_dir()?; - let resolved_name = if cmd.name.trim().is_empty() { - runtime_cwd - .file_name() - .and_then(|name| name.to_str()) - .filter(|name| !name.is_empty()) - .unwrap_or("project") - .to_string() - } else { - cmd.name.trim().to_string() - }; + let default_instance_name = runtime_cwd + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("project"); + let resolved_name = cmd.resolved_instance_name(Some(default_instance_name)); + if let Some(workspace_key) = cmd.resolved_workspace_key() { + std::env::set_var("AGENT_RELAY_WORKSPACE_KEY", &workspace_key); + std::env::set_var("RELAY_WORKSPACE_KEY", &workspace_key); + std::env::set_var("RELAY_API_KEY", &workspace_key); + } let custom_state_dir = cmd.state_dir.as_ref().map(PathBuf::from); log_startup_phase( startup_debug, @@ -342,6 +343,14 @@ 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![ ("RELAY_BASE_URL".to_string(), http_base.clone()), + ( + "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(), diff --git a/crates/broker/src/spawner.rs b/crates/broker/src/spawner.rs index 5e129fa0a..33ef34c78 100644 --- a/crates/broker/src/spawner.rs +++ b/crates/broker/src/spawner.rs @@ -225,6 +225,8 @@ pub fn spawn_env_vars( ) -> 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_BASE_URL".to_string(), base_url.to_string()), ("RELAY_CHANNELS".to_string(), channels.to_string()), diff --git a/package-lock.json b/package-lock.json index 3a88124e3..afcaec1c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agent-relay/monorepo", - "version": "8.2.0", + "version": "8.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agent-relay/monorepo", - "version": "8.2.0", + "version": "8.3.0", "license": "Apache-2.0", "workspaces": [ "packages/*", @@ -18702,43 +18702,43 @@ }, "packages/brand": { "name": "@agent-relay/brand", - "version": "8.2.0" + "version": "8.3.0" }, "packages/broker-darwin-arm64": { "name": "@agent-relay/broker-darwin-arm64", - "version": "8.2.0", + "version": "8.3.0", "license": "MIT" }, "packages/broker-darwin-x64": { "name": "@agent-relay/broker-darwin-x64", - "version": "8.2.0", + "version": "8.3.0", "license": "MIT" }, "packages/broker-linux-arm64": { "name": "@agent-relay/broker-linux-arm64", - "version": "8.2.0", + "version": "8.3.0", "license": "MIT" }, "packages/broker-linux-x64": { "name": "@agent-relay/broker-linux-x64", - "version": "8.2.0", + "version": "8.3.0", "license": "MIT" }, "packages/broker-win32-x64": { "name": "@agent-relay/broker-win32-x64", - "version": "8.2.0", + "version": "8.3.0", "license": "MIT" }, "packages/cli": { "name": "agent-relay", - "version": "8.2.0", + "version": "8.3.0", "license": "Apache-2.0", "dependencies": { - "@agent-relay/cloud": "8.2.0", - "@agent-relay/config": "8.2.0", - "@agent-relay/harness-driver": "8.2.0", - "@agent-relay/sdk": "8.2.0", - "@agent-relay/utils": "8.2.0", + "@agent-relay/cloud": "8.3.0", + "@agent-relay/config": "8.3.0", + "@agent-relay/harness-driver": "8.3.0", + "@agent-relay/sdk": "8.3.0", + "@agent-relay/utils": "8.3.0", "@modelcontextprotocol/sdk": "^1.0.0", "@relaycast/sdk": "^2.5.1", "@relayflows/cli": "^1.0.1", @@ -18761,9 +18761,9 @@ }, "packages/cloud": { "name": "@agent-relay/cloud", - "version": "8.2.0", + "version": "8.3.0", "dependencies": { - "@agent-relay/config": "8.2.0", + "@agent-relay/config": "8.3.0", "@aws-sdk/client-s3": "3.1020.0", "ignore": "^7.0.5", "tar": "^7.5.10" @@ -18779,7 +18779,7 @@ }, "packages/config": { "name": "@agent-relay/config", - "version": "8.2.0", + "version": "8.3.0", "dependencies": { "zod": "^3.23.8", "zod-to-json-schema": "^3.23.1" @@ -18792,35 +18792,35 @@ }, "packages/harness-driver": { "name": "@agent-relay/harness-driver", - "version": "8.2.0", + "version": "8.3.0", "license": "Apache-2.0", "dependencies": { - "@agent-relay/sdk": "8.2.0", + "@agent-relay/sdk": "8.3.0", "ws": "^8.18.3", "zod": "^3.23.8" }, "optionalDependencies": { - "@agent-relay/broker-darwin-arm64": "8.2.0", - "@agent-relay/broker-darwin-x64": "8.2.0", - "@agent-relay/broker-linux-arm64": "8.2.0", - "@agent-relay/broker-linux-x64": "8.2.0", - "@agent-relay/broker-win32-x64": "8.2.0" + "@agent-relay/broker-darwin-arm64": "8.3.0", + "@agent-relay/broker-darwin-x64": "8.3.0", + "@agent-relay/broker-linux-arm64": "8.3.0", + "@agent-relay/broker-linux-x64": "8.3.0", + "@agent-relay/broker-win32-x64": "8.3.0" } }, "packages/harnesses": { "name": "@agent-relay/harnesses", - "version": "8.2.0", + "version": "8.3.0", "license": "Apache-2.0", "dependencies": { - "@agent-relay/harness-driver": "8.2.0", - "@agent-relay/sdk": "8.2.0" + "@agent-relay/harness-driver": "8.3.0", + "@agent-relay/sdk": "8.3.0" } }, "packages/policy": { "name": "@agent-relay/policy", - "version": "8.2.0", + "version": "8.3.0", "dependencies": { - "@agent-relay/config": "8.2.0" + "@agent-relay/config": "8.3.0" }, "devDependencies": { "@types/node": "^22.19.3", @@ -18829,7 +18829,7 @@ }, "packages/sdk": { "name": "@agent-relay/sdk", - "version": "8.2.0", + "version": "8.3.0", "dependencies": { "@relaycast/sdk": "^2.5.1" }, @@ -18839,14 +18839,14 @@ }, "packages/telemetry": { "name": "@agent-relay/telemetry", - "version": "8.2.0", + "version": "8.3.0", "deprecated": "@agent-relay/telemetry is deprecated. Telemetry is now internal to the agent-relay CLI." }, "packages/utils": { "name": "@agent-relay/utils", - "version": "8.2.0", + "version": "8.3.0", "dependencies": { - "@agent-relay/config": "8.2.0", + "@agent-relay/config": "8.3.0", "compare-versions": "^6.1.1" }, "devDependencies": { diff --git a/packages/harness-driver/src/client.ts b/packages/harness-driver/src/client.ts index 5e6ffa3f5..a81f21718 100644 --- a/packages/harness-driver/src/client.ts +++ b/packages/harness-driver/src/client.ts @@ -53,6 +53,8 @@ import type { BeforeAgentSpawnHandler, SpawnPatch, } from './lifecycle-hooks.js'; +import { buildBrokerSpawnConfig, type RuntimeSpawnOptions } from './spawn-config.js'; +export type { BrokerInitArgs, BrokerSpawnConfig, RuntimeSpawnOptions } from './spawn-config.js'; // ── Types ────────────────────────────────────────────────────────────── @@ -84,40 +86,6 @@ export interface BrokerExitInfo { recentStderr: string[]; } -export interface BrokerInitArgs { - /** Optional HTTP API port for dashboard proxy (0 = disabled). */ - apiPort?: number; - /** Bind address for the HTTP API. Defaults to 127.0.0.1 in the broker. */ - apiBind?: string; - /** Enable persistence for broker state under the working directory. */ - persist?: boolean; - /** Override the directory used for broker state files. */ - stateDir?: string; -} - -export interface RuntimeSpawnOptions { - /** Path to the agent-relay-broker binary. Auto-resolved if omitted. */ - binaryPath?: string; - /** Structured options mapped to the broker's Rust `init` CLI flags. */ - binaryArgs?: BrokerInitArgs; - /** Broker name. Defaults to cwd basename. */ - brokerName?: string; - /** Default channels for spawned agents. */ - channels?: string[]; - /** Working directory for the broker process. */ - cwd?: string; - /** Environment variables for the broker process. */ - env?: NodeJS.ProcessEnv; - /** Forward broker stderr to this callback. */ - onStderr?: (line: string) => void; - /** Timeout in ms to wait for broker to become ready. Default: 45000. */ - startupTimeoutMs?: number; - /** Timeout in ms for HTTP requests to the broker. Default: 30000. */ - requestTimeoutMs?: number; - /** Optional shared event bus — see {@link HarnessDriverClientOptions.eventBus}. */ - eventBus?: EventBus; -} - const optionalString = z.preprocess((value) => (value === null ? undefined : value), z.string().optional()); const optionalNumber = z.preprocess((value) => (value === null ? undefined : value), z.number().optional()); @@ -252,29 +220,6 @@ function isProcessRunning(pid: number): boolean { } } -function buildBrokerInitArgs(args?: BrokerInitArgs): string[] { - if (!args) { - return []; - } - - const cliArgs: string[] = []; - - if (args.persist) { - cliArgs.push('--persist'); - } - if (args.apiPort !== undefined) { - cliArgs.push('--api-port', String(args.apiPort)); - } - if (args.apiBind !== undefined) { - cliArgs.push('--api-bind', args.apiBind); - } - if (args.stateDir !== undefined) { - cliArgs.push('--state-dir', args.stateDir); - } - - return cliArgs; -} - // ── Client ───────────────────────────────────────────────────────────── export class HarnessDriverClient { @@ -440,23 +385,8 @@ export class HarnessDriverClient { } binaryPath = resolved; } - const cwd = options?.cwd ?? process.cwd(); - const brokerName = options?.brokerName ?? (path.basename(cwd) || 'project'); - const channels = options?.channels ?? ['general']; - const timeoutMs = options?.startupTimeoutMs ?? 45_000; - const userArgs = buildBrokerInitArgs(options?.binaryArgs); - const apiKey = `br_${randomBytes(16).toString('hex')}`; - - const env = { - ...process.env, - ...options?.env, - AGENT_RELAY_STARTUP_DEBUG: - options?.env?.AGENT_RELAY_STARTUP_DEBUG ?? process.env.AGENT_RELAY_STARTUP_DEBUG ?? '1', - RELAY_BROKER_API_KEY: apiKey, - }; - - const args = ['init', '--name', brokerName, '--channels', channels.join(','), ...userArgs]; + const { cwd, timeoutMs, args, env } = buildBrokerSpawnConfig(options, apiKey); const stderrLines: string[] = []; const stdoutLines: string[] = []; diff --git a/packages/harness-driver/src/spawn-config.test.ts b/packages/harness-driver/src/spawn-config.test.ts new file mode 100644 index 000000000..8a02d76ec --- /dev/null +++ b/packages/harness-driver/src/spawn-config.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { buildBrokerSpawnConfig } from './spawn-config.js'; + +describe('buildBrokerSpawnConfig', () => { + it('does not promote legacy RELAY_API_KEY into explicit workspace-key argv', () => { + const config = buildBrokerSpawnConfig( + { + cwd: '/tmp/my-project', + env: { + RELAY_API_KEY: 'rk_live_legacy', + }, + }, + 'br_test', + {} + ); + + expect(config.workspaceKey).toBeUndefined(); + expect(config.args).not.toContain('--workspace-key'); + expect(config.env.RELAY_API_KEY).toBe('rk_live_legacy'); + }); + + it('promotes canonical workspace-key env vars into explicit workspace-key argv', () => { + const config = buildBrokerSpawnConfig( + { + cwd: '/tmp/my-project', + env: { + AGENT_RELAY_WORKSPACE_KEY: 'rk_live_workspace', + RELAY_API_KEY: 'rk_live_legacy', + }, + }, + 'br_test', + {} + ); + + expect(config.workspaceKey).toBe('rk_live_workspace'); + expect(config.args).toContain('--workspace-key'); + expect(config.args).toContain('rk_live_workspace'); + expect(config.env.AGENT_RELAY_WORKSPACE_KEY).toBe('rk_live_workspace'); + expect(config.env.RELAY_WORKSPACE_KEY).toBe('rk_live_workspace'); + expect(config.env.RELAY_API_KEY).toBe('rk_live_workspace'); + }); + + it('uses the canonical workspace-key precedence chain before broker init args', () => { + const config = buildBrokerSpawnConfig( + { + cwd: '/tmp/my-project', + brokerName: ' ', + env: { + AGENT_RELAY_WORKSPACE_KEY: ' ', + RELAY_WORKSPACE_KEY: 'rk_live_env_workspace', + }, + binaryArgs: { + persist: true, + apiPort: 0, + apiBind: '127.0.0.1', + stateDir: '/tmp/relay-state', + }, + }, + 'br_test', + { + AGENT_RELAY_BROKER_NAME: 'parent-broker', + AGENT_RELAY_WORKSPACE_KEY: 'rk_live_parent_workspace', + } + ); + + expect(config.brokerName).toBe('parent-broker'); + expect(config.workspaceKey).toBe('rk_live_env_workspace'); + expect(config.args).toEqual([ + 'init', + '--instance-name', + 'parent-broker', + '--workspace-key', + 'rk_live_env_workspace', + '--channels', + 'general', + '--persist', + '--api-port', + '0', + '--api-bind', + '127.0.0.1', + '--state-dir', + '/tmp/relay-state', + ]); + }); +}); diff --git a/packages/harness-driver/src/spawn-config.ts b/packages/harness-driver/src/spawn-config.ts new file mode 100644 index 000000000..831c0fe0c --- /dev/null +++ b/packages/harness-driver/src/spawn-config.ts @@ -0,0 +1,130 @@ +import path from 'node:path'; + +import type { EventBus } from './event-bus.js'; +import type { HarnessDriverEvents } from './lifecycle-hooks.js'; + +export interface BrokerInitArgs { + /** Optional HTTP API port for dashboard proxy (0 = disabled). */ + apiPort?: number; + /** Bind address for the HTTP API. Defaults to 127.0.0.1 in the broker. */ + apiBind?: string; + /** Enable persistence for broker state under the working directory. */ + persist?: boolean; + /** Override the directory used for broker state files. */ + stateDir?: string; +} + +export interface RuntimeSpawnOptions { + /** Path to the agent-relay-broker binary. Auto-resolved if omitted. */ + binaryPath?: string; + /** Structured options mapped to the broker's Rust `init` CLI flags. */ + binaryArgs?: BrokerInitArgs; + /** Existing Relay workspace key to join. Defaults to env when omitted. */ + workspaceKey?: string; + /** Broker name. Defaults to cwd basename. */ + brokerName?: string; + /** Default channels for spawned agents. */ + channels?: string[]; + /** Working directory for the broker process. */ + cwd?: string; + /** Environment variables for the broker process. */ + env?: NodeJS.ProcessEnv; + /** Forward broker stderr to this callback. */ + onStderr?: (line: string) => void; + /** Timeout in ms to wait for broker to become ready. Default: 45000. */ + startupTimeoutMs?: number; + /** Timeout in ms for HTTP requests to the broker. Default: 30000. */ + requestTimeoutMs?: number; + /** Optional shared event bus — see {@link HarnessDriverClientOptions.eventBus}. */ + eventBus?: EventBus; +} + +/** @internal */ +export interface BrokerSpawnConfig { + cwd: string; + brokerName: string; + workspaceKey?: string; + channels: string[]; + timeoutMs: number; + args: string[]; + env: NodeJS.ProcessEnv; +} + +function buildBrokerInitArgs(args?: BrokerInitArgs): string[] { + if (!args) { + return []; + } + + const cliArgs: string[] = []; + + if (args.persist) { + cliArgs.push('--persist'); + } + if (args.apiPort !== undefined) { + cliArgs.push('--api-port', String(args.apiPort)); + } + if (args.apiBind !== undefined) { + cliArgs.push('--api-bind', args.apiBind); + } + if (args.stateDir !== undefined) { + cliArgs.push('--state-dir', args.stateDir); + } + + return cliArgs; +} + +function nonEmptyString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +/** @internal */ +export function buildBrokerSpawnConfig( + options: RuntimeSpawnOptions | undefined, + apiKey: string, + parentEnv: NodeJS.ProcessEnv = process.env +): BrokerSpawnConfig { + const cwd = options?.cwd ?? process.cwd(); + const brokerName = + nonEmptyString(options?.brokerName) ?? + nonEmptyString(options?.env?.AGENT_RELAY_BROKER_NAME) ?? + nonEmptyString(parentEnv.AGENT_RELAY_BROKER_NAME) ?? + (path.basename(cwd) || 'project'); + const workspaceKey = + nonEmptyString(options?.workspaceKey) ?? + nonEmptyString(options?.env?.AGENT_RELAY_WORKSPACE_KEY) ?? + nonEmptyString(options?.env?.RELAY_WORKSPACE_KEY) ?? + nonEmptyString(parentEnv.AGENT_RELAY_WORKSPACE_KEY) ?? + nonEmptyString(parentEnv.RELAY_WORKSPACE_KEY); + const channels = options?.channels ?? ['general']; + const timeoutMs = options?.startupTimeoutMs ?? 45_000; + const userArgs = buildBrokerInitArgs(options?.binaryArgs); + + const env = { + ...parentEnv, + ...options?.env, + AGENT_RELAY_STARTUP_DEBUG: + options?.env?.AGENT_RELAY_STARTUP_DEBUG ?? parentEnv.AGENT_RELAY_STARTUP_DEBUG ?? '1', + RELAY_BROKER_API_KEY: apiKey, + ...(workspaceKey + ? { + AGENT_RELAY_WORKSPACE_KEY: workspaceKey, + RELAY_WORKSPACE_KEY: workspaceKey, + RELAY_API_KEY: workspaceKey, + } + : {}), + AGENT_RELAY_BROKER_NAME: brokerName, + }; + + const args = [ + 'init', + '--instance-name', + brokerName, + ...(workspaceKey ? ['--workspace-key', workspaceKey] : []), + '--channels', + channels.join(','), + ...userArgs, + ]; + + return { cwd, brokerName, workspaceKey, channels, timeoutMs, args, env }; +}