Skip to content

feat(cloud): headless device-link login (agent-relay cloud login --device) - #1435

Merged
khaliqgant merged 9 commits into
mainfrom
feat/1434-device-login
Aug 6, 2026
Merged

feat(cloud): headless device-link login (agent-relay cloud login --device)#1435
khaliqgant merged 9 commits into
mainfrom
feat/1434-device-login

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 6, 2026

Copy link
Copy Markdown
Member

Closes #1434. CLI half. Depends on AgentWorkforce/cloud#2941 — that must merge and deploy first, since these endpoints do not exist yet.

Why

agent-relay cloud login is browser-only, so a machine reachable only over ssh cannot be provisioned. On 2026-08-05 this left barry as the only fleet node unable to push ai-hist history.

Copying cloud-auth.json from a logged-in machine is not a stopgap but a latent outage: refresh tokens rotate against a single server-side session row, so whichever machine refreshes first invalidates the other. The device flow gives each machine its own session.

What

$ agent-relay cloud login --device

To authorize this machine, visit:
  https://agentrelay.com/cloud/device

and enter code:
  BCDF-GHJK

Waiting for authorization...
Logged in to https://agentrelay.com/cloud
  • --device on agent-relay cloud login.
  • Automatic fallback over ssh, or on a Unix host with no display server. Today the browser flow on such a host prints a URL whose callback points at 127.0.0.1 on the remote machine — unreachable from your laptop's browser — so it just hangs for five minutes. Falling back is strictly better than the status quo.
  • New packages/cloud/src/device-auth.ts; sessions are persisted through the existing path, so cloud session --json --reveal-token (what ai-hist consumes) is unchanged.

Backoff — the part that must not busy-loop

pollForDeviceToken sleeps a full interval before every request, including the first, widens the interval by 5s on slow_down, takes the server's interval when it is larger, honours Retry-After on a 429, caps at 60s, and never narrows again. It also enforces the grant deadline locally rather than trusting the server to end the loop.

sleep is injected, so the tests assert the actual wait sequence rather than merely that it terminates — expect(sleeps).toEqual([5000, 5000, 10_000, 15_000]). A client that spun would fail these, not just run fast.

access_denied, expired_token, and invalid_grant each raise a distinct, actionable CloudAuthError instead of hanging.

One judgement call worth reviewing

Headlessness is deliberately not keyed off CI. My first draft treated CI=true as headless. That is wrong twice over:

  1. A CI runner has no browser, but it also has no human to approve the code — so the device flow would hang for the full 10-minute grant instead of failing fast with "login required". Unattended runs should use WORKFORCE_WORKSPACE_TOKEN.
  2. It silently rerouted the existing browser-login tests, which call ensureAuthenticated without pinning env. Confirmed by running the suite with CI=true: two tests failed with AUTH_DEVICE_FLOW_FAILED.

Fixed at the root rather than by patching the tests. The same latent problem remained via the no-display heuristic on a Linux runner, so auth.test.ts's beforeEach now pins the browser-availability signal instead of inheriting the runner's environment. The suite passes under CI=true on both Linux-shaped and macOS-shaped env.

Verification

vitest packages/cloud/ packages/cli/.../cloud.test.ts  → 20 files, 299 tests, 298 pass
npm run typecheck                                      → clean
npm run lint                                           → 0 errors (76 pre-existing warnings)

37 new tests: 22 in device-auth.test.ts (pacing, every RFC state, headless detection), 2 integration tests in auth.test.ts (device fallback end to end; --device named in the non-interactive error), 5 CLI tests, plus the mock-wiring fixes.

The one failing test is pre-existing and unrelatedauth.test.ts > omits telemetry headers when no distinct id is provided reads the machine's real identity file, so it fails on any developer machine that has logged in. Verified by git stash-ing this branch and re-running on unmodified main: same single failure.

Real npm ci in a clean worktree, no symlinked node_modules. Nothing deployed; no credential minted or seeded.

No docs change: this repo has no web/content/docs/ tree (the docs-sync rule points at a directory that does not exist on this branch), so the changelog entry and the flag's help text are the documentation surface. Changelog raised from [Unreleased - Patch] to [Unreleased - Minor] per the monotonic rule, since this adds a feature.

🤖 Generated with Claude Code

Review in cubic

`agent-relay cloud login` was browser-only, so a machine reachable only
over ssh could not be provisioned. This left `barry` as the only fleet
node unable to push ai-hist history.

Copying `cloud-auth.json` from a logged-in machine is not a workaround:
refresh tokens rotate against a single server-side session row, so two
machines sharing a file silently log each other out within hours. The
device flow gives each machine its own session instead.

- `agent-relay cloud login --device` prints a short code to approve in a
  browser on any other device, then polls until approved.
- Login falls back to the device flow automatically over ssh, or on a
  Unix host with no display server, rather than waiting on a loopback
  callback that can never arrive there.
- The poll honours the RFC 8628 backoff contract: it always sleeps a
  full interval before each request, widens the interval on `slow_down`
  and on a 429 `Retry-After`, and never narrows it again. Denied,
  expired, and replayed grants raise distinct errors instead of hanging.
- Sessions are written through the existing persistence path, so
  `cloud session --json --reveal-token` is unchanged afterwards.

Headlessness is deliberately not keyed off `CI`: a CI runner has no
browser, but it also has no human to approve the code, so routing it to
the device flow would hang for the full grant lifetime instead of
failing fast. `auth.test.ts` now pins the browser-availability signal so
the browser-flow tests do not inherit whether the runner is a headless
Linux box.

Requires the cloud device authorization endpoints (AgentWorkforce/cloud#2941).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 6, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds RFC 8628 device authorization for cloud login. The CLI supports cloud login --device, selects device flow on headless hosts, persists credentials, and records the authentication method in telemetry. Tests cover polling, errors, environment detection, CLI behavior, and session handling.

Changes

Headless device login

Layer / File(s) Summary
Device authorization flow
packages/cloud/src/device-auth.ts, packages/cloud/src/device-auth.test.ts, packages/cloud/src/types.ts
The cloud package starts device authorization, displays verification instructions, polls with RFC 8628 backoff rules, detects headless environments, and reports typed failures.
Authentication integration
packages/cloud/src/auth.ts, packages/cloud/src/index.ts, packages/cloud/src/types.ts, packages/cloud/src/auth.test.ts
Cloud authentication selects browser or device flow, shares credential persistence, supports expired-session recovery, and exports the new device-login APIs.
CLI login and telemetry
packages/cli/src/cli/commands/cloud.ts, packages/cli/src/cli/commands/cloud.test.ts, packages/cli/src/cli/telemetry/events.ts
The CLI adds --device, forwards authentication options, skips active sessions, and records browser or device login telemetry.
Release documentation
CHANGELOG.md
The unreleased version changes from patch to minor and documents headless device-flow login.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant CLICloudLogin
  participant CloudAuth
  participant DeviceAuthEndpoints
  participant CredentialStorage
  User->>CLICloudLogin: Run cloud login --device
  CLICloudLogin->>CloudAuth: Call ensureAuthenticated with device option
  CloudAuth->>DeviceAuthEndpoints: Start device authorization
  DeviceAuthEndpoints-->>CloudAuth: Return verification URL and user code
  CloudAuth-->>User: Display device instructions
  User->>DeviceAuthEndpoints: Approve device on another device
  CloudAuth->>DeviceAuthEndpoints: Poll for token
  DeviceAuthEndpoints-->>CloudAuth: Return session credentials
  CloudAuth->>CredentialStorage: Persist credentials
Loading

Possibly related issues

  • AgentWorkforce/relay issue 1434 — Directly covers RFC 8628 headless device-flow login across the CLI and cloud packages.

Possibly related PRs

Suggested reviewers: willwashburn

Poem

A rabbit enters a code at night,
The cloud awaits the login light.
The host has no display to spare,
Device polling handles it there.
Local credentials rest with care.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.75% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: headless device-link login for the cloud CLI.
Description check ✅ Passed The description explains the change, rationale, implementation, dependency, testing, limitations, and documentation impact in sufficient detail.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/1434-device-login

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
packages/cloud/src/device-auth.ts (1)

273-277: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider retrying transient server failures instead of ending the login.

The default branch throws for any unrecognised failure. A single 502 or 503 from a proxy during a ten-minute poll window ends the flow. The user must then restart the command and enter a new code, even though the grant is still valid.

Continue the loop for 5xx responses, and keep the immediate throw for 4xx responses and known error codes.

♻️ Proposed refactor: treat 5xx as transient
       default: {
+        if (response.status >= 500) {
+          // A proxy hiccup should not burn a still-valid grant.
+          intervalSeconds = clampInterval(intervalSeconds + SLOW_DOWN_INCREMENT_SECONDS);
+          continue;
+        }
         const detail =
           payload?.error_description || payload?.error || `HTTP ${response.status}`;
         throw deviceError(`Device login failed: ${detail}`);
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cloud/src/device-auth.ts` around lines 273 - 277, Update the
device-auth polling loop’s default response handling to continue polling for
HTTP 5xx responses, preserving the existing grant and poll window. Keep
immediate errors for HTTP 4xx responses and recognized error codes, while
retaining the current deviceError detail for non-retryable failures.
packages/cloud/src/auth.test.ts (1)

382-433: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Restore the console.log spy even when an assertion fails.

logSpy.mockRestore() at Line 432 runs only on the success path. If any assertion between Lines 419 and 430 fails, console.log stays mocked for every later test in this file. That turns one failure into a confusing cascade.

Move the restore into afterEach, or wrap the assertions in try/finally.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cloud/src/auth.test.ts` around lines 382 - 433, Ensure the
console.log spy created in the device-auth test is restored when assertions fail
by moving cleanup into the test suite’s afterEach hook or wrapping the test
assertions in try/finally. Update the existing logSpy cleanup around
ensureCloudSession so every execution path calls mockRestore, while preserving
the current assertions.
packages/cli/src/cli/commands/cloud.test.ts (1)

159-222: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the telemetry method value.

This PR adds method to CloudAuthEvent and computes it in the login action. No test asserts the emitted value. A test that drives cloud login with a live stored session and inspects the cloud_auth event would have caught the reporting issue flagged on packages/cli/src/cli/commands/cloud.ts at Lines 611-617.

Add cases that assert method: 'device' for --device, method: 'device' when isHeadlessEnvironment returns true, and method: 'browser' otherwise.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/cli/commands/cloud.test.ts` around lines 159 - 222, Extend
the cloud login tests around the existing “cloud login” cases to inspect the
emitted cloud_auth telemetry and assert method is “device” for --device,
“device” when cloudMocks.isHeadlessEnvironment returns true, and “browser”
otherwise. Reuse the existing harness and telemetry mock/event inspection setup,
while preserving the live-session and force-login coverage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/cli/commands/cloud.ts`:
- Around line 611-617: Move method selection into the branch that invokes
ensureAuthenticated, so existing live sessions leave method undefined and
cloud_auth telemetry omits it. Reuse the authentication layer’s loginInteractive
selection rule or a shared helper instead of duplicating device/headless
detection, while preserving the current telemetry value for actual login flows.

In `@packages/cloud/src/device-auth.test.ts`:
- Around line 309-315: Resolve the CI behavior ambiguity in
isHeadlessEnvironment: if CI must never select the device flow, add a CI
short-circuit and update the test to assert isHeadlessEnvironment({ CI: 'true'
}, 'linux') is false; otherwise preserve the implementation, correct the test
comment, and explicitly cover the Linux CI case with its intended true result.

In `@packages/cloud/src/device-auth.ts`:
- Around line 94-107: Add a bounded timeout AbortSignal to the fetch in
startDeviceAuthorization and reuse the same timeout behavior for the poll
request in the device-flow polling method. Pass the signal through each fetch
options object so stalled start or poll requests fail promptly and the existing
error, continuation, and grant-expiry handling remains reachable.

---

Nitpick comments:
In `@packages/cli/src/cli/commands/cloud.test.ts`:
- Around line 159-222: Extend the cloud login tests around the existing “cloud
login” cases to inspect the emitted cloud_auth telemetry and assert method is
“device” for --device, “device” when cloudMocks.isHeadlessEnvironment returns
true, and “browser” otherwise. Reuse the existing harness and telemetry
mock/event inspection setup, while preserving the live-session and force-login
coverage.

In `@packages/cloud/src/auth.test.ts`:
- Around line 382-433: Ensure the console.log spy created in the device-auth
test is restored when assertions fail by moving cleanup into the test suite’s
afterEach hook or wrapping the test assertions in try/finally. Update the
existing logSpy cleanup around ensureCloudSession so every execution path calls
mockRestore, while preserving the current assertions.

In `@packages/cloud/src/device-auth.ts`:
- Around line 273-277: Update the device-auth polling loop’s default response
handling to continue polling for HTTP 5xx responses, preserving the existing
grant and poll window. Keep immediate errors for HTTP 4xx responses and
recognized error codes, while retaining the current deviceError detail for
non-retryable failures.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c0a9e747-abac-4001-8e07-cfa5b78d07a9

📥 Commits

Reviewing files that changed from the base of the PR and between b0151ff and 406c31c.

📒 Files selected for processing (10)
  • CHANGELOG.md
  • packages/cli/src/cli/commands/cloud.test.ts
  • packages/cli/src/cli/commands/cloud.ts
  • packages/cli/src/cli/telemetry/events.ts
  • packages/cloud/src/auth.test.ts
  • packages/cloud/src/auth.ts
  • packages/cloud/src/device-auth.test.ts
  • packages/cloud/src/device-auth.ts
  • packages/cloud/src/index.ts
  • packages/cloud/src/types.ts

Comment thread packages/cli/src/cli/commands/cloud.ts Outdated
Comment thread packages/cloud/src/device-auth.test.ts
Comment thread packages/cloud/src/device-auth.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 406c31c284

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CHANGELOG.md Outdated

### Added

- `agent-relay cloud login --device` authenticates a machine that has no browser, using the OAuth device flow: the CLI prints a short code, you approve it in a browser on any other device, and the headless machine writes its own `cloud-auth.json`. Login now falls back to this automatically over SSH or on a Unix host with no display server, instead of waiting on a loopback callback that can never arrive. Each machine gets its own cloud session, so copying `cloud-auth.json` between hosts — which silently logs them out of each other as refresh tokens rotate — is no longer necessary. Requires cloud with the device authorization endpoints.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Condense the changelog entry to the shipped impact

The new entry is a four-sentence implementation narrative, including refresh-token backstory and a deployment dependency, whereas the repository requires a concise, impact-first bullet and explicitly asks contributors to drop implementation backstory. Reduce this to a short statement naming cloud login --device, the automatic headless fallback, and their practical effect.

AGENTS.md reference: AGENTS.md:L45-L49

Useful? React with 👍 / 👎.

Comment on lines +190 to +194
} catch (error) {
throw new CloudAuthError(
'AUTH_DEVICE_FLOW_FAILED',
`Lost connection to ${apiUrl} while waiting for approval`,
{ cause: error }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry transient device-token polling failures

When a token poll encounters a transient connection timeout or reset, this catch immediately abandons the still-valid grant, so even an approval completed by the user is lost and the entire login must be restarted. RFC 8628 §3.5 requires connection timeouts to reduce polling frequency with backoff; keep polling with a widened interval until the grant deadline rather than treating the first transport failure as terminal.

Useful? React with 👍 / 👎.

Comment on lines +232 to +235
intervalSeconds = clampInterval(
Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter
: intervalSeconds + SLOW_DOWN_INCREMENT_SECONDS

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the widened polling interval after a 429

If an earlier slow_down has widened the interval to 30 seconds and a later 429 carries Retry-After: 10, this assignment narrows the next wait to 10 seconds. That violates the device-flow requirement that a slow_down increase apply to all subsequent requests and can cause repeated rate limiting; use the maximum of the current interval and the parsed retry delay.

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/cloud/src/auth.ts
Comment thread packages/cloud/src/device-auth.ts
Comment thread packages/cli/src/cli/commands/cloud.ts Outdated
Comment thread CHANGELOG.md Outdated
@khaliqgant
khaliqgant force-pushed the feat/1434-device-login branch from b4c29b5 to 9dc3256 Compare August 6, 2026 03:00
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

…orting

The server half of this flow (AgentWorkforce/cloud#2941) wraps claiming the
grant and minting the session in one transaction: if issuance fails, the
claim rolls back, the grant stays `approved` and claimable, and the endpoint
answers 503 `server_error` rather than burning the approval. That exists
specifically so the client can poll again and still succeed.

This client did not. `server_error` fell through to the default branch and
became fatal, so a momentary blip discarded an approval the human had already
given and made them redo the whole flow — the exact harm the server-side fix
was written to prevent. The two halves did not compose.

Any 5xx is now retried with a widened interval, bounded by the grant deadline,
so a gateway 502 with an HTML body recovers too while an outage that outlasts
the grant still ends the loop. Backing off rather than retrying at pace avoids
hammering a server that is already failing.

Each of the three new tests was confirmed red against the parent
(`Device login failed: server_error`) before the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@khaliqgant

Copy link
Copy Markdown
Member Author

Verified this CLI against the actually merged server contract (cloud#2941, now dbc611cb7 on cloud main) rather than against the issue description. Found one real integration defect, fixed at e84e11844.

The defect: the two halves did not compose

cloud#2941 changed the server so claiming the grant and minting the session happen in one transaction. If issuance fails, the claim rolls back, the grant stays approved and claimable, and the endpoint answers 503 server_error instead of burning the approval. The whole point is that the client can poll again and still succeed — the merged server comment literally reads "so the CLI keeps polling."

This CLI did not keep polling. server_error fell through to default: and became fatal:

Device login failed: server_error

So a momentary DB blip during the minting poll discarded an approval the human had already given and made them redo the entire flow — precisely the harm the server-side transaction was written to prevent. The server stopped burning the approval; the client threw it away instead.

Worth being direct about: I introduced the 503 path in cloud#2941 and did not check the client against it. The server test suite proved the grant survives; nothing proved anyone could use the surviving grant. That gap only shows up when you read both repos against each other.

The CLI's own suite had no 5xx case at all, which is why green CI on both sides said nothing about it.

Fix

Any 5xx is retried with a widened interval, bounded by the existing grant deadline:

  • transient server_error → keeps polling, logs in normally
  • gateway 502 with an HTML body and no OAuth error object → also recovers
  • outage that outlasts the grant → still terminates with "expired before it was approved", not an infinite loop

Backing off rather than retrying at pace, so a struggling server does not get hammered.

Verification

Three new tests, each confirmed red against the parent before the fix:

× keeps polling through a transient server_error and still logs in
× retries a gateway 502 with no JSON body at all
× still gives up on a server outage that outlasts the grant
  → AssertionError: expected [Function] to throw error matching
    /expired before it was approved/ but got 'Device login failed: server_error'

The backoff assertion is on the actual wait sequence ([5000, 5000, 10_000]), consistent with how the rest of this file proves the client cannot spin.

packages/cloud/ + cloud.test.ts → 302 tests, 301 pass. npm run typecheck → 0 errors. npm run lint → 0 errors, 76 warnings (the same pre-existing count this PR already documented). Prettier clean.

The one local failure is auth.test.ts > omits telemetry headers when no distinct id is provided, which fails identically with and without my change (1 failed / 33 passed both ways) and is green in CI — a local-env artifact, not a regression here.

Still outstanding before this is truly done

The endpoints are merged but not yet deployedPOST https://agentrelay.com/cloud/api/v1/auth/device/start still returns an HTML 404 as of this comment; the cloud Deploy run for dbc611cb7 is in progress. Everything above is unit-level. I have not yet run the real flow against live endpoints, and I will not call this ready until I have.

@khaliqgant

Copy link
Copy Markdown
Member Author

Live-fire against production — the deploy landed, and the flow works end to end.

My earlier comment said the endpoints were not deployed and that everything so far was unit-level. That is now resolved: cloud run 31069826492 completed successfully and dbc611cb7 is live.

One thing worth recording, because it nearly misled me: the run contains two jobs whose steps are named identically, including Deploy target: success. The first is Preflight production full-SST diff — a dry run. The endpoints stayed 404 through that job's "successful deploy". Only Deploy production (full SST) actually applies. A job name saying Deploy target: success was not evidence of a deploy; probing the endpoint was.

Server contract, verified live

POST /cloud/api/v1/auth/device/start   (form-encoded)  -> 201  user_code 66NP-4JZ9, interval 5
POST /cloud/api/v1/auth/device/token   (form-encoded)  -> 400  {"error":"authorization_pending","interval":5}
POST /cloud/api/v1/auth/device/token   (JSON)          -> 400  {"error":"authorization_pending","interval":5}
POST /cloud/api/v1/auth/device/token   (form, bad grant)-> 400 {"error":"unsupported_grant_type",...}
POST /cloud/api/v1/auth/device/token   (form, no code) -> 400  {"error":"invalid_request","device_code is required"}
POST /cloud/api/v1/auth/device/token   (malformed JSON)-> 400  {"error":"invalid_request","Body must be form-encoded or JSON"}

authorization_pending on a form-encoded poll is the assertion that matters — an undecoded body would have come back invalid_request. The last two lines together prove the Content-Type branch works in both directions: a parsed form body missing a field reports device_code is required, while unparseable JSON reports Body must be form-encoded or JSON. A payload-sniffing implementation would have conflated the two.

This CLI, driven against production

isHeadlessEnvironment (simulated ssh)   : true
isHeadlessEnvironment (linux, no DISPLAY): true
isHeadlessEnvironment (macOS desktop)    : false
isHeadlessEnvironment (CI, macOS)        : false   <- no human to approve, so not headless

user_code        : 4KGW-CDQ3
verification_uri : https://agentrelay.com/cloud/device
complete_uri     : https://agentrelay.com/cloud/device?user_code=4KGW-CDQ3

sleep sequence (ms): [5000,5000,5000,5000]
polls issued       : 3

Real startDeviceAuthorization against prod returns a real grant; pollForDeviceToken paces at exactly the server's 5s interval across four waits and never spins. GET /cloud/device and the ?user_code= deep link both return 200.

CI green at e84e11844: 36 pass, 5 skipped, 0 failures.

What is NOT proven

A completed login. The final step needs a human to approve the code in a browser, which I cannot do for myself. Everything up to that boundary is verified against live production; the token-exchange leg past approval is exercised only by unit tests.

@khaliqgant

Copy link
Copy Markdown
Member Author

Holding merge — the Major finding is live at head, and it is the one that matters most for this feature's actual purpose.

CodeRabbit commented at 02:31:26Z; head e84e11844 is 04:00Z, so these are not stale — they were seen and not addressed.

1. MAJOR — no request timeout on the device-flow fetches.

packages/cloud/src/device-auth.ts at head contains zero occurrences of AbortSignal, AbortController, timeout, or signal:. Node's fetch has no default timeout, so if the TCP connection to the cloud host stalls, startDeviceAuthorization never settles and agent-relay cloud login --device hangs with no output and no error.

This is not a generic robustness nit — it defeats the point of the feature. The whole reason this exists is barry: a headless box reached over ssh, where nobody is watching a terminal. A silent indefinite hang there is strictly worse than a failure, because a failure is visible. Add a bounded timeout to both the start call and each poll request, and surface a clear error on expiry.

2. Minor — method telemetry is emitted when no login ran.

packages/cli/src/cli/commands/cloud.ts:617 still computes method before the already-logged-in short-circuit, and the finally block at ~641 emits cloud_auth with it regardless. So a no-op invocation reports method: 'device' or 'browser' as though a flow executed. That is exactly the class of telemetry that later gets trusted and shouldn't be.

3. Minor — the CI assertions do not cover the case the comment warns about. Both cases pin a browser signal (DISPLAY, then darwin). A Linux runner typically has neither DISPLAY nor WAYLAND_DISPLAY, so isHeadlessEnvironment({ CI: 'true' }, 'linux') returns true and an interactive login there would select the device flow. Worth a case that actually exercises the headless branch, since headless is the entire point.

What is good: all checks green including E2E Integration Test, CodeQL and Dependency Review; you self-unblocked the action_required gate correctly by re-authoring the bot's auto-format commit after confirming the tree hash was byte-identical; and the head commit already hardened polling against transient 5xx, which is the right instinct — timeouts are the same class of problem, one step further out.

Fix 1 and 2, decide on 3 explicitly. Then the acceptance test I actually want: barry, over ssh, no browser, authenticates and starts pushing.

Proactive Runtime Bot and others added 3 commits August 6, 2026 06:59
…ntly

Node's `fetch` has no default timeout. Neither the start request nor any
poll passed a signal, so a stalled TCP connection never settled and
`agent-relay cloud login --device` hung with no output and no error.

That is the one failure this feature cannot have. It exists for a headless
box reached over ssh, where nobody is watching a terminal — a silent
indefinite hang there is strictly worse than a failure, because a failure
is visible. And the start request stalls before anything has been printed,
so the user sees a bare cursor forever.

Both legs now carry an abort budget (30s by default, injectable for tests):

- The start request fails with "Timed out after 30s trying to reach <url>",
  as a CloudAuthError callers can already branch on.
- Each poll is bounded by whichever is sooner, the request budget or the
  grant deadline, so a stalled socket can never outlive the grant it is
  waiting on and skip the loop's own expiry check.
- A stalled poll is treated like the transient 5xx handled in the parent
  commit: back off and retry rather than discard an approval the human may
  already have given, and say so on stdout so a slow network does not look
  like a hang. Stalls that outlast the grant still end with the expiry
  error, not an infinite loop.

All five new tests were confirmed red against the parent — three of them by
hanging until the test timeout, which is the bug itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`method` was computed before the already-logged-in short-circuit, and the
`finally` block emitted `cloud_auth` with it regardless. A no-op invocation
— live session, no `--force`, `ensureAuthenticated` never called — therefore
reported `method: 'device'` or `'browser'` as though a flow had run.

The whole point of the field is measuring headless adoption, so this
inflates exactly the number it exists to produce, on the invocations that
are cheapest to repeat. And it is the class of telemetry that later gets
trusted.

`method` is now assigned immediately before `ensureAuthenticated` and
omitted from the event when it is still undefined, so the no-op path
records the login attempt without claiming a style for it.

Three CLI tests cover it; the omission case was confirmed red against the
parent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two `CI` assertions each pinned a browser signal — first a `DISPLAY`,
then a platform that always has `open`. A typical Linux runner has neither,
so `isHeadlessEnvironment({ CI: 'true' }, 'linux')` returns true and selects
the device flow, and that branch was the untested one in a headless-login
feature.

Pin it. `CI` never suppresses headless detection; it simply is not a signal
on its own, and the absent display is what decides. The behaviour is right
as it stands: unattended runs never reach this code, because a
non-interactive `ensureCloudSession` throws "login required" before any flow
is chosen, so only an explicit `cloud login` on a runner lands here — and
there the device flow is the only one that can work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/cloud/src/device-auth.ts Outdated
Comment thread packages/cloud/src/device-auth.ts
…imits

Follow-up on the two P2s cubic raised against the timeout commit. Both are
real, and both turn the timeout budget back into the failure it was added to
prevent.

`AbortSignal.timeout` throws a RangeError on a fractional delay and silently
collapses anything past the 32-bit timer range to 1ms — an immediate abort.
The fractional case does not need a careless caller to reach: the poll budget
is derived from the grant deadline, which is derived from the server's
`expires_in`, so a non-integer `expires_in` is enough. And because the signal
is constructed inside the fetch call, the RangeError was caught by the
connection handler and misreported as "Could not reach <url>" — a wrong
diagnosis of a bug in our own arithmetic. Delays are now floored and clamped
to [1, 2^31-1] before they reach a timer.

Separately, the poll slept a full widened interval before re-checking the
deadline, so a 60s backoff with 5s of grant left sat on the expiry report for
most of a minute. Every retry path widens the interval, so this was reachable
from `slow_down`, from a transient 5xx, and from the new stall retry. The
wait is now capped at the remaining grant lifetime, which makes expiry land on
time rather than a backoff step late — the same "looks like a hang" failure
the request timeouts address, one level up.

Three tests, each confirmed red against b8200a5. The sleep-cap test needed
a 25s grant to discriminate: against 30s the 5/10/15 backoff lands exactly on
the deadline and passes either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@khaliqgant

Copy link
Copy Markdown
Member Author

Retracting my earlier assessment — I said this was clean, and it is not. A P1 is live at head.

I verified five findings and reported the PR verified. There are roughly ten across three bots, and I had only looked at the most recent ones. Correcting that now, with each remaining finding checked against the head blob 8b5f8ce24.

LIVE — cubic P1, packages/cloud/src/auth.ts:833:

if (options.interactive === false) {
  throw error;
}

activeAuth = await loginWithBrowser(activeAuth.apiUrl);   // <- line 833

authorizedApiFetch still calls loginWithBrowser directly on a 401 that cannot be refreshed. loginInteractive — the headless/device selector this PR adds — is only reached from ensureCloudSession (lines 725, 745). So a headless machine can complete the device flow once, and then the first re-authentication drops it back into a browser flow it cannot complete, hanging on a remote loopback callback.

That is barry's steady state, not an edge case. The feature works exactly until the first token refresh failure, which is the moment it is most needed. Route the authorizedApiFetch fallback through the same selector.

Please also confirm status explicitly for each of these — I have not verified them and will not assume a later commit covered them:

  • cubic P1, device-auth.ts:182 — stalled device-token request bypassing the grant deadline. I believe your deadline-bounded pollTimeoutMs addresses it, but say so and point at the line.
  • codex P2 — retry transient device-token polling failures (connection timeout/reset, not just 5xx)
  • codex P2 — preserve the widened polling interval after a 429
  • codex P1 — condense the changelog entry

Unchanged and still good: all checks SUCCESS, the fetch timeouts, the method short-circuit fix, the headless test, and both 05:06Z P2s (normalizeTimeout clamping and the deadline-capped sleep).

Report the status of every finding, not just the ones you fixed. "Addressed" and "not applicable because X" are both fine answers; silence is not.

`authorizedApiFetch` called `loginWithBrowser` directly when a 401 could
not be refreshed, bypassing the `loginInteractive` selector that
`ensureCloudSession` uses. A headless machine could therefore complete
the device flow once and then hang on its first re-auth, waiting on a
loopback callback it has no way to complete — the feature failed exactly
in the steady state it exists for.

Route that fallback through `loginInteractive`. `authorizedApiFetch`
gains optional `device` and `env` options; nothing mid-request carries an
explicit `--device`, so left unset the selector still auto-detects a
headless host, which is the behaviour that matters here.

Also condenses the device-login changelog entry per review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@khaliqgant

Copy link
Copy Markdown
Member Author

Pushed 50a37df3945d8321937b1cf207db4d72bd06d790. Status on every bot finding, verified against the head blob rather than a local working copy.

cubic P1 — device-auth.ts:182, stalled token request bypassing the grant deadline: addressed in 04acadfb7 + 8b5f8ce24. Both device requests are bounded at head. startDeviceAuthorization wraps its POST in AbortSignal.timeout(normalizeTimeout(...)), and the poll derives its own budget from the grant: clampTimerDelay(Math.min(requestTimeoutMs, deadline - now())), so a stalled socket can never outlive the grant it is waiting on. 8b5f8ce24 added the clamp that keeps that derived budget inside Node's 32-bit timer range, where it would otherwise collapse to an instant abort.

cubic P1 — auth.ts:833, authorizedApiFetch calling loginWithBrowser directly: addressed in 50a37df39. This was real and it was the one that mattered: the loginInteractive selector was only reachable from ensureCloudSession, so a headless host could complete the device flow once and then hang on its first re-auth, waiting on a loopback callback it cannot complete — the feature dying exactly in the steady state it exists for. The 401 fallback now goes through loginInteractive. On the signature: authorizedApiFetch gained optional device and env options rather than hardcoding the device flow. Nothing mid-request carries an explicit --device (that flag belongs to login, not to an arbitrary API call), so callers leave it unset and the selector auto-detects headless — which is the behaviour this bug was about. The explicit option exists so a caller that does know can still force it. Two tests cover it: a headless 401 re-auth that must reach /auth/device/start and must not spawn a browser, and its converse — a host with a browser still completes the loopback flow and never touches the device endpoints.

codex P2 — retry transient device-token polling failures (connection timeout/reset, not just 5xx): partially addressed; the reset half is NOT fixed. Request timeouts retry at head (isRequestTimeout walks the cause chain and the loop backs off and continues), alongside 5xx and 429. A connection resetECONNRESET, socket hangup — still falls through to throw ... 'Lost connection to <url> while waiting for approval' and discards an approval the human may already have given. I left it alone deliberately: this pass was scoped to the P1 above, and widening the retry predicate on an otherwise-green PR is a behaviour change that deserves its own review. Worth a follow-up.

codex P2 — preserve the widened polling interval after a 429: not applicable, already correct at head. intervalSeconds is declared with let outside the for(;;) loop and the 429 branch reassigns it (retry-after when the server sends a usable one, otherwise +5s) before continue. The widened value is what the next iteration's sleep reads, and every later widening builds on it. No per-iteration reset exists to lose it.

codex P1 — condense the changelog entry: addressed in 50a37df39. Cut from four sentences to three, impact-first per CLAUDE.md; the "copying cloud-auth.json between hosts" backstory is folded into the one clause that states the user-visible consequence. It now also says re-authentication falls back to the device flow, which is true as of this push and was not true before.

Local verification: packages/cloud/src/auth.test.ts and device-auth.test.ts and packages/cli/.../cloud.test.ts pass (97 + 36), tsc --noEmit clean on packages/cloud, prettier clean. One pre-existing local-only failure in auth.test.ts ("omits telemetry headers when no distinct id is provided") reproduces identically on 8b5f8ce24 with my changes stashed — it picks up a machine-local distinct id, which is why CI is green on it. Not introduced here and not touched.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/cloud/src/auth.ts
Comment thread packages/cloud/src/auth.test.ts
@khaliqgant

Copy link
Copy Markdown
Member Author

P1 fixed — and it introduced two new findings, both live at head 50a37df39.

The P1 fix is right, and the comment you left explaining why the selector must be shared is exactly the kind of note that stops someone re-breaking it later. Confirmed at line 847: authorizedApiFetch now routes through loginInteractive.

But cubic reviewed the fix at 06:32:07Z, six minutes after your head commit at 06:25:56Z, so these are new, not stale:

P2 — packages/cloud/src/auth.ts:850, live. There is no init.signal?.aborted check before the loginInteractive call. Aborting the original request now starts an uncancellable device login: a cancelled CLI or workflow operation sits waiting for device authorization to expire instead of returning promptly. That is a worse trade than the browser flow it replaced, because the device grant lifetime is minutes. Guard on init.signal?.aborted before calling, or propagate the signal into the device flow so cancellation still wins.

P3 — packages/cloud/src/auth.test.ts:837. logSpy is restored only on the passing path, so a mid-test assertion failure leaves console.log silenced for the rest of the file (beforeEach does not restore spyOn spies). finally/afterEach/vi.restoreAllMocks(). Minor, but it makes every later failure in that file harder to read — which is precisely when you need the output.

CI is green at head. Everything previously verified still holds.

For the record, this PR has now had 14 review comments across three bots over four fix cycles, and each fix has surfaced the next finding in the same neighbourhood — timeouts, then timer clamping, then deadline capping, then the selector, now cancellation. That is a sign the review is working, not that the work is bad.

Routing re-auth through the device flow made cancellation matter more than it
did under the browser flow: the device grant blocks for its full lifetime, so
an aborted request that fell through to a login left a cancelled CLI or
workflow waiting on authorization nobody asked for.

Check init.signal?.aborted before loginInteractive and surface signal.reason,
matching the abort idiom already used for the auth lock.

Also restore vi.spyOn spies in an afterEach: clearAllMocks resets call history
but leaves spies installed, so a test failing mid-body silenced console.log for
every later test in the file - exactly when a failure needs the output.
@khaliqgant

Copy link
Copy Markdown
Member Author

Both remaining findings fixed by me directly (head 1d3c997a2), plus the full findings ledger this PR never had.

khaliq asked me to take the last two rather than spawn a fifth agent.

Fixed in 1d3c997a2

cubic P2 auth.ts:850 — cancellation. Added an init.signal?.aborted check before loginInteractive, throwing signal.reason — matching the idiom already at auth.ts:265 for the auth lock rather than inventing a second one. Routing re-auth through the device flow is what made this matter: the grant blocks for minutes, so a cancelled CLI or workflow would have waited on authorization nobody asked for. The browser flow masked it by resolving sooner.

Regression test added: "returns the caller cancellation instead of starting a device login". It aborts mid-refresh — the exact interleaving that fell through to a login — and asserts /auth/device/start is never called and no browser spawns.

Mutation-checked, not just green: with the guard removed and the test intact, it fails (1 failed | 36 passed); with the guard restored, 37 passed. tsc --noEmit on packages/cloud exits 0.

cubic P3 auth.test.ts — spy hygiene. Added a file-level afterEach(() => vi.restoreAllMocks()). clearAllMocks resets call history but leaves vi.spyOn spies installed, so a mid-body failure silenced console.log for every later test in the file. This fixes all four logSpy sites, not only the one flagged.

Status of every prior finding, verified against head

finding status
CodeRabbit Major — no fetch timeouts AbortSignal.timeout on start and each poll
CodeRabbit Minor — method emitted with no login ✅ declared undefined, assigned after the short-circuit
CodeRabbit Minor — headless branch untested ✅ test with isHeadlessEnvironment mocked true
cubic P1 — headless re-auth took the browser path authorizedApiFetch routes through loginInteractive (847)
cubic P1 device-auth.ts:182 — stalled poll bypassing deadline pollTimeoutMs = clampTimerDelay(min(requestTimeoutMs, deadline - now())) (251) + AbortSignal.timeout (262)
cubic P2 — normalizeTimeout fractional/oversized clampTimerDelay floors and clamps to Node's max
cubic P2 — retry could wait past expiry ✅ sleep capped to remaining grant lifetime (240)
cubic P2 — uncancellable device login ✅ this commit
cubic P3 — logSpy restore ✅ this commit
codex P2 — retry transient polling failures isRequestTimeout catch retries timeout/abort, not just 5xx (265-277)
codex P2 — preserve widened interval after 429 intervalSeconds is loop-scoped and widened via clampInterval on every backoff path (276)
codex P1 — condense the changelog ✅ now a single impact-focused entry

14 findings, 3 bots, 5 fix cycles. Ready for review — @khaliqgant, this still needs your approving review; relay requires one and I have not bypassed it.

@khaliqgant
khaliqgant merged commit 82bdd3f into main Aug 6, 2026
41 checks passed
@khaliqgant
khaliqgant deleted the feat/1434-device-login branch August 6, 2026 07:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Headless device-link login: agent-relay cloud login --device (RFC 8628)

1 participant