diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45c212bd..ec8ae880 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,27 @@ jobs: if: runner.os == 'Linux' run: echo "Linux npm wrapper smoke runs on CNB for mirrored first-party branches." + mobile-smoke: + name: Mobile runtime smoke + if: github.event_name != 'schedule' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Install Linux system dependencies + run: | + for i in 1 2 3 4 5; do + sudo apt-get update && break + echo "apt-get update failed (attempt $i); retrying in 15s" + sleep 15 + done + sudo apt-get install -y libdbus-1-dev pkg-config + - uses: Swatinem/rust-cache@v2 + with: + cache-bin: false + - name: Run mobile smoke tests + run: ./scripts/mobile-smoke.sh + # Check documentation builds without warnings docs: name: Documentation diff --git a/Cargo.lock b/Cargo.lock index a45e42a1..9d614413 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1015,6 +1015,7 @@ dependencies = [ "pdf-extract", "portable-pty", "pretty_assertions", + "qrcode", "ratatui", "regex", "reqwest", @@ -3708,6 +3709,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quick-error" version = "2.0.1" diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 2aa6c7c9..4fd095ec 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -48,6 +48,7 @@ futures-util = "0.3.31" ratatui = "0.30" regex = "1.11" reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "form", "rustls", "http2", "gzip", "brotli"] } +qrcode = { version = "0.14", default-features = false } similar = "2" rustyline = "15.0.0" serde = { version = "1.0.228", features = ["derive"] } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index bf063624..340b3b4e 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -583,6 +583,9 @@ struct ServeArgs { /// Start runtime HTTP/SSE API server with the built-in mobile control page #[arg(long)] mobile: bool, + /// Show a QR code for the mobile URL in the terminal (requires --mobile) + #[arg(long, requires = "mobile")] + qr: bool, /// Start ACP server over stdio for editor clients such as Zed #[arg(long)] acp: bool, @@ -1002,6 +1005,7 @@ async fn main() -> Result<()> { auth_token: args.auth_token, insecure_no_auth: args.insecure_no_auth, mobile: args.mobile, + show_qr: args.qr, }, ) .await diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index be71c0e1..11d2e434 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -82,6 +82,8 @@ pub struct RuntimeApiOptions { pub insecure_no_auth: bool, /// Enables the built-in mobile control page at `/mobile`. pub mobile: bool, + /// Show a QR code for the mobile URL in the terminal. + pub show_qr: bool, } impl Default for RuntimeApiOptions { @@ -94,6 +96,7 @@ impl Default for RuntimeApiOptions { auth_token: None, insecure_no_auth: false, mobile: false, + show_qr: false, } } } @@ -473,7 +476,12 @@ pub async fn run_http_server( println!("Runtime API auth: disabled by explicit insecure mode."); } if options.mobile { - print_mobile_urls(addr, runtime_token.as_deref(), auth_enabled); + print_mobile_urls( + addr, + runtime_token.as_deref(), + auth_enabled, + options.show_qr, + ); } let is_loopback = options.host == "127.0.0.1" || options.host == "::1"; if is_loopback { @@ -669,7 +677,7 @@ async fn mobile_page(State(state): State, req: Request) -> Resp Html(MOBILE_HTML).into_response() } -fn print_mobile_urls(addr: SocketAddr, token: Option<&str>, auth_enabled: bool) { +fn print_mobile_urls(addr: SocketAddr, token: Option<&str>, auth_enabled: bool, show_qr: bool) { println!("Mobile control page enabled."); let token_query = if auth_enabled { token @@ -681,19 +689,36 @@ fn print_mobile_urls(addr: SocketAddr, token: Option<&str>, auth_enabled: bool) }; let port = addr.port(); - if addr.ip().is_unspecified() { + let qr_url = if addr.ip().is_unspecified() { println!(" Local: http://127.0.0.1:{port}/mobile{token_query}"); if let Some(ip) = detect_lan_ip() { - println!(" LAN: http://{ip}:{port}/mobile{token_query}"); + let lan_url = format!("http://{ip}:{port}/mobile{token_query}"); + println!(" LAN: {lan_url}"); + lan_url } else { println!( " LAN: bind is 0.0.0.0; open http://:{port}/mobile{token_query}" ); + format!("http://127.0.0.1:{port}/mobile{token_query}") } } else { - println!(" URL: http://{addr}/mobile{token_query}"); - } + let url = format!("http://{addr}/mobile{token_query}"); + println!(" URL: {url}"); + url + }; println!("Mobile security: use only on a trusted LAN/VPN; this server does not provide TLS."); + + if show_qr { + match qrcode::QrCode::new(qr_url.as_bytes()) { + Ok(qr) => { + let qr_str = qr.render::().module_dimensions(2, 1).build(); + println!("\n{qr_str}"); + } + Err(e) => { + eprintln!("Warning: could not generate QR code: {e}"); + } + } + } } fn url_query_component(value: &str) -> String { diff --git a/scripts/mobile-smoke.sh b/scripts/mobile-smoke.sh new file mode 100755 index 00000000..2b94a898 --- /dev/null +++ b/scripts/mobile-smoke.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# Mobile runtime surface smoke tests. +# Launches the compiled codewhale-tui binary on loopback ports and verifies +# the mobile control page, auth, API routes, and binding behaviour through +# real HTTP requests. +# +# Usage: ./scripts/mobile-smoke.sh +# Requires: curl, a built binary at target/release/codewhale-tui +# (the script will build it if cargo is available). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +BINARY="${BINARY:-${REPO_ROOT}/target/release/codewhale-tui}" +PASS=0 +FAIL=0 +SERVER_PID="" + +# ── helpers ────────────────────────────────────────────────────────────────── + +log() { printf "\033[1;34m>>> %s\033[0m\n" "$*"; } +pass() { printf "\033[1;32m ✓ %s\033[0m\n" "$*"; PASS=$((PASS + 1)); } +fail() { printf "\033[1;31m ✗ %s\033[0m\n" "$*"; FAIL=$((FAIL + 1)); } + +cleanup() { + if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +pick_port() { + # Find a free TCP port on loopback. + python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()' +} + +start_server() { + local port="$1"; shift + log "Starting server on port $port: $*" + "$BINARY" serve --port "$port" "$@" & + SERVER_PID=$! + # Wait for the server to become ready. + for _ in $(seq 1 30); do + if curl -sf "http://127.0.0.1:${port}/health" >/dev/null 2>&1; then + return 0 + fi + sleep 0.3 + done + fail "Server did not become ready on port $port" + cleanup + return 1 +} + +stop_server() { + if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + SERVER_PID="" + fi +} + +# assert_status METHOD PATH [HEADER_NAME:HEADER_VALUE] [JSON_BODY] EXPECTED_STATUS +assert_status() { + local method="$1" path="$2" header="" body="" expected="" + if [[ $# -eq 5 ]]; then + header="$3"; body="$4"; expected="$5" + elif [[ $# -eq 4 ]]; then + header="$3"; expected="$4" + else + expected="$3" + fi + + local url="http://127.0.0.1:${PORT}${path}" + local curl_args=(-sf -o /dev/null -w '%{http_code}' -X "$method") + if [[ -n "$header" ]]; then + curl_args+=(-H "$header") + fi + if [[ -n "$body" ]]; then + curl_args+=(-H "Content-Type: application/json" --data "$body") + fi + + local actual + actual=$(curl "${curl_args[@]}" "$url" 2>/dev/null || true) + + if [[ "$actual" == "$expected" ]]; then + pass "$method $path → $expected" + else + fail "$method $path → expected $expected, got $actual" + fi +} + +# assert_body_contains METHOD PATH HEADER BODY_SUBSTRING +assert_body_contains() { + local method="$1" path="$2" header="$3" substring="$4" + local url="http://127.0.0.1:${PORT}${path}" + local curl_args=(-sf -X "$method") + if [[ -n "$header" ]]; then + curl_args+=(-H "$header") + fi + + local body + body=$(curl "${curl_args[@]}" "$url" 2>/dev/null || true) + + if echo "$body" | grep -q "$substring"; then + pass "$method $path body contains '$substring'" + else + fail "$method $path body missing '$substring'" + fi +} + +# ── build ──────────────────────────────────────────────────────────────────── + +if [[ ! -x "$BINARY" ]]; then + log "Binary not found; building codewhale-tui in release mode..." + cargo build -p codewhale-tui --release --locked +fi + +log "Using binary: $BINARY" + +# ── Test Group 1: Token auth ──────────────────────────────────────────────── + +TOKEN="smoke_test_token_$$" +PORT=$(pick_port) + +log "=== Test Group 1: Token auth ===" +start_server "$PORT" --mobile --auth-token "$TOKEN" + +assert_status GET "/mobile" 401 +assert_body_contains GET "/mobile?token=${TOKEN}" "" "CodeWhale Mobile" +assert_status GET "/v1/threads/summary" 401 +assert_status GET "/v1/threads/summary" "Authorization: Bearer ${TOKEN}" 200 +assert_status POST "/v1/approvals/no_such_id" "Authorization: Bearer ${TOKEN}" '{"decision":"allow"}' 404 + +stop_server + +# ── Test Group 2: Insecure mode ───────────────────────────────────────────── + +PORT=$(pick_port) + +log "=== Test Group 2: Insecure mode (no token) ===" +start_server "$PORT" --mobile --insecure + +assert_body_contains GET "/mobile" "" "CodeWhale Mobile" +assert_status GET "/v1/threads/summary" 200 + +stop_server + +# ── Test Group 3: Binding warnings ────────────────────────────────────────── + +PORT=$(pick_port) + +log "=== Test Group 3: Binding warnings (0.0.0.0 default) ===" +STDOUT_FILE=$(mktemp) +"$BINARY" serve --port "$PORT" --mobile --insecure > "$STDOUT_FILE" 2>&1 & +SERVER_PID=$! +sleep 2 +STDOUT=$(cat "$STDOUT_FILE") +rm -f "$STDOUT_FILE" + +if echo "$STDOUT" | grep -q "0.0.0.0"; then + pass "stdout/stderr contains 0.0.0.0 binding warning" +else + fail "stdout/stderr missing 0.0.0.0 binding warning" +fi + +if echo "$STDOUT" | grep -qi "mobile"; then + pass "stdout contains mobile URL hint" +else + fail "stdout missing mobile URL hint" +fi + +stop_server + +# ── summary ────────────────────────────────────────────────────────────────── + +echo "" +log "Results: $PASS passed, $FAIL failed" + +if [[ "$FAIL" -gt 0 ]]; then + exit 1 +fi