Skip to content

feat(desktop): first-run "Free Desktop Use" vs sign in with orcabot.com#264

Merged
robdmac merged 45 commits into
mainfrom
feat/desktop-account-signin
Jul 14, 2026
Merged

feat(desktop): first-run "Free Desktop Use" vs sign in with orcabot.com#264
robdmac merged 45 commits into
mainfrom
feat/desktop-account-signin

Conversation

@robdmac

@robdmac robdmac commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Adds the first-run account screen you asked for: Free Desktop Use shown above Sign in with your orcabot.com account. Both keep running on the local control plane + local VM — signing in is purely additive (real identity now, cloud sync later), it does not move execution to the cloud.

robdmac and others added 30 commits July 12, 2026 17:41
Two visibility gaps in the desktop app:

1. No indication of which version is running. Added a `get_app_version` Tauri
   command (app.package_info().version) exposed via getAppVersion(), and a small
   "v0.5.0" badge (DesktopVersionBadge) next to the OrcaBot wordmark in both the
   dashboards-list and single-dashboard headers. Desktop-only (renders nothing on
   web / until the version resolves).

2. Auto-update gave no feedback between accepting the native "Update available"
   dialog and the relaunch. The Rust updater now emits `update-progress` events
   from the download_and_install closures (throttled to once per MB) covering
   starting → downloading (bytes + %) → installing → error. A new
   UpdateProgressOverlay listens for these and shows a fixed download bar; it's
   mounted once for all app routes via a new (app)/layout.tsx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…screen)

Claude Code's newer full-screen (alternate-screen buffer) TUI takes over the
xterm.js terminal block and prompts a one-time "use full screen?" question. Set
CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 for Claude PTYs (next to IS_SANDBOX=1) —
this forces the classic inline renderer and skips the prompt non-interactively,
regardless of any saved `tui` setting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…med out

noVNC's `_screenSize()` measures the container with getBoundingClientRect, which
includes React Flow's ancestor `transform: scale(zoom)`. Both the remote-resize
request and the autoscale use it, so a browser opened while the canvas is zoomed
out asks for a framebuffer smaller than the block and paints the leftover with the
dark `background` — a black margin that grows the further you zoom out (and new
browsers often need zooming out to place). Override `_screenSize` to use
clientWidth/Height (the true untransformed layout size, matching noVNC's own
`_currentClientSize`), so the framebuffer + scale target the real block at any
zoom. Mirrors the existing absX/absY zoom-compensation patch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…in with orcabot.com

Adds a first-run welcome screen on the desktop app: "Free Desktop Use" (the local,
no-account experience) shown above an option to connect an orcabot.com account.
Either path keeps running on the LOCAL control plane + VM — signing in is purely
additive (attaches the real identity now; cloud sync later), it does not move
execution to the cloud.

- desktop-account-store: persists the first-run choice (free | signed-in) + the
  signed-in identity. No cloud secret is stored in the browser.
- providers.tsx: the desktop auto-login is now gated on the choice — the welcome
  screen renders until one is made; re-bootstraps when it changes; signed-in uses
  the real email/name as the LOCAL dev-auth identity (dev-auth resolves by email).
  Existing authed installs are migrated to "free" so they never see the screen.
- Sign-in verifies the pasted personal access token via a NEW Tauri command
  (verify_orcabot_account) that calls the cloud /users/me from the native layer —
  avoids browser CORS (cloud only allows https://orcabot.com) and keeps the token
  out of JS, sending it only to the fixed cloud URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
… sign in)

On desktop, logout cleared auth and pushed to /login → the web-only marketing page,
which renders as a dark screen in the app, and it never reset the account choice —
so the welcome gate could never reappear (and the auto-login would re-establish the
old identity). Now desktop logout resets the account choice and skips the web
navigation, so the DesktopAuthGate shows the welcome screen. Also clears the
bootstrap run-once guard when the choice is null, so picking a choice again (even
the same one) triggers a fresh login instead of being skipped.

This also gives an easy way to reach the welcome screen when testing: log out.
(Existing installs are still auto-migrated to "free" on launch by design, so a
fresh launch doesn't bounce returning users to a login screen.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
A months-old Next.js incremental cache (.next/cache) silently served stale
compiled output — a changed page didn't rebuild, so desktop builds shipped old UI
(e.g. an old logout handler). And the asset staging only ever *added* files, so
content-hashed chunks from previous builds piled up in the served resources
(dozens of orphans), masking which chunk is live. build-desktop-resources.sh now:

- clears .next + .open-next before the OpenNext build (correctness over the
  incremental speedup for a build step; opt out with FRONTEND_CLEAN=0),
- wipes the staged frontend assets before copying the fresh build, and
- mirrors to target/release/resources with `rsync --delete`,

so every build is clean and what's on disk is exactly what was compiled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…ldcard bind)

The VM host-side forwarder (vz-helper) bound the wildcard `*:8080` (all
interfaces), but the free-port probe only tested IPv4 loopback (127.0.0.1). So a
leftover vz-helper on `*:8080` (e.g. an orphan from a Ctrl+C'd dev run) wasn't
detected — the port "looked free", got picked, and the VM then failed to bind it
("Address already in use"), defeating the whole point of dynamic ports.

Two fixes:
- vz-helper now binds LOOPBACK only (requiredLocalEndpoint 127.0.0.1). The
  forwarder is only ever reached by host-local services, so binding all
  interfaces was needless attack surface — and loopback matches the probe.
- pick_free_port now probes BOTH the IPv4 and IPv6 wildcard (0.0.0.0 + ::), not
  just 127.0.0.1, so a listener on either family can't slip past the check.

Together: a busy sandbox port is now actually detected and a free one chosen, so
an orphaned VM no longer blocks launch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…tore

persist's onRehydrateStorage referenced `useDesktopAccountStore` before the
`const` was initialized. With synchronous localStorage, zustand hydrates DURING
create(), so the callback ran inside the const's temporal dead zone → a
ReferenceError that crashed the whole React app on the client (dark screen /
"nothing connects"). SSR was unaffected (no localStorage → no hydration), which
masked it, and it only surfaced once the stale-cache clear made this code run.

Fix: track hydration AFTER the store is created via the persist API
(hasHydrated / onFinishHydration) instead of inside onRehydrateStorage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…nce-poll)

Adds a proper "Sign in with Google" to the desktop welcome screen alongside Free
Desktop Use and token sign-in (three equally-weighted options). Google requires a
real browser (it blocks OAuth in embedded webviews), and the OS browser can't hand
a session back to the Tauri webview, so:

- Control plane: `loginWithGoogle` gains a `mode=desktop`+`nonce` path (gated to
  the desktop public client, OAUTH_PUBLIC_CLIENT); the callback stashes the
  identity keyed by the nonce (reusing auth_states) and shows a "return to Orcabot"
  page. New `GET /auth/desktop/google-result?nonce=` returns it — desktop-only and
  surface-token gated so a sandbox-VM process can't harvest the identity. Web login
  is untouched.
- Frontend: the button opens the LOCAL control plane's Google login in the OS
  browser and polls the result endpoint (surface header) + on app-focus, then
  calls chooseSignedIn(email, name). Everything stays on the local control plane;
  Google just establishes the real identity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…sync verify, markers

- [P2] Update/import progress events never reached the UI: onImportProgress /
  onUpdateProgress used a bare `import("@tauri-apps/api/webview")` that doesn't
  resolve in the packaged remote-origin webview (threw, swallowed, listener null).
  Now use the injected `window.__TAURI__.event.listen` (like onAppFocus), with the
  dynamic import as a dev-only fallback.
- [P2] Logout could immediately re-sign-in: PaywallDialog's logout cleared only the
  auth store, so the auto-login re-authenticated. Centralized the desktop account
  reset in auth-store.logout() so every logout path returns to the welcome screen.
- [P2] verify_orcabot_account was a sync command doing a blocking 15s HTTP call,
  freezing native UI/IPC on slow/offline networks. Now async via spawn_blocking.
- [P3] Added the missing revision load-logs (DesktopVersionBadge, desktop-account
  -store, (app)/layout.tsx).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…ead workers.dev host

verify_orcabot_account (PAT sign-in) hardcoded
https://orcabot-controlplane.orcabot.workers.dev, and env.ts's production API
default (used by CLOUD_API_URL) pointed at the same host — which no longer
resolves (curl → 000). The live prod control plane is api.orcabot.com (its PAT
routes + /users/me return 401, confirming they're deployed). So PAT sign-in was
verifying against a dead URL and would always fail with "couldn't reach
orcabot.com". Point both at api.orcabot.com. (Prod web already builds with
NEXT_PUBLIC_API_URL=api.orcabot.com, so this only corrects the direct-read
defaults.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…tale cookie

Switching account (Free ↔ Google ↔ token) didn't take: /auth/dev/session minted
the session from `auth.user`, which comes from cookie-first authenticate(). So the
desktop app POSTing it with the OLD session cookie + NEW dev-auth headers re-minted
the OLD user's session, and /users/me (also cookie-first) then reported the old
user — the identity got stuck on whoever was signed in first.

Now /auth/dev/session mints for the DEV-AUTH-HEADER identity (resolve/create by
email, like authenticateDevMode) and overwrites the cookie, so "I'm now user X"
actually takes regardless of any stale cookie. Surface-token gated like dev-auth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…dashboards

Phase 1 groundwork for using your cloud dashboards locally:
- Native commands to store/read/clear the cloud PAT + email host-only (0600 file,
  never in the VM or webview) — a PAT is full account access.
- list_cloud_dashboards: fetches api.orcabot.com/dashboards with the stored PAT
  from the native layer (no browser CORS; token stays in Rust).
- PAT sign-in now stores the credential so the app can later list + download the
  user's cloud dashboards. The app still runs entirely on the local control plane
  + VM; the cloud is touched only for auth + sync.

Next: picker shows cloud dashboards with a download (down-arrow) / downloaded icon,
then the download pulls a dashboard into the local DB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…into local DB

Phase 1 of cloud sync — see and download your cloud dashboards, then run them
locally:
- Schema: dashboards.cloud_id (nullable + migration) links a downloaded local
  dashboard to its source cloud dashboard (drives the ✓ state + Phase-2 sync).
  createDashboard accepts cloudId; listDashboards returns it.
- Native get_cloud_dashboard: fetches one cloud dashboard's full data (items +
  edges) with the stored PAT.
- Frontend: CloudDashboardsSection lists your cloud dashboards in the picker with
  a ⬇ Download button / ✓ Downloaded state (desktop + cloud-signed-in only).
  downloadCloudDashboard materializes it into the local DB (dashboard + items +
  edges, cloud_id set), remapping item ids for edges. It then runs on the local VM.

Not yet: workspace-file copy and two-way sync (Phase 2). Structure/notes/todos/
layout come across; terminals start with a fresh local workspace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…urns a PAT

Reworks the desktop Google button so it feeds cloud sync (previously it only
established a LOCAL identity). Now it authenticates against api.orcabot.com (your
real account) and hands back a cloud PAT:

- Control plane (deploys to prod): loginWithGoogle mode=desktop now takes a PKCE
  `challenge` and works on any deployment (not just the desktop client). The
  callback mints a PAT for the Google-authed user and stashes {token,email,name}
  keyed by nonce. getDesktopGoogleResult now verifies the PKCE `verifier` (peek,
  don't consume on mismatch) before returning the PAT — so a browser-visible nonce
  alone can't harvest the credential.
- Desktop: new native poll_cloud_google_result command (the webview can't call
  api.orcabot.com — CORS). The Google button generates nonce + PKCE verifier,
  opens the cloud login in the OS browser, polls natively, then stores the PAT as
  the cloud credential + sets the local signed-in identity.

Result: Google sign-in now populates "Your cloud dashboards" just like PAT sign-in.
Requires a control-plane prod deploy for the new endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…te INSERT

De-risk the prod deploy: the INSERT no longer references cloud_id, so it can't fail
on a deployment whose schema migration hasn't added the column yet. cloud_id is set
by a follow-up UPDATE that only runs for desktop downloads (where the column
exists), so normal cloud dashboard creation is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…ing the store

If the native bridge was momentarily unavailable, setCloudCredential returned a
no-op — so a sign-in could appear to succeed (chooseSignedIn ran) without the cloud
credential ever being written, leaving "signed-in" with no cloud dashboards and no
error. Now it throws so the sign-in surfaces the failure and can be retried.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
logout() reset the auth store + account choice + session cookie but left the cloud
PAT file on disk (clearCloudCredential was defined but never called), so logging
out didn't fully disconnect the cloud account and a different account could inherit
the old credential. Now logout fire-and-forgets clearCloudCredential too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
The /dashboards API returns { dashboards: [...] }, but list_cloud_dashboards
returned the raw JSON, so CloudDashboardsSection called .map on an object → a
client-side exception ("Application error…") right after Google/PAT sign-in.
Unwrap response.dashboards (tolerate a bare array) and never hand a non-array to
.map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
Previously the local dev-auth identity was keyed by the sign-in email, so Free
(desktop@localhost) and signed-in (your email) were DIFFERENT local users with
separate dashboard sets — signing in made your local dashboards "disappear".

For a single-user desktop that's the wrong model. The local control-plane identity
is now ALWAYS the machine user (desktop@localhost) regardless of Free vs signed-in;
sign-in is purely additive (attaches the cloud credential + surfaces your email via
the account store / CloudDashboardsSection). All local dashboards — made-while-Free
and downloaded-from-cloud — live under the one machine user and are always visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
Fold the separate cloud section and the local list into one tabbed "Your
Dashboards" (DashboardsTabs):
- Two tabs with (X) counts: "Online" (cloud account) and "Local Storage" (this
  machine), same card look-and-feel in both.
- Tabs only appear when signed in to a cloud account; a Free/local-only session
  sees just the local grid. Signing in defaults to the Online tab.
- A cloud dashboard shows ⬇ Download until pulled local; once downloaded it reads
  "Local Storage" and appears in BOTH tabs (launchable from either), matched by
  cloudId.

Removes CloudDashboardsSection (superseded) and moves DashboardCard into the new
component.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…t) for Free

- DashboardsTabs: the tab bar now always renders on desktop, so a Free/local-only
  session still sees the "Local Storage (N)" tab with its count; the Online tab
  only appears once signed in to a cloud account.
- Header: a Free desktop account has nothing to sign out of, so it now shows a
  labeled "Sign in" button (→ welcome screen to attach Google/PAT) instead of
  "Log out". Signed-in cloud accounts (and web) keep the real "Log out".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
All three welcome options run entirely on the local machine; the only online piece
is cloud storage when signed in. Rename the free option to "Local Desktop Use / No
account required. Uses local storage." so it doesn't imply the others aren't local.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
Each cloud dashboard card now shows an "Open online" link (cloud icon) stacked
above the Download / Local Storage action. Clicking it opens the dashboard on
orcabot.com in the external browser (CLOUD_SITE_URL/dashboards/:id). Present
regardless of download state; stops propagation so it never triggers the card's
open-local behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…ne on top

On Online-tab cards, the cloud "Open online" link stays top-right in the header;
the Download / Local Storage action drops to the bottom row of the card (next to
the updated time) for a clearer top→bottom hierarchy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
Downloading a cloud dashboard now also copies its workspace files, not just the
canvas. Because the desktop has ONE shared /workspace, each downloaded dashboard's
files land in a per-dashboard subfolder (<workspace>/<localDashboardId>) and its
terminals are pinned there via workingDir — so two downloads never collide.

- New native command `download_cloud_workspace` (commands.rs): starts/reuses a
  cloud session (never creates a phantom terminal), lists the workspace
  recursively, GETs each file with the PAT, and writes it into the per-dashboard
  subfolder with an O_NOFOLLOW-guarded walk (mirrors the CLI `pull`). Excludes
  regenerable dirs (node_modules/.git/.npm/.browser/.claude cache). Secrets are
  redacted server-side on read, so they never transfer.
- cloud-sync.ts: pins terminal items to the subfolder (workingDir) and calls the
  new command after recreating the canvas. Best-effort — a file-copy failure
  (no active subscription, cloud VM won't start, timeout) still leaves the canvas
  downloaded and surfaces a clear message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
The initial recursive list (GET /files?recursive=true) enumerates the ENTIRE
tree server-side — including node_modules/.git — before returning, which blew the
30s request timeout on real projects (and risks the 100k-entry cap). Even though
we skip fetching those, the server still walks them.

Now walk the workspace directory-by-directory (non-recursive list per dir) and
prune excluded dirs, so we never descend into node_modules/.git. Each list is one
bounded directory (60s timeout). Fixes the "timed out reading response" on
download.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
The session-start POST (/items/:id/session) blocks while the cloud VM cold-boots,
which routinely exceeds the 30s timeout → "timed out reading response" on
download. Give that call 180s (a cold Fly boot fits comfortably); the dashboard
GET + active-status poll are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
robdmac and others added 15 commits July 14, 2026 00:25
A cloud session reads "active" before the sandbox's HTTP server is actually
serving, so the first /files calls 503 through the proxy → "HTTP 503:" on
download. Retry transient failures (503/502/504/reset/timeout): the root listing
is the readiness gate (retried ~90s with 3s backoff), deeper dirs + file GETs get
a light retry once it's serving. A permanently-unreachable workspace now fails
with a clear "try again in a moment" message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
A 2nd download cold-boots a different cloud VM, which is slow and — with only a
static "Downloading…" — looked like a hang. The native command now emits
`cloud-workspace-progress` events (starting → booting → copying N files); the
Online-tab card shows the current phase instead of a static label. Also bumps the
session-active poll to 120×2s (240s) for slow cold boots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
Print [cloud-dl] lines to stderr (session ready, per-dir entry counts, each file
get, skips with the error, final written/skipped) so a stalled download shows
exactly where it's stuck. Emit copying progress every 5 files (was 20).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…g download)

Root cause of the "stuck on copying" hang: cloud_ensure_session short-circuited on
a bare status=="active" DB session and reused it. After repeated start/stop cycles
that session is stale — active in D1 but its Fly VM has been reaped — so every file
call hung on the proxy reaching a dead machine (60s × 30 retries ≈ 31 min).

Fix: never trust a bare "active" status. Always POST /items/:id/session, which runs
ensureDashboardSandbox on the control plane — it restarts a stopped machine and
reprovisions a dead session (idempotent when healthy). Then poll for active. Also
tightened the file-list timeout (60s→30s) and root readiness retries (30→10) so a
genuinely-bad state fails in minutes with a clear message instead of hanging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
- [P1] Uncollected desktop PAT lived in D1 plaintext forever: the Google callback
  minted + stashed the PAT before the app collected it. Now the callback stashes
  only identity (+PKCE challenge +10min expiry); the PAT is minted ONLY when the
  desktop collects the result with the matching verifier. Expired stashes are
  cleaned on read. (controlplane/src/auth/google.ts — needs cloud deploy)
- [P2] Partial dashboards on failed import: recreate the canvas inside try/catch
  and delete the dashboard if any item/edge fails, so a broken copy can't linger
  marked "downloaded". (cloud-sync.ts)
- [P2] workingDir isolation bypass: a cloud terminal's `workingDir: "../x"` became
  `<id>/../x` (escapes the per-dashboard subfolder). Now sanitized — `..` rejected,
  falls back to the subfolder. (cloud-sync.ts)
- [P2] PAT file umask race: write to a temp file created 0600 then atomic-rename
  over the target; permission failure is fatal. (commands.rs)
- [P2] Unbounded per-file buffer: cap each downloaded file at 64MB (defensive;
  control plane already 413s >50MB). (commands.rs)

Still open: [P1] the desktop OAuth poll flow is phishable (attacker-chosen
nonce/challenge + victim sign-in) — needs a loopback-redirect or device-approval
redesign; flagged for a decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…shing vector

Replaces the pollable-rendezvous sign-in (attacker-chosen nonce/challenge + victim
sign-in → attacker polls a global endpoint for the victim's PAT) with a loopback
redirect, so the credential is delivered only to a listener on the machine that
started the flow.

Flow now:
- Desktop (sign_in_google_loopback): binds a temporary 127.0.0.1:<port> listener,
  opens the browser to /auth/google/login?mode=desktop&redirect_uri=<loopback>&state=<csrf>,
  receives a one-time code on the listener, exchanges it for a PAT, stores it
  host-only. The token never enters the webview.
- Control plane: desktop login now REQUIRES a loopback redirect_uri (validated at
  login and before redirect); the callback mints a one-time code + 302s to the
  loopback; new POST /auth/desktop/exchange mints the PAT from the code (single-use,
  5-min TTL). Removed getDesktopGoogleResult + the global poll endpoint.

A phished victim's browser hits THEIR own 127.0.0.1, so the attacker (on another
machine) never receives the code. redirect_uri is restricted to
127.0.0.1/::1/localhost, so it can't be pointed at an attacker host.

NOTE: requires deploying the control plane to dev + prod (api.orcabot.com) before
the new desktop build's Google sign-in works. The token (PAT) sign-in path is
unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
- [P1] Bind the one-time code to PKCE (S256): the native app holds the verifier
  (never on the wire) and sends only the challenge at login; exchange requires the
  verifier. A local observer of the loopback callback URL can't exchange the code.
  Consume atomically with DELETE … RETURNING (no SELECT-then-DELETE race). Rust
  PKCE encoding unit-tested against the reference vector.
- [P1] Native cancellation: a monotonic attempt "generation" (SIGN_IN_GEN) — cancel
  bumps it, and the loopback flow refuses to exchange or write once superseded (also
  bumped by a PAT paste / logout, so a stale Google flow can't overwrite a different
  account's credential). DesktopWelcome "Cancel" now invokes cancel_google_sign_in.
- [P1] Bounded + revocable PATs: desktop PATs get a 90-day TTL; logout now revokes
  the PAT server-side via a self-revoke endpoint (POST /auth/api-token/revoke-self)
  before deleting the local file (TTL is the offline backstop).
- [P2] Surface incomplete downloads: count skipped dirs too and show
  "N file(s)/folder(s) couldn't be copied — re-download to retry" instead of a
  silent success.
- [P2] Pin legacy terminals: wrap non-JSON/plain-string terminal content into
  valid JSON with workingDir, so those terminals are isolated to the dashboard
  subfolder too (were starting at the shared root).

Cloud endpoints (loopback login + exchange + revoke-self) require deploying the
control plane to dev + prod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
- [P1] Logout race: clear_cloud_credential now deletes the local file BEFORE
  awaiting server-side revocation, so a concurrent re-sign-in that writes a new
  credential can't be clobbered by the old logout's late deletion.
- [P1] Rate-limit the DB-mutating auth endpoints: removed /auth/desktop/exchange
  and /auth/api-token/revoke-self from the unauthenticated IP-rate-limit exemption,
  so bogus codes/PATs can't drive unlimited D1 writes (valid PATs still get the
  authenticated per-user limit).
- [P1] PKCE: verify the verifier BEFORE consuming the code — a wrong verifier no
  longer deletes the record (a local observer can't invalidate every sign-in).
  Success path consumes atomically via compare-and-delete (WHERE state=? AND
  redirect_url=? RETURNING).
- [P2] Don't revoke pasted PATs on logout: the credential file now records an
  origin ("google" vs "pat"); logout only server-revokes desktop-minted "google"
  tokens and merely forgets a user-pasted PAT locally (it may be shared with the
  CLI/automation).
- [P2] Workspace retry: incomplete downloads are remembered (localStorage, survives
  reload) and the "Local Storage" card shows a "Retry files" action that re-pulls
  just the workspace into the existing local dashboard.

Cloud endpoints still require deploying the control plane to dev + prod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
- [P2] Credential-mutation race: all credential writes/clears now run under one
  Mutex (CRED_LOCK) so the generation check and the file write are atomic — a
  cancel/logout/PAT-paste can't interleave between them. set_cloud_credential now
  writes + bumps the generation under the lock too.
- [P2] 50k-dir guard no longer reports a truncated workspace as complete: hitting
  the limit counts the unvisited dirs as skipped, so the result is marked incomplete
  (and the "Retry files" marker stays).
- [P2] Logout no longer silently succeeds on delete failure: only NotFound is
  ignored; any other remove_file error is returned, and auth-store logs a failed
  clear instead of swallowing it (a leftover file could sign the user back in).
- [P2] Abandoned desktop auth codes (PII) are swept opportunistically:
  cleanupStaleDesktopCodes deletes desktopcode rows >15 min old (by created_at) on
  every sign-in start and exchange — no scheduler needed.

Cloud endpoints still require deploying the control plane to dev + prod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…round 5)

- [P2] Cancellation now routes through the credential lock: the sign-in generation
  claim and cancel_google_sign_in both bump the generation under CRED_LOCK, and a
  cancel that still races an already-committed write is cleaned up by the frontend
  (DesktopWelcome calls clear_cloud_credential when the resolved sign-in was
  cancelled) — so a cancelled login can't leave a credential behind.
- [P2] Logout credential-deletion failure is now user-visible: clear_cloud_credential
  retries the delete, and on failure the UI shows a toast ("couldn't remove the
  stored credential… restart and sign out again") instead of only console.error.
- [P2] Bound PII in temporary auth records: the desktopcode row now stores ONLY the
  opaque userId (+ PKCE challenge + expiry) — no email/name — so an abandoned,
  never-swept row on an inactive install holds no PII. Identity is fetched from the
  users table at exchange time.

Cloud endpoints still require deploying the control plane to dev + prod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…ound 6)

The shared googleCancelRef boolean raced across Google attempts (cancel A + start
B reset it; cancel A + paste PAT then A resolves deleted the pasted token). Now
every attempt has a unique id and only the matching credential is accepted/rolled
back:

- Native: sign_in_google_loopback returns its attempt generation; on write it
  records COMMITTED_GEN (the attempt that owns the stored credential). New
  rollback_sign_in(attempt) deletes + revokes ONLY if that attempt still owns the
  credential — a no-op if a newer sign-in or a pasted PAT wrote since. PAT paste and
  clear update COMMITTED_GEN accordingly.
- Frontend: DesktopWelcome tracks a per-attempt token (currentAttemptRef). A
  resolved sign-in signs in only if still current; otherwise it calls
  rollback_sign_in(account.attempt). Cancel and PAT-paste drop the current attempt.
  Replaces the shared boolean, so a stale attempt can never sign in as the wrong
  account or delete a newer credential.

Cloud endpoints still require deploying the control plane to dev + prod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…nd 7)

- [P2] rollback_sign_in no longer loses ownership tracking on a failed delete: it
  uses the same retrying remove_credential_file helper as logout, only clears
  COMMITTED_GEN after a successful (or NotFound) delete, and returns Err otherwise —
  so a transient failure keeps the attempt→credential mapping and a retry can still
  clean it up (instead of silently orphaning the file).
- [P2] Logout deletion failure now actually reaches the UI: clearCloudCredential no
  longer swallows the native error, so auth-store's .catch runs and shows the toast
  when the credential file couldn't be removed.

Also refactored the retrying delete into remove_credential_file, shared by
clear_cloud_credential and rollback_sign_in.

Cloud endpoints still require deploying the control plane to dev + prod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
rollback_sign_in now returns errors, but DesktopWelcome invoked it with `void` —
a failed cleanup was an unhandled rejection that left the cancelled credential on
disk with no warning. Now it .catch()es: logs and shows a user-visible toast
("a cancelled sign-in left a credential… restart to clear it") so a failed
rollback is surfaced instead of silent.

Cloud endpoints still require deploying the control plane to dev + prod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
@robdmac
robdmac merged commit ce58f47 into main Jul 14, 2026
1 check passed
@robdmac
robdmac deleted the feat/desktop-account-signin branch July 14, 2026 07:26
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.

1 participant