From 0f432329fd432cebfcfd1ad80557fecf08cff7db Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Tue, 30 Jun 2026 17:04:04 -0700 Subject: [PATCH] feat(login): standardize all sign-ins on the device-code flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `railway login` (and the implicit sign-in from an unauthenticated `railway up`) now always use the OAuth device-code flow instead of auto-opening a browser when a local display is reachable. Transport was an environment/UX choice orthogonal to who is signing in — agent vs human is tracked via `caller` on both paths — so routing every sign-in through one transport removes the display-detection branch and the flaky `open()` fallback, and makes behavior identical across desktop, SSH, CI, and agent harnesses. - `login_transport()` always returns `DeviceCode`; `--browserless` is now a no-op, kept for backward compatibility with scripts. - New telemetry reason `default_device_code` marks sessions that would previously have taken the browser path; `env_ci`/`env_ssh`/ `no_display`/`flag_browserless` are still recorded. - The browser machinery (browser_login + localhost callback server) is retained as runtime-unreachable code so this can be reverted by restoring `login_transport`'s body; the now-unconstructed `AuthTransport::Browser` is `#[allow(dead_code)]`. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/login.rs | 53 +++++++++++--------------------- src/exec_context.rs | 70 ++++++++++++++++++++++++++----------------- 2 files changed, 61 insertions(+), 62 deletions(-) diff --git a/src/commands/login.rs b/src/commands/login.rs index 18a822cb2..40863dfed 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -16,19 +16,16 @@ use super::*; /// Sign in to Railway — also creates a new account if you don't have one. /// -/// Uses a single OAuth flow for both sign-in and sign-up. Brand-new -/// accounts are detected automatically and land on a welcome page; -/// existing users see the standard sign-in confirmation. Use -/// --browserless for SSH sessions, remote dev boxes, or any -/// environment where a local browser can't open. +/// Uses a single OAuth device-code flow for both sign-in and sign-up: +/// the CLI prints a one-click sign-in link (plus a short code) that you +/// complete in a browser on any device. Brand-new accounts are detected +/// automatically and land on a welcome page; existing users see the +/// standard sign-in confirmation. #[derive(Parser)] pub struct Args { - /// Use a device-code flow instead of opening a browser. Prints - /// a sign-in link + short code to use from any device. Only - /// needed when this machine truly has no browser — the CLI - /// already auto-detects SSH, CI, and missing DISPLAY. If a human - /// is at this machine (including under a coding agent), omit - /// this flag: the browser flow completes far more reliably. + /// Deprecated: the device-code flow is now always used, so this flag + /// no longer changes behavior. Retained for backward compatibility + /// with scripts that still pass it. #[clap(short, long)] pub browserless: bool, } @@ -67,43 +64,29 @@ pub async fn command(args: Args) -> Result<()> { let host = configs.get_host(); - // `login` is an explicit auth request, so we always attempt it — - // only the transport varies. Device-code when the user asked for it - // (--browserless) or no local browser is reachable (CI/SSH/no - // DISPLAY); otherwise open a browser. browser_login itself falls - // back to device-code if `open` fails. Neither path needs a TTY. + // `login` is an explicit auth request, so we always attempt it. + // Every sign-in now uses the device-code flow (see + // `ExecutionContext::login_transport`); the transport no longer + // varies by environment. Neither path needs a TTY. let ctx = crate::exec_context::ExecutionContext::detect(false, false); let transport_choice = ctx.login_transport(args.browserless); - // Why this transport was chosen, for funnel telemetry. Env - // constraints win over the flag: "flag_browserless" is reported - // only when a browser was otherwise reachable, so it counts exactly - // the sessions a bare `railway login` would have sent down the - // (far more reliable) browser path. browser_login overwrites both - // labels if its `open` attempt fails and it falls back. + // Why this transport was chosen, for funnel telemetry. Device-code + // is now the default for every sign-in ("default_device_code"); the + // environment labels (env_ci/env_ssh/no_display) and the explicit + // "flag_browserless" are still recorded so we can see how sessions + // would have been routed under the old browser-preferring logic. let mut reason = match transport_choice { crate::exec_context::AuthTransport::Browser => "browser", crate::exec_context::AuthTransport::DeviceCode => { headless_reason().unwrap_or(if args.browserless { "flag_browserless" } else { - "unknown" + "default_device_code" }) } }; - // --browserless on a machine that has a browser: honor the flag, - // but say so in the output. Agents (the dominant source of this - // combination) read command output at exactly this moment, and the - // browser path completes far more often for watched sessions. - if reason == "flag_browserless" { - println!( - " {} A browser is available on this machine — `railway login` without {} opens it directly and completes more reliably.", - "→".cyan(), - "--browserless".bold(), - ); - } - let mut transport = match transport_choice { crate::exec_context::AuthTransport::DeviceCode => "device_code", crate::exec_context::AuthTransport::Browser => "browser", diff --git a/src/exec_context.rs b/src/exec_context.rs index 863b6d61f..9f1442a7c 100644 --- a/src/exec_context.rs +++ b/src/exec_context.rs @@ -17,6 +17,14 @@ use is_terminal::IsTerminal; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuthTransport { /// Open a local browser (authorization-code + PKCE). + /// + /// Retained but no longer constructed: sign-in is standardized on + /// `DeviceCode` (see [`ExecutionContext::login_transport`]). The + /// browser flow in `commands::login` and its match arms are kept as + /// runtime-unreachable code so the policy can be reverted by + /// restoring `login_transport`'s body. `allow(dead_code)` covers the + /// now-unconstructed variant. + #[allow(dead_code)] Browser, /// Print a verification URL + device code (RFC 8628) for a human to /// use on any device. @@ -91,22 +99,29 @@ impl ExecutionContext { self.agent_harness && !self.stdin_tty } - /// Transport for an *explicit* sign-in (`railway login`). The user - /// asked to authenticate, so we always attempt it; only the - /// transport varies. - pub fn login_transport(&self, browserless: bool) -> AuthTransport { - if browserless || !self.browser_reachable { - AuthTransport::DeviceCode - } else { - AuthTransport::Browser - } + /// Transport for a sign-in (`railway login`, or the implicit sign-in + /// from an unauthenticated `railway up`). Standardized on the + /// device-code flow: the CLI prints a one-click sign-in link (plus a + /// short code) that the human completes in a browser on any device. + /// + /// Every sign-in now uses one transport regardless of environment. + /// Auto-opening a local browser was the higher-converting path for + /// watched desktop sessions, but a single transport removes the + /// branch (no display detection, no flaky `open()` fallback) and + /// makes behavior identical across desktop, SSH, CI, and agent + /// harnesses. The `browserless` argument is retained for call-site + /// compatibility but no longer changes the choice; the browser + /// machinery in `commands::login` is kept as runtime-unreachable code + /// so this can be reverted by restoring the original body. + pub fn login_transport(&self, _browserless: bool) -> AuthTransport { + AuthTransport::DeviceCode } /// Decision for *implicit* sign-in triggered as a side effect (an /// unauthenticated `railway up`). Fails fast only when there's no - /// human to complete a flow; otherwise proceeds with the appropriate - /// transport — a browser when one is reachable, or a device code - /// (which the human completes on another device) when it isn't. + /// human to complete a flow; otherwise proceeds with the standardized + /// device-code transport (which the human completes in a browser on + /// any device). pub fn auto_auth(&self, browserless: bool) -> AutoAuth { // Machine contexts have no human in the loop: JSON output is // consumed by a tool, and --ci is non-interactive by definition. @@ -118,12 +133,11 @@ impl ExecutionContext { if !self.stdout_tty && !self.agent_implicit_consent() { return AutoAuth::FailFast; } - // A human is present. Pick the transport: a browser if one can - // open, otherwise device-code (SSH / no DISPLAY) — the same - // fallback `railway login` uses here, so an unauthenticated `up` - // can sign the user in and deploy in one shot even on a remote - // box. (When there's genuinely no human, we already failed fast - // above rather than print a code into the void.) + // A human is present. Use the standardized device-code transport + // (the same one `railway login` uses), so an unauthenticated `up` + // can sign the user in and deploy in one shot in any environment. + // (When there's genuinely no human, we already failed fast above + // rather than print a code into the void.) AutoAuth::Proceed(self.login_transport(browserless)) } } @@ -152,12 +166,14 @@ mod tests { } } - // --- login_transport: explicit `railway login` always attempts --- + // --- login_transport: every explicit `railway login` uses device-code --- #[test] - fn login_uses_browser_when_reachable_and_not_browserless() { + fn login_uses_device_code_even_when_browser_reachable() { + // Standardized policy: a desktop with a reachable browser still + // uses device-code, same as every other environment. let c = ctx(false, false, true, true, false, true); - assert_eq!(c.login_transport(false), AuthTransport::Browser); + assert_eq!(c.login_transport(false), AuthTransport::DeviceCode); } #[test] @@ -168,8 +184,8 @@ mod tests { #[test] fn login_uses_device_code_when_no_browser_reachable() { - // SSH / no-DISPLAY / CI env: browser can't open, but the user - // explicitly asked to log in, so device-code (not fail-fast). + // SSH / no-DISPLAY / CI env: browser can't open, and the + // standardized flow is device-code regardless. let c = ctx(false, false, true, true, false, false); assert_eq!(c.login_transport(false), AuthTransport::DeviceCode); } @@ -181,18 +197,18 @@ mod tests { let c = ctx(false, false, true, true, false, true); assert_eq!( c.auto_auth(false), - AutoAuth::Proceed(AuthTransport::Browser) + AutoAuth::Proceed(AuthTransport::DeviceCode) ); } #[test] fn auto_auth_proceeds_under_an_agent_harness_with_piped_stdio() { // Agent harness with captured stdout/stdin: a human is watching - // and can complete the browser sign-in. + // and can complete the device-code sign-in. let c = ctx(false, false, false, false, true, true); assert_eq!( c.auto_auth(false), - AutoAuth::Proceed(AuthTransport::Browser) + AutoAuth::Proceed(AuthTransport::DeviceCode) ); } @@ -256,7 +272,7 @@ mod tests { assert!(!c.agent_implicit_consent()); assert_eq!( c.auto_auth(false), - AutoAuth::Proceed(AuthTransport::Browser) + AutoAuth::Proceed(AuthTransport::DeviceCode) ); } }