From 3c0f2f92769f5c77a4f323dfca00e7caaa93f648 Mon Sep 17 00:00:00 2001 From: Miya Date: Wed, 19 Aug 2026 09:16:08 +0200 Subject: [PATCH 01/13] fix(broker): resolve assignment repos locally Session-Id: 01a018d0-b15a-7552-b2fd-ca79a9b9396a --- CHANGELOG.md | 6 +- crates/broker/src/runtime/event_loop.rs | 4 + crates/broker/src/runtime/fleet.rs | 1 + crates/broker/src/runtime/init.rs | 5 + crates/broker/src/runtime/mod.rs | 2 +- crates/broker/src/runtime/relaycast_events.rs | 234 +++++++++++++++++- crates/broker/src/runtime/tests.rs | 3 +- 7 files changed, 249 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db2910bf6..c073f8b1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ 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 - Patch] + +### Fixed + +- Fleet repository assignments now resolve their checkout from the receiving node's local `AGENT_RELAY_NODE_REPO_PATHS` map immediately before spawn, refusing missing or invalid local paths instead of accepting dispatcher-provided working directories. ## [11.7.2] - 2026-08-19 diff --git a/crates/broker/src/runtime/event_loop.rs b/crates/broker/src/runtime/event_loop.rs index b6d9428a6..dc88ad15b 100644 --- a/crates/broker/src/runtime/event_loop.rs +++ b/crates/broker/src/runtime/event_loop.rs @@ -209,6 +209,10 @@ pub(crate) struct BrokerRuntime { pub(super) ws_inbound_rx: mpsc::Receiver, pub(super) relaycast_open: bool, pub(super) fleet_control_tx: mpsc::Sender, + /// Local checkout paths keyed by the repository identities this node serves. + /// These are loaded when the broker starts and never accepted from a remote + /// placement request. + pub(super) node_repo_paths: BTreeMap, /// This broker's relaycast node name, used to bind agents to the node over /// HTTP when the node-control `agent.register` path is unavailable. pub(super) fleet_node_name: String, diff --git a/crates/broker/src/runtime/fleet.rs b/crates/broker/src/runtime/fleet.rs index 9d9370370..d4a8af4a8 100644 --- a/crates/broker/src/runtime/fleet.rs +++ b/crates/broker/src/runtime/fleet.rs @@ -1255,6 +1255,7 @@ impl BrokerRuntime { model, exit_after_task, &ws_value, + &self.node_repo_paths, &workspace_id, Some(&action_control_dedup_key), &workspace_state, diff --git a/crates/broker/src/runtime/init.rs b/crates/broker/src/runtime/init.rs index 613534ace..004790ef9 100644 --- a/crates/broker/src/runtime/init.rs +++ b/crates/broker/src/runtime/init.rs @@ -273,6 +273,10 @@ pub(crate) async fn run_init(cmd: InitCommand, telemetry: TelemetryClient) -> Re // online without gating startup on it. let node_token = resolve_cached_node_token(&node_id, &node_workspace_id, node_base_url.as_deref()); + // A placement only carries a repository key. Checkout paths are local node + // configuration, so keep them in the broker and resolve the key immediately + // before every spawn rather than trusting a dispatcher-supplied cwd. + let node_repo_paths = super::relaycast_events::load_node_repo_paths_from_env()?; let node_manifest = bootstrap_node_manifest(&node_name, &node_id, &broker_version); // Retain the node name for the runtime: the HTTP `bind_agent_to_node` // fallback (used when node-control `agent.register` is unavailable) binds @@ -680,6 +684,7 @@ pub(crate) async fn run_init(cmd: InitCommand, telemetry: TelemetryClient) -> Re ws_inbound_rx, relaycast_open: true, fleet_control_tx, + node_repo_paths, fleet_node_name, node_delivery_token_present, node_delivery_connected: false, diff --git a/crates/broker/src/runtime/mod.rs b/crates/broker/src/runtime/mod.rs index d32a1dce3..e14bb9609 100644 --- a/crates/broker/src/runtime/mod.rs +++ b/crates/broker/src/runtime/mod.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashMap, HashSet, VecDeque}, + collections::{BTreeMap, HashMap, HashSet, VecDeque}, path::{Path, PathBuf}, process::Stdio, sync::{Arc, OnceLock}, diff --git a/crates/broker/src/runtime/relaycast_events.rs b/crates/broker/src/runtime/relaycast_events.rs index f1cdfb131..51c14298b 100644 --- a/crates/broker/src/runtime/relaycast_events.rs +++ b/crates/broker/src/runtime/relaycast_events.rs @@ -1,5 +1,51 @@ use super::*; +/// JSON object environment variable holding this node's repository key to +/// checkout-path map. It is intentionally local configuration: only its keys +/// may be advertised to the control plane. +const NODE_REPO_PATHS_ENV: &str = "AGENT_RELAY_NODE_REPO_PATHS"; + +/// Load this node's repository checkout map without touching the filesystem. +/// +/// Existence and directory checks happen at spawn time because a checkout can +/// disappear after the broker has registered the key. Requiring absolute paths +/// here ensures a map entry can never fall back to the broker process cwd. +pub(super) fn load_node_repo_paths_from_env() -> Result> { + let Some(raw) = std::env::var(NODE_REPO_PATHS_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + else { + return Ok(BTreeMap::new()); + }; + + let configured: BTreeMap = serde_json::from_str(&raw).with_context(|| { + format!("{NODE_REPO_PATHS_ENV} must be a JSON object of repo keys to paths") + })?; + let mut repo_paths = BTreeMap::new(); + for (raw_repo, raw_path) in configured { + let repo = raw_repo.trim(); + if repo.is_empty() { + anyhow::bail!("{NODE_REPO_PATHS_ENV} contains an empty repository key"); + } + let path = PathBuf::from(raw_path.trim()); + if path.as_os_str().is_empty() { + anyhow::bail!( + "{NODE_REPO_PATHS_ENV} has an empty checkout path for repository '{repo}'" + ); + } + if !path.is_absolute() { + anyhow::bail!( + "{NODE_REPO_PATHS_ENV} checkout path for repository '{repo}' must be absolute" + ); + } + if repo_paths.insert(repo.to_string(), path).is_some() { + anyhow::bail!("{NODE_REPO_PATHS_ENV} contains duplicate repository key '{repo}'"); + } + } + Ok(repo_paths) +} + impl BrokerRuntime { /// Drain a workspace-firehose event for the broker runtime. /// @@ -107,6 +153,53 @@ pub(super) fn relaycast_spawn_worker_cwd(ws_value: &Value) -> Result Result> { + let Some(assignment) = ws_value.get("assignment") else { + return Ok(None); + }; + let repo = assignment + .get("repo") + .and_then(Value::as_str) + .map(str::trim) + .filter(|repo| !repo.is_empty()) + .context("assignment.repo must be a non-empty string")?; + Ok(Some(repo)) +} + +/// Resolve a spawn working directory on this node. +/// +/// Repository assignments are mapped only through the node-local repo map. +/// In that mode every remote cwd variant is deliberately ignored, including +/// legacy Factory fields. A missing key or unusable local checkout fails the +/// spawn instead of inheriting the broker cwd or accepting a caller path. +pub(super) fn relaycast_spawn_worker_cwd_for_node( + ws_value: &Value, + node_repo_paths: &BTreeMap, +) -> Result> { + let Some(repo) = relaycast_assignment_repo(ws_value)? else { + return relaycast_spawn_worker_cwd(ws_value); + }; + + let path = node_repo_paths + .get(repo) + .with_context(|| format!("no local checkout configured for assignment.repo '{repo}'"))?; + if !path.is_absolute() { + anyhow::bail!("local checkout configured for assignment.repo '{repo}' must be absolute"); + } + let metadata = std::fs::metadata(path) + .with_context(|| format!("local checkout for assignment.repo '{repo}' is unavailable"))?; + if !metadata.is_dir() { + anyhow::bail!("local checkout for assignment.repo '{repo}' is not a directory"); + } + let path = path.to_str().context( + "local checkout configured for assignment.repo contains unsupported path characters", + )?; + Ok(Some(path.to_string())) +} + /// Read the dispatcher-issued commit attestation from an active node-control /// spawn request. The legacy workspace-stream bridge deserializes the same /// field into `SpawnParams`, but node-only `action.invoke` spawns bypass that @@ -422,6 +515,7 @@ pub(super) async fn spawn_worker_from_request( model: Option, exit_after_task: bool, ws_value: &Value, + node_repo_paths: &BTreeMap, workspace_id: &WorkspaceId, control_dedup_key: Option<&str>, workspace_state: &RelayWorkspace, @@ -462,9 +556,9 @@ pub(super) async fn spawn_worker_from_request( anyhow::bail!("agent '{name}' is the broker self"); } // Resolve and validate the directory on the selected node, before dedup or - // registration side effects. A remote Fleet cwd cannot be validated by the - // caller because the path belongs to this node's filesystem. - let worker_cwd = relaycast_spawn_worker_cwd(ws_value)?; + // registration side effects. A repository placement can only use this + // node's locally configured checkout; remote cwd fields are ignored. + let worker_cwd = relaycast_spawn_worker_cwd_for_node(ws_value, node_repo_paths)?; let local_spawn_echo_key = relaycast_spawn_control_dedup_key(workspace_id, &name); if relaycast_ws_should_apply_local_spawn_echo_dedup(control_dedup_key, &local_spawn_echo_key) && !dedup.insert_if_new(&local_spawn_echo_key, Instant::now()) @@ -1157,6 +1251,7 @@ mod tests { None, false, &ws_value, + &BTreeMap::new(), &workspace_id, Some(&control_key), &workspace, @@ -1265,6 +1360,139 @@ mod tests { assert!(error.contains("must be an absolute path"), "{error}"); } + fn node_repo_paths(repo: &str, path: &Path) -> BTreeMap { + BTreeMap::from([(repo.to_string(), path.to_path_buf())]) + } + + #[test] + fn assignment_repo_resolves_to_the_node_local_checkout() { + let checkout = tempfile::tempdir().expect("local checkout fixture"); + let paths = node_repo_paths("AgentWorkforce/relay", checkout.path()); + + let cwd = relaycast_spawn_worker_cwd_for_node( + &json!({ "assignment": { "repo": "AgentWorkforce/relay" } }), + &paths, + ) + .expect("mapped repository assignment should resolve"); + + assert_eq!(cwd.as_deref(), checkout.path().to_str()); + } + + #[test] + fn assignment_repo_rejects_a_missing_local_key() { + let checkout = tempfile::tempdir().expect("local checkout fixture"); + let paths = node_repo_paths("AgentWorkforce/factory", checkout.path()); + + let error = relaycast_spawn_worker_cwd_for_node( + &json!({ "assignment": { "repo": "AgentWorkforce/relay" } }), + &paths, + ) + .expect_err("unknown repository key must fail closed") + .to_string(); + + assert!( + error.contains( + "no local checkout configured for assignment.repo 'AgentWorkforce/relay'" + ), + "{error}" + ); + } + + #[test] + fn assignment_repo_rejects_a_missing_local_directory() { + let root = tempfile::tempdir().expect("local checkout fixture"); + let paths = node_repo_paths("AgentWorkforce/relay", &root.path().join("missing")); + + let error = relaycast_spawn_worker_cwd_for_node( + &json!({ "assignment": { "repo": "AgentWorkforce/relay" } }), + &paths, + ) + .expect_err("missing mapped checkout must fail closed") + .to_string(); + + assert!( + error.contains( + "local checkout for assignment.repo 'AgentWorkforce/relay' is unavailable" + ), + "{error}" + ); + assert!( + !error.contains(&root.path().display().to_string()), + "{error}" + ); + } + + #[test] + fn assignment_repo_rejects_a_file_local_path() { + let root = tempfile::tempdir().expect("local checkout fixture"); + let checkout_file = root.path().join("not-a-directory"); + std::fs::write(&checkout_file, "fixture").expect("write checkout file fixture"); + let paths = node_repo_paths("AgentWorkforce/relay", &checkout_file); + + let error = relaycast_spawn_worker_cwd_for_node( + &json!({ "assignment": { "repo": "AgentWorkforce/relay" } }), + &paths, + ) + .expect_err("file mapped as a checkout must fail closed") + .to_string(); + + assert!( + error.contains( + "local checkout for assignment.repo 'AgentWorkforce/relay' is not a directory" + ), + "{error}" + ); + } + + #[test] + fn assignment_repo_ignores_remote_cwd_variants() { + let checkout = tempfile::tempdir().expect("local checkout fixture"); + let remote_cwd = tempfile::tempdir().expect("remote cwd fixture"); + let paths = node_repo_paths("AgentWorkforce/relay", checkout.path()); + + let cwd = relaycast_spawn_worker_cwd_for_node( + &json!({ + "assignment": { "repo": "AgentWorkforce/relay" }, + "cwd": remote_cwd.path(), + "worker_cwd": remote_cwd.path(), + "clone_path": remote_cwd.path(), + "clonePath": remote_cwd.path(), + }), + &paths, + ) + .expect("a mapped assignment must ignore all remote cwd variants"); + + assert_eq!(cwd.as_deref(), checkout.path().to_str()); + assert_ne!(cwd.as_deref(), remote_cwd.path().to_str()); + } + + #[test] + fn assignment_repo_has_no_remote_or_broker_cwd_fallback() { + let remote_cwd = tempfile::tempdir().expect("remote cwd fixture"); + + let error = relaycast_spawn_worker_cwd_for_node( + &json!({ + "assignment": { "repo": "AgentWorkforce/relay" }, + "worker_cwd": remote_cwd.path(), + }), + &BTreeMap::new(), + ) + .expect_err("an unmapped assignment must not fall back to remote or broker cwd") + .to_string(); + + assert!( + error.contains( + "no local checkout configured for assignment.repo 'AgentWorkforce/relay'" + ), + "{error}" + ); + assert!(!error.contains("worker_cwd"), "{error}"); + assert!( + !error.contains(&remote_cwd.path().display().to_string()), + "{error}" + ); + } + #[test] fn node_control_spawn_reads_nested_commit_attestation() { let value = json!({ diff --git a/crates/broker/src/runtime/tests.rs b/crates/broker/src/runtime/tests.rs index ff1203253..3dfb4143b 100644 --- a/crates/broker/src/runtime/tests.rs +++ b/crates/broker/src/runtime/tests.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeSet, HashMap, HashSet}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, path::PathBuf, process::Stdio, sync::{Mutex, OnceLock}, @@ -265,6 +265,7 @@ fn worker_event_runtime_fixture( ws_inbound_rx, relaycast_open: true, fleet_control_tx, + node_repo_paths: BTreeMap::new(), fleet_node_name: "test-node".to_string(), node_delivery_token_present: true, node_delivery_connected: true, From 2ec5daa91683ace295040280f207498a3db8de65 Mon Sep 17 00:00:00 2001 From: Miya Date: Wed, 19 Aug 2026 09:24:40 +0200 Subject: [PATCH 02/13] fix(cli): pass node repo paths to broker Session-Id: 01a018d0-b15a-7552-b2fd-ca79a9b9396a --- .../cli/src/cli/lib/broker-lifecycle.test.ts | 14 +++++++++ packages/cli/src/cli/lib/broker-lifecycle.ts | 31 +++++++++++++++++++ .../src/cli/lib/node-provider-child.test.ts | 7 +++++ .../cli/src/cli/lib/node-provider-child.ts | 16 ++++++++++ 4 files changed, 68 insertions(+) diff --git a/packages/cli/src/cli/lib/broker-lifecycle.test.ts b/packages/cli/src/cli/lib/broker-lifecycle.test.ts index a83a1e466..eab1f9a7b 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.test.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.test.ts @@ -9,6 +9,7 @@ import { describeErrorWithCause, getBrokerStatusWithRetry, isBundledBunExecutableEntrypoint, + nodeRepoPathsForBroker, readNodeDeliveryStatus, resolveNodeIdentityFromSession, waitForNodeDelivery, @@ -35,6 +36,19 @@ describe('isBundledBunExecutableEntrypoint', () => { }); }); +describe('nodeRepoPathsForBroker', () => { + it('serializes the definition map for the local broker process', () => { + expect( + nodeRepoPathsForBroker({ repoPaths: { 'AgentWorkforce/relay': '/srv/checkouts/relay' } }) + ).toBe('{"AgentWorkforce/relay":"/srv/checkouts/relay"}'); + }); + + it('keeps an explicit empty map authoritative and leaves an absent map unset', () => { + expect(nodeRepoPathsForBroker({ repoPaths: {} })).toBe('{}'); + expect(nodeRepoPathsForBroker({})).toBeUndefined(); + }); +}); + describe('describeErrorWithCause', () => { it('returns plain message for a bare Error', () => { expect(describeErrorWithCause(new Error('boom'))).toBe('boom'); diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index 4fba55ae8..090991e8f 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -77,6 +77,8 @@ const DEFAULT_BROKER_BASE_PORT = 3888; const CONNECTION_FILENAME = 'connection.json'; const BACKGROUND_START_ERROR_FILENAME = 'background-start-error.log'; export const WORKSPACE_BINDING_SOURCE_ENV = 'AGENT_RELAY_WORKSPACE_SOURCE'; +/** Local-only node repository map inherited by the native broker. */ +export const NODE_REPO_PATHS_ENV = 'AGENT_RELAY_NODE_REPO_PATHS'; const STATUS_POLL_INTERVAL_MS = 500; const DETACHED_START_READY_TIMEOUT_MS = 10_000; const NODE_DELIVERY_READY_TIMEOUT_MS = 10_000; @@ -1513,6 +1515,10 @@ type NodeDefinitionPlan = | { mode: 'in-process'; definition: FleetNodeDefinition } | { mode: 'child-node'; configPath: string; descriptor: NodeDefinitionDescriptor }; +type NodeRepoPathsSource = { + repoPaths?: Readonly>; +}; + /** * Decide how to serve `configPath` and gather what the broker needs before it * starts (capacity, and a hard failure on a bad explicit --config). @@ -1540,6 +1546,24 @@ function planCapacitySource( return plan.mode === 'in-process' ? plan.definition : descriptorCapacitySource(plan.descriptor); } +/** + * Serialize a definition's node-local checkout map for the broker child. + * + * This never reaches a Fleet registration frame: the broker only receives it + * through its inherited environment, while the definition registration emits + * the corresponding repository keys separately. + */ +export function nodeRepoPathsForBroker(source: NodeRepoPathsSource | undefined): string | undefined { + return source?.repoPaths === undefined ? undefined : JSON.stringify(source.repoPaths); +} + +function planRepoPathsForBroker(plan: NodeDefinitionPlan | undefined): string | undefined { + if (!plan) { + return undefined; + } + return nodeRepoPathsForBroker(plan.mode === 'in-process' ? plan.definition : plan.descriptor); +} + /** * Apply the resolved workspace to the environment the broker (and any detached * child) inherits, and report which source won. Returns the pinned project @@ -1890,6 +1914,13 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): teamsConfig, planCapacitySource(nodePlan) ); + // Keep checkout paths on the receiving machine. The native broker resolves + // assignment.repo against this map immediately before spawning; registration + // only receives the key-derived data provided by the node definition. + const nodeRepoPaths = planRepoPathsForBroker(nodePlan); + if (nodeRepoPaths !== undefined) { + deps.env[NODE_REPO_PATHS_ENV] = nodeRepoPaths; + } // Kill any orphaned broker processes for this project that lost their PID // files (e.g. user deleted .agentworkforce/relay/ while broker was running). diff --git a/packages/cli/src/cli/lib/node-provider-child.test.ts b/packages/cli/src/cli/lib/node-provider-child.test.ts index ea74e3087..b048d6bbd 100644 --- a/packages/cli/src/cli/lib/node-provider-child.test.ts +++ b/packages/cli/src/cli/lib/node-provider-child.test.ts @@ -62,6 +62,13 @@ describe('parseNodeDescriptor', () => { expect(parseNodeDescriptor(stdout)).toEqual({ name: 'real', capabilities: [], maxAgents: 3 }); }); + it('preserves the local repo path map for the broker child only', () => { + const repoPaths = { 'AgentWorkforce/relay': '/srv/checkouts/relay' }; + const stdout = `${MARKER}${JSON.stringify({ name: 'real', capabilities: [], repoPaths })}\n`; + + expect(parseNodeDescriptor(stdout)).toEqual({ name: 'real', capabilities: [], repoPaths }); + }); + it('returns undefined when the child produced no descriptor', () => { expect(parseNodeDescriptor('some unrelated output\n')).toBeUndefined(); }); diff --git a/packages/cli/src/cli/lib/node-provider-child.ts b/packages/cli/src/cli/lib/node-provider-child.ts index 08da44129..f9d3133bb 100644 --- a/packages/cli/src/cli/lib/node-provider-child.ts +++ b/packages/cli/src/cli/lib/node-provider-child.ts @@ -115,6 +115,7 @@ if (describeOnly) { name: definition.name, capabilities: Object.keys(definition.capabilities || {}), ...(typeof definition.maxAgents === 'number' ? { maxAgents: definition.maxAgents } : {}), + ...(definition.repoPaths ? { repoPaths: definition.repoPaths } : {}), }; console.log('__AGENT_RELAY_NODE_DESCRIPTOR__' + JSON.stringify(descriptor)); return; @@ -256,6 +257,8 @@ export type NodeDefinitionDescriptor = { name: string; capabilities: string[]; maxAgents?: number; + /** Local-only map relayed to the broker process, never to the Fleet wire. */ + repoPaths?: Record; }; /** @@ -273,13 +276,26 @@ export function parseNodeDescriptor(stdout: string): NodeDefinitionDescriptor | return undefined; } const parsed = JSON.parse(last.slice(NODE_DESCRIPTOR_MARKER.length)) as NodeDefinitionDescriptor; + const repoPaths = stringRecord(parsed.repoPaths); return { name: parsed.name, capabilities: Array.isArray(parsed.capabilities) ? parsed.capabilities : [], ...(typeof parsed.maxAgents === 'number' ? { maxAgents: parsed.maxAgents } : {}), + ...(repoPaths ? { repoPaths } : {}), }; } +function stringRecord(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const entries = Object.entries(value); + if (!entries.every(([key, entry]) => Boolean(key) && typeof entry === 'string')) { + return undefined; + } + return Object.fromEntries(entries) as Record; +} + /** * Adapt a descriptor to the capacity shape, so a node definition served * out-of-process still contributes its `spawn:` capabilities to the From feda64417dcb01f7e528b07e8c223b8d5e1de612 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 19 Aug 2026 09:27:01 +0200 Subject: [PATCH 03/13] Add private repo path registration Session-Id: 01a018d1-53a1-7db3-ba82-39dc3094751e --- .../2026-08/traj_5ga3aenlthf7/summary.md | 31 +++++++ .../2026-08/traj_5ga3aenlthf7/trajectory.json | 53 ++++++++++++ CHANGELOG.md | 6 +- packages/fleet/README.md | 5 ++ packages/fleet/src/index.test.ts | 50 ++++++++++++ packages/fleet/src/index.ts | 81 ++++++++++++++++++- packages/fleet/src/serve-node.test.ts | 31 +++++++ packages/fleet/src/serve-node.ts | 4 +- 8 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 .agentworkforce/trajectories/completed/2026-08/traj_5ga3aenlthf7/summary.md create mode 100644 .agentworkforce/trajectories/completed/2026-08/traj_5ga3aenlthf7/trajectory.json diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_5ga3aenlthf7/summary.md b/.agentworkforce/trajectories/completed/2026-08/traj_5ga3aenlthf7/summary.md new file mode 100644 index 000000000..b30e917f9 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_5ga3aenlthf7/summary.md @@ -0,0 +1,31 @@ +# Trajectory: Implement node-local repo registration contract + +> **Status:** ✅ Completed +> **Confidence:** 95% +> **Started:** August 19, 2026 at 09:03 AM +> **Completed:** August 19, 2026 at 09:26 AM + +--- + +## Summary + +Added validated node-local repoPaths configuration and path-free repo tag registration in the Fleet SDK, with compatibility, privacy serialization tests, docs, and changelog. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Use repoPaths as the node-local TypeScript config and emit only derived repo: tags +- **Chose:** Use repoPaths as the node-local TypeScript config and emit only derived repo: tags +- **Reasoning:** Relaycast's current registration path already persists repo tags and Relay's installed NodeProviderClient supports tags but not repo_keys; the companion node-resolution lane owns the private broker handoff. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Use repoPaths as the node-local TypeScript config and emit only derived repo: tags: Use repoPaths as the node-local TypeScript config and emit only derived repo: tags diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_5ga3aenlthf7/trajectory.json b/.agentworkforce/trajectories/completed/2026-08/traj_5ga3aenlthf7/trajectory.json new file mode 100644 index 000000000..7b0d872a3 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_5ga3aenlthf7/trajectory.json @@ -0,0 +1,53 @@ +{ + "id": "traj_5ga3aenlthf7", + "version": 1, + "task": { + "title": "Implement node-local repo registration contract" + }, + "status": "completed", + "startedAt": "2026-08-19T07:03:27.927Z", + "completedAt": "2026-08-19T07:26:07.341Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-08-19T07:14:24.184Z" + } + ], + "chapters": [ + { + "id": "chap_aix2fv0fj01l", + "title": "Work", + "agentName": "default", + "startedAt": "2026-08-19T07:14:24.184Z", + "endedAt": "2026-08-19T07:26:07.341Z", + "events": [ + { + "ts": 1787123664185, + "type": "decision", + "content": "Use repoPaths as the node-local TypeScript config and emit only derived repo: tags: Use repoPaths as the node-local TypeScript config and emit only derived repo: tags", + "raw": { + "question": "Use repoPaths as the node-local TypeScript config and emit only derived repo: tags", + "chosen": "Use repoPaths as the node-local TypeScript config and emit only derived repo: tags", + "alternatives": [], + "reasoning": "Relaycast's current registration path already persists repo tags and Relay's installed NodeProviderClient supports tags but not repo_keys; the companion node-resolution lane owns the private broker handoff." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Added validated node-local repoPaths configuration and path-free repo tag registration in the Fleet SDK, with compatibility, privacy serialization tests, docs, and changelog.", + "approach": "Standard approach", + "confidence": 0.95 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relay", + "tags": [], + "_trace": { + "startRef": "286467e936bb3efbd415ed233d416e59cd390c05", + "endRef": "286467e936bb3efbd415ed233d416e59cd390c05" + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index db2910bf6..375aa65bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ 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 + +- `@agent-relay/fleet` node definitions can declare node-local `repoPaths`; registration advertises only placement-safe `repo:` tags while absolute checkout paths remain on the serving node. ## [11.7.2] - 2026-08-19 diff --git a/packages/fleet/README.md b/packages/fleet/README.md index 961b59f61..d2a8e4bcf 100644 --- a/packages/fleet/README.md +++ b/packages/fleet/README.md @@ -25,6 +25,11 @@ import { z } from 'zod'; export default defineNode({ name: 'builder', + // Absolute checkout paths stay on this node. Registration exposes only the + // owner/name keys so placement can route work without learning local paths. + repoPaths: { + 'your-org/service': '/srv/checkouts/service', + }, capabilities: { 'run:test': action({ input: z.object({ suite: z.string() }) }, async ({ input }) => { // ...run the suite... diff --git a/packages/fleet/src/index.test.ts b/packages/fleet/src/index.test.ts index e0340edb3..16bcf9850 100644 --- a/packages/fleet/src/index.test.ts +++ b/packages/fleet/src/index.test.ts @@ -6,6 +6,8 @@ import { defineDefaultLocalNode, defineNode, invokeNodeHandler, + nodeInfo, + nodeRegistrationTags, onMessage, spawn, triggerSyncInputs, @@ -27,6 +29,33 @@ describe('@agent-relay/fleet', () => { expect(node.capabilities['run:build']).toMatchObject({ name: 'run:build', kind: 'action' }); }); + it('normalizes node-local repo paths without exposing values through node info', () => { + const node = defineNode({ + name: 'builder-1', + repoPaths: { + ' AgentWorkforce/relay ': '/srv/checkouts/relay', + 'AgentWorkforce/factory': 'C:\\work\\factory', + }, + capabilities: { ping: async () => 'pong' }, + }); + + expect(node.repoPaths).toEqual({ + 'AgentWorkforce/relay': '/srv/checkouts/relay', + 'AgentWorkforce/factory': 'C:\\work\\factory', + }); + expect(JSON.stringify(nodeInfo(node))).not.toContain('/srv/checkouts/relay'); + }); + + it('preserves compatible repo tags when no local repo map is configured', () => { + const node = defineNode({ + name: 'legacy-builder', + tags: ['arm64', 'repo:AgentWorkforce/relay'], + capabilities: { ping: async () => 'pong' }, + }); + + expect(nodeRegistrationTags(node)).toEqual(['arm64', 'repo:AgentWorkforce/relay']); + }); + it('accepts a plain async handler as an escape hatch', async () => { const node = defineNode({ name: 'custom', @@ -234,6 +263,27 @@ describe('@agent-relay/fleet', () => { triggers: [onMessage({ match: /ship/i }, 'run')], }) ).toThrow(/trigger regex flags are not supported yet/); + expect(() => + defineNode({ + name: 'x', + capabilities: { run: async () => undefined }, + repoPaths: { '/Users/alice/relay': '/srv/relay' }, + }) + ).toThrow(/owner\/name/); + expect(() => + defineNode({ + name: 'x', + capabilities: { run: async () => undefined }, + repoPaths: { 'AgentWorkforce/relay': 'relative/relay' }, + }) + ).toThrow(/absolute path/); + expect(() => + defineNode({ + name: 'x', + capabilities: { run: async () => undefined }, + tags: ['repo:/Users/alice/relay'], + }) + ).toThrow(/owner\/name/); }); }); diff --git a/packages/fleet/src/index.ts b/packages/fleet/src/index.ts index 5fbc948f1..a1f7dbcdb 100644 --- a/packages/fleet/src/index.ts +++ b/packages/fleet/src/index.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; + import { z } from 'zod'; import { claude, codex, definePtyHarness, gemini, type PtyHarness } from '@agent-relay/harnesses'; import { resolveStaticHarnessConfig, type StaticPtyHarnessDefinition } from '@agent-relay/harness-driver'; @@ -91,6 +93,12 @@ export interface FleetNodeDefinitionInput { capabilities: Record; triggers?: FleetTriggerDescriptor[]; tags?: string[]; + /** + * Node-local checkout map keyed by placement-safe `owner/name` repository + * identities. Values never leave the node; registration advertises only the + * keys as `repo:` tags. + */ + repoPaths?: Record; version?: string; } @@ -101,6 +109,8 @@ export interface FleetNodeDefinition { readonly capabilities: Record; readonly triggers: FleetTriggerDescriptor[]; readonly tags?: string[]; + /** Node-private checkout paths. Never include these values in wire or log payloads. */ + readonly repoPaths?: Readonly>; readonly version?: string; } @@ -210,17 +220,38 @@ export function defineNode(input: FleetNodeDefinitionInput): FleetNodeDefinition } } + const tags = normalizeNodeTags(input.tags); + const repoPaths = normalizeRepoPaths(input.repoPaths); + return { __agentRelayFleetNode: true, name, ...(input.maxAgents !== undefined ? { maxAgents: input.maxAgents } : {}), capabilities, triggers: [...triggers], - ...(input.tags ? { tags: [...input.tags] } : {}), + ...(tags ? { tags } : {}), + ...(repoPaths ? { repoPaths } : {}), ...(input.version ? { version: input.version } : {}), }; } +/** + * Build the public registration tags for a node definition. + * + * A configured repo map is authoritative: caller-supplied `repo:*` tags are + * replaced with keys derived from that node-local map. The absolute values are + * never returned, so callers can pass this result directly to a wire client. + */ +export function nodeRegistrationTags(definition: FleetNodeDefinition): string[] | undefined { + if (!definition.repoPaths) { + return definition.tags ? [...definition.tags] : undefined; + } + return [ + ...(definition.tags ?? []).filter((tag) => !tag.startsWith('repo:')), + ...Object.keys(definition.repoPaths).map((repoKey) => `repo:${repoKey}`), + ]; +} + export function isFleetNodeDefinition(value: unknown): value is FleetNodeDefinition { return Boolean( value && @@ -439,6 +470,54 @@ function isFleetSpawnDefinition(value: FleetActionDefinition): boolean { return (value as FleetSpawnDefinition).fleetKind === 'spawn'; } +const REPO_KEY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; + +function normalizeNodeTags(tags: string[] | undefined): string[] | undefined { + if (!tags) return undefined; + const normalized = [...tags]; + for (const tag of normalized) { + if (tag.startsWith('repo:')) { + assertRepoKey(tag.slice('repo:'.length), 'repo tag'); + } + } + return normalized; +} + +function normalizeRepoPaths( + repoPaths: Record | undefined +): Readonly> | undefined { + if (repoPaths === undefined) return undefined; + const normalized: Record = {}; + for (const [rawKey, rawRepoPath] of Object.entries(repoPaths)) { + const repoKey = rawKey.trim(); + assertRepoKey(repoKey, 'repoPaths key'); + if (Object.prototype.hasOwnProperty.call(normalized, repoKey)) { + throw new Error(`defineNode requires unique repoPaths keys; duplicate "${repoKey}" after trimming`); + } + if ( + typeof rawRepoPath !== 'string' || + !rawRepoPath.trim() || + (!path.isAbsolute(rawRepoPath) && !path.win32.isAbsolute(rawRepoPath)) + ) { + throw new Error(`repoPaths["${repoKey}"] must be an absolute path`); + } + normalized[repoKey] = rawRepoPath; + } + return Object.freeze(normalized); +} + +function assertRepoKey(repoKey: string, label: string): void { + const segments = repoKey.split('/'); + if ( + repoKey.length < 3 || + repoKey.length > 201 || + !REPO_KEY_PATTERN.test(repoKey) || + segments.some((segment) => segment === '.' || segment === '..') + ) { + throw new Error(`${label} must use owner/name with no filesystem path or URL`); + } +} + function normalizePtyHarness(harness: StaticPtyHarnessDefinition | PtyHarness): StaticPtyHarnessDefinition { if (!harness || typeof harness !== 'object') { throw new Error('spawn requires a PTY harness definition'); diff --git a/packages/fleet/src/serve-node.test.ts b/packages/fleet/src/serve-node.test.ts index 432c6cefd..98111c591 100644 --- a/packages/fleet/src/serve-node.test.ts +++ b/packages/fleet/src/serve-node.test.ts @@ -120,6 +120,37 @@ describe('serveNode', () => { await running.stop(); }); + it('advertises repo keys from the node-local map without serializing absolute paths', async () => { + const privateFactoryPath = '/srv/private/checkouts/factory'; + const privateRelayPath = 'C:\\private\\relay'; + const node = defineNode({ + name: 'repo-builder', + tags: ['arm64', 'repo:legacy/ignored'], + repoPaths: { + 'AgentWorkforce/factory': privateFactoryPath, + 'AgentWorkforce/relay': privateRelayPath, + }, + capabilities: { ping: async () => 'pong' }, + }); + const running = startServeNode({ definition: node, connection, reconnect: false }); + + const sock = socket(); + sock.open(); + const register = sock.lastRegister(); + expect(register.tags).toEqual(['arm64', 'repo:AgentWorkforce/factory', 'repo:AgentWorkforce/relay']); + expect(register).not.toHaveProperty('repoPaths'); + expect(register).not.toHaveProperty('repo_paths'); + expect(register).not.toHaveProperty('repoKeys'); + expect(register).not.toHaveProperty('repo_keys'); + const serialized = JSON.stringify(register); + expect(serialized).not.toContain(privateFactoryPath); + expect(serialized).not.toContain(privateRelayPath); + + sock.emit(acceptAll(register)); + await flush(); + await running.stop(); + }); + it('runs a handler and replies action.result with its output', async () => { const node = defineNode({ name: 'p', diff --git a/packages/fleet/src/serve-node.ts b/packages/fleet/src/serve-node.ts index 5ad607c4f..ffcfb8961 100644 --- a/packages/fleet/src/serve-node.ts +++ b/packages/fleet/src/serve-node.ts @@ -3,6 +3,7 @@ import { NodeProviderClient, type NodeCapabilityHandler, type NodeHandlerContext import { invokeNodeHandler, nodeInfo, + nodeRegistrationTags, triggerSyncInputs, type FleetActionContext, type FleetNodeDefinition, @@ -183,6 +184,7 @@ export async function serveNode(options: ServeNodeOptions): Promise { const maxAgents = options.maxAgentsOverride ?? options.definition.maxAgents; const reconnect = options.reconnect ?? true; const logger = resolveLogger(options); + const registrationTags = nodeRegistrationTags(options.definition); const client = new NodeProviderClient({ ...(options.connection.baseUrl ? { baseUrl: options.connection.baseUrl } : {}), @@ -191,7 +193,7 @@ export async function serveNode(options: ServeNodeOptions): Promise { nodeName, provider: { name: providerName }, ...(maxAgents !== undefined ? { maxAgents } : {}), - ...(options.definition.tags ? { tags: [...options.definition.tags] } : {}), + ...(registrationTags ? { tags: registrationTags } : {}), ...(options.definition.version ? { version: options.definition.version } : {}), // A drop during shutdown is expected; only surface a real error otherwise. ...(reconnect ? {} : { maxReconnectAttempts: 0 }), From 40a87d73b2226786186822d90607e04df2c0454c Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Wed, 19 Aug 2026 09:31:06 +0200 Subject: [PATCH 04/13] feat(fleet): add privacy-safe repository registration Session-Id: 01a018d0-e371-75d1-8aea-1f2a8df7dfbc --- .../2026-08/traj_8xylo1e6uc4z/summary.md | 32 ++++++++ .../2026-08/traj_8xylo1e6uc4z/trajectory.json | 73 +++++++++++++++++ CHANGELOG.md | 6 +- crates/broker/src/fleet_wire.rs | 37 +++++++++ crates/broker/src/node_control.rs | 4 + crates/broker/src/protocol.rs | 3 + crates/broker/src/runtime/init.rs | 1 + .../fixtures/fleet-wire/node.register.json | 1 + .../src/cli/lib/node-provider-child.test.ts | 35 ++++++++ .../cli/src/cli/lib/node-provider-child.ts | 30 +++++++ packages/fleet/README.md | 21 +++++ packages/fleet/src/index.test.ts | 71 +++++++++++++++++ packages/fleet/src/index.ts | 79 +++++++++++++++++++ packages/fleet/src/serve-node.test.ts | 31 ++++++++ packages/fleet/src/serve-node.ts | 4 +- packages/harness-driver/src/protocol.ts | 2 + 16 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 .agentworkforce/trajectories/completed/2026-08/traj_8xylo1e6uc4z/summary.md create mode 100644 .agentworkforce/trajectories/completed/2026-08/traj_8xylo1e6uc4z/trajectory.json diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_8xylo1e6uc4z/summary.md b/.agentworkforce/trajectories/completed/2026-08/traj_8xylo1e6uc4z/summary.md new file mode 100644 index 000000000..0e89fea54 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_8xylo1e6uc4z/summary.md @@ -0,0 +1,32 @@ +# Trajectory: Add privacy-safe repository registration contract + +> **Status:** ✅ Completed +> **Confidence:** 95% +> **Started:** August 19, 2026 at 09:04 AM +> **Completed:** August 19, 2026 at 09:30 AM + +--- + +## Summary + +Added validated node-local repoPaths, privacy-safe registration keys/tags, TypeScript and Rust repo_keys wire plumbing, compiled-child local descriptor handoff, compatibility tests, and documentation. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Used repo: tags for JS provider compatibility while adding explicit repo_keys to the TypeScript/Rust broker wire mirror +- **Chose:** Used repo: tags for JS provider compatibility while adding explicit repo_keys to the TypeScript/Rust broker wire mirror +- **Reasoning:** The relay repository consumes @relaycast/sdk 8.0.0, whose strict node.register shape cannot emit a new repo_keys option yet. Existing tags are already accepted and placement reads them, so deriving authoritative tags from the node-local repoPaths map keeps this PR independently landable. The Rust field remains empty until the node-resolution lane supplies keys from its validated local map. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Used repo: tags for JS provider compatibility while adding explicit repo_keys to the TypeScript/Rust broker wire mirror: Used repo: tags for JS provider compatibility while adding explicit repo_keys to the TypeScript/Rust broker wire mirror +- Wire contract is complete and green; security review caught that owner/repo shape must reject dot path segments, so both fleet config and compiled-child IPC now enforce exactly two allowlisted non-dot segments and absolute values. diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_8xylo1e6uc4z/trajectory.json b/.agentworkforce/trajectories/completed/2026-08/traj_8xylo1e6uc4z/trajectory.json new file mode 100644 index 000000000..8f69e5a01 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_8xylo1e6uc4z/trajectory.json @@ -0,0 +1,73 @@ +{ + "id": "traj_8xylo1e6uc4z", + "version": 1, + "task": { + "title": "Add privacy-safe repository registration contract" + }, + "status": "completed", + "startedAt": "2026-08-19T07:04:08.402Z", + "completedAt": "2026-08-19T07:30:42.191Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-08-19T07:14:40.829Z" + } + ], + "chapters": [ + { + "id": "chap_ibagoj6gc7rf", + "title": "Work", + "agentName": "default", + "startedAt": "2026-08-19T07:14:40.829Z", + "endedAt": "2026-08-19T07:30:42.191Z", + "events": [ + { + "ts": 1787123680829, + "type": "decision", + "content": "Used repo: tags for JS provider compatibility while adding explicit repo_keys to the TypeScript/Rust broker wire mirror: Used repo: tags for JS provider compatibility while adding explicit repo_keys to the TypeScript/Rust broker wire mirror", + "raw": { + "question": "Used repo: tags for JS provider compatibility while adding explicit repo_keys to the TypeScript/Rust broker wire mirror", + "chosen": "Used repo: tags for JS provider compatibility while adding explicit repo_keys to the TypeScript/Rust broker wire mirror", + "alternatives": [], + "reasoning": "The relay repository consumes @relaycast/sdk 8.0.0, whose strict node.register shape cannot emit a new repo_keys option yet. Existing tags are already accepted and placement reads them, so deriving authoritative tags from the node-local repoPaths map keeps this PR independently landable. The Rust field remains empty until the node-resolution lane supplies keys from its validated local map." + }, + "significance": "high" + }, + { + "ts": 1787124151246, + "type": "reflection", + "content": "Wire contract is complete and green; security review caught that owner/repo shape must reject dot path segments, so both fleet config and compiled-child IPC now enforce exactly two allowlisted non-dot segments and absolute values.", + "raw": { + "focalPoints": [ + "privacy", + "validation", + "compatibility" + ], + "confidence": 0.95 + }, + "significance": "high", + "tags": [ + "focal:privacy", + "focal:validation", + "focal:compatibility", + "confidence:0.95" + ] + } + ] + } + ], + "retrospective": { + "summary": "Added validated node-local repoPaths, privacy-safe registration keys/tags, TypeScript and Rust repo_keys wire plumbing, compiled-child local descriptor handoff, compatibility tests, and documentation.", + "approach": "Standard approach", + "confidence": 0.95 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relay", + "tags": [], + "_trace": { + "startRef": "286467e936bb3efbd415ed233d416e59cd390c05", + "endRef": "286467e936bb3efbd415ed233d416e59cd390c05" + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index db2910bf6..1e3c5fda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ 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 + +- Fleet node definitions can declare node-local `repoPaths`; registration advertises only placement-safe repository keys and keeps absolute checkout paths private to the node. ## [11.7.2] - 2026-08-19 diff --git a/crates/broker/src/fleet_wire.rs b/crates/broker/src/fleet_wire.rs index a544e7e86..422fe34c0 100644 --- a/crates/broker/src/fleet_wire.rs +++ b/crates/broker/src/fleet_wire.rs @@ -124,6 +124,9 @@ pub struct NodeRegister { pub capabilities: Vec, pub max_agents: u32, pub tags: Vec, + /// Placement-safe repository keys; absolute node-local paths are forbidden. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub repo_keys: Vec, pub version: String, #[serde( default, @@ -1111,6 +1114,40 @@ mod tests { "resume_cursor": null }) ); + assert_eq!(encoded.get("repo_keys"), None); + } + + #[test] + fn node_register_accepts_public_repo_keys_and_rejects_private_repo_paths() { + let public = json!({ + "type": "node.register", + "v": 1, + "name": "builder-1", + "node_id": "node_1", + "capabilities": [], + "max_agents": 1, + "tags": [], + "repo_keys": ["AgentWorkforce/factory", "AgentWorkforce/relay"], + "version": "relay-broker/test", + "resume_cursor": null + }); + let decoded: BrokerToRelaycast = serde_json::from_value(public.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), public); + + let private = json!({ + "type": "node.register", + "v": 1, + "name": "builder-1", + "node_id": "node_1", + "capabilities": [], + "max_agents": 1, + "tags": [], + "repo_keys": ["AgentWorkforce/factory"], + "repo_paths": {"AgentWorkforce/factory": "/private/node/factory"}, + "version": "relay-broker/test", + "resume_cursor": null + }); + assert!(serde_json::from_value::(private).is_err()); } #[test] diff --git a/crates/broker/src/node_control.rs b/crates/broker/src/node_control.rs index 0d8ebd291..9602e1239 100644 --- a/crates/broker/src/node_control.rs +++ b/crates/broker/src/node_control.rs @@ -1145,6 +1145,7 @@ pub(crate) fn build_node_register( capabilities, max_agents: manifest.max_agents.unwrap_or(0), tags: manifest.tags.clone().unwrap_or_default(), + repo_keys: manifest.repo_keys.clone().unwrap_or_default(), version: manifest .version .as_deref() @@ -3135,6 +3136,7 @@ mod tests { }], max_agents: Some(8), tags: Some(vec!["local".to_string()]), + repo_keys: Some(vec!["AgentWorkforce/relay".to_string()]), version: Some("sidecar/1".to_string()), }; @@ -3143,6 +3145,7 @@ mod tests { assert_eq!(register.name, "builder"); assert_eq!(register.node_id, "node-manifest"); assert_eq!(register.max_agents, 8); + assert_eq!(register.repo_keys, vec!["AgentWorkforce/relay"]); assert_eq!( register.capabilities[0].metadata, Some(BTreeMap::from([( @@ -4085,6 +4088,7 @@ mod tests { }], max_agents: Some(4), tags: Some(vec!["test".to_string()]), + repo_keys: None, version: Some("sidecar/test".to_string()), } } diff --git a/crates/broker/src/protocol.rs b/crates/broker/src/protocol.rs index a74186c7a..1f95ac4cb 100644 --- a/crates/broker/src/protocol.rs +++ b/crates/broker/src/protocol.rs @@ -287,6 +287,9 @@ pub struct NodeManifest { pub max_agents: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tags: Option>, + /// Placement-safe repository keys. Absolute checkout paths remain node-local. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_keys: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub version: Option, } diff --git a/crates/broker/src/runtime/init.rs b/crates/broker/src/runtime/init.rs index 613534ace..12058b386 100644 --- a/crates/broker/src/runtime/init.rs +++ b/crates/broker/src/runtime/init.rs @@ -880,6 +880,7 @@ fn bootstrap_node_manifest(node_name: &str, node_id: &str, broker_version: &str) capabilities, max_agents: node_max_agents(), tags: None, + repo_keys: None, version: Some(broker_version.to_string()), } } diff --git a/crates/broker/tests/fixtures/fleet-wire/node.register.json b/crates/broker/tests/fixtures/fleet-wire/node.register.json index 85b7014a3..fc5a40682 100644 --- a/crates/broker/tests/fixtures/fleet-wire/node.register.json +++ b/crates/broker/tests/fixtures/fleet-wire/node.register.json @@ -25,6 +25,7 @@ ], "max_agents": 8, "tags": ["darwin", "arm64", "ssd"], + "repo_keys": ["AgentWorkforce/factory", "AgentWorkforce/relay"], "version": "relay-broker/0.7.0", "resume_cursor": null } diff --git a/packages/cli/src/cli/lib/node-provider-child.test.ts b/packages/cli/src/cli/lib/node-provider-child.test.ts index ea74e3087..6592f2c1f 100644 --- a/packages/cli/src/cli/lib/node-provider-child.test.ts +++ b/packages/cli/src/cli/lib/node-provider-child.test.ts @@ -53,6 +53,41 @@ describe('parseNodeDescriptor', () => { expect(parseNodeDescriptor(stdout)).toEqual({ name: 'n', capabilities: ['spawn:claude'] }); }); + it('carries node-private repo paths across the local describe IPC', () => { + const repoPath = path.resolve('private-checkouts', 'relay'); + const stdout = `${MARKER}${JSON.stringify({ + name: 'n', + capabilities: ['spawn:codex'], + repoPaths: { 'AgentWorkforce/relay': repoPath }, + })}\n`; + + expect(parseNodeDescriptor(stdout)).toEqual({ + name: 'n', + capabilities: ['spawn:codex'], + repoPaths: { 'AgentWorkforce/relay': repoPath }, + }); + }); + + it.each(['./repo', '../repo'])('rejects relative repo path %s from local describe IPC', (repoPath) => { + const stdout = `${MARKER}${JSON.stringify({ + name: 'n', + capabilities: [], + repoPaths: { 'AgentWorkforce/relay': repoPath }, + })}\n`; + + expect(() => parseNodeDescriptor(stdout)).toThrow(/absolute paths/); + }); + + it.each(['./repo', '../repo'])('rejects path-shaped repo key %s from local describe IPC', (repoKey) => { + const stdout = `${MARKER}${JSON.stringify({ + name: 'n', + capabilities: [], + repoPaths: { [repoKey]: path.resolve('private-checkouts', 'relay') }, + })}\n`; + + expect(() => parseNodeDescriptor(stdout)).toThrow(/owner\/repo keys/); + }); + it('ignores output the config printed on import', () => { const stdout = [ 'booting my node...', diff --git a/packages/cli/src/cli/lib/node-provider-child.ts b/packages/cli/src/cli/lib/node-provider-child.ts index 08da44129..e05ce6cab 100644 --- a/packages/cli/src/cli/lib/node-provider-child.ts +++ b/packages/cli/src/cli/lib/node-provider-child.ts @@ -115,6 +115,11 @@ if (describeOnly) { name: definition.name, capabilities: Object.keys(definition.capabilities || {}), ...(typeof definition.maxAgents === 'number' ? { maxAgents: definition.maxAgents } : {}), + // Local child -> CLI IPC only. The parent passes these values directly to + // its native broker; serveNode registration independently emits keys only. + ...(definition.repoPaths && typeof definition.repoPaths === 'object' + ? { repoPaths: definition.repoPaths } + : {}), }; console.log('__AGENT_RELAY_NODE_DESCRIPTOR__' + JSON.stringify(descriptor)); return; @@ -256,6 +261,8 @@ export type NodeDefinitionDescriptor = { name: string; capabilities: string[]; maxAgents?: number; + /** Node-private map carried only across local child-to-CLI IPC. */ + repoPaths?: Readonly>; }; /** @@ -273,13 +280,36 @@ export function parseNodeDescriptor(stdout: string): NodeDefinitionDescriptor | return undefined; } const parsed = JSON.parse(last.slice(NODE_DESCRIPTOR_MARKER.length)) as NodeDefinitionDescriptor; + const repoPaths = parseDescriptorRepoPaths(parsed.repoPaths); return { name: parsed.name, capabilities: Array.isArray(parsed.capabilities) ? parsed.capabilities : [], ...(typeof parsed.maxAgents === 'number' ? { maxAgents: parsed.maxAgents } : {}), + ...(repoPaths !== undefined ? { repoPaths } : {}), }; } +function parseDescriptorRepoPaths(value: unknown): Readonly> | undefined { + if (value === undefined) { + return undefined; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Fleet node descriptor repoPaths must be an object keyed by owner/repo'); + } + const repoPaths: Record = {}; + for (const [key, repoPath] of Object.entries(value)) { + const segments = key.split('/'); + const placementSafeKey = + segments.length === 2 && + segments.every((segment) => segment !== '.' && segment !== '..' && /^[A-Za-z0-9._-]+$/.test(segment)); + if (!placementSafeKey || typeof repoPath !== 'string' || !path.isAbsolute(repoPath)) { + throw new Error('Fleet node descriptor repoPaths must map owner/repo keys to absolute paths'); + } + repoPaths[key] = repoPath; + } + return repoPaths; +} + /** * Adapt a descriptor to the capacity shape, so a node definition served * out-of-process still contributes its `spawn:` capabilities to the diff --git a/packages/fleet/README.md b/packages/fleet/README.md index 961b59f61..dbb3148f3 100644 --- a/packages/fleet/README.md +++ b/packages/fleet/README.md @@ -48,6 +48,27 @@ agent-relay fleet nodes # list registered nodes agent-relay fleet status # show node + capability health ``` +### Repository placement + +A node can declare the checkouts it can serve with an `owner/repo` keyed map: + +```ts +export default defineNode({ + name: 'builder', + repoPaths: { + 'AgentWorkforce/factory': '/srv/repos/factory', + 'AgentWorkforce/relay': '/srv/repos/relay', + }, + capabilities: { + /* … */ + }, +}); +``` + +Each value must be an absolute path on that node. Registration derives the +placement-safe `AgentWorkforce/factory` and `AgentWorkforce/relay` keys; the +absolute values are never sent to Relaycast, exposed in the roster, or logged. + ### Serving a node programmatically `@agent-relay/fleet` also ships the node runtime, so you can start a node in diff --git a/packages/fleet/src/index.test.ts b/packages/fleet/src/index.test.ts index e0340edb3..4e8087333 100644 --- a/packages/fleet/src/index.test.ts +++ b/packages/fleet/src/index.test.ts @@ -1,3 +1,5 @@ +import { resolve } from 'node:path'; + import { describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; @@ -6,6 +8,8 @@ import { defineDefaultLocalNode, defineNode, invokeNodeHandler, + nodeInfo, + nodeRegistrationTags, onMessage, spawn, triggerSyncInputs, @@ -45,6 +49,73 @@ describe('@agent-relay/fleet', () => { ).resolves.toEqual({ input: { hello: 'world' } }); }); + it('normalizes node-local repo paths and exposes only deterministic placement keys', () => { + const factoryPath = resolve('node-private', 'factory'); + const relayPath = resolve('node-private', 'relay'); + const configured = { + ' AgentWorkforce/relay ': relayPath, + 'AgentWorkforce/factory': factoryPath, + }; + const node = defineNode({ + name: 'repo-builder', + capabilities: { ping: async () => 'pong' }, + tags: ['arm64', 'repo:legacy/manual-tag'], + repoPaths: configured, + }); + + configured[' AgentWorkforce/relay '] = resolve('different-private-path'); + + expect(node.repoPaths).toEqual({ + 'AgentWorkforce/relay': relayPath, + 'AgentWorkforce/factory': factoryPath, + }); + expect(Object.isFrozen(node.repoPaths)).toBe(true); + expect(nodeInfo(node)).toEqual({ + name: 'repo-builder', + capabilities: ['ping'], + repoKeys: ['AgentWorkforce/factory', 'AgentWorkforce/relay'], + }); + expect(nodeRegistrationTags(node)).toEqual([ + 'arm64', + 'repo:AgentWorkforce/factory', + 'repo:AgentWorkforce/relay', + ]); + expect(JSON.stringify(nodeInfo(node))).not.toContain(factoryPath); + expect(JSON.stringify(nodeInfo(node))).not.toContain(relayPath); + }); + + it('preserves legacy repo tags when repoPaths is not configured', () => { + const node = defineNode({ + name: 'legacy-builder', + capabilities: { ping: async () => 'pong' }, + tags: ['arm64', 'repo:legacy'], + }); + + expect(nodeRegistrationTags(node)).toEqual(['arm64', 'repo:legacy']); + }); + + it('rejects non-placement keys and relative checkout paths', () => { + const absolute = resolve('node-private', 'factory'); + for (const invalidKey of ['/private/node/factory', './repo', '../repo']) { + expect(() => + defineNode({ + name: 'bad-key', + capabilities: { ping: async () => 'pong' }, + repoPaths: { [invalidKey]: absolute }, + }) + ).toThrow(/owner\/repo format/); + } + for (const relativePath of ['relative/factory', './repo', '../repo']) { + expect(() => + defineNode({ + name: 'bad-path', + capabilities: { ping: async () => 'pong' }, + repoPaths: { 'AgentWorkforce/factory': relativePath }, + }) + ).toThrow(/must be an absolute path/); + } + }); + it('builds spawn_agent payloads from a PTY harness', async () => { const node = defineNode({ name: 'builder', diff --git a/packages/fleet/src/index.ts b/packages/fleet/src/index.ts index 5fbc948f1..ea04b8127 100644 --- a/packages/fleet/src/index.ts +++ b/packages/fleet/src/index.ts @@ -1,3 +1,5 @@ +import { isAbsolute } from 'node:path'; + import { z } from 'zod'; import { claude, codex, definePtyHarness, gemini, type PtyHarness } from '@agent-relay/harnesses'; import { resolveStaticHarnessConfig, type StaticPtyHarnessDefinition } from '@agent-relay/harness-driver'; @@ -17,8 +19,13 @@ export interface FleetNodeInfo { name: string; maxAgents?: number; capabilities: string[]; + /** Placement-safe repository keys; local checkout paths are never exposed. */ + repoKeys?: string[]; } +/** Node-local checkout map. Values never leave the node registration runtime. */ +export type FleetRepoPaths = Readonly>; + export interface FleetRelaySendMessageInput { to: string; text: string; @@ -91,6 +98,11 @@ export interface FleetNodeDefinitionInput { capabilities: Record; triggers?: FleetTriggerDescriptor[]; tags?: string[]; + /** + * Node-local absolute checkout paths keyed by `owner/repo`. + * Registration advertises only the keys; path values remain node-private. + */ + repoPaths?: FleetRepoPaths; version?: string; } @@ -101,6 +113,8 @@ export interface FleetNodeDefinition { readonly capabilities: Record; readonly triggers: FleetTriggerDescriptor[]; readonly tags?: string[]; + /** Node-private checkout map; only its keys may be serialized for placement. */ + readonly repoPaths?: FleetRepoPaths; readonly version?: string; } @@ -177,6 +191,7 @@ export interface SpawnHandlerOptions { export function defineNode(input: FleetNodeDefinitionInput): FleetNodeDefinition { const name = nonEmpty(input.name, 'node name'); + const repoPaths = normalizeRepoPaths(input.repoPaths); const capabilityEntries = Object.entries(input.capabilities ?? {}); if (capabilityEntries.length === 0) { throw new Error('defineNode requires at least one capability'); @@ -217,6 +232,7 @@ export function defineNode(input: FleetNodeDefinitionInput): FleetNodeDefinition capabilities, triggers: [...triggers], ...(input.tags ? { tags: [...input.tags] } : {}), + ...(repoPaths !== undefined ? { repoPaths } : {}), ...(input.version ? { version: input.version } : {}), }; } @@ -350,13 +366,41 @@ export function onMessage(input: OnMessageTriggerInput, actionName: string): Fle } export function nodeInfo(definition: FleetNodeDefinition): FleetNodeInfo { + const repoKeys = nodeRepoKeys(definition); return { name: definition.name, ...(definition.maxAgents !== undefined ? { maxAgents: definition.maxAgents } : {}), capabilities: Object.keys(definition.capabilities), + ...(repoKeys.length > 0 ? { repoKeys } : {}), }; } +/** Return deterministic, placement-safe repository keys without exposing paths. */ +export function nodeRepoKeys(definition: Pick): string[] { + return Object.keys(definition.repoPaths ?? {}).sort(); +} + +/** + * Build the public registration tags for a node definition. + * + * When `repoPaths` is present it is authoritative: legacy manually supplied + * `repo:*` tags are replaced by tags derived from the map's keys. Definitions + * without `repoPaths` retain their tags unchanged for backward compatibility. + */ +export function nodeRegistrationTags( + definition: Pick +): string[] | undefined { + if (definition.repoPaths === undefined) { + return definition.tags ? [...definition.tags] : undefined; + } + const tags = (definition.tags ?? []).filter((tag) => !tag.startsWith('repo:')); + const seen = new Set(tags); + for (const key of nodeRepoKeys(definition)) { + seen.add(`repo:${key}`); + } + return [...seen]; +} + export async function invokeNodeHandler( definition: FleetNodeDefinition, name: string, @@ -387,6 +431,7 @@ export function defineDefaultLocalNode(input: { name: string; maxAgents?: number; teams?: { agents?: Array<{ cli?: string }> } | null; + repoPaths?: FleetRepoPaths; }): FleetNodeDefinition { const harnesses = new Map([ ['claude', claude], @@ -408,6 +453,7 @@ export function defineDefaultLocalNode(input: { name: input.name, ...(input.maxAgents !== undefined ? { maxAgents: input.maxAgents } : {}), capabilities, + ...(input.repoPaths !== undefined ? { repoPaths: input.repoPaths } : {}), }); } @@ -460,6 +506,39 @@ function nonEmpty(value: string | undefined, label: string): string { return trimmed; } +function normalizeRepoPaths(repoPaths: FleetRepoPaths | undefined): FleetRepoPaths | undefined { + if (repoPaths === undefined) { + return undefined; + } + if (!repoPaths || typeof repoPaths !== 'object' || Array.isArray(repoPaths)) { + throw new Error('repoPaths must be an object keyed by owner/repo'); + } + + const normalized: Record = {}; + for (const [rawKey, rawPath] of Object.entries(repoPaths)) { + const key = rawKey.trim(); + if (!isPlacementRepoKey(key)) { + throw new Error(`repoPaths key "${rawKey}" must use owner/repo format`); + } + if (Object.prototype.hasOwnProperty.call(normalized, key)) { + throw new Error(`repoPaths contains duplicate key "${key}" after trimming`); + } + if (typeof rawPath !== 'string' || !isAbsolute(rawPath)) { + throw new Error(`repoPaths["${key}"] must be an absolute path`); + } + normalized[key] = rawPath; + } + return Object.freeze(normalized); +} + +function isPlacementRepoKey(key: string): boolean { + const segments = key.split('/'); + return ( + segments.length === 2 && + segments.every((segment) => segment !== '.' && segment !== '..' && /^[A-Za-z0-9._-]+$/.test(segment)) + ); +} + function parseWithSchema(schema: ZodLikeSchema, input: unknown): T { const parsed = schema.safeParse(input); if (parsed.success) { diff --git a/packages/fleet/src/serve-node.test.ts b/packages/fleet/src/serve-node.test.ts index 432c6cefd..f20f9e513 100644 --- a/packages/fleet/src/serve-node.test.ts +++ b/packages/fleet/src/serve-node.test.ts @@ -1,3 +1,5 @@ +import { resolve } from 'node:path'; + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; @@ -120,6 +122,35 @@ describe('serveNode', () => { await running.stop(); }); + it('serializes only placement-safe repo keys and never node-local paths', async () => { + const factoryPath = resolve('private-checkouts', 'factory'); + const relayPath = resolve('private-checkouts', 'relay'); + const node = defineNode({ + name: 'repo-builder', + capabilities: { ping: async () => 'pong' }, + tags: ['arm64', 'repo:stale/manual-tag'], + repoPaths: { + 'AgentWorkforce/relay': relayPath, + 'AgentWorkforce/factory': factoryPath, + }, + }); + const running = startServeNode({ definition: node, connection, reconnect: false }); + + const sock = socket(); + sock.open(); + const register = sock.lastRegister(); + expect(register.tags).toEqual(['arm64', 'repo:AgentWorkforce/factory', 'repo:AgentWorkforce/relay']); + expect(register).not.toHaveProperty('repoPaths'); + expect(register).not.toHaveProperty('repo_paths'); + const serialized = JSON.stringify(register); + expect(serialized).not.toContain(factoryPath); + expect(serialized).not.toContain(relayPath); + + sock.emit(acceptAll(register)); + await flush(); + await running.stop(); + }); + it('runs a handler and replies action.result with its output', async () => { const node = defineNode({ name: 'p', diff --git a/packages/fleet/src/serve-node.ts b/packages/fleet/src/serve-node.ts index 5ad607c4f..9f6721c32 100644 --- a/packages/fleet/src/serve-node.ts +++ b/packages/fleet/src/serve-node.ts @@ -3,6 +3,7 @@ import { NodeProviderClient, type NodeCapabilityHandler, type NodeHandlerContext import { invokeNodeHandler, nodeInfo, + nodeRegistrationTags, triggerSyncInputs, type FleetActionContext, type FleetNodeDefinition, @@ -183,6 +184,7 @@ export async function serveNode(options: ServeNodeOptions): Promise { const maxAgents = options.maxAgentsOverride ?? options.definition.maxAgents; const reconnect = options.reconnect ?? true; const logger = resolveLogger(options); + const registrationTags = nodeRegistrationTags(options.definition); const client = new NodeProviderClient({ ...(options.connection.baseUrl ? { baseUrl: options.connection.baseUrl } : {}), @@ -191,7 +193,7 @@ export async function serveNode(options: ServeNodeOptions): Promise { nodeName, provider: { name: providerName }, ...(maxAgents !== undefined ? { maxAgents } : {}), - ...(options.definition.tags ? { tags: [...options.definition.tags] } : {}), + ...(registrationTags !== undefined ? { tags: registrationTags } : {}), ...(options.definition.version ? { version: options.definition.version } : {}), // A drop during shutdown is expected; only surface a real error otherwise. ...(reconnect ? {} : { maxReconnectAttempts: 0 }), diff --git a/packages/harness-driver/src/protocol.ts b/packages/harness-driver/src/protocol.ts index f9a6e510b..eca187546 100644 --- a/packages/harness-driver/src/protocol.ts +++ b/packages/harness-driver/src/protocol.ts @@ -204,6 +204,8 @@ export interface NodeManifest { capabilities: NodeCapabilityManifest[]; max_agents?: number; tags?: string[]; + /** Placement-safe repository keys. Absolute checkout paths never enter this manifest. */ + repo_keys?: string[]; version?: string; } From e9f8721fb74e65fb63aa99ba029ae6cca7996df8 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Wed, 19 Aug 2026 09:31:06 +0200 Subject: [PATCH 05/13] feat(fleet): add privacy-safe repository registration Session-Id: 01a018d0-e371-75d1-8aea-1f2a8df7dfbc Session-Id: 01a018d2-29c4-7372-b4b7-fcddef41ab2a --- .../2026-08/traj_8xylo1e6uc4z/summary.md | 32 ++++++++ .../2026-08/traj_8xylo1e6uc4z/trajectory.json | 73 +++++++++++++++++ CHANGELOG.md | 8 +- crates/broker/src/fleet_wire.rs | 37 +++++++++ crates/broker/src/node_control.rs | 4 + crates/broker/src/protocol.rs | 3 + crates/broker/src/runtime/init.rs | 1 + .../fixtures/fleet-wire/node.register.json | 1 + .../src/cli/lib/node-provider-child.test.ts | 35 ++++++++ .../cli/src/cli/lib/node-provider-child.ts | 30 +++++++ packages/fleet/README.md | 21 +++++ packages/fleet/src/index.test.ts | 71 +++++++++++++++++ packages/fleet/src/index.ts | 79 +++++++++++++++++++ packages/fleet/src/serve-node.test.ts | 31 ++++++++ packages/fleet/src/serve-node.ts | 4 +- packages/harness-driver/src/protocol.ts | 2 + 16 files changed, 429 insertions(+), 3 deletions(-) create mode 100644 .agentworkforce/trajectories/completed/2026-08/traj_8xylo1e6uc4z/summary.md create mode 100644 .agentworkforce/trajectories/completed/2026-08/traj_8xylo1e6uc4z/trajectory.json diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_8xylo1e6uc4z/summary.md b/.agentworkforce/trajectories/completed/2026-08/traj_8xylo1e6uc4z/summary.md new file mode 100644 index 000000000..0e89fea54 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_8xylo1e6uc4z/summary.md @@ -0,0 +1,32 @@ +# Trajectory: Add privacy-safe repository registration contract + +> **Status:** ✅ Completed +> **Confidence:** 95% +> **Started:** August 19, 2026 at 09:04 AM +> **Completed:** August 19, 2026 at 09:30 AM + +--- + +## Summary + +Added validated node-local repoPaths, privacy-safe registration keys/tags, TypeScript and Rust repo_keys wire plumbing, compiled-child local descriptor handoff, compatibility tests, and documentation. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Used repo: tags for JS provider compatibility while adding explicit repo_keys to the TypeScript/Rust broker wire mirror +- **Chose:** Used repo: tags for JS provider compatibility while adding explicit repo_keys to the TypeScript/Rust broker wire mirror +- **Reasoning:** The relay repository consumes @relaycast/sdk 8.0.0, whose strict node.register shape cannot emit a new repo_keys option yet. Existing tags are already accepted and placement reads them, so deriving authoritative tags from the node-local repoPaths map keeps this PR independently landable. The Rust field remains empty until the node-resolution lane supplies keys from its validated local map. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Used repo: tags for JS provider compatibility while adding explicit repo_keys to the TypeScript/Rust broker wire mirror: Used repo: tags for JS provider compatibility while adding explicit repo_keys to the TypeScript/Rust broker wire mirror +- Wire contract is complete and green; security review caught that owner/repo shape must reject dot path segments, so both fleet config and compiled-child IPC now enforce exactly two allowlisted non-dot segments and absolute values. diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_8xylo1e6uc4z/trajectory.json b/.agentworkforce/trajectories/completed/2026-08/traj_8xylo1e6uc4z/trajectory.json new file mode 100644 index 000000000..8f69e5a01 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_8xylo1e6uc4z/trajectory.json @@ -0,0 +1,73 @@ +{ + "id": "traj_8xylo1e6uc4z", + "version": 1, + "task": { + "title": "Add privacy-safe repository registration contract" + }, + "status": "completed", + "startedAt": "2026-08-19T07:04:08.402Z", + "completedAt": "2026-08-19T07:30:42.191Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-08-19T07:14:40.829Z" + } + ], + "chapters": [ + { + "id": "chap_ibagoj6gc7rf", + "title": "Work", + "agentName": "default", + "startedAt": "2026-08-19T07:14:40.829Z", + "endedAt": "2026-08-19T07:30:42.191Z", + "events": [ + { + "ts": 1787123680829, + "type": "decision", + "content": "Used repo: tags for JS provider compatibility while adding explicit repo_keys to the TypeScript/Rust broker wire mirror: Used repo: tags for JS provider compatibility while adding explicit repo_keys to the TypeScript/Rust broker wire mirror", + "raw": { + "question": "Used repo: tags for JS provider compatibility while adding explicit repo_keys to the TypeScript/Rust broker wire mirror", + "chosen": "Used repo: tags for JS provider compatibility while adding explicit repo_keys to the TypeScript/Rust broker wire mirror", + "alternatives": [], + "reasoning": "The relay repository consumes @relaycast/sdk 8.0.0, whose strict node.register shape cannot emit a new repo_keys option yet. Existing tags are already accepted and placement reads them, so deriving authoritative tags from the node-local repoPaths map keeps this PR independently landable. The Rust field remains empty until the node-resolution lane supplies keys from its validated local map." + }, + "significance": "high" + }, + { + "ts": 1787124151246, + "type": "reflection", + "content": "Wire contract is complete and green; security review caught that owner/repo shape must reject dot path segments, so both fleet config and compiled-child IPC now enforce exactly two allowlisted non-dot segments and absolute values.", + "raw": { + "focalPoints": [ + "privacy", + "validation", + "compatibility" + ], + "confidence": 0.95 + }, + "significance": "high", + "tags": [ + "focal:privacy", + "focal:validation", + "focal:compatibility", + "confidence:0.95" + ] + } + ] + } + ], + "retrospective": { + "summary": "Added validated node-local repoPaths, privacy-safe registration keys/tags, TypeScript and Rust repo_keys wire plumbing, compiled-child local descriptor handoff, compatibility tests, and documentation.", + "approach": "Standard approach", + "confidence": 0.95 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relay", + "tags": [], + "_trace": { + "startRef": "286467e936bb3efbd415ed233d416e59cd390c05", + "endRef": "286467e936bb3efbd415ed233d416e59cd390c05" + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c073f8b1c..53cc17e5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,15 @@ 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 - Patch] +## [Unreleased - Minor] + +### Added + +- Fleet node definitions can declare node-local `repoPaths`; registration advertises only placement-safe repository keys and keeps absolute checkout paths private to the node. ### Fixed -- Fleet repository assignments now resolve their checkout from the receiving node's local `AGENT_RELAY_NODE_REPO_PATHS` map immediately before spawn, refusing missing or invalid local paths instead of accepting dispatcher-provided working directories. +- Fleet repository assignments now resolve their checkout from the receiving node's local `repoPaths` map immediately before spawn, refusing missing or invalid local paths instead of accepting dispatcher-provided working directories. ## [11.7.2] - 2026-08-19 diff --git a/crates/broker/src/fleet_wire.rs b/crates/broker/src/fleet_wire.rs index a544e7e86..422fe34c0 100644 --- a/crates/broker/src/fleet_wire.rs +++ b/crates/broker/src/fleet_wire.rs @@ -124,6 +124,9 @@ pub struct NodeRegister { pub capabilities: Vec, pub max_agents: u32, pub tags: Vec, + /// Placement-safe repository keys; absolute node-local paths are forbidden. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub repo_keys: Vec, pub version: String, #[serde( default, @@ -1111,6 +1114,40 @@ mod tests { "resume_cursor": null }) ); + assert_eq!(encoded.get("repo_keys"), None); + } + + #[test] + fn node_register_accepts_public_repo_keys_and_rejects_private_repo_paths() { + let public = json!({ + "type": "node.register", + "v": 1, + "name": "builder-1", + "node_id": "node_1", + "capabilities": [], + "max_agents": 1, + "tags": [], + "repo_keys": ["AgentWorkforce/factory", "AgentWorkforce/relay"], + "version": "relay-broker/test", + "resume_cursor": null + }); + let decoded: BrokerToRelaycast = serde_json::from_value(public.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), public); + + let private = json!({ + "type": "node.register", + "v": 1, + "name": "builder-1", + "node_id": "node_1", + "capabilities": [], + "max_agents": 1, + "tags": [], + "repo_keys": ["AgentWorkforce/factory"], + "repo_paths": {"AgentWorkforce/factory": "/private/node/factory"}, + "version": "relay-broker/test", + "resume_cursor": null + }); + assert!(serde_json::from_value::(private).is_err()); } #[test] diff --git a/crates/broker/src/node_control.rs b/crates/broker/src/node_control.rs index 0d8ebd291..9602e1239 100644 --- a/crates/broker/src/node_control.rs +++ b/crates/broker/src/node_control.rs @@ -1145,6 +1145,7 @@ pub(crate) fn build_node_register( capabilities, max_agents: manifest.max_agents.unwrap_or(0), tags: manifest.tags.clone().unwrap_or_default(), + repo_keys: manifest.repo_keys.clone().unwrap_or_default(), version: manifest .version .as_deref() @@ -3135,6 +3136,7 @@ mod tests { }], max_agents: Some(8), tags: Some(vec!["local".to_string()]), + repo_keys: Some(vec!["AgentWorkforce/relay".to_string()]), version: Some("sidecar/1".to_string()), }; @@ -3143,6 +3145,7 @@ mod tests { assert_eq!(register.name, "builder"); assert_eq!(register.node_id, "node-manifest"); assert_eq!(register.max_agents, 8); + assert_eq!(register.repo_keys, vec!["AgentWorkforce/relay"]); assert_eq!( register.capabilities[0].metadata, Some(BTreeMap::from([( @@ -4085,6 +4088,7 @@ mod tests { }], max_agents: Some(4), tags: Some(vec!["test".to_string()]), + repo_keys: None, version: Some("sidecar/test".to_string()), } } diff --git a/crates/broker/src/protocol.rs b/crates/broker/src/protocol.rs index a74186c7a..1f95ac4cb 100644 --- a/crates/broker/src/protocol.rs +++ b/crates/broker/src/protocol.rs @@ -287,6 +287,9 @@ pub struct NodeManifest { pub max_agents: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tags: Option>, + /// Placement-safe repository keys. Absolute checkout paths remain node-local. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_keys: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub version: Option, } diff --git a/crates/broker/src/runtime/init.rs b/crates/broker/src/runtime/init.rs index 004790ef9..97ced2812 100644 --- a/crates/broker/src/runtime/init.rs +++ b/crates/broker/src/runtime/init.rs @@ -885,6 +885,7 @@ fn bootstrap_node_manifest(node_name: &str, node_id: &str, broker_version: &str) capabilities, max_agents: node_max_agents(), tags: None, + repo_keys: None, version: Some(broker_version.to_string()), } } diff --git a/crates/broker/tests/fixtures/fleet-wire/node.register.json b/crates/broker/tests/fixtures/fleet-wire/node.register.json index 85b7014a3..fc5a40682 100644 --- a/crates/broker/tests/fixtures/fleet-wire/node.register.json +++ b/crates/broker/tests/fixtures/fleet-wire/node.register.json @@ -25,6 +25,7 @@ ], "max_agents": 8, "tags": ["darwin", "arm64", "ssd"], + "repo_keys": ["AgentWorkforce/factory", "AgentWorkforce/relay"], "version": "relay-broker/0.7.0", "resume_cursor": null } diff --git a/packages/cli/src/cli/lib/node-provider-child.test.ts b/packages/cli/src/cli/lib/node-provider-child.test.ts index ea74e3087..6592f2c1f 100644 --- a/packages/cli/src/cli/lib/node-provider-child.test.ts +++ b/packages/cli/src/cli/lib/node-provider-child.test.ts @@ -53,6 +53,41 @@ describe('parseNodeDescriptor', () => { expect(parseNodeDescriptor(stdout)).toEqual({ name: 'n', capabilities: ['spawn:claude'] }); }); + it('carries node-private repo paths across the local describe IPC', () => { + const repoPath = path.resolve('private-checkouts', 'relay'); + const stdout = `${MARKER}${JSON.stringify({ + name: 'n', + capabilities: ['spawn:codex'], + repoPaths: { 'AgentWorkforce/relay': repoPath }, + })}\n`; + + expect(parseNodeDescriptor(stdout)).toEqual({ + name: 'n', + capabilities: ['spawn:codex'], + repoPaths: { 'AgentWorkforce/relay': repoPath }, + }); + }); + + it.each(['./repo', '../repo'])('rejects relative repo path %s from local describe IPC', (repoPath) => { + const stdout = `${MARKER}${JSON.stringify({ + name: 'n', + capabilities: [], + repoPaths: { 'AgentWorkforce/relay': repoPath }, + })}\n`; + + expect(() => parseNodeDescriptor(stdout)).toThrow(/absolute paths/); + }); + + it.each(['./repo', '../repo'])('rejects path-shaped repo key %s from local describe IPC', (repoKey) => { + const stdout = `${MARKER}${JSON.stringify({ + name: 'n', + capabilities: [], + repoPaths: { [repoKey]: path.resolve('private-checkouts', 'relay') }, + })}\n`; + + expect(() => parseNodeDescriptor(stdout)).toThrow(/owner\/repo keys/); + }); + it('ignores output the config printed on import', () => { const stdout = [ 'booting my node...', diff --git a/packages/cli/src/cli/lib/node-provider-child.ts b/packages/cli/src/cli/lib/node-provider-child.ts index 08da44129..e05ce6cab 100644 --- a/packages/cli/src/cli/lib/node-provider-child.ts +++ b/packages/cli/src/cli/lib/node-provider-child.ts @@ -115,6 +115,11 @@ if (describeOnly) { name: definition.name, capabilities: Object.keys(definition.capabilities || {}), ...(typeof definition.maxAgents === 'number' ? { maxAgents: definition.maxAgents } : {}), + // Local child -> CLI IPC only. The parent passes these values directly to + // its native broker; serveNode registration independently emits keys only. + ...(definition.repoPaths && typeof definition.repoPaths === 'object' + ? { repoPaths: definition.repoPaths } + : {}), }; console.log('__AGENT_RELAY_NODE_DESCRIPTOR__' + JSON.stringify(descriptor)); return; @@ -256,6 +261,8 @@ export type NodeDefinitionDescriptor = { name: string; capabilities: string[]; maxAgents?: number; + /** Node-private map carried only across local child-to-CLI IPC. */ + repoPaths?: Readonly>; }; /** @@ -273,13 +280,36 @@ export function parseNodeDescriptor(stdout: string): NodeDefinitionDescriptor | return undefined; } const parsed = JSON.parse(last.slice(NODE_DESCRIPTOR_MARKER.length)) as NodeDefinitionDescriptor; + const repoPaths = parseDescriptorRepoPaths(parsed.repoPaths); return { name: parsed.name, capabilities: Array.isArray(parsed.capabilities) ? parsed.capabilities : [], ...(typeof parsed.maxAgents === 'number' ? { maxAgents: parsed.maxAgents } : {}), + ...(repoPaths !== undefined ? { repoPaths } : {}), }; } +function parseDescriptorRepoPaths(value: unknown): Readonly> | undefined { + if (value === undefined) { + return undefined; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Fleet node descriptor repoPaths must be an object keyed by owner/repo'); + } + const repoPaths: Record = {}; + for (const [key, repoPath] of Object.entries(value)) { + const segments = key.split('/'); + const placementSafeKey = + segments.length === 2 && + segments.every((segment) => segment !== '.' && segment !== '..' && /^[A-Za-z0-9._-]+$/.test(segment)); + if (!placementSafeKey || typeof repoPath !== 'string' || !path.isAbsolute(repoPath)) { + throw new Error('Fleet node descriptor repoPaths must map owner/repo keys to absolute paths'); + } + repoPaths[key] = repoPath; + } + return repoPaths; +} + /** * Adapt a descriptor to the capacity shape, so a node definition served * out-of-process still contributes its `spawn:` capabilities to the diff --git a/packages/fleet/README.md b/packages/fleet/README.md index 961b59f61..dbb3148f3 100644 --- a/packages/fleet/README.md +++ b/packages/fleet/README.md @@ -48,6 +48,27 @@ agent-relay fleet nodes # list registered nodes agent-relay fleet status # show node + capability health ``` +### Repository placement + +A node can declare the checkouts it can serve with an `owner/repo` keyed map: + +```ts +export default defineNode({ + name: 'builder', + repoPaths: { + 'AgentWorkforce/factory': '/srv/repos/factory', + 'AgentWorkforce/relay': '/srv/repos/relay', + }, + capabilities: { + /* … */ + }, +}); +``` + +Each value must be an absolute path on that node. Registration derives the +placement-safe `AgentWorkforce/factory` and `AgentWorkforce/relay` keys; the +absolute values are never sent to Relaycast, exposed in the roster, or logged. + ### Serving a node programmatically `@agent-relay/fleet` also ships the node runtime, so you can start a node in diff --git a/packages/fleet/src/index.test.ts b/packages/fleet/src/index.test.ts index e0340edb3..4e8087333 100644 --- a/packages/fleet/src/index.test.ts +++ b/packages/fleet/src/index.test.ts @@ -1,3 +1,5 @@ +import { resolve } from 'node:path'; + import { describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; @@ -6,6 +8,8 @@ import { defineDefaultLocalNode, defineNode, invokeNodeHandler, + nodeInfo, + nodeRegistrationTags, onMessage, spawn, triggerSyncInputs, @@ -45,6 +49,73 @@ describe('@agent-relay/fleet', () => { ).resolves.toEqual({ input: { hello: 'world' } }); }); + it('normalizes node-local repo paths and exposes only deterministic placement keys', () => { + const factoryPath = resolve('node-private', 'factory'); + const relayPath = resolve('node-private', 'relay'); + const configured = { + ' AgentWorkforce/relay ': relayPath, + 'AgentWorkforce/factory': factoryPath, + }; + const node = defineNode({ + name: 'repo-builder', + capabilities: { ping: async () => 'pong' }, + tags: ['arm64', 'repo:legacy/manual-tag'], + repoPaths: configured, + }); + + configured[' AgentWorkforce/relay '] = resolve('different-private-path'); + + expect(node.repoPaths).toEqual({ + 'AgentWorkforce/relay': relayPath, + 'AgentWorkforce/factory': factoryPath, + }); + expect(Object.isFrozen(node.repoPaths)).toBe(true); + expect(nodeInfo(node)).toEqual({ + name: 'repo-builder', + capabilities: ['ping'], + repoKeys: ['AgentWorkforce/factory', 'AgentWorkforce/relay'], + }); + expect(nodeRegistrationTags(node)).toEqual([ + 'arm64', + 'repo:AgentWorkforce/factory', + 'repo:AgentWorkforce/relay', + ]); + expect(JSON.stringify(nodeInfo(node))).not.toContain(factoryPath); + expect(JSON.stringify(nodeInfo(node))).not.toContain(relayPath); + }); + + it('preserves legacy repo tags when repoPaths is not configured', () => { + const node = defineNode({ + name: 'legacy-builder', + capabilities: { ping: async () => 'pong' }, + tags: ['arm64', 'repo:legacy'], + }); + + expect(nodeRegistrationTags(node)).toEqual(['arm64', 'repo:legacy']); + }); + + it('rejects non-placement keys and relative checkout paths', () => { + const absolute = resolve('node-private', 'factory'); + for (const invalidKey of ['/private/node/factory', './repo', '../repo']) { + expect(() => + defineNode({ + name: 'bad-key', + capabilities: { ping: async () => 'pong' }, + repoPaths: { [invalidKey]: absolute }, + }) + ).toThrow(/owner\/repo format/); + } + for (const relativePath of ['relative/factory', './repo', '../repo']) { + expect(() => + defineNode({ + name: 'bad-path', + capabilities: { ping: async () => 'pong' }, + repoPaths: { 'AgentWorkforce/factory': relativePath }, + }) + ).toThrow(/must be an absolute path/); + } + }); + it('builds spawn_agent payloads from a PTY harness', async () => { const node = defineNode({ name: 'builder', diff --git a/packages/fleet/src/index.ts b/packages/fleet/src/index.ts index 5fbc948f1..ea04b8127 100644 --- a/packages/fleet/src/index.ts +++ b/packages/fleet/src/index.ts @@ -1,3 +1,5 @@ +import { isAbsolute } from 'node:path'; + import { z } from 'zod'; import { claude, codex, definePtyHarness, gemini, type PtyHarness } from '@agent-relay/harnesses'; import { resolveStaticHarnessConfig, type StaticPtyHarnessDefinition } from '@agent-relay/harness-driver'; @@ -17,8 +19,13 @@ export interface FleetNodeInfo { name: string; maxAgents?: number; capabilities: string[]; + /** Placement-safe repository keys; local checkout paths are never exposed. */ + repoKeys?: string[]; } +/** Node-local checkout map. Values never leave the node registration runtime. */ +export type FleetRepoPaths = Readonly>; + export interface FleetRelaySendMessageInput { to: string; text: string; @@ -91,6 +98,11 @@ export interface FleetNodeDefinitionInput { capabilities: Record; triggers?: FleetTriggerDescriptor[]; tags?: string[]; + /** + * Node-local absolute checkout paths keyed by `owner/repo`. + * Registration advertises only the keys; path values remain node-private. + */ + repoPaths?: FleetRepoPaths; version?: string; } @@ -101,6 +113,8 @@ export interface FleetNodeDefinition { readonly capabilities: Record; readonly triggers: FleetTriggerDescriptor[]; readonly tags?: string[]; + /** Node-private checkout map; only its keys may be serialized for placement. */ + readonly repoPaths?: FleetRepoPaths; readonly version?: string; } @@ -177,6 +191,7 @@ export interface SpawnHandlerOptions { export function defineNode(input: FleetNodeDefinitionInput): FleetNodeDefinition { const name = nonEmpty(input.name, 'node name'); + const repoPaths = normalizeRepoPaths(input.repoPaths); const capabilityEntries = Object.entries(input.capabilities ?? {}); if (capabilityEntries.length === 0) { throw new Error('defineNode requires at least one capability'); @@ -217,6 +232,7 @@ export function defineNode(input: FleetNodeDefinitionInput): FleetNodeDefinition capabilities, triggers: [...triggers], ...(input.tags ? { tags: [...input.tags] } : {}), + ...(repoPaths !== undefined ? { repoPaths } : {}), ...(input.version ? { version: input.version } : {}), }; } @@ -350,13 +366,41 @@ export function onMessage(input: OnMessageTriggerInput, actionName: string): Fle } export function nodeInfo(definition: FleetNodeDefinition): FleetNodeInfo { + const repoKeys = nodeRepoKeys(definition); return { name: definition.name, ...(definition.maxAgents !== undefined ? { maxAgents: definition.maxAgents } : {}), capabilities: Object.keys(definition.capabilities), + ...(repoKeys.length > 0 ? { repoKeys } : {}), }; } +/** Return deterministic, placement-safe repository keys without exposing paths. */ +export function nodeRepoKeys(definition: Pick): string[] { + return Object.keys(definition.repoPaths ?? {}).sort(); +} + +/** + * Build the public registration tags for a node definition. + * + * When `repoPaths` is present it is authoritative: legacy manually supplied + * `repo:*` tags are replaced by tags derived from the map's keys. Definitions + * without `repoPaths` retain their tags unchanged for backward compatibility. + */ +export function nodeRegistrationTags( + definition: Pick +): string[] | undefined { + if (definition.repoPaths === undefined) { + return definition.tags ? [...definition.tags] : undefined; + } + const tags = (definition.tags ?? []).filter((tag) => !tag.startsWith('repo:')); + const seen = new Set(tags); + for (const key of nodeRepoKeys(definition)) { + seen.add(`repo:${key}`); + } + return [...seen]; +} + export async function invokeNodeHandler( definition: FleetNodeDefinition, name: string, @@ -387,6 +431,7 @@ export function defineDefaultLocalNode(input: { name: string; maxAgents?: number; teams?: { agents?: Array<{ cli?: string }> } | null; + repoPaths?: FleetRepoPaths; }): FleetNodeDefinition { const harnesses = new Map([ ['claude', claude], @@ -408,6 +453,7 @@ export function defineDefaultLocalNode(input: { name: input.name, ...(input.maxAgents !== undefined ? { maxAgents: input.maxAgents } : {}), capabilities, + ...(input.repoPaths !== undefined ? { repoPaths: input.repoPaths } : {}), }); } @@ -460,6 +506,39 @@ function nonEmpty(value: string | undefined, label: string): string { return trimmed; } +function normalizeRepoPaths(repoPaths: FleetRepoPaths | undefined): FleetRepoPaths | undefined { + if (repoPaths === undefined) { + return undefined; + } + if (!repoPaths || typeof repoPaths !== 'object' || Array.isArray(repoPaths)) { + throw new Error('repoPaths must be an object keyed by owner/repo'); + } + + const normalized: Record = {}; + for (const [rawKey, rawPath] of Object.entries(repoPaths)) { + const key = rawKey.trim(); + if (!isPlacementRepoKey(key)) { + throw new Error(`repoPaths key "${rawKey}" must use owner/repo format`); + } + if (Object.prototype.hasOwnProperty.call(normalized, key)) { + throw new Error(`repoPaths contains duplicate key "${key}" after trimming`); + } + if (typeof rawPath !== 'string' || !isAbsolute(rawPath)) { + throw new Error(`repoPaths["${key}"] must be an absolute path`); + } + normalized[key] = rawPath; + } + return Object.freeze(normalized); +} + +function isPlacementRepoKey(key: string): boolean { + const segments = key.split('/'); + return ( + segments.length === 2 && + segments.every((segment) => segment !== '.' && segment !== '..' && /^[A-Za-z0-9._-]+$/.test(segment)) + ); +} + function parseWithSchema(schema: ZodLikeSchema, input: unknown): T { const parsed = schema.safeParse(input); if (parsed.success) { diff --git a/packages/fleet/src/serve-node.test.ts b/packages/fleet/src/serve-node.test.ts index 432c6cefd..f20f9e513 100644 --- a/packages/fleet/src/serve-node.test.ts +++ b/packages/fleet/src/serve-node.test.ts @@ -1,3 +1,5 @@ +import { resolve } from 'node:path'; + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; @@ -120,6 +122,35 @@ describe('serveNode', () => { await running.stop(); }); + it('serializes only placement-safe repo keys and never node-local paths', async () => { + const factoryPath = resolve('private-checkouts', 'factory'); + const relayPath = resolve('private-checkouts', 'relay'); + const node = defineNode({ + name: 'repo-builder', + capabilities: { ping: async () => 'pong' }, + tags: ['arm64', 'repo:stale/manual-tag'], + repoPaths: { + 'AgentWorkforce/relay': relayPath, + 'AgentWorkforce/factory': factoryPath, + }, + }); + const running = startServeNode({ definition: node, connection, reconnect: false }); + + const sock = socket(); + sock.open(); + const register = sock.lastRegister(); + expect(register.tags).toEqual(['arm64', 'repo:AgentWorkforce/factory', 'repo:AgentWorkforce/relay']); + expect(register).not.toHaveProperty('repoPaths'); + expect(register).not.toHaveProperty('repo_paths'); + const serialized = JSON.stringify(register); + expect(serialized).not.toContain(factoryPath); + expect(serialized).not.toContain(relayPath); + + sock.emit(acceptAll(register)); + await flush(); + await running.stop(); + }); + it('runs a handler and replies action.result with its output', async () => { const node = defineNode({ name: 'p', diff --git a/packages/fleet/src/serve-node.ts b/packages/fleet/src/serve-node.ts index 5ad607c4f..9f6721c32 100644 --- a/packages/fleet/src/serve-node.ts +++ b/packages/fleet/src/serve-node.ts @@ -3,6 +3,7 @@ import { NodeProviderClient, type NodeCapabilityHandler, type NodeHandlerContext import { invokeNodeHandler, nodeInfo, + nodeRegistrationTags, triggerSyncInputs, type FleetActionContext, type FleetNodeDefinition, @@ -183,6 +184,7 @@ export async function serveNode(options: ServeNodeOptions): Promise { const maxAgents = options.maxAgentsOverride ?? options.definition.maxAgents; const reconnect = options.reconnect ?? true; const logger = resolveLogger(options); + const registrationTags = nodeRegistrationTags(options.definition); const client = new NodeProviderClient({ ...(options.connection.baseUrl ? { baseUrl: options.connection.baseUrl } : {}), @@ -191,7 +193,7 @@ export async function serveNode(options: ServeNodeOptions): Promise { nodeName, provider: { name: providerName }, ...(maxAgents !== undefined ? { maxAgents } : {}), - ...(options.definition.tags ? { tags: [...options.definition.tags] } : {}), + ...(registrationTags !== undefined ? { tags: registrationTags } : {}), ...(options.definition.version ? { version: options.definition.version } : {}), // A drop during shutdown is expected; only surface a real error otherwise. ...(reconnect ? {} : { maxReconnectAttempts: 0 }), diff --git a/packages/harness-driver/src/protocol.ts b/packages/harness-driver/src/protocol.ts index f9a6e510b..eca187546 100644 --- a/packages/harness-driver/src/protocol.ts +++ b/packages/harness-driver/src/protocol.ts @@ -204,6 +204,8 @@ export interface NodeManifest { capabilities: NodeCapabilityManifest[]; max_agents?: number; tags?: string[]; + /** Placement-safe repository keys. Absolute checkout paths never enter this manifest. */ + repo_keys?: string[]; version?: string; } From eea1d8cf0a47e0550f0506ae01bb13c70a57d3df Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Wed, 19 Aug 2026 09:38:25 +0200 Subject: [PATCH 06/13] fix(broker): complete node-local repo placement Session-Id: 01a018d2-29c4-7372-b4b7-fcddef41ab2a --- crates/broker/src/runtime/init.rs | 46 +++- crates/broker/src/runtime/relaycast_events.rs | 223 ++++++++++++++++-- .../cli/src/cli/lib/broker-lifecycle.test.ts | 100 +++++++- packages/cli/src/cli/lib/broker-lifecycle.ts | 87 +++++-- 4 files changed, 410 insertions(+), 46 deletions(-) diff --git a/crates/broker/src/runtime/init.rs b/crates/broker/src/runtime/init.rs index 97ced2812..1465a98c9 100644 --- a/crates/broker/src/runtime/init.rs +++ b/crates/broker/src/runtime/init.rs @@ -277,7 +277,9 @@ pub(crate) async fn run_init(cmd: InitCommand, telemetry: TelemetryClient) -> Re // configuration, so keep them in the broker and resolve the key immediately // before every spawn rather than trusting a dispatcher-supplied cwd. let node_repo_paths = super::relaycast_events::load_node_repo_paths_from_env()?; - let node_manifest = bootstrap_node_manifest(&node_name, &node_id, &broker_version); + let node_repo_keys = super::relaycast_events::node_repo_keys(&node_repo_paths); + let node_manifest = + bootstrap_node_manifest(&node_name, &node_id, &broker_version, node_repo_keys); // Retain the node name for the runtime: the HTTP `bind_agent_to_node` // fallback (used when node-control `agent.register` is unavailable) binds // spawned agents to this node so they become `via_node` and node delivery @@ -865,7 +867,12 @@ const DEFAULT_NODE_HARNESSES: &[&str] = &["claude", "codex", "gemini", "opencode /// placement for the whole workspace. The harness set comes from the /// `AGENT_RELAY_NODE_HARNESSES` CSV (the CLI sets it from the project's /// teams.json / node definition), falling back to a built-in default. -fn bootstrap_node_manifest(node_name: &str, node_id: &str, broker_version: &str) -> NodeManifest { +fn bootstrap_node_manifest( + node_name: &str, + node_id: &str, + broker_version: &str, + repo_keys: Vec, +) -> NodeManifest { let mut capabilities: Vec = node_capacity_harnesses() .into_iter() .map(|harness| crate::protocol::NodeCapabilityManifest { @@ -885,7 +892,7 @@ fn bootstrap_node_manifest(node_name: &str, node_id: &str, broker_version: &str) capabilities, max_agents: node_max_agents(), tags: None, - repo_keys: None, + repo_keys: (!repo_keys.is_empty()).then_some(repo_keys), version: Some(broker_version.to_string()), } } @@ -1011,7 +1018,8 @@ mod tests { // all `kind: "capacity"`. It must never advertise a bare `"spawn"`, which // the engine would materialize as a generic action pinned to this node, // hijacking capability-based spawn placement for the whole workspace. - let manifest = bootstrap_node_manifest("node-a", "node_a", "relay-broker/9.1.1"); + let manifest = + bootstrap_node_manifest("node-a", "node_a", "relay-broker/9.1.1", Vec::new()); assert!( !manifest.capabilities.is_empty(), "broker manifest must advertise its capacity" @@ -1098,6 +1106,36 @@ mod tests { ); } + #[test] + fn bootstrap_node_manifest_advertises_only_keys_from_the_runtime_map() { + let private_path = "/node-private/checkouts/factory"; + let repo_paths = BTreeMap::from([( + "AgentWorkforce/factory".to_string(), + PathBuf::from(private_path), + )]); + let manifest = bootstrap_node_manifest( + "node-a", + "node_a", + "relay-broker/9.1.1", + super::relaycast_events::node_repo_keys(&repo_paths), + ); + let register = crate::node_control::build_node_register( + &manifest, + "node-default", + "host-default", + "relay-broker/default", + None, + ); + let wire = serde_json::to_string(&crate::fleet_wire::BrokerToRelaycast::NodeRegister( + register, + )) + .expect("serialize node.register"); + + assert!(wire.contains("AgentWorkforce/factory"), "{wire}"); + assert!(!wire.contains(private_path), "{wire}"); + assert!(!wire.contains("repo_paths"), "{wire}"); + } + #[test] fn callback_host_brackets_ipv6_literals() { assert_eq!( diff --git a/crates/broker/src/runtime/relaycast_events.rs b/crates/broker/src/runtime/relaycast_events.rs index 51c14298b..f661561e5 100644 --- a/crates/broker/src/runtime/relaycast_events.rs +++ b/crates/broker/src/runtime/relaycast_events.rs @@ -5,28 +5,81 @@ use super::*; /// may be advertised to the control plane. const NODE_REPO_PATHS_ENV: &str = "AGENT_RELAY_NODE_REPO_PATHS"; +struct UniqueRepoPaths(BTreeMap); + +impl<'de> serde::Deserialize<'de> for UniqueRepoPaths { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct UniqueRepoPathsVisitor; + + impl<'de> serde::de::Visitor<'de> for UniqueRepoPathsVisitor { + type Value = UniqueRepoPaths; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a JSON object of repository keys to paths") + } + + fn visit_map(self, mut entries: A) -> std::result::Result + where + A: serde::de::MapAccess<'de>, + { + let mut paths = BTreeMap::new(); + while let Some((repo, path)) = entries.next_entry::()? { + if paths.insert(repo, path).is_some() { + return Err(serde::de::Error::custom( + "repo_paths contains a duplicate repository key", + )); + } + } + Ok(UniqueRepoPaths(paths)) + } + } + + deserializer.deserialize_map(UniqueRepoPathsVisitor) + } +} + /// Load this node's repository checkout map without touching the filesystem. /// /// Existence and directory checks happen at spawn time because a checkout can /// disappear after the broker has registered the key. Requiring absolute paths /// here ensures a map entry can never fall back to the broker process cwd. pub(super) fn load_node_repo_paths_from_env() -> Result> { - let Some(raw) = std::env::var(NODE_REPO_PATHS_ENV) - .ok() - .map(|value| value.trim().to_string()) + let raw = std::env::var_os(NODE_REPO_PATHS_ENV); + // The map is broker-private runtime state. Clear the startup bridge before + // any workers launch so absolute checkout paths cannot be inherited. + std::env::remove_var(NODE_REPO_PATHS_ENV); + let raw = raw + .map(|value| { + value.into_string().map_err(|_| { + anyhow::anyhow!("{NODE_REPO_PATHS_ENV} must contain valid Unicode JSON") + }) + }) + .transpose()?; + let Some(raw) = raw + .as_deref() + .map(str::trim) .filter(|value| !value.is_empty()) else { return Ok(BTreeMap::new()); }; - let configured: BTreeMap = serde_json::from_str(&raw).with_context(|| { - format!("{NODE_REPO_PATHS_ENV} must be a JSON object of repo keys to paths") + parse_node_repo_paths(raw) +} + +fn parse_node_repo_paths(raw: &str) -> Result> { + let UniqueRepoPaths(configured) = serde_json::from_str(raw).map_err(|error| { + anyhow::anyhow!( + "{NODE_REPO_PATHS_ENV} must be a JSON object of repo keys to paths: {error}" + ) })?; let mut repo_paths = BTreeMap::new(); for (raw_repo, raw_path) in configured { let repo = raw_repo.trim(); - if repo.is_empty() { - anyhow::bail!("{NODE_REPO_PATHS_ENV} contains an empty repository key"); + if !is_repo_key(repo) { + anyhow::bail!("{NODE_REPO_PATHS_ENV} keys must use owner/repo format"); } let path = PathBuf::from(raw_path.trim()); if path.as_os_str().is_empty() { @@ -46,6 +99,26 @@ pub(super) fn load_node_repo_paths_from_env() -> Result) -> Vec { + repo_paths.keys().cloned().collect() +} + +fn is_repo_key(value: &str) -> bool { + let Some((owner, repo)) = value.split_once('/') else { + return false; + }; + fn valid_segment(segment: &str) -> bool { + !segment.is_empty() + && segment != "." + && segment != ".." + && segment.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') + }) + } + + valid_segment(owner) && valid_segment(repo) && !repo.contains('/') +} + impl BrokerRuntime { /// Drain a workspace-firehose event for the broker runtime. /// @@ -156,17 +229,41 @@ pub(super) fn relaycast_spawn_worker_cwd(ws_value: &Value) -> Result Result> { - let Some(assignment) = ws_value.get("assignment") else { - return Ok(None); - }; - let repo = assignment - .get("repo") - .and_then(Value::as_str) - .map(str::trim) - .filter(|repo| !repo.is_empty()) - .context("assignment.repo must be a non-empty string")?; - Ok(Some(repo)) +fn relaycast_assignment_repo(ws_value: &Value) -> Result> { + const REPO_POINTERS: &[&str] = &[ + "/repo", + "/assignment/repo", + "/metadata/repo", + "/metadata/assignment/repo", + "/agent/repo", + "/agent/metadata/repo", + "/agent/metadata/assignment/repo", + ]; + + let mut resolved: Option = None; + for pointer in REPO_POINTERS { + let Some(value) = ws_value.pointer(pointer) else { + continue; + }; + let repo = value + .as_str() + .map(str::trim) + .filter(|repo| !repo.is_empty()) + .context("repo assignment must be a non-empty string")?; + anyhow::ensure!( + is_repo_key(repo), + "repo assignment must use owner/repo format" + ); + if let Some(existing) = resolved.as_deref() { + anyhow::ensure!( + existing == repo, + "spawn input contains conflicting repo assignments" + ); + } else { + resolved = Some(repo.to_string()); + } + } + Ok(resolved) } /// Resolve a spawn working directory on this node. @@ -184,7 +281,7 @@ pub(super) fn relaycast_spawn_worker_cwd_for_node( }; let path = node_repo_paths - .get(repo) + .get(&repo) .with_context(|| format!("no local checkout configured for assignment.repo '{repo}'"))?; if !path.is_absolute() { anyhow::bail!("local checkout configured for assignment.repo '{repo}' must be absolute"); @@ -976,6 +1073,20 @@ mod tests { use crate::terminal_control::TerminalToCloud; use ::relaycast::WsEvent; + static NODE_REPO_PATHS_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + struct RepoPathsEnvRestore(Option); + + impl Drop for RepoPathsEnvRestore { + fn drop(&mut self) { + if let Some(value) = self.0.take() { + std::env::set_var(NODE_REPO_PATHS_ENV, value); + } else { + std::env::remove_var(NODE_REPO_PATHS_ENV); + } + } + } + #[cfg(unix)] #[tokio::test] async fn released_view_target_emits_close_while_healthy_idle_view_stays_open() { @@ -1360,6 +1471,47 @@ mod tests { assert!(error.contains("must be an absolute path"), "{error}"); } + #[test] + fn node_repo_paths_env_is_consumed_before_workers_can_inherit_it() { + let _lock = NODE_REPO_PATHS_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _restore = RepoPathsEnvRestore(std::env::var_os(NODE_REPO_PATHS_ENV)); + std::env::set_var( + NODE_REPO_PATHS_ENV, + r#"{"AgentWorkforce/factory":"/srv/factory"}"#, + ); + + let paths = load_node_repo_paths_from_env().expect("valid node-local map"); + + assert_eq!( + paths.get("AgentWorkforce/factory"), + Some(&PathBuf::from("/srv/factory")) + ); + assert_eq!(std::env::var_os(NODE_REPO_PATHS_ENV), None); + } + + #[test] + fn node_repo_paths_parser_rejects_duplicate_json_keys() { + let error = parse_node_repo_paths( + r#"{"AgentWorkforce/factory":"/srv/first","AgentWorkforce/factory":"/srv/second"}"#, + ) + .expect_err("repeated JSON keys must not use last-value-wins behavior") + .to_string(); + + assert!(error.contains("duplicate repository key"), "{error}"); + } + + #[test] + fn node_repo_paths_parser_rejects_path_shaped_keys_without_echoing_them() { + let error = parse_node_repo_paths(r#"{"/node-private/checkouts/factory":"/srv/factory"}"#) + .expect_err("private paths cannot masquerade as registration keys") + .to_string(); + + assert!(error.contains("owner/repo format"), "{error}"); + assert!(!error.contains("node-private"), "{error}"); + } + fn node_repo_paths(repo: &str, path: &Path) -> BTreeMap { BTreeMap::from([(repo.to_string(), path.to_path_buf())]) } @@ -1378,6 +1530,39 @@ mod tests { assert_eq!(cwd.as_deref(), checkout.path().to_str()); } + #[test] + fn top_level_placement_repo_resolves_to_the_node_local_checkout() { + let checkout = tempfile::tempdir().expect("local checkout fixture"); + let paths = node_repo_paths("AgentWorkforce/factory", checkout.path()); + + let cwd = relaycast_spawn_worker_cwd_for_node( + &json!({ "repo": "AgentWorkforce/factory" }), + &paths, + ) + .expect("SDK top-level placement repo should resolve"); + + assert_eq!(cwd.as_deref(), checkout.path().to_str()); + } + + #[test] + fn conflicting_repo_shapes_fail_closed_before_cwd_fallback() { + let cwd = tempfile::tempdir().expect("cwd fixture"); + let paths = node_repo_paths("AgentWorkforce/factory", cwd.path()); + let error = relaycast_spawn_worker_cwd_for_node( + &json!({ + "repo": "AgentWorkforce/factory", + "assignment": {"repo": "AgentWorkforce/relay"}, + "worker_cwd": cwd.path(), + }), + &paths, + ) + .expect_err("conflicting repository assignments must not fall back") + .to_string(); + + assert!(error.contains("conflicting repo assignments"), "{error}"); + assert!(!error.contains(&cwd.path().display().to_string())); + } + #[test] fn assignment_repo_rejects_a_missing_local_key() { let checkout = tempfile::tempdir().expect("local checkout fixture"); diff --git a/packages/cli/src/cli/lib/broker-lifecycle.test.ts b/packages/cli/src/cli/lib/broker-lifecycle.test.ts index a83a1e466..60d075194 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.test.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.test.ts @@ -300,7 +300,7 @@ vi.mock('@agent-relay/harness-driver', () => ({ import fsReal from 'node:fs'; import os from 'node:os'; import pathReal from 'node:path'; -import { startServeNode } from '@agent-relay/fleet'; +import { startServeNode, type FleetNodeDefinition } from '@agent-relay/fleet'; import { setWorkspaceKey } from '@agent-relay/cloud'; import { runUpCommand } from './broker-lifecycle.js'; import { startReflexCapture } from './reflex-capture.js'; @@ -520,6 +520,53 @@ describe('runUpCommand node-config gating', () => { expect(served.providerName).toBe('from-config'); }); + it('feeds one private repoPaths map to the broker while the provider retains the same source', async () => { + const { deps, projectRoot, createRelay, log, warn, error } = createUpHarness(); + const privateCheckout = '/node-private/checkouts/factory'; + fsReal.writeFileSync( + pathReal.join(projectRoot, 'agent-relay.mjs'), + `export default { + __agentRelayFleetNode: true, + name: 'from-config', + capabilities: {}, + triggers: [], + repoPaths: { 'AgentWorkforce/factory': ${JSON.stringify(privateCheckout)} } + };\n` + ); + let brokerRepoPaths: string | undefined; + createRelay.mockImplementationOnce(async () => { + brokerRepoPaths = deps.env.AGENT_RELAY_NODE_REPO_PATHS; + return { + spawn: vi.fn(async () => undefined), + getStatus: vi.fn(async () => ({})), + shutdown: vi.fn(async () => undefined), + workspaceKey: 'rk_test', + workspaceId: 'rw_test', + } as unknown as CoreRelay; + }); + + await runUpCommand({ discoverConfig: true, verbose: true }, deps); + + expect(JSON.parse(brokerRepoPaths ?? '{}')).toEqual({ + 'AgentWorkforce/factory': privateCheckout, + }); + expect(deps.env.AGENT_RELAY_NODE_REPO_PATHS).toBeUndefined(); + expect( + ( + vi.mocked(startServeNode).mock.calls[0]![0].definition as FleetNodeDefinition & { + repoPaths?: Readonly>; + } + ).repoPaths + ).toEqual({ + 'AgentWorkforce/factory': privateCheckout, + }); + const output = [log, warn, error] + .flatMap((fn) => vi.mocked(fn).mock.calls.flat()) + .map(String) + .join('\n'); + expect(output).not.toContain(privateCheckout); + }); + it('never prints the node token or workspace key from the session in --verbose output', async () => { const { deps, projectRoot, log, warn, error } = createUpHarness(); fsReal.writeFileSync( @@ -538,6 +585,57 @@ describe('runUpCommand node-config gating', () => { expect(output).not.toMatch(/rk_live_|nt_live_/); }); + it('feeds compiled-child descriptor repoPaths to the broker without leaking them to the provider child', async () => { + const { deps, projectRoot, createRelay } = createUpHarness(); + const config = pathReal.join(projectRoot, 'agent-relay.mjs'); + const privateCheckout = '/node-private/checkouts/factory'; + fsReal.writeFileSync( + config, + "export default { __agentRelayFleetNode: true, name: 'child', capabilities: {}, triggers: [] };\n" + ); + deps.argv = ['bun', '/$bunfs/root/agent-relay', 'node', 'up']; + deps.cliScript = '/$bunfs/root/agent-relay'; + deps.execCommand = vi.fn(async (command: string) => ({ + stdout: command.includes('--describe') + ? `__AGENT_RELAY_NODE_DESCRIPTOR__${JSON.stringify({ + name: 'child', + capabilities: [], + repoPaths: { 'AgentWorkforce/factory': privateCheckout }, + })}\n` + : '', + stderr: '', + })); + let brokerRepoPaths: string | undefined; + createRelay.mockImplementationOnce(async () => { + brokerRepoPaths = deps.env.AGENT_RELAY_NODE_REPO_PATHS; + return { + spawn: vi.fn(async () => undefined), + getStatus: vi.fn(async () => ({})), + shutdown: vi.fn(async () => undefined), + workspaceKey: 'rk_test', + workspaceId: 'rw_test', + } as unknown as CoreRelay; + }); + const child = Object.assign(new EventEmitter(), { + pid: 42, + killed: false, + kill: vi.fn(), + }); + const providerEnvs: NodeJS.ProcessEnv[] = []; + deps.spawnProcess = vi.fn((_command, _args, options) => { + providerEnvs.push(options.env ?? {}); + queueMicrotask(() => child.emit('spawn')); + return child; + }); + + await runUpCommand({ discoverConfig: true }, deps); + + expect(JSON.parse(brokerRepoPaths ?? '{}')).toEqual({ + 'AgentWorkforce/factory': privateCheckout, + }); + expect(providerEnvs[0]?.AGENT_RELAY_NODE_REPO_PATHS).toBeUndefined(); + }); + it('shuts the broker down when its compiled-binary node provider exits', async () => { const { deps, projectRoot, error, exit } = createUpHarness(); const config = pathReal.join(projectRoot, 'agent-relay.mjs'); diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index 4fba55ae8..1ff2caf45 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -77,6 +77,7 @@ const DEFAULT_BROKER_BASE_PORT = 3888; const CONNECTION_FILENAME = 'connection.json'; const BACKGROUND_START_ERROR_FILENAME = 'background-start-error.log'; export const WORKSPACE_BINDING_SOURCE_ENV = 'AGENT_RELAY_WORKSPACE_SOURCE'; +const NODE_REPO_PATHS_ENV = 'AGENT_RELAY_NODE_REPO_PATHS'; const STATUS_POLL_INTERVAL_MS = 500; const DETACHED_START_READY_TIMEOUT_MS = 10_000; const NODE_DELIVERY_READY_TIMEOUT_MS = 10_000; @@ -1540,6 +1541,42 @@ function planCapacitySource( return plan.mode === 'in-process' ? plan.definition : descriptorCapacitySource(plan.descriptor); } +/** Read the node-private repository map from either definition execution mode. */ +function planRepoPaths(plan: NodeDefinitionPlan | undefined): Readonly> | undefined { + if (!plan) { + return undefined; + } + const source = plan.mode === 'in-process' ? plan.definition : plan.descriptor; + return (source as { repoPaths?: Readonly> }).repoPaths; +} + +/** + * Expose one local-only serialized map while the native broker process starts. + * The child captures its environment at spawn; restoring the CLI environment + * immediately afterward prevents the private paths from reaching capability + * provider children or unrelated processes started later. + */ +function applyNodeRepoPathsEnv( + env: NodeJS.ProcessEnv, + repoPaths: Readonly> | undefined +): () => void { + if (repoPaths === undefined) { + return () => undefined; + } + const previous = env[NODE_REPO_PATHS_ENV]; + const sorted = Object.fromEntries( + Object.entries(repoPaths).sort(([left], [right]) => left.localeCompare(right)) + ); + env[NODE_REPO_PATHS_ENV] = JSON.stringify(sorted); + return () => { + if (previous === undefined) { + delete env[NODE_REPO_PATHS_ENV]; + } else { + env[NODE_REPO_PATHS_ENV] = previous; + } + }; +} + /** * Apply the resolved workspace to the environment the broker (and any detached * child) inherits, and report which source won. Returns the pinned project @@ -1896,28 +1933,34 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): vlog(deps, options.verbose, 'Checking for orphaned broker processes...'); await killOrphanedBrokerProcesses(paths.projectRoot, deps); - const started = await startBrokerWithPortFallback( - paths, - basePort, - deps, - options.brokerName, - options.verbose, - // Assign `relay` as soon as the broker child process exists, not only - // once the handshake/status-check retries above also succeed. A - // SIGTERM/SIGINT arriving during that check window otherwise finds - // `relay` still null, so `shutdownOnce()` no-ops and leaks the broker - // child instead of shutting it down. - (candidate) => { - relay = candidate; - } - ).catch((err: unknown) => { - // On failure, `startBrokerWithPortFallback` has already shut down any - // candidate it created before rethrowing. Clear the early handle too - // so the outer catch's `shutdownOnce()` does not call `shutdown()` a - // second time on it. - relay = null; - throw err; - }); + const restoreRepoPathsEnv = applyNodeRepoPathsEnv(deps.env, planRepoPaths(nodePlan)); + let started: Awaited>; + try { + started = await startBrokerWithPortFallback( + paths, + basePort, + deps, + options.brokerName, + options.verbose, + // Assign `relay` as soon as the broker child process exists, not only + // once the handshake/status-check retries above also succeed. A + // SIGTERM/SIGINT arriving during that check window otherwise finds + // `relay` still null, so `shutdownOnce()` no-ops and leaks the broker + // child instead of shutting it down. + (candidate) => { + relay = candidate; + } + ).catch((err: unknown) => { + // On failure, `startBrokerWithPortFallback` has already shut down any + // candidate it created before rethrowing. Clear the early handle too + // so the outer catch's `shutdownOnce()` does not call `shutdown()` a + // second time on it. + relay = null; + throw err; + }); + } finally { + restoreRepoPathsEnv(); + } relay = started.relay; try { From 7517bbb437d309764ab03d8d5b3a18173bc0ce9b Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Wed, 19 Aug 2026 09:43:17 +0200 Subject: [PATCH 07/13] fix(fleet): harden repository registration boundary Session-Id: 01a018cd-2803-7712-8317-bcc82c02f8fa --- crates/broker/src/fleet_wire.rs | 4 +- crates/broker/src/node_control.rs | 66 ++++++++++++++++++- .../src/cli/lib/node-provider-child.test.ts | 26 +++++++- .../cli/src/cli/lib/node-provider-child.ts | 20 +----- packages/fleet/src/index.ts | 8 ++- 5 files changed, 98 insertions(+), 26 deletions(-) diff --git a/crates/broker/src/fleet_wire.rs b/crates/broker/src/fleet_wire.rs index 422fe34c0..decc34c33 100644 --- a/crates/broker/src/fleet_wire.rs +++ b/crates/broker/src/fleet_wire.rs @@ -125,8 +125,8 @@ pub struct NodeRegister { pub max_agents: u32, pub tags: Vec, /// Placement-safe repository keys; absolute node-local paths are forbidden. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub repo_keys: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_keys: Option>, pub version: String, #[serde( default, diff --git a/crates/broker/src/node_control.rs b/crates/broker/src/node_control.rs index 9602e1239..4e27d5e7c 100644 --- a/crates/broker/src/node_control.rs +++ b/crates/broker/src/node_control.rs @@ -1145,7 +1145,19 @@ pub(crate) fn build_node_register( capabilities, max_agents: manifest.max_agents.unwrap_or(0), tags: manifest.tags.clone().unwrap_or_default(), - repo_keys: manifest.repo_keys.clone().unwrap_or_default(), + // Treat the Fleet wire as a privacy boundary: even an unvalidated + // manifest must never serialize a path-shaped or malformed value. + // Preserve Some([]) so an updated node can authoritatively clear stale + // repository advertisements on the control plane. + repo_keys: manifest.repo_keys.as_ref().map(|repo_keys| { + let mut seen = HashSet::new(); + repo_keys + .iter() + .filter(|repo_key| is_placement_repo_key(repo_key)) + .filter(|repo_key| seen.insert((*repo_key).clone())) + .cloned() + .collect() + }), version: manifest .version .as_deref() @@ -1157,6 +1169,25 @@ pub(crate) fn build_node_register( } } +fn is_placement_repo_key(value: &str) -> bool { + let mut segments = value.split('/'); + let Some(owner) = segments.next() else { + return false; + }; + let Some(repo) = segments.next() else { + return false; + }; + segments.next().is_none() + && [owner, repo].into_iter().all(|segment| { + !segment.is_empty() + && segment != "." + && segment != ".." + && segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + }) +} + /// The broker's stable provider name. The broker attaches to its node as one /// provider among several, registering `spawn:` / `release` capacity /// under this name; each connection uses a fresh `instance_id` so a reconnect @@ -3145,7 +3176,10 @@ mod tests { assert_eq!(register.name, "builder"); assert_eq!(register.node_id, "node-manifest"); assert_eq!(register.max_agents, 8); - assert_eq!(register.repo_keys, vec!["AgentWorkforce/relay"]); + assert_eq!( + register.repo_keys, + Some(vec!["AgentWorkforce/relay".to_string()]) + ); assert_eq!( register.capabilities[0].metadata, Some(BTreeMap::from([( @@ -3165,6 +3199,34 @@ mod tests { ); } + #[test] + fn build_node_register_keeps_only_placement_safe_repo_keys() { + let mut manifest = test_manifest(); + manifest.repo_keys = Some(vec![ + "AgentWorkforce/relay".to_string(), + "/private/node/relay".to_string(), + "AgentWorkforce/relay/extra".to_string(), + "AgentWorkforce/relay".to_string(), + "../relay".to_string(), + ]); + + let register = + build_node_register(&manifest, "node-default", "host-default", "broker/1", None); + assert_eq!( + register.repo_keys, + Some(vec!["AgentWorkforce/relay".to_string()]) + ); + + manifest.repo_keys = Some(Vec::new()); + let clear = + build_node_register(&manifest, "node-default", "host-default", "broker/1", None); + assert_eq!(clear.repo_keys, Some(Vec::new())); + assert_eq!( + serde_json::to_value(clear).unwrap().get("repo_keys"), + Some(&json!([])) + ); + } + #[tokio::test] async fn node_control_client_round_trips_mock_engine_ws() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/packages/cli/src/cli/lib/node-provider-child.test.ts b/packages/cli/src/cli/lib/node-provider-child.test.ts index 6592f2c1f..b977a4a13 100644 --- a/packages/cli/src/cli/lib/node-provider-child.test.ts +++ b/packages/cli/src/cli/lib/node-provider-child.test.ts @@ -75,7 +75,7 @@ describe('parseNodeDescriptor', () => { repoPaths: { 'AgentWorkforce/relay': repoPath }, })}\n`; - expect(() => parseNodeDescriptor(stdout)).toThrow(/absolute paths/); + expect(() => parseNodeDescriptor(stdout)).toThrow(/absolute path/); }); it.each(['./repo', '../repo'])('rejects path-shaped repo key %s from local describe IPC', (repoKey) => { @@ -85,7 +85,29 @@ describe('parseNodeDescriptor', () => { repoPaths: { [repoKey]: path.resolve('private-checkouts', 'relay') }, })}\n`; - expect(() => parseNodeDescriptor(stdout)).toThrow(/owner\/repo keys/); + expect(() => parseNodeDescriptor(stdout)).toThrow(/owner\/repo format/); + }); + + it('uses the fleet validator for trimmed and duplicate repo keys', () => { + const repoPath = path.resolve('private-checkouts', 'relay'); + const normalized = `${MARKER}${JSON.stringify({ + name: 'n', + capabilities: [], + repoPaths: { ' AgentWorkforce/relay ': repoPath }, + })}\n`; + expect(parseNodeDescriptor(normalized)?.repoPaths).toEqual({ + 'AgentWorkforce/relay': repoPath, + }); + + const duplicate = `${MARKER}${JSON.stringify({ + name: 'n', + capabilities: [], + repoPaths: { + 'AgentWorkforce/relay': repoPath, + ' AgentWorkforce/relay ': path.resolve('private-checkouts', 'other'), + }, + })}\n`; + expect(() => parseNodeDescriptor(duplicate)).toThrow(/duplicate key/); }); it('ignores output the config printed on import', () => { diff --git a/packages/cli/src/cli/lib/node-provider-child.ts b/packages/cli/src/cli/lib/node-provider-child.ts index e05ce6cab..ee070bdeb 100644 --- a/packages/cli/src/cli/lib/node-provider-child.ts +++ b/packages/cli/src/cli/lib/node-provider-child.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { createRequire } from 'node:module'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { normalizeFleetRepoPaths } from '@agent-relay/fleet'; import { createLogger } from '@agent-relay/utils'; import type { CoreDependencies, SpawnedProcess } from '../commands/core.js'; @@ -290,24 +291,7 @@ export function parseNodeDescriptor(stdout: string): NodeDefinitionDescriptor | } function parseDescriptorRepoPaths(value: unknown): Readonly> | undefined { - if (value === undefined) { - return undefined; - } - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('Fleet node descriptor repoPaths must be an object keyed by owner/repo'); - } - const repoPaths: Record = {}; - for (const [key, repoPath] of Object.entries(value)) { - const segments = key.split('/'); - const placementSafeKey = - segments.length === 2 && - segments.every((segment) => segment !== '.' && segment !== '..' && /^[A-Za-z0-9._-]+$/.test(segment)); - if (!placementSafeKey || typeof repoPath !== 'string' || !path.isAbsolute(repoPath)) { - throw new Error('Fleet node descriptor repoPaths must map owner/repo keys to absolute paths'); - } - repoPaths[key] = repoPath; - } - return repoPaths; + return normalizeFleetRepoPaths(value); } /** diff --git a/packages/fleet/src/index.ts b/packages/fleet/src/index.ts index ea04b8127..4bf6cfdc9 100644 --- a/packages/fleet/src/index.ts +++ b/packages/fleet/src/index.ts @@ -191,7 +191,7 @@ export interface SpawnHandlerOptions { export function defineNode(input: FleetNodeDefinitionInput): FleetNodeDefinition { const name = nonEmpty(input.name, 'node name'); - const repoPaths = normalizeRepoPaths(input.repoPaths); + const repoPaths = normalizeFleetRepoPaths(input.repoPaths); const capabilityEntries = Object.entries(input.capabilities ?? {}); if (capabilityEntries.length === 0) { throw new Error('defineNode requires at least one capability'); @@ -506,7 +506,11 @@ function nonEmpty(value: string | undefined, label: string): string { return trimmed; } -function normalizeRepoPaths(repoPaths: FleetRepoPaths | undefined): FleetRepoPaths | undefined { +/** + * Validate and normalize the node-private repository map used by both direct + * definitions and the compiled child descriptor bridge. + */ +export function normalizeFleetRepoPaths(repoPaths: unknown): FleetRepoPaths | undefined { if (repoPaths === undefined) { return undefined; } From 4ddaf31acde439409065461b6e152052762ab3fe Mon Sep 17 00:00:00 2001 From: Miya Date: Wed, 19 Aug 2026 09:45:02 +0200 Subject: [PATCH 08/13] fix(broker): enforce local repository placement Session-Id: 01a018d0-b15a-7552-b2fd-ca79a9b9396a --- crates/broker/src/runtime/init.rs | 69 +++++++++++++++++-- crates/broker/src/runtime/relaycast_events.rs | 42 ++++++++--- 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/crates/broker/src/runtime/init.rs b/crates/broker/src/runtime/init.rs index 004790ef9..f916906e4 100644 --- a/crates/broker/src/runtime/init.rs +++ b/crates/broker/src/runtime/init.rs @@ -1,5 +1,8 @@ use super::*; -use std::net::{IpAddr, SocketAddr}; +use std::{ + collections::BTreeMap, + net::{IpAddr, SocketAddr}, +}; pub(crate) async fn run_init(cmd: InitCommand, telemetry: TelemetryClient) -> Result<()> { let broker_start = Instant::now(); @@ -277,7 +280,8 @@ pub(crate) async fn run_init(cmd: InitCommand, telemetry: TelemetryClient) -> Re // configuration, so keep them in the broker and resolve the key immediately // before every spawn rather than trusting a dispatcher-supplied cwd. let node_repo_paths = super::relaycast_events::load_node_repo_paths_from_env()?; - let node_manifest = bootstrap_node_manifest(&node_name, &node_id, &broker_version); + let node_manifest = + bootstrap_node_manifest(&node_name, &node_id, &broker_version, &node_repo_paths); // Retain the node name for the runtime: the HTTP `bind_agent_to_node` // fallback (used when node-control `agent.register` is unavailable) binds // spawned agents to this node so they become `via_node` and node delivery @@ -865,7 +869,12 @@ const DEFAULT_NODE_HARNESSES: &[&str] = &["claude", "codex", "gemini", "opencode /// placement for the whole workspace. The harness set comes from the /// `AGENT_RELAY_NODE_HARNESSES` CSV (the CLI sets it from the project's /// teams.json / node definition), falling back to a built-in default. -fn bootstrap_node_manifest(node_name: &str, node_id: &str, broker_version: &str) -> NodeManifest { +fn bootstrap_node_manifest( + node_name: &str, + node_id: &str, + broker_version: &str, + node_repo_paths: &BTreeMap, +) -> NodeManifest { let mut capabilities: Vec = node_capacity_harnesses() .into_iter() .map(|harness| crate::protocol::NodeCapabilityManifest { @@ -884,7 +893,14 @@ fn bootstrap_node_manifest(node_name: &str, node_id: &str, broker_version: &str) node_id: Some(node_id.to_string()), capabilities, max_agents: node_max_agents(), - tags: None, + // A node map is private configuration. Publish only its placement keys, + // never the checkout paths, through the existing public tag contract. + tags: (!node_repo_paths.is_empty()).then(|| { + node_repo_paths + .keys() + .map(|repo_key| format!("repo:{repo_key}")) + .collect() + }), version: Some(broker_version.to_string()), } } @@ -1010,7 +1026,8 @@ mod tests { // all `kind: "capacity"`. It must never advertise a bare `"spawn"`, which // the engine would materialize as a generic action pinned to this node, // hijacking capability-based spawn placement for the whole workspace. - let manifest = bootstrap_node_manifest("node-a", "node_a", "relay-broker/9.1.1"); + let manifest = + bootstrap_node_manifest("node-a", "node_a", "relay-broker/9.1.1", &BTreeMap::new()); assert!( !manifest.capabilities.is_empty(), "broker manifest must advertise its capacity" @@ -1044,6 +1061,48 @@ mod tests { assert_eq!(manifest.name, "node-a"); assert_eq!(manifest.node_id.as_deref(), Some("node_a")); assert_eq!(manifest.version.as_deref(), Some("relay-broker/9.1.1")); + assert_eq!(manifest.tags, None); + } + + #[test] + fn bootstrap_node_manifest_advertises_only_local_repo_keys() { + let local_checkout = PathBuf::from("/private/node/checkouts/relay"); + let node_repo_paths = BTreeMap::from([ + ("AgentWorkforce/relay".to_string(), local_checkout.clone()), + ( + "AgentWorkforce/factory".to_string(), + PathBuf::from("/private/node/checkouts/factory"), + ), + ]); + + let manifest = + bootstrap_node_manifest("node-a", "node_a", "relay-broker/9.1.1", &node_repo_paths); + + assert_eq!( + manifest.tags, + Some(vec![ + "repo:AgentWorkforce/factory".to_string(), + "repo:AgentWorkforce/relay".to_string(), + ]) + ); + let register = crate::node_control::build_node_register( + &manifest, + "default-node-id", + "default-node-name", + "default-version", + None, + ); + assert_eq!(register.tags, manifest.tags.clone().unwrap_or_default()); + let serialized = + serde_json::to_string(®ister).expect("node registration should serialize"); + assert!( + !serialized.contains(&local_checkout.display().to_string()), + "node registration must not leak local checkout paths: {serialized}" + ); + assert!( + !serialized.contains("/private/node/checkouts/factory"), + "node registration must not leak local checkout paths: {serialized}" + ); } #[test] diff --git a/crates/broker/src/runtime/relaycast_events.rs b/crates/broker/src/runtime/relaycast_events.rs index 51c14298b..748c9696f 100644 --- a/crates/broker/src/runtime/relaycast_events.rs +++ b/crates/broker/src/runtime/relaycast_events.rs @@ -154,18 +154,22 @@ pub(super) fn relaycast_spawn_worker_cwd(ws_value: &Value) -> Result Result> { - let Some(assignment) = ws_value.get("assignment") else { +/// placement field is authoritative even when it is malformed: treating an +/// empty or invalid key as absent would re-enable fallback to a remotely +/// supplied cwd. +fn relaycast_placement_repo(ws_value: &Value) -> Result> { + let Some(candidate) = ws_value + .get("assignment") + .and_then(|assignment| assignment.get("repo")) + .or_else(|| ws_value.get("repo")) + else { return Ok(None); }; - let repo = assignment - .get("repo") - .and_then(Value::as_str) + let repo = candidate + .as_str() .map(str::trim) .filter(|repo| !repo.is_empty()) - .context("assignment.repo must be a non-empty string")?; + .context("assignment.repo or repo must be a non-empty string")?; Ok(Some(repo)) } @@ -179,7 +183,7 @@ pub(super) fn relaycast_spawn_worker_cwd_for_node( ws_value: &Value, node_repo_paths: &BTreeMap, ) -> Result> { - let Some(repo) = relaycast_assignment_repo(ws_value)? else { + let Some(repo) = relaycast_placement_repo(ws_value)? else { return relaycast_spawn_worker_cwd(ws_value); }; @@ -1378,6 +1382,26 @@ mod tests { assert_eq!(cwd.as_deref(), checkout.path().to_str()); } + #[test] + fn top_level_repo_resolves_to_the_node_local_checkout() { + let checkout = tempfile::tempdir().expect("local checkout fixture"); + let remote_cwd = tempfile::tempdir().expect("remote cwd fixture"); + let paths = node_repo_paths("AgentWorkforce/relay", checkout.path()); + + let cwd = relaycast_spawn_worker_cwd_for_node( + &json!({ + "assignment": { "project": "factory" }, + "repo": "AgentWorkforce/relay", + "worker_cwd": remote_cwd.path(), + }), + &paths, + ) + .expect("top-level repository placement should resolve locally"); + + assert_eq!(cwd.as_deref(), checkout.path().to_str()); + assert_ne!(cwd.as_deref(), remote_cwd.path().to_str()); + } + #[test] fn assignment_repo_rejects_a_missing_local_key() { let checkout = tempfile::tempdir().expect("local checkout fixture"); From c4ec7b7f1a1cef3c52a6b38285fb749e7c0b87b7 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Wed, 19 Aug 2026 09:50:37 +0200 Subject: [PATCH 09/13] fix(protocol): reject malformed repo placement Session-Id: 01a018d2-29c4-7372-b4b7-fcddef41ab2a --- crates/broker/src/fleet_wire.rs | 98 ++++++++++++++++++- crates/broker/src/runtime/relaycast_events.rs | 56 +++++++---- 2 files changed, 134 insertions(+), 20 deletions(-) diff --git a/crates/broker/src/fleet_wire.rs b/crates/broker/src/fleet_wire.rs index 422fe34c0..e296e5439 100644 --- a/crates/broker/src/fleet_wire.rs +++ b/crates/broker/src/fleet_wire.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use serde::{ de::{self, Deserializer}, @@ -125,7 +125,12 @@ pub struct NodeRegister { pub max_agents: u32, pub tags: Vec, /// Placement-safe repository keys; absolute node-local paths are forbidden. - #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[serde( + default, + deserialize_with = "deserialize_repo_keys", + serialize_with = "serialize_repo_keys", + skip_serializing_if = "Vec::is_empty" + )] pub repo_keys: Vec, pub version: String, #[serde( @@ -415,6 +420,50 @@ where T::deserialize(deserializer).map(Some) } +pub(crate) fn is_placement_repo_key(value: &str) -> bool { + let Some((owner, repo)) = value.split_once('/') else { + return false; + }; + fn valid_segment(segment: &str) -> bool { + !segment.is_empty() + && segment != "." + && segment != ".." + && segment.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') + }) + } + + valid_segment(owner) && valid_segment(repo) && !repo.contains('/') +} + +fn validate_repo_keys(repo_keys: &[String]) -> Result<(), &'static str> { + let mut seen = BTreeSet::new(); + if repo_keys + .iter() + .any(|key| !is_placement_repo_key(key) || !seen.insert(key)) + { + return Err("repo_keys must contain unique placement-safe owner/repo keys"); + } + Ok(()) +} + +fn deserialize_repo_keys<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let repo_keys = Vec::::deserialize(deserializer)?; + validate_repo_keys(&repo_keys).map_err(de::Error::custom)?; + Ok(repo_keys) +} + +fn serialize_repo_keys(repo_keys: &[String], serializer: S) -> Result +where + S: Serializer, +{ + validate_repo_keys(repo_keys).map_err(ser::Error::custom)?; + repo_keys.serialize(serializer) +} + fn deserialize_optional_finite_nonnegative_f64<'de, D>( deserializer: D, ) -> Result, D::Error> @@ -1150,6 +1199,51 @@ mod tests { assert!(serde_json::from_value::(private).is_err()); } + #[test] + fn node_register_rejects_invalid_repo_keys_on_both_wire_directions() { + let invalid_key = "/node-private/checkouts/factory"; + let invalid_wire = json!({ + "type": "node.register", + "v": 1, + "name": "builder-1", + "node_id": "node_1", + "capabilities": [], + "max_agents": 1, + "tags": [], + "repo_keys": [invalid_key], + "version": "relay-broker/test", + "resume_cursor": null + }); + let error = serde_json::from_value::(invalid_wire) + .expect_err("path-shaped repository keys must be rejected") + .to_string(); + assert!(error.contains("placement-safe owner/repo"), "{error}"); + assert!(!error.contains("node-private"), "{error}"); + + let mut outbound: BrokerToRelaycast = serde_json::from_value(json!({ + "type": "node.register", + "v": 1, + "name": "builder-1", + "node_id": "node_1", + "capabilities": [], + "max_agents": 1, + "tags": [], + "repo_keys": ["AgentWorkforce/factory"], + "version": "relay-broker/test", + "resume_cursor": null + })) + .unwrap(); + let BrokerToRelaycast::NodeRegister(register) = &mut outbound else { + unreachable!("fixture is node.register"); + }; + register.repo_keys = vec![invalid_key.to_string()]; + let error = serde_json::to_value(outbound) + .expect_err("invalid keys must never serialize into registration") + .to_string(); + assert!(error.contains("placement-safe owner/repo"), "{error}"); + assert!(!error.contains("node-private"), "{error}"); + } + #[test] fn optional_fields_reject_explicit_nulls() { let null_request_id = json!({ diff --git a/crates/broker/src/runtime/relaycast_events.rs b/crates/broker/src/runtime/relaycast_events.rs index f661561e5..432ec282b 100644 --- a/crates/broker/src/runtime/relaycast_events.rs +++ b/crates/broker/src/runtime/relaycast_events.rs @@ -78,7 +78,7 @@ fn parse_node_repo_paths(raw: &str) -> Result> { let mut repo_paths = BTreeMap::new(); for (raw_repo, raw_path) in configured { let repo = raw_repo.trim(); - if !is_repo_key(repo) { + if !crate::fleet_wire::is_placement_repo_key(repo) { anyhow::bail!("{NODE_REPO_PATHS_ENV} keys must use owner/repo format"); } let path = PathBuf::from(raw_path.trim()); @@ -103,22 +103,6 @@ pub(super) fn node_repo_keys(repo_paths: &BTreeMap) -> Vec bool { - let Some((owner, repo)) = value.split_once('/') else { - return false; - }; - fn valid_segment(segment: &str) -> bool { - !segment.is_empty() - && segment != "." - && segment != ".." - && segment.chars().all(|character| { - character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') - }) - } - - valid_segment(owner) && valid_segment(repo) && !repo.contains('/') -} - impl BrokerRuntime { /// Drain a workspace-firehose event for the broker runtime. /// @@ -239,6 +223,24 @@ fn relaycast_assignment_repo(ws_value: &Value) -> Result> { "/agent/metadata/repo", "/agent/metadata/assignment/repo", ]; + const ASSIGNMENT_POINTERS: &[&str] = &[ + "/assignment", + "/metadata/assignment", + "/agent/metadata/assignment", + ]; + + for pointer in ASSIGNMENT_POINTERS { + let Some(assignment) = ws_value.pointer(pointer) else { + continue; + }; + let assignment = assignment + .as_object() + .context("assignment must be an object containing repo")?; + anyhow::ensure!( + assignment.contains_key("repo"), + "assignment must include repo" + ); + } let mut resolved: Option = None; for pointer in REPO_POINTERS { @@ -251,7 +253,7 @@ fn relaycast_assignment_repo(ws_value: &Value) -> Result> { .filter(|repo| !repo.is_empty()) .context("repo assignment must be a non-empty string")?; anyhow::ensure!( - is_repo_key(repo), + crate::fleet_wire::is_placement_repo_key(repo), "repo assignment must use owner/repo format" ); if let Some(existing) = resolved.as_deref() { @@ -1563,6 +1565,24 @@ mod tests { assert!(!error.contains(&cwd.path().display().to_string())); } + #[test] + fn assignment_without_repo_fails_closed_before_cwd_fallback() { + let cwd = tempfile::tempdir().expect("cwd fixture"); + let error = relaycast_spawn_worker_cwd_for_node( + &json!({ + "assignment": {}, + "cwd": cwd.path(), + "worker_cwd": cwd.path(), + }), + &BTreeMap::new(), + ) + .expect_err("malformed assignment must not restore remote cwd fallback") + .to_string(); + + assert!(error.contains("assignment must include repo"), "{error}"); + assert!(!error.contains(&cwd.path().display().to_string())); + } + #[test] fn assignment_repo_rejects_a_missing_local_key() { let checkout = tempfile::tempdir().expect("local checkout fixture"); From e1af76840a25a451e4e86acaaac7e1c6920be0f9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 19 Aug 2026 07:55:45 +0000 Subject: [PATCH 10/13] style: auto-format with Prettier --- packages/fleet/src/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/fleet/src/index.ts b/packages/fleet/src/index.ts index 2a4bb9fa8..ea04b8127 100644 --- a/packages/fleet/src/index.ts +++ b/packages/fleet/src/index.ts @@ -99,10 +99,10 @@ export interface FleetNodeDefinitionInput { triggers?: FleetTriggerDescriptor[]; tags?: string[]; /** - * Node-local absolute checkout paths keyed by `owner/repo`. - * Registration advertises only the keys; path values remain node-private. - */ - repoPaths?: FleetRepoPaths; + * Node-local absolute checkout paths keyed by `owner/repo`. + * Registration advertises only the keys; path values remain node-private. + */ + repoPaths?: FleetRepoPaths; version?: string; } From 8f58e7e7cee8b2212c8e1b591c3c9f990bb2c331 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Wed, 19 Aug 2026 09:58:01 +0200 Subject: [PATCH 11/13] test(fleet): remove invalid duplicate fixtures Session-Id: 01a018d2-29c4-7372-b4b7-fcddef41ab2a Session-Id: 01a018d2-29c4-7372-b4b7-fcddef41ab2a --- packages/fleet/src/index.test.ts | 48 -------------------------------- 1 file changed, 48 deletions(-) diff --git a/packages/fleet/src/index.test.ts b/packages/fleet/src/index.test.ts index a4734c1c2..4e8087333 100644 --- a/packages/fleet/src/index.test.ts +++ b/packages/fleet/src/index.test.ts @@ -31,33 +31,6 @@ describe('@agent-relay/fleet', () => { expect(node.capabilities['run:build']).toMatchObject({ name: 'run:build', kind: 'action' }); }); - it('normalizes node-local repo paths without exposing values through node info', () => { - const node = defineNode({ - name: 'builder-1', - repoPaths: { - ' AgentWorkforce/relay ': '/srv/checkouts/relay', - 'AgentWorkforce/factory': 'C:\\work\\factory', - }, - capabilities: { ping: async () => 'pong' }, - }); - - expect(node.repoPaths).toEqual({ - 'AgentWorkforce/relay': '/srv/checkouts/relay', - 'AgentWorkforce/factory': 'C:\\work\\factory', - }); - expect(JSON.stringify(nodeInfo(node))).not.toContain('/srv/checkouts/relay'); - }); - - it('preserves compatible repo tags when no local repo map is configured', () => { - const node = defineNode({ - name: 'legacy-builder', - tags: ['arm64', 'repo:AgentWorkforce/relay'], - capabilities: { ping: async () => 'pong' }, - }); - - expect(nodeRegistrationTags(node)).toEqual(['arm64', 'repo:AgentWorkforce/relay']); - }); - it('accepts a plain async handler as an escape hatch', async () => { const node = defineNode({ name: 'custom', @@ -332,27 +305,6 @@ describe('@agent-relay/fleet', () => { triggers: [onMessage({ match: /ship/i }, 'run')], }) ).toThrow(/trigger regex flags are not supported yet/); - expect(() => - defineNode({ - name: 'x', - capabilities: { run: async () => undefined }, - repoPaths: { '/Users/alice/relay': '/srv/relay' }, - }) - ).toThrow(/owner\/name/); - expect(() => - defineNode({ - name: 'x', - capabilities: { run: async () => undefined }, - repoPaths: { 'AgentWorkforce/relay': 'relative/relay' }, - }) - ).toThrow(/absolute path/); - expect(() => - defineNode({ - name: 'x', - capabilities: { run: async () => undefined }, - tags: ['repo:/Users/alice/relay'], - }) - ).toThrow(/owner\/name/); }); }); From 0e6ced874366015d5dc9a918e0a3b7308ed3ff59 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 19 Aug 2026 10:07:21 +0200 Subject: [PATCH 12/13] chore(trajectory): record node repo resolution work Session-Id: 01a018d0-ccf4-7b02-8624-f2ad69524c6a Session-Id: 01a018d0-ccf4-7b02-8624-f2ad69524c6a --- .../2026-08/traj_bgak19d8cp7m.trace.json | 245 ++++++++++++++++++ .../2026-08/traj_bgak19d8cp7m/summary.md | 39 +++ .../2026-08/traj_bgak19d8cp7m/trajectory.json | 89 +++++++ 3 files changed, 373 insertions(+) create mode 100644 .agentworkforce/trajectories/completed/2026-08/traj_bgak19d8cp7m.trace.json create mode 100644 .agentworkforce/trajectories/completed/2026-08/traj_bgak19d8cp7m/summary.md create mode 100644 .agentworkforce/trajectories/completed/2026-08/traj_bgak19d8cp7m/trajectory.json diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_bgak19d8cp7m.trace.json b/.agentworkforce/trajectories/completed/2026-08/traj_bgak19d8cp7m.trace.json new file mode 100644 index 000000000..69302f881 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_bgak19d8cp7m.trace.json @@ -0,0 +1,245 @@ +{ + "version": "1.0.0", + "id": "6719de3e-e2f6-4c3a-81a3-2e62d82a5e6d", + "timestamp": "2026-08-19T08:07:14.777Z", + "trajectory": "traj_bgak19d8cp7m", + "files": [ + { + "path": "crates/broker/src/runtime/event_loop.rs", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 209, + "end_line": 217, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + } + ] + } + ] + }, + { + "path": "crates/broker/src/runtime/fleet.rs", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1255, + "end_line": 1261, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + } + ] + } + ] + }, + { + "path": "crates/broker/src/runtime/init.rs", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 273, + "end_line": 279, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + }, + { + "start_line": 681, + "end_line": 687, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + } + ] + } + ] + }, + { + "path": "crates/broker/src/runtime/mod.rs", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 83, + "end_line": 89, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + } + ] + } + ] + }, + { + "path": "crates/broker/src/runtime/relaycast_events.rs", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 71, + "end_line": 83, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + }, + { + "start_line": 428, + "end_line": 434, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + }, + { + "start_line": 471, + "end_line": 477, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + }, + { + "start_line": 1164, + "end_line": 1170, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + }, + { + "start_line": 1242, + "end_line": 1248, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + }, + { + "start_line": 1256, + "end_line": 1265, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + }, + { + "start_line": 1267, + "end_line": 1278, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + } + ] + } + ] + }, + { + "path": "crates/broker/src/runtime/repo_placement.rs", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 285, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + } + ] + } + ] + }, + { + "path": "crates/broker/src/runtime/tests.rs", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 265, + "end_line": 271, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + } + ] + } + ] + }, + { + "path": "packages/cli/src/cli/lib/broker-lifecycle.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 520, + "end_line": 541, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + } + ] + } + ] + }, + { + "path": "packages/cli/src/cli/lib/broker-lifecycle.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1513, + "end_line": 1527, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + }, + { + "start_line": 1889, + "end_line": 1902, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + } + ] + } + ] + }, + { + "path": "packages/cli/src/cli/lib/node-provider-child.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 177, + "end_line": 214, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + } + ] + } + ] + }, + { + "path": "packages/cli/src/cli/lib/node-provider-child.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 115, + "end_line": 123, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + }, + { + "start_line": 259, + "end_line": 266, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + }, + { + "start_line": 278, + "end_line": 296, + "revision": "f57210bd016748099d7505ff6d59e68d61c3cad3" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_bgak19d8cp7m/summary.md b/.agentworkforce/trajectories/completed/2026-08/traj_bgak19d8cp7m/summary.md new file mode 100644 index 000000000..7112c7ef7 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_bgak19d8cp7m/summary.md @@ -0,0 +1,39 @@ +# Trajectory: Resolve assigned repositories on fleet nodes before spawn + +> **Status:** ✅ Completed +> **Confidence:** 95% +> **Started:** August 19, 2026 at 09:01 AM +> **Completed:** August 19, 2026 at 10:07 AM + +--- + +## Summary + +Implemented node-local repository assignment resolution with private repo map ingestion, key-only registration, fail-closed spawn validation, malicious cwd rejection, and focused Rust/CLI coverage; integrated the wire parent and passed format, typecheck, clippy, and tests. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Keep repository paths node-private and resolve only assignment keys at the final spawn boundary +- **Chose:** Keep repository paths node-private and resolve only assignment keys at the final spawn boundary +- **Reasoning:** Factory-provided cwd variants cross a trust boundary; loading and consuming the node-local map at broker startup prevents path disclosure and inheritance, while spawn-time filesystem checks fail closed if checkout state changes. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Keep repository paths node-private and resolve only assignment keys at the final spawn boundary: Keep repository paths node-private and resolve only assignment keys at the final spawn boundary +- Integrated the complete wire parent before finalizing the node lane; the final diff is limited to node-local ingestion, registration key derivation, CLI env bridging, and authoritative spawn resolution. + +--- + +## Artifacts + +**Commits:** f57210bd0 +**Files changed:** 11 diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_bgak19d8cp7m/trajectory.json b/.agentworkforce/trajectories/completed/2026-08/traj_bgak19d8cp7m/trajectory.json new file mode 100644 index 000000000..b90deb71d --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_bgak19d8cp7m/trajectory.json @@ -0,0 +1,89 @@ +{ + "id": "traj_bgak19d8cp7m", + "version": 1, + "task": { + "title": "Resolve assigned repositories on fleet nodes before spawn" + }, + "status": "completed", + "startedAt": "2026-08-19T07:01:52.414Z", + "completedAt": "2026-08-19T08:07:14.424Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-08-19T08:07:10.556Z" + } + ], + "chapters": [ + { + "id": "chap_wccc720lacbj", + "title": "Work", + "agentName": "default", + "startedAt": "2026-08-19T08:07:10.556Z", + "endedAt": "2026-08-19T08:07:14.424Z", + "events": [ + { + "ts": 1787126830557, + "type": "decision", + "content": "Keep repository paths node-private and resolve only assignment keys at the final spawn boundary: Keep repository paths node-private and resolve only assignment keys at the final spawn boundary", + "raw": { + "question": "Keep repository paths node-private and resolve only assignment keys at the final spawn boundary", + "chosen": "Keep repository paths node-private and resolve only assignment keys at the final spawn boundary", + "alternatives": [], + "reasoning": "Factory-provided cwd variants cross a trust boundary; loading and consuming the node-local map at broker startup prevents path disclosure and inheritance, while spawn-time filesystem checks fail closed if checkout state changes." + }, + "significance": "high" + }, + { + "ts": 1787126832493, + "type": "reflection", + "content": "Integrated the complete wire parent before finalizing the node lane; the final diff is limited to node-local ingestion, registration key derivation, CLI env bridging, and authoritative spawn resolution.", + "raw": { + "focalPoints": [ + "privacy", + "branch-lineage", + "fail-closed-tests" + ], + "adjustments": "Preserved the parent wire validator during merge conflicts and retained the node runtime's derived repo_keys.", + "confidence": 0.94 + }, + "significance": "high", + "tags": [ + "focal:privacy", + "focal:branch-lineage", + "focal:fail-closed-tests", + "confidence:0.94" + ] + } + ] + } + ], + "retrospective": { + "summary": "Implemented node-local repository assignment resolution with private repo map ingestion, key-only registration, fail-closed spawn validation, malicious cwd rejection, and focused Rust/CLI coverage; integrated the wire parent and passed format, typecheck, clippy, and tests.", + "approach": "Standard approach", + "confidence": 0.95 + }, + "commits": [ + "f57210bd0" + ], + "filesChanged": [ + "crates/broker/src/runtime/event_loop.rs", + "crates/broker/src/runtime/fleet.rs", + "crates/broker/src/runtime/init.rs", + "crates/broker/src/runtime/mod.rs", + "crates/broker/src/runtime/relaycast_events.rs", + "crates/broker/src/runtime/repo_placement.rs", + "crates/broker/src/runtime/tests.rs", + "packages/cli/src/cli/lib/broker-lifecycle.test.ts", + "packages/cli/src/cli/lib/broker-lifecycle.ts", + "packages/cli/src/cli/lib/node-provider-child.test.ts", + "packages/cli/src/cli/lib/node-provider-child.ts" + ], + "projectId": "AgentWorkforce/relay", + "tags": [], + "_trace": { + "startRef": "286467e936bb3efbd415ed233d416e59cd390c05", + "endRef": "f57210bd016748099d7505ff6d59e68d61c3cad3", + "traceId": "6719de3e-e2f6-4c3a-81a3-2e62d82a5e6d" + } +} \ No newline at end of file From 643bf8877ed0d7871d09df437cb9018c6bae977d Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Wed, 19 Aug 2026 10:14:32 +0200 Subject: [PATCH 13/13] fix(broker): preserve repo map configuration presence Session-Id: 01a018d2-29c4-7372-b4b7-fcddef41ab2a Session-Id: 01a018cd-2803-7712-8317-bcc82c02f8fa --- crates/broker/src/runtime/init.rs | 41 ++++++++++---- crates/broker/src/runtime/relaycast_events.rs | 53 +++++++++++++------ 2 files changed, 68 insertions(+), 26 deletions(-) diff --git a/crates/broker/src/runtime/init.rs b/crates/broker/src/runtime/init.rs index 6929752c0..3ec66583d 100644 --- a/crates/broker/src/runtime/init.rs +++ b/crates/broker/src/runtime/init.rs @@ -276,8 +276,11 @@ pub(crate) async fn run_init(cmd: InitCommand, telemetry: TelemetryClient) -> Re // A placement only carries a repository key. Checkout paths are local node // configuration, so keep them in the broker and resolve the key immediately // before every spawn rather than trusting a dispatcher-supplied cwd. - let node_repo_paths = super::relaycast_events::load_node_repo_paths_from_env()?; - let node_repo_keys = super::relaycast_events::node_repo_keys(&node_repo_paths); + let configured_node_repo_paths = super::relaycast_events::load_node_repo_paths_from_env()?; + let node_repo_keys = configured_node_repo_paths + .as_ref() + .map(super::relaycast_events::node_repo_keys); + let node_repo_paths = configured_node_repo_paths.unwrap_or_default(); let node_manifest = bootstrap_node_manifest(&node_name, &node_id, &broker_version, node_repo_keys); // Retain the node name for the runtime: the HTTP `bind_agent_to_node` @@ -871,7 +874,7 @@ fn bootstrap_node_manifest( node_name: &str, node_id: &str, broker_version: &str, - repo_keys: Vec, + repo_keys: Option>, ) -> NodeManifest { let mut capabilities: Vec = node_capacity_harnesses() .into_iter() @@ -892,10 +895,10 @@ fn bootstrap_node_manifest( capabilities, max_agents: node_max_agents(), tags: None, - // The same locally parsed map drives this keys-only registration and - // spawn-time checkout resolution. Keep Some([]) authoritative so a - // removed mapping clears stale control-plane advertisements. - repo_keys: Some(repo_keys), + // Preserve whether the local map was configured. Legacy brokers omit + // repo_keys when the environment bridge is absent, while an explicit + // empty map advertises Some([]) to clear stale placement state. + repo_keys, version: Some(broker_version.to_string()), } } @@ -1021,8 +1024,7 @@ mod tests { // all `kind: "capacity"`. It must never advertise a bare `"spawn"`, which // the engine would materialize as a generic action pinned to this node, // hijacking capability-based spawn placement for the whole workspace. - let manifest = - bootstrap_node_manifest("node-a", "node_a", "relay-broker/9.1.1", Vec::new()); + let manifest = bootstrap_node_manifest("node-a", "node_a", "relay-broker/9.1.1", None); assert!( !manifest.capabilities.is_empty(), "broker manifest must advertise its capacity" @@ -1057,7 +1059,26 @@ mod tests { assert_eq!(manifest.node_id.as_deref(), Some("node_a")); assert_eq!(manifest.version.as_deref(), Some("relay-broker/9.1.1")); assert_eq!(manifest.tags, None); + assert_eq!(manifest.repo_keys, None); + } + + #[test] + fn bootstrap_node_manifest_preserves_explicit_empty_repo_keys() { + let manifest = + bootstrap_node_manifest("node-a", "node_a", "relay-broker/9.1.1", Some(Vec::new())); + let register = crate::node_control::build_node_register( + &manifest, + "node-default", + "host-default", + "relay-broker/default", + None, + ); + let wire = + serde_json::to_value(crate::fleet_wire::BrokerToRelaycast::NodeRegister(register)) + .expect("serialize node.register"); + assert_eq!(manifest.repo_keys, Some(Vec::new())); + assert_eq!(wire.get("repo_keys"), Some(&serde_json::json!([]))); } #[test] @@ -1122,7 +1143,7 @@ mod tests { "node-a", "node_a", "relay-broker/9.1.1", - super::relaycast_events::node_repo_keys(&repo_paths), + Some(super::relaycast_events::node_repo_keys(&repo_paths)), ); let register = crate::node_control::build_node_register( &manifest, diff --git a/crates/broker/src/runtime/relaycast_events.rs b/crates/broker/src/runtime/relaycast_events.rs index 432ec282b..2b03b27ac 100644 --- a/crates/broker/src/runtime/relaycast_events.rs +++ b/crates/broker/src/runtime/relaycast_events.rs @@ -46,27 +46,19 @@ impl<'de> serde::Deserialize<'de> for UniqueRepoPaths { /// Existence and directory checks happen at spawn time because a checkout can /// disappear after the broker has registered the key. Requiring absolute paths /// here ensures a map entry can never fall back to the broker process cwd. -pub(super) fn load_node_repo_paths_from_env() -> Result> { +pub(super) fn load_node_repo_paths_from_env() -> Result>> { let raw = std::env::var_os(NODE_REPO_PATHS_ENV); // The map is broker-private runtime state. Clear the startup bridge before // any workers launch so absolute checkout paths cannot be inherited. std::env::remove_var(NODE_REPO_PATHS_ENV); - let raw = raw - .map(|value| { - value.into_string().map_err(|_| { - anyhow::anyhow!("{NODE_REPO_PATHS_ENV} must contain valid Unicode JSON") - }) - }) - .transpose()?; - let Some(raw) = raw - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - else { - return Ok(BTreeMap::new()); + let Some(raw) = raw else { + return Ok(None); }; + let raw = raw + .into_string() + .map_err(|_| anyhow::anyhow!("{NODE_REPO_PATHS_ENV} must contain valid Unicode JSON"))?; - parse_node_repo_paths(raw) + parse_node_repo_paths(raw.trim()).map(Some) } fn parse_node_repo_paths(raw: &str) -> Result> { @@ -1484,7 +1476,9 @@ mod tests { r#"{"AgentWorkforce/factory":"/srv/factory"}"#, ); - let paths = load_node_repo_paths_from_env().expect("valid node-local map"); + let paths = load_node_repo_paths_from_env() + .expect("valid node-local map") + .expect("map was explicitly configured"); assert_eq!( paths.get("AgentWorkforce/factory"), @@ -1493,6 +1487,33 @@ mod tests { assert_eq!(std::env::var_os(NODE_REPO_PATHS_ENV), None); } + #[test] + fn node_repo_paths_env_absence_remains_unconfigured() { + let _lock = NODE_REPO_PATHS_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _restore = RepoPathsEnvRestore(std::env::var_os(NODE_REPO_PATHS_ENV)); + std::env::remove_var(NODE_REPO_PATHS_ENV); + + let paths = load_node_repo_paths_from_env().expect("absent map is valid"); + + assert_eq!(paths, None); + } + + #[test] + fn node_repo_paths_env_preserves_explicit_empty_configuration() { + let _lock = NODE_REPO_PATHS_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _restore = RepoPathsEnvRestore(std::env::var_os(NODE_REPO_PATHS_ENV)); + std::env::set_var(NODE_REPO_PATHS_ENV, "{}"); + + let paths = load_node_repo_paths_from_env().expect("empty configured map is valid"); + + assert_eq!(paths, Some(BTreeMap::new())); + assert_eq!(std::env::var_os(NODE_REPO_PATHS_ENV), None); + } + #[test] fn node_repo_paths_parser_rejects_duplicate_json_keys() { let error = parse_node_repo_paths(