Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The CLI and `@agent-relay/harness-driver` now resolve the broker in this order: `BROKER_BINARY_PATH` / `AGENT_RELAY_BIN`, then `~/.agentworkforce/relay/bin`, then the npm platform package, then `PATH`. A broker in the install directory now wins over one found on `PATH`.
- `install.sh` honours `AGENT_RELAY_HOME` as the install root when `AGENT_RELAY_INSTALL_DIR` is not set. A relocated install exports its root from the `agent-relay` launcher.
- `relay mcp` without `RELAY_AGENT_NAME` now registers each session as `<tool>-<project folder>` (for example `claude-myproject`, from the MCP client's name), adding a short suffix when another live session holds that name and taking the name back after a restart, instead of registering every session as `orchestrator`.
- The probe daemon now picks its workspace the way the CLI does: a workspace-key environment variable, then the project's pinned key (`AGENT_RELAY_PROJECT`), then the active workspace in `workspaces.json`, each sent only to the server `relaycast-origins.json` binds it to. Your Cloud sign-in is used only when the CLI selects none, so local and self-hosted `relay init` workspaces work without signing in. If the CLI's workspace files are malformed, the probe reports why and uses no workspace instead of falling back to the Cloud one.

### Removed

Expand Down
2 changes: 1 addition & 1 deletion crates/probe/docs/agent-relay-probe.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ so nothing written after the collector started waits for the backstop.
attach to yet), or `disabled` (`AGENT_RELAY_PROBE_FS_EVENTS=0` in the
collector's environment). If the store cannot be opened for the watch, the
thread runs the timer itself (`mode: timer`, `fallback_reason:
watch_unavailable`). Each change of driver is logged as one content-free
watch_unavailable`). Each change of driver is logged as one content-free
JSON line.

The detached collector (`run`, the session collector the relay core and
Expand Down
656 changes: 350 additions & 306 deletions crates/probe/docs/relay-core.md

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion crates/probe/src/relay/account/workspace_recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use super::*;
use crate::relay::store::{CloudWorkspace, Store};
use crate::relay::workspace::Workspace;
use crate::relay::workspace::{Workspace, WorkspaceSource};

impl Account {
/// Refreshes this app's retained Cloud session and re-resolves only the
Expand All @@ -16,6 +16,12 @@ impl Account {
crate::relay::workspace::Workspace,
crate::relay::client::WorkspaceInfo,
)> {
// Only the account's own key is recovered through the account: a
// key the CLI named is the CLI's to rotate, and is picked up from
// its files on the next refresh.
if !refused.source.is_cloud() {
return None;
}
let core = self.core()?;
if core.discover().as_ref() != Some(refused) {
return None;
Expand Down Expand Up @@ -62,6 +68,7 @@ impl Account {
key: resolved.key.clone(),
base_url: resolved.base_url.clone(),
workspace_id: resolved.workspace_id.clone(),
source: WorkspaceSource::Cloud,
};
// This is the sole safe retry; a mutation that triggered recovery is never replayed.
let info = crate::relay::client::workspace_info(&replacement)
Expand Down
45 changes: 39 additions & 6 deletions crates/probe/src/relay/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,9 @@ pub struct Core {
pub(crate) config: Config,
pub(crate) hub: Hub,
workspace: Mutex<Option<Workspace>>,
/// Why the last discovery could not use the CLI's workspace, once
/// reported, so the same reason is not repeated every pass.
discovery_problem: Mutex<Option<String>>,
push: tokio::sync::Mutex<PushManager>,
shutdown: watch::Sender<bool>,
pub(crate) machine_prefix: String,
Expand Down Expand Up @@ -302,6 +305,7 @@ impl Core {
config,
hub,
workspace: Mutex::new(None),
discovery_problem: Mutex::new(None),
push: tokio::sync::Mutex::new(push),
shutdown,
machine_prefix,
Expand Down Expand Up @@ -422,20 +426,44 @@ impl Core {
.activity(Level::Warning, notice.message(workspace.label()));
}

/// The signed-in account's workspace, as the agent store records it now.
/// The workspace this daemon uses now: the `agent-relay` CLI's when it
/// selects one, else the signed-in account's (`workspace::resolve`).
/// `None` when neither names one or the CLI's may not be used.
pub fn discover(&self) -> Option<Workspace> {
self.discovery().ok().flatten()
}

/// Like [`Core::discover`], with the printable reason the CLI's
/// selection may not be used.
fn discovery(&self) -> Result<Option<Workspace>, String> {
let env = |key: &str| self.config.env.get(key).cloned();
workspace::resolve(
app_store::load_cloud(&self.config.app_data_dir).as_ref(),
&env,
&self.config.home,
)
}

/// The local half of `refresh_workspace`: records the workspace
/// discovery finds now, and answers whether Relaycast is still to be
/// asked about it. Nothing here waits on the network.
pub fn rediscover_workspace(&self) -> bool {
let found = self.discover();
let (found, problem) = match self.discovery() {
Ok(found) => (found, None),
Err(problem) => (None, Some(problem)),
};
let new_problem = {
let mut reported = self.discovery_problem.lock().unwrap();
let new = problem.is_some() && *reported != problem;
*reported = problem.clone();
new
};
if let (true, Some(problem)) = (new_problem, &problem) {
self.hub.activity(
Level::Warning,
format!("Not using the agent-relay CLI's workspace: {problem}"),
);
}
let changed = {
let mut current = self.workspace.lock().unwrap();
let changed = *current != found;
Expand All @@ -449,13 +477,17 @@ impl Core {
Some(workspace) => self.hub.activity(
Level::Info,
format!(
"Using your account's Agent Relay workspace at {}.",
"Using the Agent Relay workspace from {} at {}.",
workspace.source.describe(),
workspace.base_url
),
),
None => self
.hub
.activity(Level::Warning, "Sign in to use an Agent Relay workspace."),
// A refused CLI selection was reported above.
None if problem.is_some() => {}
None => self.hub.activity(
Level::Warning,
"Run `relay init` or sign in to use an Agent Relay workspace.",
),
}
// The spawn node follows the workspace. At start the first agent
// sync can run before discovery, and nothing else would bring
Expand Down Expand Up @@ -1051,6 +1083,7 @@ mod key_migration_tests {
key: Secret::new(key),
base_url: "https://cast.test".into(),
workspace_id: None,
source: Default::default(),
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/probe/src/relay/desktop_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1193,6 +1193,7 @@ mod tests {
key: Secret::new(LOCAL_KEY),
base_url: CAST.into(),
workspace_id: Some("rw_cast".into()),
source: Default::default(),
};
let mut status = WorkspaceStatus::discovered(Some(&active));
let account_id = "rw_cast".to_string();
Expand Down
99 changes: 88 additions & 11 deletions crates/probe/src/relay/messaging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,30 @@ pub struct PersonIdentity {
pub base_url: String,
}

/// Whether `workspace` is the Relaycast workspace `identity` sends in. The
/// identity is minted for the signed-in account's Cloud workspace, whose
/// Relaycast key is `account_key`: the daemon's workspace is that one only
/// when it uses the same key on the identity's origin. A CLI workspace on
/// the same origin (two workspaces on `cast.agentrelay.com`) is another
/// workspace, so its roster proves nothing about your sends.
fn is_identity_workspace(
identity: &PersonIdentity,
workspace: &Workspace,
account_key: Option<&Secret>,
) -> bool {
workspace.base_url == identity.base_url && account_key == Some(&workspace.key)
}

/// The person identity is registered in the team Relaycast workspace, even
/// though it uses its own agent token. Fall back to that token only while no
/// matching team workspace is available (for example during startup).
/// matching team workspace is available (for example during startup, or
/// while the daemon uses a CLI workspace that is not the account's).
fn person_workspace_scope(
identity: &PersonIdentity,
workspace: Option<&Workspace>,
account_key: Option<&Secret>,
) -> WorkspaceScope {
match workspace.filter(|workspace| workspace.base_url == identity.base_url) {
match workspace.filter(|workspace| is_identity_workspace(identity, workspace, account_key)) {
Some(workspace) => {
WorkspaceScope::credential(&workspace.base_url, &workspace.key, "this workspace")
}
Expand Down Expand Up @@ -351,7 +367,8 @@ impl Core {
pub(crate) fn person_push_target(&self) -> Option<PushTarget> {
let identity = self.current_identity()?;
let team = self.workspace();
let workspace = person_workspace_scope(&identity, team.as_ref());
let account_key = self.account_workspace_key();
let workspace = person_workspace_scope(&identity, team.as_ref(), account_key.as_ref());
Some(PushTarget {
agent: person_channel(&identity.name),
base_url: identity.base_url,
Expand Down Expand Up @@ -425,16 +442,25 @@ impl Core {
Ok(sent)
}

/// Whether the workspace key's roster is the workspace your messages
/// go to: your identity sends on its origin and the account does not
/// use another workspace.
/// The signed-in account's Relaycast workspace key: the workspace your
/// relay identity sends in.
fn account_workspace_key(&self) -> Option<Secret> {
self.store
.read(|store| store.cloud.as_ref().map(|cloud| cloud.key.clone()))
}

/// Whether the daemon workspace's roster is the workspace your messages
/// go to: the same workspace (by key, not only by origin) as the
/// signed-in account your identity sends in. A CLI workspace that is
/// not the account's is never taken as proof about your sends.
pub(crate) async fn roster_is_messaging_workspace(&self) -> bool {
let Some(workspace) = self.workspace() else {
return false;
};
self.person_identity(false)
.await
.is_ok_and(|identity| identity.base_url == workspace.base_url)
let account_key = self.account_workspace_key();
self.person_identity(false).await.is_ok_and(|identity| {
is_identity_workspace(&identity, &workspace, account_key.as_ref())
})
}

/// The recipient of a send to `endpoint` and what Relaycast holds for
Expand Down Expand Up @@ -744,6 +770,7 @@ mod tests {
key: Secret::new("rk_team_workspace"),
base_url: "https://relay.example".into(),
workspace_id: Some("rw_team".into()),
source: Default::default(),
};
let person = PersonIdentity {
name: "person".into(),
Expand All @@ -752,7 +779,7 @@ mod tests {
base_url: team.base_url.clone(),
};
let team_scope = WorkspaceScope::credential(&team.base_url, &team.key, "team");
let person_scope = person_workspace_scope(&person, Some(&team));
let person_scope = person_workspace_scope(&person, Some(&team), Some(&team.key));
let gate = RelaycastBackoff::default();
gate.record_error(
&team_scope,
Expand All @@ -770,6 +797,51 @@ mod tests {
assert!(!gate.allowed(&person_scope));
}

#[test]
fn only_the_account_workspace_proves_anything_about_person_sends() {
let account = Workspace {
key: Secret::new("rk_live_account00"),
base_url: "https://cast.agentrelay.com".into(),
workspace_id: Some("rw_account".into()),
source: Default::default(),
};
let person = PersonIdentity {
name: "person".into(),
agent_id: "agent_person".into(),
token: Secret::new("at_person"),
base_url: account.base_url.clone(),
};
let account_key = Some(&account.key);
assert!(is_identity_workspace(&person, &account, account_key));
// The CLI's workspace B on the same origin as account A is another
// workspace: its roster says nothing about sends in A.
let cli = Workspace {
key: Secret::new("rk_live_cli_other"),
workspace_id: None,
source: crate::relay::workspace::WorkspaceSource::CliEnv("RELAY_WORKSPACE_KEY".into()),
..account.clone()
};
assert!(!is_identity_workspace(&person, &cli, account_key));
// The CLI naming the account's own key is the account's workspace.
let same = Workspace {
key: account.key.clone(),
..cli.clone()
};
assert!(is_identity_workspace(&person, &same, account_key));
// No account key, or another origin, proves nothing.
assert!(!is_identity_workspace(&person, &account, None));
let elsewhere = Workspace {
base_url: "http://127.0.0.1:8787".into(),
..account.clone()
};
assert!(!is_identity_workspace(&person, &elsewhere, account_key));
// Background calls as you are gated by your own token, not B's.
assert_eq!(
person_workspace_scope(&person, Some(&cli), account_key),
WorkspaceScope::credential(&person.base_url, &person.token, "your messaging workspace"),
);
}

#[test]
fn a_desktop_address_proves_its_conversation_by_the_agent_id_it_embeds() {
let address = "grok-relay-1d77b6@linux-host-233378063229464576";
Expand All @@ -787,7 +859,12 @@ mod tests {
None
);
assert_eq!(
endpoint_binding(&conversation, &[], "other@linux-host-233378063229464576", ME),
endpoint_binding(
&conversation,
&[],
"other@linux-host-233378063229464576",
ME
),
None
);
let own = format!("{ME}@linux-host-agent_person");
Expand Down
1 change: 1 addition & 0 deletions crates/probe/src/relay/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1880,6 +1880,7 @@ mod tests {
key: Secret::new(key.to_string()),
base_url: "http://localhost:9".into(),
workspace_id: id.map(str::to_string),
source: Default::default(),
};
let mut intent = interrupted("w1", true, None);
intent.workspace_id = Some("rw_1".into());
Expand Down
1 change: 1 addition & 0 deletions crates/probe/src/relay/spawn/control/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ fn selected_workspace() -> Workspace {
key: Secret::new("test-workspace-secret"),
base_url: "https://cast.example".into(),
workspace_id: None,
source: Default::default(),
}
}
#[test]
Expand Down
1 change: 1 addition & 0 deletions crates/probe/src/relay/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,7 @@ mod tests {
key: crate::relay::workspace::Secret::new("rk_live_secret0000"),
base_url: "https://cast.agentrelay.com".into(),
workspace_id: Some("rw_1".into()),
source: Default::default(),
};
let hub = hub();
hub.update(|status| status.workspace = WorkspaceStatus::discovered(Some(&workspace)));
Expand Down
Loading
Loading