diff --git a/apps/staged/README.md b/apps/staged/README.md index 2c87703f7..10650ab30 100644 --- a/apps/staged/README.md +++ b/apps/staged/README.md @@ -169,8 +169,14 @@ just ci-local # Pre-push hook when Staged changed: same checks, frontend and R # Maintenance just clean # Remove build artifacts + +# Browser / phone access (opt-in HTTPS server on :5175) +just dev-web # Dev mode plus the web server; Vite proxies /api to it +just run-web # Release build serving the production bundle ``` +`dev-web` and `run-web` need `STAGED_WEB_CERT_PATH` and `STAGED_WEB_KEY_PATH` (PEM files) and, for `dev-web`, `STAGED_WEB_HOST`. The server is **unauthenticated** and binds `0.0.0.0:5175`: anyone who can reach the port can run every command the desktop window can, including repo actions. Only enable it on a network you trust. + ## Architecture ### Backend (Rust / Tauri) diff --git a/apps/staged/justfile b/apps/staged/justfile index b22c85e62..dc8ed3838 100644 --- a/apps/staged/justfile +++ b/apps/staged/justfile @@ -73,6 +73,121 @@ dev repo="": stage-sidecar {{ 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. +# The server is unauthenticated and binds 0.0.0.0:5175: anyone who can reach +# the port can run repo actions. Only use it on a network you trust. +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 and run the release app with the HTTPS web server. Requires PEM cert/key files. +# Same warning as `dev-web`: unauthenticated on 0.0.0.0:5175, anyone who can +# reach the port can run repo actions. +run-web: + #!/usr/bin/env bash + set -euo pipefail + + # Unlike `dev-web` there is no Vite/HMR: the desktop window and the phone + # get the same built bundle, so this is the path that exercises the service + # worker and the production frontend. One Staged process at a time — a + # `just dev`/`dev-web` instance elsewhere shares ~/.staged and the port. + + [[ -d node_modules ]] || pnpm install + + if [[ -z "${STAGED_WEB_CERT_PATH:-}" || -z "${STAGED_WEB_KEY_PATH:-}" ]]; then + printf '%s\n' \ + 'Error: `just run-web` serves browser access over HTTPS.' \ + 'Provide PEM certificate/key files covering the hostname you browse to:' \ + '' \ + ' STAGED_WEB_CERT_PATH=/path/to/cert.pem \' \ + ' STAGED_WEB_KEY_PATH=/path/to/key.pem \' \ + ' just run-web' >&2 + exit 1 + fi + + # `tauri build` runs `pnpm run build` first, so dist/ is always regenerated + # here. The window's assets are compiled into the binary while the server + # reads dist/ from disk — rebuilding both together keeps them in sync. + # `--bundles app` skips the DMG that `just build` would also produce. + pnpm exec tauri build --bundles app + + # Cargo resolves a relative CARGO_TARGET_DIR against its own cwd (src-tauri) + TARGET_DIR="$(pwd)/src-tauri/target" + if [[ -n "${CARGO_TARGET_DIR:-}" ]]; then + case "$CARGO_TARGET_DIR" in + /*) TARGET_DIR="$CARGO_TARGET_DIR" ;; + *) TARGET_DIR="$(pwd)/src-tauri/$CARGO_TARGET_DIR" ;; + esac + fi + + APP_BIN="$TARGET_DIR/release/bundle/macos/Staged.app/Contents/MacOS/Staged" + if [[ ! -x "$APP_BIN" ]]; then + echo "Error: no app bundle at $APP_BIN" >&2 + exit 1 + fi + + export STAGED_WEB_SERVER=1 + + # A bundled release binary cannot find dist/ relative to itself, so point + # the server at it directly rather than relying on the launch cwd. + export STAGED_WEB_DIST="$(pwd)/dist" + + "$APP_BIN" & + APP_PID=$! + trap 'kill "$APP_PID" 2>/dev/null || true' EXIT + + # Release builds install no logger, so web server failures (bad cert path, + # :5175 already bound by another Staged instance) are completely silent. + # Probe the server rather than trusting that the app launched. + STATUS=000 + for _ in $(seq 1 30); do + kill -0 "$APP_PID" 2>/dev/null || break + STATUS=$(curl -sk --max-time 2 -o /dev/null -w '%{http_code}' https://localhost:5175/sw.js) || true + [[ "$STATUS" == "000" ]] || break + sleep 1 + done + + case "$STATUS" in + 2*|3*) + echo "Web server ready on https://${STAGED_WEB_HOST:-$(hostname)}:5175" + ;; + 404) + echo "Web server is up but is serving 404s — check STAGED_WEB_DIST=$STAGED_WEB_DIST holds the built bundle." >&2 + ;; + *) + printf '%s\n' \ + 'Web server never came up on :5175. Release builds log nothing, so check:' \ + ' - STAGED_WEB_CERT_PATH / STAGED_WEB_KEY_PATH point at readable PEM files' \ + ' - no other Staged instance already holds the port' >&2 + ;; + esac + + wait "$APP_PID" || true + # Build the app for production build: pnpm run tauri:build diff --git a/apps/staged/src-tauri/Cargo.lock b/apps/staged/src-tauri/Cargo.lock index d81845a0d..0730fa48e 100644 --- a/apps/staged/src-tauri/Cargo.lock +++ b/apps/staged/src-tauri/Cargo.lock @@ -11,7 +11,6 @@ dependencies = [ "anyhow", "async-trait", "axum", - "axum-extra", "base64 0.23.1", "blox-cli", "builderbot-actions", @@ -534,28 +533,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-extra" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970" -dependencies = [ - "axum", - "axum-core", - "bytes", - "cookie", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "base64" version = "0.21.7" @@ -1041,7 +1018,6 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ - "percent-encoding", "time", "version_check", ] diff --git a/apps/staged/src-tauri/Cargo.toml b/apps/staged/src-tauri/Cargo.toml index 691abe855..56ec4f26c 100644 --- a/apps/staged/src-tauri/Cargo.toml +++ b/apps/staged/src-tauri/Cargo.toml @@ -65,12 +65,13 @@ tauri-plugin-store = "2.5.0" # MCP server for project sessions rmcp = { version = "3.5", features = ["server", "transport-streamable-http-server"] } axum = { version = "0.8", features = ["ws", "http2"] } -axum-extra = { version = "0.12", features = ["cookie"] } -tower-http = { version = "0.7", features = ["fs", "cors", "compression-br", "compression-gzip"] } +tower-http = { version = "0.7", features = ["fs", "compression-br", "compression-gzip"] } 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"] } +# Random tokens (web_server::generate_token, used by the control server) and +# constant-time secret comparison in control_server; rand also backs the +# rate-limit scheduler jitter. rand = "0.10" -hex = "0.4" subtle = "2.6" time = "0.3" pikchr = "0.1.4" @@ -81,9 +82,11 @@ resvg = "0.49" usvg = "0.49" tiny-skia = "0.12" -# Managed Node.js runtime: tarball extraction for runtime installs +# Managed Node.js runtime: tarball extraction and checksum verification for +# runtime installs tar = "0.4" flate2 = "1" +hex = "0.4" # Note image dimensions (note_media::dimensions): header-only pixel size for # every stored raster format, and the PNG pHYs chunk. Both are already in the diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index d7f00dcbb..ace552e7e 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -2213,21 +2213,20 @@ pub fn run() { project_transfer::prune_backups(transfer_store.as_deref()); }); - // 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. + // Once enabled it binds 0.0.0.0:5175 with TLS and no authentication: + // anyone who can reach the port can run every command the desktop + // window can, including repo and branch actions. The only boundary + // is network reachability, so keep it off unless browser access from + // a trusted network is wanted. 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 c988ed5aa..fbb6bc072 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -5,11 +5,20 @@ //! //! - `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) +//! - `GET /*` — static files from the built Svelte frontend +//! (`STAGED_WEB_DIST`, else `dist/` located relative to the executable) //! -//! All `/api/*` routes (except `/api/auth`) require authentication via either -//! an `Authorization: Bearer ` header or a valid `staged_session` cookie. +//! There is no authentication: anyone who can reach the port can run every +//! command the desktop window can, including repo and branch actions. TLS +//! protects the bytes in transit, not who sends them. The server only starts +//! when `STAGED_WEB_SERVER=1` is set, and there is no CORS layer, so a page +//! on another origin cannot drive it from a browser that can reach the host: +//! both `just run-web` (dist served from this origin) and `just dev-web` +//! (Vite proxies `/api` to it) are same-origin. CORS does not cover WebSocket +//! handshakes, so `/api/events` additionally refuses an upgrade whose `Origin` +//! names another origin (see [`ws_origin_allowed`]); without that, a page on +//! any origin could read the event stream. Non-browser clients send no +//! `Origin` and are not restricted — there is no authentication to enforce. //! //! # Handler futures must not own queue state //! @@ -23,11 +32,6 @@ //! spawns it and only awaits the handle; the source-text test //! `web_arms_that_write_running_rows_are_detached` keeps new arms honest. -// 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; @@ -36,18 +40,15 @@ use std::time::Duration; use axum::extract::ws::{Message, WebSocket}; use axum::extract::{DefaultBodyLimit, Path, Query, Request, State, WebSocketUpgrade}; -use axum::http::StatusCode; -use axum::middleware::{self, Next}; +use axum::http::{header, HeaderMap, StatusCode, Uri}; 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::RngExt; use rustls::pki_types::pem::PemObject; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use serde_json::Value; -use subtle::ConstantTimeEq; use tauri::Manager; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::broadcast; @@ -55,7 +56,6 @@ use tokio_rustls::server::TlsStream; use tokio_rustls::TlsAcceptor; use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate}; use tower_http::compression::CompressionLayer; -use tower_http::cors::CorsLayer; use tower_http::services::ServeDir; use crate::actions::{ActionExecutor, ActionRegistry}; @@ -77,10 +77,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. @@ -164,6 +160,10 @@ fn broadcast_web_event( // ============================================================================= /// Generate a cryptographically random hex-encoded token (256-bit). +/// +/// The web server itself is unauthenticated and no longer uses this, but the +/// control server (`control_server::{grants, enrol, consent}`) mints its +/// grant tokens, enrolment nonces and reconnect secrets through it. pub fn generate_token() -> String { let bytes: [u8; 32] = rand::rng().random(); hex::encode(bytes) @@ -171,6 +171,9 @@ pub fn generate_token() -> String { const CERT_PATH_ENV: &str = "STAGED_WEB_CERT_PATH"; const KEY_PATH_ENV: &str = "STAGED_WEB_KEY_PATH"; +/// Directory holding the built frontend. Set by `just run-web`, whose release +/// binary sits in an app bundle from which `dist/` cannot be found. +const DIST_DIR_ENV: &str = "STAGED_WEB_DIST"; fn load_tls_acceptor() -> Result { let cert_path = std::env::var(CERT_PATH_ENV) @@ -245,19 +248,65 @@ impl Listener for TlsListener { } } +/// Where the built frontend lives. `STAGED_WEB_DIST` wins when set (used +/// as given, even if it does not exist yet, so a bad value shows up as 404s +/// from the path that was asked for rather than silently falling back); +/// otherwise the first existing candidate relative to the executable or the +/// cwd. +fn resolve_dist_dir() -> PathBuf { + if let Some(dir) = std::env::var_os(DIST_DIR_ENV).filter(|v| !v.is_empty()) { + return PathBuf::from(dir); + } + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .map(|p| { + let candidates = vec![ + p.join("../dist"), // exe next to a dist/ directory + 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")) +} + /// 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. -/// That restore should serve [`app_router`], which already composes the API -/// routes, [`media_router`] (the frontend's `resolveStagedMediaUrl` emits -/// `/api/media/...` in non-Tauri builds) and the static bundle. -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) { + tauri::async_runtime::spawn(async move { + let dist_dir = resolve_dist_dir(); + + let app = app_router(state, &dist_dir); + + 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() + ); + 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}"); + } + }); } /// The composed app: API routes, note media, and the built frontend from @@ -267,7 +316,9 @@ pub fn start(_state: WebAppState) { /// and a base64-image-heavy one ~3×. The default predicate skips images and /// bodies under 32 bytes, so WebSocket upgrades and media are untouched. pub(crate) fn app_router(state: WebAppState, dist_dir: &std::path::Path) -> Router { - let media_routes = match media_router(state.clone()) { + // Note media is served from `/api/media/...`, which the frontend's + // `resolveStagedMediaUrl` emits in non-Tauri builds. + let media_routes = match media_router(&state) { Ok(router) => router, Err(e) => { log::warn!("[web_server] note media unavailable: {e}"); @@ -281,7 +332,6 @@ pub(crate) fn app_router(state: WebAppState, dist_dir: &std::path::Path) -> Rout .merge(media_routes) .fallback_service(ServeDir::new(dist_dir).append_index_html_on_directories(true)) .layer(compression_layer()) - .layer(CorsLayer::permissive()) } /// Response compression for every route. tower-http's default predicate @@ -325,25 +375,21 @@ where ) } -/// Kept ready for the mobile-web startup restore. The route and auth layer are -/// exercised together by oneshot tests, without starting a listener. -pub(crate) fn media_router(state: WebAppState) -> Result { +/// Note and tool media routes, mounted by [`app_router`]. The routes are +/// exercised by oneshot tests, without starting a listener. +pub(crate) fn media_router(state: &WebAppState) -> Result { let store = get_store( state .app_handle .state::>>>() .inner(), )?; - Ok(media_router_for_store( - store, - state.auth_token, - state.sessions, - )) + Ok(media_router_for_store(store)) } /// Tool and note media are user/agent-supplied bytes served from the app /// origin. Viewed top-level (e.g. "Open image in new tab") an SVG would run -/// script with the session cookie in scope; `sandbox` puts it in an opaque +/// script with this origin's `/api` routes in reach; `sandbox` puts it in an opaque /// origin with script disabled and `default-src 'none'` blocks its subresource /// loads, while `style-src 'unsafe-inline'` keeps its own `