Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@
"reasoning": "User requested Relaycast request attribution, install/update events, and MCP action-call telemetry while preserving UA-like harness values."
},
"significance": "high"
},
{
"ts": 1780750374621,
"type": "decision",
"content": "Use additive aliases for explicit workspace and broker instance contract: Use additive aliases for explicit workspace and broker instance contract",
"raw": {
"question": "Use additive aliases for explicit workspace and broker instance contract",
"chosen": "Use additive aliases for explicit workspace and broker instance contract",
"alternatives": [],
"reasoning": "The broker already supports legacy RELAY_API_KEY/--name flows; adding --workspace-key, --instance-name, and AGENT_RELAY_WORKSPACE_KEY while retaining old names lets cloud and pear use the locked contract without breaking existing CLI and SDK callers."
},
"significance": "high"
}
]
}
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `agent-relay-broker` and `@agent-relay/harness-driver` accept explicit workspace keys and broker instance names, so local and cloud brokers can join the same Relay workspace with stable, addressable names.
- `@agent-relay/harnesses` adds a `grok` PTY harness for the Grok CLI, including Relaycast MCP support for spawned agents.
- `@agent-relay/harnesses` is now published to npm, so SDK consumers can install the prebuilt PTY harnesses and harness-authoring helpers.
- `agent-relay drive` and `agent-relay passthrough` add adaptive predictive echo so typing stays responsive when driving a high-latency or remote agent, and stays invisible on fast local links.
Expand Down
43 changes: 40 additions & 3 deletions crates/broker/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ impl Commands {
let pid = std::process::id();
match self {
Commands::Init(cmd) => {
let name = cmd.name.trim();
let name = cmd.resolved_instance_name(None);
if !name.is_empty() {
return name.to_string();
return name;
}
std::env::current_dir()
.ok()
Expand Down Expand Up @@ -215,9 +215,18 @@ pub(crate) struct McpArgsCommand {

#[derive(Debug, clap::Args)]
pub(crate) struct InitCommand {
#[arg(long, default_value = "")]
/// Legacy broker instance name flag. Prefer --instance-name.
#[arg(long, default_value = "", alias = "broker-name")]
pub(crate) name: String,

/// Stable broker instance name within the Relay workspace.
#[arg(long = "instance-name")]
pub(crate) instance_name: Option<String>,

/// Join an existing Relay workspace instead of creating a fresh one.
#[arg(long = "workspace-key")]
pub(crate) workspace_key: Option<String>,

#[arg(long, default_value = "general")]
pub(crate) channels: String,

Expand Down Expand Up @@ -248,6 +257,34 @@ pub(crate) struct InitCommand {
pub(crate) state_dir: Option<String>,
}

impl InitCommand {
pub(crate) fn resolved_instance_name(&self, fallback: Option<&str>) -> String {
self.instance_name
.clone()
.or_else(|| std::env::var("AGENT_RELAY_BROKER_NAME").ok())
.or_else(|| {
let name = self.name.trim();
if name.is_empty() {
None
} else {
Some(name.to_string())
}
})
.or_else(|| fallback.map(ToOwned::to_owned))
.unwrap_or_default()
.trim()
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
.to_string()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

pub(crate) fn resolved_workspace_key(&self) -> Option<String> {
self.workspace_key
.clone()
.or_else(|| std::env::var("AGENT_RELAY_WORKSPACE_KEY").ok())
.map(|key| key.trim().to_string())
.filter(|key| !key.is_empty())
Comment on lines +285 to +289

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Blank --workspace-key values are treated as present too early, which can suppress env fallback and silently fall back to workspace creation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/broker/src/cli/mod.rs, line 280:

<comment>Blank `--workspace-key` values are treated as present too early, which can suppress env fallback and silently fall back to workspace creation.</comment>

<file context>
@@ -248,6 +257,34 @@ pub(crate) struct InitCommand {
+    }
+
+    pub(crate) fn resolved_workspace_key(&self) -> Option<String> {
+        self.workspace_key
+            .clone()
+            .or_else(|| std::env::var("AGENT_RELAY_WORKSPACE_KEY").ok())
</file context>
Suggested change
self.workspace_key
.clone()
.or_else(|| std::env::var("AGENT_RELAY_WORKSPACE_KEY").ok())
.map(|key| key.trim().to_string())
.filter(|key| !key.is_empty())
self.workspace_key
.as_deref()
.map(str::trim)
.filter(|key| !key.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
std::env::var("AGENT_RELAY_WORKSPACE_KEY")
.ok()
.map(|key| key.trim().to_string())
.filter(|key| !key.is_empty())
})

}
}

#[derive(Debug, clap::Args, Clone)]
pub(crate) struct PtyCommand {
pub(crate) cli: String,
Expand Down
190 changes: 175 additions & 15 deletions crates/broker/src/relaycast/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ struct WorkspaceSource {
api_key: String,
}

struct EnvWorkspaceKey {
source: &'static str,
key: String,
explicit_join: bool,
}

impl CredentialSet {
pub fn from_json(raw: &str) -> Result<Self> {
let value: Value = serde_json::from_str(raw).context("invalid credential set JSON")?;
Expand Down Expand Up @@ -444,40 +450,47 @@ impl AuthClient {
strict_name: bool,
agent_type: Option<&str>,
) -> Result<AuthSessionSet> {
let env_workspace_key = std::env::var("RELAY_API_KEY")
.ok()
.and_then(|s| normalize_workspace_key(&s));
let env_workspace_key = env_workspace_key()?;

let mut workspace_id_hint: Option<String> = None;

let mut candidates: Vec<(&str, String)> = Vec::new();
let mut candidates: Vec<EnvWorkspaceKey> = Vec::new();
if let Some(key) = env_workspace_key {
candidates.push(("env", key));
candidates.push(key);
}

let mut attempted_fresh_workspace = false;
if candidates.is_empty() {
let ws_name = deterministic_workspace_name();
let (workspace_id, api_key) = self.create_workspace(&ws_name).await?;
workspace_id_hint = Some(workspace_id);
candidates.push(("fresh", api_key));
candidates.push(EnvWorkspaceKey {
source: "fresh",
key: api_key,
explicit_join: false,
});
attempted_fresh_workspace = true;
}

let preferred_name = requested_name;
let mut auth_rejections = Vec::new();

for (source, key) in &candidates {
for candidate in &candidates {
tracing::info!(
target = "relay_broker::auth",
source = %source,
source = %candidate.source,
preferred_name = ?preferred_name,
strict_name = %strict_name,
agent_type = ?agent_type,
"attempting registration with workspace key"
);
match self
.register_agent_with_workspace_key(key, preferred_name, strict_name, agent_type)
.register_agent_with_workspace_key(
&candidate.key,
preferred_name,
strict_name,
agent_type,
)
.await
{
Ok(registration) => {
Expand All @@ -487,22 +500,38 @@ impl AuthClient {
returned_name = %registration.1,
"registration succeeded"
);
let session =
self.finish_session(key.clone(), workspace_id_hint.clone(), registration)?;
let session = self.finish_session(
candidate.key.clone(),
workspace_id_hint.clone(),
registration,
)?;
return Ok(AuthSessionSet {
default_workspace_id: Some(session.credentials.workspace_id.clone()),
memberships: vec![session],
});
}
Err(error) if is_auth_rejection(&error) => {
auth_rejections.push(format!("{source} key rejected"));
if candidate.explicit_join {
return Err(error).context(format!(
"explicit workspace key from {} was rejected",
candidate.source
));
}
auth_rejections.push(format!("{} key rejected", candidate.source));
}
Err(error) if is_rate_limited(&error) => {
auth_rejections.push(format!("{source} key rate-limited"));
if candidate.explicit_join {
return Err(error).context(format!(
"explicit workspace key from {} was rate-limited",
candidate.source
));
}
auth_rejections.push(format!("{} key rate-limited", candidate.source));
}
Err(error) => {
return Err(error).context(format!(
"failed registering agent with {source} workspace key"
"failed registering agent with {} workspace key",
candidate.source
));
}
}
Expand Down Expand Up @@ -787,6 +816,33 @@ fn normalize_workspace_key(raw: &str) -> Option<String> {
}
}

fn env_workspace_key() -> Result<Option<EnvWorkspaceKey>> {
for name in ["AGENT_RELAY_WORKSPACE_KEY", "RELAY_WORKSPACE_KEY"] {
if let Ok(raw) = std::env::var(name) {
let trimmed = raw.trim();
if trimmed.is_empty() {
continue;
}
let key = normalize_workspace_key(trimmed)
.with_context(|| format!("{name} is not a valid workspace key"))?;
return Ok(Some(EnvWorkspaceKey {
source: name,
key,
explicit_join: true,
}));
}
}

Ok(std::env::var("RELAY_API_KEY")
.ok()
.and_then(|value| normalize_workspace_key(&value))
.map(|key| EnvWorkspaceKey {
source: "RELAY_API_KEY",
key,
explicit_join: false,
}))
}

fn is_auth_rejection(err: &anyhow::Error) -> bool {
auth_http_status(err)
.is_some_and(|status| status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN)
Expand Down Expand Up @@ -962,6 +1018,8 @@ mod tests {
// SAFETY: test-only; Rust warns about remove_var in multi-threaded
// contexts but we accept the risk in test code.
unsafe {
std::env::remove_var("AGENT_RELAY_WORKSPACE_KEY");
std::env::remove_var("RELAY_WORKSPACE_KEY");
std::env::remove_var("RELAY_API_KEY");
std::env::remove_var("RELAY_WORKSPACES_JSON");
std::env::remove_var("RELAY_DEFAULT_WORKSPACE");
Expand Down Expand Up @@ -1006,7 +1064,7 @@ mod tests {
let _env_guard = clear_relay_env();
let server = MockServer::start();
unsafe {
std::env::set_var("RELAY_API_KEY", "rk_live_env");
std::env::set_var("AGENT_RELAY_WORKSPACE_KEY", "rk_live_env");
}
let register = server.mock(|when, then| {
when.method(POST)
Expand All @@ -1024,6 +1082,108 @@ mod tests {
assert_eq!(session.credentials.api_key, "rk_live_env");
register.assert_hits(1);

unsafe {
std::env::remove_var("AGENT_RELAY_WORKSPACE_KEY");
}
}

#[tokio::test]
async fn rejected_explicit_workspace_key_does_not_create_workspace() {
let _env_guard = clear_relay_env();
let server = MockServer::start();
unsafe {
std::env::set_var("AGENT_RELAY_WORKSPACE_KEY", "rk_live_rejected");
}
let rejected_register = server.mock(|when, then| {
when.method(POST)
.path("/v1/agents")
.header("authorization", "Bearer rk_live_rejected");
then.status(401)
.header("content-type", "application/json")
.body(r#"{"ok":false,"error":{"code":"unauthorized","message":"unauthorized"}}"#);
});
let workspace = server.mock(|when, then| {
when.method(POST).path("/v1/workspaces");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"ok":true,"data":{"workspace_id":"ws_new","api_key":"rk_live_new","created_at":"2025-01-01T00:00:00Z"}}"#);
});

let client = AuthClient::new(server.base_url());
let error = client.startup_session(Some("lead")).await.unwrap_err();
assert!(
error
.to_string()
.contains("explicit workspace key from AGENT_RELAY_WORKSPACE_KEY was rejected"),
"unexpected error: {error:#}"
);
rejected_register.assert_hits(1);
workspace.assert_hits(0);

unsafe {
std::env::remove_var("AGENT_RELAY_WORKSPACE_KEY");
}
}

#[tokio::test]
async fn canonical_workspace_key_takes_precedence_over_legacy_api_key() {
let _env_guard = clear_relay_env();
let server = MockServer::start();
unsafe {
std::env::set_var("AGENT_RELAY_WORKSPACE_KEY", "rk_live_canonical");
std::env::set_var("RELAY_API_KEY", "rk_live_legacy");
}
let canonical_register = server.mock(|when, then| {
when.method(POST)
.path("/v1/agents")
.header("authorization", "Bearer rk_live_canonical");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"ok":true,"data":{"id":"a2","name":"lead","token":"at_live_2","status":"online","created_at":"2025-01-01T00:00:00Z"}}"#);
});
let legacy_register = server.mock(|when, then| {
when.method(POST)
.path("/v1/agents")
.header("authorization", "Bearer rk_live_legacy");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"ok":true,"data":{"id":"a3","name":"lead","token":"at_live_3","status":"online","created_at":"2025-01-01T00:00:00Z"}}"#);
});

let client = AuthClient::new(server.base_url());
let session = client.startup_session(Some("lead")).await.unwrap();
assert_eq!(session.credentials.api_key, "rk_live_canonical");
canonical_register.assert_hits(1);
legacy_register.assert_hits(0);

unsafe {
std::env::remove_var("AGENT_RELAY_WORKSPACE_KEY");
std::env::remove_var("RELAY_API_KEY");
}
}

#[tokio::test]
async fn legacy_relay_api_key_still_joins_existing_workspace() {
let _env_guard = clear_relay_env();
let server = MockServer::start();
unsafe {
std::env::set_var("RELAY_API_KEY", "rk_live_legacy");
}
let register = server.mock(|when, then| {
when.method(POST)
.path("/v1/agents")
.header("authorization", "Bearer rk_live_legacy");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"ok":true,"data":{"id":"a2","name":"lead","token":"at_live_2","status":"online","created_at":"2025-01-01T00:00:00Z"}}"#);
});

let client = AuthClient::new(server.base_url());

let session = client.startup_session(Some("lead")).await.unwrap();
assert_eq!(session.credentials.api_key, "rk_live_legacy");
register.assert_hits(1);

unsafe {
std::env::remove_var("RELAY_API_KEY");
}
Expand Down
Loading