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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions crates/xcb-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,9 @@ enum Commands {
/// Bootstrap or invite token when the deployment gates enrollment.
#[arg(long)]
invite: Option<String>,
/// 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<String>,
/// Enroll as a dispatch-only controller instead of a workspace
Expand Down
31 changes: 19 additions & 12 deletions crates/xcb-cli/src/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,6 @@
},
}

/// 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
Expand All @@ -109,20 +105,29 @@
}

/// 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<String> {
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<String>,
stored: impl FnOnce() -> Result<Option<String>>,
) -> Result<String> {
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://<deployment>.convex.cloud` or set XCB_RELAY_URL",
))
}

/// One line of interactive input with the prompt on stderr — stdout is
Expand Down Expand Up @@ -266,6 +271,7 @@
// `--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?;
}

Expand Down Expand Up @@ -380,6 +386,7 @@
// replace/delete the auth session the worker is still using during OTP.
let mut client = RelayClient::connect(intent.endpoint()).await?;
if options.code.is_none() {
eprintln!("Requesting a sign-in code from {}.", intent.endpoint());
relay_link::request_code(&mut client, &email, None).await?;
}
let code = match options.code {
Expand Down
14 changes: 14 additions & 0 deletions crates/xcb-cli/tests/cli_ux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,20 @@ fn reauth_requires_a_linked_device_without_starting_local_stores() {
assert!(!sandbox.state().join("cloud/device.json").exists());
}

#[test]
fn a_first_link_without_a_relay_refuses_instead_of_guessing_a_local_backend() {
let sandbox = Sandbox::new("link-no-relay");
for env in [&[][..], &[("XCB_RELAY_URL", "")][..]] {
let output = sandbox.run(&["--json", "link", "--email", "owner@example.test"], env);
assert_eq!(output.status.code(), Some(1), "{output:?}");
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let message = value["error"]["message"].as_str().unwrap().to_lowercase();
assert!(message.contains("no relay configured"), "{message}");
assert!(message.contains("--relay"), "{message}");
assert!(!text(&output.stderr).contains("Requesting a sign-in code"));
}
}

#[test]
fn ordinary_link_keeps_a_linked_device_and_session_byte_for_byte() {
let sandbox = Sandbox::new("link-existing");
Expand Down
17 changes: 11 additions & 6 deletions crates/xcb-runtime/src/cloud/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,13 @@ fn protocol(what: &'static str) -> 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
Expand Down Expand Up @@ -118,7 +123,7 @@ impl RelayClient {
pub async fn connect(deployment_url: &str) -> Result<Self> {
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,
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion crates/xcb-runtime/src/cloud/lane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)));
}

Expand Down
5 changes: 3 additions & 2 deletions docs/remote-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>` 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

Expand Down
Loading