From 24e8bf15adbf413d29d4739dca12358b5e1cfe53 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 13 May 2026 17:09:25 +1000 Subject: [PATCH 1/4] feat(staged): add browser-accessible web mode with transport abstraction Restore the full Axum HTTPS web server (previously stubbed) and implement the frontend transport layer for running Staged in a browser. This enables phone and desktop browser access to a running Staged instance. Key changes: - Unstub web_server::start() with TLS listener, static file serving, and auth-protected API routes - Add HTTP transport in invokeCommand() that POSTs to /api/invoke/{command} with automatic 401 redirect to login - Add WebSocket singleton for server-sent events in web mode - Add WebLogin.svelte token entry screen with /api/auth session cookie flow - Implement localStorage backend for persistentStore in web mode - Expose web access token via Tauri command and settings UI with copy button - Add `just dev-web` recipe requiring HTTPS cert/key env vars - Add writeClipboardText/readClipboardText web fallbacks in transport layer Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/justfile | 29 ++++ apps/staged/package.json | 1 + apps/staged/src-tauri/Cargo.lock | 4 +- apps/staged/src-tauri/src/lib.rs | 19 ++- apps/staged/src-tauri/src/web_server.rs | 74 ++++++++-- apps/staged/src/App.svelte | 31 +++- apps/staged/src/lib/commands.ts | 9 ++ .../src/lib/features/layout/WebLogin.svelte | 137 ++++++++++++++++++ .../lib/features/settings/SettingsPage.svelte | 80 +++++++++- apps/staged/src/lib/transport.ts | 48 +++++- apps/staged/vite.config.ts | 30 ++++ 11 files changed, 438 insertions(+), 24 deletions(-) create mode 100644 apps/staged/src/lib/features/layout/WebLogin.svelte diff --git a/apps/staged/justfile b/apps/staged/justfile index 8e1f5bce3..0bf7f294e 100644 --- a/apps/staged/justfile +++ b/apps/staged/justfile @@ -60,6 +60,35 @@ dev repo="": {{ if repo != "" { "export STAGED_REPO=" + repo } else { "" } }} pnpm exec tauri dev --config "$TAURI_CONFIG" +# Run with the HTTPS web server enabled for phone/browser access. +# Requires PEM cert/key files and a hostname covered by the certificate. +dev-web repo="": + #!/usr/bin/env bash + set -euo pipefail + + [[ -d node_modules ]] || pnpm install + + if [[ -z "${STAGED_WEB_CERT_PATH:-}" || -z "${STAGED_WEB_KEY_PATH:-}" || -z "${STAGED_WEB_HOST:-}" ]]; then + printf '%s\n' \ + 'Error: `just dev-web` serves browser access over HTTPS.' \ + 'Provide PEM certificate/key files and a hostname covered by the certificate:' \ + '' \ + ' STAGED_WEB_CERT_PATH=/path/to/cert.pem \' \ + ' STAGED_WEB_KEY_PATH=/path/to/key.pem \' \ + ' STAGED_WEB_HOST=hostname.example.com \' \ + ' just dev-web' >&2 + exit 1 + fi + + VITE_PORT=$(python3 -c "import hashlib,os; h=int(hashlib.sha256(os.getcwd().encode()).hexdigest(),16); print(10000 + h % 55000)") + export VITE_PORT + export STAGED_WEB_SERVER=1 + TAURI_CONFIG="{\"build\":{\"devUrl\":\"https://${STAGED_WEB_HOST}:${VITE_PORT}\",\"beforeDevCommand\":\"exec ./node_modules/.bin/vite --port ${VITE_PORT} --strictPort --host 0.0.0.0\"}}" + + echo "Starting on https://${STAGED_WEB_HOST}:${VITE_PORT} (HTTPS web server on :5175)" + {{ if repo != "" { "export STAGED_REPO=" + repo } else { "" } }} + pnpm exec tauri dev --config "$TAURI_CONFIG" + # Build the app for production build: ./scripts/prepare-acp-tools-resource.sh diff --git a/apps/staged/package.json b/apps/staged/package.json index a6e003cf5..6486ac157 100644 --- a/apps/staged/package.json +++ b/apps/staged/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "dev:web": "vite --host 0.0.0.0", "build": "vite build", "preview": "vite preview", "check": "svelte-check --tsconfig ./tsconfig.app.json --fail-on-warnings && tsc -p tsconfig.node.json", diff --git a/apps/staged/src-tauri/Cargo.lock b/apps/staged/src-tauri/Cargo.lock index c1024ff2e..afdae812e 100644 --- a/apps/staged/src-tauri/Cargo.lock +++ b/apps/staged/src-tauri/Cargo.lock @@ -1158,9 +1158,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "data-url" diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 8ad89022f..cbd2f53eb 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -60,6 +60,10 @@ struct DbState { needs_reset: Mutex>, } +/// Holds the bearer token for web server authentication so it can be +/// retrieved by the frontend (Tauri command) and shown to the user. +struct WebAccessToken(String); + #[derive(Default)] struct ShutdownState { quit_in_progress: AtomicBool, @@ -287,6 +291,12 @@ fn stop_actions_for_app_shutdown(app_handle: &tauri::AppHandle) { // Store status commands // ============================================================================= +/// Returns the bearer token used to authenticate web browser clients. +#[tauri::command] +fn get_web_access_token(token: tauri::State<'_, WebAccessToken>) -> String { + token.0.clone() +} + /// Returns null if the store is ready, or version info if a reset is needed. #[tauri::command] fn get_store_status(db_state: tauri::State<'_, DbState>) -> Option { @@ -2144,14 +2154,16 @@ pub fn run() { let (event_tx, _) = tokio::sync::broadcast::channel::(256); app.manage(event_tx.clone()); - // Web server startup is stubbed out in this build. - // TODO(web): restore web server startup from the `mobile-web` branch. + // Start the Axum web server only when opted-in via environment variable. + // This avoids exposing an HTTP server on all interfaces for users who + // don't need browser-based access. let web_server_enabled = std::env::var("STAGED_WEB_SERVER") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); if web_server_enabled { let auth_token = web_server::generate_token(); + app.manage(WebAccessToken(auth_token.clone())); web_server::start(web_server::WebAppState { app_handle: app.handle().clone(), event_tx, @@ -2160,6 +2172,8 @@ pub fn run() { std::collections::HashSet::new(), )), }); + } else { + app.manage(WebAccessToken(String::new())); } if cfg!(debug_assertions) { @@ -2191,6 +2205,7 @@ pub fn run() { } }) .invoke_handler(tauri::generate_handler![ + get_web_access_token, get_store_status, confirm_reset_store, list_projects, diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 47f569c05..9a2e2f52c 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -11,10 +11,6 @@ //! All `/api/*` routes (except `/api/auth`) require authentication via either //! an `Authorization: Bearer ` header or a valid `staged_session` cookie. -// The full implementation is preserved here but start() is currently stubbed out, -// so most items appear unused to the compiler. -#![allow(dead_code, unused_imports)] - use std::collections::HashSet; use std::io; use std::net::SocketAddr; @@ -189,14 +185,68 @@ impl Listener for TlsListener { /// Start the Axum web server in a background tokio task. /// -/// Stubbed — logs a warning and returns. The full implementation (TLS listener, -/// Axum router with static file serving) is intentionally disabled in this build. -/// All route handlers, auth middleware, and the `dispatch()` match block are kept -/// compiling so they stay in sync with the rest of the codebase. -/// -/// TODO(web): restore full web server startup from the `mobile-web` branch. -pub fn start(_state: WebAppState) { - log::warn!("Web server requested but this build has the web server stubbed out"); +/// This should be called from the Tauri `setup` hook after all managed state +/// has been registered. +pub fn start(state: WebAppState) { + let token = state.auth_token.clone(); + tauri::async_runtime::spawn(async move { + let dist_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + // In dev, the exe is in src-tauri/target/debug; dist is at ../../dist relative to src-tauri + .map(|p| { + // Try multiple candidate paths for the built frontend + let candidates = vec![ + p.join("../dist"), // production bundle + p.join("../../../../dist"), // dev (target/debug -> src-tauri -> apps/staged -> dist) + PathBuf::from("../dist"), // relative to cwd + ]; + candidates + .into_iter() + .find(|c| c.exists()) + .unwrap_or_else(|| PathBuf::from("../dist")) + }) + .unwrap_or_else(|| PathBuf::from("../dist")); + + // Protected API routes require auth (Bearer token or session cookie) + let api_routes = Router::new() + .route("/api/invoke/{command}", post(invoke_command)) + .route("/api/events", get(ws_events)) + .route_layer(middleware::from_fn_with_state(state.clone(), require_auth)); + + // Auth endpoint is public (it's where you submit the token) + let auth_route = Router::new().route("/api/auth", post(authenticate)); + + let app = api_routes + .merge(auth_route) + .fallback_service(ServeDir::new(&dist_dir).append_index_html_on_directories(true)) + .layer(CorsLayer::permissive()) + .with_state(state); + + let addr = "0.0.0.0:5175"; + let tls_acceptor = match load_tls_acceptor() { + Ok(acceptor) => acceptor, + Err(e) => { + log::error!("[web_server] {e}"); + return; + } + }; + log::info!( + "[web_server] starting HTTPS on {addr}, serving static files from {}", + dist_dir.display() + ); + log::info!("[web_server] web access token: {token}"); + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + log::error!("[web_server] failed to bind {addr}: {e}"); + return; + } + }; + if let Err(e) = axum::serve(TlsListener::new(listener, tls_acceptor), app).await { + log::error!("[web_server] server error: {e}"); + } + }); } // ============================================================================= diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index 9f3c50da5..e5d4609f5 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -7,6 +7,7 @@ -{#if preferences.loaded} +{#if showLogin} + +{:else if preferences.loaded} {#if storeIncompat && storeIncompat.kind === 'needs_reset'}
diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 155777076..cd999e202 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -52,6 +52,15 @@ export interface WorktreeChangesPreview { conflictedPaths: string[]; } +// ============================================================================= +// Web access +// ============================================================================= + +/** Returns the bearer token for web server authentication (Tauri-only). */ +export function getWebAccessToken(): Promise { + return invokeCommand('get_web_access_token'); +} + // ============================================================================= // Store status // ============================================================================= diff --git a/apps/staged/src/lib/features/layout/WebLogin.svelte b/apps/staged/src/lib/features/layout/WebLogin.svelte new file mode 100644 index 000000000..ea10a83e0 --- /dev/null +++ b/apps/staged/src/lib/features/layout/WebLogin.svelte @@ -0,0 +1,137 @@ + + + + + + diff --git a/apps/staged/src/lib/features/settings/SettingsPage.svelte b/apps/staged/src/lib/features/settings/SettingsPage.svelte index d60243766..3e0797155 100644 --- a/apps/staged/src/lib/features/settings/SettingsPage.svelte +++ b/apps/staged/src/lib/features/settings/SettingsPage.svelte @@ -10,9 +10,12 @@ import DoctorSettingsPanel from './DoctorSettingsPanel.svelte'; import GeneralSettingsPanel from './GeneralSettingsPanel.svelte'; import KeyboardSettingsPanel from './KeyboardSettingsPanel.svelte'; - import { isTauri } from '../../transport'; + import { isTauri, writeClipboardText } from '../../transport'; + import * as commands from '../../commands'; let appVersion = $state(__APP_VERSION__); + let webToken = $state(null); + let tokenCopied = $state(false); onMount(async () => { if (!isTauri) return; @@ -23,7 +26,20 @@ } catch (error) { console.warn('[Settings] Could not load runtime app version', error); } + + try { + webToken = await commands.getWebAccessToken(); + } catch { + // web server may not be running + } }); + + async function copyToken() { + if (!webToken) return; + await writeClipboardText(webToken); + tokenCopied = true; + setTimeout(() => (tokenCopied = false), 2000); + } @@ -85,6 +101,18 @@
+ + {#if webToken} +
+ Web Access Token +
+ {webToken.slice(0, 8)}... + +
+
+ {/if}
@@ -268,5 +296,55 @@ .nav-meta { display: none; } + + .web-token-section { + display: none; + } + } + + .web-token-section { + margin-top: auto; + padding: 12px; + border-top: 1px solid var(--border-subtle); + } + + .web-token-label { + font-size: var(--size-xs); + color: var(--text-muted); + display: block; + margin-bottom: 6px; + } + + .web-token-row { + display: flex; + align-items: center; + gap: 8px; + } + + .web-token-value { + font-size: var(--size-xs); + color: var(--text-faint); + background: var(--bg-deepest); + padding: 2px 6px; + border-radius: 3px; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + } + + .web-token-copy { + background: none; + border: 1px solid var(--border-muted); + border-radius: 4px; + color: var(--text-muted); + font-size: var(--size-xs); + padding: 2px 8px; + cursor: pointer; + white-space: nowrap; + } + + .web-token-copy:hover { + color: var(--text-primary); + border-color: var(--border-emphasis); } diff --git a/apps/staged/src/lib/transport.ts b/apps/staged/src/lib/transport.ts index 0f89441b8..e632825fe 100644 --- a/apps/staged/src/lib/transport.ts +++ b/apps/staged/src/lib/transport.ts @@ -6,7 +6,6 @@ * - Event listening (Tauri events vs WebSocket) * - Window management (Tauri window vs no-op) * - Clipboard (Tauri plugin vs navigator.clipboard) - * */ // --------------------------------------------------------------------------- @@ -38,6 +37,11 @@ export async function invokeCommand( body: JSON.stringify(args ?? {}), }); + if (response.status === 401) { + redirectToLogin(); + throw new Error('Authentication required'); + } + if (!response.ok) { const text = await response.text(); let message = text; @@ -55,6 +59,35 @@ export async function invokeCommand( return (await response.json()) as T; } +// --------------------------------------------------------------------------- +// Web authentication +// --------------------------------------------------------------------------- + +let loginRedirectPending = false; + +function redirectToLogin(): void { + if (loginRedirectPending) return; + loginRedirectPending = true; + // Use a small delay to batch multiple 401s that fire simultaneously + setTimeout(() => { + window.location.hash = '#/login'; + loginRedirectPending = false; + }, 50); +} + +/** + * Submit a bearer token to the web server's auth endpoint. + * On success the server sets a session cookie and subsequent requests are authenticated. + */ +export async function submitWebToken(token: string): Promise { + const response = await fetch('/api/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + return response.ok; +} + // --------------------------------------------------------------------------- // Event listening // --------------------------------------------------------------------------- @@ -66,10 +99,10 @@ export type UnlistenFn = () => void; * API; in web mode it connects to the shared WebSocket event stream. * * Returns a synchronous unlisten function. Registration happens asynchronously - * in the background; if the unlisten is called before registration finishes, - * the eventual listener is torn down on arrival. This makes the helper safe to - * use directly in `onMount` cleanup blocks without an intermediate - * `Promise` reference that could race the unmount. + * in the background for Tauri; if the unlisten is called before registration + * finishes, the eventual listener is torn down on arrival. This makes the + * helper safe to use directly in `onMount` cleanup blocks without an + * intermediate `Promise` reference that could race the unmount. */ export function listenToEvent(event: string, callback: (payload: T) => void): UnlistenFn { if (!isTauri) { @@ -250,6 +283,7 @@ interface WindowHandle { const noopWindow: WindowHandle = { show: async () => {}, close: async () => { + // In browser mode, just close the tab/window window.close(); }, startDragging: async () => {}, @@ -258,7 +292,7 @@ const noopWindow: WindowHandle = { /** * Get a handle to the current window. In Tauri mode this returns the real - * Tauri window; in web mode it returns a no-op implementation. + * Tauri window; in web mode it returns a no-op (or limited) implementation. */ export async function getWindow(): Promise { if (isTauri) { @@ -276,6 +310,7 @@ export async function getWindow(): Promise { export function getWindowSync(): WindowHandle { if (!isTauri) return noopWindow; + // Return a proxy that lazily imports the Tauri window API return { show: async () => { const { getCurrentWindow } = await import('@tauri-apps/api/window'); @@ -330,5 +365,6 @@ export async function onDragDropEvent( // eslint-disable-next-line @typescript-eslint/no-explicit-any return getCurrentWebview().onDragDropEvent(callback as any); } + // No-op in web mode — native file drag is a Tauri-only feature return () => {}; } diff --git a/apps/staged/vite.config.ts b/apps/staged/vite.config.ts index ae91f011f..1d4b3b25f 100644 --- a/apps/staged/vite.config.ts +++ b/apps/staged/vite.config.ts @@ -14,6 +14,24 @@ const serviceWorkerCacheHashLength = 12; const packageJson = JSON.parse( readFileSync(resolve(rootDir, 'package.json'), 'utf8') ) as { version: string }; +const webCertPath = process.env.STAGED_WEB_CERT_PATH; +const webKeyPath = process.env.STAGED_WEB_KEY_PATH; +const webHost = process.env.STAGED_WEB_HOST; + +function requireWebPath(name: string, value: string | undefined): string { + if (!value) { + throw new Error(`${name} must be set to enable HTTPS web mode`); + } + return resolve(value); +} + +const webHttps = + webCertPath || webKeyPath + ? { + cert: readFileSync(requireWebPath('STAGED_WEB_CERT_PATH', webCertPath)), + key: readFileSync(requireWebPath('STAGED_WEB_KEY_PATH', webKeyPath)), + } + : undefined; type HashInput = { contents: string | Uint8Array; @@ -123,7 +141,19 @@ export default defineConfig({ }, }, server: { + // Network access (0.0.0.0) is enabled via `--host` in `just dev-web`. + // Default `dev` stays on localhost to avoid exposing the dev server. port, strictPort: true, + https: webHttps, + allowedHosts: webHost ? [webHost] : undefined, + proxy: { + '/api': { + target: `${webHttps ? 'https' : 'http'}://localhost:5175`, + changeOrigin: true, + secure: false, + ws: true, // WebSocket proxy for /api/events + }, + }, }, }); From 1ad881906086008a78248c3ce66de695695d8566 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 22 Jun 2026 17:09:31 +1000 Subject: [PATCH 2/4] feat(staged): add resilient web resume cache Cherry-pick 6606433087b8048c09457523ec1923b8bf97ed01 to add the web resume cache and page lifecycle refresh behavior. Cache command results with stale-while-revalidate semantics, register lifecycle and invalidation listeners, and add tests covering the browser resume path. Signed-off-by: Matt Toohey --- apps/staged/src/lib/commands.test.ts | 143 +++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/apps/staged/src/lib/commands.test.ts b/apps/staged/src/lib/commands.test.ts index 9db0ed4dd..94812113a 100644 --- a/apps/staged/src/lib/commands.test.ts +++ b/apps/staged/src/lib/commands.test.ts @@ -521,3 +521,146 @@ describe('cached mutation command wrappers', () => { }); }); }); + +describe('cached mutation command wrappers', () => { + function deferred() { + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; + } + + let invokeCommand: ReturnType; + let cachedCommand: ReturnType; + let invalidateCache: ReturnType; + let invalidateCacheByCommand: ReturnType; + + beforeEach(() => { + vi.resetModules(); + invokeCommand = vi.fn(); + cachedCommand = vi.fn(); + invalidateCache = vi.fn(); + invalidateCacheByCommand = vi.fn(); + + vi.doMock('./transport', () => ({ + isTauri: false, + invokeCommand, + })); + vi.doMock('./cache', () => ({ + cachedCommand, + cachedInvoke: vi.fn(), + invalidateCache, + invalidateCacheByCommand, + })); + }); + + afterEach(() => { + vi.doUnmock('./transport'); + vi.doUnmock('./cache'); + }); + + it('waits for repo list invalidation before resolving addProjectRepo', async () => { + const repo = { id: 'repo-1' }; + const invalidated = deferred(); + invokeCommand.mockResolvedValue(repo); + invalidateCache.mockReturnValue(invalidated.promise); + + const { addProjectRepo } = await import('./commands'); + + let settled = false; + const result = addProjectRepo('project-1', 'block/builderbot').then((value) => { + settled = true; + return value; + }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(invalidateCache).toHaveBeenCalledWith('list_project_repos', { projectId: 'project-1' }); + expect(settled).toBe(false); + + invalidated.resolve(); + + await expect(result).resolves.toBe(repo); + }); + + it('waits for all project cache invalidations before resolving deleteProject', async () => { + const projectsInvalidated = deferred(); + const branchesInvalidated = deferred(); + const reposInvalidated = deferred(); + invokeCommand.mockResolvedValue(undefined); + invalidateCacheByCommand + .mockReturnValueOnce(projectsInvalidated.promise) + .mockReturnValueOnce(branchesInvalidated.promise) + .mockReturnValueOnce(reposInvalidated.promise); + + const { deleteProject } = await import('./commands'); + + let settled = false; + const result = deleteProject('project-1').then(() => { + settled = true; + }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(invalidateCacheByCommand.mock.calls).toEqual([ + ['list_projects'], + ['list_branches_for_project'], + ['list_project_repos'], + ]); + expect(settled).toBe(false); + + projectsInvalidated.resolve(); + branchesInvalidated.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + + reposInvalidated.resolve(); + + await expect(result).resolves.toBeUndefined(); + }); + + it('bypasses the SWR cache when fetching fresh session messages', async () => { + const messages = [{ id: 1, sessionId: 'session-1', role: 'assistant', content: 'done' }]; + invokeCommand.mockResolvedValue(messages); + + const { getFreshSessionMessages } = await import('./commands'); + + await expect(getFreshSessionMessages('session-1')).resolves.toBe(messages); + expect(invokeCommand).toHaveBeenCalledWith('get_session_messages', { + sessionId: 'session-1', + }); + expect(cachedCommand).not.toHaveBeenCalled(); + }); + + it('uses the standard provider discovery cache by default', async () => { + const providers = [{ id: 'goose', label: 'Goose' }]; + cachedCommand.mockResolvedValue({ data: providers, revalidating: null }); + + const { discoverAcpProviders } = await import('./commands'); + + await expect(discoverAcpProviders()).resolves.toEqual({ + data: providers, + revalidating: null, + }); + expect(cachedCommand).toHaveBeenCalledWith('discover_acp_providers', undefined, { + ttl: 30 * 60_000, + }); + }); + + it('forces provider discovery revalidation without bypassing the cached value', async () => { + const providers = [{ id: 'goose', label: 'Goose' }]; + const revalidating = Promise.resolve([{ id: 'codex', label: 'Codex' }]); + cachedCommand.mockResolvedValue({ data: providers, revalidating }); + + const { discoverAcpProviders } = await import('./commands'); + + await expect(discoverAcpProviders({ force: true })).resolves.toEqual({ + data: providers, + revalidating, + }); + expect(cachedCommand).toHaveBeenCalledWith('discover_acp_providers', undefined, { ttl: 0 }); + }); +}); From aca52a23d9bd0eb9d2e554f3ad183e6879a10b72 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 3 Jul 2026 19:08:44 +1000 Subject: [PATCH 3/4] fix(staged): expose web access token in web dispatch Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/web_server.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 9a2e2f52c..c7a7175dd 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -522,6 +522,7 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result Ok(serde_json::to_value(&state.auth_token).unwrap()), "get_store_status" => { // We don't have DbState in web context — return null (store ready) Ok(Value::Null) From 2816a49364e51443818bc880345a63f88dd0d076 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 16 Jul 2026 10:48:50 +1000 Subject: [PATCH 4/4] feat(staged): remove web access token auth from web mode Unauthenticated HTTPS is enough for browser access, so drop the bearer token / session cookie layer entirely: - Remove the require_auth middleware, /api/auth endpoint, session registry, and token generation from the Axum web server - Remove the WebAccessToken managed state and get_web_access_token Tauri command - Remove the WebLogin token entry screen, #/login hash routing, and 401-redirect handling from the frontend transport layer - Remove the token display/copy section from the settings page - Drop the now-unused axum-extra, rand, hex, subtle, and time crates Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/Cargo.lock | 29 ---- apps/staged/src-tauri/Cargo.toml | 5 - apps/staged/src-tauri/src/lib.rs | 19 --- apps/staged/src-tauri/src/web_server.rs | 119 +-------------- apps/staged/src/App.svelte | 31 +--- apps/staged/src/lib/commands.ts | 9 -- .../src/lib/features/layout/WebLogin.svelte | 137 ------------------ .../lib/features/settings/SettingsPage.svelte | 80 +--------- apps/staged/src/lib/transport.ts | 34 ----- 9 files changed, 4 insertions(+), 459 deletions(-) delete mode 100644 apps/staged/src/lib/features/layout/WebLogin.svelte diff --git a/apps/staged/src-tauri/Cargo.lock b/apps/staged/src-tauri/Cargo.lock index afdae812e..cb6df08b7 100644 --- a/apps/staged/src-tauri/Cargo.lock +++ b/apps/staged/src-tauri/Cargo.lock @@ -11,19 +11,16 @@ dependencies = [ "anyhow", "async-trait", "axum", - "axum-extra", "base64 0.22.1", "blox-cli", "builderbot-actions", "dirs", "doctor", "git-diff", - "hex", "include_dir", "libc", "log", "pikchr", - "rand 0.9.4", "regex", "reqwest", "resvg", @@ -35,7 +32,6 @@ dependencies = [ "serde_json", "sha2 0.11.0", "strip-ansi-escapes", - "subtle", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", @@ -48,7 +44,6 @@ dependencies = [ "tauri-plugin-window-state", "tempfile", "thiserror 2.0.18", - "time", "tiny-skia", "tokio", "tokio-rustls", @@ -482,29 +477,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-extra" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" -dependencies = [ - "axum", - "axum-core", - "bytes", - "cookie", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "serde_core", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "base64" version = "0.21.7" @@ -952,7 +924,6 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ - "percent-encoding", "time", "version_check", ] diff --git a/apps/staged/src-tauri/Cargo.toml b/apps/staged/src-tauri/Cargo.toml index 54c1125f1..6114445d6 100644 --- a/apps/staged/src-tauri/Cargo.toml +++ b/apps/staged/src-tauri/Cargo.toml @@ -65,14 +65,9 @@ tauri-plugin-store = "2.4.2" # MCP server for project sessions rmcp = { version = "0.17", features = ["server", "transport-streamable-http-server"] } axum = { version = "0.8", features = ["ws"] } -axum-extra = { version = "0.10", features = ["cookie"] } tower-http = { version = "0.6", features = ["fs", "cors"] } rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std", "tls12"] } tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] } -rand = "0.9" -hex = "0.4" -subtle = "2.6" -time = "0.3" pikchr = "0.1.4" # Pikchr preview MCP tool: render Pikchr source to SVG (same C engine as the diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index cbd2f53eb..d8d7512dc 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -60,10 +60,6 @@ struct DbState { needs_reset: Mutex>, } -/// Holds the bearer token for web server authentication so it can be -/// retrieved by the frontend (Tauri command) and shown to the user. -struct WebAccessToken(String); - #[derive(Default)] struct ShutdownState { quit_in_progress: AtomicBool, @@ -291,12 +287,6 @@ fn stop_actions_for_app_shutdown(app_handle: &tauri::AppHandle) { // Store status commands // ============================================================================= -/// Returns the bearer token used to authenticate web browser clients. -#[tauri::command] -fn get_web_access_token(token: tauri::State<'_, WebAccessToken>) -> String { - token.0.clone() -} - /// Returns null if the store is ready, or version info if a reset is needed. #[tauri::command] fn get_store_status(db_state: tauri::State<'_, DbState>) -> Option { @@ -2162,18 +2152,10 @@ pub fn run() { .unwrap_or(false); if web_server_enabled { - let auth_token = web_server::generate_token(); - app.manage(WebAccessToken(auth_token.clone())); web_server::start(web_server::WebAppState { app_handle: app.handle().clone(), event_tx, - auth_token, - sessions: std::sync::Arc::new(std::sync::Mutex::new( - std::collections::HashSet::new(), - )), }); - } else { - app.manage(WebAccessToken(String::new())); } if cfg!(debug_assertions) { @@ -2205,7 +2187,6 @@ pub fn run() { } }) .invoke_handler(tauri::generate_handler![ - get_web_access_token, get_store_status, confirm_reset_store, list_projects, diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index c7a7175dd..d25dcfbc3 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -5,13 +5,8 @@ //! //! - `POST /api/invoke/{command}` — dispatches to the same logic as Tauri commands //! - `GET /api/events` — WebSocket that broadcasts Tauri events as JSON -//! - `POST /api/auth` — accepts bearer token and sets session cookie //! - `GET /*` — static files from `../dist` (the built Svelte frontend) -//! -//! All `/api/*` routes (except `/api/auth`) require authentication via either -//! an `Authorization: Bearer ` header or a valid `staged_session` cookie. -use std::collections::HashSet; use std::io; use std::net::SocketAddr; use std::path::PathBuf; @@ -19,19 +14,15 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use axum::extract::ws::{Message, WebSocket}; -use axum::extract::{Path, Query, Request, State, WebSocketUpgrade}; +use axum::extract::{Path, Query, State, WebSocketUpgrade}; use axum::http::StatusCode; -use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Json, Response}; use axum::routing::{get, post}; use axum::serve::Listener; use axum::Router; -use axum_extra::extract::cookie::{Cookie, CookieJar}; -use rand::Rng; use rustls::pki_types::pem::PemObject; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use serde_json::Value; -use subtle::ConstantTimeEq; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::broadcast; use tokio_rustls::server::TlsStream; @@ -58,10 +49,6 @@ use crate::store::{self, Store}; pub struct WebAppState { pub app_handle: tauri::AppHandle, pub event_tx: broadcast::Sender, - /// Hex-encoded 256-bit token required to authenticate web clients. - pub auth_token: String, - /// Set of valid session IDs, one per authenticated client. - pub sessions: Arc>>, } /// A serialized event for WebSocket broadcast. @@ -106,12 +93,6 @@ pub fn emit_to_all( // Server startup // ============================================================================= -/// Generate a cryptographically random hex-encoded token (256-bit). -pub fn generate_token() -> String { - let bytes: [u8; 32] = rand::rng().random(); - hex::encode(bytes) -} - const CERT_PATH_ENV: &str = "STAGED_WEB_CERT_PATH"; const KEY_PATH_ENV: &str = "STAGED_WEB_KEY_PATH"; @@ -188,7 +169,6 @@ impl Listener for TlsListener { /// This should be called from the Tauri `setup` hook after all managed state /// has been registered. pub fn start(state: WebAppState) { - let token = state.auth_token.clone(); tauri::async_runtime::spawn(async move { let dist_dir = std::env::current_exe() .ok() @@ -208,17 +188,9 @@ pub fn start(state: WebAppState) { }) .unwrap_or_else(|| PathBuf::from("../dist")); - // Protected API routes require auth (Bearer token or session cookie) - let api_routes = Router::new() + let app = Router::new() .route("/api/invoke/{command}", post(invoke_command)) .route("/api/events", get(ws_events)) - .route_layer(middleware::from_fn_with_state(state.clone(), require_auth)); - - // Auth endpoint is public (it's where you submit the token) - let auth_route = Router::new().route("/api/auth", post(authenticate)); - - let app = api_routes - .merge(auth_route) .fallback_service(ServeDir::new(&dist_dir).append_index_html_on_directories(true)) .layer(CorsLayer::permissive()) .with_state(state); @@ -235,7 +207,6 @@ pub fn start(state: WebAppState) { "[web_server] starting HTTPS on {addr}, serving static files from {}", dist_dir.display() ); - log::info!("[web_server] web access token: {token}"); let listener = match tokio::net::TcpListener::bind(addr).await { Ok(l) => l, Err(e) => { @@ -249,91 +220,6 @@ pub fn start(state: WebAppState) { }); } -// ============================================================================= -// Authentication -// ============================================================================= - -const SESSION_COOKIE_NAME: &str = "staged_session"; -const SESSION_MAX_AGE_DAYS: i64 = 7; - -/// Constant-time string comparison to prevent timing side-channel attacks. -fn constant_time_eq(a: &str, b: &str) -> bool { - a.as_bytes().ct_eq(b.as_bytes()).into() -} - -/// Middleware that rejects unauthenticated requests to protected routes. -/// -/// Accepts either: -/// - `Authorization: Bearer ` header matching the server's auth token -/// - `staged_session` cookie matching the server's session ID -async fn require_auth( - State(state): State, - jar: CookieJar, - request: Request, - next: Next, -) -> Response { - // Check Authorization header (constant-time comparison to prevent timing attacks) - if let Some(auth_header) = request.headers().get("authorization") { - if let Ok(value) = auth_header.to_str() { - if let Some(token) = value.strip_prefix("Bearer ") { - if constant_time_eq(token, &state.auth_token) { - return next.run(request).await; - } - } - } - } - - // Check session cookie against the set of valid sessions - if let Some(cookie) = jar.get(SESSION_COOKIE_NAME) { - let is_valid = { - let sessions = state.sessions.lock().unwrap_or_else(|e| e.into_inner()); - let cookie_val = cookie.value(); - sessions.iter().any(|s| constant_time_eq(cookie_val, s)) - }; - if is_valid { - return next.run(request).await; - } - } - - (StatusCode::UNAUTHORIZED, "Authentication required").into_response() -} - -/// POST /api/auth — validate the bearer token and issue a session cookie. -/// -/// Expects JSON body: `{ "token": "" }` -async fn authenticate( - State(state): State, - jar: CookieJar, - Json(body): Json, -) -> Response { - let token = body - .get("token") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - - if !constant_time_eq(token, &state.auth_token) { - return (StatusCode::UNAUTHORIZED, "Invalid token").into_response(); - } - - // Generate a unique session ID for this client and register it. - let new_session_id = generate_token(); - state - .sessions - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert(new_session_id.clone()); - - let cookie = Cookie::build((SESSION_COOKIE_NAME, new_session_id)) - .path("/") - .http_only(true) - .secure(true) - .max_age(time::Duration::days(SESSION_MAX_AGE_DAYS)) - .same_site(axum_extra::extract::cookie::SameSite::Lax) - .build(); - - (jar.add(cookie), Json(serde_json::json!({ "ok": true }))).into_response() -} - // ============================================================================= // WebSocket endpoint — /api/events // ============================================================================= @@ -522,7 +408,6 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result Ok(serde_json::to_value(&state.auth_token).unwrap()), "get_store_status" => { // We don't have DbState in web context — return null (store ready) Ok(Value::Null) diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index e5d4609f5..9f3c50da5 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -7,7 +7,6 @@ -{#if showLogin} - -{:else if preferences.loaded} +{#if preferences.loaded} {#if storeIncompat && storeIncompat.kind === 'needs_reset'}
diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index cd999e202..155777076 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -52,15 +52,6 @@ export interface WorktreeChangesPreview { conflictedPaths: string[]; } -// ============================================================================= -// Web access -// ============================================================================= - -/** Returns the bearer token for web server authentication (Tauri-only). */ -export function getWebAccessToken(): Promise { - return invokeCommand('get_web_access_token'); -} - // ============================================================================= // Store status // ============================================================================= diff --git a/apps/staged/src/lib/features/layout/WebLogin.svelte b/apps/staged/src/lib/features/layout/WebLogin.svelte deleted file mode 100644 index ea10a83e0..000000000 --- a/apps/staged/src/lib/features/layout/WebLogin.svelte +++ /dev/null @@ -1,137 +0,0 @@ - - - - - - diff --git a/apps/staged/src/lib/features/settings/SettingsPage.svelte b/apps/staged/src/lib/features/settings/SettingsPage.svelte index 3e0797155..d60243766 100644 --- a/apps/staged/src/lib/features/settings/SettingsPage.svelte +++ b/apps/staged/src/lib/features/settings/SettingsPage.svelte @@ -10,12 +10,9 @@ import DoctorSettingsPanel from './DoctorSettingsPanel.svelte'; import GeneralSettingsPanel from './GeneralSettingsPanel.svelte'; import KeyboardSettingsPanel from './KeyboardSettingsPanel.svelte'; - import { isTauri, writeClipboardText } from '../../transport'; - import * as commands from '../../commands'; + import { isTauri } from '../../transport'; let appVersion = $state(__APP_VERSION__); - let webToken = $state(null); - let tokenCopied = $state(false); onMount(async () => { if (!isTauri) return; @@ -26,20 +23,7 @@ } catch (error) { console.warn('[Settings] Could not load runtime app version', error); } - - try { - webToken = await commands.getWebAccessToken(); - } catch { - // web server may not be running - } }); - - async function copyToken() { - if (!webToken) return; - await writeClipboardText(webToken); - tokenCopied = true; - setTimeout(() => (tokenCopied = false), 2000); - } @@ -101,18 +85,6 @@
- - {#if webToken} -
- Web Access Token -
- {webToken.slice(0, 8)}... - -
-
- {/if}
@@ -296,55 +268,5 @@ .nav-meta { display: none; } - - .web-token-section { - display: none; - } - } - - .web-token-section { - margin-top: auto; - padding: 12px; - border-top: 1px solid var(--border-subtle); - } - - .web-token-label { - font-size: var(--size-xs); - color: var(--text-muted); - display: block; - margin-bottom: 6px; - } - - .web-token-row { - display: flex; - align-items: center; - gap: 8px; - } - - .web-token-value { - font-size: var(--size-xs); - color: var(--text-faint); - background: var(--bg-deepest); - padding: 2px 6px; - border-radius: 3px; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - } - - .web-token-copy { - background: none; - border: 1px solid var(--border-muted); - border-radius: 4px; - color: var(--text-muted); - font-size: var(--size-xs); - padding: 2px 8px; - cursor: pointer; - white-space: nowrap; - } - - .web-token-copy:hover { - color: var(--text-primary); - border-color: var(--border-emphasis); } diff --git a/apps/staged/src/lib/transport.ts b/apps/staged/src/lib/transport.ts index e632825fe..08aa6f351 100644 --- a/apps/staged/src/lib/transport.ts +++ b/apps/staged/src/lib/transport.ts @@ -37,11 +37,6 @@ export async function invokeCommand( body: JSON.stringify(args ?? {}), }); - if (response.status === 401) { - redirectToLogin(); - throw new Error('Authentication required'); - } - if (!response.ok) { const text = await response.text(); let message = text; @@ -59,35 +54,6 @@ export async function invokeCommand( return (await response.json()) as T; } -// --------------------------------------------------------------------------- -// Web authentication -// --------------------------------------------------------------------------- - -let loginRedirectPending = false; - -function redirectToLogin(): void { - if (loginRedirectPending) return; - loginRedirectPending = true; - // Use a small delay to batch multiple 401s that fire simultaneously - setTimeout(() => { - window.location.hash = '#/login'; - loginRedirectPending = false; - }, 50); -} - -/** - * Submit a bearer token to the web server's auth endpoint. - * On success the server sets a session cookie and subsequent requests are authenticated. - */ -export async function submitWebToken(token: string): Promise { - const response = await fetch('/api/auth', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token }), - }); - return response.ok; -} - // --------------------------------------------------------------------------- // Event listening // ---------------------------------------------------------------------------