From 44c04958ced4d48d7d6d5dceeda89035f2c4dffe Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:19:43 -0400 Subject: [PATCH] Link: require an explicit relay instead of a local-backend default A first `xcb link` with no --relay and no XCB_RELAY_URL silently targeted http://127.0.0.1:3210, the local development backend. On a fresh machine the owner's email went to whatever answered there, and the request stalled until the 30-second client timeout reported only "relay request timed out". - No relay configured now stops before any network call with a message naming --relay and XCB_RELAY_URL. - `xcb link` and `xcb link --reauth` print the relay they ask for a code. - Relay timeouts name the deployment that did not answer. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 12 ++++++++++ crates/xcb-cli/src/main.rs | 6 ++--- crates/xcb-cli/src/remote.rs | 31 ++++++++++++++++---------- crates/xcb-cli/tests/cli_ux.rs | 14 ++++++++++++ crates/xcb-runtime/src/cloud/client.rs | 17 +++++++++----- crates/xcb-runtime/src/cloud/lane.rs | 2 +- docs/remote-operations.md | 5 +++-- 7 files changed, 63 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee8a485..b4d57d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ workflow copies that section onto the GitHub Release page and refuses to publish when it is missing, empty, or still says Unreleased. Write it in the version bump pull request by renaming `## Unreleased` to the version. +## Unreleased + +`xcb link` on a new machine uses only the relay you name, and says which relay +it is waiting on. + +- A first link with no `--relay` and no `XCB_RELAY_URL` stops with a message + that names both. Before, it contacted a local development backend at + `127.0.0.1:3210`, which could wait 30 seconds and report only "relay request + timed out". +- `xcb link` prints the relay it asks for a sign-in code, and a relay timeout + names the address that did not answer. + ## 0.10.5 - 2026-09-27 xcb can recover a stopped Codex sign-in that used a different ChatGPT account diff --git a/crates/xcb-cli/src/main.rs b/crates/xcb-cli/src/main.rs index 009ff5a..2d67587 100644 --- a/crates/xcb-cli/src/main.rs +++ b/crates/xcb-cli/src/main.rs @@ -179,9 +179,9 @@ enum Commands { /// Bootstrap or invite token when the deployment gates enrollment. #[arg(long)] invite: Option, - /// Relay deployment URL; defaults to $XCB_RELAY_URL or the local - /// backend, and is saved at enrollment. With --reauth, it must match - /// this machine's saved relay. + /// Relay deployment URL; defaults to $XCB_RELAY_URL, and is saved at + /// enrollment. A first link needs one of them. With --reauth, it must + /// match this machine's saved relay. #[arg(long)] relay: Option, /// Enroll as a dispatch-only controller instead of a workspace diff --git a/crates/xcb-cli/src/remote.rs b/crates/xcb-cli/src/remote.rs index d92e519..0d1c80c 100644 --- a/crates/xcb-cli/src/remote.rs +++ b/crates/xcb-cli/src/remote.rs @@ -89,10 +89,6 @@ pub enum RemoteCommand { }, } -/// The relay the CLI reaches when nothing overrides it: the anonymous -/// local Convex backend. Production linkage comes from `xcb link -/// --relay` or `XCB_RELAY_URL`, persisted into custody at enrollment. -pub const DEFAULT_DEPLOYMENT_URL: &str = "http://127.0.0.1:3210"; const RELAY_URL_ENV: &str = "XCB_RELAY_URL"; /// How long `xcb link` waits for an enrolled device to admit this one @@ -109,20 +105,29 @@ fn not_linked() -> Error { } /// Resolve the relay URL: explicit flag, then environment, then the -/// stored link, then the local-backend default. +/// stored link. There is no default: each owner runs their own relay, +/// and a first link that silently fell back to a local backend sent the +/// owner's email to whatever answered on 127.0.0.1 and then timed out. fn deployment_url(flag: Option<&str>, state_root: &Path) -> Result { + resolve_relay(flag, std::env::var(RELAY_URL_ENV).ok(), || { + Ok(custody::load_link(state_root)?.map(|link| link.deployment_url)) + }) +} + +fn resolve_relay( + flag: Option<&str>, + env: Option, + stored: impl FnOnce() -> Result>, +) -> Result { if let Some(url) = flag { return Ok(url.to_string()); } - if let Ok(url) = std::env::var(RELAY_URL_ENV) - && !url.is_empty() - { + if let Some(url) = env.filter(|url| !url.is_empty()) { return Ok(url); } - if let Some(link) = custody::load_link(state_root)? { - return Ok(link.deployment_url); - } - Ok(DEFAULT_DEPLOYMENT_URL.to_string()) + stored()?.ok_or(Error::Message( + "no relay configured; pass `xcb link --relay https://.convex.cloud` or set XCB_RELAY_URL", + )) } /// One line of interactive input with the prompt on stderr — stdout is @@ -266,6 +271,7 @@ pub async fn link(state_root: &Path, options: LinkOptions<'_>) -> Result { // `--code` verifies a code an earlier `xcb link` already emailed — // a fresh request would invalidate it. if code.is_none() { + eprintln!("Requesting a sign-in code from {url}."); relay_link::request_code(&mut client, &email, invite).await?; } @@ -380,6 +386,7 @@ async fn reauthenticate(state_root: &Path, options: LinkOptions<'_>) -> Result Error { Error::Protocol(what) } -fn timed_out() -> Error { - Error::Unavailable("relay request timed out") +/// Names the relay, so a stall against the wrong deployment says which +/// address never answered. +fn timed_out(deployment_url: &str) -> Error { + Error::Unavailable(dynamic(format!( + "relay request timed out: {deployment_url} did not answer within {}s", + REQUEST_TIMEOUT.as_secs() + ))) } /// A bounded protocol detail for errors that arrive from the relay or @@ -118,7 +123,7 @@ impl RelayClient { pub async fn connect(deployment_url: &str) -> Result { let client = tokio::time::timeout(REQUEST_TIMEOUT, ConvexClient::new(deployment_url)) .await - .map_err(|_| timed_out())? + .map_err(|_| timed_out(deployment_url))? .map_err(|error| protocol(dynamic(format!("convex connect: {error}"))))?; Ok(Self { client, @@ -162,7 +167,7 @@ impl RelayClient { self.client.mutation(path, object_args(args)?), ) .await - .map_err(|_| timed_out())? + .map_err(|_| timed_out(&self.deployment_url))? .map_err(|error| protocol(dynamic(format!("relay mutation {path}: {error}"))))?; unwrap(result) } @@ -172,7 +177,7 @@ impl RelayClient { let result = tokio::time::timeout(REQUEST_TIMEOUT, self.client.query(path, object_args(args)?)) .await - .map_err(|_| timed_out())? + .map_err(|_| timed_out(&self.deployment_url))? .map_err(|error| protocol(dynamic(format!("relay query {path}: {error}"))))?; unwrap(result) } @@ -184,7 +189,7 @@ impl RelayClient { self.client.action(path, object_args(args)?), ) .await - .map_err(|_| timed_out())? + .map_err(|_| timed_out(&self.deployment_url))? .map_err(|error| protocol(dynamic(format!("relay action {path}: {error}"))))?; unwrap(result) } diff --git a/crates/xcb-runtime/src/cloud/lane.rs b/crates/xcb-runtime/src/cloud/lane.rs index bd21bf7..086461f 100644 --- a/crates/xcb-runtime/src/cloud/lane.rs +++ b/crates/xcb-runtime/src/cloud/lane.rs @@ -709,7 +709,7 @@ mod tests { ))); assert!(!presence_lapsed(&Error::Protocol("relay unauthenticated"))); assert!(!presence_lapsed(&Error::Unavailable( - "relay request timed out" + "relay request timed out: https://relay.example did not answer within 30s" ))); } diff --git a/docs/remote-operations.md b/docs/remote-operations.md index 4cbbb83..a46c2b6 100644 --- a/docs/remote-operations.md +++ b/docs/remote-operations.md @@ -7,8 +7,9 @@ terminal, and task content crosses the relay end-to-end encrypted. The fleet needs a relay: a [Convex](https://convex.dev) deployment of this repository's `convex/` backend that you run. `xcb link --relay ` or -`XCB_RELAY_URL` points xcb at it. [Relay deployment](relay-deployment.md) lists -the settings a relay needs. +`XCB_RELAY_URL` points xcb at it. The first link on a machine needs one of +them; xcb saves the relay at enrollment and later commands use it. +[Relay deployment](relay-deployment.md) lists the settings a relay needs. ## Enroll a machine