diff --git a/apps/staged/justfile b/apps/staged/justfile index 8e1f5bce..0bf7f294 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 a6e003cf..6486ac15 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 c1024ff2..cb6df08b 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", ] @@ -1158,9 +1129,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/Cargo.toml b/apps/staged/src-tauri/Cargo.toml index 54c1125f..6114445d 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 8ad89022..d8d7512d 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -2144,21 +2144,17 @@ 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(); 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(), - )), }); } diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 47f569c0..d25dcfbc 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -5,17 +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. - -// 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; use std::path::PathBuf; @@ -23,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; @@ -62,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. @@ -110,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"; @@ -189,99 +166,58 @@ 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"); -} - -// ============================================================================= -// 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; - } +/// This should be called from the Tauri `setup` hook after all managed state +/// has been registered. +pub fn start(state: WebAppState) { + 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")); + + let app = Router::new() + .route("/api/invoke/{command}", post(invoke_command)) + .route("/api/events", get(ws_events)) + .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; } - } - } - - // 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; + log::info!( + "[web_server] starting HTTPS on {addr}, serving static files from {}", + dist_dir.display() + ); + 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}"); } - } - - (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() + }); } // ============================================================================= diff --git a/apps/staged/src/lib/commands.test.ts b/apps/staged/src/lib/commands.test.ts index 9db0ed4d..94812113 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 }); + }); +}); diff --git a/apps/staged/src/lib/transport.ts b/apps/staged/src/lib/transport.ts index 0f89441b..08aa6f35 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) - * */ // --------------------------------------------------------------------------- @@ -66,10 +65,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 +249,7 @@ interface WindowHandle { const noopWindow: WindowHandle = { show: async () => {}, close: async () => { + // In browser mode, just close the tab/window window.close(); }, startDragging: async () => {}, @@ -258,7 +258,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 +276,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 +331,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 ae91f011..1d4b3b25 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 + }, + }, }, });