feat(desktop): first-run "Free Desktop Use" vs sign in with orcabot.com#264
Merged
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.