diff --git a/CHANGELOG.md b/CHANGELOG.md index fcaf3a6013..45788366d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `-` (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 diff --git a/crates/probe/docs/agent-relay-probe.md b/crates/probe/docs/agent-relay-probe.md index 9434a2893b..116b7bc460 100644 --- a/crates/probe/docs/agent-relay-probe.md +++ b/crates/probe/docs/agent-relay-probe.md @@ -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 diff --git a/crates/probe/docs/relay-core.md b/crates/probe/docs/relay-core.md index ebc22eaa52..f4ac3cd86e 100644 --- a/crates/probe/docs/relay-core.md +++ b/crates/probe/docs/relay-core.md @@ -52,19 +52,19 @@ the daemon itself. The rule: code that draws pixels or calls a platform UI API stays in the app; everything else is in the core. -| Concern | Where | Why | -|---|---|---| -| Views, menu bar / tray, windows, dialogs, clipboard | UI | Pixels and platform UI APIs | -| Opening a browser (sign-in approval, integration connect links) | UI | `NSWorkspace` / `xdg-open` / `ShellExecute`. The core emits the URL on the `sign_in` event; the loopback OAuth receiver, PKCE and device-code polling are core | -| Notifications | UI | `UserNotifications` / `Gio.Notification` / Windows toast notifications. The core emits `notify` events with the text | -| macOS launch at login | UI | `SMAppService` is a platform API: `.agent` registers the core's LaunchAgent, `.mainApp` opens the app at login when the person chooses. Linux autostart is a `.desktop` file and is core (`login_item.set`) | -| Windows launch at login | UI | The background service is a per-user logon task (Task Scheduler, through `schtasks`, no administrator rights) that runs `agent-relay.exe --headless`; the app registers it, off, for a per-user install and turns it on and off from Settings, as it does the systemd user unit on Linux | -| Self-update | UI | It replaces the files the daemon runs from and relaunches the app, so it cannot run inside the daemon. macOS installs the DMG; a Linux per-user install unpacks the verified tarball beside the running version and repoints the systemd user unit, and a `.deb` install downloads the verified package for the package manager; a Windows per-user install unpacks the verified zip beside the running version and repoints the logon task | -| Wording, relative times, search filters, layout of status | UI | Presentation. The core supplies typed state (`uploader.stage`, `needs_start`, timestamps), never display strings | -| Deep-link delivery (`agentrelay://`) | UI receives, core parses | The platform hands the URL to the app; `links.parse` validates it | -| PostHog telemetry | Core | One schema for both apps. The core records the operations it runs (sign-in, uploader commands, disconnect); the UI calls `telemetry.record` for UI-only ones; queueing, rate limits, identity and opt-out (`settings.set {"analytics_enabled"}`) are core | -| Keychain (desktop mailbox credential) | Core | Not UI: `security-framework` on macOS, a 0600 file on Linux, the same file in the user profile on Windows | -| Bridge endpoint | Core | A Unix socket on macOS and Linux; on Windows a named pipe, `\\.\pipe\agent-relay-`, whose DACL admits only the current user (`relay::transport`) | +| Concern | Where | Why | +| --------------------------------------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Views, menu bar / tray, windows, dialogs, clipboard | UI | Pixels and platform UI APIs | +| Opening a browser (sign-in approval, integration connect links) | UI | `NSWorkspace` / `xdg-open` / `ShellExecute`. The core emits the URL on the `sign_in` event; the loopback OAuth receiver, PKCE and device-code polling are core | +| Notifications | UI | `UserNotifications` / `Gio.Notification` / Windows toast notifications. The core emits `notify` events with the text | +| macOS launch at login | UI | `SMAppService` is a platform API: `.agent` registers the core's LaunchAgent, `.mainApp` opens the app at login when the person chooses. Linux autostart is a `.desktop` file and is core (`login_item.set`) | +| Windows launch at login | UI | The background service is a per-user logon task (Task Scheduler, through `schtasks`, no administrator rights) that runs `agent-relay.exe --headless`; the app registers it, off, for a per-user install and turns it on and off from Settings, as it does the systemd user unit on Linux | +| Self-update | UI | It replaces the files the daemon runs from and relaunches the app, so it cannot run inside the daemon. macOS installs the DMG; a Linux per-user install unpacks the verified tarball beside the running version and repoints the systemd user unit, and a `.deb` install downloads the verified package for the package manager; a Windows per-user install unpacks the verified zip beside the running version and repoints the logon task | +| Wording, relative times, search filters, layout of status | UI | Presentation. The core supplies typed state (`uploader.stage`, `needs_start`, timestamps), never display strings | +| Deep-link delivery (`agentrelay://`) | UI receives, core parses | The platform hands the URL to the app; `links.parse` validates it | +| PostHog telemetry | Core | One schema for both apps. The core records the operations it runs (sign-in, uploader commands, disconnect); the UI calls `telemetry.record` for UI-only ones; queueing, rate limits, identity and opt-out (`settings.set {"analytics_enabled"}`) are core | +| Keychain (desktop mailbox credential) | Core | Not UI: `security-framework` on macOS, a 0600 file on Linux, the same file in the user profile on Windows | +| Bridge endpoint | Core | A Unix socket on macOS and Linux; on Windows a named pipe, `\\.\pipe\agent-relay-`, whose DACL admits only the current user (`relay::transport`) | ## Transport @@ -137,7 +137,7 @@ watchdog exposes retry and diagnostics if the service has not become ready. approval the app opens **System Settings › General › Login Items**. It then connects with backoff while launchd starts the core. Agent Relay Dev registers `com.agentrelay.desktop.dev.core` with `--socket core.dev.sock - --app-data-dir com.agentrelay.desktop.dev --site-url `. The agent +--app-data-dir com.agentrelay.desktop.dev --site-url `. The agent stays registered across sign-out: agents on this Mac's relay workspace need no Agent Relay sign-in. With **Settings › General › Keep agents connected after quitting** off (it is on by default), quitting the app unregisters it, @@ -190,8 +190,8 @@ handler cannot return anything but its registered type, and parameters are read before a handler runs. The Swift client's types are generated from the registry, never written by hand: -| Generated | For | Shape | -|---|---|---| +| Generated | For | Shape | +| --------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `macos/Sources/RelayDesktopCore/CoreContract.swift` | macOS | Plain `Codable` structs and enums, the method names (`CoreMethod`), `CoreEvent`, and one typed `CoreClient` call per pooled method (`client.agentsList()`); `CoreClient` sends the connection-scoped methods (`hello`, `subscribe`, `unsubscribe`, `daemon.shutdown`) itself | `scripts/generate-contract.sh` regenerates it (`cargo run --bin @@ -220,30 +220,30 @@ still refuses unknown values in parameters. `data.kind` is the stable code clients branch on; `message` is a sentence safe to show a person. Messages never carry provider bodies, keys or tokens. -| kind | code | retryable | Meaning | -|---|---|---|---| -| `parse_error` | -32700 | no | The line is not JSON (`id` is null) | -| `invalid_request` | -32600 | no | Not JSON-RPC 2.0, or the line is too long | -| `method_not_found` | -32601 | no | No such method | -| `invalid_params` | -32602 | no | Parameters do not match the method | -| `internal` | -32603 | no | A defect in the core, or a Relaycast response it cannot read | -| `handshake_required` | -32000 | no | `hello` first | -| `bridge_version_mismatch` | -32000 | no | Client and daemon speak different bridge versions | -| `not_signed_in` | -32000 | no | No workspace key is known | -| `refused` | -32000 | no | The core refuses on principle (for example a non-https origin) | -| `relaycast_unreachable` | -32000 | yes | Relaycast could not be reached or failed on its side (5xx) | -| `relaycast_refused` | -32000 | no | Relaycast refused the request as made (other 4xx, such as 400 or 422, or plan quota exhaustion) | -| `unauthorized` | -32000 | no | Relaycast rejected the credential | -| `rate_limited` | -32000 | yes | Relaycast is temporarily rate limiting | -| `not_found` | -32000 | no | Relaycast, or this computer, has no such resource (an address no agent is at, an agent not registered here) | -| `conflict` | -32000 | no | A conflicting change (a name taken in the workspace or by another session here) | -| `shutting_down` | -32000 | yes | The daemon is stopping | -| `busy` | -32000 | yes | Another change to the same agent or session is still running | -| `store_failed` | -32000 | no | The agent store could not be saved, so nothing changed | -| `fleet_unavailable` | -32000 | yes | Whether this computer's relay already manages the session could not be read, so it is not registered now | -| `cloud_failed` | -32000 | yes | Agent Relay Cloud (or relayfile) could not be reached or failed | -| `cloud_refused` | -32000 | no | Agent Relay Cloud refused the request; `message` is its own text when it sent one | -| `uploader_failed` | -32000 | no | The probe's uploader command failed or ran out of time; `message` is the probe's own sentence | +| kind | code | retryable | Meaning | +| ------------------------- | ------ | --------- | ----------------------------------------------------------------------------------------------------------- | +| `parse_error` | -32700 | no | The line is not JSON (`id` is null) | +| `invalid_request` | -32600 | no | Not JSON-RPC 2.0, or the line is too long | +| `method_not_found` | -32601 | no | No such method | +| `invalid_params` | -32602 | no | Parameters do not match the method | +| `internal` | -32603 | no | A defect in the core, or a Relaycast response it cannot read | +| `handshake_required` | -32000 | no | `hello` first | +| `bridge_version_mismatch` | -32000 | no | Client and daemon speak different bridge versions | +| `not_signed_in` | -32000 | no | No workspace key is known | +| `refused` | -32000 | no | The core refuses on principle (for example a non-https origin) | +| `relaycast_unreachable` | -32000 | yes | Relaycast could not be reached or failed on its side (5xx) | +| `relaycast_refused` | -32000 | no | Relaycast refused the request as made (other 4xx, such as 400 or 422, or plan quota exhaustion) | +| `unauthorized` | -32000 | no | Relaycast rejected the credential | +| `rate_limited` | -32000 | yes | Relaycast is temporarily rate limiting | +| `not_found` | -32000 | no | Relaycast, or this computer, has no such resource (an address no agent is at, an agent not registered here) | +| `conflict` | -32000 | no | A conflicting change (a name taken in the workspace or by another session here) | +| `shutting_down` | -32000 | yes | The daemon is stopping | +| `busy` | -32000 | yes | Another change to the same agent or session is still running | +| `store_failed` | -32000 | no | The agent store could not be saved, so nothing changed | +| `fleet_unavailable` | -32000 | yes | Whether this computer's relay already manages the session could not be read, so it is not registered now | +| `cloud_failed` | -32000 | yes | Agent Relay Cloud (or relayfile) could not be reached or failed | +| `cloud_refused` | -32000 | no | Agent Relay Cloud refused the request; `message` is its own text when it sent one | +| `uploader_failed` | -32000 | no | The probe's uploader command failed or ran out of time; `message` is the probe's own sentence | Provider `plan_limit_exceeded` responses (including HTTP 429) are reported as `relaycast_refused`, with a safe instruction to upgrade the workspace plan @@ -282,20 +282,39 @@ The UI renders one snapshot, returned by `status.get` and by `subscribe`: ```json { "revision": 7, - "daemon": {"version": "2026.10.1", "bridge_version": 2, "pid": 4242, - "started_at_ms": 1790683200000, "socket": "/Users/a/.agentworkforce/desktop/core.sock"}, - "machine": {"host_name": "Alice's MacBook Pro.local", "machine_prefix": "alice-s-macbook-pro"}, - "workspace": {"state": "found", "base_url": "https://cast.agentrelay.com", - "workspace_id": "rw_7ccfea89", "workspace_name": "Acme", - "verification": "verified", "checked_at_ms": 1790683201000}, - "push": {"codex-api-3f72": {"state": "live"}, - "claude-web-9a1b": {"state": "waiting", "retry_in_ms": 3712}}, - "agents": {"store_error": null, "delivery_error": null}, - "account": {"state": "signed_in", "phase": null, "error": null, "action": null, - "reused_login": null, "site_url": "https://agentrelay.com", - "workspace_id": "0f8fad5b-d9cb-469f-a165-70867728950e", "workspace_name": "Acme", - "person": {"id": "user_1", "name": "Ada", "email": "ada@example.com", "avatar_url": null}, - "progress": null} + "daemon": { + "version": "2026.10.1", + "bridge_version": 2, + "pid": 4242, + "started_at_ms": 1790683200000, + "socket": "/Users/a/.agentworkforce/desktop/core.sock" + }, + "machine": { "host_name": "Alice's MacBook Pro.local", "machine_prefix": "alice-s-macbook-pro" }, + "workspace": { + "state": "found", + "base_url": "https://cast.agentrelay.com", + "workspace_id": "rw_7ccfea89", + "workspace_name": "Acme", + "verification": "verified", + "checked_at_ms": 1790683201000 + }, + "push": { + "codex-api-3f72": { "state": "live" }, + "claude-web-9a1b": { "state": "waiting", "retry_in_ms": 3712 } + }, + "agents": { "store_error": null, "delivery_error": null }, + "account": { + "state": "signed_in", + "phase": null, + "error": null, + "action": null, + "reused_login": null, + "site_url": "https://agentrelay.com", + "workspace_id": "0f8fad5b-d9cb-469f-a165-70867728950e", + "workspace_name": "Acme", + "person": { "id": "user_1", "name": "Ada", "email": "ada@example.com", "avatar_url": null }, + "progress": null + } } ``` @@ -327,14 +346,14 @@ Events are notifications on the connection that subscribed: {"jsonrpc":"2.0","method":"event","params":{"topic":"status","seq":12,"data":{…snapshot…}}} ``` -| Topic | data | Notes | -|---|---|---| -| `status` | The whole snapshot | After every change. Replace, never merge; ignore one whose `revision` is not newer than what is shown | -| `activity` | `{"level":"info"\|"warning"\|"error","message":"…","at_ms":…}` | Append-only log lines | -| `shutdown` | `{}` | Sent to every greeted client, subscribed or not; the connection then closes | +| Topic | data | Notes | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `status` | The whole snapshot | After every change. Replace, never merge; ignore one whose `revision` is not newer than what is shown | +| `activity` | `{"level":"info"\|"warning"\|"error","message":"…","at_ms":…}` | Append-only log lines | +| `shutdown` | `{}` | Sent to every greeted client, subscribed or not; the connection then closes | | `invalidated` | `{"what":"sessions"\|"agents"\|"roster"\|"fleet"\|"messages"\|"people"\|"activity"\|"uploader"\|"chat"\|"projects"\|"incoming"\|"settings"}` | Re-fetch that collection (2a; `sessions`, `fleet` 2b — compared every 5 s —; `uploader`, `chat`, `projects`, `incoming` 2c; `settings` when an agent changed one through the session socket's `PATCH /settings`) | -| `sign_in` | `status.account` plus `{"verification_url","verification_url_complete","user_code","expires_at","interval"}` (null when no link is waiting) | After every sign-in step; the UI opens `verification_url_complete` when it appears (2c) | -| `notify` | `{"title","body","link"}` | The UI shows a notification; `link` (nullable) opens on click, e.g. a provider's connect page (2a) | +| `sign_in` | `status.account` plus `{"verification_url","verification_url_complete","user_code","expires_at","interval"}` (null when no link is waiting) | After every sign-in step; the UI opens `verification_url_complete` when it appears (2c) | +| `notify` | `{"title","body","link"}` | The UI shows a notification; `link` (nullable) opens on click, e.g. a provider's connect page (2a) | `seq` increases by one per event across all topics, so a client sees gaps for topics it is not subscribed to. A client that falls more than 256 events @@ -348,27 +367,27 @@ this document describes the finished system. **Core** -| Method | Params | Result | Lands in | -|---|---|---|---| -| `hello` | `{"bridge_version":2}` | `{"bridge_version","daemon_version","pid","methods":[…],"topics":[…]}` | 1 | -| `diagnostics.requests` | — | `{"entries":[RequestEntry]}`; local bounded request log, newest first, redacted request/response, status, timing and stable ID; memory only, no telemetry | 1 | -| `status.get` | — | The snapshot | 1 | -| `subscribe` | `{"topics":["status","activity",…]}` | `{"topics":[…],"status":{…snapshot…}}` | 1 | -| `unsubscribe` | `{"topics":[…]}` | `{"topics":[…remaining]}` | 1 | -| `workspace.refresh` | — | `status.workspace` after rediscovery (and verification when the key changed or was unverified) | 1 | -| `workspace.verify` | — | `status.workspace`; `GET /v1/workspace` with the key | 1 | -| `daemon.shutdown` | — | `{"stopping":true}`; allowed before `hello` | 1 | +| Method | Params | Result | Lands in | +| ---------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `hello` | `{"bridge_version":2}` | `{"bridge_version","daemon_version","pid","methods":[…],"topics":[…]}` | 1 | +| `diagnostics.requests` | — | `{"entries":[RequestEntry]}`; local bounded request log, newest first, redacted request/response, status, timing and stable ID; memory only, no telemetry | 1 | +| `status.get` | — | The snapshot | 1 | +| `subscribe` | `{"topics":["status","activity",…]}` | `{"topics":[…],"status":{…snapshot…}}` | 1 | +| `unsubscribe` | `{"topics":[…]}` | `{"topics":[…remaining]}` | 1 | +| `workspace.refresh` | — | `status.workspace` after rediscovery (and verification when the key changed or was unverified) | 1 | +| `workspace.verify` | — | `status.workspace`; `GET /v1/workspace` with the key | 1 | +| `daemon.shutdown` | — | `{"stopping":true}`; allowed before `hello` | 1 | **Workspace and account** -| Method | Params | Result | Lands in | -|---|---|---|---| -| `account.status` | — | As the `sign_in` topic: `status.account` plus the waiting link's fields, null when none is waiting | 2c | -| `account.sign_in` | `{"site_url"?, "workspace"?, "sharing":"all"\|"new"\|"selected", "acknowledge_uninspected_schedules"?, "flow"?:"browser"\|"device"}` | As `account.status`, at once; the attempt continues in the background and reports on `sign_in`. While one is running, or once signed in, answers where it stands; signed in with no session the core can call Cloud with as the person (the official CLI's it borrowed expired, or Cloud refused the kept one), it asks the person again (`flow` as above, the browser reusing a signed-in browser) and keeps the session, leaving the uploader's connection alone; an approval as another person than the connection's keeps nothing | 2c | -| `account.select_workspace` | `{"workspace_id":""}` | `{}`; completes the native `selecting_workspace` phase using one of `account.workspace_choices`. Setup waits for this choice; cancellation leaves the workspace unconnected | 2c | -| `account.sign_in.cancel` | — | `{}`; a sign-in that has not finished leaves nothing behind: the Cloud session and workspace key it kept go back to what they were (a reused kept session stays), and a connection its `cloud install` made is disconnected (as `account.sign_out` does), with nothing attaching it meanwhile | 2c | -| `account.sign_out` | — | `{}`; forgets and revokes the Cloud session, stops every uploader command, disconnects this computer's install, deactivates the mailbox; `uploader_failed` when the disconnect did not finish (the next sign-in retries it first) | 2c | -| `account.connect_workspace` | `{"workspace_id":""}` | `status.workspace`; Cloud `/workspaces/{id}/resolve` with the kept session, else after a browser sign-in (instant in a signed-in browser) | 2c | +| Method | Params | Result | Lands in | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `account.status` | — | As the `sign_in` topic: `status.account` plus the waiting link's fields, null when none is waiting | 2c | +| `account.sign_in` | `{"site_url"?, "workspace"?, "sharing":"all"\|"new"\|"selected", "acknowledge_uninspected_schedules"?, "flow"?:"browser"\|"device"}` | As `account.status`, at once; the attempt continues in the background and reports on `sign_in`. While one is running, or once signed in, answers where it stands; signed in with no session the core can call Cloud with as the person (the official CLI's it borrowed expired, or Cloud refused the kept one), it asks the person again (`flow` as above, the browser reusing a signed-in browser) and keeps the session, leaving the uploader's connection alone; an approval as another person than the connection's keeps nothing | 2c | +| `account.select_workspace` | `{"workspace_id":""}` | `{}`; completes the native `selecting_workspace` phase using one of `account.workspace_choices`. Setup waits for this choice; cancellation leaves the workspace unconnected | 2c | +| `account.sign_in.cancel` | — | `{}`; a sign-in that has not finished leaves nothing behind: the Cloud session and workspace key it kept go back to what they were (a reused kept session stays), and a connection its `cloud install` made is disconnected (as `account.sign_out` does), with nothing attaching it meanwhile | 2c | +| `account.sign_out` | — | `{}`; forgets and revokes the Cloud session, stops every uploader command, disconnects this computer's install, deactivates the mailbox; `uploader_failed` when the disconnect did not finish (the next sign-in retries it first) | 2c | +| `account.connect_workspace` | `{"workspace_id":""}` | `status.workspace`; Cloud `/workspaces/{id}/resolve` with the kept session, else after a browser sign-in (instant in a signed-in browser) | 2c | Sign-in (`relay::account`): @@ -416,19 +435,19 @@ forgets it; a rate limit, timeout or outage keeps it for a later try. **Uploader** — the probe's own commands, run as this binary -| Method | Params | Result | Lands in | -|---|---|---|---| -| `uploader.status` | — | `{"running","paused","sharing_mode","directory","collector_replacing","collector_outdated","delivery":{"bootstrap_complete","pending_records","acknowledged_records","skipped_records","unqueued_changes","last_acknowledged_ms","failure"},"sessions":{"total","shared","excluded"},"stage","stage_message","needs_start","journal_full","upgrading"}`, read from the probe's `status --json`; `stage` is `not_running`\|`paused`\|`needs_attention`\|`offline`\|`preparing`\|`uploading`\|`up_to_date`, `stage_message` the probe's sentence for that stage when it has one, `directory` the install's (it holds `collector.log`); `not_signed_in` when nothing is connected | 2c, 3 (`directory`) | -| `uploader.start` / `uploader.pause` / `uploader.resume` / `uploader.retry` / `uploader.compact` / `uploader.disconnect` | — | `{"running","started"}`, `{"paused"}`, `{"paused"}`, `{"retried"}`, `{"removed_records"}`, `{"disconnected"}`, from the `--json` commands. `uploader.retry` restarts a job a non-retryable verdict blocked, or wakes an active job waiting out a `transient` failure's backoff (its attempt count is kept); `retried` is `true` when it did either. A paused or cancelled job, a batch in flight and a `rate_limited` wait (the receiver asked for less traffic, including when a sibling batch of the same window then failed transiently) are left as they are and answer `false`. Both apps offer Retry for a stop and for a lasting `transient` failure (`stage` `needs_attention`), and send a Retry or Compact the core never received (the bridge's "starting"/"reconnecting" failure) once on their own when the core next answers | 2c | -| `uploader.sharing.set` | `{"mode":"all"\|"new"\|"selected"}` | `{"sharing_mode"}` | 2c | -| `uploader.sessions.list` | `{"limit"?}` | `{"sharing_mode","sessions":[…]}` | 2c | -| `uploader.sessions.query` | `{"query"?,"sort"?,"ascending"?,"cursor"?,"known_revision"?,"limit"?,"hidden"?}` | `{"sessions":[…],"local_sessions":[…],"archived_keys"?,"next_cursor","revision","candidates_scanned","unchanged","verification_pending"}`; indexed local catalog query with at most 200 candidate rows and a 256 KiB page limit. Optional `archived_keys` contains snapshot classification for only returned rows (at most 200 keys), and counts toward the same encoded page budget. Follow `next_cursor` even after an empty page; `verification_pending` requests a retry without skipping unresolved upload proofs. Cursors retain a bounded recent snapshot and expire explicitly. `known_revision` may suppress a complete unchanged page; incomplete verification must be retried. Global Reasoning sorting is unavailable until that metadata is indexed. | paged local sessions | -| `uploader.sessions.facets` | `{"cursor"?,"limit"?}` | `{"harnesses":[…],"projects":[…],"next_cursor","revision"}`; catalog-wide filter suggestions, independent of the visible session page. Each reply contains at most 200 values and 64 KiB including its cursor. Follow `next_cursor` to complete the suggestions; cursors track the catalog epoch and facet membership, so appends that leave both harness and project membership unchanged do not invalidate them. | paged local sessions | -| `uploader.sessions.preview` | `{"limit"?,"refresh"?}` | `{"sessions":[…]}` | 2c | -| `uploader.sessions.delete` | `{"session_keys":[…]}` | `{"updated":n}` | 2c | -| `uploader.sessions.reset` | `{"confirmation":"DELETE ALL SESSIONS"}` | `{"reset":true}` | 2c | -| `uploader.sessions.include` / `.exclude` | `{"session_keys":[…]}` | `{"updated":n}` | 2c | -| `uploader.sessions.delete` | `{"session_keys":[…]}` | `{"updated":n}`; confirmed deletion of this account’s uploaded copy and delegated subagents, retaining local history. A durable plan reconciles interrupted requests; re-inclusion explicitly clears the server deletion fence. | 2c | +| Method | Params | Result | Lands in | +| ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------- | +| `uploader.status` | — | `{"running","paused","sharing_mode","directory","collector_replacing","collector_outdated","delivery":{"bootstrap_complete","pending_records","acknowledged_records","skipped_records","unqueued_changes","last_acknowledged_ms","failure"},"sessions":{"total","shared","excluded"},"stage","stage_message","needs_start","journal_full","upgrading"}`, read from the probe's `status --json`; `stage` is `not_running`\|`paused`\|`needs_attention`\|`offline`\|`preparing`\|`uploading`\|`up_to_date`, `stage_message` the probe's sentence for that stage when it has one, `directory` the install's (it holds `collector.log`); `not_signed_in` when nothing is connected | 2c, 3 (`directory`) | +| `uploader.start` / `uploader.pause` / `uploader.resume` / `uploader.retry` / `uploader.compact` / `uploader.disconnect` | — | `{"running","started"}`, `{"paused"}`, `{"paused"}`, `{"retried"}`, `{"removed_records"}`, `{"disconnected"}`, from the `--json` commands. `uploader.retry` restarts a job a non-retryable verdict blocked, or wakes an active job waiting out a `transient` failure's backoff (its attempt count is kept); `retried` is `true` when it did either. A paused or cancelled job, a batch in flight and a `rate_limited` wait (the receiver asked for less traffic, including when a sibling batch of the same window then failed transiently) are left as they are and answer `false`. Both apps offer Retry for a stop and for a lasting `transient` failure (`stage` `needs_attention`), and send a Retry or Compact the core never received (the bridge's "starting"/"reconnecting" failure) once on their own when the core next answers | 2c | +| `uploader.sharing.set` | `{"mode":"all"\|"new"\|"selected"}` | `{"sharing_mode"}` | 2c | +| `uploader.sessions.list` | `{"limit"?}` | `{"sharing_mode","sessions":[…]}` | 2c | +| `uploader.sessions.query` | `{"query"?,"sort"?,"ascending"?,"cursor"?,"known_revision"?,"limit"?,"hidden"?}` | `{"sessions":[…],"local_sessions":[…],"archived_keys"?,"next_cursor","revision","candidates_scanned","unchanged","verification_pending"}`; indexed local catalog query with at most 200 candidate rows and a 256 KiB page limit. Optional `archived_keys` contains snapshot classification for only returned rows (at most 200 keys), and counts toward the same encoded page budget. Follow `next_cursor` even after an empty page; `verification_pending` requests a retry without skipping unresolved upload proofs. Cursors retain a bounded recent snapshot and expire explicitly. `known_revision` may suppress a complete unchanged page; incomplete verification must be retried. Global Reasoning sorting is unavailable until that metadata is indexed. | paged local sessions | +| `uploader.sessions.facets` | `{"cursor"?,"limit"?}` | `{"harnesses":[…],"projects":[…],"next_cursor","revision"}`; catalog-wide filter suggestions, independent of the visible session page. Each reply contains at most 200 values and 64 KiB including its cursor. Follow `next_cursor` to complete the suggestions; cursors track the catalog epoch and facet membership, so appends that leave both harness and project membership unchanged do not invalidate them. | paged local sessions | +| `uploader.sessions.preview` | `{"limit"?,"refresh"?}` | `{"sessions":[…]}` | 2c | +| `uploader.sessions.delete` | `{"session_keys":[…]}` | `{"updated":n}` | 2c | +| `uploader.sessions.reset` | `{"confirmation":"DELETE ALL SESSIONS"}` | `{"reset":true}` | 2c | +| `uploader.sessions.include` / `.exclude` | `{"session_keys":[…]}` | `{"updated":n}` | 2c | +| `uploader.sessions.delete` | `{"session_keys":[…]}` | `{"updated":n}`; confirmed deletion of this account’s uploaded copy and delegated subagents, retaining local history. A durable plan reconciles interrupted requests; re-inclusion explicitly clears the server deletion fence. | 2c | The uploader commands are the probe's own `--json` commands, run as child processes of this very binary, so there is no version skew. They stay @@ -458,72 +477,72 @@ uploads already; an install a newer probe runs is left alone. **Agents on this computer** -| Method | Params | Result | Lands in | -|---|---|---|---| -| `sessions.transcript` | `{"source","session_id","cursor"?:{"ts_ms","id"},"following"?:false}` | `{"entries":[{"id","role","kind","text","timestamp_ms","truncated"}],"next_cursor"}`; local-only paged conversation. First page hydrates one session in a private reader cache, independent of upload selection. Up to 100 events, 64 KiB per entry and 512 KiB serialized entries per page; oversized text is marked truncated. Requires the account plugin, not an upload. | 2c | -| `sessions.list` | — | `{"sessions":[{"target":{"kind":"codex"\|"claude-terminal"\|"claude-cloud"\|"grok"\|"grok-open"\|"opencode","id"},"title","project","directory","live","updated_at_ms","registration":{"name","address","added_by_session","push","spawn"?:{"harness","can_take_over","taken_over"}}\|null,"suggested_name"}]}`; `spawn` is set on an agent Agent Relay spawned here (see `agents.take_over`); a registered session discovery no longer finds is listed with `live:false`; Grok/OpenCode rows come only from the verified probe-owned registry, plus `grok-open` rows from the already-open registry when `AGENT_RELAY_ENABLE_EXISTING_GROK=1`; a Grok session both registries claim is not listed under either kind | 2b | -| `sessions.title.capability` | `{"source","session_id"}` | `{"supported","reason"}` (`reason` is null when supported); native title-edit support for the exact catalog identity, including history-only rows; unsupported providers or unavailable sessions include an explanation. Available without an account. | native session titles | -| `sessions.title.set` | `{"source","session_id","title"}` | `{"title"}` after the native provider acknowledges the metadata change; trims outer whitespace, accepts 1–200 characters without control characters, and applies provider-specific limits. Uses trusted discovery to resolve the catalog identity; accepts no client paths or endpoints. Publishes sessions/uploader invalidations on success. Available without an account. See native title support (relay-desktop `session-title-editing.md`). | native session titles | -| `sessions.managed.launch` | `{"kind":"grok"\|"opencode","executable","directory","session_id"}` | `{"target","title","directory"}` after the default-off probe has launched/supervised the endpoint and verified OS identity plus provider readback; endpoint, pid, socket and credentials are never accepted | managed provider bridge | -| `sessions.existing.register` | `{"kind":"grok-open","session_id","directory"}` | `{"target","title","directory"}` after the default-off probe (`AGENT_RELAY_ENABLE_EXISTING_GROK=1`) has found exactly one marker-free Grok leader of this user from the kernel, proved its executable, argv and socket peer, and read back model/version from a `session/load` of that session in `directory`; a session or leader the managed registry holds is refused; process, socket, endpoint and credentials are never accepted | already-open Grok route | -| `agents.list` | — | `{"agents":[{"name","address","target","title","registered_at","added_by_session","reply_with_final_answer","webhook":{"id","url"}\|null,"integrations":[{"provider","resource","ready"}],"push","delivery_error"}]}` | 2a | -| `agents.register` | `{"target":{"kind","id"},"name"?,"description"?}` | `{"name","address","already_registered"}`; a session already registered answers `already_registered: true`; otherwise it must be one `sessions.list` finds live (`refused`); without `name`, its suggested name | 2a | -| `agents.unregister` | `{"name"}` | `{}`; waits for a delivery in flight; refused while integrations remain | 2a | -| `agents.rename` | `{"name","new_name"}` (`name` may be either of the agent's names) | `{"name","address","released"}`: the session's new and only name, and the earlier name it replaced; renaming back to the registered name makes it current again | 2a | -| `agents.test_message` | `{"name","text"?}` | `{"message_id"}`; sent as you when signed in, else as this computer's test sender | 2a | -| `agents.transcript` | `{"name","cursor"?:{"ts_ms","id"}}` | `sessions.transcript`'s page plus `follow_cursor`, where what is written next starts: what a spawned agent whose `registration.spawn.can_take_over` is set has said, from its own output (a spawned Claude's stream-json log, `/spawned-agents/.jsonl`), read from the byte offset in `cursor.id`; a line still being written waits for the next read. `refused` for an agent without a transcript of its own (a spawned Codex thread: read its session with `sessions.transcript`), `not_found` for an unknown name | native spawn | -| `agents.take_over` | `{"name","interrupt"?}` | `{"name","target","directory","interrupted"}` once the spawned agent's headless process has stopped, so the session is the caller's to resume (`claude --resume ` in `directory`): exactly one process runs a session. Without `interrupt` it first waits up to 10 minutes for a running turn to end (`busy` after); a second call with `interrupt` while one waits makes it stop the agent at once, and both answer alike. The agent keeps its name, address and place; its process becomes whichever one runs the session, and releasing it leaves that process alone. Taking over one already taken over answers again without stopping anything; `refused` for a harness that cannot be taken over (Codex: open its thread in Codex) | native spawn | -| `agents.reply_setting` | `{"name","enabled":true\|false\|null}` | `{}`; `null` answers automatically only when the session cannot reply itself | 2a | -| `agents.webhook.create` | `{"name"}` | `{"id","url","secret"\|null,"created"}`; the secret only to the call that created it | 2a | -| `agents.webhook.revoke` | `{"name"}` | `{"already_deleted"}` | 2a | -| `agents.integrations.subscribe` | `{"name","provider","resource"}` | `{"subscribed":true,"provider","resource","path_glob"}` | 2a | -| `agents.integrations.unsubscribe` / `.clean_up` | `{"name","provider","resource"}` | `{"subscribed":false,"provider","resource","already_unsubscribed"?}`; also finishes an unfinished subscription | 2a | -| `settings.get` / `settings.set` | `{"allow_self_registration"?, "auto_activate"?, "claude_session_hook"?, "direct_delivery"?, "direct_delivery_default"?, "devin_direct_delivery"?, "analytics_enabled"?, "contractor_access"?}` | All settings, plus the read-only `direct_delivery_managed` (the organization's `crossSessionInbound`, absent when it sets none); `direct_delivery_default` is write-only: `true` sets `accept` unless the user's own Claude settings already name `crossSessionInbound`, `false` changes nothing; `claude_session_hook` is off unless turned on and absent when no Claude login directory exists (the SessionStart hook never registers a session); `devin_direct_delivery` adds or removes Agent Relay's lifecycle hooks in `~/.config/devin/config.json`, on by default where Devin has run, and absent where it has not; a setting the core runs without is `invalid_params`, and so are an unknown or read-only key and a non-boolean, which change nothing | 2a (store settings), 2b (`direct_delivery`, `claude_session_hook`, `devin_direct_delivery`), 2c (`analytics_enabled`, `contractor_access`) | -| `activity.list` | `{"limit"?}` | `{"activity":[{"at_ms","agent","sender","preview","outcome":"injected"\|"skipped"\|"failed"\|"waiting"\|"in_doubt"\|"answered","detail","settlement":"handed_off"\|"waiting_for_turn"\|"visible"\|null}]}`, newest first; `settlement` is set on entries that place the message in the session (a queue handoff, a handoff behind the session's running turn, or the message recorded in the session) and includes Relay Connect deliveries under the participant's name | 2a | -| `claude_delivery.status` | — | `{"restricted","sessions":[{"id","agent","title","direct"}],"allowed_senders":[{"address","name"}],"held":[{"id","agent","session_id","sender","sender_name","text","held_at_ms"}]}` | 2b | -| `claude_delivery.restrict` / `.session` / `.sender` | `{"restricted"}` / `{"session_id","direct"}` / `{"sender","name","allowed"}` | `{}`; controls allowed-member enforcement, per-session Ask first, and the exact sender allowlist | 2b | -| `claude_delivery.approve` / `.dismiss` | `{"id"}` | `{"changed"}`; `false` when the held delivery is gone | 2b | -| `openclaw.relay_tools.status` | — | `{"state":"unavailable"\|"not_connected"\|"waiting"\|"connected","detail"}`: whether the person's OpenClaw agents can use the hosted Agent Relay MCP. `waiting` while an `openclaw mcp login` Agent Relay started waits for the browser; otherwise what `openclaw mcp status --json` says of the `mcp.servers` entry at `/cloud/api/v1/mcp/shared-sessions`, reused for 30 s. `detail` says why it is `unavailable` (OpenClaw off or not set up, a Gateway older than 2026.8.1 that cannot limit relay sessions — even when signed in —, a Gateway that cannot be reached to check (a core that has not met it shakes hands first), its CLI not found, no `mcp login`, an `agent_relay` entry at another address); an entry saved with `enabled: false` is `not_connected`, or why the last sign-in failed (`not_connected`). See OpenClaw and Hermes delivery (relay-desktop `openclaw-hermes-delivery.md`) | OpenClaw | -| `openclaw.relay_tools.connect` | — | `{"state":"waiting"\|"connected","authorization_url"}`: adds `agent_relay` with `openclaw mcp add … --auth oauth --no-probe` unless an entry already has the URL (an `agent_relay` at another address is `refused`, never overwritten), then starts `openclaw mcp login ` and answers with the consent page to open; `connected` and no URL when OpenClaw was already signed in. A login already waiting answers with its page again. Failures are `refused` with the words to show | OpenClaw | +| Method | Params | Result | Lands in | +| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `sessions.transcript` | `{"source","session_id","cursor"?:{"ts_ms","id"},"following"?:false}` | `{"entries":[{"id","role","kind","text","timestamp_ms","truncated"}],"next_cursor"}`; local-only paged conversation. First page hydrates one session in a private reader cache, independent of upload selection. Up to 100 events, 64 KiB per entry and 512 KiB serialized entries per page; oversized text is marked truncated. Requires the account plugin, not an upload. | 2c | +| `sessions.list` | — | `{"sessions":[{"target":{"kind":"codex"\|"claude-terminal"\|"claude-cloud"\|"grok"\|"grok-open"\|"opencode","id"},"title","project","directory","live","updated_at_ms","registration":{"name","address","added_by_session","push","spawn"?:{"harness","can_take_over","taken_over"}}\|null,"suggested_name"}]}`; `spawn` is set on an agent Agent Relay spawned here (see `agents.take_over`); a registered session discovery no longer finds is listed with `live:false`; Grok/OpenCode rows come only from the verified probe-owned registry, plus `grok-open` rows from the already-open registry when `AGENT_RELAY_ENABLE_EXISTING_GROK=1`; a Grok session both registries claim is not listed under either kind | 2b | +| `sessions.title.capability` | `{"source","session_id"}` | `{"supported","reason"}` (`reason` is null when supported); native title-edit support for the exact catalog identity, including history-only rows; unsupported providers or unavailable sessions include an explanation. Available without an account. | native session titles | +| `sessions.title.set` | `{"source","session_id","title"}` | `{"title"}` after the native provider acknowledges the metadata change; trims outer whitespace, accepts 1–200 characters without control characters, and applies provider-specific limits. Uses trusted discovery to resolve the catalog identity; accepts no client paths or endpoints. Publishes sessions/uploader invalidations on success. Available without an account. See native title support (relay-desktop `session-title-editing.md`). | native session titles | +| `sessions.managed.launch` | `{"kind":"grok"\|"opencode","executable","directory","session_id"}` | `{"target","title","directory"}` after the default-off probe has launched/supervised the endpoint and verified OS identity plus provider readback; endpoint, pid, socket and credentials are never accepted | managed provider bridge | +| `sessions.existing.register` | `{"kind":"grok-open","session_id","directory"}` | `{"target","title","directory"}` after the default-off probe (`AGENT_RELAY_ENABLE_EXISTING_GROK=1`) has found exactly one marker-free Grok leader of this user from the kernel, proved its executable, argv and socket peer, and read back model/version from a `session/load` of that session in `directory`; a session or leader the managed registry holds is refused; process, socket, endpoint and credentials are never accepted | already-open Grok route | +| `agents.list` | — | `{"agents":[{"name","address","target","title","registered_at","added_by_session","reply_with_final_answer","webhook":{"id","url"}\|null,"integrations":[{"provider","resource","ready"}],"push","delivery_error"}]}` | 2a | +| `agents.register` | `{"target":{"kind","id"},"name"?,"description"?}` | `{"name","address","already_registered"}`; a session already registered answers `already_registered: true`; otherwise it must be one `sessions.list` finds live (`refused`); without `name`, its suggested name | 2a | +| `agents.unregister` | `{"name"}` | `{}`; waits for a delivery in flight; refused while integrations remain | 2a | +| `agents.rename` | `{"name","new_name"}` (`name` may be either of the agent's names) | `{"name","address","released"}`: the session's new and only name, and the earlier name it replaced; renaming back to the registered name makes it current again | 2a | +| `agents.test_message` | `{"name","text"?}` | `{"message_id"}`; sent as you when signed in, else as this computer's test sender | 2a | +| `agents.transcript` | `{"name","cursor"?:{"ts_ms","id"}}` | `sessions.transcript`'s page plus `follow_cursor`, where what is written next starts: what a spawned agent whose `registration.spawn.can_take_over` is set has said, from its own output (a spawned Claude's stream-json log, `/spawned-agents/.jsonl`), read from the byte offset in `cursor.id`; a line still being written waits for the next read. `refused` for an agent without a transcript of its own (a spawned Codex thread: read its session with `sessions.transcript`), `not_found` for an unknown name | native spawn | +| `agents.take_over` | `{"name","interrupt"?}` | `{"name","target","directory","interrupted"}` once the spawned agent's headless process has stopped, so the session is the caller's to resume (`claude --resume ` in `directory`): exactly one process runs a session. Without `interrupt` it first waits up to 10 minutes for a running turn to end (`busy` after); a second call with `interrupt` while one waits makes it stop the agent at once, and both answer alike. The agent keeps its name, address and place; its process becomes whichever one runs the session, and releasing it leaves that process alone. Taking over one already taken over answers again without stopping anything; `refused` for a harness that cannot be taken over (Codex: open its thread in Codex) | native spawn | +| `agents.reply_setting` | `{"name","enabled":true\|false\|null}` | `{}`; `null` answers automatically only when the session cannot reply itself | 2a | +| `agents.webhook.create` | `{"name"}` | `{"id","url","secret"\|null,"created"}`; the secret only to the call that created it | 2a | +| `agents.webhook.revoke` | `{"name"}` | `{"already_deleted"}` | 2a | +| `agents.integrations.subscribe` | `{"name","provider","resource"}` | `{"subscribed":true,"provider","resource","path_glob"}` | 2a | +| `agents.integrations.unsubscribe` / `.clean_up` | `{"name","provider","resource"}` | `{"subscribed":false,"provider","resource","already_unsubscribed"?}`; also finishes an unfinished subscription | 2a | +| `settings.get` / `settings.set` | `{"allow_self_registration"?, "auto_activate"?, "claude_session_hook"?, "direct_delivery"?, "direct_delivery_default"?, "devin_direct_delivery"?, "analytics_enabled"?, "contractor_access"?}` | All settings, plus the read-only `direct_delivery_managed` (the organization's `crossSessionInbound`, absent when it sets none); `direct_delivery_default` is write-only: `true` sets `accept` unless the user's own Claude settings already name `crossSessionInbound`, `false` changes nothing; `claude_session_hook` is off unless turned on and absent when no Claude login directory exists (the SessionStart hook never registers a session); `devin_direct_delivery` adds or removes Agent Relay's lifecycle hooks in `~/.config/devin/config.json`, on by default where Devin has run, and absent where it has not; a setting the core runs without is `invalid_params`, and so are an unknown or read-only key and a non-boolean, which change nothing | 2a (store settings), 2b (`direct_delivery`, `claude_session_hook`, `devin_direct_delivery`), 2c (`analytics_enabled`, `contractor_access`) | +| `activity.list` | `{"limit"?}` | `{"activity":[{"at_ms","agent","sender","preview","outcome":"injected"\|"skipped"\|"failed"\|"waiting"\|"in_doubt"\|"answered","detail","settlement":"handed_off"\|"waiting_for_turn"\|"visible"\|null}]}`, newest first; `settlement` is set on entries that place the message in the session (a queue handoff, a handoff behind the session's running turn, or the message recorded in the session) and includes Relay Connect deliveries under the participant's name | 2a | +| `claude_delivery.status` | — | `{"restricted","sessions":[{"id","agent","title","direct"}],"allowed_senders":[{"address","name"}],"held":[{"id","agent","session_id","sender","sender_name","text","held_at_ms"}]}` | 2b | +| `claude_delivery.restrict` / `.session` / `.sender` | `{"restricted"}` / `{"session_id","direct"}` / `{"sender","name","allowed"}` | `{}`; controls allowed-member enforcement, per-session Ask first, and the exact sender allowlist | 2b | +| `claude_delivery.approve` / `.dismiss` | `{"id"}` | `{"changed"}`; `false` when the held delivery is gone | 2b | +| `openclaw.relay_tools.status` | — | `{"state":"unavailable"\|"not_connected"\|"waiting"\|"connected","detail"}`: whether the person's OpenClaw agents can use the hosted Agent Relay MCP. `waiting` while an `openclaw mcp login` Agent Relay started waits for the browser; otherwise what `openclaw mcp status --json` says of the `mcp.servers` entry at `/cloud/api/v1/mcp/shared-sessions`, reused for 30 s. `detail` says why it is `unavailable` (OpenClaw off or not set up, a Gateway older than 2026.8.1 that cannot limit relay sessions — even when signed in —, a Gateway that cannot be reached to check (a core that has not met it shakes hands first), its CLI not found, no `mcp login`, an `agent_relay` entry at another address); an entry saved with `enabled: false` is `not_connected`, or why the last sign-in failed (`not_connected`). See OpenClaw and Hermes delivery (relay-desktop `openclaw-hermes-delivery.md`) | OpenClaw | +| `openclaw.relay_tools.connect` | — | `{"state":"waiting"\|"connected","authorization_url"}`: adds `agent_relay` with `openclaw mcp add … --auth oauth --no-probe` unless an entry already has the URL (an `agent_relay` at another address is `refused`, never overwritten), then starts `openclaw mcp login ` and answers with the consent page to open; `connected` and no URL when OpenClaw was already signed in. A login already waiting answers with its page again. Failures are `refused` with the words to show | OpenClaw | **Workspace directory and messaging** -| Method | Params | Result | Lands in | -|---|---|---|---| -| `roster.list` | `{"where"?, "include_idle"?, "q"?}` | `{"agents":[{"name","address","kind","where":"this_computer"\|"cloud"\|"other_desktop","status":"active"\|"idle","liveness":"live"\|"idle"\|"offline","last_seen_ms","description","host","owner_hash","session_id","is_self","provider","activity"?:{"turn"?:"working"\|"idle","repo"?,"branch"?,"doing"?,"updated_ms"?}}],"unread_statuses":[…],"fetched_at_ms"}`; without `include_idle`, the running agents (`live`, `idle`), one Relaycast read per running status; with it, every agent; a status whose read fails leaves out only its agents and is listed in `unread_statuses` (every read failing fails the list); see People and their agents (relay-desktop `agents-people.md`) | 2a | -| `roster.get` | `{"to"\|"address","message_id"?}` | `{"known","agent":{…roster.list's agent}\|null,"message":"queued"\|"delivered"\|"picked_up"\|"no_such_agent"\|null}`: one recipient (`GET /v1/agents/{name}`) and, with `message_id`, its row in Relaycast's `pending_deliveries`; `known: false` (and nothing else) when the key's workspace is not the one your messages go to | 2a | -| `spawn.targets` | — | Typed online node identities, known spawn capabilities and capacity; no credentials or delivery configuration | 2b | -| `spawn.invoke` | `{"caller_name","node_id","node_name","name","harness","task","directory","idempotency_key"}` | Typed native invocation receipt; exact advertised target, which must be this computer's own spawn node (status and release can only bind a worker spawned here), and absolute folder required | 2b | -| `spawn.status` | `{"caller_name","action":"spawn"\|"release","invocation_id"}` | Typed native invocation status; agent_id/name/node are enriched only from an exact same-workspace local spawn invocation, whether or not the worker still runs; raw input/output and provider error text are omitted | 2b | -| `spawn.release` | `{"caller_name","node_id","node_name","name","expected_agent_id","idempotency_key"}` | Typed exact-release receipt; first release proves a local spawn by its exact saved identity (a crashed or ended worker included), private credential-free claims permit identical replay after removal | 2b | -| `spawn.claim.retry` | — | Asks Agent Relay Cloud at once to record this computer's spawn node as the signed-in person's, whatever the backoff after a failed claim (`status.spawn.claim`) | 2b | -| `fleet.list` | — | `{"brokers":[{"node_id","name","workspace_id"?,"error"?,"workers":[{"id":"\|","name","provider","provider_label","model","runtime_kind","session_id","channels","generation","state":"starting"\|"working"\|"idle"\|"waiting"\|"unknown","pending_messages","last_activity_ms","target":{"kind","id"}\|null}]}]}`; a broker that cannot be read has an `error`, never an empty list | 2b | -| `fleet.release` | `{"node_id","name","generation"}` | `{}`; generation-checked (`conflict` when the broker's worker is another generation, `not_found` when gone) | 2b | -| `cursor_cloud.models.list` | `{}` | Cursor Cloud models available through the workspace's Cloud-held credential | 2a | -| `cursor_cloud.repositories.list` | `{}` | Cached Cursor-accessible repositories, with freshness and stale-fallback state | 2a | -| `cursor_cloud.environments.list` | `{"query"?,"owner"?,"named"?,"repo"?:[],"user_id"?,"cursor"?,"limit"?}` | Cursor Cloud environments and pagination cursor | 2a | -| `cursor_cloud.agents.list` | `{"cursor"?,"limit"?,"include_archived"?}` | Cursor Cloud's durable agents plus their Relay binding state and pagination cursor | 2a | -| `cursor_cloud.agents.spawn` | `{"client_request_id","task","name"?,"repositories"?,"model"?,"environment"?,"work_on_current_branch"?,"auto_create_pull_request"?}` | Creates a durable Cursor Cloud agent and initial run; Cloud makes `client_request_id` idempotent and begins its Relay binding | 2a | -| `cursor_cloud.agents.get` | `{"agent_id"}` | Durable Cursor state (`ACTIVE`, `IDLE`, or `ARCHIVED`), latest run, repositories and Relay binding | 2a | -| `cursor_cloud.runs.create` | `{"agent_id","client_request_id","task","delivery"?:"steer"\|"run"}` | Injects a follow-up with `steer` by default (accepted by the running turn or queued next), or explicitly starts a run that refuses a busy agent | 2a | -| `cursor_cloud.runs.list` | `{"agent_id","cursor"?,"limit"?}` | Paginated native runs for durable settlement and recovery | 2a | -| `cursor_cloud.runs.get` | `{"agent_id","run_id"}` | Run state, final result, duration and pushed branches or pull requests | 2a | -| `cursor_cloud.runs.cancel` | `{"agent_id","run_id"}` | Cancels a live run without deleting the durable agent | 2a | -| `cursor_cloud.agents.archive` / `.unarchive` | `{"agent_id"}` | Reversible release/restore plus Relay binding state; the core never permanently deletes a Cursor Cloud agent | 2a | -| `replicas.environments.list` | `{}` | Environments visible to the workspace's Cloud-held Replicas key | 2a | -| `replicas.spawn` | `{"client_request_id","environment_id","name","task","coding_agent"?,"model"?,"report_to"?}` | Durable launch and relay binding; retain `spawn_id` and reuse the request id on retries | 2a | -| `replicas.spawn.get` | `{"spawn_id"}` | Continues preparation, helper installation and binding; reports uncertain outcomes explicitly | 2a | -| `replicas.release` | `{"spawn_id"}` | Revokes the relay binding and sleeps the replica, preserving its history | 2a | -| `capy.projects.list` | `{"cursor"?}` | `{"projects":[{"id","name"}],"truncated","next_cursor"}`; Capy projects visible to the workspace's Cloud-held key | 2a | -| `capy.threads.spawn` | `{"client_request_id","project_id","task","model","pull_request_author":"user"\|"capy"?,"report_to"?}` | `{"thread_id","title","status","binding":{"state":"pending"\|"bound","agent"?}}`; Cloud keeps the Capy key and makes retries with one client request id idempotent. `model` is required (Capy has no default): a missing or blank one is `invalid_params`, naming the field with an example such as `anthropic/claude-sonnet-5-5`, before Cloud is called | 2a | -| `capy.threads.get` | `{"thread_id"}` | The same thread and relay-binding status as `capy.threads.spawn` | 2a | -| `messages.conversations` | — | `{"identity","conversations":[{"id","peer":{"name","agent_id"},"last_message","unread_count"}]}` | 2a | -| `messages.history` | `{"endpoint","before"?}` | `{"identity","messages":[{"id","sender","sender_id","sender_address","text","created_at","thread_id","mine"}]}` in reading order (replies beneath their parent); exact-address history fails closed without immutable proof | 2a | -| `messages.send` | `{"to"\|"address","text","reply_to"?}` | `{"message_id","conversation_id","to","recipient":{…roster.get's answer for this message}}`, read just after the send; no agent at the name or address is `not_found` | 2a | -| `sessions.overview.get` | `{"session_id"}` | Cloud's saved session analysis, including request status and any generated brief | 2a | -| `sessions.overview.request` | `{"session_id"}` | Cloud's accepted summary request; the app reads status with `sessions.overview.get` | 2a | -| `sessions.overview.events` | `{"session_id"}` | A bounded excerpt of the first uploaded session events, used to show the original user request when no generated brief exists | 2a | -| `people.list` | — | `{"people":[{"name","avatar_url","identity_hash","is_self"}]}` | 2a | +| Method | Params | Result | Lands in | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | +| `roster.list` | `{"where"?, "include_idle"?, "q"?}` | `{"agents":[{"name","address","kind","where":"this_computer"\|"cloud"\|"other_desktop","status":"active"\|"idle","liveness":"live"\|"idle"\|"offline","last_seen_ms","description","host","owner_hash","session_id","is_self","provider","activity"?:{"turn"?:"working"\|"idle","repo"?,"branch"?,"doing"?,"updated_ms"?}}],"unread_statuses":[…],"fetched_at_ms"}`; without `include_idle`, the running agents (`live`, `idle`), one Relaycast read per running status; with it, every agent; a status whose read fails leaves out only its agents and is listed in `unread_statuses` (every read failing fails the list); see People and their agents (relay-desktop `agents-people.md`) | 2a | +| `roster.get` | `{"to"\|"address","message_id"?}` | `{"known","agent":{…roster.list's agent}\|null,"message":"queued"\|"delivered"\|"picked_up"\|"no_such_agent"\|null}`: one recipient (`GET /v1/agents/{name}`) and, with `message_id`, its row in Relaycast's `pending_deliveries`; `known: false` (and nothing else) when the key's workspace is not the one your messages go to | 2a | +| `spawn.targets` | — | Typed online node identities, known spawn capabilities and capacity; no credentials or delivery configuration | 2b | +| `spawn.invoke` | `{"caller_name","node_id","node_name","name","harness","task","directory","idempotency_key"}` | Typed native invocation receipt; exact advertised target, which must be this computer's own spawn node (status and release can only bind a worker spawned here), and absolute folder required | 2b | +| `spawn.status` | `{"caller_name","action":"spawn"\|"release","invocation_id"}` | Typed native invocation status; agent_id/name/node are enriched only from an exact same-workspace local spawn invocation, whether or not the worker still runs; raw input/output and provider error text are omitted | 2b | +| `spawn.release` | `{"caller_name","node_id","node_name","name","expected_agent_id","idempotency_key"}` | Typed exact-release receipt; first release proves a local spawn by its exact saved identity (a crashed or ended worker included), private credential-free claims permit identical replay after removal | 2b | +| `spawn.claim.retry` | — | Asks Agent Relay Cloud at once to record this computer's spawn node as the signed-in person's, whatever the backoff after a failed claim (`status.spawn.claim`) | 2b | +| `fleet.list` | — | `{"brokers":[{"node_id","name","workspace_id"?,"error"?,"workers":[{"id":"\|","name","provider","provider_label","model","runtime_kind","session_id","channels","generation","state":"starting"\|"working"\|"idle"\|"waiting"\|"unknown","pending_messages","last_activity_ms","target":{"kind","id"}\|null}]}]}`; a broker that cannot be read has an `error`, never an empty list | 2b | +| `fleet.release` | `{"node_id","name","generation"}` | `{}`; generation-checked (`conflict` when the broker's worker is another generation, `not_found` when gone) | 2b | +| `cursor_cloud.models.list` | `{}` | Cursor Cloud models available through the workspace's Cloud-held credential | 2a | +| `cursor_cloud.repositories.list` | `{}` | Cached Cursor-accessible repositories, with freshness and stale-fallback state | 2a | +| `cursor_cloud.environments.list` | `{"query"?,"owner"?,"named"?,"repo"?:[],"user_id"?,"cursor"?,"limit"?}` | Cursor Cloud environments and pagination cursor | 2a | +| `cursor_cloud.agents.list` | `{"cursor"?,"limit"?,"include_archived"?}` | Cursor Cloud's durable agents plus their Relay binding state and pagination cursor | 2a | +| `cursor_cloud.agents.spawn` | `{"client_request_id","task","name"?,"repositories"?,"model"?,"environment"?,"work_on_current_branch"?,"auto_create_pull_request"?}` | Creates a durable Cursor Cloud agent and initial run; Cloud makes `client_request_id` idempotent and begins its Relay binding | 2a | +| `cursor_cloud.agents.get` | `{"agent_id"}` | Durable Cursor state (`ACTIVE`, `IDLE`, or `ARCHIVED`), latest run, repositories and Relay binding | 2a | +| `cursor_cloud.runs.create` | `{"agent_id","client_request_id","task","delivery"?:"steer"\|"run"}` | Injects a follow-up with `steer` by default (accepted by the running turn or queued next), or explicitly starts a run that refuses a busy agent | 2a | +| `cursor_cloud.runs.list` | `{"agent_id","cursor"?,"limit"?}` | Paginated native runs for durable settlement and recovery | 2a | +| `cursor_cloud.runs.get` | `{"agent_id","run_id"}` | Run state, final result, duration and pushed branches or pull requests | 2a | +| `cursor_cloud.runs.cancel` | `{"agent_id","run_id"}` | Cancels a live run without deleting the durable agent | 2a | +| `cursor_cloud.agents.archive` / `.unarchive` | `{"agent_id"}` | Reversible release/restore plus Relay binding state; the core never permanently deletes a Cursor Cloud agent | 2a | +| `replicas.environments.list` | `{}` | Environments visible to the workspace's Cloud-held Replicas key | 2a | +| `replicas.spawn` | `{"client_request_id","environment_id","name","task","coding_agent"?,"model"?,"report_to"?}` | Durable launch and relay binding; retain `spawn_id` and reuse the request id on retries | 2a | +| `replicas.spawn.get` | `{"spawn_id"}` | Continues preparation, helper installation and binding; reports uncertain outcomes explicitly | 2a | +| `replicas.release` | `{"spawn_id"}` | Revokes the relay binding and sleeps the replica, preserving its history | 2a | +| `capy.projects.list` | `{"cursor"?}` | `{"projects":[{"id","name"}],"truncated","next_cursor"}`; Capy projects visible to the workspace's Cloud-held key | 2a | +| `capy.threads.spawn` | `{"client_request_id","project_id","task","model","pull_request_author":"user"\|"capy"?,"report_to"?}` | `{"thread_id","title","status","binding":{"state":"pending"\|"bound","agent"?}}`; Cloud keeps the Capy key and makes retries with one client request id idempotent. `model` is required (Capy has no default): a missing or blank one is `invalid_params`, naming the field with an example such as `anthropic/claude-sonnet-5-5`, before Cloud is called | 2a | +| `capy.threads.get` | `{"thread_id"}` | The same thread and relay-binding status as `capy.threads.spawn` | 2a | +| `messages.conversations` | — | `{"identity","conversations":[{"id","peer":{"name","agent_id"},"last_message","unread_count"}]}` | 2a | +| `messages.history` | `{"endpoint","before"?}` | `{"identity","messages":[{"id","sender","sender_id","sender_address","text","created_at","thread_id","mine"}]}` in reading order (replies beneath their parent); exact-address history fails closed without immutable proof | 2a | +| `messages.send` | `{"to"\|"address","text","reply_to"?}` | `{"message_id","conversation_id","to","recipient":{…roster.get's answer for this message}}`, read just after the send; no agent at the name or address is `not_found` | 2a | +| `sessions.overview.get` | `{"session_id"}` | Cloud's saved session analysis, including request status and any generated brief | 2a | +| `sessions.overview.request` | `{"session_id"}` | Cloud's accepted summary request; the app reads status with `sessions.overview.get` | 2a | +| `sessions.overview.events` | `{"session_id"}` | A bounded excerpt of the first uploaded session events, used to show the original user request when no generated brief exists | 2a | +| `people.list` | — | `{"people":[{"name","avatar_url","identity_hash","is_self"}]}` | 2a | `people.list` returns the avatar's https URL rather than a cached file: the UI loads public images itself, and Cloud's email, role and join date are @@ -532,12 +551,12 @@ dropped in the core. `identity_hash` joins a person to `roster.list`'s **Platform services** -| Method | Params | Result | Lands in | -|---|---|---|---| -| `links.parse` | `{"url","site_url"?}` | `{"kind":"connect","site_url","account_id","workspace_id"}` or `{"kind":"message","endpoint","address"}`, else `invalid_params`; `site_url` is the site the app is built for (default: the signed-in site, else `https://agentrelay.com`) | 2a | -| `telemetry.record` | `{"operation","outcome","failure"?,"count"?,"mode"?,"surface"?,"agent_kind"?,"already_registered"?,"is_reply"?}` | `{}`; an unknown value is `invalid_params`. `update_install` success waits up to two seconds for the telemetry sender before replying, so a hosted core can be released after the reply. Desktop update callers bound each awaited telemetry RPC to three seconds; GTK awaits it off the UI thread. | 2c | -| `login_item.set` (Linux) | `{"enabled"}` | `{}`; `refused` on macOS, where `SMAppService` is the app's | 2c | -| `login_item.status` (Linux) | — | `{"enabled"}`; `refused` on macOS | 3 | +| Method | Params | Result | Lands in | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `links.parse` | `{"url","site_url"?}` | `{"kind":"connect","site_url","account_id","workspace_id"}` or `{"kind":"message","endpoint","address"}`, else `invalid_params`; `site_url` is the site the app is built for (default: the signed-in site, else `https://agentrelay.com`) | 2a | +| `telemetry.record` | `{"operation","outcome","failure"?,"count"?,"mode"?,"surface"?,"agent_kind"?,"already_registered"?,"is_reply"?}` | `{}`; an unknown value is `invalid_params`. `update_install` success waits up to two seconds for the telemetry sender before replying, so a hosted core can be released after the reply. Desktop update callers bound each awaited telemetry RPC to three seconds; GTK awaits it off the UI thread. | 2c | +| `login_item.set` (Linux) | `{"enabled"}` | `{}`; `refused` on macOS, where `SMAppService` is the app's | 2c | +| `login_item.status` (Linux) | — | `{"enabled"}`; `refused` on macOS | 3 | Telemetry goes to PostHog through `/i/v0/e/` on the configured release host only for the production site in a release build. Events use `desktop:/Conversations/.json` and @@ -603,14 +622,14 @@ The conversation and project view are kept per account in **Contractor access** — off unless `RELAY_DESKTOP_CONTRACTOR_ACCESS` or the `contractor_access` setting is on; while off, nothing asks Cloud -| Method | Params | Result | Lands in | -|---|---|---|---| -| `contractors.status` | — | `{"enabled","availability":"dormant"\|"unloaded"\|"unsupported"\|"ready"\|"failed","failure","role":"owner"\|"member"\|"contractor"\|null,"grant","pending_seats":[{"user_id","name","avatar_url","identity_hash","role","joined_at_ms"}],"services":[{"id","name","sensitivity":"standard"\|"high","keywords"}],"grants":[{"id","user_id","display_name","description","scopes":[{"service","access"}],"granted_by","created_at_ms"}],"provisioning":{"grant_id","failed"}}` from the cache; owner-only lists are empty for others | 2c | -| `contractors.refresh` | — | As `contractors.status`, after Cloud `access/me` (and, for an owner, `/members`, `access/services`, `access/grants`); provisions or drops this person's own grant; `unsupported` while Cloud answers 404/405/501 | 2c | -| `contractors.propose` | `{"user_id","description"}` | `{"scopes":[{"service","access":"none"\|"read"\|"write"}],"rationale","source":"cloud"\|"heuristic"}`, one row per catalogue service; owner only | 2c | -| `contractors.approve` | `{"user_id","description","scopes":[…]}` | `{"grant"}`; only scopes that grant something are sent; owner only | 2c | -| `contractors.revoke` | `{"grant_id"}` | `{}`; `busy` while the same revoke runs; owner only | 2c | -| `contractors.dismiss` | `{"user_id"}` | As `contractors.status`; hidden per site and workspace on this computer | 2c | +| Method | Params | Result | Lands in | +| --------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `contractors.status` | — | `{"enabled","availability":"dormant"\|"unloaded"\|"unsupported"\|"ready"\|"failed","failure","role":"owner"\|"member"\|"contractor"\|null,"grant","pending_seats":[{"user_id","name","avatar_url","identity_hash","role","joined_at_ms"}],"services":[{"id","name","sensitivity":"standard"\|"high","keywords"}],"grants":[{"id","user_id","display_name","description","scopes":[{"service","access"}],"granted_by","created_at_ms"}],"provisioning":{"grant_id","failed"}}` from the cache; owner-only lists are empty for others | 2c | +| `contractors.refresh` | — | As `contractors.status`, after Cloud `access/me` (and, for an owner, `/members`, `access/services`, `access/grants`); provisions or drops this person's own grant; `unsupported` while Cloud answers 404/405/501 | 2c | +| `contractors.propose` | `{"user_id","description"}` | `{"scopes":[{"service","access":"none"\|"read"\|"write"}],"rationale","source":"cloud"\|"heuristic"}`, one row per catalogue service; owner only | 2c | +| `contractors.approve` | `{"user_id","description","scopes":[…]}` | `{"grant"}`; only scopes that grant something are sent; owner only | 2c | +| `contractors.revoke` | `{"grant_id"}` | `{}`; `busy` while the same revoke runs; owner only | 2c | +| `contractors.dismiss` | `{"user_id"}` | As `contractors.status`; hidden per site and workspace on this computer | 2c | A 403 is `refused`. Sign-in adopts the person's own grant; sign-out drops it. @@ -736,12 +755,12 @@ appear), and says why (`recipients::unresolved`). An `ambiguous_address` (an address Relaycast reads more than one way) is passed on as it is: -| Asked for | Socket error (HTTP) | `messages.send` kind | Says | -|---|---|---|---| -| A short name that more than one agent's name contains | `ambiguous_recipient` (400) | `invalid_params` | every candidate's full address | -| A short name exactly one agent's name contains, or an address whose agent now runs elsewhere | Relaycast's code (404) | `not_found` | "Did you mean" its full address | -| A person (their name, first name, or name joined with `-`) | `recipient_is_person` (400) | `invalid_params` | that it is a person, and their agents' full addresses | -| Anything else | Relaycast's code (404) | `not_found` | to list agents first (`GET /agents` on the socket) | +| Asked for | Socket error (HTTP) | `messages.send` kind | Says | +| -------------------------------------------------------------------------------------------- | --------------------------- | -------------------- | ----------------------------------------------------- | +| A short name that more than one agent's name contains | `ambiguous_recipient` (400) | `invalid_params` | every candidate's full address | +| A short name exactly one agent's name contains, or an address whose agent now runs elsewhere | Relaycast's code (404) | `not_found` | "Did you mean" its full address | +| A person (their name, first name, or name joined with `-`) | `recipient_is_person` (400) | `invalid_params` | that it is a person, and their agents' full addresses | +| Anything else | Relaycast's code (404) | `not_found` | to list agents first (`GET /agents` on the socket) | Matching ignores case. A name shared by several people (two people with the same first name) lists each of them with their agents. An `agent@machine` @@ -754,19 +773,44 @@ other failure stays generic. ## Workspace discovery -The workspace is the signed-in account's (`cloud` in the agent store, at its -own origin), written at sign-in together with the uploader's target, so uploads -and messaging always use one workspace and one key (`relay::workspace`). -Nothing else names a workspace key: the environment, the `agent-relay` CLI's -files under `$AGENT_RELAY_HOME` and keys pasted into the app are not read. -Signing in to another workspace replaces it. - -Only `rk_…` keys of at least 12 characters without whitespace qualify. -`RELAYCAST_URL` overrides the origin when it is a bare https origin (http and -a port only for `localhost`/`127.0.0.1`; no user info, path, query or -fragment); anything else leaves `https://cast.agentrelay.com`. The daemon -rediscovers every 30 s and on `workspace.refresh`, and verifies a new key with -`GET /v1/workspace`. +The daemon uses one Relaycast workspace at a time (`relay::workspace`), from +the first of two sources that names one: + +1. **The `agent-relay` CLI** (`relay::workspace::cli`), read and never + written, by the CLI's own precedence: a workspace-key variable in the + daemon's environment (`RELAY_WORKSPACE_KEY` > `AGENT_RELAY_WORKSPACE_KEY` > + `RELAY_API_KEY`); then the project pin + `/.agentworkforce/relay/workspace-key.json` when + `AGENT_RELAY_PROJECT` names the project (the daemon has no working + directory to search from); then the active entry of + `$AGENT_RELAY_HOME/workspaces.json` (default `~/.agentworkforce/relay`). + Its origin follows the CLI too: a project's persisted Relaycast route + (only Agent Relay's own gateways), else `RELAY_BASE_URL`, else the key's + self-hosted binding in `relaycast-origins.json` + (`relay::workspace::origin_binding`: the PBKDF2-SHA256 fingerprint shared + with the CLI and the broker), else `https://cast.agentrelay.com`. A bound + key goes only to its bound server, so `relay init`'s local server + (`http://127.0.0.1:8787`) and self-hosted servers (any http(s) base URL, + path prefix included) work with no sign-in. A malformed bindings file, an + unreadable `workspaces.json` or a malformed `RELAY_BASE_URL` fails closed: + the daemon has no workspace and says why in its activity, rather than + sending the key to the hosted default or falling back to the account's. +2. **The Cloud sign-in** (`cloud` in the agent store, at its own origin), + written at sign-in together with the uploader's target, used only when the + CLI names nothing: a desktop user who never ran the CLI. `RELAYCAST_URL` + overrides its origin when it is a bare https origin (http and a port only + for `localhost`/`127.0.0.1`; no user info, path, query or fragment); + anything else leaves `https://cast.agentrelay.com`. + +Neither source's key is sent to the other's origin: `RELAYCAST_URL` applies +only to the account's key, and CLI bindings only to the CLI's. Only +`rk_…` keys of at least 12 characters without whitespace qualify. A refused +key is recovered through Cloud (`account::workspace_recovery`) only when it +came from the sign-in; a key the CLI named is the CLI's to rotate, and a +rotated `workspaces.json` entry is picked up by the next rediscovery. When the +CLI names a workspace, messaging uses it while uploads still go to the signed-in +account's workspace. The daemon rediscovers every 30 s and on +`workspace.refresh`, and verifies a new key with `GET /v1/workspace`. The machine prefix is the host name with `.local` dropped, diacritics folded, non-`[a-z0-9]` runs as one dash, at most 40 characters, else `computer`; each @@ -957,16 +1001,16 @@ agents into its session (through the `Injector`) and settles it: The core is built with `daemon::Plugins`; each defaults to a no-op until its stage lands. -| Trait | File | Supplies | Default | Lands in | -|---|---|---|---|---| -| `inject::Injector` | `probe/src/relay/inject.rs` | `inject` (`Delivered`, `Retry`, `Waiting`, `InDoubt`, `Refused`), `reply_hint`, `final_answer` per session kind | `NoInjector`: every delivery waits | 2b | -| `sessions::SessionDirectory` | `probe/src/relay/sessions.rs` | Sessions on this computer, fleet-managed ones excluded | `NoSessions` | 2b | -| `cloud_session::CloudAccess` | `probe/src/relay/cloud_session.rs` | The signed-in account and authenticated `/cloud/api/v1` requests | `SignedOut` | 2c | -| `agents::SettingsExtension` | `probe/src/relay/agents.rs` | Settings owned elsewhere (`direct_delivery`, `analytics_enabled`, `contractor_access`) | none | 2b, 2c | -| `outbound::RelaySend` | `probe/src/relay/outbound.rs` | The relay-send socket's path, pointer and Claude Code's direct-delivery setting (`setup::DirectDelivery`) | `None`: not served | 2b | -| `daemon::Plugins::account` | `probe/src/relay/account.rs` | Sign-in, the uploader and telemetry (`account::with_account` adds the account, also as `cloud`) | none: account, uploader and telemetry methods are not offered | 2c | -| `uploader::ProbeRunner` | `probe/src/relay/uploader.rs` | Running the probe's commands | `ProcessRunner` over this binary | 2c | -| `contractor_access::Provisioner` | `probe/src/relay/contractor_access.rs` | `provision(grant)` / `deprovision()` for this person's own grant, one at a time, in order | `PendingSeatTokens`: holds nothing | 2c | +| Trait | File | Supplies | Default | Lands in | +| -------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | -------- | +| `inject::Injector` | `probe/src/relay/inject.rs` | `inject` (`Delivered`, `Retry`, `Waiting`, `InDoubt`, `Refused`), `reply_hint`, `final_answer` per session kind | `NoInjector`: every delivery waits | 2b | +| `sessions::SessionDirectory` | `probe/src/relay/sessions.rs` | Sessions on this computer, fleet-managed ones excluded | `NoSessions` | 2b | +| `cloud_session::CloudAccess` | `probe/src/relay/cloud_session.rs` | The signed-in account and authenticated `/cloud/api/v1` requests | `SignedOut` | 2c | +| `agents::SettingsExtension` | `probe/src/relay/agents.rs` | Settings owned elsewhere (`direct_delivery`, `analytics_enabled`, `contractor_access`) | none | 2b, 2c | +| `outbound::RelaySend` | `probe/src/relay/outbound.rs` | The relay-send socket's path, pointer and Claude Code's direct-delivery setting (`setup::DirectDelivery`) | `None`: not served | 2b | +| `daemon::Plugins::account` | `probe/src/relay/account.rs` | Sign-in, the uploader and telemetry (`account::with_account` adds the account, also as `cloud`) | none: account, uploader and telemetry methods are not offered | 2c | +| `uploader::ProbeRunner` | `probe/src/relay/uploader.rs` | Running the probe's commands | `ProcessRunner` over this binary | 2c | +| `contractor_access::Provisioner` | `probe/src/relay/contractor_access.rs` | `provision(grant)` / `deprovision()` for this person's own grant, one at a time, in order | `PendingSeatTokens`: holds nothing | 2c | `relay serve` builds its plugins with `local::plugins`: `LocalSessions` (Codex and Claude discovery over a `process::ProcessTable`, fleet-owned @@ -986,31 +1030,31 @@ The one exception is `relay::sdk_gaps`, a minimal typed client for surface the SDK does not cover the way the desktop needs; each item there is an upstream request and is deleted once the SDK has it. -| Endpoint | Auth | SDK | -|---|---|---| -| `POST /v1/agents` | `rk_` | `RelayCast::register_agent` — **gap**: `CreateAgentRequest` has no `direct_machine_prefix` or `auto_join_general` (`sdk_gaps::register_agent`) | -| `DELETE /v1/agents/{name}` | `rk_` | `RelayCast::delete_agent` | -| `GET /v1/agents?status=` | `rk_` | `RelayCast::list_agents` — **gap**: `Agent` has no `address` (`sdk_gaps::list_agents`) | -| `GET /v1/agents/{name}` | `rk_` | `RelayCast::get_agent` — **gap**: `Agent` has no `address` (`sdk_gaps::get_agent`) | -| `POST /v1/agents/heartbeat` | `at_` | `AgentClient::heartbeat` | -| `GET /v1/agent` | `at_` | `AgentClient::me` | -| `GET /v1/workspace` | `rk_` | `RelayCast::workspace_info` — **gap**: `Workspace` requires `api_key_hash`, which the server no longer sends (`sdk_gaps::workspace_identity`) | -| `POST /v1/agent/node-token` | `at_` | **gap**: no public method (`sdk_gaps::mint_node_token`, over the SDK's `HttpClient`) | -| `WS /v1/node/ws` | `nt_` | **gap**: `WsClient` acks every `deliver` on the socket (terminal, before injection), omits `machine_id` from `node.register`, heartbeats every 30 s instead of 15 s, signals `Open` before the registration reply, and cannot send `origin_surface` (`sdk_gaps::open_node_socket`) | -| `GET /v1/deliveries` | `at_` | `AgentClient::deliveries` — **gap**: `DeliveryMessage` has no `agent_address` or `metadata` (relayfile path) (`sdk_gaps::deliveries`) | -| `POST /v1/deliveries/{id}/ack` / `fail` | `at_` | `AgentClient::ack_delivery` / `fail_delivery` | -| `POST /v1/channels`, `…/join`, `…/leave`, `GET /v1/channels/{name}` | `at_` | `AgentClient::ensure_joined_channel`, `join_channel`, `leave_channel`, `get_channel` | -| `DELETE /v1/channels/{name}` | `rk_` | `AgentClient::archive_channel`, built with the workspace key | -| `POST` / `GET` / `DELETE /v1/webhooks` | `rk_` | `RelayCast::create_webhook`, `list_webhooks`, `delete_webhook` | -| `POST` / `GET` / `DELETE /v1/subscriptions` | `rk_` | `RelayCast::create_subscription`, `list_subscriptions`, `delete_subscription` | -| `POST /v1/agents/{name}/subscription-channel` | `rk_` | **gap**: no method (`sdk_gaps::subscription_channel`) | -| `POST /v1/integrations/relayfile/inbound-target` | `rk_` | **gap**: no method (`sdk_gaps::relayfile_inbound_target`) | -| `POST /v1/dm` | `at_` | `AgentClient::dm` for a name — **gap**: `SendDmRequest` has no `address` (exact `agent@machine` endpoint) (`sdk_gaps::dm_address`) | -| `POST /v1/messages/{id}/replies` | `at_` | `AgentClient::reply` | -| `GET /v1/dm/conversations` | `at_` | `AgentClient::dm_conversations` — **gap**: participants are flattened to names, losing the immutable `agent_id` exact-address proof needs (`sdk_gaps::dm_conversations`) | -| `GET /v1/dm/{id}/messages` | `at_` | `AgentClient::dm_messages` — **gap**: `MessageWithMeta` has no `agent_address` or `thread_id` (`sdk_gaps::dm_messages`) | -| `GET /v1/messages/{id}` | `at_` | `AgentClient::message` — same gap (`sdk_gaps::message`) | -| `GET /v1/workspace` | `rk_` | `RelayCast::workspace_info` | +| Endpoint | Auth | SDK | +| ------------------------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST /v1/agents` | `rk_` | `RelayCast::register_agent` — **gap**: `CreateAgentRequest` has no `direct_machine_prefix` or `auto_join_general` (`sdk_gaps::register_agent`) | +| `DELETE /v1/agents/{name}` | `rk_` | `RelayCast::delete_agent` | +| `GET /v1/agents?status=` | `rk_` | `RelayCast::list_agents` — **gap**: `Agent` has no `address` (`sdk_gaps::list_agents`) | +| `GET /v1/agents/{name}` | `rk_` | `RelayCast::get_agent` — **gap**: `Agent` has no `address` (`sdk_gaps::get_agent`) | +| `POST /v1/agents/heartbeat` | `at_` | `AgentClient::heartbeat` | +| `GET /v1/agent` | `at_` | `AgentClient::me` | +| `GET /v1/workspace` | `rk_` | `RelayCast::workspace_info` — **gap**: `Workspace` requires `api_key_hash`, which the server no longer sends (`sdk_gaps::workspace_identity`) | +| `POST /v1/agent/node-token` | `at_` | **gap**: no public method (`sdk_gaps::mint_node_token`, over the SDK's `HttpClient`) | +| `WS /v1/node/ws` | `nt_` | **gap**: `WsClient` acks every `deliver` on the socket (terminal, before injection), omits `machine_id` from `node.register`, heartbeats every 30 s instead of 15 s, signals `Open` before the registration reply, and cannot send `origin_surface` (`sdk_gaps::open_node_socket`) | +| `GET /v1/deliveries` | `at_` | `AgentClient::deliveries` — **gap**: `DeliveryMessage` has no `agent_address` or `metadata` (relayfile path) (`sdk_gaps::deliveries`) | +| `POST /v1/deliveries/{id}/ack` / `fail` | `at_` | `AgentClient::ack_delivery` / `fail_delivery` | +| `POST /v1/channels`, `…/join`, `…/leave`, `GET /v1/channels/{name}` | `at_` | `AgentClient::ensure_joined_channel`, `join_channel`, `leave_channel`, `get_channel` | +| `DELETE /v1/channels/{name}` | `rk_` | `AgentClient::archive_channel`, built with the workspace key | +| `POST` / `GET` / `DELETE /v1/webhooks` | `rk_` | `RelayCast::create_webhook`, `list_webhooks`, `delete_webhook` | +| `POST` / `GET` / `DELETE /v1/subscriptions` | `rk_` | `RelayCast::create_subscription`, `list_subscriptions`, `delete_subscription` | +| `POST /v1/agents/{name}/subscription-channel` | `rk_` | **gap**: no method (`sdk_gaps::subscription_channel`) | +| `POST /v1/integrations/relayfile/inbound-target` | `rk_` | **gap**: no method (`sdk_gaps::relayfile_inbound_target`) | +| `POST /v1/dm` | `at_` | `AgentClient::dm` for a name — **gap**: `SendDmRequest` has no `address` (exact `agent@machine` endpoint) (`sdk_gaps::dm_address`) | +| `POST /v1/messages/{id}/replies` | `at_` | `AgentClient::reply` | +| `GET /v1/dm/conversations` | `at_` | `AgentClient::dm_conversations` — **gap**: participants are flattened to names, losing the immutable `agent_id` exact-address proof needs (`sdk_gaps::dm_conversations`) | +| `GET /v1/dm/{id}/messages` | `at_` | `AgentClient::dm_messages` — **gap**: `MessageWithMeta` has no `agent_address` or `thread_id` (`sdk_gaps::dm_messages`) | +| `GET /v1/messages/{id}` | `at_` | `AgentClient::message` — same gap (`sdk_gaps::message`) | +| `GET /v1/workspace` | `rk_` | `RelayCast::workspace_info` | Every gap is still a call through the SDK's own `HttpClient` (auth, envelopes, retries and errors are the SDK's). @@ -1037,49 +1081,49 @@ agent and restarts one whose origin, token or machine address changed. ## Modules -| Module | Owns | -|---|---| -| `relay::protocol` | Envelopes, events, error kinds, `BRIDGE_VERSION` | -| `relay::contract` | The method and topic registry with every parameter, result and event type; Swift generation (`codegen`, `relay-contract-codegen`); the Rust client's tolerant reading (`reader`) | -| `relay::server` | Accept loop, peer admission, handshake, dispatch, event forwarding | -| `relay::daemon` | `relay serve`, single-instance lock, socket lifecycle, `Core`, shutdown | -| `relay::state` | The snapshot and the event hub | -| `relay::paths` / `relay::peer` | Socket and lock paths; kernel peer credentials | -| `relay::workspace` / `relay::app_store` / `relay::machine` | Discovery, origin validation, the store's workspace fields, the machine prefix | -| `relay::client` | Relaycast clients over the SDK | -| `relay::sdk_gaps` | Relaycast surface the SDK lacks | -| `relay::push` | Direct node sockets | -| `relay::cli` | `relay call METHOD [--params JSON]`; `relay socket-request` and `relay subscribe `, the session-socket clients | -| `relay::methods` | The bridge methods for agents, messaging, people, links and settings (2a) | -| `relay::store` | The agent store: format, migration, flock, three-way merge, set-aside of unreadable files (2a) | -| `relay::agents` | Names, registration, rename, removal, webhooks, test sender, settings, cleanups (2a) | -| `relay::delivery` | Policy, injected text, the per-agent line of work, ack/fail, in-doubt, final answers (2a) | -| `relay::messaging` / `relay::people` | Person identity, DMs, exact-address proof; members, avatars and the roster (2a) | -| `relay::integrations` | Relayfile subscriptions (2a) | -| `relay::links` | Message endpoints and deep links (2a) | -| `relay::inject` | The `Injector` trait (2a) | -| `relay::sessions` | The `SessionDirectory` trait (2a), with `caller` and `fleet_owned` (2b) | -| `relay::process` | The kernel's process table (`/proc`; `libproc`, `KERN_PROCARGS2` and code signatures on macOS) and the Codex caller rules (2b) | -| `relay::codex` | Codex threads, rollouts, `codex queue`, the app-server proxy, the desktop app's IPC socket, the injection router (2b) | -| `relay::claude` | Claude Code's session registry, peer inbox, Claude app sessions and `claude --print --cloud`, transcripts (2b) | -| `relay::local` | `LocalSessions`, `LocalInjector` and the plugins `relay serve` runs with (2b) | -| `relay::local_methods` | `sessions.list`, `fleet.list`, `fleet.release`, the sessions/fleet watcher (2b) | -| `relay::outbound` | The relay-send socket and its routes (2b) | -| `relay::setup` | Agent-led setup routes and defaults (2b) | -| `relay::fleet` | Broker discovery and `/api/spawned` (2b) | -| `relay::claude_settings` | `crossSessionInbound` (2b) | -| `relay::claude_delivery_policy` | Per-session and per-sender Claude Code delivery consent plus the durable Ask-first queue (2b) | -| `relay::outbox` | The desktop mailbox's durable layer: the Codex outbox store (cursor, checkpoints, ack journal), Codex and Claude delivery workers, the sharing policy and inboxes (2b); its Cloud receiver is `relay::mailbox` (2c) | -| `relay::cloud_session` | The `CloudAccess` trait (2a); Cloud session calls: refresh, handoff exchange, revoke, `whoami`, workspace resolve; where the session is kept (2c) | -| `relay::cloud_http` / `relay::cloud_login` | One redirect-free, bounded Cloud request; browser (loopback + PKCE) and device sign-in (2c) | -| `relay::account` / `relay::account_methods` | Sign-in, the kept session, the account workspace, sign-out, the daemon's `CloudAccess`; the 2c bridge methods (2c) | -| `relay::uploader` / `relay::probe_copy` | The probe's commands, stages and collector supervision; the `~/.local/bin` copy (2c) | -| `relay::relay_uploads` | Sessions on the relay are uploaded (4) | -| `relay::mailbox` / `relay::incoming` | The desktop mailbox: activation, credential, socket and poll, acks, catalog; incoming messages to shared Codex threads (2c, over 2b's outboxes) | -| `relay::cloud_chat` | Skip chat: requests, the saved conversation and project view, pushes (2c) | -| `relay::contractor_access` | Owner grants of scoped service access to contractor seats over `CloudAccess` (members via `relay::people`); this person's own grant to a `Provisioner`; the flag and per-workspace dismissals in `contractor-access.json` (2c) | -| `relay::spawn` | The spawn node: Relaycast `spawn:codex`/`spawn:claude`/`release` capacity, headless launches, spawned agents' registrations (native-spawn.md (relay-desktop `native-spawn.md`)) | -| `relay::telemetry` / `relay::login_item` | PostHog: schema, opt-out, identity, rate limits, the send queue; Linux launch at login (2c) | +| Module | Owns | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `relay::protocol` | Envelopes, events, error kinds, `BRIDGE_VERSION` | +| `relay::contract` | The method and topic registry with every parameter, result and event type; Swift generation (`codegen`, `relay-contract-codegen`); the Rust client's tolerant reading (`reader`) | +| `relay::server` | Accept loop, peer admission, handshake, dispatch, event forwarding | +| `relay::daemon` | `relay serve`, single-instance lock, socket lifecycle, `Core`, shutdown | +| `relay::state` | The snapshot and the event hub | +| `relay::paths` / `relay::peer` | Socket and lock paths; kernel peer credentials | +| `relay::workspace` / `relay::app_store` / `relay::machine` | Discovery, origin validation, the store's workspace fields, the machine prefix | +| `relay::client` | Relaycast clients over the SDK | +| `relay::sdk_gaps` | Relaycast surface the SDK lacks | +| `relay::push` | Direct node sockets | +| `relay::cli` | `relay call METHOD [--params JSON]`; `relay socket-request` and `relay subscribe `, the session-socket clients | +| `relay::methods` | The bridge methods for agents, messaging, people, links and settings (2a) | +| `relay::store` | The agent store: format, migration, flock, three-way merge, set-aside of unreadable files (2a) | +| `relay::agents` | Names, registration, rename, removal, webhooks, test sender, settings, cleanups (2a) | +| `relay::delivery` | Policy, injected text, the per-agent line of work, ack/fail, in-doubt, final answers (2a) | +| `relay::messaging` / `relay::people` | Person identity, DMs, exact-address proof; members, avatars and the roster (2a) | +| `relay::integrations` | Relayfile subscriptions (2a) | +| `relay::links` | Message endpoints and deep links (2a) | +| `relay::inject` | The `Injector` trait (2a) | +| `relay::sessions` | The `SessionDirectory` trait (2a), with `caller` and `fleet_owned` (2b) | +| `relay::process` | The kernel's process table (`/proc`; `libproc`, `KERN_PROCARGS2` and code signatures on macOS) and the Codex caller rules (2b) | +| `relay::codex` | Codex threads, rollouts, `codex queue`, the app-server proxy, the desktop app's IPC socket, the injection router (2b) | +| `relay::claude` | Claude Code's session registry, peer inbox, Claude app sessions and `claude --print --cloud`, transcripts (2b) | +| `relay::local` | `LocalSessions`, `LocalInjector` and the plugins `relay serve` runs with (2b) | +| `relay::local_methods` | `sessions.list`, `fleet.list`, `fleet.release`, the sessions/fleet watcher (2b) | +| `relay::outbound` | The relay-send socket and its routes (2b) | +| `relay::setup` | Agent-led setup routes and defaults (2b) | +| `relay::fleet` | Broker discovery and `/api/spawned` (2b) | +| `relay::claude_settings` | `crossSessionInbound` (2b) | +| `relay::claude_delivery_policy` | Per-session and per-sender Claude Code delivery consent plus the durable Ask-first queue (2b) | +| `relay::outbox` | The desktop mailbox's durable layer: the Codex outbox store (cursor, checkpoints, ack journal), Codex and Claude delivery workers, the sharing policy and inboxes (2b); its Cloud receiver is `relay::mailbox` (2c) | +| `relay::cloud_session` | The `CloudAccess` trait (2a); Cloud session calls: refresh, handoff exchange, revoke, `whoami`, workspace resolve; where the session is kept (2c) | +| `relay::cloud_http` / `relay::cloud_login` | One redirect-free, bounded Cloud request; browser (loopback + PKCE) and device sign-in (2c) | +| `relay::account` / `relay::account_methods` | Sign-in, the kept session, the account workspace, sign-out, the daemon's `CloudAccess`; the 2c bridge methods (2c) | +| `relay::uploader` / `relay::probe_copy` | The probe's commands, stages and collector supervision; the `~/.local/bin` copy (2c) | +| `relay::relay_uploads` | Sessions on the relay are uploaded (4) | +| `relay::mailbox` / `relay::incoming` | The desktop mailbox: activation, credential, socket and poll, acks, catalog; incoming messages to shared Codex threads (2c, over 2b's outboxes) | +| `relay::cloud_chat` | Skip chat: requests, the saved conversation and project view, pushes (2c) | +| `relay::contractor_access` | Owner grants of scoped service access to contractor seats over `CloudAccess` (members via `relay::people`); this person's own grant to a `Provisioner`; the flag and per-workspace dismissals in `contractor-access.json` (2c) | +| `relay::spawn` | The spawn node: Relaycast `spawn:codex`/`spawn:claude`/`release` capacity, headless launches, spawned agents' registrations (native-spawn.md (relay-desktop `native-spawn.md`)) | +| `relay::telemetry` / `relay::login_item` | PostHog: schema, opt-out, identity, rate limits, the send queue; Linux launch at login (2c) | ## Port map @@ -1095,31 +1139,31 @@ only the bridge, headless is the daemon; ### macOS (`macos/Sources`) -| Swift | Rust | PR | -|---|---|---| -| `RelayDesktopCore/InjectableAgents.swift` (`RelaycastWorkspace`, `validatedBaseURL`, `machinePrefix`) | `relay::workspace`, `relay::app_store`, `relay::machine` | 1 | -| `RelayDesktop/RelaycastPushChannel.swift` | `relay::push`, `relay::sdk_gaps` | 1 | -| `RelayDesktop/RelaycastClient.swift` | `relay::client`, `relay::sdk_gaps`, `relay::messaging`, `relay::people` | 1 (workspace), 2a (rest) | -| `RelayDesktopCore/InjectableAgents.swift` (names, targets, deliveries, policy, store, relayfile paths, integration events), `InjectableReplies.swift`, `InjectableIntegration.swift`, `InjectableStoreMerge.swift` | `relay::agents`, `relay::delivery`, `relay::store`, `relay::integrations` | 2a | -| `RelayDesktopCore/InjectableAgents.swift` (`CloudWorkspaceResolution`, `CloudSessionTokens`) | `relay::cloud_session` | 2c | -| `RelayDesktop/InjectableAgents.swift` (registration, rename, webhooks, test sender, delivery, final answers, integrations, direct conversations) | `relay::agents`, `relay::delivery`, `relay::integrations`, `relay::messaging` | 2a | -| `RelayDesktop/InjectableAgents.swift` (discovery, fleet, setup routes, Claude inbound setting, Cloud session) | `relay::sessions`, `relay::fleet`, `relay::setup`, `relay::claude_settings`, `relay::cloud_session` | 2b, 2c | -| `RelayDesktop/InjectableStoreFile.swift` | `relay::store` | 2a | -| `RelayDesktop/Outbound.swift` | `relay::outbound` | 2b | -| `RelayDesktopCore/FleetAgents.swift`, `FleetBridge.swift` | `relay::fleet` | 2b | -| `RelayDesktop/CodexBridge.swift`, `CodexQueueBridge.swift`, `CodexAppServerBridge.swift`, `CodexDelivery.swift`, `CodexInbox.swift`, `CodexIncomingRouter.swift`; Core `CodexIPCFraming.swift`, `CodexIPCMessages.swift`, `CodexOutbox.swift`, `CodexQueueCommand.swift`, `CodexRollout.swift`, `CodexSharing.swift`, `CodexInboundMessage.swift` | `relay::inject::codex`, `relay::sessions` | 2b | -| `RelayDesktop/ClaudeBridge.swift`, `ClaudeTerminalBridge.swift`, `ClaudeDelivery.swift`, `ClaudeInbox.swift`; Core `ClaudePeerFrames.swift`, `ClaudeSendCommand.swift`, `ClaudeTerminalSession.swift`, `ClaudeCloudSession.swift`, `ClaudeInboundMessage.swift` | `relay::inject::claude`, `relay::sessions` | 2b | -| Core `ClaudeInboundSetting.swift` | `relay::claude_settings` | 2b | -| `RelayDesktop/AgentRelayCloud.swift`, `DesktopDeviceLogin.swift`, `DesktopOAuthReceiver.swift` | `relay::cloud_session`, `relay::cloud_login`, `relay::account` (person credential, relayfile credential and connect links: 2a, through `CloudAccess`) | 2c | -| `RelayDesktop/DesktopCloudLogin.swift`, `DesktopCloudNetwork.swift`, `DesktopCloudReceiver.swift`, `DesktopCloudSession.swift`; Core `DesktopCloudContract.swift` | `relay::mailbox`, `relay::incoming` | 2c | -| `RelayDesktop/ProbeBridge.swift`, `ProbeInstaller.swift`, `Supervisor.swift`, `SessionPreview.swift`; Core `BridgeModels.swift`, `BridgeDecoder.swift`, `ProbeCommand.swift`, `ProbeRelease.swift`, `CaptureProgress.swift`, `NDJSON.swift` | `relay::uploader`, `relay::probe_copy`. `ProbeBridge.locate`'s choice of a newer `~/.local/bin` probe is gone: the app runs its bundled helper as the daemon, and an install a newer probe runs reports `upgrade_required` | 2c | -| `RelayDesktop/WorkspacePeopleModel.swift` (fetch); Core `AgentPeople.swift` | `relay::people` | 2a | -| `RelayDesktop/CloudChatClient.swift`, `CloudChatModel.swift` (persistence, requests); Core `SkipProjects.swift` | `relay::cloud_chat` | 2c | -| `RelayDesktop/ContractorAccessModel.swift` (requests); Core `ContractorAccess.swift`, `ContractorAccessHTTPClient.swift` | `relay::contractor_access` (the members roster: `relay::people`, 2a) | 2c | -| `RelayDesktop/DesktopTelemetry.swift` | `relay::telemetry` | 2c | -| Core `AgentEndpoint.swift`, `DesktopConnection.swift` | `relay::links` | 2a | -| Core `SessionUploadState.swift`, `UploadStages.swift`, `StatusDisplay.swift` (state derivations) | `relay::uploader` (typed `stage`, `needs_start`) | 2c | -| `RelayDesktop/AppModel.swift`, `InjectableAgents.swift` (sessions on the relay are uploaded), `ProbeBridge.swift` (upload health) | `relay::relay_uploads`, `relay::account` (upload health); `AppModel.swift` is a bridge client | 4 | +| Swift | Rust | PR | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | +| `RelayDesktopCore/InjectableAgents.swift` (`RelaycastWorkspace`, `validatedBaseURL`, `machinePrefix`) | `relay::workspace`, `relay::app_store`, `relay::machine` | 1 | +| `RelayDesktop/RelaycastPushChannel.swift` | `relay::push`, `relay::sdk_gaps` | 1 | +| `RelayDesktop/RelaycastClient.swift` | `relay::client`, `relay::sdk_gaps`, `relay::messaging`, `relay::people` | 1 (workspace), 2a (rest) | +| `RelayDesktopCore/InjectableAgents.swift` (names, targets, deliveries, policy, store, relayfile paths, integration events), `InjectableReplies.swift`, `InjectableIntegration.swift`, `InjectableStoreMerge.swift` | `relay::agents`, `relay::delivery`, `relay::store`, `relay::integrations` | 2a | +| `RelayDesktopCore/InjectableAgents.swift` (`CloudWorkspaceResolution`, `CloudSessionTokens`) | `relay::cloud_session` | 2c | +| `RelayDesktop/InjectableAgents.swift` (registration, rename, webhooks, test sender, delivery, final answers, integrations, direct conversations) | `relay::agents`, `relay::delivery`, `relay::integrations`, `relay::messaging` | 2a | +| `RelayDesktop/InjectableAgents.swift` (discovery, fleet, setup routes, Claude inbound setting, Cloud session) | `relay::sessions`, `relay::fleet`, `relay::setup`, `relay::claude_settings`, `relay::cloud_session` | 2b, 2c | +| `RelayDesktop/InjectableStoreFile.swift` | `relay::store` | 2a | +| `RelayDesktop/Outbound.swift` | `relay::outbound` | 2b | +| `RelayDesktopCore/FleetAgents.swift`, `FleetBridge.swift` | `relay::fleet` | 2b | +| `RelayDesktop/CodexBridge.swift`, `CodexQueueBridge.swift`, `CodexAppServerBridge.swift`, `CodexDelivery.swift`, `CodexInbox.swift`, `CodexIncomingRouter.swift`; Core `CodexIPCFraming.swift`, `CodexIPCMessages.swift`, `CodexOutbox.swift`, `CodexQueueCommand.swift`, `CodexRollout.swift`, `CodexSharing.swift`, `CodexInboundMessage.swift` | `relay::inject::codex`, `relay::sessions` | 2b | +| `RelayDesktop/ClaudeBridge.swift`, `ClaudeTerminalBridge.swift`, `ClaudeDelivery.swift`, `ClaudeInbox.swift`; Core `ClaudePeerFrames.swift`, `ClaudeSendCommand.swift`, `ClaudeTerminalSession.swift`, `ClaudeCloudSession.swift`, `ClaudeInboundMessage.swift` | `relay::inject::claude`, `relay::sessions` | 2b | +| Core `ClaudeInboundSetting.swift` | `relay::claude_settings` | 2b | +| `RelayDesktop/AgentRelayCloud.swift`, `DesktopDeviceLogin.swift`, `DesktopOAuthReceiver.swift` | `relay::cloud_session`, `relay::cloud_login`, `relay::account` (person credential, relayfile credential and connect links: 2a, through `CloudAccess`) | 2c | +| `RelayDesktop/DesktopCloudLogin.swift`, `DesktopCloudNetwork.swift`, `DesktopCloudReceiver.swift`, `DesktopCloudSession.swift`; Core `DesktopCloudContract.swift` | `relay::mailbox`, `relay::incoming` | 2c | +| `RelayDesktop/ProbeBridge.swift`, `ProbeInstaller.swift`, `Supervisor.swift`, `SessionPreview.swift`; Core `BridgeModels.swift`, `BridgeDecoder.swift`, `ProbeCommand.swift`, `ProbeRelease.swift`, `CaptureProgress.swift`, `NDJSON.swift` | `relay::uploader`, `relay::probe_copy`. `ProbeBridge.locate`'s choice of a newer `~/.local/bin` probe is gone: the app runs its bundled helper as the daemon, and an install a newer probe runs reports `upgrade_required` | 2c | +| `RelayDesktop/WorkspacePeopleModel.swift` (fetch); Core `AgentPeople.swift` | `relay::people` | 2a | +| `RelayDesktop/CloudChatClient.swift`, `CloudChatModel.swift` (persistence, requests); Core `SkipProjects.swift` | `relay::cloud_chat` | 2c | +| `RelayDesktop/ContractorAccessModel.swift` (requests); Core `ContractorAccess.swift`, `ContractorAccessHTTPClient.swift` | `relay::contractor_access` (the members roster: `relay::people`, 2a) | 2c | +| `RelayDesktop/DesktopTelemetry.swift` | `relay::telemetry` | 2c | +| Core `AgentEndpoint.swift`, `DesktopConnection.swift` | `relay::links` | 2a | +| Core `SessionUploadState.swift`, `UploadStages.swift`, `StatusDisplay.swift` (state derivations) | `relay::uploader` (typed `stage`, `needs_start`) | 2c | +| `RelayDesktop/AppModel.swift`, `InjectableAgents.swift` (sessions on the relay are uploaded), `ProbeBridge.swift` (upload health) | `relay::relay_uploads`, `relay::account` (upload health); `AppModel.swift` is a bridge client | 4 | The macOS app after PR 4: `RelayDesktopApp.swift`; `AppModel.swift` (bridge client: connection, account, uploader, settings) with `RelayModel.swift`, diff --git a/crates/probe/src/relay/account/workspace_recovery.rs b/crates/probe/src/relay/account/workspace_recovery.rs index 74053fa8da..64c4c6f018 100644 --- a/crates/probe/src/relay/account/workspace_recovery.rs +++ b/crates/probe/src/relay/account/workspace_recovery.rs @@ -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 @@ -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; @@ -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) diff --git a/crates/probe/src/relay/daemon.rs b/crates/probe/src/relay/daemon.rs index 7e0d7e0bd6..0978ad83ad 100644 --- a/crates/probe/src/relay/daemon.rs +++ b/crates/probe/src/relay/daemon.rs @@ -180,6 +180,9 @@ pub struct Core { pub(crate) config: Config, pub(crate) hub: Hub, workspace: Mutex>, + /// 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>, push: tokio::sync::Mutex, shutdown: watch::Sender, pub(crate) machine_prefix: String, @@ -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, @@ -422,12 +426,21 @@ 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 { + self.discovery().ok().flatten() + } + + /// Like [`Core::discover`], with the printable reason the CLI's + /// selection may not be used. + fn discovery(&self) -> Result, 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, ) } @@ -435,7 +448,22 @@ impl Core { /// 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; @@ -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 @@ -1051,6 +1083,7 @@ mod key_migration_tests { key: Secret::new(key), base_url: "https://cast.test".into(), workspace_id: None, + source: Default::default(), } } diff --git a/crates/probe/src/relay/desktop_identity.rs b/crates/probe/src/relay/desktop_identity.rs index b2e6e73b88..455334e446 100644 --- a/crates/probe/src/relay/desktop_identity.rs +++ b/crates/probe/src/relay/desktop_identity.rs @@ -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(); diff --git a/crates/probe/src/relay/messaging.rs b/crates/probe/src/relay/messaging.rs index 6a9618d58a..893866b21c 100644 --- a/crates/probe/src/relay/messaging.rs +++ b/crates/probe/src/relay/messaging.rs @@ -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") } @@ -351,7 +367,8 @@ impl Core { pub(crate) fn person_push_target(&self) -> Option { 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, @@ -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 { + 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 @@ -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(), @@ -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, @@ -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"; @@ -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"); diff --git a/crates/probe/src/relay/spawn.rs b/crates/probe/src/relay/spawn.rs index 336b8669b8..99c8b2f4f2 100644 --- a/crates/probe/src/relay/spawn.rs +++ b/crates/probe/src/relay/spawn.rs @@ -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()); diff --git a/crates/probe/src/relay/spawn/control/tests.rs b/crates/probe/src/relay/spawn/control/tests.rs index 3fa3badd1e..a6eb47f84a 100644 --- a/crates/probe/src/relay/spawn/control/tests.rs +++ b/crates/probe/src/relay/spawn/control/tests.rs @@ -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] diff --git a/crates/probe/src/relay/state.rs b/crates/probe/src/relay/state.rs index 755c9da166..bfd0d22a55 100644 --- a/crates/probe/src/relay/state.rs +++ b/crates/probe/src/relay/state.rs @@ -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))); diff --git a/crates/probe/src/relay/workspace.rs b/crates/probe/src/relay/workspace.rs index a87efabed0..cb913ae03b 100644 --- a/crates/probe/src/relay/workspace.rs +++ b/crates/probe/src/relay/workspace.rs @@ -1,9 +1,29 @@ -//! The Relaycast workspace this computer's agents live in: the signed-in -//! account's workspace (`cloud` in the agent store), the same one uploads go -//! to. Sign-in writes it; nothing else names a workspace key. +//! The Relaycast workspace this computer's agents live in. Two sources can +//! name it, and the daemon uses exactly one at a time: //! -//! `RELAYCAST_URL` overrides the origin, but only a bare https origin (http -//! only for localhost) is accepted; anything else leaves the account's. +//! 1. **The `agent-relay` CLI** (`cli`): the workspace the CLI itself would +//! use on this machine, by its own precedence (environment key, the +//! `AGENT_RELAY_PROJECT` project pin, then the active entry of +//! `$AGENT_RELAY_HOME/workspaces.json`) and its own origin rule +//! (`relaycast-origins.json` bindings, `RELAY_BASE_URL`, else the hosted +//! default). This is how `relay init`'s local and self-hosted servers work +//! with no Cloud sign-in, and it keeps the probe in the same workspace as +//! the CLI's MCP tools in every session. +//! 2. **The Cloud sign-in** (`cloud` in the agent store, written by the +//! desktop sign-in), used only when the CLI selects nothing — a desktop +//! user who never ran the CLI. `RELAYCAST_URL` overrides its origin, but +//! only a bare https origin (http only for localhost) is accepted; +//! anything else leaves the account's. +//! +//! Each source's key goes only to that source's origin: `RELAYCAST_URL` +//! never re-origins a CLI key, and a CLI binding never re-origins the +//! account's. When the CLI's files forbid using the key it selects (a +//! malformed `relaycast-origins.json`, a bad `RELAY_BASE_URL`, an unreadable +//! `workspaces.json`), resolution fails closed with that reason instead of +//! falling back to the account's workspace. + +pub mod cli; +pub mod origin_binding; use crate::relay::app_store::CloudWorkspace; use serde::{Deserialize, Serialize}; @@ -42,11 +62,61 @@ pub struct Workspace { pub base_url: String, /// The Relaycast workspace id (`rw_…`), when sign-in recorded it. pub workspace_id: Option, + /// Which source named this workspace. Only a `Cloud` key is ever + /// recovered through the Cloud account. + pub source: WorkspaceSource, +} + +/// Where the daemon's workspace key came from. Printable: it names a +/// variable or a file, never key material. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum WorkspaceSource { + /// The desktop's Cloud sign-in (`cloud` in the agent store). + #[default] + Cloud, + /// A CLI workspace-key environment variable (its name). + CliEnv(String), + /// The CLI project pin `/.agentworkforce/relay/workspace-key.json`. + CliProject(PathBuf), + /// The active entry of the CLI's `workspaces.json`. + CliStore { file: PathBuf, name: String }, +} + +impl WorkspaceSource { + pub fn is_cloud(&self) -> bool { + matches!(self, WorkspaceSource::Cloud) + } + + /// Where the key came from, for activity and errors. + pub fn describe(&self) -> String { + match self { + WorkspaceSource::Cloud => "your Agent Relay account".to_string(), + WorkspaceSource::CliEnv(name) => format!("${name}"), + WorkspaceSource::CliProject(file) => file.display().to_string(), + WorkspaceSource::CliStore { file, name } => { + format!("{} (active: \"{name}\")", file.display()) + } + } + } +} + +/// The daemon's workspace: the CLI's when the CLI selects one, else the +/// Cloud sign-in's (see the module docs). `Ok(None)` when neither names one; +/// `Err` with a printable reason when the CLI's selection may not be used. +pub fn resolve( + cloud: Option<&CloudWorkspace>, + env: &dyn Fn(&str) -> Option, + home: &Path, +) -> Result, String> { + match cli::resolve(env, &relay_home(env, home))? { + Some(workspace) => Ok(Some(workspace)), + None => Ok(resolve_cloud(cloud, env)), + } } /// The account's workspace, at its own origin unless `RELAYCAST_URL` /// overrides it; `None` when signed out or its key is not a workspace key. -pub fn resolve( +pub fn resolve_cloud( cloud: Option<&CloudWorkspace>, env: &dyn Fn(&str) -> Option, ) -> Option { @@ -59,6 +129,7 @@ pub fn resolve( key: cloud.key.clone(), base_url, workspace_id: cloud.workspace_id.clone(), + source: WorkspaceSource::Cloud, }) } @@ -130,7 +201,7 @@ mod tests { .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(); - resolve(cloud.as_ref(), &move |key: &str| env.get(key).cloned()) + resolve_cloud(cloud.as_ref(), &move |key: &str| env.get(key).cloned()) } fn account(key: &str, base_url: &str) -> CloudWorkspace { @@ -143,9 +214,9 @@ mod tests { } #[test] - fn the_account_workspace_is_the_only_workspace() { + fn the_cloud_source_is_the_account_workspace() { assert!(resolve_with(&[], None).is_none()); - // Environment keys name no workspace. + // Environment keys are the CLI source's, never the account's. assert!(resolve_with(&[("RELAY_WORKSPACE_KEY", "rk_live_env00000")], None).is_none()); let found = resolve_with( &[], @@ -159,6 +230,163 @@ mod tests { assert!(resolve_with(&[], Some(account("at_live_agenttoken", DEFAULT_BASE_URL))).is_none()); } + /// `resolve` over a fresh home whose CLI state `setup` writes. + fn combined( + env: &[(&str, &str)], + cloud: Option, + setup: impl FnOnce(&Path), + ) -> Result, String> { + let home = tempfile::tempdir().unwrap(); + let relay = home.path().join(".agentworkforce/relay"); + std::fs::create_dir_all(&relay).unwrap(); + setup(&relay); + let env: HashMap = env + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + resolve( + cloud.as_ref(), + &move |key: &str| env.get(key).cloned(), + home.path(), + ) + } + + fn store(relay: &Path, key: &str) { + std::fs::write( + relay.join("workspaces.json"), + format!(r#"{{"active":"local","workspaces":{{"local":{{"key":"{key}"}}}}}}"#), + ) + .unwrap(); + } + + fn bind(relay: &Path, key: &str, base_url: &str) { + std::fs::write( + relay.join("relaycast-origins.json"), + format!( + r#"{{"version":1,"origins":{{"{}":"{base_url}"}}}}"#, + origin_binding::credential_fingerprint(key) + ), + ) + .unwrap(); + } + + #[test] + fn the_cli_workspace_wins_over_the_cloud_sign_in() { + let found = combined( + &[], + Some(account("rk_live_account00", DEFAULT_BASE_URL)), + |relay| { + store(relay, "rk_live_cli_local0"); + bind(relay, "rk_live_cli_local0", "http://127.0.0.1:8787"); + }, + ) + .unwrap() + .unwrap(); + assert_eq!(found.key.expose(), "rk_live_cli_local0"); + assert_eq!(found.base_url, "http://127.0.0.1:8787"); + assert!(!found.source.is_cloud()); + // The account's recorded id is the account's; the CLI's is verified. + assert_eq!(found.workspace_id, None); + } + + #[test] + fn the_cloud_sign_in_is_used_only_when_the_cli_selects_nothing() { + let found = combined( + &[], + Some(account("rk_live_account00", "http://localhost:9999")), + |_| {}, + ) + .unwrap() + .unwrap(); + assert_eq!(found.key.expose(), "rk_live_account00"); + assert_eq!(found.base_url, "http://localhost:9999"); + assert_eq!(found.source, WorkspaceSource::Cloud); + // An active name with no saved entry selects nothing either. + let found = combined( + &[], + Some(account("rk_live_account00", DEFAULT_BASE_URL)), + |relay| { + std::fs::write( + relay.join("workspaces.json"), + r#"{"active":"gone","workspaces":{}}"#, + ) + .unwrap() + }, + ) + .unwrap() + .unwrap(); + assert!(found.source.is_cloud()); + assert!(combined(&[], None, |_| {}).unwrap().is_none()); + } + + #[test] + fn neither_source_lends_its_origin_to_the_other() { + // RELAYCAST_URL re-origins only the account's key. + let found = combined( + &[("RELAYCAST_URL", "https://relay.example.com")], + Some(account("rk_live_account00", DEFAULT_BASE_URL)), + |relay| store(relay, "rk_live_cli_hosted"), + ) + .unwrap() + .unwrap(); + assert_eq!(found.key.expose(), "rk_live_cli_hosted"); + assert_eq!(found.base_url, DEFAULT_BASE_URL); + // A binding of the account's key never re-origins it either: the + // account's key keeps the account's origin. + let found = combined( + &[], + Some(account("rk_live_account00", DEFAULT_BASE_URL)), + |relay| bind(relay, "rk_live_account00", "https://relay.corp.test"), + ) + .unwrap() + .unwrap(); + assert!(found.source.is_cloud()); + assert_eq!(found.base_url, DEFAULT_BASE_URL); + } + + #[test] + fn a_broken_cli_selection_fails_closed_instead_of_falling_back_to_the_account() { + let error = combined( + &[], + Some(account("rk_live_account00", DEFAULT_BASE_URL)), + |relay| { + store(relay, "rk_live_cli_local0"); + std::fs::write(relay.join("relaycast-origins.json"), "{").unwrap(); + }, + ) + .unwrap_err(); + assert!(error.contains("relaycast-origins.json"), "{error}"); + assert!(!error.contains("rk_live"), "{error}"); + let error = combined( + &[], + Some(account("rk_live_account00", DEFAULT_BASE_URL)), + |relay| std::fs::write(relay.join("workspaces.json"), "not json").unwrap(), + ) + .unwrap_err(); + assert!(error.contains("workspaces.json"), "{error}"); + // A store the CLI throws on is not "nothing selected" either. + let error = combined( + &[], + Some(account("rk_live_account00", DEFAULT_BASE_URL)), + |relay| std::fs::write(relay.join("workspaces.json"), "null").unwrap(), + ) + .unwrap_err(); + assert!(error.contains("workspaces.json"), "{error}"); + } + + #[test] + fn agent_relay_home_locates_the_cli_state() { + let elsewhere = tempfile::tempdir().unwrap(); + store(elsewhere.path(), "rk_live_isolated0"); + let relay_home = elsewhere.path().to_string_lossy().into_owned(); + let found = combined(&[("AGENT_RELAY_HOME", &relay_home)], None, |relay| { + store(relay, "rk_live_default00") + }) + .unwrap() + .unwrap(); + assert_eq!(found.key.expose(), "rk_live_isolated0"); + } + #[test] fn relaycast_url_overrides_the_origin_only_when_valid() { let cloud = || Some(account("rk_live_account00", "https://cast.agentrelay.com")); diff --git a/crates/probe/src/relay/workspace/cli.rs b/crates/probe/src/relay/workspace/cli.rs new file mode 100644 index 0000000000..b9686db1ff --- /dev/null +++ b/crates/probe/src/relay/workspace/cli.rs @@ -0,0 +1,483 @@ +//! The workspace the `agent-relay` CLI selects, read and never written. +//! +//! This follows the CLI's precedence ladder +//! (`packages/cloud/src/project-workspace-key.ts` `resolveWorkspaceSelection`) +//! and its transport rule (`packages/cli/src/cli/lib/sdk-client.ts` +//! `resolveWorkspaceTransport`), minus the `--workspace-key` flag the daemon +//! has no equivalent of: +//! +//! 1. **env**: `RELAY_WORKSPACE_KEY` > `AGENT_RELAY_WORKSPACE_KEY` > `RELAY_API_KEY`; +//! 2. **project**: `/.agentworkforce/relay/workspace-key.json`, where +//! the project is `AGENT_RELAY_PROJECT` (the CLI's explicit project root; +//! the daemon has no working directory of its own to search from). An +//! absent or malformed pin falls through, as in the CLI; +//! 3. **store**: the active entry of `$AGENT_RELAY_HOME/workspaces.json` +//! (`{ active, workspaces: { name: { key } } }`). A file that exists but +//! cannot be read or parsed is an error, as in the CLI. +//! +//! The destination is then chosen the CLI's way: +//! +//! - a persisted project route (`relaycastRoute` + `relaycastBaseUrl`) must be +//! one of Agent Relay's own gateways, and a credential bound to another +//! server is refused rather than sent there; +//! - otherwise a non-blank `RELAY_BASE_URL` is used (a malformed one is an +//! error, never a fallback); +//! - otherwise the credential's binding in `relaycast-origins.json`; +//! - otherwise the hosted default, `https://cast.agentrelay.com`. +//! +//! Whenever a credential is about to be used the bindings file must parse: +//! a malformed one fails closed. + +use super::origin_binding::{self, OriginBindings}; +use super::{is_workspace_key, Secret, Workspace, WorkspaceSource, DEFAULT_BASE_URL}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +/// Workspace-key environment variables, highest precedence first. +pub const WORKSPACE_KEY_ENV_VARS: [&str; 3] = [ + "RELAY_WORKSPACE_KEY", + "AGENT_RELAY_WORKSPACE_KEY", + "RELAY_API_KEY", +]; +/// The CLI's explicit project root. +pub const PROJECT_ENV: &str = "AGENT_RELAY_PROJECT"; +/// The explicit Relaycast base URL the CLI honours. +pub const BASE_URL_ENV: &str = "RELAY_BASE_URL"; +pub const WORKSPACE_STORE_FILE: &str = "workspaces.json"; +pub const PROJECT_WORKSPACE_KEY_FILE: &str = "workspace-key.json"; +pub const RELAYCAST_CREDENTIALS_FILE: &str = "relaycast-credentials.json"; +const PROJECT_DATA_DIR: &str = ".agentworkforce/relay"; + +const CANONICAL_RELAYCAST_ORIGIN: &str = "https://cast.agentrelay.com"; +const AGENT37_RELAYCAST_ORIGIN: &str = "https://agent37-cast.agentrelay.com"; +const DEV_RELAYCAST_ORIGIN: &str = "https://dev-cast.agentrelay.com"; +const DEV_CLOUD_API_URL: &str = "https://dev.agentrelay.com/cloud"; + +/// The project pin, as the CLI reads it (`readProjectWorkspaceSession`). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct ProjectSession { + workspace_key: String, + route: Option, + relaycast_base_url: Option, + relaycast_cloud_api_url: Option, + /// The route credential: the one the pin's `relaycastApiKeyRef` names in + /// `relaycast-credentials.json` when it matches the pin, else a legacy + /// inline `relaycastApiKey`. + relaycast_api_key: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Route { + Canonical, + Agent37Isolated, +} + +impl Route { + fn parse(value: &str) -> Option { + match value { + "canonical" => Some(Route::Canonical), + "agent37-isolated" => Some(Route::Agent37Isolated), + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + Route::Canonical => "canonical", + Route::Agent37Isolated => "agent37-isolated", + } + } +} + +/// One selected key, where it came from, and the project route it carries. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Selection { + key: String, + source: WorkspaceSource, + project: Option, +} + +/// The CLI's workspace for this machine: `Ok(None)` when the CLI selects +/// none, `Err` with a printable reason (never key material) when its files +/// or settings forbid using the one it selects. +pub fn resolve( + env: &dyn Fn(&str) -> Option, + relay_home: &Path, +) -> Result, String> { + let Some(selection) = select(env, relay_home)? else { + return Ok(None); + }; + transport(selection, env, relay_home).map(Some) +} + +fn select( + env: &dyn Fn(&str) -> Option, + relay_home: &Path, +) -> Result, String> { + let data_dir = env(PROJECT_ENV) + .and_then(trimmed) + .map(|root| project_data_dir(Path::new(&root))); + let project = data_dir + .as_deref() + .and_then(|dir| read_project_session(dir, relay_home)); + + for name in WORKSPACE_KEY_ENV_VARS { + if let Some(key) = env(name).and_then(trimmed) { + // The pin's route rides along only for the same key, as in the CLI. + let project = project.filter(|project| project.workspace_key == key); + return Ok(Some(Selection { + key, + source: WorkspaceSource::CliEnv(name.to_string()), + project, + })); + } + } + if let (Some(project), Some(dir)) = (project, data_dir) { + return Ok(Some(Selection { + key: project.workspace_key.clone(), + source: WorkspaceSource::CliProject(dir.join(PROJECT_WORKSPACE_KEY_FILE)), + project: Some(project), + })); + } + let store = relay_home.join(WORKSPACE_STORE_FILE); + Ok(read_active_store_key(&store)?.map(|(name, key)| Selection { + key, + source: WorkspaceSource::CliStore { file: store, name }, + project: None, + })) +} + +fn transport( + selection: Selection, + env: &dyn Fn(&str) -> Option, + relay_home: &Path, +) -> Result { + let project = selection.project.as_ref(); + let credential = project + .and_then(|project| project.relaycast_api_key.clone()) + .unwrap_or_else(|| selection.key.clone()); + if !is_workspace_key(&credential) { + return Err(format!( + "The workspace key from {} is not a Relaycast workspace key.", + selection.source.describe() + )); + } + let bindings = origin_binding::read_bindings(&origin_binding::bindings_path(relay_home))?; + let base_url = match persisted_route_origin(project)? { + Some(persisted) => { + // A derived destination never moves a binding. + accept_derived_destination(&bindings, &credential, &persisted)?; + persisted + } + None => match env(BASE_URL_ENV) { + Some(requested) => origin_binding::configured_base_url(&requested) + .map_err(|reason| format!("{BASE_URL_ENV} {reason}."))?, + None => None, + } + .or_else(|| bindings.bound(&credential).map(str::to_string)) + .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()), + }; + Ok(Workspace { + key: Secret::new(credential), + base_url, + // The pin's `workspaceId` is the Cloud's name for the workspace, not + // necessarily Relaycast's; verification records Relaycast's own. + workspace_id: None, + source: selection.source, + }) +} + +/// `validatePersistedRelaycastBaseUrl`: no route is `None`; a route must be +/// complete and name the gateway its kind belongs to, bare https. +fn persisted_route_origin(project: Option<&ProjectSession>) -> Result, String> { + let Some(project) = project else { + return Ok(None); + }; + if project.relaycast_base_url.is_none() + && project.route.is_none() + && project.relaycast_api_key.is_none() + { + return Ok(None); + } + let (Some(base_url), Some(route)) = (&project.relaycast_base_url, project.route) else { + return Err("The persisted Relaycast workspace route is incomplete.".into()); + }; + let expected = match route { + Route::Canonical + if project.relaycast_cloud_api_url.as_deref() == Some(DEV_CLOUD_API_URL) => + { + DEV_RELAYCAST_ORIGIN + } + Route::Canonical => CANONICAL_RELAYCAST_ORIGIN, + Route::Agent37Isolated => AGENT37_RELAYCAST_ORIGIN, + }; + let untrusted = || "The persisted Relaycast workspace route is not trusted.".to_string(); + let parsed = url::Url::parse(base_url) + .map_err(|_| "The persisted Relaycast workspace route is invalid.".to_string())?; + if parsed.scheme() != "https" + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.port().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || !(parsed.path().is_empty() || parsed.path() == "/") + || parsed.origin().ascii_serialization() != expected + { + return Err(untrusted()); + } + if route == Route::Agent37Isolated && project.relaycast_api_key.is_none() { + return Err( + "The persisted isolated Relaycast credential is unavailable or mismatched.".into(), + ); + } + Ok(Some(expected.to_string())) +} + +/// `acceptDerivedDestination`, without the write: a bound credential may go +/// only to its own server. +fn accept_derived_destination( + bindings: &OriginBindings, + credential: &str, + destination: &str, +) -> Result<(), String> { + let Some(bound) = bindings.bound(credential) else { + return Ok(()); + }; + let bound_origin = origin_binding::origin_of(bound).unwrap_or_else(|| bound.to_string()); + let destination = origin_binding::origin_of(destination).unwrap_or_default(); + if bound_origin == destination { + return Ok(()); + } + Err(format!( + "This Relaycast credential belongs to {bound_origin}, so it will not be sent to {destination}." + )) +} + +/// `/.agentworkforce/relay`, the root made absolute against the +/// daemon's working directory as the CLI's `path.resolve` would. +fn project_data_dir(root: &Path) -> PathBuf { + let root = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf()); + root.join(PROJECT_DATA_DIR) +} + +/// The project pin, or `None` when absent or malformed (the CLI falls +/// through to the machine store then). +fn read_project_session(data_dir: &Path, relay_home: &Path) -> Option { + let raw = std::fs::read_to_string(data_dir.join(PROJECT_WORKSPACE_KEY_FILE)).ok()?; + let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?; + let text = |field: &str| { + parsed + .get(field) + .and_then(|v| v.as_str()) + .and_then(trimmed_str) + }; + let workspace_key = text("workspaceKey")?; + let workspace_id = text("workspaceId"); + let route = parsed + .get("relaycastRoute") + .and_then(|v| v.as_str()) + .and_then(Route::parse); + let relaycast_base_url = text("relaycastBaseUrl"); + let api_key_ref = text("relaycastApiKeyRef"); + let relaycast_api_key = match api_key_ref { + Some(reference) => { + let expected = match (&workspace_id, route, &relaycast_base_url) { + (Some(id), Some(route), Some(base_url)) => { + Some(credential_ref(data_dir, id, route.as_str(), base_url)) + } + _ => None, + }; + if expected.as_deref() == Some(reference.as_str()) { + read_route_credential( + relay_home, + &reference, + workspace_id.as_deref()?, + route?, + relaycast_base_url.as_deref()?, + ) + } else { + None + } + } + None => text("relaycastApiKey"), + }; + Some(ProjectSession { + workspace_key, + route, + relaycast_base_url, + relaycast_cloud_api_url: text("relaycastCloudApiUrl"), + relaycast_api_key, + }) +} + +/// A route credential from `relaycast-credentials.json`, only when it +/// records the same workspace, route and server as the pin. +fn read_route_credential( + relay_home: &Path, + reference: &str, + workspace_id: &str, + route: Route, + base_url: &str, +) -> Option { + let raw = std::fs::read_to_string(relay_home.join(RELAYCAST_CREDENTIALS_FILE)).ok()?; + let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?; + let entry = parsed.get("credentials")?.get(reference)?; + let field = |name: &str| entry.get(name).and_then(|v| v.as_str()); + (field("workspaceId") == Some(workspace_id) + && field("route") == Some(route.as_str()) + && field("baseUrl") == Some(base_url)) + .then(|| field("apiKey").map(str::to_string)) + .flatten() +} + +/// `relaycastCredentialRef`: SHA-256 hex of the canonical project data +/// directory, the workspace id, the route and the server's origin, joined +/// by NULs. +fn credential_ref(data_dir: &Path, workspace_id: &str, route: &str, base_url: &str) -> String { + let trimmed = base_url.trim(); + let endpoint = origin_binding::origin_of(trimmed).unwrap_or_else(|| trimmed.to_string()); + let mut hasher = Sha256::new(); + hasher.update(canonical_data_dir(data_dir).as_bytes()); + hasher.update([0]); + hasher.update(workspace_id.as_bytes()); + hasher.update([0]); + hasher.update(route.as_bytes()); + hasher.update([0]); + hasher.update(endpoint.as_bytes()); + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// The data directory resolved through symlinks (its missing tail kept), +/// case-folded on Windows, as the CLI's `canonicalProjectDataDir`. +fn canonical_data_dir(data_dir: &Path) -> String { + let resolved = std::path::absolute(data_dir).unwrap_or_else(|_| data_dir.to_path_buf()); + let mut current = resolved.clone(); + let mut missing = Vec::new(); + let canonical = loop { + match std::fs::canonicalize(¤t) { + Ok(real) => { + break missing + .iter() + .rev() + .fold(real, |path, part| path.join(part)) + } + Err(_) => match (current.parent(), current.file_name()) { + (Some(parent), Some(name)) => { + missing.push(name.to_os_string()); + current = parent.to_path_buf(); + } + _ => break resolved, + }, + } + }; + let text = canonical.to_string_lossy().into_owned(); + if cfg!(windows) { + without_verbatim_prefix(&text).to_lowercase() + } else { + text + } +} + +/// A Windows path as Node's `fs.realpathSync.native` prints it: libuv strips +/// the `\\?\` verbatim prefix `GetFinalPathNameByHandle` (and so +/// `std::fs::canonicalize`) returns, and turns `\\?\UNC\server\share` back +/// into `\\server\share`. Any other verbatim form (a volume GUID) is kept. +fn without_verbatim_prefix(path: &str) -> String { + if let Some(rest) = path.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{rest}"); + } + match path.strip_prefix(r"\\?\") { + Some(rest) if is_drive_path(rest) => rest.to_string(), + _ => path.to_string(), + } +} + +/// `C:` or `C:\…`. +fn is_drive_path(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 2 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && (bytes.len() == 2 || bytes[2] == b'\\') +} + +/// The active store entry's name and key. A missing file selects nothing; +/// one that cannot be read or parsed is an error, as in the CLI. +/// +/// The CLI (`readWorkspaceStore` then `resolveActiveWorkspaceSelection`) +/// throws on a top-level `null` and on an `active` or entry `key` that is +/// present but not a string, so those fail closed here too. A `null` or +/// missing `workspaces` is `{}` there (`parsed.workspaces ?? {}`), so it +/// selects nothing, as does an entry that is not an object. +fn read_active_store_key(file: &Path) -> Result, String> { + let raw = match std::fs::read_to_string(file) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => { + return Err(format!( + "Could not read {}; fix or remove it.", + file.display() + )) + } + }; + let parsed: serde_json::Value = serde_json::from_str(&raw) + .map_err(|_| format!("{} is not valid JSON; fix or remove it.", file.display()))?; + let malformed = |what: &str| { + format!( + "{} is not a valid workspace store ({what}); fix or remove it.", + file.display() + ) + }; + if parsed.is_null() { + return Err(malformed("it is null")); + } + let Some(active) = optional_string(parsed.get("active")) + .map_err(|_| malformed("\"active\" is not a string"))? + .and_then(trimmed_str) + else { + return Ok(None); + }; + let entry = match parsed.get("workspaces") { + Some(serde_json::Value::Object(workspaces)) => workspaces.get(&active), + // `workspaces[active]` on an array reads an index, as in JavaScript. + Some(serde_json::Value::Array(workspaces)) => active + .parse::() + .ok() + .filter(|index| index.to_string() == active) + .and_then(|index| workspaces.get(index)), + _ => None, + }; + let key = match entry { + Some(serde_json::Value::Object(entry)) => optional_string(entry.get("key")) + .map_err(|_| malformed("the active entry's \"key\" is not a string"))? + .and_then(trimmed_str), + _ => None, + }; + Ok(key.map(|key| (active, key))) +} + +/// A field JavaScript would `?.trim()`: absent or `null` is unset, a string +/// is itself, anything else throws. +fn optional_string(value: Option<&serde_json::Value>) -> Result, ()> { + match value { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::String(text)) => Ok(Some(text)), + Some(_) => Err(()), + } +} + +fn trimmed(value: String) -> Option { + trimmed_str(&value) +} + +fn trimmed_str(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/probe/src/relay/workspace/cli/tests.rs b/crates/probe/src/relay/workspace/cli/tests.rs new file mode 100644 index 0000000000..232547d862 --- /dev/null +++ b/crates/probe/src/relay/workspace/cli/tests.rs @@ -0,0 +1,651 @@ +use super::*; +use std::collections::HashMap; + +const LOCAL: &str = "http://127.0.0.1:8787"; + +/// A home with the CLI's state directory and a project checkout. +struct Machine { + _dir: tempfile::TempDir, + relay: PathBuf, + project: PathBuf, + env: HashMap, +} + +impl Machine { + fn new() -> Machine { + let dir = tempfile::tempdir().unwrap(); + let relay = dir.path().join("relay-home"); + let project = dir.path().join("checkout"); + std::fs::create_dir_all(&relay).unwrap(); + std::fs::create_dir_all(project.join(PROJECT_DATA_DIR)).unwrap(); + Machine { + _dir: dir, + relay, + project, + env: HashMap::new(), + } + } + + fn env(mut self, name: &str, value: &str) -> Machine { + self.env.insert(name.into(), value.into()); + self + } + + fn in_project(self) -> Machine { + let root = self.project.to_string_lossy().into_owned(); + self.env(PROJECT_ENV, &root) + } + + fn store(self, active: &str, entries: &[(&str, &str)]) -> Machine { + let workspaces: serde_json::Map = entries + .iter() + .map(|(name, key)| (name.to_string(), serde_json::json!({ "key": key }))) + .collect(); + self.write_relay( + WORKSPACE_STORE_FILE, + &serde_json::json!({ "active": active, "workspaces": workspaces }).to_string(), + ) + } + + fn pin(self, pin: serde_json::Value) -> Machine { + std::fs::write( + self.project + .join(PROJECT_DATA_DIR) + .join(PROJECT_WORKSPACE_KEY_FILE), + pin.to_string(), + ) + .unwrap(); + self + } + + fn bind(self, bindings: &[(&str, &str)]) -> Machine { + let origins: serde_json::Map = bindings + .iter() + .map(|(key, base_url)| { + ( + origin_binding::credential_fingerprint(key), + serde_json::Value::from(*base_url), + ) + }) + .collect(); + self.write_relay( + origin_binding::ORIGIN_BINDINGS_FILE, + &serde_json::json!({ "version": 1, "origins": origins }).to_string(), + ) + } + + fn write_relay(self, file: &str, contents: &str) -> Machine { + std::fs::write(self.relay.join(file), contents).unwrap(); + self + } + + fn resolve(&self) -> Result, String> { + let env = self.env.clone(); + resolve(&move |key: &str| env.get(key).cloned(), &self.relay) + } + + fn found(&self) -> Workspace { + self.resolve().unwrap().expect("a CLI workspace") + } + + fn data_dir(&self) -> PathBuf { + self.project.join(PROJECT_DATA_DIR) + } +} + +fn key_and_origin(workspace: &Workspace) -> (&str, &str) { + (workspace.key.expose(), workspace.base_url.as_str()) +} + +// --------------------------------------------------------------------------- +// Precedence + +#[test] +fn nothing_selected_is_none() { + assert_eq!(Machine::new().resolve().unwrap(), None); + assert_eq!(Machine::new().in_project().resolve().unwrap(), None); + // Blank values select nothing. + let machine = Machine::new() + .env("RELAY_WORKSPACE_KEY", " ") + .store(" ", &[(" ", "rk_live_blank0000")]); + assert_eq!(machine.resolve().unwrap(), None); +} + +#[test] +fn environment_keys_rank_in_the_cli_order() { + let all = Machine::new() + .in_project() + .env("RELAY_WORKSPACE_KEY", "rk_live_env_first") + .env("AGENT_RELAY_WORKSPACE_KEY", "rk_live_env_second") + .env("RELAY_API_KEY", "rk_live_env_third0") + .pin(serde_json::json!({ "workspaceKey": "rk_live_project00" })) + .store("local", &[("local", "rk_live_store0000")]); + let found = all.found(); + assert_eq!(found.key.expose(), "rk_live_env_first"); + assert_eq!( + found.source, + WorkspaceSource::CliEnv("RELAY_WORKSPACE_KEY".into()) + ); + + let found = Machine::new() + .env("RELAY_WORKSPACE_KEY", " ") + .env("AGENT_RELAY_WORKSPACE_KEY", " rk_live_env_second ") + .env("RELAY_API_KEY", "rk_live_env_third0") + .found(); + assert_eq!(found.key.expose(), "rk_live_env_second"); + assert_eq!( + found.source, + WorkspaceSource::CliEnv("AGENT_RELAY_WORKSPACE_KEY".into()) + ); + + let found = Machine::new() + .env("RELAY_API_KEY", "rk_live_env_third0") + .store("local", &[("local", "rk_live_store0000")]) + .found(); + assert_eq!(found.key.expose(), "rk_live_env_third0"); +} + +#[test] +fn the_project_pin_outranks_the_machine_store() { + let machine = Machine::new() + .in_project() + .pin(serde_json::json!({ "workspaceKey": " rk_live_project00 " })) + .store("local", &[("local", "rk_live_store0000")]); + let found = machine.found(); + assert_eq!(found.key.expose(), "rk_live_project00"); + assert_eq!( + found.source, + WorkspaceSource::CliProject(machine.data_dir().join(PROJECT_WORKSPACE_KEY_FILE)) + ); +} + +#[test] +fn the_project_pin_is_read_only_for_an_explicit_project() { + // Without AGENT_RELAY_PROJECT the daemon has no project: the machine's + // active workspace applies. + let found = Machine::new() + .pin(serde_json::json!({ "workspaceKey": "rk_live_project00" })) + .store("local", &[("local", "rk_live_store0000")]) + .found(); + assert_eq!(found.key.expose(), "rk_live_store0000"); +} + +#[test] +fn a_malformed_or_keyless_pin_falls_through_to_the_store() { + for pin in [ + "not json", + "{}", + r#"{"workspaceKey":" "}"#, + r#"{"workspaceKey":7}"#, + ] { + let machine = Machine::new() + .in_project() + .store("local", &[("local", "rk_live_store0000")]); + std::fs::write(machine.data_dir().join(PROJECT_WORKSPACE_KEY_FILE), pin).unwrap(); + assert_eq!(machine.found().key.expose(), "rk_live_store0000", "{pin}"); + } +} + +#[test] +fn the_store_uses_its_active_entry() { + let machine = Machine::new().store( + "local", + &[ + ("hosted", "rk_live_hosted000"), + ("local", "rk_live_local0000"), + ], + ); + let found = machine.found(); + assert_eq!(found.key.expose(), "rk_live_local0000"); + assert_eq!( + found.source, + WorkspaceSource::CliStore { + file: machine.relay.join(WORKSPACE_STORE_FILE), + name: "local".into(), + } + ); + assert!(found.source.describe().contains("(active: \"local\")")); + // No active entry, or one with no saved key, selects nothing. + let machine = Machine::new().write_relay( + WORKSPACE_STORE_FILE, + r#"{"workspaces":{"local":{"key":"rk_live_local0000"}}}"#, + ); + assert_eq!(machine.resolve().unwrap(), None); + let machine = Machine::new().store("gone", &[("local", "rk_live_local0000")]); + assert_eq!(machine.resolve().unwrap(), None); +} + +#[test] +fn an_unreadable_store_fails_closed() { + let machine = Machine::new().write_relay(WORKSPACE_STORE_FILE, "{ not json"); + let error = machine.resolve().unwrap_err(); + assert!(error.contains(WORKSPACE_STORE_FILE), "{error}"); + let machine = Machine::new(); + std::fs::create_dir(machine.relay.join(WORKSPACE_STORE_FILE)).unwrap(); + assert!(machine.resolve().is_err()); +} + +#[test] +fn a_store_the_cli_would_throw_on_fails_closed() { + // packages/cloud/src/workspace-store.ts readWorkspaceStore reads + // `parsed.previous` (a TypeError on `null`), and + // resolveActiveWorkspaceSelection `?.trim()`s `active` and the entry's + // `key` (a TypeError on any other non-string). + for contents in [ + "null", + r#"{"active":7,"workspaces":{"7":{"key":"rk_live_local0000"}}}"#, + r#"{"active":true,"workspaces":{}}"#, + r#"{"active":"local","workspaces":{"local":{"key":7}}}"#, + r#"{"active":"local","workspaces":{"local":{"key":{"value":"rk_live_local0000"}}}}"#, + ] { + let machine = Machine::new().write_relay(WORKSPACE_STORE_FILE, contents); + let error = machine.resolve().unwrap_err(); + assert!(error.contains(WORKSPACE_STORE_FILE), "{contents}: {error}"); + assert!(!error.contains("rk_live"), "{contents}: {error}"); + } +} + +#[test] +fn a_store_the_cli_reads_as_empty_selects_nothing() { + // `parsed.workspaces ?? {}`: a null or missing map is empty in the CLI, + // and an entry that is not an object has no `key`. + for contents in [ + r#"{"active":"local","workspaces":null}"#, + r#"{"active":"local"}"#, + r#"{"active":null,"workspaces":{"local":{"key":"rk_live_local0000"}}}"#, + r#"{"active":"local","workspaces":{"local":null}}"#, + r#"{"active":"local","workspaces":{"local":"rk_live_local0000"}}"#, + r#"{"active":"local","workspaces":{"local":{"key":null}}}"#, + r#"{"active":"local","workspaces":"local"}"#, + "[]", + "7", + r#""rk_live_local0000""#, + ] { + let machine = Machine::new().write_relay(WORKSPACE_STORE_FILE, contents); + assert_eq!(machine.resolve(), Ok(None), "{contents}"); + } +} + +#[test] +fn a_rotated_store_key_is_picked_up_on_the_next_resolve() { + let machine = Machine::new() + .store("local", &[("local", "rk_live_before000")]) + .bind(&[("rk_live_before000", LOCAL)]); + assert_eq!(machine.found().key.expose(), "rk_live_before000"); + let machine = machine + .store("local", &[("local", "rk_live_after0000")]) + .bind(&[("rk_live_before000", LOCAL), ("rk_live_after0000", LOCAL)]); + let found = machine.found(); + assert_eq!(key_and_origin(&found), ("rk_live_after0000", LOCAL)); +} + +#[test] +fn only_workspace_keys_are_used() { + let error = Machine::new() + .env("RELAY_API_KEY", "at_live_agent_token") + .resolve() + .unwrap_err(); + assert!(error.contains("$RELAY_API_KEY"), "{error}"); + assert!(!error.contains("at_live"), "{error}"); +} + +// --------------------------------------------------------------------------- +// Origins + +#[test] +fn an_unbound_credential_goes_to_the_hosted_default() { + let found = Machine::new() + .store("hosted", &[("hosted", "rk_live_hosted000")]) + .bind(&[("rk_live_other0000", "https://relay.corp.test")]) + .found(); + assert_eq!(found.base_url, DEFAULT_BASE_URL); +} + +#[test] +fn a_bound_credential_goes_only_to_its_server() { + // relay init's local server: http on loopback with a port. + let found = Machine::new() + .store("local", &[("local", "rk_live_local0000")]) + .bind(&[("rk_live_local0000", LOCAL)]) + .found(); + assert_eq!(key_and_origin(&found), ("rk_live_local0000", LOCAL)); + // A self-hosted server behind a path prefix and a port. + let found = Machine::new() + .env("RELAY_WORKSPACE_KEY", "rk_live_selfhost0") + .bind(&[( + "rk_live_selfhost0", + "https://relay.corp.test:8443/relaycast", + )]) + .found(); + assert_eq!(found.base_url, "https://relay.corp.test:8443/relaycast"); +} + +#[test] +fn the_shared_fixture_binding_resolves_its_server() { + #[derive(serde::Deserialize)] + struct Vector { + credential: String, + normalized_base_url: String, + binding_file: serde_json::Value, + } + let vector: Vector = serde_json::from_str(include_str!( + "../../../../../../packages/contracts/fixtures/relaycast-origin-binding.json" + )) + .unwrap(); + // The fixture's credential is not rk_-shaped beyond its prefix check, so + // pass it through the environment as an operator would. + let found = Machine::new() + .env("RELAY_WORKSPACE_KEY", &vector.credential) + .write_relay( + origin_binding::ORIGIN_BINDINGS_FILE, + &vector.binding_file.to_string(), + ) + .found(); + assert_eq!(found.key.expose(), vector.credential.trim()); + assert_eq!(found.base_url, vector.normalized_base_url); +} + +#[test] +fn relay_base_url_is_the_explicit_destination() { + let found = Machine::new() + .store("local", &[("local", "rk_live_local0000")]) + .bind(&[("rk_live_local0000", LOCAL)]) + .env(BASE_URL_ENV, "http://localhost:8799/") + .found(); + assert_eq!(found.base_url, "http://localhost:8799"); + // Blank is unset: the binding applies. + let found = Machine::new() + .store("local", &[("local", "rk_live_local0000")]) + .bind(&[("rk_live_local0000", LOCAL)]) + .env(BASE_URL_ENV, " ") + .found(); + assert_eq!(found.base_url, LOCAL); +} + +#[test] +fn a_malformed_relay_base_url_never_falls_back() { + for bad in [ + "not a url", + "ftp://relay.corp.test", + "https://u:p@relay.corp.test", + ] { + let error = Machine::new() + .store("local", &[("local", "rk_live_local0000")]) + .bind(&[("rk_live_local0000", LOCAL)]) + .env(BASE_URL_ENV, bad) + .resolve() + .unwrap_err(); + assert!(error.contains(BASE_URL_ENV), "{bad}: {error}"); + } +} + +#[test] +fn a_malformed_bindings_file_fails_closed_even_with_an_explicit_url() { + for machine in [ + Machine::new().store("local", &[("local", "rk_live_local0000")]), + Machine::new() + .store("local", &[("local", "rk_live_local0000")]) + .env(BASE_URL_ENV, LOCAL), + ] { + let machine = machine.write_relay(origin_binding::ORIGIN_BINDINGS_FILE, "{]"); + let error = machine.resolve().unwrap_err(); + assert!(error.contains("relaycast-origins.json"), "{error}"); + } + // With nothing selected there is no credential to protect. + let machine = Machine::new().write_relay(origin_binding::ORIGIN_BINDINGS_FILE, "{]"); + assert_eq!(machine.resolve().unwrap(), None); +} + +// --------------------------------------------------------------------------- +// Persisted project routes + +#[test] +fn a_canonical_route_uses_its_gateway() { + let found = Machine::new() + .in_project() + .pin(serde_json::json!({ + "workspaceKey": "rk_live_project00", + "relaycastRoute": "canonical", + "relaycastBaseUrl": "https://cast.agentrelay.com/", + })) + .env(BASE_URL_ENV, "https://relay.corp.test") + .found(); + // The persisted route outranks RELAY_BASE_URL, as in the CLI. + assert_eq!( + key_and_origin(&found), + ("rk_live_project00", DEFAULT_BASE_URL) + ); + + let found = Machine::new() + .in_project() + .pin(serde_json::json!({ + "workspaceKey": "rk_live_project00", + "relaycastRoute": "canonical", + "relaycastBaseUrl": DEV_RELAYCAST_ORIGIN, + "relaycastCloudApiUrl": DEV_CLOUD_API_URL, + })) + .found(); + assert_eq!(found.base_url, DEV_RELAYCAST_ORIGIN); +} + +#[test] +fn an_isolated_route_uses_its_matching_route_credential() { + let machine = Machine::new().in_project(); + let reference = credential_ref( + &machine.data_dir(), + "ws_1", + "agent37-isolated", + AGENT37_RELAYCAST_ORIGIN, + ); + let credentials = |api_key: &str, workspace_id: &str| { + serde_json::json!({ "credentials": { reference.clone(): { + "workspaceId": workspace_id, + "route": "agent37-isolated", + "baseUrl": AGENT37_RELAYCAST_ORIGIN, + "apiKey": api_key, + }}}) + .to_string() + }; + let pin = serde_json::json!({ + "workspaceKey": "rk_live_project00", + "workspaceId": "ws_1", + "relaycastRoute": "agent37-isolated", + "relaycastBaseUrl": AGENT37_RELAYCAST_ORIGIN, + "relaycastApiKeyRef": reference, + }); + let machine = machine.pin(pin.clone()).write_relay( + RELAYCAST_CREDENTIALS_FILE, + &credentials("rk_live_route0000", "ws_1"), + ); + let found = machine.found(); + assert_eq!( + key_and_origin(&found), + ("rk_live_route0000", AGENT37_RELAYCAST_ORIGIN) + ); + + // A stored credential recorded for another workspace is not this pin's, + // and an isolated route without its credential is refused. + let machine = machine.write_relay( + RELAYCAST_CREDENTIALS_FILE, + &credentials("rk_live_route0000", "ws_other"), + ); + let error = machine.resolve().unwrap_err(); + assert!(error.contains("isolated"), "{error}"); + + // A tampered reference is never followed. + let mut tampered = pin; + tampered["relaycastApiKeyRef"] = serde_json::Value::from("0".repeat(64)); + let machine = machine.pin(tampered).write_relay( + RELAYCAST_CREDENTIALS_FILE, + &credentials("rk_live_route0000", "ws_1"), + ); + assert!(machine.resolve().is_err()); +} + +#[test] +fn an_environment_key_inherits_only_its_own_project_route() { + let pin = serde_json::json!({ + "workspaceKey": "rk_live_project00", + "relaycastRoute": "canonical", + "relaycastBaseUrl": DEV_RELAYCAST_ORIGIN, + "relaycastCloudApiUrl": DEV_CLOUD_API_URL, + }); + let same = Machine::new() + .in_project() + .pin(pin.clone()) + .env("RELAY_WORKSPACE_KEY", "rk_live_project00") + .found(); + assert_eq!(same.base_url, DEV_RELAYCAST_ORIGIN); + let other = Machine::new() + .in_project() + .pin(pin) + .env("RELAY_WORKSPACE_KEY", "rk_live_other0000") + .found(); + assert_eq!(other.base_url, DEFAULT_BASE_URL); +} + +#[test] +fn untrusted_or_incomplete_routes_are_refused() { + for pin in [ + serde_json::json!({ "workspaceKey": "rk_live_project00", "relaycastRoute": "canonical" }), + serde_json::json!({ "workspaceKey": "rk_live_project00", "relaycastBaseUrl": DEFAULT_BASE_URL }), + serde_json::json!({ "workspaceKey": "rk_live_project00", "relaycastRoute": "mystery", "relaycastBaseUrl": DEFAULT_BASE_URL }), + serde_json::json!({ "workspaceKey": "rk_live_project00", "relaycastRoute": "canonical", "relaycastBaseUrl": "https://relay.corp.test" }), + serde_json::json!({ "workspaceKey": "rk_live_project00", "relaycastRoute": "canonical", "relaycastBaseUrl": "https://cast.agentrelay.com:8443" }), + serde_json::json!({ "workspaceKey": "rk_live_project00", "relaycastRoute": "canonical", "relaycastBaseUrl": "https://cast.agentrelay.com/v1" }), + // The dev gateway needs the dev Cloud. + serde_json::json!({ "workspaceKey": "rk_live_project00", "relaycastRoute": "canonical", "relaycastBaseUrl": DEV_RELAYCAST_ORIGIN }), + ] { + let machine = Machine::new().in_project().pin(pin.clone()); + assert!(machine.resolve().is_err(), "{pin}"); + } +} + +#[test] +fn a_bound_credential_is_never_sent_to_a_persisted_route() { + let error = Machine::new() + .in_project() + .pin(serde_json::json!({ + "workspaceKey": "rk_live_project00", + "relaycastRoute": "canonical", + "relaycastBaseUrl": DEFAULT_BASE_URL, + })) + .bind(&[("rk_live_project00", "https://relay.corp.test")]) + .resolve() + .unwrap_err(); + assert!(error.contains("https://relay.corp.test"), "{error}"); + assert!(!error.contains("rk_live"), "{error}"); +} + +// --------------------------------------------------------------------------- +// Read-only + +#[test] +fn resolving_never_writes_the_cli_state() { + let machine = Machine::new() + .in_project() + .pin(serde_json::json!({ "workspaceKey": "rk_live_project00" })) + .store("local", &[("local", "rk_live_store0000")]) + .env(BASE_URL_ENV, "https://relay.corp.test"); + let snapshot = |dir: &Path| { + let mut entries: Vec<_> = std::fs::read_dir(dir) + .unwrap() + .map(|entry| { + let entry = entry.unwrap(); + (entry.file_name(), std::fs::read(entry.path()).ok()) + }) + .collect(); + entries.sort(); + entries + }; + let (relay, project) = (snapshot(&machine.relay), snapshot(&machine.data_dir())); + // The CLI would bind the key to RELAY_BASE_URL here; the probe only reads. + assert_eq!(machine.found().base_url, "https://relay.corp.test"); + assert_eq!(snapshot(&machine.relay), relay); + assert_eq!(snapshot(&machine.data_dir()), project); +} + +#[test] +fn verbatim_windows_paths_print_as_node_prints_them() { + for (canonicalized, node) in [ + (r"\\?\C:\Users\dev\checkout", r"C:\Users\dev\checkout"), + (r"\\?\C:\", r"C:\"), + (r"\\?\d:", r"d:"), + (r"\\?\UNC\server\share\checkout", r"\\server\share\checkout"), + // Already plain, or a verbatim form with no plain spelling. + (r"C:\Users\dev", r"C:\Users\dev"), + (r"\\server\share", r"\\server\share"), + ( + r"\\?\Volume{0b1c2d3e-0000-0000-0000-000000000000}\x", + r"\\?\Volume{0b1c2d3e-0000-0000-0000-000000000000}\x", + ), + ] { + assert_eq!( + without_verbatim_prefix(canonicalized), + node, + "{canonicalized}" + ); + } +} + +#[cfg(windows)] +#[test] +fn the_credential_reference_matches_the_cli_on_windows() { + // relaycastCredentialRef(dataDir, "ws_1", "agent37-isolated", + // "https://agent37-cast.agentrelay.com/") on Windows: only `C:\` exists, + // which realpathSync.native prints as `C:\`; the missing tail is kept + // and the whole path lowercased. That is the SHA-256 of + // "c:\nonexistent-relay-probe-test\checkout\.agentworkforce\relay\0ws_1\0agent37-isolated\0https://agent37-cast.agentrelay.com". + let expected = "ec05ee466537a841736141b4fe2e820c17a13c17f0d3072ceb248f5eeefb8342"; + for spelling in [ + r"C:\nonexistent-relay-probe-test\checkout\.agentworkforce\relay", + r"c:\NonExistent-Relay-Probe-Test\Checkout\.AgentWorkforce\Relay", + ] { + let reference = credential_ref( + Path::new(spelling), + "ws_1", + "agent37-isolated", + "https://agent37-cast.agentrelay.com/", + ); + assert_eq!(reference, expected, "{spelling}"); + } + + // An existing directory canonicalizes to `\\?\C:\…` in Rust; the CLI + // hashes the plain, lowercased path, and any casing alias of it. + let machine = Machine::new().in_project(); + let data_dir = machine.data_dir(); + let canonical = canonical_data_dir(&data_dir); + assert!(!canonical.starts_with(r"\\?\"), "{canonical}"); + assert!(is_drive_path(&canonical), "{canonical}"); + assert_eq!(canonical, canonical.to_lowercase()); + let alias = PathBuf::from(data_dir.to_string_lossy().to_uppercase()); + assert_eq!(canonical_data_dir(&alias), canonical); + let digest = Sha256::digest( + format!("{canonical}\0ws_1\0agent37-isolated\0{AGENT37_RELAYCAST_ORIGIN}").as_bytes(), + ); + let expected: String = digest.iter().map(|byte| format!("{byte:02x}")).collect(); + for dir in [&data_dir, &alias] { + assert_eq!( + credential_ref(dir, "ws_1", "agent37-isolated", AGENT37_RELAYCAST_ORIGIN), + expected + ); + } +} + +#[cfg(unix)] +#[test] +fn the_credential_reference_matches_the_cli() { + // packages/cloud/src/workspace-store.ts relaycastCredentialRef(dataDir, + // "ws_1", "agent37-isolated", "https://agent37-cast.agentrelay.com/") for + // a data dir that does not exist (so the path is kept as given). + let reference = credential_ref( + Path::new("/nonexistent-relay-probe-test/checkout/.agentworkforce/relay"), + "ws_1", + "agent37-isolated", + "https://agent37-cast.agentrelay.com/", + ); + assert_eq!( + reference, + "5efe173d0f0bf1522a9926ff2f455c7f72744ad6d3e6673091b23590e13b0510" + ); +} diff --git a/crates/probe/src/relay/workspace/origin_binding.rs b/crates/probe/src/relay/workspace/origin_binding.rs new file mode 100644 index 0000000000..dd8bef1312 --- /dev/null +++ b/crates/probe/src/relay/workspace/origin_binding.rs @@ -0,0 +1,304 @@ +//! The `agent-relay` CLI's Relaycast origin bindings +//! (`$AGENT_RELAY_HOME/relaycast-origins.json`), read and never written. +//! +//! The CLI records which self-hosted server each credential belongs to, keyed +//! by a one-way fingerprint of the credential, so that a self-hosted key is +//! never sent to the hosted gateway. Hosted servers (`*.agentrelay.com`) are +//! the default and are never bound. A file that cannot be read or parsed fails +//! closed: ignoring it would unbind every credential and send it to the +//! hosted default. This mirrors `packages/cli/src/cli/lib/relaycast-origin-binding.ts` +//! and the broker's `crates/broker/src/transport.rs`; all three are checked +//! against `packages/contracts/fixtures/relaycast-origin-binding.json`. + +use std::collections::HashMap; +use std::num::NonZeroU32; +use std::path::{Path, PathBuf}; + +/// The bindings file, next to the CLI's `workspaces.json`. +pub const ORIGIN_BINDINGS_FILE: &str = "relaycast-origins.json"; + +const FINGERPRINT_SALT: &[u8] = b"agent-relay:relaycast-origin-binding"; +const FINGERPRINT_ITERATIONS: NonZeroU32 = match NonZeroU32::new(10_000) { + Some(iterations) => iterations, + None => panic!("PBKDF2 needs at least one iteration"), +}; +const HOSTED_DOMAIN: &str = "agentrelay.com"; + +/// The validated bindings: credential fingerprint → self-hosted base URL. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct OriginBindings { + origins: HashMap, +} + +impl OriginBindings { + /// The self-hosted server `credential` is bound to, if any. + pub fn bound(&self, credential: &str) -> Option<&str> { + self.origins + .get(&credential_fingerprint(credential)) + .map(String::as_str) + } +} + +/// `/relaycast-origins.json`. +pub fn bindings_path(relay_home: &Path) -> PathBuf { + relay_home.join(ORIGIN_BINDINGS_FILE) +} + +/// Reads the bindings file. Missing means no bindings; anything unreadable, +/// unparsable, of an unknown version, or holding a bad fingerprint or server +/// is an error naming the file (never a credential). +pub fn read_bindings(path: &Path) -> Result { + let raw = match std::fs::read_to_string(path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(OriginBindings::default()); + } + Err(_) => { + return Err(format!( + "Could not read {}; fix or remove it.", + path.display() + )) + } + }; + let invalid = |detail: &str| { + format!( + "{} is not a valid Relaycast origin file ({detail}); fix or remove it.", + path.display() + ) + }; + let parsed: serde_json::Value = serde_json::from_str(&raw).map_err(|_| invalid("not JSON"))?; + let object = parsed.as_object().ok_or_else(|| invalid("not an object"))?; + if object.get("version").and_then(serde_json::Value::as_f64) != Some(1.0) { + return Err(invalid("unknown version")); + } + let origins = object + .get("origins") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| invalid("\"origins\" is not an object"))?; + let mut validated = HashMap::with_capacity(origins.len()); + for (fingerprint, value) in origins { + if !is_fingerprint(fingerprint) { + return Err(invalid(&format!("bad fingerprint \"{fingerprint}\""))); + } + let base_url = value + .as_str() + .and_then(|value| configured_base_url(value).ok().flatten()) + .filter(|base_url| !is_hosted(base_url)) + .ok_or_else(|| invalid(&format!("bad server for \"{fingerprint}\"")))?; + validated.insert(fingerprint.clone(), base_url); + } + Ok(OriginBindings { origins: validated }) +} + +/// The CLI's one-way credential fingerprint: PBKDF2-HMAC-SHA256 of the +/// trimmed credential, 10,000 iterations, 16 bytes, lowercase hex. +pub fn credential_fingerprint(credential: &str) -> String { + let mut derived = [0u8; 16]; + ring::pbkdf2::derive( + ring::pbkdf2::PBKDF2_HMAC_SHA256, + FINGERPRINT_ITERATIONS, + FINGERPRINT_SALT, + credential.trim().as_bytes(), + &mut derived, + ); + derived.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn is_fingerprint(value: &str) -> bool { + value.len() == 32 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// The CLI's `configuredBaseUrl`: blank is unset (`Ok(None)`); otherwise an +/// http(s) URL without credentials, query or fragment, reduced to its origin +/// plus any path prefix without trailing slashes. Anything else is an error, +/// because falling back to the hosted default would send the credential +/// somewhere nobody chose. +pub fn configured_base_url(value: &str) -> Result, String> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(None); + } + let parsed = url::Url::parse(trimmed).map_err(|_| "is not a valid URL".to_string())?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err("must be an http(s) URL".into()); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("must not contain credentials".into()); + } + if parsed.query().is_some_and(|query| !query.is_empty()) + || parsed + .fragment() + .is_some_and(|fragment| !fragment.is_empty()) + { + return Err("must not contain a query or fragment".into()); + } + let origin = parsed.origin(); + if !origin.is_tuple() { + return Err("is not a valid URL".into()); + } + Ok(Some(format!( + "{}{}", + origin.ascii_serialization(), + parsed.path().trim_end_matches('/') + ))) +} + +/// `base_url`'s origin (`scheme://host[:port]`), if it parses. +pub fn origin_of(base_url: &str) -> Option { + let origin = url::Url::parse(base_url).ok()?.origin(); + origin.is_tuple().then(|| origin.ascii_serialization()) +} + +/// Agent Relay's own gateways (`https://*.agentrelay.com`): the default, +/// never bound. +pub fn is_hosted(base_url: &str) -> bool { + url::Url::parse(base_url).is_ok_and(|url| { + let host = url.host_str().unwrap_or_default(); + url.scheme() == "https" + && (host == HOSTED_DOMAIN || host.ends_with(&format!(".{HOSTED_DOMAIN}"))) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Deserialize)] + struct SharedVector { + credential: String, + fingerprint: String, + base_url_input: String, + normalized_base_url: String, + binding_file: serde_json::Value, + } + + /// The vector the CLI's TypeScript tests and the broker's Rust tests read. + fn shared_vector() -> SharedVector { + serde_json::from_str(include_str!( + "../../../../../packages/contracts/fixtures/relaycast-origin-binding.json" + )) + .expect("the shared Relaycast origin vector is valid JSON") + } + + fn write(dir: &Path, contents: &str) -> PathBuf { + let path = bindings_path(dir); + std::fs::write(&path, contents).unwrap(); + path + } + + #[test] + fn fingerprint_and_binding_file_match_the_shared_cli_and_broker_contract() { + let vector = shared_vector(); + assert_eq!( + credential_fingerprint(&vector.credential), + vector.fingerprint + ); + assert_eq!( + configured_base_url(&vector.base_url_input) + .unwrap() + .as_deref(), + Some(vector.normalized_base_url.as_str()) + ); + let dir = tempfile::tempdir().unwrap(); + let path = write(dir.path(), &vector.binding_file.to_string()); + let bindings = read_bindings(&path).unwrap(); + // The fingerprint trims, as the CLI's does. + assert_eq!( + bindings.bound(vector.credential.trim()), + Some(vector.normalized_base_url.as_str()) + ); + assert_eq!( + bindings.bound(&vector.credential), + Some(vector.normalized_base_url.as_str()) + ); + assert_eq!(bindings.bound("rk_live_some_other_key"), None); + } + + #[test] + fn a_missing_file_binds_nothing() { + let dir = tempfile::tempdir().unwrap(); + let bindings = read_bindings(&bindings_path(dir.path())).unwrap(); + assert_eq!(bindings.bound("rk_live_anything"), None); + } + + #[test] + fn malformed_files_fail_closed() { + let dir = tempfile::tempdir().unwrap(); + for bad in [ + "not json", + "[]", + "null", + r#"{"version":2,"origins":{}}"#, + r#"{"origins":{}}"#, + r#"{"version":1}"#, + r#"{"version":1,"origins":[]}"#, + r#"{"version":1,"origins":{"NOT-A-FINGERPRINT":"https://relay.corp.test"}}"#, + r#"{"version":1,"origins":{"0dcecdc46ef84167c45e73cf1e859dd4":"https://cast.agentrelay.com"}}"#, + r#"{"version":1,"origins":{"0dcecdc46ef84167c45e73cf1e859dd4":"ftp://relay.corp.test"}}"#, + r#"{"version":1,"origins":{"0dcecdc46ef84167c45e73cf1e859dd4":42}}"#, + r#"{"version":1,"origins":{"0dcecdc46ef84167c45e73cf1e859dd4":"https://u:p@relay.corp.test"}}"#, + ] { + let path = write(dir.path(), bad); + let error = read_bindings(&path).expect_err(bad); + assert!(error.contains("relaycast-origins.json"), "{error}"); + } + } + + #[cfg(unix)] + #[test] + fn an_unreadable_file_fails_closed() { + let dir = tempfile::tempdir().unwrap(); + // A directory where the file should be cannot be read as one. + std::fs::create_dir(bindings_path(dir.path())).unwrap(); + assert!(read_bindings(&bindings_path(dir.path())).is_err()); + } + + #[test] + fn base_urls_normalize_like_the_cli() { + for (input, expected) in [ + ("http://127.0.0.1:8787", "http://127.0.0.1:8787"), + ("http://127.0.0.1:8787/", "http://127.0.0.1:8787"), + ("http://LOCALHOST:8787", "http://localhost:8787"), + ("https://relay.corp.test:443/", "https://relay.corp.test"), + ( + "https://relay.corp.test/relaycast/", + "https://relay.corp.test/relaycast", + ), + ("http://[::1]:8787", "http://[::1]:8787"), + ( + " https://cast.agentrelay.com ", + "https://cast.agentrelay.com", + ), + ] { + assert_eq!( + configured_base_url(input).unwrap().as_deref(), + Some(expected), + "{input}" + ); + } + assert_eq!(configured_base_url(" ").unwrap(), None); + for bad in [ + "relay.corp.test", + "ftp://relay.corp.test", + "https://u:p@relay.corp.test", + "https://relay.corp.test/?q=1", + "https://relay.corp.test/#f", + ] { + assert!(configured_base_url(bad).is_err(), "{bad}"); + } + } + + #[test] + fn only_https_agentrelay_hosts_are_hosted() { + assert!(is_hosted("https://cast.agentrelay.com")); + assert!(is_hosted("https://agent37-cast.agentrelay.com")); + assert!(is_hosted("https://agentrelay.com")); + assert!(!is_hosted("http://cast.agentrelay.com")); + assert!(!is_hosted("https://evilagentrelay.com")); + assert!(!is_hosted("http://127.0.0.1:8787")); + } +} diff --git a/crates/probe/src/relay/workspace_recovery_tests.rs b/crates/probe/src/relay/workspace_recovery_tests.rs index 6367385c12..3a8849019c 100644 --- a/crates/probe/src/relay/workspace_recovery_tests.rs +++ b/crates/probe/src/relay/workspace_recovery_tests.rs @@ -652,3 +652,82 @@ async fn recovery_refused_key_is_not_rechecked_in_a_loop() { fn workspace_stale_ms() -> i64 { super::key_rotation::REVERIFY_VERIFIED_AFTER.as_millis() as i64 + 1_000 } + +/// Points this machine's `agent-relay` CLI at `key`, bound to `site`. +fn select_cli_workspace(h: &Harness, key: &str) { + let relay = h._temp.path().join(".agentworkforce/relay"); + std::fs::create_dir_all(&relay).unwrap(); + std::fs::write( + relay.join("workspaces.json"), + json!({"active":"local","workspaces":{"local":{"key":key}}}).to_string(), + ) + .unwrap(); + std::fs::write( + relay.join("relaycast-origins.json"), + json!({"version":1,"origins":{ + crate::relay::workspace::origin_binding::credential_fingerprint(key): h.fake.site, + }}) + .to_string(), + ) + .unwrap(); +} + +/// A refused key the CLI named is never recovered through the Cloud +/// account, even when it is the account's key too: the CLI owns it. +#[tokio::test] +async fn recovery_never_applies_to_a_cli_workspace() { + let h = Harness::new().await; + select_cli_workspace(&h, OLD); + h.core.rediscover_workspace(); + let workspace = h.core.workspace().unwrap(); + assert!(!workspace.source.is_cloud()); + assert_eq!(workspace.base_url, h.fake.site); + assert!(h.verify().await.is_err()); + assert_eq!( + h.core.status().workspace.verification, + Verification::Rejected + ); + assert_eq!(h.fake.count("/auth/token/refresh"), 0); + assert_eq!(h.fake.count("/resolve"), 0); + assert!(h.cloud_key_is(OLD)); +} + +/// A key rotated in the CLI's `workspaces.json` is picked up and verified by +/// the next refresh pass, without asking Cloud. +#[tokio::test] +async fn refresh_picks_up_a_key_rotated_in_the_cli_store() { + let h = Harness::new().await; + h.fake.state.lock().unwrap().old_status = 200; + select_cli_workspace(&h, OLD); + assert_eq!( + h.core.refresh_workspace().await.verification, + Verification::Verified + ); + select_cli_workspace(&h, NEW); + let status = h.core.refresh_workspace().await; + assert_eq!(status.verification, Verification::Verified); + assert_eq!(status.workspace_id.as_deref(), Some("rw_review")); + assert_eq!(h.core.workspace().unwrap().key.expose(), NEW); + assert_eq!(h.fake.new_gets(), 1); + assert_eq!(h.fake.count("/resolve"), 0); + // The account's own key is untouched. + assert!(h.cloud_key_is(OLD)); +} + +/// A CLI selection that may not be used leaves the daemon with no +/// workspace rather than falling back to the account's. +#[tokio::test] +async fn a_refused_cli_selection_does_not_fall_back_to_the_account() { + let h = Harness::new().await; + assert!(h.core.workspace().unwrap().source.is_cloud()); + select_cli_workspace(&h, OLD); + std::fs::write( + h._temp + .path() + .join(".agentworkforce/relay/relaycast-origins.json"), + "{", + ) + .unwrap(); + assert!(!h.core.rediscover_workspace()); + assert!(h.core.workspace().is_none()); +} diff --git a/docs/probe-move.md b/docs/probe-move.md index dd0fe2c147..785ffc4df7 100644 --- a/docs/probe-move.md +++ b/docs/probe-move.md @@ -114,21 +114,76 @@ relay-desktop CI checks that are not ported yet: - arm64 Linux, x64 macOS and arm64 Windows lanes; - the Pi live-session scripts. -## Remaining phases +## Phase 2: the probe reads the CLI's workspace (done) + +`src/relay/workspace.rs` now resolves the daemon's one Relaycast workspace +from two sources, read-only (the probe never writes the CLI's files): + +1. **The `agent-relay` CLI** (`src/relay/workspace/cli.rs`), mirroring + `packages/cloud/src/project-workspace-key.ts` `resolveWorkspaceSelection` + and `packages/cli/src/cli/lib/sdk-client.ts` `resolveWorkspaceTransport` + (minus the `--workspace-key` flag): + 1. the env key: `RELAY_WORKSPACE_KEY` > `AGENT_RELAY_WORKSPACE_KEY` > + `RELAY_API_KEY`; + 2. the project pin `/.agentworkforce/relay/workspace-key.json`, + only when `AGENT_RELAY_PROJECT` names the project (the CLI's explicit + project root; the daemon has no working directory to search from). A + missing or malformed pin falls through, as in the CLI. Persisted + Relaycast routes (`relaycastRoute`/`relaycastBaseUrl`, and the route + credential `relaycastApiKeyRef` names in `relaycast-credentials.json`) + are honoured with the CLI's checks; + 3. the active entry of `$AGENT_RELAY_HOME|~/.agentworkforce/relay/workspaces.json`. + + Origin: a persisted route (Agent Relay gateways only; a key bound + elsewhere is refused), else `RELAY_BASE_URL`, else the key's binding in + `relaycast-origins.json` (`src/relay/workspace/origin_binding.rs`: + PBKDF2-HMAC-SHA256, salt `agent-relay:relaycast-origin-binding`, 10,000 + iterations, 16 bytes hex, via the `ring` already linked; tested against + `packages/contracts/fixtures/relaycast-origin-binding.json`, the vector the + CLI and the broker use), else `https://cast.agentrelay.com`. A bound key + goes only to its server; `http://127.0.0.1:8787` and other self-hosted + http(s) base URLs (ports and path prefixes included) are accepted as the + CLI accepts them. A malformed bindings file, an unreadable `workspaces.json` + or a malformed `RELAY_BASE_URL` fails closed. + +2. **The Cloud sign-in** (`cloud` in the agent store; `RELAYCAST_URL` still + re-origins only this key), used only when the CLI names nothing. + +**Combination rule.** The CLI source wins whenever it names a workspace, so +the probe registers and injects in the same workspace the CLI's MCP tools in +each session use; within the CLI source an explicit env key or project pin +outranks the machine store, exactly as in the CLI. The Cloud sign-in is the +fallback for desktop users who never ran the CLI. A CLI selection that may not +be used (fail-closed cases above) leaves the daemon with **no** workspace and +an activity warning: it never falls back to the account's workspace, and +neither source's key is ever sent to the other's origin. `Workspace.source` +records which source named the key. + +**Refresh, verification and recovery.** The 30 s rediscovery re-reads the +CLI's files, so a key rotated in `workspaces.json` is picked up and verified +by the next pass (and the existing same-origin key-migration bookkeeping +applies). Cloud recovery (`account/workspace_recovery.rs`) runs only for a +`Cloud`-sourced key; a refused CLI key is left `rejected` until the CLI's +files change. + +**One workspace per daemon.** The daemon holds a single +`Mutex>` that about 40 call sites (registrations, push, +delivery, spawn, messaging, verification) read as _the_ workspace, so this +phase supports one workspace per machine: the env key, the +`AGENT_RELAY_PROJECT` pin, or the machine's active CLI workspace, else the +Cloud sign-in. A project whose pin names a different workspace than the +machine's active one is **not** served by the daemon yet; that needs per- +registration workspaces (a registration already records its `base_url` and +`workspace_key`, but discovery, push and verification assume one). Until +then, `relay init` keeps the machine-wide active workspace (#2007), so the +common single-workspace machine is covered. + +Behaviour change for desktop users: a desktop that is signed in to Cloud +**and** has an active CLI workspace now uses the CLI's for messaging (uploads +still go to the signed-in account). This lands in relay-desktop only when it +consumes this core (phase 4). -2. **The probe reads the CLI's workspace.** Today `src/relay/workspace.rs` - resolves the Relaycast workspace only from the Cloud sign-in (`cloud` in - the agent store, optionally re-originated by `RELAYCAST_URL`). It must - follow the CLI's ladder (`packages/cloud/src/project-workspace-key.ts`): - 1. the env key; - 2. the project pin `/.agentworkforce/relay/workspace-key.json`; - 3. the machine-global active entry in - `$AGENT_RELAY_HOME|~/.agentworkforce/relay/workspaces.json`. - - It must honour `relaycast-origins.json` origin bindings, so that - self-hosted keys never go to the hosted gateway. Then local and self-hosted - setups work without a Cloud sign-in. The core already locates the CLI's - home (`workspace::relay_home`) but only reads it. +## Remaining phases 3. **`relay init` installs and runs the probe.** It downloads `agent-relay-probe` into `~/.agentworkforce/relay/bin`, runs `relay serve