From 0a6576c15c0cfa93963887a4012fded88fbe4a87 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Sun, 12 Jul 2026 17:41:54 +0100 Subject: [PATCH 01/44] feat(desktop): show running version in header + auto-update progress UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/app/src-tauri/src/commands.rs | 8 + desktop/app/src-tauri/src/main.rs | 59 +++++++- .../src/app/(app)/dashboards/[id]/page.tsx | 2 + frontend/src/app/(app)/dashboards/page.tsx | 2 + frontend/src/app/(app)/layout.tsx | 15 ++ .../src/components/DesktopVersionBadge.tsx | 39 +++++ .../src/components/UpdateProgressOverlay.tsx | 140 ++++++++++++++++++ frontend/src/lib/tauri-bridge.ts | 44 +++++- 8 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 frontend/src/app/(app)/layout.tsx create mode 100644 frontend/src/components/DesktopVersionBadge.tsx create mode 100644 frontend/src/components/UpdateProgressOverlay.tsx diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index 5befee12..2277ad8f 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -608,6 +608,14 @@ pub fn quit_app(app: tauri::AppHandle) { app.exit(0); } +/// The running app's version (from tauri.conf.json / Cargo.toml), e.g. "0.5.0". +/// Shown in the desktop header so users can see what they're running — the +/// version is otherwise invisible in a packaged build. +#[tauri::command] +pub fn get_app_version(app: tauri::AppHandle) -> String { + app.package_info().version.to_string() +} + /// Return the per-boot surface token. The host frontend sends it as the /// `X-Orcabot-Surface` header so the control plane knows the request is from the /// trusted GUI (not a process inside the sandbox VM spoofing dev-auth). diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index 2b2252e9..2c7d3d8b 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -26,6 +26,19 @@ fn pid_file_path(data_dir: &Path) -> PathBuf { data_dir.join("desktop-services.pid") } +/// Progress of an in-flight auto-update, emitted to the GUI as `update-progress` +/// so the frontend can show a download bar (the native "Update available" dialog +/// otherwise gives no feedback between "Update & restart" and the relaunch). +#[derive(Clone, serde::Serialize)] +struct UpdateProgress { + /// "starting" | "downloading" | "installing" | "error" + phase: &'static str, + downloaded: u64, + total: Option, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, +} + /// File recording the ports the stack actually bound to this boot (some may be /// dynamic when a default was busy). The `orcabot` CLI reads this so it connects /// to the right control plane / sandbox / frontend instead of the hardcoded @@ -981,6 +994,7 @@ fn main() { commands::open_url, commands::reveal_workspace, commands::get_ports, + commands::get_app_version, ]) .setup(|app| { let services = Arc::new(DesktopServices::new()); @@ -1085,6 +1099,7 @@ fn main() { // prompt, because restarting tears down any running VM/terminal/agent // session (RunEvent::Exit shuts the services down). if !headless { + use tauri::Emitter; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; use tauri_plugin_updater::UpdaterExt; let handle = app.handle().clone(); @@ -1115,9 +1130,49 @@ fn main() { eprintln!("[updater] update deferred by user"); return; } - match update.download_and_install(|_chunk, _total| {}, || {}).await { + + // Tell the GUI a download is starting so it can show a progress bar, + // then stream progress from the download closure. Emits are throttled + // to once per MB so a ~190MB download doesn't flood IPC. + let _ = handle.emit( + "update-progress", + UpdateProgress { phase: "starting", downloaded: 0, total: None, message: None }, + ); + let h_chunk = handle.clone(); + let h_done = handle.clone(); + let got = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let last_mb = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let got_c = got.clone(); + let result = update + .download_and_install( + move |chunk, total| { + use std::sync::atomic::Ordering::Relaxed; + let so_far = got_c.fetch_add(chunk as u64, Relaxed) + chunk as u64; + let mb = so_far / (1024 * 1024); + if mb != last_mb.swap(mb, Relaxed) { + let _ = h_chunk.emit( + "update-progress", + UpdateProgress { phase: "downloading", downloaded: so_far, total, message: None }, + ); + } + }, + move || { + let _ = h_done.emit( + "update-progress", + UpdateProgress { phase: "installing", downloaded: 0, total: None, message: None }, + ); + }, + ) + .await; + match result { Ok(_) => { eprintln!("[updater] installed; relaunching"); handle.restart(); } - Err(e) => eprintln!("[updater] install failed: {e}"), + Err(e) => { + eprintln!("[updater] install failed: {e}"); + let _ = handle.emit( + "update-progress", + UpdateProgress { phase: "error", downloaded: 0, total: None, message: Some(e.to_string()) }, + ); + } } }); } diff --git a/frontend/src/app/(app)/dashboards/[id]/page.tsx b/frontend/src/app/(app)/dashboards/[id]/page.tsx index aa727deb..936cb1ab 100644 --- a/frontend/src/app/(app)/dashboards/[id]/page.tsx +++ b/frontend/src/app/(app)/dashboards/[id]/page.tsx @@ -76,6 +76,7 @@ import { ShareDashboardDialog } from "@/components/dialogs/ShareDashboardDialog" import { BugReportDialog } from "@/components/dialogs/BugReportDialog"; import { OnboardingDialog } from "@/components/dialogs/OnboardingDialog"; import { Canvas } from "@/components/canvas"; +import { DesktopVersionBadge } from "@/components/DesktopVersionBadge"; import { CursorOverlay, PresenceList } from "@/components/multiplayer"; import { useAuthStore } from "@/stores/auth-store"; import { PaywallDialog } from "@/components/subscription/PaywallDialog"; @@ -3605,6 +3606,7 @@ export default function DashboardPage() { OrcaBot +
diff --git a/frontend/src/app/(app)/dashboards/page.tsx b/frontend/src/app/(app)/dashboards/page.tsx index 8c3bf441..0b23b290 100644 --- a/frontend/src/app/(app)/dashboards/page.tsx +++ b/frontend/src/app/(app)/dashboards/page.tsx @@ -54,6 +54,7 @@ import { import { useAuthStore } from "@/stores/auth-store"; import { PaywallDialog } from "@/components/subscription/PaywallDialog"; import { TrialBanner } from "@/components/subscription/TrialBanner"; +import { DesktopVersionBadge } from "@/components/DesktopVersionBadge"; import { API, DESKTOP_MODE } from "@/config/env"; import { switchToCli } from "@/lib/tauri-bridge"; import { @@ -370,6 +371,7 @@ export default function DashboardsPage() { className="w-7 h-7 object-contain" /> OrcaBot +
diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx new file mode 100644 index 00000000..08b15df8 --- /dev/null +++ b/frontend/src/app/(app)/layout.tsx @@ -0,0 +1,15 @@ +import { UpdateProgressOverlay } from "@/components/UpdateProgressOverlay"; + +/** + * Pass-through layout for the authenticated app routes. Its only job is to mount + * the desktop auto-update progress overlay once for every app page (dashboards, + * settings, admin, splash) so update feedback shows wherever the user is. + */ +export default function AppLayout({ children }: { children: React.ReactNode }) { + return ( + <> + {children} + + + ); +} diff --git a/frontend/src/components/DesktopVersionBadge.tsx b/frontend/src/components/DesktopVersionBadge.tsx new file mode 100644 index 00000000..f09ec515 --- /dev/null +++ b/frontend/src/components/DesktopVersionBadge.tsx @@ -0,0 +1,39 @@ +// REVISION: desktop-version-badge-v1 +"use client"; + +import { useEffect, useState } from "react"; +import { DESKTOP_MODE } from "@/config/env"; +import { getAppVersion } from "@/lib/tauri-bridge"; + +/** + * Small "v0.5.0" tag shown next to the Orcabot wordmark in the desktop app so + * users can see which version they're running (invisible otherwise in a packaged + * build). Renders nothing on web/Cloudflare builds or until the version resolves. + */ +export function DesktopVersionBadge({ className = "" }: { className?: string }) { + const [version, setVersion] = useState(null); + + useEffect(() => { + if (!DESKTOP_MODE) return; + let alive = true; + getAppVersion() + .then((v) => { + if (alive) setVersion(v); + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, []); + + if (!DESKTOP_MODE || !version) return null; + + return ( + + v{version} + + ); +} diff --git a/frontend/src/components/UpdateProgressOverlay.tsx b/frontend/src/components/UpdateProgressOverlay.tsx new file mode 100644 index 00000000..d5462971 --- /dev/null +++ b/frontend/src/components/UpdateProgressOverlay.tsx @@ -0,0 +1,140 @@ +// REVISION: update-progress-overlay-v1 +"use client"; + +import { useEffect, useState } from "react"; +import { DESKTOP_MODE } from "@/config/env"; +import { onUpdateProgress, type UpdateProgress } from "@/lib/tauri-bridge"; + +const MODULE_REVISION = "update-progress-overlay-v1"; +if (typeof window !== "undefined") { + console.log( + `[update-overlay] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` + ); +} + +function fmtBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +/** + * Fixed toast that appears while the desktop app is auto-updating. The native + * "Update available" dialog gives no feedback between the user accepting and the + * relaunch; this listens to the Rust `update-progress` events and shows a live + * download bar (then an "installing / restarting" state). No-op on web. + */ +export function UpdateProgressOverlay() { + const [progress, setProgress] = useState(null); + + useEffect(() => { + if (!DESKTOP_MODE) return; + let unlisten: (() => void) | null = null; + onUpdateProgress((p) => setProgress(p)).then((u) => { + unlisten = u; + }); + return () => { + if (unlisten) unlisten(); + }; + }, []); + + if (!DESKTOP_MODE || !progress) return null; + + const { phase, downloaded, total, message } = progress; + const pct = + total && total > 0 + ? Math.min(100, Math.round((downloaded / total) * 100)) + : null; + + const isError = phase === "error"; + const isInstalling = phase === "installing"; + const indeterminate = !isError && !isInstalling && pct === null; + + let title: string; + let detail: string; + if (isError) { + title = "Update failed"; + detail = message || "The update couldn't be installed. It'll retry next launch."; + } else if (isInstalling) { + title = "Installing update…"; + detail = "The app will restart in a moment."; + } else if (phase === "starting") { + title = "Downloading update…"; + detail = "Starting…"; + } else { + title = "Downloading update…"; + detail = + pct !== null + ? `${pct}% · ${fmtBytes(downloaded)}${total ? ` / ${fmtBytes(total)}` : ""}` + : fmtBytes(downloaded); + } + + return ( +
+
+ {!isError && ( + + )} + {title} +
+
{detail}
+ + {!isError && ( +
+
+
+ )} + + +
+ ); +} diff --git a/frontend/src/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index c45b81d8..92bdd215 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -1,8 +1,8 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: tauri-bridge-v5-global-invoke -const MODULE_REVISION = "tauri-bridge-v5-global-invoke"; +// REVISION: tauri-bridge-v6-version-and-update-progress +const MODULE_REVISION = "tauri-bridge-v6-version-and-update-progress"; console.log( `[tauri-bridge] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` ); @@ -68,6 +68,13 @@ export interface ImportProgress { phase: "scanning" | "copying" | "done" | "error"; } +export interface UpdateProgress { + phase: "starting" | "downloading" | "installing" | "error"; + downloaded: number; + total: number | null; + message?: string; +} + // ---- Commands ---- /** Get the host workspace directory path. */ @@ -77,6 +84,18 @@ export async function getWorkspacePath(): Promise { return invoke("get_workspace_path") as Promise; } +/** The running app version (e.g. "0.5.0"); null on web/Cloudflare builds. */ +export async function getAppVersion(): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) return null; + try { + const v = (await invoke("get_app_version")) as string; + return typeof v === "string" && v ? v : null; + } catch { + return null; + } +} + /** Reveal the host workspace directory in Finder/Explorer (desktop only). */ export async function revealWorkspace(): Promise { const invoke = await getTauriInvoke(); @@ -264,6 +283,27 @@ export async function onImportProgress( } } +/** + * Listen for auto-update progress emitted from Rust (`update-progress`). Fires + * once the user accepts the update (download start → per-MB progress → install), + * so the UI can show a download bar. No-op off desktop. + */ +export async function onUpdateProgress( + callback: (progress: UpdateProgress) => void +): Promise<(() => void) | null> { + if (!DESKTOP_MODE) return null; + try { + const mod = await import(/* webpackIgnore: true */ TAURI_WEBVIEW); + const unlisten = await mod.getCurrentWebview().listen( + "update-progress", + (event: { payload: UpdateProgress }) => callback(event.payload) + ); + return unlisten; + } catch { + return null; + } +} + /** Listen for native drag-drop events on the Tauri webview. */ export async function onDragDrop( callback: (event: { From 9bee3beda4b353983bda9a08e650a8c82c329730 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Sun, 12 Jul 2026 21:35:35 +0100 Subject: [PATCH 02/44] fix(sandbox): force Claude Code classic renderer (no full-screen alt-screen) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- sandbox/internal/sessions/session.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sandbox/internal/sessions/session.go b/sandbox/internal/sessions/session.go index 0366ac92..5f757871 100644 --- a/sandbox/internal/sessions/session.go +++ b/sandbox/internal/sessions/session.go @@ -668,6 +668,12 @@ func (s *Session) CreatePTYWithOptions(opts CreatePTYOptions) (*PTYInfo, error) // reconnect logic turns into a PTY restart loop. if agentType == mcp.AgentTypeClaude { envVars["IS_SANDBOX"] = "1" + // REVISION: claude-no-altscreen-v1 + // Force Claude Code's classic inline renderer instead of the full-screen + // alternate-screen TUI (and skip its one-time "use full screen?" prompt). + // The alt-screen takeover is disruptive inside the xterm.js terminal block; + // this env forces classic mode regardless of any saved `tui` setting. + envVars["CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN"] = "1" } // HOME resolves to a dedicated dir INSIDE the workspace, not the workspace root // itself. Keeping ~ under the workspace keeps agent home-dir files self-contained From d56d63ee451cbd692a54ff82cc4751404e8d7301 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Sun, 12 Jul 2026 21:35:35 +0100 Subject: [PATCH 03/44] fix(frontend): stop the browser block's black margin growing when zoomed out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- frontend/src/components/blocks/VncViewer.tsx | 30 ++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/blocks/VncViewer.tsx b/frontend/src/components/blocks/VncViewer.tsx index 4a97d99a..322ab257 100644 --- a/frontend/src/components/blocks/VncViewer.tsx +++ b/frontend/src/components/blocks/VncViewer.tsx @@ -3,7 +3,7 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: vnc-viewer-v1-native-rfb +// REVISION: vnc-viewer-v2-zoom-screensize-margin-fix // // Native noVNC (RFB) viewer — no iframe. Renders the remote framebuffer onto a // canvas inside a plain
that lives directly in the React Flow node, so the @@ -20,7 +20,7 @@ import * as React from "react"; -const MODULE_REVISION = "vnc-viewer-v1-native-rfb"; +const MODULE_REVISION = "vnc-viewer-v2-zoom-screensize-margin-fix"; if (typeof console !== "undefined") { console.log(`[VncViewer] REVISION: ${MODULE_REVISION} loaded`); } @@ -112,6 +112,32 @@ export function VncViewer({ } catch { /* noVNC internals changed — leave input mapping as-is */ } + + // Fix the black margin that grows as the canvas is zoomed 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 that value, so a browser opened while + // zoomed out asks for a framebuffer smaller than the block and paints the + // leftover with `background` (the black margin) — bigger the further you're + // zoomed out. clientWidth/Height are the true (untransformed) layout size and + // match noVNC's own `_currentClientSize()`, so the resize + scale target the + // real block at any zoom (no margin, no resize loop). Best-effort. + try { + const internals = r as unknown as { + _screen?: HTMLElement; + _screenSize?: () => { w: number; h: number }; + }; + const screenEl = internals._screen; + if (screenEl && typeof internals._screenSize === "function") { + internals._screenSize = () => ({ + w: screenEl.clientWidth, + h: screenEl.clientHeight, + }); + } + } catch { + /* noVNC internals changed — leave sizing as-is */ + } + const { viewOnly: vo, qualityLevel: q, compressionLevel: c } = optsRef.current; // Ask the server (x11vnc/Xvfb) to resize its framebuffer to the node so the // page FILLS the block. Falls back to scaleViewport (aspect-fit) when the From cbf67a6a5312cae11ad41a960357fccd728534a1 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Sun, 12 Jul 2026 22:38:21 +0100 Subject: [PATCH 04/44] =?UTF-8?q?feat(desktop):=20first-run=20account=20sc?= =?UTF-8?q?reen=20=E2=80=94=20"Free=20Desktop=20Use"=20or=20sign=20in=20wi?= =?UTF-8?q?th=20orcabot.com?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/app/src-tauri/src/commands.rs | 51 ++++++ desktop/app/src-tauri/src/main.rs | 1 + frontend/src/app/providers.tsx | 71 +++++++- .../src/components/desktop/DesktopWelcome.tsx | 156 ++++++++++++++++++ frontend/src/config/env.ts | 9 + frontend/src/lib/tauri-bridge.ts | 16 ++ frontend/src/stores/desktop-account-store.ts | 62 +++++++ 7 files changed, 357 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/desktop/DesktopWelcome.tsx create mode 100644 frontend/src/stores/desktop-account-store.ts diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index 2277ad8f..df8a4c5f 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -616,6 +616,57 @@ pub fn get_app_version(app: tauri::AppHandle) -> String { app.package_info().version.to_string() } +#[derive(Serialize, Clone)] +pub struct OrcabotAccount { + pub email: String, + pub name: String, +} + +/// Verify an orcabot.com personal access token and return its account identity. +/// Runs from the native layer (not the webview) so it isn't subject to browser +/// CORS, and the token is only ever sent to the FIXED cloud control-plane URL — +/// a compromised webview can't redirect it elsewhere. The desktop app keeps +/// running on the LOCAL control plane; this only confirms the account and reads +/// the email/name to use as the local identity. +#[tauri::command] +pub fn verify_orcabot_account(token: String) -> Result { + let token = token.trim(); + if !token.starts_with("orca_pat_") { + return Err("That doesn't look like an Orcabot token (starts with orca_pat_).".into()); + } + // Fixed to the public cloud control plane on purpose (token exfil guard). + let url = "https://orcabot-controlplane.orcabot.workers.dev/users/me"; + match ureq::get(url) + .set("Authorization", &format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(15)) + .call() + { + Ok(resp) => { + let body: serde_json::Value = resp + .into_json() + .map_err(|e| format!("unexpected response from orcabot.com: {e}"))?; + let email = body["user"]["email"].as_str().unwrap_or("").trim().to_string(); + if email.is_empty() { + return Err("That account has no email — can't sign in.".into()); + } + let name = body["user"]["name"] + .as_str() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(&email) + .to_string(); + Ok(OrcabotAccount { email, name }) + } + Err(ureq::Error::Status(401, _)) => { + Err("That token was rejected. Create a fresh one on orcabot.com and try again.".into()) + } + Err(ureq::Error::Status(code, _)) => { + Err(format!("orcabot.com returned an error ({code}).")) + } + Err(e) => Err(format!("Couldn't reach orcabot.com: {e}")), + } +} + /// Return the per-boot surface token. The host frontend sends it as the /// `X-Orcabot-Surface` header so the control plane knows the request is from the /// trusted GUI (not a process inside the sandbox VM spoofing dev-auth). diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index 2c7d3d8b..b5f9791c 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -995,6 +995,7 @@ fn main() { commands::reveal_workspace, commands::get_ports, commands::get_app_version, + commands::verify_orcabot_account, ]) .setup(|app| { let services = Arc::new(DesktopServices::new()); diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx index c432b008..f73664a1 100644 --- a/frontend/src/app/providers.tsx +++ b/frontend/src/app/providers.tsx @@ -2,8 +2,8 @@ // SPDX-License-Identifier: LicenseRef-Proprietary "use client"; -// REVISION: providers-v6-analytics-init -const MODULE_REVISION = "providers-v6-analytics-init"; +// REVISION: providers-v7-desktop-account-gate +const MODULE_REVISION = "providers-v7-desktop-account-gate"; console.log( `[providers] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` ); @@ -15,6 +15,8 @@ import { Toaster } from "sonner"; import { API, DESKTOP_MODE } from "@/config/env"; import { ensureSurfaceToken } from "@/lib/tauri-bridge"; import { getAuthHeaders, useAuthStore } from "@/stores/auth-store"; +import { useDesktopAccountStore } from "@/stores/desktop-account-store"; +import { DesktopWelcome } from "@/components/desktop/DesktopWelcome"; import type { User, SubscriptionInfo } from "@/types"; import { initAnalytics, stopAnalytics, resetQueue } from "@/lib/analytics"; @@ -49,9 +51,16 @@ interface ProvidersProps { function AuthBootstrapper() { const { isAuthenticated, setUser, setAuthResolved, loginDevMode } = useAuthStore(); const userId = useAuthStore((s) => s.user?.id ?? null); + // Desktop first-run account choice (free / signed-in). Drives whether/who we + // auto-login as; until it's set on a fresh install, the welcome gate is shown. + const accountChoice = useDesktopAccountStore((s) => s.choice); + const accountEmail = useDesktopAccountStore((s) => s.email); + const accountName = useDesktopAccountStore((s) => s.name); + const chooseFree = useDesktopAccountStore((s) => s.chooseFree); // Track which user ID we last validated — re-runs on user switch, not just once per page const validatedUserRef = React.useRef(null); - const unauthBootstrappedRef = React.useRef(false); + // Which (mode/choice) we last bootstrapped for, so a first-run choice re-runs it. + const bootstrappedForRef = React.useRef(null); React.useEffect(() => { // If already authenticated (from localStorage hydration), just mark as resolved. @@ -62,6 +71,9 @@ function AuthBootstrapper() { if (validatedUserRef.current !== userId) { validatedUserRef.current = userId; if (DESKTOP_MODE) { + // Existing installs (already authed before the first-run choice existed) + // count as "free" so the welcome screen never appears for them. + if (!accountChoice) chooseFree(); void ensureSurfaceToken() .then(() => { const authHeaders = getAuthHeaders(); @@ -104,11 +116,19 @@ function AuthBootstrapper() { return; } - if (unauthBootstrappedRef.current) { + // Desktop: nothing to log in as until the first-run choice is made (the + // DesktopWelcome gate is rendered instead). Re-bootstrap if the choice changes + // (null → free/signed-in, or account switch). Web bootstraps exactly once. + if (DESKTOP_MODE && !accountChoice) { return; } - - unauthBootstrappedRef.current = true; + const bootstrapKey = DESKTOP_MODE + ? `${accountChoice}:${accountEmail ?? ""}` + : "web"; + if (bootstrappedForRef.current === bootstrapKey) { + return; + } + bootstrappedForRef.current = bootstrapKey; // Reset validated user so post-login validation runs for the new session validatedUserRef.current = null; let isActive = true; @@ -121,7 +141,14 @@ function AuthBootstrapper() { // below carry X-Orcabot-Surface — the control plane requires it to honor // dev-auth, or /auth/dev/session and the /me ID-sync 401 (wrong user → // empty dashboards). - loginDevMode("Desktop User", "desktop@localhost"); + // "signed-in" uses the real account's email/name as the LOCAL dev-auth + // identity (data stays local; dev-auth resolves users by email). "free" + // uses the anonymous local identity. Either way we run on local control plane. + if (accountChoice === "signed-in" && accountEmail) { + loginDevMode(accountName || accountEmail, accountEmail); + } else { + loginDevMode("Desktop User", "desktop@localhost"); + } await Promise.race([ ensureSurfaceToken(), new Promise((resolve) => setTimeout(resolve, 3000)), @@ -184,7 +211,17 @@ function AuthBootstrapper() { return () => { isActive = false; }; - }, [isAuthenticated, userId, setUser, setAuthResolved, loginDevMode]); + }, [ + isAuthenticated, + userId, + setUser, + setAuthResolved, + loginDevMode, + accountChoice, + accountEmail, + accountName, + chooseFree, + ]); return null; } @@ -206,6 +243,22 @@ function AnalyticsBootstrapper() { return null; } +/** + * On the desktop app's first run, show the welcome screen (Free Desktop Use vs. + * sign in) instead of the app until a choice is made. Web is unaffected, and + * returning/authenticated desktop users skip straight through. + */ +function DesktopAuthGate({ children }: { children: React.ReactNode }) { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const choice = useDesktopAccountStore((s) => s.choice); + const hydrated = useDesktopAccountStore((s) => s.hydrated); + + if (DESKTOP_MODE && hydrated && !isAuthenticated && choice === null) { + return ; + } + return <>{children}; +} + export function Providers({ children }: ProvidersProps) { const queryClient = getQueryClient(); @@ -214,7 +267,7 @@ export function Providers({ children }: ProvidersProps) { - {children} + {children} s.chooseFree); + const chooseSignedIn = useDesktopAccountStore((s) => s.chooseSignedIn); + + const [showSignIn, setShowSignIn] = React.useState(false); + const [token, setToken] = React.useState(""); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(null); + + const connect = async () => { + const t = token.trim(); + if (!t) return; + if (!t.startsWith("orca_pat_")) { + setError("That doesn't look like an Orcabot token (starts with orca_pat_)."); + return; + } + setBusy(true); + setError(null); + try { + // Verify the token + read the identity via the native layer (no CORS). The + // app still runs locally; we just use the real email/name as the local + // account identity, and this proves the token is valid before we trust it. + const account = await verifyOrcabotAccount(t); + chooseSignedIn(account.email, account.name || account.email); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ Orcabot { + (e.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> +

Welcome to Orcabot

+

+ Everything runs locally on this machine either way. +

+ + {/* Primary: free, local, no account */} + + +
+ + or + +
+ + {!showSignIn ? ( + + ) : ( +
+
Connect your orcabot.com account
+
    +
  1. Open orcabot.com and sign in.
  2. +
  3. In Settings, create a personal access token.
  4. +
  5. Paste it below.
  6. +
+ + { + setToken(e.target.value); + if (error) setError(null); + }} + placeholder="orca_pat_…" + autoComplete="off" + spellCheck={false} + className="mt-3 w-full rounded-lg bg-[var(--background)] border border-[var(--border)] px-3 py-2 text-sm outline-none focus:border-[var(--accent,#5b8cff)]" + /> + {error && ( +
{error}
+ )} +
+ + +
+
+ )} +
+
+ ); +} + +export default DesktopWelcome; diff --git a/frontend/src/config/env.ts b/frontend/src/config/env.ts index e26f0cf8..43b05aef 100644 --- a/frontend/src/config/env.ts +++ b/frontend/src/config/env.ts @@ -105,6 +105,15 @@ export const CLOUDFLARE_API_URL = resolveApiUrl(); export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || SITE_URL_BY_TARGET[FRONTEND_TARGET]; +// The PUBLIC cloud control plane + site, regardless of build target. On desktop +// CLOUDFLARE_API_URL points at the LOCAL control plane, so these give the desktop +// app a way to reach orcabot.com for optional "sign in with your account" — the +// app itself keeps running on the local control plane. +export const CLOUD_API_URL = + process.env.NEXT_PUBLIC_CLOUD_API_URL || API_URL_BY_TARGET.production; +export const CLOUD_SITE_URL = + process.env.NEXT_PUBLIC_CLOUD_SITE_URL || SITE_URL_BY_TARGET.production; + export const DEV_MODE_ENABLED = process.env.NEXT_PUBLIC_DEV_MODE_ENABLED === "true"; diff --git a/frontend/src/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index 92bdd215..1fb67fb7 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -96,6 +96,22 @@ export async function getAppVersion(): Promise { } } +export interface OrcabotAccount { + email: string; + name: string; +} + +/** + * Verify an orcabot.com personal access token via the native layer (no browser + * CORS; the token is only sent to the fixed cloud URL). Resolves to the account + * identity, or throws with a user-facing message. Desktop-only. + */ +export async function verifyOrcabotAccount(token: string): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) throw new Error("Sign-in is only available in the desktop app."); + return invoke("verify_orcabot_account", { token }) as Promise; +} + /** Reveal the host workspace directory in Finder/Explorer (desktop only). */ export async function revealWorkspace(): Promise { const invoke = await getTauriInvoke(); diff --git a/frontend/src/stores/desktop-account-store.ts b/frontend/src/stores/desktop-account-store.ts new file mode 100644 index 00000000..5ae7179e --- /dev/null +++ b/frontend/src/stores/desktop-account-store.ts @@ -0,0 +1,62 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary + +// REVISION: desktop-account-store-v1 +"use client"; + +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +/** + * First-run account choice for the DESKTOP app. The desktop stack always runs on + * the LOCAL control plane + local VM; this only records whether the user chose to + * stay anonymous ("free") or connect their orcabot.com identity ("signed-in"). + * Signing in is additive — it sets the local account's identity to the real + * email/name and (later) enables cloud sync; it does NOT move execution to cloud. + * + * We intentionally do NOT persist any cloud secret (PAT) here — that will live in + * the Tauri backend / OS keychain when cloud sync lands. This store holds only the + * choice and the display identity. + */ +export type DesktopAccountChoice = "free" | "signed-in"; + +interface DesktopAccountState { + /** null until the user has made a first-run choice. */ + choice: DesktopAccountChoice | null; + /** Real account identity when signed-in (used as the local dev-auth identity). */ + email: string | null; + name: string | null; + /** True once the persisted value has hydrated from storage (client-only). */ + hydrated: boolean; + + chooseFree: () => void; + chooseSignedIn: (email: string, name: string) => void; + /** Return to the first-run choice (e.g. "sign out" / "use a different account"). */ + reset: () => void; +} + +export const useDesktopAccountStore = create()( + persist( + (set) => ({ + choice: null, + email: null, + name: null, + hydrated: false, + + chooseFree: () => set({ choice: "free", email: null, name: null }), + chooseSignedIn: (email: string, name: string) => + set({ choice: "signed-in", email, name }), + reset: () => set({ choice: null, email: null, name: null }), + }), + { + name: "orcabot-desktop-account", + // Persist only the choice + identity, never a transient flag. + partialize: (s) => ({ choice: s.choice, email: s.email, name: s.name }), + onRehydrateStorage: () => () => { + // Runs after the persisted value is applied — flip the flag so the gate + // renders once we actually know the choice (avoids a first-run flash). + useDesktopAccountStore.setState({ hydrated: true }); + }, + } + ) +); From a44cfb88f68f24ee32920029404284e7155b382d Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 12:47:11 +0100 Subject: [PATCH 05/44] fix(desktop): logout returns to the first-run welcome screen (Free vs sign in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- frontend/src/app/(app)/dashboards/page.tsx | 11 ++++++++++- frontend/src/app/providers.tsx | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/(app)/dashboards/page.tsx b/frontend/src/app/(app)/dashboards/page.tsx index d0c19dea..6f3c77bc 100644 --- a/frontend/src/app/(app)/dashboards/page.tsx +++ b/frontend/src/app/(app)/dashboards/page.tsx @@ -56,6 +56,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { PaywallDialog } from "@/components/subscription/PaywallDialog"; import { TrialBanner } from "@/components/subscription/TrialBanner"; import { DesktopVersionBadge } from "@/components/DesktopVersionBadge"; +import { useDesktopAccountStore } from "@/stores/desktop-account-store"; import { API, DESKTOP_MODE } from "@/config/env"; import { switchToCli } from "@/lib/tauri-bridge"; import { @@ -352,7 +353,15 @@ export default function DashboardsPage() { // Ignore logout errors and clear local state anyway. } logout(); - router.push("/login"); + if (DESKTOP_MODE) { + // Desktop: return to the first-run screen (Free vs sign in). Resetting the + // choice + the cleared auth makes the DesktopAuthGate render the welcome + // screen — no navigation needed (/login → "/" is the web-only marketing + // page, which shows as a dark screen in the app). + useDesktopAccountStore.getState().reset(); + } else { + router.push("/login"); + } }; if (!isAuthResolved || !isAuthenticated) { diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx index f73664a1..57accd76 100644 --- a/frontend/src/app/providers.tsx +++ b/frontend/src/app/providers.tsx @@ -120,6 +120,9 @@ function AuthBootstrapper() { // DesktopWelcome gate is rendered instead). Re-bootstrap if the choice changes // (null → free/signed-in, or account switch). Web bootstraps exactly once. if (DESKTOP_MODE && !accountChoice) { + // Logged out / reset to the welcome screen — clear the guard so the NEXT + // choice (even the same one) triggers a fresh login instead of being skipped. + bootstrappedForRef.current = null; return; } const bootstrapKey = DESKTOP_MODE From c0d22641832453f919b482879809a82f2f6e30ab Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 15:23:40 +0100 Subject: [PATCH 06/44] fix(desktop): clean frontend build so stale cache/chunks can't ship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/scripts/build-desktop-resources.sh | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/desktop/scripts/build-desktop-resources.sh b/desktop/scripts/build-desktop-resources.sh index b023f3f1..9236ed7a 100755 --- a/desktop/scripts/build-desktop-resources.sh +++ b/desktop/scripts/build-desktop-resources.sh @@ -173,6 +173,16 @@ if [ -d "$FRONTEND_DIR" ]; then printf '%s\n' "Building frontend worker..." + # Step 0: clear stale build artifacts. Next.js's incremental cache (.next/cache) + # has silently served MONTHS-old compiled output before — a changed page didn't + # rebuild, shipping stale UI into the desktop app. This is a build step, not a + # hot dev loop, so correctness beats the incremental speedup: clean by default. + # Opt out with FRONTEND_CLEAN=0 when iterating and you trust the cache. + if [ "${FRONTEND_CLEAN:-1}" != "0" ]; then + printf '%s\n' "Clearing frontend build cache (.next, .open-next)..." + rm -rf "$FRONTEND_DIR/.next" "$FRONTEND_DIR/.open-next" + fi + # Step 1: Build with OpenNext (creates .open-next/) ( cd "$FRONTEND_DIR" @@ -287,8 +297,11 @@ ${WASM_MODULES} ], CAPNP_EOF printf '%s\n' " Generated: workerd.frontend.capnp" - # Copy assets directory + # Copy assets directory. Wipe the staged copy first so orphaned chunks from + # previous builds don't pile up (content-hashed filenames mean stale chunks are + # never overwritten, just accumulate — confusing to debug and bloats the app). if [ -d "$FRONTEND_DIR/.open-next/assets" ]; then + rm -rf "$FRONTEND_RES_DIR/assets" cp -r "$FRONTEND_DIR/.open-next/assets" "$FRONTEND_RES_DIR/" printf '%s\n' " Staged: frontend assets" fi @@ -353,8 +366,10 @@ fi TARGET_RES_DIR="$ROOT_DIR/app/src-tauri/target/release/resources" if [ -d "$TARGET_RES_DIR" ]; then if command -v rsync >/dev/null 2>&1; then - rsync -a "$TAURI_RESOURCES_DIR/" "$TARGET_RES_DIR/" + # --delete mirrors exactly, so stale frontend chunks don't linger here either. + rsync -a --delete "$TAURI_RESOURCES_DIR/" "$TARGET_RES_DIR/" else + rm -rf "$TARGET_RES_DIR/frontend/assets" cp -R "$TAURI_RESOURCES_DIR/." "$TARGET_RES_DIR/" fi printf '%s\n' " synced resources -> target/release/resources (dev binary picks these up)" From e9f915f515c14da9b17247d689cbae788feb3bd7 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 15:47:06 +0100 Subject: [PATCH 07/44] fix(desktop): sandbox port conflict slips past dynamic allocation (wildcard bind) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/app/src-tauri/src/main.rs | 19 ++++++++++++++++++- .../src-tauri/vz-helper/Sources/main.swift | 10 +++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index b5f9791c..4cd59131 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -156,10 +156,27 @@ fn passthrough_env(workerd_env: &mut Vec<(&'static str, String)>, key: &'static /// back to `preferred` if nothing is free in range (the later bind then fails /// loudly). Used so the app boots even when a default port is occupied (e.g. a /// stray `wrangler dev` on 8787) instead of silently failing to start. +fn port_is_free(port: u16) -> bool { + // Probe BOTH the IPv4 wildcard and the IPv6 wildcard, not just IPv4 loopback. + // Our consumers bind differently: workerd/d1-shim bind 127.0.0.1 (covered by + // 0.0.0.0, a superset), but the VM forwarder (vz-helper) binds the IPv6/IPv4 + // wildcard `*:port`. A 127.0.0.1-only probe misses a leftover on `*:port`, so + // the port looked free and the VM then collided on it ("Address already in + // use"). Require both families bindable so nothing slips past. + let v4 = std::net::TcpListener::bind(("0.0.0.0", port)).is_ok(); + let v6 = match std::net::TcpListener::bind(("::", port)) { + Ok(_) => true, + Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => false, + // IPv6 unavailable/unsupported — don't treat as "in use" (avoids false skips). + Err(_) => true, + }; + v4 && v6 +} + fn pick_free_port(preferred: u16, used: &[u16]) -> u16 { let mut p = preferred; for _ in 0..200 { - if !used.contains(&p) && std::net::TcpListener::bind(("127.0.0.1", p)).is_ok() { + if !used.contains(&p) && port_is_free(p) { return p; } p = match p.checked_add(1) { diff --git a/desktop/app/src-tauri/vz-helper/Sources/main.swift b/desktop/app/src-tauri/vz-helper/Sources/main.swift index 189cab47..de20d7ea 100644 --- a/desktop/app/src-tauri/vz-helper/Sources/main.swift +++ b/desktop/app/src-tauri/vz-helper/Sources/main.swift @@ -406,7 +406,15 @@ class TCPToVsockForwarder { throw NSError(domain: "TCPForwarder", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid port: \(hostPort)"]) } - listener = try NWListener(using: params, on: port) + // Bind LOOPBACK only. The forwarder is reached solely by host-local + // services (control plane, health checks), never the network, so exposing + // it on all interfaces (`*:port`) was needless attack surface. It also + // makes the bind match the Rust free-port probe (127.0.0.1): a busy port + // is now actually detected, so a dynamic one is chosen instead of the VM + // colliding on the wildcard. + params.requiredLocalEndpoint = NWEndpoint.hostPort(host: .ipv4(.loopback), port: port) + + listener = try NWListener(using: params) // Capture hostPort directly to avoid weak self issues in logging let capturedHostPort = hostPort From 7763b7d7ff112bb35d825303576bdb64ae3dd150 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 16:16:01 +0100 Subject: [PATCH 08/44] =?UTF-8?q?fix(desktop):=20crash=20on=20load=20?= =?UTF-8?q?=E2=80=94=20TDZ=20ReferenceError=20in=20desktop-account-store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- frontend/src/stores/desktop-account-store.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/frontend/src/stores/desktop-account-store.ts b/frontend/src/stores/desktop-account-store.ts index 5ae7179e..38046b33 100644 --- a/frontend/src/stores/desktop-account-store.ts +++ b/frontend/src/stores/desktop-account-store.ts @@ -52,11 +52,21 @@ export const useDesktopAccountStore = create()( name: "orcabot-desktop-account", // Persist only the choice + identity, never a transient flag. partialize: (s) => ({ choice: s.choice, email: s.email, name: s.name }), - onRehydrateStorage: () => () => { - // Runs after the persisted value is applied — flip the flag so the gate - // renders once we actually know the choice (avoids a first-run flash). - useDesktopAccountStore.setState({ hydrated: true }); - }, } ) ); + +// Flip `hydrated` once the persisted value has loaded, so the gate doesn't flash +// the welcome screen before we know the choice. This MUST be done after the store +// is created — referencing `useDesktopAccountStore` inside persist's +// onRehydrateStorage hits the const's temporal dead zone (sync localStorage +// hydrates during create()), which threw a ReferenceError and crashed the app. +if (typeof window !== "undefined") { + const markHydrated = () => + useDesktopAccountStore.setState({ hydrated: true }); + if (useDesktopAccountStore.persist.hasHydrated()) { + markHydrated(); + } else { + useDesktopAccountStore.persist.onFinishHydration(markHydrated); + } +} From f6687c89fe793464b6685c6ee0bc782c5497b5bd Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 16:48:53 +0100 Subject: [PATCH 09/44] feat(desktop): Sign in with Google on the first-run screen (local, nonce-poll) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- controlplane/src/auth/google.ts | 109 +++++- controlplane/src/auth/middleware.ts | 2 +- controlplane/src/index.ts | 7 + .../src/components/desktop/DesktopWelcome.tsx | 313 ++++++++++++------ 4 files changed, 328 insertions(+), 103 deletions(-) diff --git a/controlplane/src/auth/google.ts b/controlplane/src/auth/google.ts index cfe7708f..d364c7dd 100644 --- a/controlplane/src/auth/google.ts +++ b/controlplane/src/auth/google.ts @@ -1,12 +1,13 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: popup-login-v1-oauth-popup -const moduleRevision = "popup-login-v1-oauth-popup"; +// REVISION: desktop-login-v1-google-nonce-poll +const moduleRevision = "desktop-login-v1-google-nonce-poll"; console.log(`[auth/google] REVISION: ${moduleRevision} loaded at ${new Date().toISOString()}`); import type { Env } from '../types'; import { buildSessionCookie, createUserSession } from './sessions'; +import { devAuthSurfaceTrusted } from './middleware'; import { processPendingInvitations } from '../members/handler'; const GOOGLE_LOGIN_SCOPE = [ @@ -201,11 +202,17 @@ export async function loginWithGoogle( } const requestUrl = new URL(request.url); - const isPopupMode = requestUrl.searchParams.get('mode') === 'popup'; + const mode = requestUrl.searchParams.get('mode'); + const isPopupMode = mode === 'popup'; + // Desktop login: the app opens this in the OS browser and polls for the result + // by a nonce (the OS browser can't hand a session back to the Tauri webview). + // Only on the desktop public client; ignored elsewhere so web login is untouched. + const isDesktopMode = mode === 'desktop' && env.OAUTH_PUBLIC_CLIENT === 'true'; + const desktopNonce = isDesktopMode ? requestUrl.searchParams.get('nonce') : null; // Validate Turnstile bot verification token (skip if not configured, e.g. dev) - // Skip Turnstile in popup mode — Google OAuth consent screen provides bot protection - if (env.TURNSTILE_SECRET_KEY && !isPopupMode) { + // Skip Turnstile in popup/desktop mode — Google's consent screen provides bot protection + if (env.TURNSTILE_SECRET_KEY && !isPopupMode && !isDesktopMode) { const turnstileToken = requestUrl.searchParams.get('turnstile_token'); if (!turnstileToken) { return renderErrorPage('Bot verification required. Please try again.'); @@ -220,10 +227,22 @@ export async function loginWithGoogle( } } + if (isDesktopMode && !desktopNonce) { + return renderErrorPage('Desktop sign-in is missing its nonce.'); + } + const state = crypto.randomUUID(); const redirectUri = `${getRedirectBase(request, env)}/auth/google/callback`; - // In popup mode, store sentinel "popup" so callback knows to render completion page - const postLoginRedirect = isPopupMode ? 'popup' : resolvePostLoginRedirect(request, env); + // Sentinel in the state's redirect slot tells the callback how to finish: + // - "popup" → postMessage completion page (web popup login) + // - "desktop:" → stash identity for the desktop app to poll, show a + // "return to Orcabot" page (no browser session is useful) + // - a URL → normal 302 redirect + session cookie + const postLoginRedirect = isPopupMode + ? 'popup' + : isDesktopMode + ? `desktop:${desktopNonce}` + : resolvePostLoginRedirect(request, env); await createAuthState(env, state, postLoginRedirect); @@ -320,6 +339,21 @@ export async function callbackGoogle( // Process any pending dashboard invitations for this email await processPendingInvitations(env, userId, userInfo.email); + // Desktop login: no browser session is useful (the desktop app authenticates to + // its LOCAL control plane via dev-auth, keyed by email — which findOrCreateUser + // above just ensured exists). Stash the identity keyed by the app's nonce so it + // can poll for it, then tell the user to return to Orcabot. + if (postLoginRedirect.startsWith('desktop:')) { + const nonce = postLoginRedirect.slice('desktop:'.length); + const name = userInfo.name || userInfo.email.split('@')[0]; + await createAuthState( + env, + `desktopresult:${nonce}`, + JSON.stringify({ email: userInfo.email, name }) + ); + return renderDesktopReturnPage(); + } + const session = await createUserSession(env, userId); const cookie = buildSessionCookie(request, session.id, session.expiresAt); @@ -344,6 +378,67 @@ export async function callbackGoogle( }); } +function renderDesktopReturnPage(): Response { + return new Response( + ` + + + + Signed in + + + +
+

Signed in to Orcabot

+

You can close this tab and return to the Orcabot app.

+
+ +`, + { headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' } } + ); +} + +/** + * Desktop app polls this (by the nonce it generated + started the browser flow + * with) to learn the signed-in identity, since the OS browser can't hand a + * session back to the Tauri webview. Desktop-only (OAUTH_PUBLIC_CLIENT) and gated + * by the per-boot surface token, so a process inside the sandbox VM — which never + * gets the token — can't harvest the identity even if it guessed the nonce. + */ +export async function getDesktopGoogleResult(request: Request, env: Env): Promise { + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, + }); + + if (env.OAUTH_PUBLIC_CLIENT !== 'true') { + return json({ error: 'not_available' }, 404); + } + if (!devAuthSurfaceTrusted(request, env)) { + return json({ error: 'forbidden' }, 403); + } + const nonce = new URL(request.url).searchParams.get('nonce'); + if (!nonce) { + return json({ error: 'missing_nonce' }, 400); + } + const identity = await consumeAuthState(env, `desktopresult:${nonce}`); + if (!identity) { + return json({ pending: true }); + } + try { + const parsed = JSON.parse(identity) as { email: string; name: string }; + return json({ email: parsed.email, name: parsed.name }); + } catch { + return json({ error: 'corrupt' }, 500); + } +} + function renderLoginCompletePage( frontendOrigin: string, cookie: string, diff --git a/controlplane/src/auth/middleware.ts b/controlplane/src/auth/middleware.ts index 1694884b..9e22eaa4 100644 --- a/controlplane/src/auth/middleware.ts +++ b/controlplane/src/auth/middleware.ts @@ -77,7 +77,7 @@ export async function authenticate( * the host frontend (which sends the matching X-Orcabot-Surface header) may use * dev-auth. When unset (cloud / local dev / older builds), enforcement is off. */ -function devAuthSurfaceTrusted(request: Request, env: Env): boolean { +export function devAuthSurfaceTrusted(request: Request, env: Env): boolean { const expected = env.SURFACE_TOKEN; if (!expected) return true; // Header for fetch/XHR; query param for top-level browser navigations (OAuth diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index 24b228d0..1ae63b26 100644 --- a/controlplane/src/index.ts +++ b/controlplane/src/index.ts @@ -922,6 +922,7 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick s.chooseFree); const chooseSignedIn = useDesktopAccountStore((s) => s.chooseSignedIn); - const [showSignIn, setShowSignIn] = React.useState(false); + // "token" opens the paste panel; "google" shows the waiting-for-browser state. + const [panel, setPanel] = React.useState(null); + + // Token (PAT) flow const [token, setToken] = React.useState(""); - const [busy, setBusy] = React.useState(false); - const [error, setError] = React.useState(null); + const [tokenBusy, setTokenBusy] = React.useState(false); + const [tokenError, setTokenError] = React.useState(null); + + // Google flow + const [googleError, setGoogleError] = React.useState(null); + const googleCancelRef = React.useRef(false); + React.useEffect( + () => () => { + googleCancelRef.current = true; + }, + [] + ); - const connect = async () => { + const connectToken = async () => { const t = token.trim(); if (!t) return; - if (!t.startsWith("orca_pat_")) { - setError("That doesn't look like an Orcabot token (starts with orca_pat_)."); - return; - } - setBusy(true); - setError(null); + setTokenBusy(true); + setTokenError(null); try { - // Verify the token + read the identity via the native layer (no CORS). The - // app still runs locally; we just use the real email/name as the local - // account identity, and this proves the token is valid before we trust it. const account = await verifyOrcabotAccount(t); chooseSignedIn(account.email, account.name || account.email); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + setTokenError(e instanceof Error ? e.message : String(e)); + } finally { + setTokenBusy(false); + } + }; + + const startGoogle = async () => { + googleCancelRef.current = false; + setGoogleError(null); + setPanel("google"); + let unfocus: (() => void) | null = null; + try { + const nonce = randomNonce(); + await ensureSurfaceToken(); + const surface = getCachedSurfaceToken(); + const base = CLOUDFLARE_API_URL; + + // Open the OS browser to the LOCAL control plane's Google login. Google + // requires a real browser (it blocks OAuth in embedded webviews), so we + // poll for the result by nonce rather than getting a callback in-app. + await openExternalUrl( + `${base}/auth/google/login?mode=desktop&nonce=${encodeURIComponent(nonce)}` + ); + + const pollOnce = async (): Promise => { + try { + const resp = await fetch( + `${base}/auth/desktop/google-result?nonce=${encodeURIComponent(nonce)}`, + { + headers: surface ? { "X-Orcabot-Surface": surface } : {}, + cache: "no-store", + } + ); + if (resp.ok) { + const data = (await resp.json()) as { + email?: string; + name?: string; + }; + if (data.email) { + chooseSignedIn(data.email, data.name || data.email); + return true; + } + } + } catch { + /* keep polling */ + } + return false; + }; + + // Poll immediately whenever the app regains focus (i.e. you switch back + // from the browser after signing in), plus a steady background poll. + unfocus = await onAppFocus(() => { + void pollOnce(); + }); + + const deadline = Date.now() + 150_000; // 2.5 min + while (!googleCancelRef.current && Date.now() < deadline) { + if (await pollOnce()) return; // success → chooseSignedIn unmounts this + await new Promise((r) => setTimeout(r, 2000)); + } + if (!googleCancelRef.current) { + setGoogleError("Sign-in timed out. Please try again."); + setPanel(null); + } + } catch (e) { + setGoogleError( + e instanceof Error ? e.message : "Couldn't start Google sign-in." + ); + setPanel(null); } finally { - setBusy(false); + if (unfocus) unfocus(); } }; + const cancelGoogle = () => { + googleCancelRef.current = true; + setPanel(null); + }; + + const optionClass = + "w-full rounded-xl px-5 py-4 text-left border border-[var(--border)] " + + "hover:bg-[var(--background-elevated)] hover:border-[var(--foreground)]/30 " + + "transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent,#5b8cff)]"; + return (
@@ -66,86 +160,115 @@ export function DesktopWelcome() { }} />

Welcome to Orcabot

-

- Everything runs locally on this machine either way. +

+ Everything runs locally on this machine — pick how you sign in.

- {/* Primary: free, local, no account */} - +
- - -
- - or - -
- - {!showSignIn ? ( - ) : ( -
-
Connect your orcabot.com account
-
    -
  1. Open orcabot.com and sign in.
  2. -
  3. In Settings, create a personal access token.
  4. -
  5. Paste it below.
  6. -
+
+ {/* 1. Free / local */} + + + {/* 2. Google */} - { - setToken(e.target.value); - if (error) setError(null); - }} - placeholder="orca_pat_…" - autoComplete="off" - spellCheck={false} - className="mt-3 w-full rounded-lg bg-[var(--background)] border border-[var(--border)] px-3 py-2 text-sm outline-none focus:border-[var(--accent,#5b8cff)]" - /> - {error && ( -
{error}
+ + {/* 3. Token */} + + + {googleError && ( +
{googleError}
+ )} + + {panel === "token" && ( +
+
    +
  1. Open orcabot.com and sign in.
  2. +
  3. In Settings, create a personal access token.
  4. +
  5. Paste it below.
  6. +
+ + { + setToken(e.target.value); + if (tokenError) setTokenError(null); + }} + placeholder="orca_pat_…" + autoComplete="off" + spellCheck={false} + className="mt-3 w-full rounded-lg bg-[var(--background)] border border-[var(--border)] px-3 py-2 text-sm outline-none focus:border-[var(--accent,#5b8cff)]" + /> + {tokenError && ( +
+ {tokenError} +
+ )} + +
)} -
- - -
)}
From 623509822c17d8de8feac1c57ee87eeb80cb203d Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 16:49:05 +0100 Subject: [PATCH 10/44] =?UTF-8?q?fix(desktop):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20event=20listeners,=20centralized=20logout,=20async?= =?UTF-8?q?=20verify,=20markers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/app/src-tauri/src/commands.rs | 11 +++- frontend/src/app/(app)/dashboards/page.tsx | 13 ++--- frontend/src/app/(app)/layout.tsx | 4 ++ .../src/components/DesktopVersionBadge.tsx | 7 +++ frontend/src/lib/tauri-bridge.ts | 56 ++++++++++++------- frontend/src/stores/auth-store.ts | 10 ++++ frontend/src/stores/desktop-account-store.ts | 9 ++- 7 files changed, 79 insertions(+), 31 deletions(-) diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index df8a4c5f..97b7898f 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -628,8 +628,17 @@ pub struct OrcabotAccount { /// a compromised webview can't redirect it elsewhere. The desktop app keeps /// running on the LOCAL control plane; this only confirms the account and reads /// the email/name to use as the local identity. +/// +/// Async: the blocking HTTP call (up to 15s on a slow/offline network) runs on a +/// blocking thread so it never freezes the native UI/IPC event loop during sign-in. #[tauri::command] -pub fn verify_orcabot_account(token: String) -> Result { +pub async fn verify_orcabot_account(token: String) -> Result { + tauri::async_runtime::spawn_blocking(move || verify_orcabot_account_blocking(&token)) + .await + .map_err(|e| format!("sign-in task failed: {e}"))? +} + +fn verify_orcabot_account_blocking(token: &str) -> Result { let token = token.trim(); if !token.starts_with("orca_pat_") { return Err("That doesn't look like an Orcabot token (starts with orca_pat_).".into()); diff --git a/frontend/src/app/(app)/dashboards/page.tsx b/frontend/src/app/(app)/dashboards/page.tsx index 6f3c77bc..64f7746f 100644 --- a/frontend/src/app/(app)/dashboards/page.tsx +++ b/frontend/src/app/(app)/dashboards/page.tsx @@ -56,7 +56,6 @@ import { useAuthStore } from "@/stores/auth-store"; import { PaywallDialog } from "@/components/subscription/PaywallDialog"; import { TrialBanner } from "@/components/subscription/TrialBanner"; import { DesktopVersionBadge } from "@/components/DesktopVersionBadge"; -import { useDesktopAccountStore } from "@/stores/desktop-account-store"; import { API, DESKTOP_MODE } from "@/config/env"; import { switchToCli } from "@/lib/tauri-bridge"; import { @@ -352,14 +351,12 @@ export default function DashboardsPage() { } catch { // Ignore logout errors and clear local state anyway. } + // logout() also resets the desktop account choice (centralized in the store), + // so the DesktopAuthGate shows the welcome screen. On desktop we therefore skip + // navigating to /login → "/" (the web-only marketing page, which is a dark + // screen in the app); the gate overlays the welcome screen in place. logout(); - if (DESKTOP_MODE) { - // Desktop: return to the first-run screen (Free vs sign in). Resetting the - // choice + the cleared auth makes the DesktopAuthGate render the welcome - // screen — no navigation needed (/login → "/" is the web-only marketing - // page, which shows as a dark screen in the app). - useDesktopAccountStore.getState().reset(); - } else { + if (!DESKTOP_MODE) { router.push("/login"); } }; diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index 08b15df8..5186d5ad 100644 --- a/frontend/src/app/(app)/layout.tsx +++ b/frontend/src/app/(app)/layout.tsx @@ -1,5 +1,9 @@ import { UpdateProgressOverlay } from "@/components/UpdateProgressOverlay"; +// REVISION: app-layout-v1-update-overlay +const MODULE_REVISION = "app-layout-v1-update-overlay"; +console.log(`[app-layout] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}`); + /** * Pass-through layout for the authenticated app routes. Its only job is to mount * the desktop auto-update progress overlay once for every app page (dashboards, diff --git a/frontend/src/components/DesktopVersionBadge.tsx b/frontend/src/components/DesktopVersionBadge.tsx index f09ec515..8cded226 100644 --- a/frontend/src/components/DesktopVersionBadge.tsx +++ b/frontend/src/components/DesktopVersionBadge.tsx @@ -5,6 +5,13 @@ import { useEffect, useState } from "react"; import { DESKTOP_MODE } from "@/config/env"; import { getAppVersion } from "@/lib/tauri-bridge"; +const MODULE_REVISION = "desktop-version-badge-v1"; +if (typeof window !== "undefined") { + console.log( + `[desktop-version-badge] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` + ); +} + /** * Small "v0.5.0" tag shown next to the Orcabot wordmark in the desktop app so * users can see which version they're running (invisible otherwise in a packaged diff --git a/frontend/src/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index 1fb67fb7..dc8dbb2b 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -1,8 +1,8 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: tauri-bridge-v6-version-and-update-progress -const MODULE_REVISION = "tauri-bridge-v6-version-and-update-progress"; +// REVISION: tauri-bridge-v7-global-event-listen +const MODULE_REVISION = "tauri-bridge-v7-global-event-listen"; console.log( `[tauri-bridge] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` ); @@ -282,23 +282,47 @@ export async function pickFolder(): Promise { // ---- Events ---- -/** Listen for import progress events emitted from the Rust backend. */ -export async function onImportProgress( - callback: (progress: ImportProgress) => void +/** + * Subscribe to a Rust-emitted (`app.emit`) event. Prefers the runtime-injected + * `window.__TAURI__.event.listen` (present via withGlobalTauri) — the same global + * onAppFocus uses — because the bare `import("@tauri-apps/api/webview")` specifier + * does NOT resolve from the remote-origin packaged webview (it throws, the error + * is swallowed, and the listener silently never fires). Falls back to the dynamic + * import for dev builds where the specifier does resolve. No-op off desktop. + */ +async function listenGlobal( + event: string, + callback: (payload: T) => void ): Promise<(() => void) | null> { - if (!DESKTOP_MODE) return null; + if (!DESKTOP_MODE || typeof window === "undefined") return null; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const listen = (window as any).__TAURI__?.event?.listen; + if (typeof listen === "function") { + const unlisten = await listen(event, (e: { payload: T }) => callback(e.payload)); + return typeof unlisten === "function" ? unlisten : null; + } + } catch { + /* fall through to the dynamic-import path */ + } try { const mod = await import(/* webpackIgnore: true */ TAURI_WEBVIEW); - const unlisten = await mod.getCurrentWebview().listen( - "folder-import-progress", - (event: { payload: ImportProgress }) => callback(event.payload) - ); + const unlisten = await mod + .getCurrentWebview() + .listen(event, (e: { payload: T }) => callback(e.payload)); return unlisten; } catch { return null; } } +/** Listen for import progress events emitted from the Rust backend. */ +export async function onImportProgress( + callback: (progress: ImportProgress) => void +): Promise<(() => void) | null> { + return listenGlobal("folder-import-progress", callback); +} + /** * Listen for auto-update progress emitted from Rust (`update-progress`). Fires * once the user accepts the update (download start → per-MB progress → install), @@ -307,17 +331,7 @@ export async function onImportProgress( export async function onUpdateProgress( callback: (progress: UpdateProgress) => void ): Promise<(() => void) | null> { - if (!DESKTOP_MODE) return null; - try { - const mod = await import(/* webpackIgnore: true */ TAURI_WEBVIEW); - const unlisten = await mod.getCurrentWebview().listen( - "update-progress", - (event: { payload: UpdateProgress }) => callback(event.payload) - ); - return unlisten; - } catch { - return null; - } + return listenGlobal("update-progress", callback); } /** Listen for native drag-drop events on the Tauri webview. */ diff --git a/frontend/src/stores/auth-store.ts b/frontend/src/stores/auth-store.ts index 26bffcfe..0addae95 100644 --- a/frontend/src/stores/auth-store.ts +++ b/frontend/src/stores/auth-store.ts @@ -8,6 +8,7 @@ import { persist } from "zustand/middleware"; import type { User, SubscriptionInfo } from "@/types"; import { generateId } from "@/lib/utils"; import { getCachedSurfaceToken } from "@/lib/tauri-bridge"; +import { useDesktopAccountStore } from "@/stores/desktop-account-store"; /** On desktop, the control plane only honors dev-auth for requests carrying the * per-boot surface token, so include it when present (blocks VM-origin spoofing). */ @@ -105,6 +106,15 @@ export const useAuthStore = create()( isAuthResolved: true, subscription: null, }); + // Desktop: also clear the first-run account choice so logout returns to the + // welcome screen (Free / Google / token) instead of the auto-login + // re-signing the user straight back in. Centralized here so every logout + // path (dashboards header, PaywallDialog, …) behaves the same. No-op on web. + try { + useDesktopAccountStore.getState().reset(); + } catch { + /* store unavailable (SSR / very early) — nothing to reset */ + } }, setLoading: (loading: boolean) => { diff --git a/frontend/src/stores/desktop-account-store.ts b/frontend/src/stores/desktop-account-store.ts index 38046b33..789840f0 100644 --- a/frontend/src/stores/desktop-account-store.ts +++ b/frontend/src/stores/desktop-account-store.ts @@ -1,12 +1,19 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: desktop-account-store-v1 +// REVISION: desktop-account-store-v2-hydration-fix "use client"; import { create } from "zustand"; import { persist } from "zustand/middleware"; +const MODULE_REVISION = "desktop-account-store-v2-hydration-fix"; +if (typeof window !== "undefined") { + console.log( + `[desktop-account-store] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` + ); +} + /** * First-run account choice for the DESKTOP app. The desktop stack always runs on * the LOCAL control plane + local VM; this only records whether the user chose to From 802510ffcfe25d51b0fdab007af45faf718fc8db Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 17:49:13 +0100 Subject: [PATCH 11/44] fix(desktop): point cloud-account calls at api.orcabot.com, not the dead workers.dev host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/app/src-tauri/src/commands.rs | 2 +- frontend/src/config/env.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index 97b7898f..4a43d7b0 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -644,7 +644,7 @@ fn verify_orcabot_account_blocking(token: &str) -> Result = { localhost: "http://localhost:8787", dev: "https://api.dev.orcabot.com", - production: "https://orcabot-controlplane.orcabot.workers.dev", + // The prod control plane is api.orcabot.com. (The old workers.dev subdomain is + // dead — the prod web build overrides this via NEXT_PUBLIC_API_URL, but + // CLOUD_API_URL reads this default directly, so it must be correct.) + production: "https://api.orcabot.com", }; const SITE_URL_BY_TARGET: Record = { From 65490615e46f2bf2b5a835b1c11cfc116e5d84fe Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 17:56:58 +0100 Subject: [PATCH 12/44] =?UTF-8?q?fix(desktop):=20identity=20switch=20stuck?= =?UTF-8?q?=20=E2=80=94=20/auth/dev/session=20minted=20from=20stale=20cook?= =?UTF-8?q?ie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- controlplane/src/index.ts | 43 ++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index 1ae63b26..60c675cb 100644 --- a/controlplane/src/index.ts +++ b/controlplane/src/index.ts @@ -11,7 +11,7 @@ console.log(`[controlplane] REVISION: controlplane-v17-pty-ws-edge-debug loaded */ import type { Env, DashboardItem, RecipeStep, Session } from './types'; -import { authenticate, requireAuth, rejectPatAuth, requireInternalAuth, validateMcpAuth, type AuthContext } from './auth/middleware'; +import { authenticate, requireAuth, rejectPatAuth, requireInternalAuth, validateMcpAuth, devAuthSurfaceTrusted, type AuthContext } from './auth/middleware'; import { checkRateLimitIp, checkRateLimitUser } from './ratelimit/middleware'; import { initializeDatabase } from './db/schema'; import { ensureDb, type EnvWithDb } from './db/remote'; @@ -1169,21 +1169,50 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick(); + let resolvedId = existing?.id; + if (!resolvedId) { + const now = new Date().toISOString(); + await env.DB + .prepare('INSERT INTO users (id, email, name, created_at, trial_started_at) VALUES (?, ?, ?, ?, ?)') + .bind(headerUserId, userEmail, userName, now, now) + .run(); + resolvedId = headerUserId; + } + + const session = await createUserSession(env, resolvedId); const cookie = buildSessionCookie(request, session.id, session.expiresAt); - return new Response(null, { - status: 204, + return new Response(JSON.stringify({ id: resolvedId, email: userEmail }), { + status: 200, headers: { + 'Content-Type': 'application/json', 'Set-Cookie': cookie, }, }); From d23b0aa1f3b03b8fe0311b6d7c2947cfbb014b65 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 18:20:59 +0100 Subject: [PATCH 13/44] =?UTF-8?q?feat(desktop):=20cloud=20sync=20foundatio?= =?UTF-8?q?n=20=E2=80=94=20store=20credential=20+=20list=20cloud=20dashboa?= =?UTF-8?q?rds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/app/src-tauri/src/commands.rs | 92 +++++++++++++++++++ desktop/app/src-tauri/src/main.rs | 4 + .../src/components/desktop/DesktopWelcome.tsx | 4 + frontend/src/lib/tauri-bridge.ts | 42 +++++++++ 4 files changed, 142 insertions(+) diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index 4a43d7b0..1e0cffb2 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -676,6 +676,98 @@ fn verify_orcabot_account_blocking(token: &str) -> Result Option { + use tauri::Manager; + app.path().app_data_dir().ok().map(|d| d.join("cloud-credential")) +} + +fn read_cloud_credential(app: &tauri::AppHandle) -> Option<(String, String)> { + let path = cloud_credential_path(app)?; + let contents = std::fs::read_to_string(path).ok()?; + let mut lines = contents.lines(); + let token = lines.next()?.trim().to_string(); + let email = lines.next().unwrap_or("").trim().to_string(); + if token.is_empty() { + return None; + } + Some((token, email)) +} + +#[derive(Serialize, Clone)] +pub struct CloudAccount { + pub email: String, +} + +/// Persist the cloud credential (PAT + email) host-only (0600) for dashboard sync. +#[tauri::command] +pub fn set_cloud_credential(app: tauri::AppHandle, token: String, email: String) -> Result<(), String> { + let token = token.trim(); + if !token.starts_with("orca_pat_") { + return Err("Not an Orcabot token.".into()); + } + let path = cloud_credential_path(&app).ok_or("no app data dir")?; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + std::fs::write(&path, format!("{}\n{}\n", token, email.trim())) + .map_err(|e| format!("failed to store credential: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + +/// The signed-in cloud account (email), or null if not signed in to the cloud. +#[tauri::command] +pub fn get_cloud_account(app: tauri::AppHandle) -> Option { + read_cloud_credential(&app).map(|(_, email)| CloudAccount { email }) +} + +/// Forget the stored cloud credential (sign out of cloud sync). +#[tauri::command] +pub fn clear_cloud_credential(app: tauri::AppHandle) -> Result<(), String> { + if let Some(path) = cloud_credential_path(&app) { + let _ = std::fs::remove_file(path); + } + Ok(()) +} + +/// List the signed-in user's CLOUD dashboards from api.orcabot.com using the +/// stored PAT. Native (no browser CORS; token never leaves Rust). Returns the raw +/// JSON so the frontend can render the list + mark which are downloaded locally. +#[tauri::command] +pub async fn list_cloud_dashboards(app: tauri::AppHandle) -> Result { + let (token, _email) = read_cloud_credential(&app).ok_or("Not signed in to the cloud.")?; + tauri::async_runtime::spawn_blocking(move || { + match ureq::get(&format!("{CLOUD_API_BASE}/dashboards")) + .set("Authorization", &format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(20)) + .call() + { + Ok(resp) => resp + .into_json::() + .map_err(|e| format!("unexpected response from orcabot.com: {e}")), + Err(ureq::Error::Status(401, _)) => { + Err("Cloud session expired — sign in again.".into()) + } + Err(ureq::Error::Status(code, _)) => Err(format!("orcabot.com returned {code}.")), + Err(e) => Err(format!("Couldn't reach orcabot.com: {e}")), + } + }) + .await + .map_err(|e| format!("list task failed: {e}"))? +} + /// Return the per-boot surface token. The host frontend sends it as the /// `X-Orcabot-Surface` header so the control plane knows the request is from the /// trusted GUI (not a process inside the sandbox VM spoofing dev-auth). diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index 4cd59131..1a608f04 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -1013,6 +1013,10 @@ fn main() { commands::get_ports, commands::get_app_version, commands::verify_orcabot_account, + commands::set_cloud_credential, + commands::get_cloud_account, + commands::clear_cloud_credential, + commands::list_cloud_dashboards, ]) .setup(|app| { let services = Arc::new(DesktopServices::new()); diff --git a/frontend/src/components/desktop/DesktopWelcome.tsx b/frontend/src/components/desktop/DesktopWelcome.tsx index 856536b6..be0fc7f5 100644 --- a/frontend/src/components/desktop/DesktopWelcome.tsx +++ b/frontend/src/components/desktop/DesktopWelcome.tsx @@ -11,6 +11,7 @@ import { getCachedSurfaceToken, onAppFocus, openExternalUrl, + setCloudCredential, verifyOrcabotAccount, } from "@/lib/tauri-bridge"; import { useDesktopAccountStore } from "@/stores/desktop-account-store"; @@ -62,6 +63,9 @@ export function DesktopWelcome() { setTokenError(null); try { const account = await verifyOrcabotAccount(t); + // Keep the token as the cloud credential so we can list/download the user's + // cloud dashboards (the app still runs locally; this is only for sync). + await setCloudCredential(t, account.email); chooseSignedIn(account.email, account.name || account.email); } catch (e) { setTokenError(e instanceof Error ? e.message : String(e)); diff --git a/frontend/src/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index dc8dbb2b..50c04006 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -112,6 +112,48 @@ export async function verifyOrcabotAccount(token: string): Promise; } +// ---- Cloud account credential (dashboard sync) ---- + +export interface CloudAccount { + email: string; +} + +/** Persist the cloud PAT + email natively (host-only) for dashboard sync. */ +export async function setCloudCredential(token: string, email: string): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) return; + await invoke("set_cloud_credential", { token, email }); +} + +/** The signed-in cloud account, or null if not connected. */ +export async function getCloudAccount(): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) return null; + try { + return (await invoke("get_cloud_account")) as CloudAccount | null; + } catch { + return null; + } +} + +/** Forget the stored cloud credential. */ +export async function clearCloudCredential(): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) return; + try { + await invoke("clear_cloud_credential"); + } catch { + /* ignore */ + } +} + +/** List the signed-in user's cloud dashboards (raw JSON from api.orcabot.com). */ +export async function listCloudDashboards(): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) throw new Error("Cloud dashboards are only available in the desktop app."); + return invoke("list_cloud_dashboards"); +} + /** Reveal the host workspace directory in Finder/Explorer (desktop only). */ export async function revealWorkspace(): Promise { const invoke = await getTauriInvoke(); From 38c893336ca6645a36cefdf1cf3d8db12c98373d Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 18:40:12 +0100 Subject: [PATCH 14/44] feat(desktop): picker shows cloud dashboards with download; download into local DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- controlplane/src/dashboards/handler.ts | 11 +- controlplane/src/db/schema.ts | 14 +- controlplane/src/index.ts | 2 +- controlplane/src/types.ts | 3 + desktop/app/src-tauri/src/commands.rs | 29 ++++ desktop/app/src-tauri/src/main.rs | 1 + frontend/src/app/(app)/dashboards/page.tsx | 10 ++ .../desktop/CloudDashboardsSection.tsx | 154 ++++++++++++++++++ frontend/src/lib/api/cloudflare/dashboards.ts | 7 +- frontend/src/lib/cloud-sync.ts | 67 ++++++++ frontend/src/lib/tauri-bridge.ts | 7 + frontend/src/types/dashboard.ts | 3 + 12 files changed, 299 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/desktop/CloudDashboardsSection.tsx create mode 100644 frontend/src/lib/cloud-sync.ts diff --git a/controlplane/src/dashboards/handler.ts b/controlplane/src/dashboards/handler.ts index ee0a9a34..544ea37f 100644 --- a/controlplane/src/dashboards/handler.ts +++ b/controlplane/src/dashboards/handler.ts @@ -28,6 +28,7 @@ function fоrmatDashbоard(row: Record): Dashboard & { secretsC updatedAt: row.updated_at as string, secretsCount: row.secrets_count !== undefined ? Number(row.secrets_count) : undefined, linkedCount: row.linked_count !== undefined ? Number(row.linked_count) : undefined, + cloudId: (row.cloud_id as string | null) ?? null, }; } @@ -177,18 +178,18 @@ export async function getDashbоard( export async function createDashbоard( env: Env, userId: string, - data: { name: string; templateId?: string }, + data: { name: string; templateId?: string; cloudId?: string }, ctx?: Pick, preferredRegion?: string, ): Promise { const id = generateId(); const now = new Date().toISOString(); - // Create dashboard + // Create dashboard (cloud_id set when downloading from a cloud account) await env.DB.prepare(` - INSERT INTO dashboards (id, name, owner_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?) - `).bind(id, data.name, userId, now, now).run(); + INSERT INTO dashboards (id, name, owner_id, cloud_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + `).bind(id, data.name, userId, data.cloudId ?? null, now, now).run(); // Add owner as member await env.DB.prepare(` diff --git a/controlplane/src/db/schema.ts b/controlplane/src/db/schema.ts index aa1aa28a..23a572b6 100644 --- a/controlplane/src/db/schema.ts +++ b/controlplane/src/db/schema.ts @@ -23,6 +23,10 @@ CREATE TABLE IF NOT EXISTS dashboards ( name TEXT NOT NULL, owner_id TEXT NOT NULL REFERENCES users(id), setup_guide TEXT, + -- Desktop: when this dashboard was downloaded from a cloud account, the id of + -- the source cloud dashboard (for the downloaded ✓ state + two-way sync). NULL + -- for purely-local dashboards. + cloud_id TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -1145,7 +1149,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_link_edge_b ON link_edge_map(link_id, edge `; // Initialize the database -const SCHEMA_REVISION = "schema-v15-api-tokens"; +const SCHEMA_REVISION = "schema-v16-dashboard-cloud-id"; export async function initializeDatabase(db: D1Database): Promise { console.log(`[schema] REVISION: ${SCHEMA_REVISION} loaded at ${new Date().toISOString()}`); @@ -1189,6 +1193,14 @@ export async function initializeDatabase(db: D1Database): Promise { // Column already exists. } + try { + await db.prepare(` + ALTER TABLE dashboards ADD COLUMN cloud_id TEXT + `).run(); + } catch { + // Column already exists. + } + try { await db.prepare(` ALTER TABLE egress_blocked_defaults ADD COLUMN revoked_at TEXT diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index 60c675cb..17fc29da 100644 --- a/controlplane/src/index.ts +++ b/controlplane/src/index.ts @@ -1425,7 +1425,7 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick Result Result { + let (token, _email) = read_cloud_credential(&app).ok_or("Not signed in to the cloud.")?; + tauri::async_runtime::spawn_blocking(move || { + match ureq::get(&format!("{CLOUD_API_BASE}/dashboards/{dashboard_id}")) + .set("Authorization", &format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(30)) + .call() + { + Ok(resp) => resp + .into_json::() + .map_err(|e| format!("unexpected response from orcabot.com: {e}")), + Err(ureq::Error::Status(401, _)) => { + Err("Cloud session expired — sign in again.".into()) + } + Err(ureq::Error::Status(code, _)) => Err(format!("orcabot.com returned {code}.")), + Err(e) => Err(format!("Couldn't reach orcabot.com: {e}")), + } + }) + .await + .map_err(|e| format!("fetch task failed: {e}"))? +} + /// Return the per-boot surface token. The host frontend sends it as the /// `X-Orcabot-Surface` header so the control plane knows the request is from the /// trusted GUI (not a process inside the sandbox VM spoofing dev-auth). diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index 1a608f04..742bd79d 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -1017,6 +1017,7 @@ fn main() { commands::get_cloud_account, commands::clear_cloud_credential, commands::list_cloud_dashboards, + commands::get_cloud_dashboard, ]) .setup(|app| { let services = Arc::new(DesktopServices::new()); diff --git a/frontend/src/app/(app)/dashboards/page.tsx b/frontend/src/app/(app)/dashboards/page.tsx index 64f7746f..3aa1868c 100644 --- a/frontend/src/app/(app)/dashboards/page.tsx +++ b/frontend/src/app/(app)/dashboards/page.tsx @@ -56,6 +56,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { PaywallDialog } from "@/components/subscription/PaywallDialog"; import { TrialBanner } from "@/components/subscription/TrialBanner"; import { DesktopVersionBadge } from "@/components/DesktopVersionBadge"; +import { CloudDashboardsSection } from "@/components/desktop/CloudDashboardsSection"; import { API, DESKTOP_MODE } from "@/config/env"; import { switchToCli } from "@/lib/tauri-bridge"; import { @@ -534,6 +535,15 @@ export default function DashboardsPage() {
+ {/* Desktop: your cloud dashboards (download into the local DB to run them) */} + router.push(`/dashboards/${id}`)} + onDownloaded={() => + queryClient.invalidateQueries({ queryKey: ["dashboards"] }) + } + /> + {/* Two-column layout: Dashboards (left) + Environment Variables (right) */}
{/* Your Dashboards Section */} diff --git a/frontend/src/components/desktop/CloudDashboardsSection.tsx b/frontend/src/components/desktop/CloudDashboardsSection.tsx new file mode 100644 index 00000000..5d4822ec --- /dev/null +++ b/frontend/src/components/desktop/CloudDashboardsSection.tsx @@ -0,0 +1,154 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary + +// REVISION: cloud-dashboards-section-v1 +"use client"; + +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Download, Check, Loader2, Cloud } from "lucide-react"; +import { DESKTOP_MODE } from "@/config/env"; +import { getCloudAccount, listCloudDashboards } from "@/lib/tauri-bridge"; +import { downloadCloudDashboard } from "@/lib/cloud-sync"; +import type { Dashboard } from "@/types"; + +const MODULE_REVISION = "cloud-dashboards-section-v1"; +if (typeof window !== "undefined") { + console.log( + `[cloud-dashboards-section] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` + ); +} + +interface CloudDashboard { + id: string; + name: string; + updatedAt?: string; +} + +/** + * Desktop-only picker section: lists the signed-in user's CLOUD dashboards with a + * download (⬇) button, and a downloaded (✓) state for ones already pulled into the + * local DB. Downloading materializes the dashboard locally (see cloud-sync) and it + * then runs on the local VM. Renders nothing unless signed in to a cloud account. + */ +export function CloudDashboardsSection({ + localDashboards, + onOpen, + onDownloaded, +}: { + localDashboards: Dashboard[]; + onOpen: (localDashboardId: string) => void; + onDownloaded: () => void; +}) { + const [cloudEmail, setCloudEmail] = React.useState(null); + const [downloading, setDownloading] = React.useState>(new Set()); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + if (!DESKTOP_MODE) return; + let alive = true; + getCloudAccount() + .then((acc) => { + if (alive) setCloudEmail(acc?.email ?? null); + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, []); + + const { data: cloudDashboards, isLoading } = useQuery({ + queryKey: ["cloud-dashboards"], + queryFn: async () => (await listCloudDashboards()) as CloudDashboard[], + enabled: DESKTOP_MODE && !!cloudEmail, + staleTime: 30_000, + }); + + if (!DESKTOP_MODE || !cloudEmail) return null; + + // Map cloud dashboard id → the local dashboard that was downloaded from it. + const localByCloudId = new Map(); + for (const d of localDashboards) { + if (d.cloudId) localByCloudId.set(d.cloudId, d); + } + + const download = async (cd: CloudDashboard) => { + setError(null); + setDownloading((s) => new Set(s).add(cd.id)); + try { + await downloadCloudDashboard(cd.id, cd.name); + onDownloaded(); + } catch (e) { + setError(e instanceof Error ? e.message : "Download failed."); + } finally { + setDownloading((s) => { + const next = new Set(s); + next.delete(cd.id); + return next; + }); + } + }; + + return ( +
+
+ +

Your cloud dashboards

+ {cloudEmail} +
+ + {error && ( +
{error}
+ )} + + {isLoading ? ( +
+ Loading… +
+ ) : !cloudDashboards || cloudDashboards.length === 0 ? ( +
+ No dashboards in your cloud account yet. +
+ ) : ( +
+ {cloudDashboards.map((cd) => { + const local = localByCloudId.get(cd.id); + const isDownloading = downloading.has(cd.id); + return ( +
+ {cd.name} + {local ? ( + + ) : ( + + )} +
+ ); + })} +
+ )} +
+ ); +} + +export default CloudDashboardsSection; diff --git a/frontend/src/lib/api/cloudflare/dashboards.ts b/frontend/src/lib/api/cloudflare/dashboards.ts index 8f699c21..ce0517f2 100644 --- a/frontend/src/lib/api/cloudflare/dashboards.ts +++ b/frontend/src/lib/api/cloudflare/dashboards.ts @@ -111,12 +111,15 @@ export async function getDashboard(id: string): Promise<{ */ export async function createDashboard( name: string, - templateId?: string + templateId?: string, + cloudId?: string ): Promise<{ dashboard: Dashboard; viewport?: { x: number; y: number; zoom: number }; hasSetupGuide?: boolean }> { const response = await apiPost(API.cloudflare.dashboards, { name, templateId, - } as DashboardCreateRequest); + // Set when materializing a downloaded cloud dashboard (desktop sync). + cloudId, + } as DashboardCreateRequest & { cloudId?: string }); return { dashboard: response.dashboard, viewport: response.viewport, hasSetupGuide: response.hasSetupGuide }; } diff --git a/frontend/src/lib/cloud-sync.ts b/frontend/src/lib/cloud-sync.ts new file mode 100644 index 00000000..37580eb2 --- /dev/null +++ b/frontend/src/lib/cloud-sync.ts @@ -0,0 +1,67 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary + +// REVISION: cloud-sync-v1-download +"use client"; + +import { getCloudDashboard } from "@/lib/tauri-bridge"; +import { createDashboard, createItem, createEdge } from "@/lib/api/cloudflare/dashboards"; +import type { Dashboard, DashboardItem, DashboardEdge } from "@/types"; + +if (typeof window !== "undefined") { + console.log( + `[cloud-sync] REVISION: cloud-sync-v1-download loaded at ${new Date().toISOString()}` + ); +} + +interface CloudDashboardData { + dashboard: { name: string }; + items: DashboardItem[]; + edges: DashboardEdge[]; +} + +/** + * Download a cloud dashboard into the LOCAL control-plane DB. Fetches the cloud + * dashboard's structure (items + edges) via the native layer (PAT), then + * recreates it locally with `cloudId` set so it shows as downloaded and Phase-2 + * sync can map it back. The local copy then runs on the local VM. + * + * Not yet copied: workspace files (terminals start fresh) and sessions — those + * are follow-ups. Structure/notes/todos/layout come across. + */ +export async function downloadCloudDashboard( + cloudId: string, + fallbackName: string +): Promise { + const data = (await getCloudDashboard(cloudId)) as CloudDashboardData; + const name = data.dashboard?.name || fallbackName; + + const { dashboard } = await createDashboard(name, undefined, cloudId); + + // Recreate items, mapping each cloud item id → the new local id (edges use it). + const idMap = new Map(); + for (const item of data.items || []) { + const local = await createItem(dashboard.id, { + type: item.type, + content: item.content, + position: item.position, + size: item.size, + metadata: item.metadata, + }); + idMap.set(item.id, local.id); + } + + for (const edge of data.edges || []) { + const sourceItemId = idMap.get(edge.sourceItemId); + const targetItemId = idMap.get(edge.targetItemId); + if (!sourceItemId || !targetItemId) continue; // endpoint didn't survive (e.g. filtered item) + await createEdge(dashboard.id, { + sourceItemId, + targetItemId, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + }); + } + + return dashboard; +} diff --git a/frontend/src/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index 50c04006..eafdb8fc 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -154,6 +154,13 @@ export async function listCloudDashboards(): Promise { return invoke("list_cloud_dashboards"); } +/** Fetch one cloud dashboard's full data (dashboard + items + edges) for download. */ +export async function getCloudDashboard(dashboardId: string): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) throw new Error("Cloud dashboards are only available in the desktop app."); + return invoke("get_cloud_dashboard", { dashboardId }); +} + /** Reveal the host workspace directory in Finder/Explorer (desktop only). */ export async function revealWorkspace(): Promise { const invoke = await getTauriInvoke(); diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index 39a0aced..c49a4598 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -14,6 +14,9 @@ export interface Dashboard { secretsCount?: number; /** Number of active links to/from this dashboard */ linkedCount?: number; + /** Desktop: the source cloud dashboard id if this was downloaded from a cloud + * account (drives the "downloaded" state + sync); null/absent for local ones. */ + cloudId?: string | null; } /** From a3cf67b63dfb29bb64b4d7eea630146bb2189830 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 18:59:20 +0100 Subject: [PATCH 15/44] feat(desktop): Google sign-in authenticates against the cloud and returns a PAT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- controlplane/src/auth/google.ts | 90 ++++++++++++------- desktop/app/src-tauri/src/commands.rs | 57 ++++++++++++ desktop/app/src-tauri/src/main.rs | 1 + .../src/components/desktop/DesktopWelcome.tsx | 89 +++++++++--------- frontend/src/lib/tauri-bridge.ts | 21 +++++ 5 files changed, 176 insertions(+), 82 deletions(-) diff --git a/controlplane/src/auth/google.ts b/controlplane/src/auth/google.ts index d364c7dd..0d684e96 100644 --- a/controlplane/src/auth/google.ts +++ b/controlplane/src/auth/google.ts @@ -7,7 +7,7 @@ console.log(`[auth/google] REVISION: ${moduleRevision} loaded at ${new Date().to import type { Env } from '../types'; import { buildSessionCookie, createUserSession } from './sessions'; -import { devAuthSurfaceTrusted } from './middleware'; +import { createApiToken } from './api-token'; import { processPendingInvitations } from '../members/handler'; const GOOGLE_LOGIN_SCOPE = [ @@ -206,9 +206,12 @@ export async function loginWithGoogle( const isPopupMode = mode === 'popup'; // Desktop login: the app opens this in the OS browser and polls for the result // by a nonce (the OS browser can't hand a session back to the Tauri webview). - // Only on the desktop public client; ignored elsewhere so web login is untouched. - const isDesktopMode = mode === 'desktop' && env.OAUTH_PUBLIC_CLIENT === 'true'; + // The result is a PAT (full credential), so it's protected with PKCE: the app + // sends the code_challenge here and the matching verifier when it polls. Works on + // any deployment; web popup/redirect login is untouched. + const isDesktopMode = mode === 'desktop'; const desktopNonce = isDesktopMode ? requestUrl.searchParams.get('nonce') : null; + const desktopChallenge = isDesktopMode ? requestUrl.searchParams.get('challenge') : null; // Validate Turnstile bot verification token (skip if not configured, e.g. dev) // Skip Turnstile in popup/desktop mode — Google's consent screen provides bot protection @@ -227,21 +230,22 @@ export async function loginWithGoogle( } } - if (isDesktopMode && !desktopNonce) { - return renderErrorPage('Desktop sign-in is missing its nonce.'); + if (isDesktopMode && (!desktopNonce || !desktopChallenge)) { + return renderErrorPage('Desktop sign-in is missing its nonce/challenge.'); } const state = crypto.randomUUID(); const redirectUri = `${getRedirectBase(request, env)}/auth/google/callback`; // Sentinel in the state's redirect slot tells the callback how to finish: - // - "popup" → postMessage completion page (web popup login) - // - "desktop:" → stash identity for the desktop app to poll, show a - // "return to Orcabot" page (no browser session is useful) - // - a URL → normal 302 redirect + session cookie + // - "popup" → postMessage completion page (web popup login) + // - "desktop::" → mint a PAT, stash it for the desktop app to + // poll (PKCE-guarded), show a "return" page + // - a URL → normal 302 redirect + session cookie + // nonce is a UUID and challenge is base64url — neither contains ':'. const postLoginRedirect = isPopupMode ? 'popup' : isDesktopMode - ? `desktop:${desktopNonce}` + ? `desktop:${desktopNonce}:${desktopChallenge}` : resolvePostLoginRedirect(request, env); await createAuthState(env, state, postLoginRedirect); @@ -339,17 +343,18 @@ export async function callbackGoogle( // Process any pending dashboard invitations for this email await processPendingInvitations(env, userId, userInfo.email); - // Desktop login: no browser session is useful (the desktop app authenticates to - // its LOCAL control plane via dev-auth, keyed by email — which findOrCreateUser - // above just ensured exists). Stash the identity keyed by the app's nonce so it - // can poll for it, then tell the user to return to Orcabot. + // Desktop login: mint a PAT so the desktop app gets a real cloud credential + // (for listing + syncing the user's cloud dashboards), and stash it — with the + // PKCE challenge — keyed by the app's nonce for it to poll. No browser session + // is useful (the app authenticates to its LOCAL control plane via dev-auth). if (postLoginRedirect.startsWith('desktop:')) { - const nonce = postLoginRedirect.slice('desktop:'.length); + const [, nonce, challenge] = postLoginRedirect.split(':'); const name = userInfo.name || userInfo.email.split('@')[0]; + const { token } = await createApiToken(env, userId, 'Orcabot Desktop'); await createAuthState( env, `desktopresult:${nonce}`, - JSON.stringify({ email: userInfo.email, name }) + JSON.stringify({ token, email: userInfo.email, name, challenge }) ); return renderDesktopReturnPage(); } @@ -403,12 +408,20 @@ function renderDesktopReturnPage(): Response { ); } +/** base64url(SHA-256(input)) — PKCE S256 code_challenge computation. */ +async function sha256Base64Url(input: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)); + let bin = ''; + for (const b of new Uint8Array(digest)) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + /** - * Desktop app polls this (by the nonce it generated + started the browser flow - * with) to learn the signed-in identity, since the OS browser can't hand a - * session back to the Tauri webview. Desktop-only (OAUTH_PUBLIC_CLIENT) and gated - * by the per-boot surface token, so a process inside the sandbox VM — which never - * gets the token — can't harvest the identity even if it guessed the nonce. + * Desktop app polls this (by the nonce it started the browser flow with, plus the + * PKCE verifier) to collect its cloud PAT + identity — the OS browser can't hand a + * session back to the Tauri webview. Protected by PKCE: the result is only returned + * to whoever holds the verifier whose SHA-256 matches the challenge sent at login, + * so knowing the (browser-visible) nonce alone can't harvest the PAT. */ export async function getDesktopGoogleResult(request: Request, env: Env): Promise { const json = (body: unknown, status = 200) => @@ -417,26 +430,35 @@ export async function getDesktopGoogleResult(request: Request, env: Env): Promis headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, }); - if (env.OAUTH_PUBLIC_CLIENT !== 'true') { - return json({ error: 'not_available' }, 404); - } - if (!devAuthSurfaceTrusted(request, env)) { - return json({ error: 'forbidden' }, 403); - } - const nonce = new URL(request.url).searchParams.get('nonce'); - if (!nonce) { - return json({ error: 'missing_nonce' }, 400); + const url = new URL(request.url); + const nonce = url.searchParams.get('nonce'); + const verifier = url.searchParams.get('verifier'); + if (!nonce || !verifier) { + return json({ error: 'missing_nonce_or_verifier' }, 400); } - const identity = await consumeAuthState(env, `desktopresult:${nonce}`); - if (!identity) { + + const key = `desktopresult:${nonce}`; + // Peek (don't consume): a wrong verifier must NOT delete the result, or an + // attacker who guessed the nonce could deny the legit app its PAT. + const rec = await env.DB + .prepare('SELECT redirect_url as v FROM auth_states WHERE state = ?') + .bind(key) + .first<{ v: string }>(); + if (!rec) { return json({ pending: true }); } + let parsed: { token: string; email: string; name: string; challenge: string }; try { - const parsed = JSON.parse(identity) as { email: string; name: string }; - return json({ email: parsed.email, name: parsed.name }); + parsed = JSON.parse(rec.v); } catch { return json({ error: 'corrupt' }, 500); } + if ((await sha256Base64Url(verifier)) !== parsed.challenge) { + return json({ error: 'forbidden' }, 403); + } + // Verified — consume it and hand back the PAT + identity. + await env.DB.prepare('DELETE FROM auth_states WHERE state = ?').bind(key).run(); + return json({ token: parsed.token, email: parsed.email, name: parsed.name }); } function renderLoginCompletePage( diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index a66ab492..21271e67 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -797,6 +797,63 @@ pub async fn get_cloud_dashboard( .map_err(|e| format!("fetch task failed: {e}"))? } +#[derive(Serialize, Clone)] +pub struct CloudGoogleResult { + pub pending: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +/// Poll the CLOUD control plane for the desktop Google sign-in result (a PAT + +/// identity), keyed by the app's nonce and PKCE verifier. Native so it isn't +/// subject to browser CORS (the cloud only allows the orcabot.com origin). Returns +/// {pending:true} until the browser sign-in completes. +#[tauri::command] +pub async fn poll_cloud_google_result( + nonce: String, + verifier: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + match ureq::get(&format!("{CLOUD_API_BASE}/auth/desktop/google-result")) + .query("nonce", &nonce) + .query("verifier", &verifier) + .timeout(std::time::Duration::from_secs(15)) + .call() + { + Ok(resp) => { + let v: serde_json::Value = resp + .into_json() + .map_err(|e| format!("unexpected response from orcabot.com: {e}"))?; + if v["pending"].as_bool() == Some(true) { + return Ok(CloudGoogleResult { + pending: true, + token: None, + email: None, + name: None, + }); + } + let token = v["token"].as_str().map(str::to_string); + let email = v["email"].as_str().map(str::to_string); + let name = v["name"].as_str().map(str::to_string); + if token.is_some() && email.is_some() { + Ok(CloudGoogleResult { pending: false, token, email, name }) + } else { + Err("orcabot.com returned an unexpected sign-in result.".into()) + } + } + Err(ureq::Error::Status(403, _)) => Err("Sign-in verification failed.".into()), + Err(ureq::Error::Status(code, _)) => Err(format!("orcabot.com returned {code}.")), + Err(e) => Err(format!("Couldn't reach orcabot.com: {e}")), + } + }) + .await + .map_err(|e| format!("poll task failed: {e}"))? +} + /// Return the per-boot surface token. The host frontend sends it as the /// `X-Orcabot-Surface` header so the control plane knows the request is from the /// trusted GUI (not a process inside the sandbox VM spoofing dev-auth). diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index 742bd79d..a6abf070 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -1018,6 +1018,7 @@ fn main() { commands::clear_cloud_credential, commands::list_cloud_dashboards, commands::get_cloud_dashboard, + commands::poll_cloud_google_result, ]) .setup(|app| { let services = Arc::new(DesktopServices::new()); diff --git a/frontend/src/components/desktop/DesktopWelcome.tsx b/frontend/src/components/desktop/DesktopWelcome.tsx index be0fc7f5..ba7ddac5 100644 --- a/frontend/src/components/desktop/DesktopWelcome.tsx +++ b/frontend/src/components/desktop/DesktopWelcome.tsx @@ -5,18 +5,16 @@ "use client"; import * as React from "react"; -import { CLOUDFLARE_API_URL, CLOUD_SITE_URL } from "@/config/env"; +import { CLOUD_API_URL, CLOUD_SITE_URL } from "@/config/env"; import { - ensureSurfaceToken, - getCachedSurfaceToken, - onAppFocus, openExternalUrl, + pollCloudGoogleResult, setCloudCredential, verifyOrcabotAccount, } from "@/lib/tauri-bridge"; import { useDesktopAccountStore } from "@/stores/desktop-account-store"; -const MODULE_REVISION = "desktop-welcome-v2-three-options-google"; +const MODULE_REVISION = "desktop-welcome-v3-google-cloud-pkce"; if (typeof window !== "undefined") { console.log( `[desktop-welcome] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` @@ -28,6 +26,25 @@ function randomNonce(): string { return `${Date.now()}-${Math.random().toString(36).slice(2)}`; } +function base64UrlEncode(bytes: Uint8Array): string { + let bin = ""; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** PKCE code_verifier: 32 random bytes, base64url. */ +function randomVerifier(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return base64UrlEncode(bytes); +} + +/** PKCE S256 code_challenge = base64url(SHA-256(verifier)). */ +async function sha256Base64Url(input: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); + return base64UrlEncode(new Uint8Array(digest)); +} + /** * First-run screen for the desktop app. Three equally-weighted choices — use it * free (local, no account), sign in with Google, or paste an Orcabot token — all @@ -78,54 +95,32 @@ export function DesktopWelcome() { googleCancelRef.current = false; setGoogleError(null); setPanel("google"); - let unfocus: (() => void) | null = null; try { + // PKCE: the browser flow returns a real cloud PAT, so protect the poll with + // a verifier whose SHA-256 (the challenge) is sent to the CLOUD at login. const nonce = randomNonce(); - await ensureSurfaceToken(); - const surface = getCachedSurfaceToken(); - const base = CLOUDFLARE_API_URL; + const verifier = randomVerifier(); + const challenge = await sha256Base64Url(verifier); - // Open the OS browser to the LOCAL control plane's Google login. Google - // requires a real browser (it blocks OAuth in embedded webviews), so we - // poll for the result by nonce rather than getting a callback in-app. + // Google requires a REAL browser (it blocks OAuth in embedded webviews), and + // this authenticates against the CLOUD control plane (your real account) so + // we get a cloud credential for dashboard sync. await openExternalUrl( - `${base}/auth/google/login?mode=desktop&nonce=${encodeURIComponent(nonce)}` + `${CLOUD_API_URL}/auth/google/login?mode=desktop` + + `&nonce=${encodeURIComponent(nonce)}` + + `&challenge=${encodeURIComponent(challenge)}` ); - const pollOnce = async (): Promise => { - try { - const resp = await fetch( - `${base}/auth/desktop/google-result?nonce=${encodeURIComponent(nonce)}`, - { - headers: surface ? { "X-Orcabot-Surface": surface } : {}, - cache: "no-store", - } - ); - if (resp.ok) { - const data = (await resp.json()) as { - email?: string; - name?: string; - }; - if (data.email) { - chooseSignedIn(data.email, data.name || data.email); - return true; - } - } - } catch { - /* keep polling */ - } - return false; - }; - - // Poll immediately whenever the app regains focus (i.e. you switch back - // from the browser after signing in), plus a steady background poll. - unfocus = await onAppFocus(() => { - void pollOnce(); - }); - - const deadline = Date.now() + 150_000; // 2.5 min + const deadline = Date.now() + 180_000; // 3 min while (!googleCancelRef.current && Date.now() < deadline) { - if (await pollOnce()) return; // success → chooseSignedIn unmounts this + const res = await pollCloudGoogleResult(nonce, verifier); + if (res && !res.pending && res.token && res.email) { + // Store the cloud PAT (for listing/syncing dashboards) + set the local + // signed-in identity. App still runs locally. + await setCloudCredential(res.token, res.email); + chooseSignedIn(res.email, res.name || res.email); + return; // chooseSignedIn unmounts this + } await new Promise((r) => setTimeout(r, 2000)); } if (!googleCancelRef.current) { @@ -137,8 +132,6 @@ export function DesktopWelcome() { e instanceof Error ? e.message : "Couldn't start Google sign-in." ); setPanel(null); - } finally { - if (unfocus) unfocus(); } }; diff --git a/frontend/src/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index eafdb8fc..e5653f01 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -161,6 +161,27 @@ export async function getCloudDashboard(dashboardId: string): Promise { return invoke("get_cloud_dashboard", { dashboardId }); } +export interface CloudGoogleResult { + pending: boolean; + token?: string; + email?: string; + name?: string; +} + +/** + * Poll the CLOUD control plane for the desktop Google sign-in result (a cloud PAT + * + identity), by nonce + PKCE verifier. Native (no browser CORS). Returns + * {pending:true} until the browser sign-in completes. + */ +export async function pollCloudGoogleResult( + nonce: string, + verifier: string +): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) throw new Error("Sign-in is only available in the desktop app."); + return invoke("poll_cloud_google_result", { nonce, verifier }) as Promise; +} + /** Reveal the host workspace directory in Finder/Explorer (desktop only). */ export async function revealWorkspace(): Promise { const invoke = await getTauriInvoke(); From 55d88535ef89138bf3297b6f5e3b0a7eca8aac64 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 19:00:37 +0100 Subject: [PATCH 16/44] fix(controlplane): set dashboard cloud_id via UPDATE, not in the create 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- controlplane/src/dashboards/handler.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/controlplane/src/dashboards/handler.ts b/controlplane/src/dashboards/handler.ts index 544ea37f..8f068387 100644 --- a/controlplane/src/dashboards/handler.ts +++ b/controlplane/src/dashboards/handler.ts @@ -185,11 +185,19 @@ export async function createDashbоard( const id = generateId(); const now = new Date().toISOString(); - // Create dashboard (cloud_id set when downloading from a cloud account) + // Create dashboard. cloud_id (desktop downloads only) is set via a follow-up + // UPDATE rather than in this INSERT, so the INSERT never references the column — + // keeps working on a deployment whose schema migration hasn't added it yet, and + // the UPDATE only runs on desktop where the column exists. await env.DB.prepare(` - INSERT INTO dashboards (id, name, owner_id, cloud_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - `).bind(id, data.name, userId, data.cloudId ?? null, now, now).run(); + INSERT INTO dashboards (id, name, owner_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `).bind(id, data.name, userId, now, now).run(); + if (data.cloudId) { + await env.DB.prepare(`UPDATE dashboards SET cloud_id = ? WHERE id = ?`) + .bind(data.cloudId, id) + .run(); + } // Add owner as member await env.DB.prepare(` From c67998bf0ce0eee687a5665c3fd154f4412aa512 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 20:40:18 +0100 Subject: [PATCH 17/44] fix(desktop): setCloudCredential fails loud instead of silently skipping the store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- frontend/src/lib/tauri-bridge.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index e5653f01..e14ebe3d 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -118,10 +118,12 @@ export interface CloudAccount { email: string; } -/** Persist the cloud PAT + email natively (host-only) for dashboard sync. */ +/** Persist the cloud PAT + email natively (host-only) for dashboard sync. Throws + * (rather than silently no-op'ing) if the native bridge is unavailable, so a + * sign-in can't appear to succeed without actually storing the credential. */ export async function setCloudCredential(token: string, email: string): Promise { const invoke = await getTauriInvoke(); - if (!invoke) return; + if (!invoke) throw new Error("Couldn't reach the desktop app to store the sign-in."); await invoke("set_cloud_credential", { token, email }); } From 0e3f4fd9c3da6f9fb7926de2827712449e272474 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 20:49:18 +0100 Subject: [PATCH 18/44] fix(desktop): logout also clears the stored cloud credential 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- frontend/src/stores/auth-store.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/stores/auth-store.ts b/frontend/src/stores/auth-store.ts index 0addae95..48d3ec36 100644 --- a/frontend/src/stores/auth-store.ts +++ b/frontend/src/stores/auth-store.ts @@ -7,7 +7,7 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { User, SubscriptionInfo } from "@/types"; import { generateId } from "@/lib/utils"; -import { getCachedSurfaceToken } from "@/lib/tauri-bridge"; +import { getCachedSurfaceToken, clearCloudCredential } from "@/lib/tauri-bridge"; import { useDesktopAccountStore } from "@/stores/desktop-account-store"; /** On desktop, the control plane only honors dev-auth for requests carrying the @@ -112,6 +112,10 @@ export const useAuthStore = create()( // path (dashboards header, PaywallDialog, …) behaves the same. No-op on web. try { useDesktopAccountStore.getState().reset(); + // Fully disconnect the cloud account: forget the stored PAT so a later + // sign-in re-establishes it cleanly (and a different account can't inherit + // the previous credential). Fire-and-forget; no-op on web. + void clearCloudCredential(); } catch { /* store unavailable (SSR / very early) — nothing to reset */ } From 13be2a5cebd61643e4f7e7ff2ff7ab6a0f0043ce Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 22:09:58 +0100 Subject: [PATCH 19/44] =?UTF-8?q?fix(desktop):=20cloud=20dashboards=20list?= =?UTF-8?q?=20crash=20=E2=80=94=20unwrap=20{=20dashboards:=20[...]=20}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- .../src/components/desktop/CloudDashboardsSection.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/desktop/CloudDashboardsSection.tsx b/frontend/src/components/desktop/CloudDashboardsSection.tsx index 5d4822ec..513a531f 100644 --- a/frontend/src/components/desktop/CloudDashboardsSection.tsx +++ b/frontend/src/components/desktop/CloudDashboardsSection.tsx @@ -59,7 +59,14 @@ export function CloudDashboardsSection({ const { data: cloudDashboards, isLoading } = useQuery({ queryKey: ["cloud-dashboards"], - queryFn: async () => (await listCloudDashboards()) as CloudDashboard[], + queryFn: async (): Promise => { + // The /dashboards API wraps the list as { dashboards: [...] }; tolerate a + // bare array too. Never return a non-array (guards the .map below). + const raw = await listCloudDashboards(); + if (Array.isArray(raw)) return raw as CloudDashboard[]; + const wrapped = (raw as { dashboards?: unknown })?.dashboards; + return Array.isArray(wrapped) ? (wrapped as CloudDashboard[]) : []; + }, enabled: DESKTOP_MODE && !!cloudEmail, staleTime: 30_000, }); From a38824eca54b764277172d4cf35dffc4f27b95ed Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 22:39:54 +0100 Subject: [PATCH 20/44] =?UTF-8?q?feat(desktop):=20machine-wide=20local=20d?= =?UTF-8?q?ashboards=20=E2=80=94=20stable=20local=20identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- frontend/src/app/providers.tsx | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx index 57accd76..e1a00f66 100644 --- a/frontend/src/app/providers.tsx +++ b/frontend/src/app/providers.tsx @@ -2,8 +2,8 @@ // SPDX-License-Identifier: LicenseRef-Proprietary "use client"; -// REVISION: providers-v7-desktop-account-gate -const MODULE_REVISION = "providers-v7-desktop-account-gate"; +// REVISION: providers-v8-machine-wide-local-identity +const MODULE_REVISION = "providers-v8-machine-wide-local-identity"; console.log( `[providers] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` ); @@ -55,7 +55,8 @@ function AuthBootstrapper() { // auto-login as; until it's set on a fresh install, the welcome gate is shown. const accountChoice = useDesktopAccountStore((s) => s.choice); const accountEmail = useDesktopAccountStore((s) => s.email); - const accountName = useDesktopAccountStore((s) => s.name); + // (account name/email are shown via the account store + CloudDashboardsSection; + // the LOCAL identity no longer depends on them — see bootstrap below.) const chooseFree = useDesktopAccountStore((s) => s.chooseFree); // Track which user ID we last validated — re-runs on user switch, not just once per page const validatedUserRef = React.useRef(null); @@ -144,14 +145,15 @@ function AuthBootstrapper() { // below carry X-Orcabot-Surface — the control plane requires it to honor // dev-auth, or /auth/dev/session and the /me ID-sync 401 (wrong user → // empty dashboards). - // "signed-in" uses the real account's email/name as the LOCAL dev-auth - // identity (data stays local; dev-auth resolves users by email). "free" - // uses the anonymous local identity. Either way we run on local control plane. - if (accountChoice === "signed-in" && accountEmail) { - loginDevMode(accountName || accountEmail, accountEmail); - } else { - loginDevMode("Desktop User", "desktop@localhost"); - } + // + // Machine-wide local dashboards: the LOCAL dev-auth identity is ALWAYS the + // single machine user (desktop@localhost), regardless of Free vs signed-in. + // Signing in is purely additive — it attaches the cloud credential and + // surfaces your email (desktop-account-store + CloudDashboardsSection) — it + // must NOT switch the local owner, or your local dashboards would fork per + // identity and "disappear" when you sign in. Downloaded cloud dashboards + // land under this same machine user, alongside everything made while Free. + loginDevMode("Desktop User", "desktop@localhost"); await Promise.race([ ensureSurfaceToken(), new Promise((resolve) => setTimeout(resolve, 3000)), @@ -222,7 +224,6 @@ function AuthBootstrapper() { loginDevMode, accountChoice, accountEmail, - accountName, chooseFree, ]); From 92a125f6facd07c82fdd97b81474818b29862f1e Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 23:00:10 +0100 Subject: [PATCH 21/44] feat(desktop): Online / Local Storage tabs under "Your Dashboards" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- frontend/src/app/(app)/dashboards/page.tsx | 132 +----- .../desktop/CloudDashboardsSection.tsx | 161 ------- .../src/components/desktop/DashboardsTabs.tsx | 399 ++++++++++++++++++ 3 files changed, 418 insertions(+), 274 deletions(-) delete mode 100644 frontend/src/components/desktop/CloudDashboardsSection.tsx create mode 100644 frontend/src/components/desktop/DashboardsTabs.tsx diff --git a/frontend/src/app/(app)/dashboards/page.tsx b/frontend/src/app/(app)/dashboards/page.tsx index 3aa1868c..ec2b7ecc 100644 --- a/frontend/src/app/(app)/dashboards/page.tsx +++ b/frontend/src/app/(app)/dashboards/page.tsx @@ -2,8 +2,8 @@ // SPDX-License-Identifier: LicenseRef-Proprietary "use client"; -// REVISION: layout-v7-secret-input -const MODULE_REVISION = "layout-v7-secret-input"; +// REVISION: layout-v8-dashboards-tabs +const MODULE_REVISION = "layout-v8-dashboards-tabs"; console.log( `[dashboards] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` ); @@ -28,7 +28,6 @@ import { XCircle, Clock, BarChart3, - Link2, Terminal, } from "lucide-react"; import { toast } from "sonner"; @@ -36,10 +35,6 @@ import { toast } from "sonner"; import { Button, Card, - CardContent, - CardHeader, - CardTitle, - Skeleton, Dialog, DialogContent, DialogHeader, @@ -56,7 +51,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { PaywallDialog } from "@/components/subscription/PaywallDialog"; import { TrialBanner } from "@/components/subscription/TrialBanner"; import { DesktopVersionBadge } from "@/components/DesktopVersionBadge"; -import { CloudDashboardsSection } from "@/components/desktop/CloudDashboardsSection"; +import { DashboardsTabs } from "@/components/desktop/DashboardsTabs"; import { API, DESKTOP_MODE } from "@/config/env"; import { switchToCli } from "@/lib/tauri-bridge"; import { @@ -70,7 +65,7 @@ import { type UserSecret, } from "@/lib/api/cloudflare"; import { listTemplates, deleteTemplate, approveTemplate } from "@/lib/api/cloudflare/templates"; -import { formatRelativeTime, cn } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import type { Dashboard, TemplateCategory } from "@/types/dashboard"; export default function DashboardsPage() { @@ -535,70 +530,23 @@ export default function DashboardsPage() {
- {/* Desktop: your cloud dashboards (download into the local DB to run them) */} - router.push(`/dashboards/${id}`)} - onDownloaded={() => - queryClient.invalidateQueries({ queryKey: ["dashboards"] }) - } - /> - {/* Two-column layout: Dashboards (left) + Environment Variables (right) */}
- {/* Your Dashboards Section */} -
-

- Your Dashboards -

- - {isLoading ? ( -
- {[1, 2, 3, 4].map((i) => ( - - ))} -
- ) : error ? ( -
-

- Failed to load dashboards. Please try again. -

- -
- ) : dashboards && dashboards.length > 0 ? ( -
- {dashboards.map((dashboard) => ( - router.push(`/dashboards/${dashboard.id}`)} - onDelete={() => setDeleteTarget(dashboard)} - /> - ))} -
- ) : ( -
-

- No dashboards yet. Create your first one! -

- -
- )} -
+ {/* Your Dashboards — Online / Local Storage tabs (desktop); local grid on web */} + + queryClient.invalidateQueries({ queryKey: ["dashboards"] }) + } + onOpen={(id) => router.push(`/dashboards/${id}`)} + onDelete={(dashboard) => setDeleteTarget(dashboard)} + onCreateFirst={() => setIsCreateOpen(true)} + onDownloaded={() => + queryClient.invalidateQueries({ queryKey: ["dashboards"] }) + } + /> {/* Secrets & Environment Variables Section */}
@@ -1032,45 +980,3 @@ function NewDashboardCard({ ); } -interface DashboardCardProps { - dashboard: Dashboard; - onClick: () => void; - onDelete: () => void; -} - -function DashboardCard({ dashboard, onClick, onDelete }: DashboardCardProps) { - const isLinked = (dashboard.linkedCount ?? 0) > 0; - - return ( - -
- -
-
- {dashboard.name} - {isLinked && ( - - - - )} -
- -
-
- -

- Updated {formatRelativeTime(dashboard.updatedAt)} -

-
-
-
- ); -} diff --git a/frontend/src/components/desktop/CloudDashboardsSection.tsx b/frontend/src/components/desktop/CloudDashboardsSection.tsx deleted file mode 100644 index 513a531f..00000000 --- a/frontend/src/components/desktop/CloudDashboardsSection.tsx +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright 2026 Rob Macrae. All rights reserved. -// SPDX-License-Identifier: LicenseRef-Proprietary - -// REVISION: cloud-dashboards-section-v1 -"use client"; - -import * as React from "react"; -import { useQuery } from "@tanstack/react-query"; -import { Download, Check, Loader2, Cloud } from "lucide-react"; -import { DESKTOP_MODE } from "@/config/env"; -import { getCloudAccount, listCloudDashboards } from "@/lib/tauri-bridge"; -import { downloadCloudDashboard } from "@/lib/cloud-sync"; -import type { Dashboard } from "@/types"; - -const MODULE_REVISION = "cloud-dashboards-section-v1"; -if (typeof window !== "undefined") { - console.log( - `[cloud-dashboards-section] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` - ); -} - -interface CloudDashboard { - id: string; - name: string; - updatedAt?: string; -} - -/** - * Desktop-only picker section: lists the signed-in user's CLOUD dashboards with a - * download (⬇) button, and a downloaded (✓) state for ones already pulled into the - * local DB. Downloading materializes the dashboard locally (see cloud-sync) and it - * then runs on the local VM. Renders nothing unless signed in to a cloud account. - */ -export function CloudDashboardsSection({ - localDashboards, - onOpen, - onDownloaded, -}: { - localDashboards: Dashboard[]; - onOpen: (localDashboardId: string) => void; - onDownloaded: () => void; -}) { - const [cloudEmail, setCloudEmail] = React.useState(null); - const [downloading, setDownloading] = React.useState>(new Set()); - const [error, setError] = React.useState(null); - - React.useEffect(() => { - if (!DESKTOP_MODE) return; - let alive = true; - getCloudAccount() - .then((acc) => { - if (alive) setCloudEmail(acc?.email ?? null); - }) - .catch(() => {}); - return () => { - alive = false; - }; - }, []); - - const { data: cloudDashboards, isLoading } = useQuery({ - queryKey: ["cloud-dashboards"], - queryFn: async (): Promise => { - // The /dashboards API wraps the list as { dashboards: [...] }; tolerate a - // bare array too. Never return a non-array (guards the .map below). - const raw = await listCloudDashboards(); - if (Array.isArray(raw)) return raw as CloudDashboard[]; - const wrapped = (raw as { dashboards?: unknown })?.dashboards; - return Array.isArray(wrapped) ? (wrapped as CloudDashboard[]) : []; - }, - enabled: DESKTOP_MODE && !!cloudEmail, - staleTime: 30_000, - }); - - if (!DESKTOP_MODE || !cloudEmail) return null; - - // Map cloud dashboard id → the local dashboard that was downloaded from it. - const localByCloudId = new Map(); - for (const d of localDashboards) { - if (d.cloudId) localByCloudId.set(d.cloudId, d); - } - - const download = async (cd: CloudDashboard) => { - setError(null); - setDownloading((s) => new Set(s).add(cd.id)); - try { - await downloadCloudDashboard(cd.id, cd.name); - onDownloaded(); - } catch (e) { - setError(e instanceof Error ? e.message : "Download failed."); - } finally { - setDownloading((s) => { - const next = new Set(s); - next.delete(cd.id); - return next; - }); - } - }; - - return ( -
-
- -

Your cloud dashboards

- {cloudEmail} -
- - {error && ( -
{error}
- )} - - {isLoading ? ( -
- Loading… -
- ) : !cloudDashboards || cloudDashboards.length === 0 ? ( -
- No dashboards in your cloud account yet. -
- ) : ( -
- {cloudDashboards.map((cd) => { - const local = localByCloudId.get(cd.id); - const isDownloading = downloading.has(cd.id); - return ( -
- {cd.name} - {local ? ( - - ) : ( - - )} -
- ); - })} -
- )} -
- ); -} - -export default CloudDashboardsSection; diff --git a/frontend/src/components/desktop/DashboardsTabs.tsx b/frontend/src/components/desktop/DashboardsTabs.tsx new file mode 100644 index 00000000..833f1021 --- /dev/null +++ b/frontend/src/components/desktop/DashboardsTabs.tsx @@ -0,0 +1,399 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary + +// REVISION: dashboards-tabs-v1 +"use client"; + +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Download, HardDrive, Trash2, Link2, Plus, Loader2 } from "lucide-react"; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Skeleton, +} from "@/components/ui"; +import { DESKTOP_MODE } from "@/config/env"; +import { getCloudAccount, listCloudDashboards } from "@/lib/tauri-bridge"; +import { downloadCloudDashboard } from "@/lib/cloud-sync"; +import { formatRelativeTime, cn } from "@/lib/utils"; +import type { Dashboard } from "@/types/dashboard"; + +const MODULE_REVISION = "dashboards-tabs-v1"; +if (typeof window !== "undefined") { + console.log( + `[dashboards-tabs] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` + ); +} + +interface CloudDashboard { + id: string; + name: string; + updatedAt?: string; +} + +/** + * "Your Dashboards" with two tabs — **Online** (your cloud account's dashboards) + * and **Local Storage** (what's on this machine). Only shown when signed in to a + * cloud account; a Free/local-only session sees just the local grid (no tabs, no + * Online tab). Signing in defaults to the Online tab. + * + * A cloud dashboard shows a ⬇ Download until it's pulled local; once downloaded it + * reads "Local Storage" and appears in BOTH tabs (launchable from either) since it + * now exists as a local dashboard with a matching `cloudId`. + */ +export function DashboardsTabs({ + localDashboards, + isLoading, + error, + onRetry, + onOpen, + onDelete, + onCreateFirst, + onDownloaded, +}: { + localDashboards: Dashboard[]; + isLoading: boolean; + error: unknown; + onRetry: () => void; + onOpen: (localDashboardId: string) => void; + onDelete: (dashboard: Dashboard) => void; + onCreateFirst: () => void; + onDownloaded: () => void; +}) { + const [cloudEmail, setCloudEmail] = React.useState(null); + const [downloading, setDownloading] = React.useState>(new Set()); + const [downloadError, setDownloadError] = React.useState(null); + const [tab, setTab] = React.useState<"online" | "local">("local"); + const defaultedToOnline = React.useRef(false); + + React.useEffect(() => { + if (!DESKTOP_MODE) return; + let alive = true; + getCloudAccount() + .then((acc) => { + if (alive) setCloudEmail(acc?.email ?? null); + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, []); + + const hasCloud = DESKTOP_MODE && !!cloudEmail; + + const { data: cloudDashboards, isLoading: cloudLoading } = useQuery({ + queryKey: ["cloud-dashboards"], + queryFn: async (): Promise => { + // /dashboards wraps the list as { dashboards: [...] }; tolerate a bare array + // too. Never return a non-array (guards the .map below). + const raw = await listCloudDashboards(); + if (Array.isArray(raw)) return raw as CloudDashboard[]; + const wrapped = (raw as { dashboards?: unknown })?.dashboards; + return Array.isArray(wrapped) ? (wrapped as CloudDashboard[]) : []; + }, + enabled: hasCloud, + staleTime: 30_000, + }); + + // Signing in defaults to the Online tab (once); logging out drops back to Local + // and re-arms the default so the next sign-in lands on Online again. + React.useEffect(() => { + if (hasCloud && !defaultedToOnline.current) { + defaultedToOnline.current = true; + setTab("online"); + } else if (!hasCloud) { + defaultedToOnline.current = false; + setTab("local"); + } + }, [hasCloud]); + + const localByCloudId = React.useMemo(() => { + const m = new Map(); + for (const d of localDashboards) if (d.cloudId) m.set(d.cloudId, d); + return m; + }, [localDashboards]); + + const cloudList = cloudDashboards ?? []; + + const download = async (cd: CloudDashboard) => { + setDownloadError(null); + setDownloading((s) => new Set(s).add(cd.id)); + try { + await downloadCloudDashboard(cd.id, cd.name); + onDownloaded(); + } catch (e) { + setDownloadError(e instanceof Error ? e.message : "Download failed."); + } finally { + setDownloading((s) => { + const next = new Set(s); + next.delete(cd.id); + return next; + }); + } + }; + + const gridClass = "grid grid-cols-1 md:grid-cols-2 gap-4"; + + const localGrid = () => { + if (isLoading) { + return ( +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ ); + } + if (error) { + return ( +
+

+ Failed to load dashboards. Please try again. +

+ +
+ ); + } + if (localDashboards.length > 0) { + return ( +
+ {localDashboards.map((dashboard) => ( + onOpen(dashboard.id)} + onDelete={() => onDelete(dashboard)} + /> + ))} +
+ ); + } + return ( +
+

+ No dashboards yet. Create your first one! +

+ +
+ ); + }; + + const onlineGrid = () => { + if (cloudLoading && cloudList.length === 0) { + return ( +
+ {[1, 2].map((i) => ( + + ))} +
+ ); + } + if (cloudList.length === 0) { + return ( +
+

+ No dashboards in your cloud account yet. +

+
+ ); + } + return ( +
+ {cloudList.map((cd) => ( + void download(cd)} + onOpen={onOpen} + /> + ))} +
+ ); + }; + + return ( +
+

Your Dashboards

+ + {hasCloud && ( +
+ setTab("online")} + label="Online" + count={cloudList.length} + /> + setTab("local")} + label="Local Storage" + count={localDashboards.length} + /> +
+ )} + + {downloadError && hasCloud && tab === "online" && ( +
+ {downloadError} +
+ )} + + {hasCloud && tab === "online" ? onlineGrid() : localGrid()} +
+ ); +} + +function TabButton({ + active, + onClick, + label, + count, +}: { + active: boolean; + onClick: () => void; + label: string; + count: number; +}) { + return ( + + ); +} + +function CloudDashboardCard({ + cd, + local, + isDownloading, + onDownload, + onOpen, +}: { + cd: CloudDashboard; + local: Dashboard | undefined; + isDownloading: boolean; + onDownload: () => void; + onOpen: (localDashboardId: string) => void; +}) { + const downloaded = !!local; + return ( + +
onOpen(local!.id) : undefined}> + +
+ {cd.name} + {downloaded ? ( + + Local Storage + + ) : ( + + )} +
+
+ +

+ {cd.updatedAt + ? `Updated ${formatRelativeTime(cd.updatedAt)}` + : "In your cloud account"} +

+
+
+
+ ); +} + +function DashboardCard({ + dashboard, + onClick, + onDelete, +}: { + dashboard: Dashboard; + onClick: () => void; + onDelete: () => void; +}) { + const isLinked = (dashboard.linkedCount ?? 0) > 0; + + return ( + +
+ +
+
+ {dashboard.name} + {isLinked && ( + + + + )} +
+ +
+
+ +

+ Updated {formatRelativeTime(dashboard.updatedAt)} +

+
+
+
+ ); +} + +export default DashboardsTabs; From c0628c983811165ac76125ee98ad40d452ad18a4 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 23:10:34 +0100 Subject: [PATCH 22/44] feat(desktop): keep Local Storage tab on Free + "Sign in" (not Log out) for Free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- frontend/src/app/(app)/dashboards/page.tsx | 34 +++++++++++++++---- .../src/components/desktop/DashboardsTabs.tsx | 23 ++++++++----- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/frontend/src/app/(app)/dashboards/page.tsx b/frontend/src/app/(app)/dashboards/page.tsx index ec2b7ecc..caa0e2a9 100644 --- a/frontend/src/app/(app)/dashboards/page.tsx +++ b/frontend/src/app/(app)/dashboards/page.tsx @@ -2,8 +2,8 @@ // SPDX-License-Identifier: LicenseRef-Proprietary "use client"; -// REVISION: layout-v8-dashboards-tabs -const MODULE_REVISION = "layout-v8-dashboards-tabs"; +// REVISION: layout-v9-free-signin-btn +const MODULE_REVISION = "layout-v9-free-signin-btn"; console.log( `[dashboards] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` ); @@ -29,6 +29,7 @@ import { Clock, BarChart3, Terminal, + LogIn, } from "lucide-react"; import { toast } from "sonner"; @@ -48,6 +49,7 @@ import { Tooltip, } from "@/components/ui"; import { useAuthStore } from "@/stores/auth-store"; +import { useDesktopAccountStore } from "@/stores/desktop-account-store"; import { PaywallDialog } from "@/components/subscription/PaywallDialog"; import { TrialBanner } from "@/components/subscription/TrialBanner"; import { DesktopVersionBadge } from "@/components/DesktopVersionBadge"; @@ -72,6 +74,11 @@ export default function DashboardsPage() { const router = useRouter(); const queryClient = useQueryClient(); const { user, logout, isAuthenticated, isAuthResolved, isAdmin, setUser } = useAuthStore(); + // Desktop-only: a Free (local-only) account has nothing to "log out" of, so the + // header offers "Sign in" (→ welcome screen) instead of "Log out". A signed-in + // cloud account still gets a real "Log out". + const accountChoice = useDesktopAccountStore((s) => s.choice); + const isFreeDesktop = DESKTOP_MODE && accountChoice === "free"; const [isCreateOpen, setIsCreateOpen] = React.useState(false); const [newDashboardName, setNewDashboardName] = React.useState(""); @@ -440,11 +447,24 @@ export default function DashboardsPage() { - - - + {isFreeDesktop ? ( + + + + ) : ( + + + + )}
diff --git a/frontend/src/components/desktop/DashboardsTabs.tsx b/frontend/src/components/desktop/DashboardsTabs.tsx index 833f1021..1175d3ed 100644 --- a/frontend/src/components/desktop/DashboardsTabs.tsx +++ b/frontend/src/components/desktop/DashboardsTabs.tsx @@ -1,7 +1,7 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: dashboards-tabs-v1 +// REVISION: dashboards-tabs-v2-local-tab-on-free "use client"; import * as React from "react"; @@ -21,7 +21,7 @@ import { downloadCloudDashboard } from "@/lib/cloud-sync"; import { formatRelativeTime, cn } from "@/lib/utils"; import type { Dashboard } from "@/types/dashboard"; -const MODULE_REVISION = "dashboards-tabs-v1"; +const MODULE_REVISION = "dashboards-tabs-v2-local-tab-on-free"; if (typeof window !== "undefined") { console.log( `[dashboards-tabs] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` @@ -228,14 +228,19 @@ export function DashboardsTabs({

Your Dashboards

- {hasCloud && ( + {/* Desktop always shows the tab bar (Local Storage tab + count is always + present, even on Free); the Online tab only appears once signed in. Web + has no cloud/local split, so it renders just the grid below. */} + {DESKTOP_MODE && (
- setTab("online")} - label="Online" - count={cloudList.length} - /> + {hasCloud && ( + setTab("online")} + label="Online" + count={cloudList.length} + /> + )} setTab("local")} From 4eda80a83647c336fea31c12e90097279f5103e2 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 23:22:49 +0100 Subject: [PATCH 23/44] =?UTF-8?q?copy(desktop):=20"Local=20Desktop=20Use"?= =?UTF-8?q?=20=E2=80=94=20clearer=20than=20"Free=20Desktop=20Use"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- frontend/src/components/desktop/DesktopWelcome.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/desktop/DesktopWelcome.tsx b/frontend/src/components/desktop/DesktopWelcome.tsx index ba7ddac5..27b6889a 100644 --- a/frontend/src/components/desktop/DesktopWelcome.tsx +++ b/frontend/src/components/desktop/DesktopWelcome.tsx @@ -14,7 +14,7 @@ import { } from "@/lib/tauri-bridge"; import { useDesktopAccountStore } from "@/stores/desktop-account-store"; -const MODULE_REVISION = "desktop-welcome-v3-google-cloud-pkce"; +const MODULE_REVISION = "desktop-welcome-v4-local-copy"; if (typeof window !== "undefined") { console.log( `[desktop-welcome] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` @@ -186,11 +186,11 @@ export function DesktopWelcome() {
) : (
- {/* 1. Free / local */} + {/* 1. Local / no account */} From f3bda3f4527008c000cd4534221ecc479ef1b929 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 23:27:07 +0100 Subject: [PATCH 24/44] chore(desktop): align DesktopWelcome revision header comment Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- frontend/src/components/desktop/DesktopWelcome.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/desktop/DesktopWelcome.tsx b/frontend/src/components/desktop/DesktopWelcome.tsx index 27b6889a..e282569b 100644 --- a/frontend/src/components/desktop/DesktopWelcome.tsx +++ b/frontend/src/components/desktop/DesktopWelcome.tsx @@ -1,7 +1,7 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: desktop-welcome-v2-three-options-google +// REVISION: desktop-welcome-v4-local-copy "use client"; import * as React from "react"; From 95f7a87d95180ce2a74dc6dab1a7d5a84c05aa28 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 23:35:28 +0100 Subject: [PATCH 25/44] feat(desktop): "Open online" cloud link on Online-tab dashboard cards 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- .../src/components/desktop/DashboardsTabs.tsx | 66 ++++++++++++------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/desktop/DashboardsTabs.tsx b/frontend/src/components/desktop/DashboardsTabs.tsx index 1175d3ed..fa8dca0d 100644 --- a/frontend/src/components/desktop/DashboardsTabs.tsx +++ b/frontend/src/components/desktop/DashboardsTabs.tsx @@ -1,12 +1,12 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: dashboards-tabs-v2-local-tab-on-free +// REVISION: dashboards-tabs-v3-cloud-link "use client"; import * as React from "react"; import { useQuery } from "@tanstack/react-query"; -import { Download, HardDrive, Trash2, Link2, Plus, Loader2 } from "lucide-react"; +import { Download, HardDrive, Trash2, Link2, Plus, Loader2, Cloud } from "lucide-react"; import { Button, Card, @@ -15,13 +15,17 @@ import { CardTitle, Skeleton, } from "@/components/ui"; -import { DESKTOP_MODE } from "@/config/env"; -import { getCloudAccount, listCloudDashboards } from "@/lib/tauri-bridge"; +import { DESKTOP_MODE, CLOUD_SITE_URL } from "@/config/env"; +import { + getCloudAccount, + listCloudDashboards, + openExternalUrl, +} from "@/lib/tauri-bridge"; import { downloadCloudDashboard } from "@/lib/cloud-sync"; import { formatRelativeTime, cn } from "@/lib/utils"; import type { Dashboard } from "@/types/dashboard"; -const MODULE_REVISION = "dashboards-tabs-v2-local-tab-on-free"; +const MODULE_REVISION = "dashboards-tabs-v3-cloud-link"; if (typeof window !== "undefined") { console.log( `[dashboards-tabs] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` @@ -314,32 +318,46 @@ function CloudDashboardCard({
{cd.name} - {downloaded ? ( - - Local Storage - - ) : ( +
+ {/* Cloud link — opens this dashboard on orcabot.com in the browser. */} - )} + {downloaded ? ( + + Local Storage + + ) : ( + + )} +
From 4e85e9be7ffdc080b5895688ad5dcf2bef077260 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 13 Jul 2026 23:45:25 +0100 Subject: [PATCH 26/44] style(desktop): move Download/Local Storage to card bottom, Open online on top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- .../src/components/desktop/DashboardsTabs.tsx | 86 ++++++++++--------- 1 file changed, 44 insertions(+), 42 deletions(-) diff --git a/frontend/src/components/desktop/DashboardsTabs.tsx b/frontend/src/components/desktop/DashboardsTabs.tsx index fa8dca0d..cd23fe52 100644 --- a/frontend/src/components/desktop/DashboardsTabs.tsx +++ b/frontend/src/components/desktop/DashboardsTabs.tsx @@ -1,7 +1,7 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: dashboards-tabs-v3-cloud-link +// REVISION: dashboards-tabs-v4-action-bottom "use client"; import * as React from "react"; @@ -25,7 +25,7 @@ import { downloadCloudDashboard } from "@/lib/cloud-sync"; import { formatRelativeTime, cn } from "@/lib/utils"; import type { Dashboard } from "@/types/dashboard"; -const MODULE_REVISION = "dashboards-tabs-v3-cloud-link"; +const MODULE_REVISION = "dashboards-tabs-v4-action-bottom"; if (typeof window !== "undefined") { console.log( `[dashboards-tabs] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` @@ -318,54 +318,56 @@ function CloudDashboardCard({
{cd.name} -
- {/* Cloud link — opens this dashboard on orcabot.com in the browser. */} + {/* Cloud link (top) — opens this dashboard on orcabot.com in the browser. */} + +
+ + + {/* Updated time (left) + Download / Local Storage action pinned to the + bottom of the card (right). */} +
+

+ {cd.updatedAt + ? `Updated ${formatRelativeTime(cd.updatedAt)}` + : "In your cloud account"} +

+ {downloaded ? ( + + Local Storage + + ) : ( - {downloaded ? ( - - Local Storage - - ) : ( - - )} -
+ )}
-
- -

- {cd.updatedAt - ? `Updated ${formatRelativeTime(cd.updatedAt)}` - : "In your cloud account"} -

From f74f96732bd6a14d35262c125e41825ebdd6823f Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 14 Jul 2026 00:02:37 +0100 Subject: [PATCH 27/44] feat(desktop): copy workspace files on cloud dashboard download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (/) 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/app/src-tauri/src/commands.rs | 347 ++++++++++++++++++ desktop/app/src-tauri/src/main.rs | 1 + .../src/components/desktop/DashboardsTabs.tsx | 17 +- frontend/src/lib/cloud-sync.ts | 75 +++- frontend/src/lib/tauri-bridge.ts | 21 ++ 5 files changed, 445 insertions(+), 16 deletions(-) diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index 21271e67..3d800e8b 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -797,6 +797,353 @@ pub async fn get_cloud_dashboard( .map_err(|e| format!("fetch task failed: {e}"))? } +// ===== Cloud workspace download (per-dashboard file copy) ===== +// +// Downloading a cloud dashboard copies its canvas (frontend) AND its workspace +// files (this command). The desktop has ONE shared /workspace, so to keep two +// downloaded dashboards from colliding we write each dashboard's files into a +// per-dashboard subfolder `/workspace/` (subdir = the new local +// dashboard id); the recreated terminals get `workingDir=` so they open +// there. Mirrors the CLI `pull`: start/reuse a cloud session, list the workspace +// recursively, GET each file, write it locally with an O_NOFOLLOW-guarded walk. +// Secret values are redacted server-side on read, so secrets never transfer. + +#[derive(Serialize, Clone)] +pub struct WorkspaceDownloadResult { + pub written: u64, + pub skipped: u64, + /// false when the cloud dashboard has no terminal/session — nothing to pull + /// (not an error; a notes-only dashboard has no workspace files). + pub had_workspace: bool, +} + +/// GET a JSON body from the cloud with the PAT. Maps the paywall to a sentinel. +fn cloud_get_json(token: &str, url: &str) -> Result { + match ureq::get(url) + .set("Authorization", &format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(30)) + .call() + { + Ok(rp) => Ok(rp.into_json().unwrap_or(serde_json::Value::Null)), + Err(ureq::Error::Status(401, _)) => Err("Cloud session expired — sign in again.".into()), + Err(ureq::Error::Status(c, rp)) => { + let b = rp.into_string().unwrap_or_default(); + if c == 403 && b.contains("SUBSCRIPTION_REQUIRED") { + return Err("SUBSCRIPTION_REQUIRED".into()); + } + Err(format!("orcabot.com returned {c}.")) + } + Err(e) => Err(format!("Couldn't reach orcabot.com: {e}")), + } +} + +fn cloud_post_json(token: &str, url: &str, body: serde_json::Value) -> Result { + match ureq::post(url) + .set("Authorization", &format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(30)) + .send_json(body) + { + Ok(rp) => Ok(rp.into_json().unwrap_or(serde_json::Value::Null)), + Err(ureq::Error::Status(401, _)) => Err("Cloud session expired — sign in again.".into()), + Err(ureq::Error::Status(c, rp)) => { + let b = rp.into_string().unwrap_or_default(); + if c == 403 && b.contains("SUBSCRIPTION_REQUIRED") { + return Err("SUBSCRIPTION_REQUIRED".into()); + } + Err(format!("orcabot.com returned {c}.")) + } + Err(e) => Err(format!("Couldn't reach orcabot.com: {e}")), + } +} + +fn cloud_file_list(token: &str, sid: &str) -> Result, String> { + let url = format!("{CLOUD_API_BASE}/sessions/{sid}/files?path=/&recursive=true"); + let v = cloud_get_json(token, &url)?; + Ok(v.get("files").and_then(|x| x.as_array()).cloned().unwrap_or_default()) +} + +fn cloud_file_get(token: &str, sid: &str, rel: &str) -> Result, String> { + use std::io::Read; + let url = format!("{CLOUD_API_BASE}/sessions/{sid}/file"); + match ureq::get(&url) + .set("Authorization", &format!("Bearer {token}")) + .query("path", rel) + .timeout(std::time::Duration::from_secs(120)) + .call() + { + Ok(rp) => { + let mut buf = Vec::new(); + rp.into_reader().read_to_end(&mut buf).map_err(|e| e.to_string())?; + Ok(buf) + } + Err(ureq::Error::Status(c, rp)) => { + Err(format!("HTTP {c}: {}", rp.into_string().unwrap_or_default().trim())) + } + Err(e) => Err(e.to_string()), + } +} + +/// Get a live cloud session id for `dash` so the file API works: reuse an active +/// session, else start one on an EXISTING terminal item (boots the cloud VM). We +/// never CREATE a terminal item — a download shouldn't add phantom blocks to the +/// user's cloud dashboard. Returns None when there is no terminal item at all. +fn cloud_ensure_session(token: &str, dash: &str) -> Result, String> { + let dash_url = format!("{CLOUD_API_BASE}/dashboards/{dash}"); + let v = cloud_get_json(token, &dash_url)?; + + if let Some(sessions) = v.get("sessions").and_then(|x| x.as_array()) { + for s in sessions { + if s.get("status").and_then(|x| x.as_str()) == Some("active") { + if let Some(id) = s.get("id").and_then(|x| x.as_str()) { + return Ok(Some(id.to_string())); + } + } + } + } + + let item_id = v + .get("items") + .and_then(|x| x.as_array()) + .and_then(|items| { + items + .iter() + .find(|it| it.get("type").and_then(|x| x.as_str()) == Some("terminal")) + .and_then(|it| it.get("id").and_then(|x| x.as_str()).map(String::from)) + }); + let item_id = match item_id { + Some(i) => i, + None => return Ok(None), + }; + + cloud_post_json( + token, + &format!("{CLOUD_API_BASE}/dashboards/{dash}/items/{item_id}/session"), + serde_json::json!({}), + )?; + + // Poll for the session to go active (cloud spins up a VM — allow generous time). + for _ in 0..60 { + std::thread::sleep(std::time::Duration::from_secs(2)); + let v = cloud_get_json(token, &dash_url)?; + if let Some(sessions) = v.get("sessions").and_then(|x| x.as_array()) { + for s in sessions { + if s.get("itemId").and_then(|x| x.as_str()) == Some(item_id.as_str()) + && s.get("status").and_then(|x| x.as_str()) == Some("active") + { + if let Some(id) = s.get("id").and_then(|x| x.as_str()) { + return Ok(Some(id.to_string())); + } + } + } + } + } + Err("timed out waiting for your cloud workspace to start".into()) +} + +/// Regenerable caches / transients / runtime state we never transfer (mirrors the +/// CLI's `ws_excluded`). +fn ws_excluded(rel: &str) -> bool { + let rel = rel.trim_start_matches('/'); + rel.starts_with(".browser") + || rel.starts_with(".npm") + || rel == ".orcabot" + || rel.starts_with(".orcabot/") + || rel.starts_with(".claude/cache") + || rel == ".git" + || rel.starts_with(".git/") + || rel.split('/').any(|seg| seg == "node_modules") +} + +/// Lexical/ancestor pre-filter for a remote-supplied workspace-relative path. +/// Rejects `..`, absolute paths, and writes through an in-workspace symlink whose +/// nearest existing ancestor escapes the root. The authoritative guard is the +/// O_NOFOLLOW walk in `safe_workspace_write`. (Mirrors the CLI helper.) +fn safe_workspace_dest(ws_canon: &Path, rel: &str) -> Option { + let rel_path = Path::new(rel); + for c in rel_path.components() { + if !matches!(c, Component::Normal(_) | Component::CurDir) { + return None; + } + } + let dest = ws_canon.join(rel_path); + let mut anc = dest.parent(); + while let Some(a) = anc { + if a.exists() { + match a.canonicalize() { + Ok(real) if real.starts_with(ws_canon) => break, + _ => return None, + } + } + anc = a.parent(); + } + Some(dest) +} + +/// Write `data` to `rel` under `ws_root`, walking every path component with +/// openat + O_NOFOLLOW so no component can be a symlink (race-safe against a +/// workspace-sharing process). (Mirrors the CLI helper.) +#[cfg(unix)] +fn safe_workspace_write(ws_root: &Path, rel: &str, data: &[u8]) -> std::io::Result<()> { + use std::ffi::CString; + use std::io::{Error, ErrorKind, Write}; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::io::FromRawFd; + + fn cstr(bytes: &[u8]) -> std::io::Result { + CString::new(bytes).map_err(|_| Error::new(ErrorKind::InvalidInput, "NUL in path")) + } + + let root_c = cstr(ws_root.as_os_str().as_bytes())?; + let mut dirfd = unsafe { libc::open(root_c.as_ptr(), libc::O_DIRECTORY | libc::O_CLOEXEC) }; + if dirfd < 0 { + return Err(Error::last_os_error()); + } + + let comps: Vec<&str> = rel.split('/').filter(|c| !c.is_empty() && *c != ".").collect(); + let (file_name, dirs) = match comps.split_last() { + Some(x) => x, + None => { + unsafe { libc::close(dirfd) }; + return Err(Error::new(ErrorKind::InvalidInput, "empty path")); + } + }; + + for comp in dirs { + if *comp == ".." { + unsafe { libc::close(dirfd) }; + return Err(Error::new(ErrorKind::InvalidInput, "'..' in path")); + } + let c = cstr(comp.as_bytes())?; + let mk = unsafe { libc::mkdirat(dirfd, c.as_ptr(), 0o755) }; + if mk < 0 { + let err = Error::last_os_error(); + if err.raw_os_error() != Some(libc::EEXIST) { + unsafe { libc::close(dirfd) }; + return Err(err); + } + } + let next = unsafe { + libc::openat( + dirfd, + c.as_ptr(), + libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + unsafe { libc::close(dirfd) }; + if next < 0 { + return Err(Error::last_os_error()); + } + dirfd = next; + } + + if *file_name == ".." { + unsafe { libc::close(dirfd) }; + return Err(Error::new(ErrorKind::InvalidInput, "'..' in path")); + } + let fc = cstr(file_name.as_bytes())?; + let filefd = unsafe { + libc::openat( + dirfd, + fc.as_ptr(), + libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_NOFOLLOW | libc::O_CLOEXEC, + 0o644, + ) + }; + unsafe { libc::close(dirfd) }; + if filefd < 0 { + return Err(Error::last_os_error()); + } + let mut f = unsafe { std::fs::File::from_raw_fd(filefd) }; + f.write_all(data) +} + +#[cfg(not(unix))] +fn safe_workspace_write(ws_root: &Path, rel: &str, data: &[u8]) -> std::io::Result<()> { + let dest = ws_root.join(rel); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&dest, data) +} + +/// Copy a cloud dashboard's workspace files into the local per-dashboard subfolder +/// `/workspace/`. Best-effort per file; returns counts. Runs on a +/// blocking thread (ureq + a session-start poll that can take a minute+). +#[tauri::command] +pub async fn download_cloud_workspace( + app: tauri::AppHandle, + cloud_id: String, + subdir: String, +) -> Result { + use tauri::Manager; + let (token, _email) = read_cloud_credential(&app).ok_or("Not signed in to the cloud.")?; + + // subdir is the local dashboard id — must be a single safe path component. + let subdir = subdir.trim().trim_matches('/').to_string(); + if subdir.is_empty() || subdir.contains('/') || subdir.contains("..") { + return Err("invalid workspace subdir".into()); + } + let ws_root = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("workspace") + .join(&subdir); + std::fs::create_dir_all(&ws_root).map_err(|e| format!("create workspace dir: {e}"))?; + let ws_canon = ws_root + .canonicalize() + .map_err(|e| format!("resolve workspace dir: {e}"))?; + + tauri::async_runtime::spawn_blocking(move || { + let sid = match cloud_ensure_session(&token, &cloud_id) { + Ok(Some(s)) => s, + Ok(None) => { + return Ok(WorkspaceDownloadResult { written: 0, skipped: 0, had_workspace: false }) + } + Err(e) if e == "SUBSCRIPTION_REQUIRED" => { + return Err( + "Starting your cloud workspace needs an active OrcaBot subscription.".into(), + ) + } + Err(e) => return Err(e), + }; + + let files = cloud_file_list(&token, &sid)?; + let mut written = 0u64; + let mut skipped = 0u64; + for f in &files { + if f.get("is_dir").and_then(|x| x.as_bool()).unwrap_or(false) { + continue; + } + let rel = match f.get("path").and_then(|x| x.as_str()) { + Some(p) => p.trim_start_matches('/').to_string(), + None => continue, + }; + if rel.is_empty() || ws_excluded(&rel) { + continue; + } + if safe_workspace_dest(&ws_canon, &rel).is_none() { + skipped += 1; + continue; + } + let data = match cloud_file_get(&token, &sid, &rel) { + Ok(d) => d, + Err(_) => { + skipped += 1; + continue; + } + }; + match safe_workspace_write(&ws_canon, &rel, &data) { + Ok(()) => written += 1, + Err(_) => skipped += 1, + } + } + Ok(WorkspaceDownloadResult { written, skipped, had_workspace: true }) + }) + .await + .map_err(|e| format!("workspace download task failed: {e}"))? +} + #[derive(Serialize, Clone)] pub struct CloudGoogleResult { pub pending: bool, diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index a6abf070..cad8013f 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -1018,6 +1018,7 @@ fn main() { commands::clear_cloud_credential, commands::list_cloud_dashboards, commands::get_cloud_dashboard, + commands::download_cloud_workspace, commands::poll_cloud_google_result, ]) .setup(|app| { diff --git a/frontend/src/components/desktop/DashboardsTabs.tsx b/frontend/src/components/desktop/DashboardsTabs.tsx index cd23fe52..a25cebf3 100644 --- a/frontend/src/components/desktop/DashboardsTabs.tsx +++ b/frontend/src/components/desktop/DashboardsTabs.tsx @@ -1,7 +1,7 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: dashboards-tabs-v4-action-bottom +// REVISION: dashboards-tabs-v5-workspace-download "use client"; import * as React from "react"; @@ -25,7 +25,7 @@ import { downloadCloudDashboard } from "@/lib/cloud-sync"; import { formatRelativeTime, cn } from "@/lib/utils"; import type { Dashboard } from "@/types/dashboard"; -const MODULE_REVISION = "dashboards-tabs-v4-action-bottom"; +const MODULE_REVISION = "dashboards-tabs-v5-workspace-download"; if (typeof window !== "undefined") { console.log( `[dashboards-tabs] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` @@ -126,11 +126,20 @@ export function DashboardsTabs({ setDownloadError(null); setDownloading((s) => new Set(s).add(cd.id)); try { - await downloadCloudDashboard(cd.id, cd.name); - onDownloaded(); + const res = await downloadCloudDashboard(cd.id, cd.name); + // Canvas downloaded; if the workspace file copy failed, say so (the dashboard + // is still usable, just without its files). + if (res.workspaceError) { + setDownloadError( + `“${cd.name}” downloaded, but its files couldn't be copied: ${res.workspaceError}` + ); + } } catch (e) { setDownloadError(e instanceof Error ? e.message : "Download failed."); } finally { + // Always refresh — the canvas may have been created even if files failed, so + // the card should flip to "Local Storage". + onDownloaded(); setDownloading((s) => { const next = new Set(s); next.delete(cd.id); diff --git a/frontend/src/lib/cloud-sync.ts b/frontend/src/lib/cloud-sync.ts index 37580eb2..4003f462 100644 --- a/frontend/src/lib/cloud-sync.ts +++ b/frontend/src/lib/cloud-sync.ts @@ -1,16 +1,20 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: cloud-sync-v1-download +// REVISION: cloud-sync-v2-workspace-files "use client"; -import { getCloudDashboard } from "@/lib/tauri-bridge"; +import { + getCloudDashboard, + downloadCloudWorkspace, + type WorkspaceDownloadResult, +} from "@/lib/tauri-bridge"; import { createDashboard, createItem, createEdge } from "@/lib/api/cloudflare/dashboards"; import type { Dashboard, DashboardItem, DashboardEdge } from "@/types"; if (typeof window !== "undefined") { console.log( - `[cloud-sync] REVISION: cloud-sync-v1-download loaded at ${new Date().toISOString()}` + `[cloud-sync] REVISION: cloud-sync-v2-workspace-files loaded at ${new Date().toISOString()}` ); } @@ -20,30 +24,67 @@ interface CloudDashboardData { edges: DashboardEdge[]; } +export interface DownloadResult { + dashboard: Dashboard; + /** Workspace file-copy result, or null if it couldn't run. */ + workspace: WorkspaceDownloadResult | null; + /** Set if the workspace file copy failed (the canvas still downloaded). */ + workspaceError?: string; +} + +/** + * On the desktop there is ONE shared /workspace, so each downloaded dashboard's + * terminals are pinned to a per-dashboard subfolder (= the new local dashboard id) + * via `workingDir`. This keeps two downloads from colliding and matches where + * `download_cloud_workspace` writes the files. Any existing workingDir is preserved + * underneath the subfolder. Returns the content JSON string to store on the item. + */ +function pinTerminalToSubdir(content: string, subdir: string): string { + if (!content.trim().startsWith("{")) { + return content; // not JSON we understand — leave it (cwds to workspace root) + } + let obj: Record; + try { + obj = JSON.parse(content) as Record; + } catch { + return content; + } + const existing = + typeof obj.workingDir === "string" ? obj.workingDir.replace(/^\/+/, "").trim() : ""; + obj.workingDir = existing ? `${subdir}/${existing}` : subdir; + return JSON.stringify(obj); +} + /** - * Download a cloud dashboard into the LOCAL control-plane DB. Fetches the cloud - * dashboard's structure (items + edges) via the native layer (PAT), then - * recreates it locally with `cloudId` set so it shows as downloaded and Phase-2 - * sync can map it back. The local copy then runs on the local VM. + * Download a cloud dashboard into the LOCAL control-plane DB and copy its + * workspace files. Fetches the cloud dashboard's structure (items + edges) via the + * native layer (PAT) and recreates it locally with `cloudId` set (so it shows as + * downloaded and Phase-2 sync can map it back), then pulls the cloud workspace + * files into a per-dashboard subfolder. The local copy runs on the local VM. * - * Not yet copied: workspace files (terminals start fresh) and sessions — those - * are follow-ups. Structure/notes/todos/layout come across. + * The workspace copy is best-effort: if it fails (cloud VM won't start, no + * subscription, timeout), the canvas is still downloaded and `workspaceError` is + * set so the caller can tell the user. */ export async function downloadCloudDashboard( cloudId: string, fallbackName: string -): Promise { +): Promise { const data = (await getCloudDashboard(cloudId)) as CloudDashboardData; const name = data.dashboard?.name || fallbackName; const { dashboard } = await createDashboard(name, undefined, cloudId); + const subdir = dashboard.id; // Recreate items, mapping each cloud item id → the new local id (edges use it). + // Terminal items are pinned to this dashboard's workspace subfolder. const idMap = new Map(); for (const item of data.items || []) { + const content = + item.type === "terminal" ? pinTerminalToSubdir(item.content, subdir) : item.content; const local = await createItem(dashboard.id, { type: item.type, - content: item.content, + content, position: item.position, size: item.size, metadata: item.metadata, @@ -63,5 +104,15 @@ export async function downloadCloudDashboard( }); } - return dashboard; + // Pull the workspace files into /. Best-effort — the canvas + // is already created, so a failure here doesn't undo the download. + let workspace: WorkspaceDownloadResult | null = null; + let workspaceError: string | undefined; + try { + workspace = await downloadCloudWorkspace(cloudId, subdir); + } catch (e) { + workspaceError = e instanceof Error ? e.message : String(e); + } + + return { dashboard, workspace, workspaceError }; } diff --git a/frontend/src/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index e14ebe3d..b086191d 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -163,6 +163,27 @@ export async function getCloudDashboard(dashboardId: string): Promise { return invoke("get_cloud_dashboard", { dashboardId }); } +export interface WorkspaceDownloadResult { + written: number; + skipped: number; + /** false when the cloud dashboard has no terminal/session (no files to pull). */ + had_workspace: boolean; +} + +/** + * Copy a cloud dashboard's workspace files into the local per-dashboard subfolder + * (`/`). Starts/reuses a cloud session, so it can take a minute + * while the cloud VM boots. Native — the PAT never leaves Rust. + */ +export async function downloadCloudWorkspace( + cloudId: string, + subdir: string +): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) throw new Error("Cloud dashboards are only available in the desktop app."); + return invoke("download_cloud_workspace", { cloudId, subdir }) as Promise; +} + export interface CloudGoogleResult { pending: boolean; token?: string; From d6fb50ea42f128ff8713b4e8adbc698b6d10c8c9 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 14 Jul 2026 00:17:03 +0100 Subject: [PATCH 28/44] fix(desktop): walk cloud workspace dir-by-dir (recursive list timed out) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/app/src-tauri/src/commands.rs | 89 +++++++++++++++++++-------- 1 file changed, 62 insertions(+), 27 deletions(-) diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index 3d800e8b..ed3030a0 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -1,8 +1,8 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: folder-import-v9-shell-quote-cli -const MODULE_REVISION: &str = "folder-import-v9-shell-quote-cli"; +// REVISION: folder-import-v10-cloud-workspace-walk +const MODULE_REVISION: &str = "folder-import-v10-cloud-workspace-walk"; use serde::Serialize; use std::path::{Component, Path, PathBuf}; @@ -856,10 +856,29 @@ fn cloud_post_json(token: &str, url: &str, body: serde_json::Value) -> Result Result, String> { - let url = format!("{CLOUD_API_BASE}/sessions/{sid}/files?path=/&recursive=true"); - let v = cloud_get_json(token, &url)?; - Ok(v.get("files").and_then(|x| x.as_array()).cloned().unwrap_or_default()) +/// List ONE directory's immediate children (non-recursive). We walk the tree +/// ourselves so we can prune excluded dirs (node_modules/.git/…) instead of a +/// server-side recursive walk that enumerates every file first — that blew the +/// request timeout (and the 100k-entry cap) on real projects. `dir` is a +/// workspace path like "/" or "/src". +fn cloud_dir_list(token: &str, sid: &str, dir: &str) -> Result, String> { + let url = format!("{CLOUD_API_BASE}/sessions/{sid}/files"); + match ureq::get(&url) + .set("Authorization", &format!("Bearer {token}")) + .query("path", dir) + .timeout(std::time::Duration::from_secs(60)) + .call() + { + Ok(rp) => { + let v: serde_json::Value = rp.into_json().unwrap_or(serde_json::Value::Null); + Ok(v.get("files").and_then(|x| x.as_array()).cloned().unwrap_or_default()) + } + Err(ureq::Error::Status(401, _)) => Err("Cloud session expired — sign in again.".into()), + Err(ureq::Error::Status(c, rp)) => { + Err(format!("HTTP {c}: {}", rp.into_string().unwrap_or_default().trim())) + } + Err(e) => Err(format!("Couldn't reach orcabot.com: {e}")), + } } fn cloud_file_get(token: &str, sid: &str, rel: &str) -> Result, String> { @@ -1108,34 +1127,50 @@ pub async fn download_cloud_workspace( Err(e) => return Err(e), }; - let files = cloud_file_list(&token, &sid)?; + // Walk the workspace directory-by-directory, pruning excluded dirs so we + // never descend into node_modules/.git. Each list is one (bounded) dir. let mut written = 0u64; let mut skipped = 0u64; - for f in &files { - if f.get("is_dir").and_then(|x| x.as_bool()).unwrap_or(false) { - continue; + let mut queue: Vec = vec![String::new()]; // "" = workspace root + let mut listed = 0u32; + while let Some(dir_rel) = queue.pop() { + listed += 1; + if listed > 50_000 { + break; // pathological-tree guard } - let rel = match f.get("path").and_then(|x| x.as_str()) { - Some(p) => p.trim_start_matches('/').to_string(), - None => continue, + let query_path = if dir_rel.is_empty() { + "/".to_string() + } else { + format!("/{dir_rel}") }; - if rel.is_empty() || ws_excluded(&rel) { - continue; - } - if safe_workspace_dest(&ws_canon, &rel).is_none() { - skipped += 1; - continue; - } - let data = match cloud_file_get(&token, &sid, &rel) { - Ok(d) => d, - Err(_) => { + let entries = cloud_dir_list(&token, &sid, &query_path)?; + for e in &entries { + let rel = match e.get("path").and_then(|x| x.as_str()) { + Some(p) => p.trim_start_matches('/').to_string(), + None => continue, + }; + if rel.is_empty() || ws_excluded(&rel) { + continue; + } + if e.get("is_dir").and_then(|x| x.as_bool()).unwrap_or(false) { + queue.push(rel); // descend into non-excluded subdir + continue; + } + if safe_workspace_dest(&ws_canon, &rel).is_none() { skipped += 1; continue; } - }; - match safe_workspace_write(&ws_canon, &rel, &data) { - Ok(()) => written += 1, - Err(_) => skipped += 1, + let data = match cloud_file_get(&token, &sid, &rel) { + Ok(d) => d, + Err(_) => { + skipped += 1; + continue; + } + }; + match safe_workspace_write(&ws_canon, &rel, &data) { + Ok(()) => written += 1, + Err(_) => skipped += 1, + } } } Ok(WorkspaceDownloadResult { written, skipped, had_workspace: true }) From beee8c79f049180b9b97f586234a071eeaa05d2f Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 14 Jul 2026 00:21:33 +0100 Subject: [PATCH 29/44] fix(desktop): 180s timeout for cloud session-start (Fly cold boot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/app/src-tauri/src/commands.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index ed3030a0..cfd8238e 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -837,10 +837,15 @@ fn cloud_get_json(token: &str, url: &str) -> Result { } } -fn cloud_post_json(token: &str, url: &str, body: serde_json::Value) -> Result { +fn cloud_post_json( + token: &str, + url: &str, + body: serde_json::Value, + timeout_secs: u64, +) -> Result { match ureq::post(url) .set("Authorization", &format!("Bearer {token}")) - .timeout(std::time::Duration::from_secs(30)) + .timeout(std::time::Duration::from_secs(timeout_secs)) .send_json(body) { Ok(rp) => Ok(rp.into_json().unwrap_or(serde_json::Value::Null)), @@ -934,10 +939,13 @@ fn cloud_ensure_session(token: &str, dash: &str) -> Result, Strin None => return Ok(None), }; + // Starting a session cold-boots a Fly VM, and the control plane may hold the + // request open until it's provisioned — allow well past a cold boot (not 30s). cloud_post_json( token, &format!("{CLOUD_API_BASE}/dashboards/{dash}/items/{item_id}/session"), serde_json::json!({}), + 180, )?; // Poll for the session to go active (cloud spins up a VM — allow generous time). From 9047b4133217ff2673fda3f4ff72817f9d1ee1bb Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 14 Jul 2026 00:25:51 +0100 Subject: [PATCH 30/44] fix(desktop): retry cloud file API on 503 (sandbox warming up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/app/src-tauri/src/commands.rs | 74 +++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index cfd8238e..db7f4782 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -907,6 +907,61 @@ fn cloud_file_get(token: &str, sid: &str, rel: &str) -> Result, String> } } +/// A transient/retryable failure: the cloud sandbox is provisioning (proxy 503/ +/// 502/504) or a connection blipped. The session can read "active" before the +/// sandbox HTTP is actually serving, so the first file calls need to be retried. +fn is_transient_err(e: &str) -> bool { + e.contains("HTTP 503") + || e.contains("HTTP 502") + || e.contains("HTTP 504") + || e.contains("Network Error") + || e.contains("reset") + || e.contains("timed out") +} + +/// List a directory, retrying transient failures (sandbox warming up) with a 3s +/// backoff. Use a large `attempts` for the first (root) list — that's the window +/// where the just-started sandbox may still be booting its HTTP server. +fn cloud_dir_list_ready( + token: &str, + sid: &str, + dir: &str, + attempts: u32, +) -> Result, String> { + let mut last = String::new(); + for i in 0..attempts.max(1) { + match cloud_dir_list(token, sid, dir) { + Ok(v) => return Ok(v), + Err(e) if is_transient_err(&e) => { + last = e; + if i + 1 < attempts { + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + Err(e) => return Err(e), + } + } + Err(last) +} + +/// GET a file, retrying transient failures a few times (2s backoff). +fn cloud_file_get_ready(token: &str, sid: &str, rel: &str) -> Result, String> { + let mut last = String::new(); + for i in 0..4 { + match cloud_file_get(token, sid, rel) { + Ok(v) => return Ok(v), + Err(e) if is_transient_err(&e) => { + last = e; + if i < 3 { + std::thread::sleep(std::time::Duration::from_secs(2)); + } + } + Err(e) => return Err(e), + } + } + Err(last) +} + /// Get a live cloud session id for `dash` so the file API works: reuse an active /// session, else start one on an EXISTING terminal item (boots the cloud VM). We /// never CREATE a terminal item — a download shouldn't add phantom blocks to the @@ -1146,12 +1201,25 @@ pub async fn download_cloud_workspace( if listed > 50_000 { break; // pathological-tree guard } - let query_path = if dir_rel.is_empty() { + let is_root = dir_rel.is_empty(); + let query_path = if is_root { "/".to_string() } else { format!("/{dir_rel}") }; - let entries = cloud_dir_list(&token, &sid, &query_path)?; + // The root list is the readiness gate — the just-started sandbox may + // still be booting its HTTP server (proxy 503s), so retry it for up to + // ~90s. Deeper dirs only need a light retry once it's serving. + let entries = match cloud_dir_list_ready(&token, &sid, &query_path, if is_root { 30 } else { 4 }) { + Ok(v) => v, + Err(e) if is_root => { + return Err(format!( + "cloud workspace didn't become reachable ({}). Try again in a moment.", + e.trim() + )) + } + Err(_) => continue, // a deeper dir stayed unreachable — skip it + }; for e in &entries { let rel = match e.get("path").and_then(|x| x.as_str()) { Some(p) => p.trim_start_matches('/').to_string(), @@ -1168,7 +1236,7 @@ pub async fn download_cloud_workspace( skipped += 1; continue; } - let data = match cloud_file_get(&token, &sid, &rel) { + let data = match cloud_file_get_ready(&token, &sid, &rel) { Ok(d) => d, Err(_) => { skipped += 1; From 94798ef13c10fbe87c75351ee3b6030037dbaaf0 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 14 Jul 2026 00:43:33 +0100 Subject: [PATCH 31/44] feat(desktop): live progress for cloud workspace download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/app/src-tauri/src/commands.rs | 45 +++++++++++++++-- .../src/components/desktop/DashboardsTabs.tsx | 48 +++++++++++++++++-- frontend/src/lib/tauri-bridge.ts | 17 +++++++ 3 files changed, 103 insertions(+), 7 deletions(-) diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index db7f4782..472f971b 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -817,6 +817,17 @@ pub struct WorkspaceDownloadResult { pub had_workspace: bool, } +/// Progress for a workspace download, emitted as `cloud-workspace-progress` so the +/// UI can show what's happening during a slow cold cloud-VM boot (otherwise a +/// legitimately slow pull looks like a hang). Keyed by `cloud_id`. +#[derive(Serialize, Clone)] +pub struct CloudWorkspaceProgress { + pub cloud_id: String, + /// "starting" | "booting" | "copying" + pub phase: String, + pub written: u64, +} + /// GET a JSON body from the cloud with the PAT. Maps the paywall to a sentinel. fn cloud_get_json(token: &str, url: &str) -> Result { match ureq::get(url) @@ -966,7 +977,12 @@ fn cloud_file_get_ready(token: &str, sid: &str, rel: &str) -> Result, St /// session, else start one on an EXISTING terminal item (boots the cloud VM). We /// never CREATE a terminal item — a download shouldn't add phantom blocks to the /// user's cloud dashboard. Returns None when there is no terminal item at all. -fn cloud_ensure_session(token: &str, dash: &str) -> Result, String> { +/// `on_boot` is called each poll iteration while waiting for a cold VM (progress). +fn cloud_ensure_session( + token: &str, + dash: &str, + on_boot: &dyn Fn(), +) -> Result, String> { let dash_url = format!("{CLOUD_API_BASE}/dashboards/{dash}"); let v = cloud_get_json(token, &dash_url)?; @@ -1004,7 +1020,8 @@ fn cloud_ensure_session(token: &str, dash: &str) -> Result, Strin )?; // Poll for the session to go active (cloud spins up a VM — allow generous time). - for _ in 0..60 { + for _ in 0..120 { + on_boot(); std::thread::sleep(std::time::Duration::from_secs(2)); let v = cloud_get_json(token, &dash_url)?; if let Some(sessions) = v.get("sessions").and_then(|x| x.as_array()) { @@ -1176,8 +1193,22 @@ pub async fn download_cloud_workspace( .canonicalize() .map_err(|e| format!("resolve workspace dir: {e}"))?; + let app2 = app.clone(); tauri::async_runtime::spawn_blocking(move || { - let sid = match cloud_ensure_session(&token, &cloud_id) { + // Emit progress so a slow cold cloud-VM boot doesn't look like a hang. + let emit = |phase: &str, written: u64| { + let _ = app2.emit( + "cloud-workspace-progress", + CloudWorkspaceProgress { + cloud_id: cloud_id.clone(), + phase: phase.to_string(), + written, + }, + ); + }; + + emit("starting", 0); + let sid = match cloud_ensure_session(&token, &cloud_id, &|| emit("booting", 0)) { Ok(Some(s)) => s, Ok(None) => { return Ok(WorkspaceDownloadResult { written: 0, skipped: 0, had_workspace: false }) @@ -1190,6 +1221,7 @@ pub async fn download_cloud_workspace( Err(e) => return Err(e), }; + emit("copying", 0); // Walk the workspace directory-by-directory, pruning excluded dirs so we // never descend into node_modules/.git. Each list is one (bounded) dir. let mut written = 0u64; @@ -1244,7 +1276,12 @@ pub async fn download_cloud_workspace( } }; match safe_workspace_write(&ws_canon, &rel, &data) { - Ok(()) => written += 1, + Ok(()) => { + written += 1; + if written % 20 == 0 { + emit("copying", written); + } + } Err(_) => skipped += 1, } } diff --git a/frontend/src/components/desktop/DashboardsTabs.tsx b/frontend/src/components/desktop/DashboardsTabs.tsx index a25cebf3..ae600dde 100644 --- a/frontend/src/components/desktop/DashboardsTabs.tsx +++ b/frontend/src/components/desktop/DashboardsTabs.tsx @@ -1,7 +1,7 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: dashboards-tabs-v5-workspace-download +// REVISION: dashboards-tabs-v6-download-progress "use client"; import * as React from "react"; @@ -20,12 +20,14 @@ import { getCloudAccount, listCloudDashboards, openExternalUrl, + onCloudWorkspaceProgress, + type CloudWorkspaceProgress, } from "@/lib/tauri-bridge"; import { downloadCloudDashboard } from "@/lib/cloud-sync"; import { formatRelativeTime, cn } from "@/lib/utils"; import type { Dashboard } from "@/types/dashboard"; -const MODULE_REVISION = "dashboards-tabs-v5-workspace-download"; +const MODULE_REVISION = "dashboards-tabs-v6-download-progress"; if (typeof window !== "undefined") { console.log( `[dashboards-tabs] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` @@ -70,9 +72,27 @@ export function DashboardsTabs({ const [cloudEmail, setCloudEmail] = React.useState(null); const [downloading, setDownloading] = React.useState>(new Set()); const [downloadError, setDownloadError] = React.useState(null); + const [progress, setProgress] = React.useState>({}); const [tab, setTab] = React.useState<"online" | "local">("local"); const defaultedToOnline = React.useRef(false); + // Live progress for in-flight downloads (so a slow cold cloud-VM boot doesn't + // look like a hang), keyed by cloud dashboard id. + React.useEffect(() => { + let unlisten: (() => void) | null = null; + let alive = true; + onCloudWorkspaceProgress((p) => { + setProgress((prev) => ({ ...prev, [p.cloud_id]: p })); + }).then((u) => { + if (alive) unlisten = u; + else u?.(); + }); + return () => { + alive = false; + unlisten?.(); + }; + }, []); + React.useEffect(() => { if (!DESKTOP_MODE) return; let alive = true; @@ -145,6 +165,11 @@ export function DashboardsTabs({ next.delete(cd.id); return next; }); + setProgress((prev) => { + const next = { ...prev }; + delete next[cd.id]; + return next; + }); } }; @@ -229,6 +254,7 @@ export function DashboardsTabs({ cd={cd} local={localByCloudId.get(cd.id)} isDownloading={downloading.has(cd.id)} + downloadingLabel={downloadLabel(progress[cd.id])} onDownload={() => void download(cd)} onOpen={onOpen} /> @@ -302,16 +328,32 @@ function TabButton({ ); } +/** Map download progress to a short status label shown on the card. */ +function downloadLabel(p?: CloudWorkspaceProgress): string { + switch (p?.phase) { + case "starting": + return "Starting…"; + case "booting": + return "Starting cloud workspace…"; + case "copying": + return p.written > 0 ? `Copying files… (${p.written})` : "Copying files…"; + default: + return "Downloading…"; + } +} + function CloudDashboardCard({ cd, local, isDownloading, + downloadingLabel, onDownload, onOpen, }: { cd: CloudDashboard; local: Dashboard | undefined; isDownloading: boolean; + downloadingLabel: string; onDownload: () => void; onOpen: (localDashboardId: string) => void; }) { @@ -373,7 +415,7 @@ function CloudDashboardCard({ ) : ( )} - {isDownloading ? "Downloading…" : "Download"} + {isDownloading ? downloadingLabel : "Download"} )}
diff --git a/frontend/src/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index b086191d..54b92101 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -416,6 +416,23 @@ export async function onImportProgress( return listenGlobal("folder-import-progress", callback); } +export interface CloudWorkspaceProgress { + cloud_id: string; + /** "starting" | "booting" | "copying" */ + phase: string; + written: number; +} + +/** + * Listen for cloud workspace-download progress (`cloud-workspace-progress`) so the + * UI can show "Starting…/Booting…/Copying files" during a slow cold cloud-VM boot. + */ +export async function onCloudWorkspaceProgress( + callback: (progress: CloudWorkspaceProgress) => void +): Promise<(() => void) | null> { + return listenGlobal("cloud-workspace-progress", callback); +} + /** * Listen for auto-update progress emitted from Rust (`update-progress`). Fires * once the user accepts the update (download start → per-MB progress → install), From f84ed4427f1ad2502d46322cdaaf8e0d28449003 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 14 Jul 2026 00:49:42 +0100 Subject: [PATCH 32/44] chore(desktop): diagnostic logging for cloud workspace download 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/app/src-tauri/src/commands.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index 472f971b..8a898e6e 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -1221,6 +1221,7 @@ pub async fn download_cloud_workspace( Err(e) => return Err(e), }; + eprintln!("[cloud-dl] session ready ({sid}); listing workspace"); emit("copying", 0); // Walk the workspace directory-by-directory, pruning excluded dirs so we // never descend into node_modules/.git. Each list is one (bounded) dir. @@ -1245,13 +1246,18 @@ pub async fn download_cloud_workspace( let entries = match cloud_dir_list_ready(&token, &sid, &query_path, if is_root { 30 } else { 4 }) { Ok(v) => v, Err(e) if is_root => { + eprintln!("[cloud-dl] root list failed: {e}"); return Err(format!( "cloud workspace didn't become reachable ({}). Try again in a moment.", e.trim() )) } - Err(_) => continue, // a deeper dir stayed unreachable — skip it + Err(e) => { + eprintln!("[cloud-dl] skip dir {query_path}: {e}"); + continue; // a deeper dir stayed unreachable — skip it + } }; + eprintln!("[cloud-dl] {} -> {} entries", query_path, entries.len()); for e in &entries { let rel = match e.get("path").and_then(|x| x.as_str()) { Some(p) => p.trim_start_matches('/').to_string(), @@ -1268,9 +1274,11 @@ pub async fn download_cloud_workspace( skipped += 1; continue; } + eprintln!("[cloud-dl] get {rel}"); let data = match cloud_file_get_ready(&token, &sid, &rel) { Ok(d) => d, - Err(_) => { + Err(e) => { + eprintln!("[cloud-dl] skip {rel}: {e}"); skipped += 1; continue; } @@ -1278,14 +1286,18 @@ pub async fn download_cloud_workspace( match safe_workspace_write(&ws_canon, &rel, &data) { Ok(()) => { written += 1; - if written % 20 == 0 { + if written % 5 == 0 { emit("copying", written); } } - Err(_) => skipped += 1, + Err(e) => { + eprintln!("[cloud-dl] write {rel} failed: {e}"); + skipped += 1; + } } } } + eprintln!("[cloud-dl] done: written={written} skipped={skipped}"); Ok(WorkspaceDownloadResult { written, skipped, had_workspace: true }) }) .await From 48eb5d2978959da0599e47246816ae934974baea Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 14 Jul 2026 00:56:31 +0100 Subject: [PATCH 33/44] fix(desktop): always ensure cloud sandbox (stale "active" session hung download) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/app/src-tauri/src/commands.rs | 33 ++++++++++++--------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index 8a898e6e..96be3f52 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -882,7 +882,7 @@ fn cloud_dir_list(token: &str, sid: &str, dir: &str) -> Result { @@ -973,11 +973,13 @@ fn cloud_file_get_ready(token: &str, sid: &str, rel: &str) -> Result, St Err(last) } -/// Get a live cloud session id for `dash` so the file API works: reuse an active -/// session, else start one on an EXISTING terminal item (boots the cloud VM). We -/// never CREATE a terminal item — a download shouldn't add phantom blocks to the -/// user's cloud dashboard. Returns None when there is no terminal item at all. -/// `on_boot` is called each poll iteration while waiting for a cold VM (progress). +/// Get a live cloud session id for `dash` whose sandbox is actually running, so +/// the file API works. We do NOT trust a bare "active" DB status — that can point +/// at a reaped VM, and the file proxy then hangs forever trying to reach a dead +/// machine. Instead we always POST /session, which runs ensureDashboardSandbox on +/// the control plane: it restarts a stopped machine and reprovisions a dead +/// session. We never CREATE a terminal item (no phantom blocks); returns None when +/// the dashboard has no terminal item at all. `on_boot` fires each poll (progress). fn cloud_ensure_session( token: &str, dash: &str, @@ -986,16 +988,6 @@ fn cloud_ensure_session( let dash_url = format!("{CLOUD_API_BASE}/dashboards/{dash}"); let v = cloud_get_json(token, &dash_url)?; - if let Some(sessions) = v.get("sessions").and_then(|x| x.as_array()) { - for s in sessions { - if s.get("status").and_then(|x| x.as_str()) == Some("active") { - if let Some(id) = s.get("id").and_then(|x| x.as_str()) { - return Ok(Some(id.to_string())); - } - } - } - } - let item_id = v .get("items") .and_then(|x| x.as_array()) @@ -1010,8 +1002,11 @@ fn cloud_ensure_session( None => return Ok(None), }; - // Starting a session cold-boots a Fly VM, and the control plane may hold the - // request open until it's provisioned — allow well past a cold boot (not 30s). + // Always POST — ensureDashboardSandbox restarts a stopped machine / reprovisions + // a dead session. It cold-boots a Fly VM and may hold the request open until + // provisioned, so allow well past a cold boot (not 30s). Idempotent when the + // sandbox is already healthy. + eprintln!("[cloud-dl] ensuring sandbox for terminal {item_id}"); cloud_post_json( token, &format!("{CLOUD_API_BASE}/dashboards/{dash}/items/{item_id}/session"), @@ -1243,7 +1238,7 @@ pub async fn download_cloud_workspace( // The root list is the readiness gate — the just-started sandbox may // still be booting its HTTP server (proxy 503s), so retry it for up to // ~90s. Deeper dirs only need a light retry once it's serving. - let entries = match cloud_dir_list_ready(&token, &sid, &query_path, if is_root { 30 } else { 4 }) { + let entries = match cloud_dir_list_ready(&token, &sid, &query_path, if is_root { 10 } else { 4 }) { Ok(v) => v, Err(e) if is_root => { eprintln!("[cloud-dl] root list failed: {e}"); From decbb16b69b7ba87444ab061f90ffcb4a0c15ec5 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 14 Jul 2026 01:04:42 +0100 Subject: [PATCH 34/44] security: address code-review findings on desktop cloud download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [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 `/../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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- controlplane/src/auth/google.ts | 39 +++++++++--- desktop/app/src-tauri/src/commands.rs | 44 ++++++++++++-- frontend/src/lib/cloud-sync.ts | 87 ++++++++++++++++++--------- 3 files changed, 129 insertions(+), 41 deletions(-) diff --git a/controlplane/src/auth/google.ts b/controlplane/src/auth/google.ts index 0d684e96..a38d5dc3 100644 --- a/controlplane/src/auth/google.ts +++ b/controlplane/src/auth/google.ts @@ -343,18 +343,26 @@ export async function callbackGoogle( // Process any pending dashboard invitations for this email await processPendingInvitations(env, userId, userInfo.email); - // Desktop login: mint a PAT so the desktop app gets a real cloud credential - // (for listing + syncing the user's cloud dashboards), and stash it — with the - // PKCE challenge — keyed by the app's nonce for it to poll. No browser session - // is useful (the app authenticates to its LOCAL control plane via dev-auth). + // Desktop login: stash the identity (+ PKCE challenge + short expiry) keyed by + // the app's nonce for it to poll. We do NOT mint the PAT here — a flow the user + // abandons (closes the app after the browser step) would otherwise leave a + // usable, plaintext, non-expiring token in D1 forever. The PAT is minted only + // when the desktop app COLLECTS the result with the matching verifier + // (getDesktopGoogleResult). No browser session is useful (the app authenticates + // to its LOCAL control plane via dev-auth). if (postLoginRedirect.startsWith('desktop:')) { const [, nonce, challenge] = postLoginRedirect.split(':'); const name = userInfo.name || userInfo.email.split('@')[0]; - const { token } = await createApiToken(env, userId, 'Orcabot Desktop'); await createAuthState( env, `desktopresult:${nonce}`, - JSON.stringify({ token, email: userInfo.email, name, challenge }) + JSON.stringify({ + userId, + email: userInfo.email, + name, + challenge, + expiresAt: Date.now() + 10 * 60 * 1000, // 10 min to collect + }) ); return renderDesktopReturnPage(); } @@ -447,18 +455,31 @@ export async function getDesktopGoogleResult(request: Request, env: Env): Promis if (!rec) { return json({ pending: true }); } - let parsed: { token: string; email: string; name: string; challenge: string }; + let parsed: { + userId: string; + email: string; + name: string; + challenge: string; + expiresAt?: number; + }; try { parsed = JSON.parse(rec.v); } catch { return json({ error: 'corrupt' }, 500); } + // Expired abandoned result — clean it up and report nothing outstanding. + if (parsed.expiresAt && Date.now() > parsed.expiresAt) { + await env.DB.prepare('DELETE FROM auth_states WHERE state = ?').bind(key).run(); + return json({ pending: true }); + } if ((await sha256Base64Url(verifier)) !== parsed.challenge) { return json({ error: 'forbidden' }, 403); } - // Verified — consume it and hand back the PAT + identity. + // Verified — mint the PAT NOW (only for the collector holding the verifier), + // then consume the stash and hand back the token + identity. + const { token } = await createApiToken(env, parsed.userId, 'Orcabot Desktop'); await env.DB.prepare('DELETE FROM auth_states WHERE state = ?').bind(key).run(); - return json({ token: parsed.token, email: parsed.email, name: parsed.name }); + return json({ token, email: parsed.email, name: parsed.name }); } function renderLoginCompletePage( diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index 96be3f52..3422ce80 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -717,13 +717,36 @@ pub fn set_cloud_credential(app: tauri::AppHandle, token: String, email: String) if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - std::fs::write(&path, format!("{}\n{}\n", token, email.trim())) - .map_err(|e| format!("failed to store credential: {e}"))?; + // Write to a temp file created 0600, then atomically rename over the target — + // so the credential is never briefly world-readable (umask race) and any + // pre-existing loose-permission file is replaced by a 0600 one. Permission + // failures are fatal (we must not leave a readable token on disk). + let contents = format!("{}\n{}\n", token, email.trim()); + let tmp = path.with_extension("tmp"); #[cfg(unix)] { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&tmp) + .map_err(|e| format!("failed to store credential: {e}"))?; + f.write_all(contents.as_bytes()) + .map_err(|e| format!("failed to store credential: {e}"))?; + let _ = f.sync_all(); } + #[cfg(not(unix))] + { + std::fs::write(&tmp, &contents) + .map_err(|e| format!("failed to store credential: {e}"))?; + } + std::fs::rename(&tmp, &path).map_err(|e| { + let _ = std::fs::remove_file(&tmp); + format!("failed to store credential: {e}") + })?; Ok(()) } @@ -897,6 +920,11 @@ fn cloud_dir_list(token: &str, sid: &str, dir: &str) -> Result Result, String> { use std::io::Read; let url = format!("{CLOUD_API_BASE}/sessions/{sid}/file"); @@ -908,7 +936,13 @@ fn cloud_file_get(token: &str, sid: &str, rel: &str) -> Result, String> { Ok(rp) => { let mut buf = Vec::new(); - rp.into_reader().read_to_end(&mut buf).map_err(|e| e.to_string())?; + rp.into_reader() + .take(MAX_DOWNLOAD_FILE_BYTES + 1) + .read_to_end(&mut buf) + .map_err(|e| e.to_string())?; + if buf.len() as u64 > MAX_DOWNLOAD_FILE_BYTES { + return Err("file exceeds size limit".into()); + } Ok(buf) } Err(ureq::Error::Status(c, rp)) => { diff --git a/frontend/src/lib/cloud-sync.ts b/frontend/src/lib/cloud-sync.ts index 4003f462..b0366376 100644 --- a/frontend/src/lib/cloud-sync.ts +++ b/frontend/src/lib/cloud-sync.ts @@ -9,7 +9,12 @@ import { downloadCloudWorkspace, type WorkspaceDownloadResult, } from "@/lib/tauri-bridge"; -import { createDashboard, createItem, createEdge } from "@/lib/api/cloudflare/dashboards"; +import { + createDashboard, + createItem, + createEdge, + deleteDashboard, +} from "@/lib/api/cloudflare/dashboards"; import type { Dashboard, DashboardItem, DashboardEdge } from "@/types"; if (typeof window !== "undefined") { @@ -39,6 +44,22 @@ export interface DownloadResult { * `download_cloud_workspace` writes the files. Any existing workingDir is preserved * underneath the subfolder. Returns the content JSON string to store on the item. */ +/** + * Sanitize a workspace-relative path to plain segments: strip leading slashes and + * `.`/empty segments, and REJECT any `..` (returns "" so the caller falls back to + * the bare subfolder). Without this, a terminal whose content has + * `workingDir: "../other"` would become `/../other` and the sandbox would + * normalize it OUT of the dashboard's isolated subfolder. + */ +function sanitizeRel(p: string): string { + const parts = p + .replace(/^\/+/, "") + .split("/") + .filter((seg) => seg !== "" && seg !== "."); + if (parts.some((seg) => seg === "..")) return ""; + return parts.join("/"); +} + function pinTerminalToSubdir(content: string, subdir: string): string { if (!content.trim().startsWith("{")) { return content; // not JSON we understand — leave it (cwds to workspace root) @@ -50,7 +71,7 @@ function pinTerminalToSubdir(content: string, subdir: string): string { return content; } const existing = - typeof obj.workingDir === "string" ? obj.workingDir.replace(/^\/+/, "").trim() : ""; + typeof obj.workingDir === "string" ? sanitizeRel(obj.workingDir) : ""; obj.workingDir = existing ? `${subdir}/${existing}` : subdir; return JSON.stringify(obj); } @@ -76,32 +97,44 @@ export async function downloadCloudDashboard( const { dashboard } = await createDashboard(name, undefined, cloudId); const subdir = dashboard.id; - // Recreate items, mapping each cloud item id → the new local id (edges use it). - // Terminal items are pinned to this dashboard's workspace subfolder. - const idMap = new Map(); - for (const item of data.items || []) { - const content = - item.type === "terminal" ? pinTerminalToSubdir(item.content, subdir) : item.content; - const local = await createItem(dashboard.id, { - type: item.type, - content, - position: item.position, - size: item.size, - metadata: item.metadata, - }); - idMap.set(item.id, local.id); - } + // Recreate the canvas atomically: if any item/edge fails, delete the partial + // dashboard so it doesn't linger marked "downloaded" (with a cloudId) but broken. + try { + // Recreate items, mapping each cloud item id → the new local id (edges use it). + // Terminal items are pinned to this dashboard's workspace subfolder. + const idMap = new Map(); + for (const item of data.items || []) { + const content = + item.type === "terminal" ? pinTerminalToSubdir(item.content, subdir) : item.content; + const local = await createItem(dashboard.id, { + type: item.type, + content, + position: item.position, + size: item.size, + metadata: item.metadata, + }); + idMap.set(item.id, local.id); + } - for (const edge of data.edges || []) { - const sourceItemId = idMap.get(edge.sourceItemId); - const targetItemId = idMap.get(edge.targetItemId); - if (!sourceItemId || !targetItemId) continue; // endpoint didn't survive (e.g. filtered item) - await createEdge(dashboard.id, { - sourceItemId, - targetItemId, - sourceHandle: edge.sourceHandle, - targetHandle: edge.targetHandle, - }); + for (const edge of data.edges || []) { + const sourceItemId = idMap.get(edge.sourceItemId); + const targetItemId = idMap.get(edge.targetItemId); + if (!sourceItemId || !targetItemId) continue; // endpoint didn't survive (e.g. filtered item) + await createEdge(dashboard.id, { + sourceItemId, + targetItemId, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + }); + } + } catch (e) { + // Roll back the partial dashboard (best-effort) and surface the failure. + try { + await deleteDashboard(dashboard.id); + } catch { + /* leave it; the canvas error below is the real signal */ + } + throw e; } // Pull the workspace files into /. Best-effort — the canvas From 71ffd5c89ae8b6b4462de84234de59b89fd097f7 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 14 Jul 2026 01:31:27 +0100 Subject: [PATCH 35/44] =?UTF-8?q?security[P1]:=20loopback=20(RFC=208252)?= =?UTF-8?q?=20desktop=20Google=20sign-in=20=E2=80=94=20closes=20phishing?= =?UTF-8?q?=20vector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: listener, opens the browser to /auth/google/login?mode=desktop&redirect_uri=&state=, 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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- controlplane/src/auth/google.ts | 160 +++++----- controlplane/src/index.ts | 10 +- desktop/app/src-tauri/src/commands.rs | 274 +++++++++++++----- desktop/app/src-tauri/src/main.rs | 2 +- .../src/components/desktop/DesktopWelcome.tsx | 73 +---- frontend/src/lib/tauri-bridge.ts | 23 +- 6 files changed, 311 insertions(+), 231 deletions(-) diff --git a/controlplane/src/auth/google.ts b/controlplane/src/auth/google.ts index a38d5dc3..75546e35 100644 --- a/controlplane/src/auth/google.ts +++ b/controlplane/src/auth/google.ts @@ -209,9 +209,15 @@ export async function loginWithGoogle( // The result is a PAT (full credential), so it's protected with PKCE: the app // sends the code_challenge here and the matching verifier when it polls. Works on // any deployment; web popup/redirect login is untouched. + // Desktop login uses a LOOPBACK redirect (RFC 8252): the app runs a temporary + // 127.0.0.1 listener and passes it as redirect_uri. After sign-in we redirect the + // browser to that loopback with a one-time code — so the credential is delivered + // only to a listener on the machine that started the flow. A phished victim's + // browser hits THEIR own loopback, not the attacker's, which closes the earlier + // pollable-rendezvous hole. `state` is the app's CSRF token for its listener. const isDesktopMode = mode === 'desktop'; - const desktopNonce = isDesktopMode ? requestUrl.searchParams.get('nonce') : null; - const desktopChallenge = isDesktopMode ? requestUrl.searchParams.get('challenge') : null; + const desktopLoopback = isDesktopMode ? requestUrl.searchParams.get('redirect_uri') : null; + const desktopState = isDesktopMode ? requestUrl.searchParams.get('state') : null; // Validate Turnstile bot verification token (skip if not configured, e.g. dev) // Skip Turnstile in popup/desktop mode — Google's consent screen provides bot protection @@ -230,22 +236,21 @@ export async function loginWithGoogle( } } - if (isDesktopMode && (!desktopNonce || !desktopChallenge)) { - return renderErrorPage('Desktop sign-in is missing its nonce/challenge.'); + if (isDesktopMode && (!desktopLoopback || !desktopState || !isLoopbackRedirect(desktopLoopback))) { + return renderErrorPage('Desktop sign-in requires a loopback redirect.'); } const state = crypto.randomUUID(); const redirectUri = `${getRedirectBase(request, env)}/auth/google/callback`; // Sentinel in the state's redirect slot tells the callback how to finish: - // - "popup" → postMessage completion page (web popup login) - // - "desktop::" → mint a PAT, stash it for the desktop app to - // poll (PKCE-guarded), show a "return" page - // - a URL → normal 302 redirect + session cookie - // nonce is a UUID and challenge is base64url — neither contains ':'. + // - "popup" → postMessage completion page (web popup login) + // - "desktoplb:" → mint a one-time code, redirect to the app's + // loopback so it can exchange the code for a PAT + // - a URL → normal 302 redirect + session cookie const postLoginRedirect = isPopupMode ? 'popup' : isDesktopMode - ? `desktop:${desktopNonce}:${desktopChallenge}` + ? `desktoplb:${btoa(JSON.stringify({ uri: desktopLoopback, st: desktopState }))}` : resolvePostLoginRedirect(request, env); await createAuthState(env, state, postLoginRedirect); @@ -343,28 +348,44 @@ export async function callbackGoogle( // Process any pending dashboard invitations for this email await processPendingInvitations(env, userId, userInfo.email); - // Desktop login: stash the identity (+ PKCE challenge + short expiry) keyed by - // the app's nonce for it to poll. We do NOT mint the PAT here — a flow the user - // abandons (closes the app after the browser step) would otherwise leave a - // usable, plaintext, non-expiring token in D1 forever. The PAT is minted only - // when the desktop app COLLECTS the result with the matching verifier - // (getDesktopGoogleResult). No browser session is useful (the app authenticates - // to its LOCAL control plane via dev-auth). - if (postLoginRedirect.startsWith('desktop:')) { - const [, nonce, challenge] = postLoginRedirect.split(':'); + // Desktop login (loopback): mint a one-time code, stash the identity (short + // expiry), and redirect the browser to the app's 127.0.0.1 listener with the + // code. The PAT is minted only when the app exchanges the code (exchangeDesktopCode) + // — so an abandoned flow never creates a token, and the code reaches only a + // listener on the machine that started the flow (not a global poll an attacker + // could hit). Re-validate the loopback target before redirecting. + if (postLoginRedirect.startsWith('desktoplb:')) { + let uri: string; + let st: string; + try { + const dec = JSON.parse(atob(postLoginRedirect.slice('desktoplb:'.length))) as { + uri: string; + st: string; + }; + uri = dec.uri; + st = dec.st; + } catch { + return renderErrorPage('Desktop sign-in state was corrupt.'); + } + if (!isLoopbackRedirect(uri)) { + return renderErrorPage('Invalid desktop redirect target.'); + } const name = userInfo.name || userInfo.email.split('@')[0]; + const code = `${crypto.randomUUID()}${crypto.randomUUID()}`.replace(/-/g, ''); await createAuthState( env, - `desktopresult:${nonce}`, + `desktopcode:${code}`, JSON.stringify({ userId, email: userInfo.email, name, - challenge, - expiresAt: Date.now() + 10 * 60 * 1000, // 10 min to collect + expiresAt: Date.now() + 5 * 60 * 1000, // 5 min to exchange }) ); - return renderDesktopReturnPage(); + const dest = new URL(uri); + dest.searchParams.set('code', code); + dest.searchParams.set('state', st); + return Response.redirect(dest.toString(), 302); } const session = await createUserSession(env, userId); @@ -391,94 +412,65 @@ export async function callbackGoogle( }); } -function renderDesktopReturnPage(): Response { - return new Response( - ` - - - - Signed in - - - -
-

Signed in to Orcabot

-

You can close this tab and return to the Orcabot app.

-
- -`, - { headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' } } - ); -} -/** base64url(SHA-256(input)) — PKCE S256 code_challenge computation. */ -async function sha256Base64Url(input: string): Promise { - const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)); - let bin = ''; - for (const b of new Uint8Array(digest)) bin += String.fromCharCode(b); - return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +/** A redirect target usable by the desktop loopback flow: http on 127.0.0.1/::1/ + * localhost only. Enforced at login AND before the callback redirect so a crafted + * redirect_uri can't send a signed-in victim's code anywhere but their own machine. */ +function isLoopbackRedirect(uri: string): boolean { + try { + const u = new URL(uri); + if (u.protocol !== 'http:') return false; + return u.hostname === '127.0.0.1' || u.hostname === '::1' || u.hostname === 'localhost'; + } catch { + return false; + } } /** - * Desktop app polls this (by the nonce it started the browser flow with, plus the - * PKCE verifier) to collect its cloud PAT + identity — the OS browser can't hand a - * session back to the Tauri webview. Protected by PKCE: the result is only returned - * to whoever holds the verifier whose SHA-256 matches the challenge sent at login, - * so knowing the (browser-visible) nonce alone can't harvest the PAT. + * Desktop app exchanges the one-time `code` it received on its 127.0.0.1 listener + * (from the OAuth callback redirect) for its cloud PAT + identity. The code is the + * only way to obtain the token and was delivered solely to a loopback listener on + * the machine that ran the flow, so a remote attacker can't harvest it. Single-use + * (consumed on lookup) and short-lived. */ -export async function getDesktopGoogleResult(request: Request, env: Env): Promise { +export async function exchangeDesktopCode(request: Request, env: Env): Promise { const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, }); - const url = new URL(request.url); - const nonce = url.searchParams.get('nonce'); - const verifier = url.searchParams.get('verifier'); - if (!nonce || !verifier) { - return json({ error: 'missing_nonce_or_verifier' }, 400); + let body: { code?: string }; + try { + body = (await request.json()) as { code?: string }; + } catch { + return json({ error: 'bad_request' }, 400); + } + const code = body.code; + if (!code) { + return json({ error: 'missing_code' }, 400); } - const key = `desktopresult:${nonce}`; - // Peek (don't consume): a wrong verifier must NOT delete the result, or an - // attacker who guessed the nonce could deny the legit app its PAT. + const key = `desktopcode:${code}`; const rec = await env.DB .prepare('SELECT redirect_url as v FROM auth_states WHERE state = ?') .bind(key) .first<{ v: string }>(); + // Consume immediately (single-use) regardless of what happens next. + await env.DB.prepare('DELETE FROM auth_states WHERE state = ?').bind(key).run(); if (!rec) { - return json({ pending: true }); + return json({ error: 'not_found' }, 404); } - let parsed: { - userId: string; - email: string; - name: string; - challenge: string; - expiresAt?: number; - }; + let parsed: { userId: string; email: string; name: string; expiresAt?: number }; try { parsed = JSON.parse(rec.v); } catch { return json({ error: 'corrupt' }, 500); } - // Expired abandoned result — clean it up and report nothing outstanding. if (parsed.expiresAt && Date.now() > parsed.expiresAt) { - await env.DB.prepare('DELETE FROM auth_states WHERE state = ?').bind(key).run(); - return json({ pending: true }); + return json({ error: 'expired' }, 410); } - if ((await sha256Base64Url(verifier)) !== parsed.challenge) { - return json({ error: 'forbidden' }, 403); - } - // Verified — mint the PAT NOW (only for the collector holding the verifier), - // then consume the stash and hand back the token + identity. const { token } = await createApiToken(env, parsed.userId, 'Orcabot Desktop'); - await env.DB.prepare('DELETE FROM auth_states WHERE state = ?').bind(key).run(); return json({ token, email: parsed.email, name: parsed.name }); } diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index 17fc29da..1dd9fdf8 100644 --- a/controlplane/src/index.ts +++ b/controlplane/src/index.ts @@ -922,7 +922,7 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick Result<(), String> { - let token = token.trim(); - if !token.starts_with("orca_pat_") { - return Err("Not an Orcabot token.".into()); - } - let path = cloud_credential_path(&app).ok_or("no app data dir")?; +/// Persist the cloud credential (PAT + email) host-only (0600), atomically. +/// Write to a temp file created 0600, then rename over the target — so the token is +/// never briefly world-readable (umask race) and any pre-existing loose-permission +/// file is replaced by a 0600 one. Permission failures are fatal. +fn write_cloud_credential(app: &tauri::AppHandle, token: &str, email: &str) -> Result<(), String> { + let path = cloud_credential_path(app).ok_or("no app data dir")?; if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - // Write to a temp file created 0600, then atomically rename over the target — - // so the credential is never briefly world-readable (umask race) and any - // pre-existing loose-permission file is replaced by a 0600 one. Permission - // failures are fatal (we must not leave a readable token on disk). let contents = format!("{}\n{}\n", token, email.trim()); let tmp = path.with_extension("tmp"); #[cfg(unix)] @@ -750,6 +744,205 @@ pub fn set_cloud_credential(app: tauri::AppHandle, token: String, email: String) Ok(()) } +/// Persist the cloud credential (PAT + email) host-only (0600) for dashboard sync. +#[tauri::command] +pub fn set_cloud_credential(app: tauri::AppHandle, token: String, email: String) -> Result<(), String> { + let token = token.trim(); + if !token.starts_with("orca_pat_") { + return Err("Not an Orcabot token.".into()); + } + write_cloud_credential(&app, token, &email) +} + +#[derive(Serialize, Clone)] +pub struct CloudSignIn { + pub email: String, + pub name: String, +} + +/// Cryptographically-random hex token (OS RNG via /dev/urandom; the OS-seeded +/// RandomState as a fallback). Used as the loopback CSRF `state`. +fn random_hex(n: usize) -> String { + #[cfg(unix)] + { + use std::io::Read; + if let Ok(mut f) = std::fs::File::open("/dev/urandom") { + let mut buf = vec![0u8; n]; + if f.read_exact(&mut buf).is_ok() { + return buf.iter().map(|b| format!("{b:02x}")).collect(); + } + } + } + use std::hash::{BuildHasher, Hasher}; + let mut s = String::new(); + while s.len() < n * 2 { + let h = std::collections::hash_map::RandomState::new() + .build_hasher() + .finish(); + s.push_str(&format!("{h:016x}")); + } + s.truncate(n * 2); + s +} + +/// Percent-encode a URL query value (unreserved chars pass through). +fn pct(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + (b as char).to_string() + } + _ => format!("%{b:02X}"), + }) + .collect() +} + +fn open_in_browser(url: &str) -> Result<(), String> { + #[cfg(target_os = "macos")] + let mut cmd = std::process::Command::new("open"); + #[cfg(target_os = "linux")] + let mut cmd = std::process::Command::new("xdg-open"); + #[cfg(target_os = "windows")] + let mut cmd = { + let mut c = std::process::Command::new("cmd"); + c.args(["/C", "start", ""]); + c + }; + cmd.arg(url) + .spawn() + .map(|_| ()) + .map_err(|e| format!("failed to open browser: {e}")) +} + +fn parse_query(path: &str) -> (Option, Option) { + let q = path.splitn(2, '?').nth(1).unwrap_or(""); + let mut code = None; + let mut state = None; + for kv in q.split('&') { + let mut it = kv.splitn(2, '='); + match (it.next(), it.next()) { + (Some("code"), Some(v)) => code = Some(v.to_string()), + (Some("state"), Some(v)) => state = Some(v.to_string()), + _ => {} + } + } + (code, state) +} + +/// Wait (bounded) for the OAuth callback on the loopback listener; return the +/// one-time `code` once a `/cb?code=…&state=…` request arrives with our state. +fn await_loopback_code( + listener: std::net::TcpListener, + expect_state: &str, +) -> Result { + use std::io::{Read, Write}; + use std::time::{Duration, Instant}; + listener.set_nonblocking(true).ok(); + let deadline = Instant::now() + Duration::from_secs(180); + loop { + if Instant::now() > deadline { + return Err("timed out waiting for the browser sign-in".into()); + } + match listener.accept() { + Ok((mut stream, _)) => { + stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).unwrap_or(0); + let req = String::from_utf8_lossy(&buf[..n]); + let path = req + .lines() + .next() + .and_then(|l| l.split_whitespace().nth(1)) + .unwrap_or(""); + let (code, state) = parse_query(path); + if !path.starts_with("/cb") || code.is_none() { + // Stray request (favicon, etc.) — brush it off and keep waiting. + let _ = + stream.write_all(b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n"); + continue; + } + let ok = state.as_deref() == Some(expect_state); + let page = if ok { + "Signed in

Signed in to Orcabot

You can close this tab and return to the app.

" + } else { + "Sign-in failed

Sign-in couldn't be verified

Please try again from the app.

" + }; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{}", + page.len(), + page + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); + if !ok { + return Err("sign-in verification failed (state mismatch)".into()); + } + return Ok(code.unwrap()); + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(200)); + } + Err(e) => return Err(format!("loopback listener error: {e}")), + } + } +} + +fn exchange_desktop_code(code: &str) -> Result<(String, String, String), String> { + let url = format!("{CLOUD_API_BASE}/auth/desktop/exchange"); + match ureq::post(&url) + .timeout(std::time::Duration::from_secs(30)) + .send_json(serde_json::json!({ "code": code })) + { + Ok(rp) => { + let v: serde_json::Value = rp.into_json().map_err(|e| e.to_string())?; + let token = v + .get("token") + .and_then(|x| x.as_str()) + .ok_or("sign-in response had no token")? + .to_string(); + let email = v.get("email").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let name = v.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string(); + Ok((token, email, name)) + } + Err(ureq::Error::Status(c, rp)) => Err(format!( + "sign-in exchange failed ({c}): {}", + rp.into_string().unwrap_or_default().trim() + )), + Err(e) => Err(format!("couldn't reach orcabot.com: {e}")), + } +} + +/// Sign in to the cloud with Google via a LOOPBACK redirect (RFC 8252): run a +/// temporary 127.0.0.1 listener, open the browser to the cloud login pointing back +/// at it, receive a one-time code there, exchange it for a PAT, and store the PAT +/// host-only. The token never enters the webview. Returns {email,name} for the UI. +#[tauri::command] +pub async fn sign_in_google_loopback(app: tauri::AppHandle) -> Result { + let listener = std::net::TcpListener::bind("127.0.0.1:0") + .map_err(|e| format!("could not start local sign-in listener: {e}"))?; + let port = listener.local_addr().map_err(|e| e.to_string())?.port(); + let state = random_hex(16); + let redirect = format!("http://127.0.0.1:{port}/cb"); + let login_url = format!( + "{CLOUD_API_BASE}/auth/google/login?mode=desktop&redirect_uri={}&state={}", + pct(&redirect), + pct(&state) + ); + open_in_browser(&login_url)?; + + let (token, email, name) = tauri::async_runtime::spawn_blocking( + move || -> Result<(String, String, String), String> { + let code = await_loopback_code(listener, &state)?; + exchange_desktop_code(&code) + }, + ) + .await + .map_err(|e| format!("sign-in task failed: {e}"))??; + + write_cloud_credential(&app, &token, &email)?; + Ok(CloudSignIn { email, name }) +} + /// The signed-in cloud account (email), or null if not signed in to the cloud. #[tauri::command] pub fn get_cloud_account(app: tauri::AppHandle) -> Option { @@ -1333,63 +1526,6 @@ pub async fn download_cloud_workspace( .map_err(|e| format!("workspace download task failed: {e}"))? } -#[derive(Serialize, Clone)] -pub struct CloudGoogleResult { - pub pending: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub token: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub email: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, -} - -/// Poll the CLOUD control plane for the desktop Google sign-in result (a PAT + -/// identity), keyed by the app's nonce and PKCE verifier. Native so it isn't -/// subject to browser CORS (the cloud only allows the orcabot.com origin). Returns -/// {pending:true} until the browser sign-in completes. -#[tauri::command] -pub async fn poll_cloud_google_result( - nonce: String, - verifier: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - match ureq::get(&format!("{CLOUD_API_BASE}/auth/desktop/google-result")) - .query("nonce", &nonce) - .query("verifier", &verifier) - .timeout(std::time::Duration::from_secs(15)) - .call() - { - Ok(resp) => { - let v: serde_json::Value = resp - .into_json() - .map_err(|e| format!("unexpected response from orcabot.com: {e}"))?; - if v["pending"].as_bool() == Some(true) { - return Ok(CloudGoogleResult { - pending: true, - token: None, - email: None, - name: None, - }); - } - let token = v["token"].as_str().map(str::to_string); - let email = v["email"].as_str().map(str::to_string); - let name = v["name"].as_str().map(str::to_string); - if token.is_some() && email.is_some() { - Ok(CloudGoogleResult { pending: false, token, email, name }) - } else { - Err("orcabot.com returned an unexpected sign-in result.".into()) - } - } - Err(ureq::Error::Status(403, _)) => Err("Sign-in verification failed.".into()), - Err(ureq::Error::Status(code, _)) => Err(format!("orcabot.com returned {code}.")), - Err(e) => Err(format!("Couldn't reach orcabot.com: {e}")), - } - }) - .await - .map_err(|e| format!("poll task failed: {e}"))? -} - /// Return the per-boot surface token. The host frontend sends it as the /// `X-Orcabot-Surface` header so the control plane knows the request is from the /// trusted GUI (not a process inside the sandbox VM spoofing dev-auth). diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index cad8013f..bcaa0ac5 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -1014,12 +1014,12 @@ fn main() { commands::get_app_version, commands::verify_orcabot_account, commands::set_cloud_credential, + commands::sign_in_google_loopback, commands::get_cloud_account, commands::clear_cloud_credential, commands::list_cloud_dashboards, commands::get_cloud_dashboard, commands::download_cloud_workspace, - commands::poll_cloud_google_result, ]) .setup(|app| { let services = Arc::new(DesktopServices::new()); diff --git a/frontend/src/components/desktop/DesktopWelcome.tsx b/frontend/src/components/desktop/DesktopWelcome.tsx index e282569b..9f684dcc 100644 --- a/frontend/src/components/desktop/DesktopWelcome.tsx +++ b/frontend/src/components/desktop/DesktopWelcome.tsx @@ -5,46 +5,22 @@ "use client"; import * as React from "react"; -import { CLOUD_API_URL, CLOUD_SITE_URL } from "@/config/env"; +import { CLOUD_SITE_URL } from "@/config/env"; import { openExternalUrl, - pollCloudGoogleResult, + signInGoogleLoopback, setCloudCredential, verifyOrcabotAccount, } from "@/lib/tauri-bridge"; import { useDesktopAccountStore } from "@/stores/desktop-account-store"; -const MODULE_REVISION = "desktop-welcome-v4-local-copy"; +const MODULE_REVISION = "desktop-welcome-v5-loopback-signin"; if (typeof window !== "undefined") { console.log( `[desktop-welcome] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` ); } -function randomNonce(): string { - if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID(); - return `${Date.now()}-${Math.random().toString(36).slice(2)}`; -} - -function base64UrlEncode(bytes: Uint8Array): string { - let bin = ""; - for (const b of bytes) bin += String.fromCharCode(b); - return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -/** PKCE code_verifier: 32 random bytes, base64url. */ -function randomVerifier(): string { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return base64UrlEncode(bytes); -} - -/** PKCE S256 code_challenge = base64url(SHA-256(verifier)). */ -async function sha256Base64Url(input: string): Promise { - const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); - return base64UrlEncode(new Uint8Array(digest)); -} - /** * First-run screen for the desktop app. Three equally-weighted choices — use it * free (local, no account), sign in with Google, or paste an Orcabot token — all @@ -96,40 +72,19 @@ export function DesktopWelcome() { setGoogleError(null); setPanel("google"); try { - // PKCE: the browser flow returns a real cloud PAT, so protect the poll with - // a verifier whose SHA-256 (the challenge) is sent to the CLOUD at login. - const nonce = randomNonce(); - const verifier = randomVerifier(); - const challenge = await sha256Base64Url(verifier); - - // Google requires a REAL browser (it blocks OAuth in embedded webviews), and - // this authenticates against the CLOUD control plane (your real account) so - // we get a cloud credential for dashboard sync. - await openExternalUrl( - `${CLOUD_API_URL}/auth/google/login?mode=desktop` + - `&nonce=${encodeURIComponent(nonce)}` + - `&challenge=${encodeURIComponent(challenge)}` - ); - - const deadline = Date.now() + 180_000; // 3 min - while (!googleCancelRef.current && Date.now() < deadline) { - const res = await pollCloudGoogleResult(nonce, verifier); - if (res && !res.pending && res.token && res.email) { - // Store the cloud PAT (for listing/syncing dashboards) + set the local - // signed-in identity. App still runs locally. - await setCloudCredential(res.token, res.email); - chooseSignedIn(res.email, res.name || res.email); - return; // chooseSignedIn unmounts this - } - await new Promise((r) => setTimeout(r, 2000)); - } - if (!googleCancelRef.current) { - setGoogleError("Sign-in timed out. Please try again."); - setPanel(null); - } + // Loopback sign-in (RFC 8252): the native layer opens the OS browser to the + // cloud login, receives the result on a local 127.0.0.1 listener, and stores + // the PAT host-only — the token never enters this webview. Resolves with the + // identity once done. Google needs a real browser (blocks embedded webviews), + // and this authenticates against the CLOUD so we get a credential for sync. + const account = await signInGoogleLoopback(); + if (googleCancelRef.current) return; + chooseSignedIn(account.email, account.name || account.email); + // chooseSignedIn unmounts this component. } catch (e) { + if (googleCancelRef.current) return; setGoogleError( - e instanceof Error ? e.message : "Couldn't start Google sign-in." + e instanceof Error ? e.message : "Couldn't complete Google sign-in." ); setPanel(null); } diff --git a/frontend/src/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index 54b92101..cfd743f6 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -184,25 +184,22 @@ export async function downloadCloudWorkspace( return invoke("download_cloud_workspace", { cloudId, subdir }) as Promise; } -export interface CloudGoogleResult { - pending: boolean; - token?: string; - email?: string; - name?: string; +export interface CloudSignIn { + email: string; + name: string; } /** - * Poll the CLOUD control plane for the desktop Google sign-in result (a cloud PAT - * + identity), by nonce + PKCE verifier. Native (no browser CORS). Returns - * {pending:true} until the browser sign-in completes. + * Sign in to the cloud with Google via a LOOPBACK redirect (RFC 8252). The native + * layer runs a temporary 127.0.0.1 listener, opens the browser to the cloud login + * pointing back at it, receives a one-time code there, exchanges it for a PAT, and + * stores the PAT host-only — the token never enters the webview. Resolves with + * {email,name} once sign-in completes (rejects on timeout/cancel). Desktop only. */ -export async function pollCloudGoogleResult( - nonce: string, - verifier: string -): Promise { +export async function signInGoogleLoopback(): Promise { const invoke = await getTauriInvoke(); if (!invoke) throw new Error("Sign-in is only available in the desktop app."); - return invoke("poll_cloud_google_result", { nonce, verifier }) as Promise; + return invoke("sign_in_google_loopback") as Promise; } /** Reveal the host workspace directory in Finder/Explorer (desktop only). */ From 6cef1e68d4c6c09cb01a4d0dbaf290dac3abe7cf Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 14 Jul 2026 01:54:53 +0100 Subject: [PATCH 36/44] security: harden loopback sign-in + workspace download (review round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- controlplane/src/auth/api-token.ts | 28 +++++ controlplane/src/auth/google.ts | 60 ++++++--- controlplane/src/index.ts | 9 +- desktop/app/src-tauri/src/commands.rs | 119 ++++++++++++++++-- desktop/app/src-tauri/src/main.rs | 1 + .../src/components/desktop/DashboardsTabs.tsx | 12 +- .../src/components/desktop/DesktopWelcome.tsx | 4 + frontend/src/lib/cloud-sync.ts | 14 ++- frontend/src/lib/tauri-bridge.ts | 8 ++ 9 files changed, 219 insertions(+), 36 deletions(-) diff --git a/controlplane/src/auth/api-token.ts b/controlplane/src/auth/api-token.ts index 1766543c..eaa9e326 100644 --- a/controlplane/src/auth/api-token.ts +++ b/controlplane/src/auth/api-token.ts @@ -111,6 +111,34 @@ export async function revokeApiToken(env: Env, userId: string, id: string): Prom return (res.meta?.changes ?? 0) > 0; } +/** + * Revoke the PAT presented as `Authorization: Bearer` — self-revoke, so the desktop + * app can invalidate its own cloud credential on logout (the normal token-management + * routes are method-gated against PATs). Presenting the token IS the authorization; + * it can only ever revoke itself. No-op (still 200) if unknown/already revoked. + */ +export async function revokeSelfApiToken(request: Request, env: Env): Promise { + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, + }); + const auth = request.headers.get('Authorization') || ''; + const m = auth.match(/^Bearer\s+(.+)$/i); + const token = m?.[1]?.trim(); + if (!token || !token.startsWith(PAT_PREFIX)) { + return json({ error: 'not_a_pat' }, 400); + } + const tokenHash = await hashToken(token); + await env.DB.prepare( + `UPDATE api_tokens SET revoked_at = datetime('now') + WHERE token_hash = ? AND revoked_at IS NULL` + ) + .bind(tokenHash) + .run(); + return json({ ok: true }); +} + /** * Resolve a bearer PAT to a user. Returns null if the token is malformed, * unknown, revoked, or expired. Fail-closed. Updates last_used_at on success. diff --git a/controlplane/src/auth/google.ts b/controlplane/src/auth/google.ts index 75546e35..de6311ff 100644 --- a/controlplane/src/auth/google.ts +++ b/controlplane/src/auth/google.ts @@ -218,6 +218,10 @@ export async function loginWithGoogle( const isDesktopMode = mode === 'desktop'; const desktopLoopback = isDesktopMode ? requestUrl.searchParams.get('redirect_uri') : null; const desktopState = isDesktopMode ? requestUrl.searchParams.get('state') : null; + // PKCE (S256): binds the one-time code to a verifier the native app never puts on + // the wire. Even a browser extension / local process that observes the loopback + // callback URL (which carries the code) can't exchange it without the verifier. + const desktopChallenge = isDesktopMode ? requestUrl.searchParams.get('challenge') : null; // Validate Turnstile bot verification token (skip if not configured, e.g. dev) // Skip Turnstile in popup/desktop mode — Google's consent screen provides bot protection @@ -236,8 +240,8 @@ export async function loginWithGoogle( } } - if (isDesktopMode && (!desktopLoopback || !desktopState || !isLoopbackRedirect(desktopLoopback))) { - return renderErrorPage('Desktop sign-in requires a loopback redirect.'); + if (isDesktopMode && (!desktopLoopback || !desktopState || !desktopChallenge || !isLoopbackRedirect(desktopLoopback))) { + return renderErrorPage('Desktop sign-in requires a loopback redirect and PKCE challenge.'); } const state = crypto.randomUUID(); @@ -250,7 +254,7 @@ export async function loginWithGoogle( const postLoginRedirect = isPopupMode ? 'popup' : isDesktopMode - ? `desktoplb:${btoa(JSON.stringify({ uri: desktopLoopback, st: desktopState }))}` + ? `desktoplb:${btoa(JSON.stringify({ uri: desktopLoopback, st: desktopState, ch: desktopChallenge }))}` : resolvePostLoginRedirect(request, env); await createAuthState(env, state, postLoginRedirect); @@ -357,13 +361,16 @@ export async function callbackGoogle( if (postLoginRedirect.startsWith('desktoplb:')) { let uri: string; let st: string; + let ch: string; try { const dec = JSON.parse(atob(postLoginRedirect.slice('desktoplb:'.length))) as { uri: string; st: string; + ch: string; }; uri = dec.uri; st = dec.st; + ch = dec.ch; } catch { return renderErrorPage('Desktop sign-in state was corrupt.'); } @@ -379,6 +386,7 @@ export async function callbackGoogle( userId, email: userInfo.email, name, + challenge: ch, // PKCE S256 — exchanged only with the matching verifier expiresAt: Date.now() + 5 * 60 * 1000, // 5 min to exchange }) ); @@ -426,12 +434,25 @@ function isLoopbackRedirect(uri: string): boolean { } } +/** Desktop-minted cloud PATs get a bounded lifetime so a leaked/orphaned one dies + * on its own even if logout's server-side revoke never runs (offline logout). */ +const DESKTOP_PAT_TTL_DAYS = 90; + +/** base64url(SHA-256(input)) — PKCE S256 challenge computation. */ +async function sha256Base64Url(input: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)); + let bin = ''; + for (const b of new Uint8Array(digest)) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + /** * Desktop app exchanges the one-time `code` it received on its 127.0.0.1 listener - * (from the OAuth callback redirect) for its cloud PAT + identity. The code is the - * only way to obtain the token and was delivered solely to a loopback listener on - * the machine that ran the flow, so a remote attacker can't harvest it. Single-use - * (consumed on lookup) and short-lived. + * (from the OAuth callback redirect) for its cloud PAT + identity. The code alone is + * NOT sufficient — the app must also present the PKCE `verifier` whose S256 hash + * matches the challenge sent at login, so a local observer of the loopback callback + * URL can't exchange the code it saw. Atomically single-use (DELETE … RETURNING) and + * short-lived; the PAT is bounded by a TTL. */ export async function exchangeDesktopCode(request: Request, env: Env): Promise { const json = (body: unknown, status = 200) => @@ -440,28 +461,30 @@ export async function exchangeDesktopCode(request: Request, env: Env): Promise(); - // Consume immediately (single-use) regardless of what happens next. - await env.DB.prepare('DELETE FROM auth_states WHERE state = ?').bind(key).run(); if (!rec) { return json({ error: 'not_found' }, 404); } - let parsed: { userId: string; email: string; name: string; expiresAt?: number }; + let parsed: { userId: string; email: string; name: string; challenge?: string; expiresAt?: number }; try { parsed = JSON.parse(rec.v); } catch { @@ -470,7 +493,10 @@ export async function exchangeDesktopCode(request: Request, env: Env): Promise parsed.expiresAt) { return json({ error: 'expired' }, 410); } - const { token } = await createApiToken(env, parsed.userId, 'Orcabot Desktop'); + if (!parsed.challenge || (await sha256Base64Url(verifier)) !== parsed.challenge) { + return json({ error: 'forbidden' }, 403); + } + const { token } = await createApiToken(env, parsed.userId, 'Orcabot Desktop', DESKTOP_PAT_TTL_DAYS); return json({ token, email: parsed.email, name: parsed.name }); } diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index 1dd9fdf8..f1da3a93 100644 --- a/controlplane/src/index.ts +++ b/controlplane/src/index.ts @@ -48,7 +48,7 @@ import { isAdminEmail } from './auth/admin'; import { getSubscriptionStatus, hasActiveAccess, isExemptEmail } from './subscriptions/check'; import * as subscriptions from './subscriptions/handler'; import { buildSessionCookie, createUserSession } from './auth/sessions'; -import { createApiToken, listApiTokens, revokeApiToken } from './auth/api-token'; +import { createApiToken, listApiTokens, revokeApiToken, revokeSelfApiToken } from './auth/api-token'; import { checkAndCacheSandbоxHealth, getCachedHealth } from './health/checker'; import { sendEmail, buildInterestThankYouEmail, buildInterestNotificationEmail, buildTemplateReviewEmail } from './email/resend'; import * as blog from './blog/handler'; @@ -923,6 +923,7 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick bool { + SIGN_IN_GEN.load(std::sync::atomic::Ordering::SeqCst) == my_gen +} + +/// base64url (no padding) — matches the control plane's PKCE challenge encoding. +fn b64url(bytes: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::new(); + for chunk in bytes.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = *chunk.get(1).unwrap_or(&0) as u32; + let b2 = *chunk.get(2).unwrap_or(&0) as u32; + let n = (b0 << 16) | (b1 << 8) | b2; + out.push(T[((n >> 18) & 63) as usize] as char); + out.push(T[((n >> 12) & 63) as usize] as char); + if chunk.len() > 1 { + out.push(T[((n >> 6) & 63) as usize] as char); + } + if chunk.len() > 2 { + out.push(T[(n & 63) as usize] as char); + } + } + out +} + +/// PKCE S256 challenge: base64url(SHA-256(verifier)). +fn pkce_challenge(verifier: &str) -> String { + use sha2::{Digest, Sha256}; + b64url(&Sha256::digest(verifier.as_bytes())) +} + /// Cryptographically-random hex token (OS RNG via /dev/urandom; the OS-seeded /// RandomState as a fallback). Used as the loopback CSRF `state`. fn random_hex(n: usize) -> String { @@ -834,12 +874,16 @@ fn parse_query(path: &str) -> (Option, Option) { fn await_loopback_code( listener: std::net::TcpListener, expect_state: &str, + my_gen: u64, ) -> Result { use std::io::{Read, Write}; use std::time::{Duration, Instant}; listener.set_nonblocking(true).ok(); let deadline = Instant::now() + Duration::from_secs(180); loop { + if !sign_in_current(my_gen) { + return Err("sign-in cancelled".into()); + } if Instant::now() > deadline { return Err("timed out waiting for the browser sign-in".into()); } @@ -887,11 +931,11 @@ fn await_loopback_code( } } -fn exchange_desktop_code(code: &str) -> Result<(String, String, String), String> { +fn exchange_desktop_code(code: &str, verifier: &str) -> Result<(String, String, String), String> { let url = format!("{CLOUD_API_BASE}/auth/desktop/exchange"); match ureq::post(&url) .timeout(std::time::Duration::from_secs(30)) - .send_json(serde_json::json!({ "code": code })) + .send_json(serde_json::json!({ "code": code, "verifier": verifier })) { Ok(rp) => { let v: serde_json::Value = rp.into_json().map_err(|e| e.to_string())?; @@ -918,40 +962,75 @@ fn exchange_desktop_code(code: &str) -> Result<(String, String, String), String> /// host-only. The token never enters the webview. Returns {email,name} for the UI. #[tauri::command] pub async fn sign_in_google_loopback(app: tauri::AppHandle) -> Result { + // Claim a fresh attempt generation; any later cancel / sign-in / PAT paste bumps + // it, so this flow will refuse to exchange or store once superseded. + let my_gen = SIGN_IN_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; + let listener = std::net::TcpListener::bind("127.0.0.1:0") .map_err(|e| format!("could not start local sign-in listener: {e}"))?; let port = listener.local_addr().map_err(|e| e.to_string())?.port(); let state = random_hex(16); + // PKCE: keep the verifier in-process; send only its S256 challenge in the URL. + let verifier = random_hex(32); + let challenge = pkce_challenge(&verifier); let redirect = format!("http://127.0.0.1:{port}/cb"); let login_url = format!( - "{CLOUD_API_BASE}/auth/google/login?mode=desktop&redirect_uri={}&state={}", + "{CLOUD_API_BASE}/auth/google/login?mode=desktop&redirect_uri={}&state={}&challenge={}", pct(&redirect), - pct(&state) + pct(&state), + pct(&challenge) ); open_in_browser(&login_url)?; let (token, email, name) = tauri::async_runtime::spawn_blocking( move || -> Result<(String, String, String), String> { - let code = await_loopback_code(listener, &state)?; - exchange_desktop_code(&code) + let code = await_loopback_code(listener, &state, my_gen)?; + if !sign_in_current(my_gen) { + return Err("sign-in cancelled".into()); + } + exchange_desktop_code(&code, &verifier) }, ) .await .map_err(|e| format!("sign-in task failed: {e}"))??; + // Final guard: don't overwrite the credential if the attempt was cancelled or + // superseded (e.g. the user pasted a PAT for a different account meanwhile). + if !sign_in_current(my_gen) { + return Err("sign-in cancelled".into()); + } write_cloud_credential(&app, &token, &email)?; Ok(CloudSignIn { email, name }) } +/// Cancel an in-flight loopback sign-in: bumps the attempt generation so the native +/// flow stops before exchanging the code or writing the credential. +#[tauri::command] +pub fn cancel_google_sign_in() { + SIGN_IN_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst); +} + /// The signed-in cloud account (email), or null if not signed in to the cloud. #[tauri::command] pub fn get_cloud_account(app: tauri::AppHandle) -> Option { read_cloud_credential(&app).map(|(_, email)| CloudAccount { email }) } -/// Forget the stored cloud credential (sign out of cloud sync). +/// Forget the stored cloud credential (sign out of cloud sync). Also revokes the +/// PAT server-side (best-effort) so logout doesn't leave an active token behind — +/// the TTL is only a backstop for offline logout. Supersedes any in-flight sign-in. #[tauri::command] -pub fn clear_cloud_credential(app: tauri::AppHandle) -> Result<(), String> { +pub async fn clear_cloud_credential(app: tauri::AppHandle) -> Result<(), String> { + SIGN_IN_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if let Some((token, _email)) = read_cloud_credential(&app) { + let _ = tauri::async_runtime::spawn_blocking(move || { + let _ = ureq::post(&format!("{CLOUD_API_BASE}/auth/api-token/revoke-self")) + .set("Authorization", &format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(10)) + .call(); + }) + .await; + } if let Some(path) = cloud_credential_path(&app) { let _ = std::fs::remove_file(path); } @@ -1476,6 +1555,7 @@ pub async fn download_cloud_workspace( } Err(e) => { eprintln!("[cloud-dl] skip dir {query_path}: {e}"); + skipped += 1; // count it so the result reports incompleteness continue; // a deeper dir stayed unreachable — skip it } }; @@ -1668,3 +1748,24 @@ pub fn switch_to_cli(app: tauri::AppHandle) -> Result<(), String> { Err("switch_to_cli is only supported on macOS".into()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pkce_challenge_matches_reference() { + // Must equal base64url(SHA-256("test")) exactly, or the control plane's PKCE + // check (sha256Base64Url in google.ts) rejects every desktop sign-in. + assert_eq!( + pkce_challenge("test"), + "n4bQgYhMfWWaL-qgxVrQFaO_TxsrC4Is0V1sFbDwCgg" + ); + } + + #[test] + fn b64url_is_unpadded() { + assert_eq!(b64url(&[0x00]), "AA"); + assert_eq!(b64url(&[0xff, 0xff]), "__8"); + } +} diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index bcaa0ac5..1a83aa3e 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -1015,6 +1015,7 @@ fn main() { commands::verify_orcabot_account, commands::set_cloud_credential, commands::sign_in_google_loopback, + commands::cancel_google_sign_in, commands::get_cloud_account, commands::clear_cloud_credential, commands::list_cloud_dashboards, diff --git a/frontend/src/components/desktop/DashboardsTabs.tsx b/frontend/src/components/desktop/DashboardsTabs.tsx index ae600dde..82e03331 100644 --- a/frontend/src/components/desktop/DashboardsTabs.tsx +++ b/frontend/src/components/desktop/DashboardsTabs.tsx @@ -1,7 +1,7 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: dashboards-tabs-v6-download-progress +// REVISION: dashboards-tabs-v7-surface-skipped "use client"; import * as React from "react"; @@ -27,7 +27,7 @@ import { downloadCloudDashboard } from "@/lib/cloud-sync"; import { formatRelativeTime, cn } from "@/lib/utils"; import type { Dashboard } from "@/types/dashboard"; -const MODULE_REVISION = "dashboards-tabs-v6-download-progress"; +const MODULE_REVISION = "dashboards-tabs-v7-surface-skipped"; if (typeof window !== "undefined") { console.log( `[dashboards-tabs] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` @@ -147,12 +147,16 @@ export function DashboardsTabs({ setDownloading((s) => new Set(s).add(cd.id)); try { const res = await downloadCloudDashboard(cd.id, cd.name); - // Canvas downloaded; if the workspace file copy failed, say so (the dashboard - // is still usable, just without its files). + // Canvas downloaded; report incomplete workspace copies (hard error, or some + // files/dirs skipped) so it's not silently presented as complete. if (res.workspaceError) { setDownloadError( `“${cd.name}” downloaded, but its files couldn't be copied: ${res.workspaceError}` ); + } else if (res.workspace && res.workspace.skipped > 0) { + setDownloadError( + `“${cd.name}” downloaded, but ${res.workspace.skipped} file(s)/folder(s) couldn't be copied — some workspace files may be missing. Re-download to retry.` + ); } } catch (e) { setDownloadError(e instanceof Error ? e.message : "Download failed."); diff --git a/frontend/src/components/desktop/DesktopWelcome.tsx b/frontend/src/components/desktop/DesktopWelcome.tsx index 9f684dcc..fabf506a 100644 --- a/frontend/src/components/desktop/DesktopWelcome.tsx +++ b/frontend/src/components/desktop/DesktopWelcome.tsx @@ -9,6 +9,7 @@ import { CLOUD_SITE_URL } from "@/config/env"; import { openExternalUrl, signInGoogleLoopback, + cancelGoogleSignIn, setCloudCredential, verifyOrcabotAccount, } from "@/lib/tauri-bridge"; @@ -92,6 +93,9 @@ export function DesktopWelcome() { const cancelGoogle = () => { googleCancelRef.current = true; + // Actually stop the native flow (it keeps waiting/exchanging otherwise), so a + // cancelled sign-in can't later silently commit a credential. + void cancelGoogleSignIn(); setPanel(null); }; diff --git a/frontend/src/lib/cloud-sync.ts b/frontend/src/lib/cloud-sync.ts index b0366376..060f64a1 100644 --- a/frontend/src/lib/cloud-sync.ts +++ b/frontend/src/lib/cloud-sync.ts @@ -1,7 +1,7 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: cloud-sync-v2-workspace-files +// REVISION: cloud-sync-v3-pin-legacy-atomic "use client"; import { @@ -19,7 +19,7 @@ import type { Dashboard, DashboardItem, DashboardEdge } from "@/types"; if (typeof window !== "undefined") { console.log( - `[cloud-sync] REVISION: cloud-sync-v2-workspace-files loaded at ${new Date().toISOString()}` + `[cloud-sync] REVISION: cloud-sync-v3-pin-legacy-atomic loaded at ${new Date().toISOString()}` ); } @@ -61,14 +61,18 @@ function sanitizeRel(p: string): string { } function pinTerminalToSubdir(content: string, subdir: string): string { - if (!content.trim().startsWith("{")) { - return content; // not JSON we understand — leave it (cwds to workspace root) + const trimmed = content.trim(); + // Legacy / malformed terminal content is a plain string (the parser treats it as + // the terminal's display name). Wrap it into valid JSON so it, too, is pinned to + // this dashboard's subfolder instead of starting at the shared workspace root. + if (!trimmed.startsWith("{")) { + return JSON.stringify({ name: trimmed || "Terminal", workingDir: subdir }); } let obj: Record; try { obj = JSON.parse(content) as Record; } catch { - return content; + return JSON.stringify({ name: trimmed, workingDir: subdir }); } const existing = typeof obj.workingDir === "string" ? sanitizeRel(obj.workingDir) : ""; diff --git a/frontend/src/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index cfd743f6..ce4e5b5c 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -202,6 +202,14 @@ export async function signInGoogleLoopback(): Promise { return invoke("sign_in_google_loopback") as Promise; } +/** Cancel an in-flight loopback Google sign-in so the native flow stops before it + * exchanges the code or writes the credential. No-op off desktop. */ +export async function cancelGoogleSignIn(): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) return; + await invoke("cancel_google_sign_in"); +} + /** Reveal the host workspace directory in Finder/Explorer (desktop only). */ export async function revealWorkspace(): Promise { const invoke = await getTauriInvoke(); From fcbd61b2ab0da1d4630f6d81ce85141a64c2d301 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 14 Jul 2026 01:59:27 +0100 Subject: [PATCH 37/44] docs(desktop): drop orphaned pinTerminalToSubdir comment Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- frontend/src/lib/cloud-sync.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/frontend/src/lib/cloud-sync.ts b/frontend/src/lib/cloud-sync.ts index 060f64a1..96c87652 100644 --- a/frontend/src/lib/cloud-sync.ts +++ b/frontend/src/lib/cloud-sync.ts @@ -37,13 +37,6 @@ export interface DownloadResult { workspaceError?: string; } -/** - * On the desktop there is ONE shared /workspace, so each downloaded dashboard's - * terminals are pinned to a per-dashboard subfolder (= the new local dashboard id) - * via `workingDir`. This keeps two downloads from colliding and matches where - * `download_cloud_workspace` writes the files. Any existing workingDir is preserved - * underneath the subfolder. Returns the content JSON string to store on the item. - */ /** * Sanitize a workspace-relative path to plain segments: strip leading slashes and * `.`/empty segments, and REJECT any `..` (returns "" so the caller falls back to From 3912d9c97a2df9115d6644d196f32791228105a6 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 14 Jul 2026 02:09:29 +0100 Subject: [PATCH 38/44] security: harden loopback + logout + download (review round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [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) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- controlplane/src/auth/google.ts | 24 +++- controlplane/src/index.ts | 2 - desktop/app/src-tauri/src/commands.rs | 58 ++++++--- .../src/components/desktop/DashboardsTabs.tsx | 118 ++++++++++++++++-- 4 files changed, 165 insertions(+), 37 deletions(-) diff --git a/controlplane/src/auth/google.ts b/controlplane/src/auth/google.ts index de6311ff..6b29c6bd 100644 --- a/controlplane/src/auth/google.ts +++ b/controlplane/src/auth/google.ts @@ -472,13 +472,13 @@ export async function exchangeDesktopCode(request: Request, env: Env): Promise(); if (!rec) { @@ -488,13 +488,25 @@ export async function exchangeDesktopCode(request: Request, env: Env): Promise parsed.expiresAt) { + await env.DB.prepare('DELETE FROM auth_states WHERE state = ?').bind(key).run(); return json({ error: 'expired' }, 410); } if (!parsed.challenge || (await sha256Base64Url(verifier)) !== parsed.challenge) { - return json({ error: 'forbidden' }, 403); + return json({ error: 'forbidden' }, 403); // wrong verifier — leave the code intact + } + // PKCE verified — atomically consume this exact record. A concurrent request that + // already deleted it makes this affect 0 rows → treat as already used. + const consumed = await env.DB + .prepare('DELETE FROM auth_states WHERE state = ? AND redirect_url = ? RETURNING redirect_url as v') + .bind(key, rec.v) + .first<{ v: string }>(); + if (!consumed) { + return json({ error: 'not_found' }, 404); } const { token } = await createApiToken(env, parsed.userId, 'Orcabot Desktop', DESKTOP_PAT_TTL_DAYS); return json({ token, email: parsed.email, name: parsed.name }); diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index f1da3a93..85105b93 100644 --- a/controlplane/src/index.ts +++ b/controlplane/src/index.ts @@ -922,8 +922,6 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick Option { app.path().app_data_dir().ok().map(|d| d.join("cloud-credential")) } -fn read_cloud_credential(app: &tauri::AppHandle) -> Option<(String, String)> { +/// (token, email, origin). `origin` is "google" for a desktop-minted cloud PAT, +/// "pat" for a user-pasted token, or "" for legacy files. Only "google" tokens are +/// safe to revoke on logout (a pasted PAT may be shared with the CLI/automation). +fn read_cloud_credential_full(app: &tauri::AppHandle) -> Option<(String, String, String)> { let path = cloud_credential_path(app)?; let contents = std::fs::read_to_string(path).ok()?; let mut lines = contents.lines(); let token = lines.next()?.trim().to_string(); let email = lines.next().unwrap_or("").trim().to_string(); + let origin = lines.next().unwrap_or("").trim().to_string(); if token.is_empty() { return None; } - Some((token, email)) + Some((token, email, origin)) +} + +fn read_cloud_credential(app: &tauri::AppHandle) -> Option<(String, String)> { + read_cloud_credential_full(app).map(|(t, e, _)| (t, e)) } #[derive(Serialize, Clone)] @@ -710,12 +718,17 @@ pub struct CloudAccount { /// Write to a temp file created 0600, then rename over the target — so the token is /// never briefly world-readable (umask race) and any pre-existing loose-permission /// file is replaced by a 0600 one. Permission failures are fatal. -fn write_cloud_credential(app: &tauri::AppHandle, token: &str, email: &str) -> Result<(), String> { +fn write_cloud_credential( + app: &tauri::AppHandle, + token: &str, + email: &str, + origin: &str, +) -> Result<(), String> { let path = cloud_credential_path(app).ok_or("no app data dir")?; if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - let contents = format!("{}\n{}\n", token, email.trim()); + let contents = format!("{}\n{}\n{}\n", token, email.trim(), origin); let tmp = path.with_extension("tmp"); #[cfg(unix)] { @@ -751,7 +764,9 @@ pub fn set_cloud_credential(app: tauri::AppHandle, token: String, email: String) if !token.starts_with("orca_pat_") { return Err("Not an Orcabot token.".into()); } - write_cloud_credential(&app, token, &email)?; + // "pat" origin — a user-pasted token, possibly shared with the CLI/automation, + // so logout must NOT revoke it server-side (only forget it locally). + write_cloud_credential(&app, token, &email, "pat")?; // A deliberate credential set (PAT paste, or the loopback flow) supersedes any // in-flight Google sign-in so it can't later overwrite this one. SIGN_IN_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst); @@ -999,7 +1014,8 @@ pub async fn sign_in_google_loopback(app: tauri::AppHandle) -> Result Option { read_cloud_credential(&app).map(|(_, email)| CloudAccount { email }) } -/// Forget the stored cloud credential (sign out of cloud sync). Also revokes the -/// PAT server-side (best-effort) so logout doesn't leave an active token behind — -/// the TTL is only a backstop for offline logout. Supersedes any in-flight sign-in. +/// Forget the stored cloud credential (sign out of cloud sync). Deletes the local +/// file FIRST (before any await) so a concurrent re-sign-in that writes a new +/// credential can't be clobbered by this logout's late deletion. Then, only for a +/// desktop-minted ("google") token, revokes it server-side (best-effort; the TTL is +/// the offline backstop). A user-pasted PAT is only forgotten locally — it may be +/// shared with the CLI/automation, so we must not revoke it globally. #[tauri::command] pub async fn clear_cloud_credential(app: tauri::AppHandle) -> Result<(), String> { SIGN_IN_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if let Some((token, _email)) = read_cloud_credential(&app) { - let _ = tauri::async_runtime::spawn_blocking(move || { - let _ = ureq::post(&format!("{CLOUD_API_BASE}/auth/api-token/revoke-self")) - .set("Authorization", &format!("Bearer {token}")) - .timeout(std::time::Duration::from_secs(10)) - .call(); - }) - .await; - } + let creds = read_cloud_credential_full(&app); if let Some(path) = cloud_credential_path(&app) { let _ = std::fs::remove_file(path); } + if let Some((token, _email, origin)) = creds { + if origin == "google" { + let _ = tauri::async_runtime::spawn_blocking(move || { + let _ = ureq::post(&format!("{CLOUD_API_BASE}/auth/api-token/revoke-self")) + .set("Authorization", &format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(10)) + .call(); + }) + .await; + } + } Ok(()) } diff --git a/frontend/src/components/desktop/DashboardsTabs.tsx b/frontend/src/components/desktop/DashboardsTabs.tsx index 82e03331..0873ec6e 100644 --- a/frontend/src/components/desktop/DashboardsTabs.tsx +++ b/frontend/src/components/desktop/DashboardsTabs.tsx @@ -1,12 +1,12 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: dashboards-tabs-v7-surface-skipped +// REVISION: dashboards-tabs-v8-workspace-retry "use client"; import * as React from "react"; import { useQuery } from "@tanstack/react-query"; -import { Download, HardDrive, Trash2, Link2, Plus, Loader2, Cloud } from "lucide-react"; +import { Download, HardDrive, Trash2, Link2, Plus, Loader2, Cloud, RefreshCw } from "lucide-react"; import { Button, Card, @@ -21,13 +21,14 @@ import { listCloudDashboards, openExternalUrl, onCloudWorkspaceProgress, + downloadCloudWorkspace, type CloudWorkspaceProgress, } from "@/lib/tauri-bridge"; import { downloadCloudDashboard } from "@/lib/cloud-sync"; import { formatRelativeTime, cn } from "@/lib/utils"; import type { Dashboard } from "@/types/dashboard"; -const MODULE_REVISION = "dashboards-tabs-v7-surface-skipped"; +const MODULE_REVISION = "dashboards-tabs-v8-workspace-retry"; if (typeof window !== "undefined") { console.log( `[dashboards-tabs] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` @@ -73,9 +74,28 @@ export function DashboardsTabs({ const [downloading, setDownloading] = React.useState>(new Set()); const [downloadError, setDownloadError] = React.useState(null); const [progress, setProgress] = React.useState>({}); + // Cloud ids whose last workspace copy was incomplete (persisted, so it survives + // reload) — those cards get a "Retry files" action. + const [incomplete, setIncomplete] = React.useState>(new Set()); const [tab, setTab] = React.useState<"online" | "local">("local"); const defaultedToOnline = React.useRef(false); + const wsKey = (cloudId: string) => `orcabot:ws-incomplete:${cloudId}`; + const markIncomplete = React.useCallback((cloudId: string, yes: boolean) => { + try { + if (yes) localStorage.setItem(wsKey(cloudId), "1"); + else localStorage.removeItem(wsKey(cloudId)); + } catch { + /* localStorage unavailable — in-memory state still updates */ + } + setIncomplete((prev) => { + const next = new Set(prev); + if (yes) next.add(cloudId); + else next.delete(cloudId); + return next; + }); + }, []); + // Live progress for in-flight downloads (so a slow cold cloud-VM boot doesn't // look like a hang), keyed by cloud dashboard id. React.useEffect(() => { @@ -142,21 +162,40 @@ export function DashboardsTabs({ const cloudList = cloudDashboards ?? []; + // Hydrate the incomplete set from localStorage once the cloud list is known. + React.useEffect(() => { + if (typeof window === "undefined" || !cloudDashboards) return; + const s = new Set(); + for (const cd of cloudDashboards) { + try { + if (localStorage.getItem(wsKey(cd.id)) === "1") s.add(cd.id); + } catch { + /* ignore */ + } + } + setIncomplete(s); + }, [cloudDashboards]); + const download = async (cd: CloudDashboard) => { setDownloadError(null); setDownloading((s) => new Set(s).add(cd.id)); try { const res = await downloadCloudDashboard(cd.id, cd.name); // Canvas downloaded; report incomplete workspace copies (hard error, or some - // files/dirs skipped) so it's not silently presented as complete. + // files/dirs skipped) so it's not silently presented as complete, and remember + // the incomplete state so the card can offer a retry. if (res.workspaceError) { + markIncomplete(cd.id, true); setDownloadError( `“${cd.name}” downloaded, but its files couldn't be copied: ${res.workspaceError}` ); } else if (res.workspace && res.workspace.skipped > 0) { + markIncomplete(cd.id, true); setDownloadError( - `“${cd.name}” downloaded, but ${res.workspace.skipped} file(s)/folder(s) couldn't be copied — some workspace files may be missing. Re-download to retry.` + `“${cd.name}” downloaded, but ${res.workspace.skipped} file(s)/folder(s) couldn't be copied — use “Retry files” on the card to try again.` ); + } else if (res.workspace) { + markIncomplete(cd.id, false); } } catch (e) { setDownloadError(e instanceof Error ? e.message : "Download failed."); @@ -177,6 +216,37 @@ export function DashboardsTabs({ } }; + // Workspace-only retry for an already-downloaded dashboard: re-pull the cloud + // files into the existing local dashboard's subfolder (no canvas re-create). + const retryWorkspace = async (cd: CloudDashboard, local: Dashboard) => { + setDownloadError(null); + setDownloading((s) => new Set(s).add(cd.id)); + try { + const ws = await downloadCloudWorkspace(cd.id, local.id); + if (ws.skipped > 0) { + markIncomplete(cd.id, true); + setDownloadError( + `Retried “${cd.name}”, but ${ws.skipped} item(s) still couldn't be copied.` + ); + } else { + markIncomplete(cd.id, false); + } + } catch (e) { + setDownloadError(e instanceof Error ? e.message : "Retry failed."); + } finally { + setDownloading((s) => { + const next = new Set(s); + next.delete(cd.id); + return next; + }); + setProgress((prev) => { + const next = { ...prev }; + delete next[cd.id]; + return next; + }); + } + }; + const gridClass = "grid grid-cols-1 md:grid-cols-2 gap-4"; const localGrid = () => { @@ -259,7 +329,9 @@ export function DashboardsTabs({ local={localByCloudId.get(cd.id)} isDownloading={downloading.has(cd.id)} downloadingLabel={downloadLabel(progress[cd.id])} + incomplete={incomplete.has(cd.id)} onDownload={() => void download(cd)} + onRetry={(local) => void retryWorkspace(cd, local)} onOpen={onOpen} /> ))} @@ -351,14 +423,18 @@ function CloudDashboardCard({ local, isDownloading, downloadingLabel, + incomplete, onDownload, + onRetry, onOpen, }: { cd: CloudDashboard; local: Dashboard | undefined; isDownloading: boolean; downloadingLabel: string; + incomplete: boolean; onDownload: () => void; + onRetry: (local: Dashboard) => void; onOpen: (localDashboardId: string) => void; }) { const downloaded = !!local; @@ -397,12 +473,32 @@ function CloudDashboardCard({ : "In your cloud account"}

{downloaded ? ( - - Local Storage - +
+ + Local Storage + + {incomplete && + (isDownloading ? ( + + {downloadingLabel} + + ) : ( + + ))} +
) : (