Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,17 @@ All notable changes to Agent Relay will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [Unreleased - Minor]

### Added

- Broker run-token mode: with `RELAY_RUN_TOKEN_FILE` set and no `rk_` workspace key, the broker registers itself and its workers, mints its node, and releases workers with a short-lived Relaycast run token (`rt_live_`) that it reads from the file on each request. It never creates a workspace in this mode, and workers get only their own agent token.
- In run-token mode the broker rotates its own and every worker's 15-minute Relaycast agent token before it expires. Workers get a `RELAY_AGENT_TOKEN_FILE` that the broker replaces atomically on each rotation, and the Agent Relay MCP server and CLI read that file on every request, with no workspace key required.
- `HarnessDriverClient.spawn({ runTokenFile })` (or `RELAY_RUN_TOKEN_FILE`) starts the broker in run-token mode and passes it the file path only, with no workspace-key variables.

### Security

- Spawned workers no longer inherit `RELAY_RUN_TOKEN_FILE`, and log redaction now masks `rt_live_` run tokens.

## [14.0.0] - 2026-10-13

Expand Down
10 changes: 9 additions & 1 deletion crates/broker/src/devin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,19 @@ pub(crate) async fn prepare_worker_config(cli: &str) -> Result<Option<tempfile::
}
}
let env = |key| std::env::var(key).ok();
// Run-token mode hands the worker a rotated token file, not a token.
let agent_credential = env("RELAY_AGENT_TOKEN").or_else(|| {
env(crate::relaycast::agent_token_rotation::AGENT_TOKEN_FILE_ENV).map(|path| {
crate::relaycast::agent_token_rotation::agent_token_file_ref(std::path::Path::new(
&path,
))
})
});
let config = crate::snippets::agent_relay_mcp_config_json_with_result(
env("RELAY_API_KEY").as_deref(),
env("RELAY_BASE_URL").as_deref(),
env("RELAY_AGENT_NAME").as_deref(),
env("RELAY_AGENT_TOKEN").as_deref(),
agent_credential.as_deref(),
env("RELAY_WORKSPACES_JSON").as_deref(),
env("RELAY_DEFAULT_WORKSPACE").as_deref(),
None,
Expand Down
172 changes: 158 additions & 14 deletions crates/broker/src/node_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ pub(crate) struct FleetControlConfig {
#[derive(Clone)]
pub(crate) struct NodeTokenMinter {
pub(crate) workspace_key: String,
/// Run-token mode: `workspace_key` is empty and `POST /v1/nodes` is
/// authenticated with the run token read from this file per mint (one
/// re-read and retry on 401).
pub(crate) run_token: Option<crate::relaycast::run_token::RunTokenSource>,
pub(crate) workspace_id: String,
pub(crate) base_url: Option<String>,
pub(crate) node_id: String,
Expand Down Expand Up @@ -195,19 +199,54 @@ impl NodeTokenMinter {
idempotency_key: Option<&str>,
) -> std::result::Result<String, CreateNodeMintError> {
let request = create_node_request(&self.node_id, &self.node_name, &self.broker_version);
match mint_node_token(
&self.workspace_key,
self.base_url.as_deref(),
request,
current_node_token,
idempotency_key,
MintNodeTokenLogContext {
node_id: &self.node_id,
workspace_id: &self.workspace_id,
},
)
.await
{
let context = MintNodeTokenLogContext {
node_id: &self.node_id,
workspace_id: &self.workspace_id,
};
let minted = match self.run_token.as_ref() {
Some(source) => crate::relaycast::run_token::call_with_run_token(
source,
|error: &CreateNodeMintError| error.status() == Some(401),
|token| {
let request = request.clone();
async move {
mint_node_token(
&token,
self.base_url.as_deref(),
request,
current_node_token,
idempotency_key,
context,
)
.await
}
},
)
.await
.map_err(|error| match error {
crate::relaycast::run_token::RunTokenCallError::Call(error) => error,
crate::relaycast::run_token::RunTokenCallError::Read(error) => {
CreateNodeMintError::Api {
status: 401,
code: "run_token_unavailable".to_string(),
message: format!("run token unavailable: {error:#}"),
body: String::new(),
}
}
}),
None => {
mint_node_token(
&self.workspace_key,
self.base_url.as_deref(),
request,
current_node_token,
idempotency_key,
context,
)
.await
}
};
match minted {
Ok(token) => {
tracing::info!(
target = "relay_broker::fleet",
Expand Down Expand Up @@ -436,6 +475,7 @@ fn is_node_token_proof_conflict(error: &CreateNodeMintError) -> bool {
error.status() == Some(409) && error.code() == Some("node_token_proof_required")
}

#[derive(Clone, Copy)]
pub(crate) struct MintNodeTokenLogContext<'a> {
pub(crate) node_id: &'a str,
pub(crate) workspace_id: &'a str,
Expand Down Expand Up @@ -647,7 +687,7 @@ fn redact_relaycast_secrets(input: &str) -> String {

let mut redacted = input.to_string();
let bearer_re = BEARER_SECRET_RE.get_or_init(|| {
regex::Regex::new(r#"(rk_live_|at_live_|nt_live_|br_|Bearer\s+)[A-Za-z0-9._-]+"#)
regex::Regex::new(r#"(rk_live_|rt_live_|at_live_|nt_live_|br_|Bearer\s+)[A-Za-z0-9._-]+"#)
.expect("bearer secret redaction regex must compile")
});
redacted = bearer_re
Expand Down Expand Up @@ -8167,6 +8207,7 @@ mod tests {
broker_version: "broker/test".to_string(),
token_minter: Some(NodeTokenMinter {
workspace_key: "rk_live_test".to_string(),
run_token: None,
workspace_id: "ws_test".to_string(),
base_url: Some(mint_server.base_url()),
node_id: "node-test".to_string(),
Expand Down Expand Up @@ -8251,6 +8292,7 @@ mod tests {
broker_version: "broker/test".to_string(),
token_minter: Some(NodeTokenMinter {
workspace_key: "rk_live_test".to_string(),
run_token: None,
workspace_id: "ws_test".to_string(),
base_url: Some(mint_server.base_url()),
node_id: "node-test".to_string(),
Expand Down Expand Up @@ -8339,6 +8381,7 @@ mod tests {
broker_version: "broker/test".to_string(),
token_minter: Some(NodeTokenMinter {
workspace_key: "rk_live_test".to_string(),
run_token: None,
workspace_id: "ws_test".to_string(),
base_url: Some(mint_server.base_url()),
node_id: "node-test".to_string(),
Expand Down Expand Up @@ -8450,6 +8493,7 @@ mod tests {
broker_version: "broker/test".to_string(),
token_minter: Some(NodeTokenMinter {
workspace_key: "rk_live_test".to_string(),
run_token: None,
workspace_id: "ws_test".to_string(),
base_url: Some(mint_server.base_url()),
node_id: "node-test".to_string(),
Expand Down Expand Up @@ -8590,6 +8634,7 @@ mod tests {
broker_version: "broker/test".to_string(),
token_minter: Some(NodeTokenMinter {
workspace_key: "rk_live_test".to_string(),
run_token: None,
workspace_id: "ws_test".to_string(),
base_url: Some(mint_base_url.clone()),
node_id: "node-test".to_string(),
Expand Down Expand Up @@ -8705,6 +8750,7 @@ mod tests {
.unwrap();
let minter = NodeTokenMinter {
workspace_key: "rk_live_test".to_string(),
run_token: None,
workspace_id: "ws_test".to_string(),
base_url: Some(mint_base_url.clone()),
node_id: "node-test".to_string(),
Expand Down Expand Up @@ -8773,6 +8819,7 @@ mod tests {
.unwrap();
let minter = NodeTokenMinter {
workspace_key: "rk_live_test".to_string(),
run_token: None,
workspace_id: "ws_test".to_string(),
base_url: Some(mint_base_url.clone()),
node_id: "node-test".to_string(),
Expand Down Expand Up @@ -8833,6 +8880,7 @@ mod tests {
.unwrap();
let minter = NodeTokenMinter {
workspace_key: "rk_live_test".to_string(),
run_token: None,
workspace_id: "ws_test".to_string(),
base_url: Some(mint_base_url.clone()),
node_id: "node-test".to_string(),
Expand Down Expand Up @@ -8889,6 +8937,7 @@ mod tests {
.unwrap();
let minter = NodeTokenMinter {
workspace_key: "rk_live_test".to_string(),
run_token: None,
workspace_id: "ws_test".to_string(),
base_url: Some(mint_base_url.clone()),
node_id: "node-test".to_string(),
Expand Down Expand Up @@ -10923,3 +10972,98 @@ mod tests {

#[cfg(test)]
mod registration_tests;

#[cfg(test)]
mod run_token_mint_tests {
use httpmock::{Method::POST, MockServer};
use serde_json::json;

use super::NodeTokenMinter;
use crate::relaycast::run_token::{
test_support::{future_expiry, write_run_token_file},
RunTokenSource,
};

fn node_created(token: &str) -> serde_json::Value {
json!({
"ok": true,
"data": {
"id": "node_run", "name": "run-node", "kind": "ws", "role": "broker",
"version": "relay-broker/test", "status": "offline", "live": false,
"handlers_live": false, "load": null, "active_agents": 0, "max_agents": 0,
"created_at": "2026-10-17T00:00:00Z", "token": token
}
})
}

fn run_token_minter(base_url: String, source: RunTokenSource) -> NodeTokenMinter {
NodeTokenMinter {
// Run-token mode: no workspace key at all.
workspace_key: String::new(),
run_token: Some(source),
workspace_id: "ws_run".to_string(),
base_url: Some(base_url),
node_id: "node_run".to_string(),
node_name: "run-node".to_string(),
broker_version: "relay-broker/test".to_string(),
token_path: None,
adopt_cached_token_after_conflict: true,
}
}

#[tokio::test]
async fn node_is_minted_with_the_current_run_token() {
let server = MockServer::start();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("relaycast-run.json");
write_run_token_file(&path, "rt_live_mint_one", &future_expiry());
let minter = run_token_minter(server.base_url(), RunTokenSource::new(&path));

let first = server.mock(|when, then| {
when.method(POST)
.path("/v1/nodes")
.header("authorization", "Bearer rt_live_mint_one");
then.status(201).json_body(node_created("nt_live_first"));
});
assert_eq!(minter.mint(None).await.unwrap(), "nt_live_first");
first.assert_hits(1);

// A rotated file is picked up by the very next mint.
write_run_token_file(&path, "rt_live_mint_two", &future_expiry());
let second = server.mock(|when, then| {
when.method(POST)
.path("/v1/nodes")
.header("authorization", "Bearer rt_live_mint_two");
then.status(201).json_body(node_created("nt_live_second"));
});
assert_eq!(minter.mint(None).await.unwrap(), "nt_live_second");
second.assert_hits(1);
first.assert_hits(1);
}

#[tokio::test]
async fn a_rejected_run_token_is_retried_once_then_surfaces_401() {
let server = MockServer::start();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("relaycast-run.json");
write_run_token_file(&path, "rt_live_revoked", &future_expiry());
let minter = run_token_minter(server.base_url(), RunTokenSource::new(&path));

let rejected = server.mock(|when, then| {
when.method(POST).path("/v1/nodes");
then.status(401).json_body(json!({"ok": false, "error": {
"code": "run_authority_revoked", "message": "revoked"}}));
});
let error = minter.mint(None).await.unwrap_err();
assert_eq!(error.status(), Some(401));
assert!(!error.to_string().contains("rt_live_revoked"));
rejected.assert_hits(2);
}

#[test]
fn mint_error_redaction_covers_run_tokens() {
let redacted = super::redact_relaycast_secrets("echo rt_live_abc.def and rk_live_x");
assert!(!redacted.contains("abc.def"), "{redacted}");
assert!(!redacted.contains("rk_live_x"), "{redacted}");
}
}
15 changes: 14 additions & 1 deletion crates/broker/src/redact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ use std::sync::LazyLock;

use regex::Regex;

static PATTERNS: LazyLock<[Regex; 3]> = LazyLock::new(|| {
static PATTERNS: LazyLock<[Regex; 4]> = LazyLock::new(|| {
[
// Bare Relaycast credentials, wherever they appear: workspace keys,
// run tokens, agent tokens, and node tokens.
Regex::new(r#"((?:rk|rt|at|nt)_(?:live|test)_)[A-Za-z0-9._-]+"#).expect("valid regex"),
Regex::new(r#"(?i)(api[_-]?key\s*[:=]\s*)([^\s"']+)"#).expect("valid regex"),
Regex::new(r#"(?i)(token\s*[:=]\s*)([^\s"']+)"#).expect("valid regex"),
Regex::new(r#"(?i)(authorization:\s*bearer\s+)([^\s]+)"#).expect("valid regex"),
Expand Down Expand Up @@ -34,4 +37,14 @@ mod tests {
assert!(!redacted.contains("abc"));
assert!(!redacted.contains("xyz"));
}

#[test]
fn redacts_bare_relaycast_credentials_including_run_tokens() {
let line = "using rt_live_abc123 and rk_live_def456 then at_live_x.y nt_live_z";
let redacted = redact(line);
for secret in ["abc123", "def456", "x.y", "nt_live_z"] {
assert!(!redacted.contains(secret), "{redacted}");
}
assert!(redacted.contains("rt_live_[REDACTED]"), "{redacted}");
}
}
Loading
Loading