diff --git a/README.md b/README.md index 9adcd93fe..c1f33bbc2 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ It is built around DeepSeek V4 (`deepseek-v4-pro` / `deepseek-v4-flash`), includ - **Workspace rollback** — side-git pre/post-turn snapshots with `/restore` and `revert_turn`, without touching your repo's `.git` - **OS-level sandbox** — Seatbelt on macOS, Landlock on Linux, Job Objects on Windows; shell commands run with workspace-scoped filesystem access only - **Durable task queue** — background tasks can survive restarts -- **HTTP/SSE runtime API** — `codewhale serve --http` for headless agent workflows +- **HTTP/SSE runtime API** — `codewhale serve --http` for headless agent workflows, or `codewhale serve --mobile` for the built-in LAN control page - **MCP protocol** — connect to Model Context Protocol servers for extended tooling; please see [docs/MCP.md](docs/MCP.md) - **Native RLM** (`rlm_open`/`rlm_eval`) — persistent REPL sessions for batched analysis; run cheap `deepseek-v4-flash` children with bounded helpers like `peek`, `search`, `chunk`, and `sub_query_batch` - **LSP diagnostics** — inline error/warning surfacing after every edit via rust-analyzer, pyright, typescript-language-server, gopls, clangd @@ -322,6 +322,7 @@ codewhale resume --last # resume the most recent sessi codewhale resume # resume a specific session by UUID codewhale fork # fork a saved session into a sibling path codewhale serve --http # HTTP/SSE API server +codewhale serve --mobile # HTTP/SSE API + mobile control page codewhale serve --acp # ACP stdio adapter for Zed/custom agents codewhale run pr # fetch PR and pre-seed review prompt codewhale mcp list # list configured MCP servers diff --git a/README.zh-CN.md b/README.zh-CN.md index f5824a66b..ac9a6e01c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -84,7 +84,7 @@ codewhale 是一个完全运行在终端里的编程智能体。它让 DeepSeek - **会话保存和恢复** —— 长任务的断点续作 - **工作区回滚** —— 通过 side-git 记录每轮前后快照,支持 `/restore` 和 `revert_turn`,不影响项目自己的 `.git` - **持久化任务队列** —— 后台任务在重启后仍然存在,支持计划任务和长时间运行的操作 -- **HTTP/SSE 运行时 API** —— `codewhale serve --http` 用于无界面智能体流程 +- **HTTP/SSE 运行时 API** —— `codewhale serve --http` 用于无界面智能体流程,`codewhale serve --mobile` 提供内置局域网控制页 - **MCP 协议** —— 连接 Model Context Protocol 服务器扩展工具,见 [docs/MCP.md](docs/MCP.md) - **LSP 诊断** —— 每次编辑后通过 rust-analyzer、pyright、typescript-language-server、gopls、clangd 提供内联错误/警告 - **用户记忆** —— 可选的持久化笔记文件注入系统提示,实现跨会话偏好保持 @@ -305,6 +305,7 @@ codewhale resume --last # 恢复最近会话 codewhale resume # 按 UUID 恢复指定会话 codewhale fork # 将已保存会话分叉为兄弟路径 codewhale serve --http # HTTP/SSE API 服务 +codewhale serve --mobile # HTTP/SSE API + 手机控制页 codewhale serve --acp # Zed/自定义智能体的 ACP stdio 适配器 codewhale run pr # 获取 PR 并预填审查提示 codewhale mcp list # 列出已配置 MCP 服务器 diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 11beeb87b..b0dcc989f 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -508,6 +508,9 @@ struct ServeArgs { /// Start runtime HTTP/SSE API server #[arg(long)] http: bool, + /// Start runtime HTTP/SSE API server with the built-in mobile control page + #[arg(long)] + mobile: bool, /// Start ACP server over stdio for editor clients such as Zed #[arg(long)] acp: bool, @@ -838,28 +841,35 @@ async fn main() -> Result<()> { let workspace = cli.workspace.clone().unwrap_or_else(|| { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) }); - let selected_modes = [args.mcp, args.http, args.acp] + let http_selected = args.http || args.mobile; + let selected_modes = [args.mcp, http_selected, args.acp] .into_iter() .filter(|selected| *selected) .count(); if selected_modes != 1 { - bail!("Choose exactly one server mode: --mcp, --http, or --acp"); + bail!("Choose exactly one server mode: --mcp, --http/--mobile, or --acp"); } if args.mcp { mcp_server::run_mcp_server(workspace) - } else if args.http { + } else if http_selected { let config = load_config_from_cli(&cli)?; let cors_origins = resolve_cors_origins(&config, &args.cors_origin); + let host = if args.mobile && args.host == "127.0.0.1" { + "0.0.0.0".to_string() + } else { + args.host + }; runtime_api::run_http_server( config, workspace, runtime_api::RuntimeApiOptions { - host: args.host, + host, port: args.port, workers: args.workers.clamp(1, 8), cors_origins, auth_token: args.auth_token, insecure_no_auth: args.insecure_no_auth, + mobile: args.mobile, }, ) .await diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 20110cc45..23b260853 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; use std::convert::Infallible; use std::fs; -use std::net::SocketAddr; +use std::net::{SocketAddr, UdpSocket}; use std::path::PathBuf; use std::process::Command; use std::sync::Arc; @@ -14,6 +14,7 @@ use async_stream::stream; use axum::extract::{Path, Query, Request, State}; use axum::http::{HeaderValue, Method, StatusCode, header}; use axum::middleware::{self, Next}; +use axum::response::Html; use axum::response::sse::{Event as SseEvent, KeepAlive, Sse}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; @@ -60,6 +61,7 @@ pub struct RuntimeApiState { auth_required: bool, bind_host: String, bind_port: u16, + mobile_enabled: bool, } #[derive(Debug, Clone)] @@ -78,6 +80,8 @@ pub struct RuntimeApiOptions { pub auth_token: Option, /// Allow `/v1/*` routes without auth when no token is configured. pub insecure_no_auth: bool, + /// Enables the built-in mobile control page at `/mobile`. + pub mobile: bool, } impl Default for RuntimeApiOptions { @@ -89,6 +93,7 @@ impl Default for RuntimeApiOptions { cors_origins: Vec::new(), auth_token: None, insecure_no_auth: false, + mobile: false, } } } @@ -423,6 +428,7 @@ pub async fn run_http_server( auth_required: auth_enabled, bind_host: options.host.clone(), bind_port: options.port, + mobile_enabled: options.mobile, }; let app = build_router(state); @@ -445,6 +451,9 @@ pub async fn run_http_server( } else { println!("Runtime API auth: disabled by explicit insecure mode."); } + if options.mobile { + print_mobile_urls(addr, runtime_token.as_deref(), auth_enabled); + } let is_loopback = options.host == "127.0.0.1" || options.host == "::1"; if is_loopback { println!("Security: this server is local-first. Do not expose it to untrusted networks."); @@ -529,6 +538,8 @@ pub fn build_router(state: RuntimeApiState) -> Router { Router::new() .route("/health", get(health)) + .route("/mobile", get(mobile_page)) + .route("/mobile/", get(mobile_page)) .route("/v1/runtime/info", get(runtime_info)) .merge(api_routes) .layer(cors_layer(&state.cors_origins)) @@ -581,6 +592,68 @@ fn token_from_query(query: Option<&str>) -> Option<&str> { }) } +async fn mobile_page(State(state): State) -> Response { + if !state.mobile_enabled { + return ( + StatusCode::NOT_FOUND, + "mobile control is disabled; start with `codewhale serve --mobile`", + ) + .into_response(); + } + Html(MOBILE_HTML).into_response() +} + +fn print_mobile_urls(addr: SocketAddr, token: Option<&str>, auth_enabled: bool) { + println!("Mobile control page enabled."); + let token_query = if auth_enabled { + token + .filter(|token| !token.trim().is_empty()) + .map(|token| format!("?token={}", url_query_component(token))) + .unwrap_or_default() + } else { + String::new() + }; + + let port = addr.port(); + 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}"); + } else { + println!( + " LAN: bind is 0.0.0.0; open http://:{port}/mobile{token_query}" + ); + } + } else { + println!(" URL: http://{addr}/mobile{token_query}"); + } + println!("Mobile security: use only on a trusted LAN/VPN; this server does not provide TLS."); +} + +fn url_query_component(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + encoded.push(byte as char); + } + _ => { + use std::fmt::Write as _; + let _ = write!(encoded, "%{byte:02X}"); + } + } + } + encoded +} + +fn detect_lan_ip() -> Option { + let socket = UdpSocket::bind("0.0.0.0:0").ok()?; + // UDP connect only selects the outbound interface locally; no packet is sent. + socket.connect("10.255.255.255:1").ok()?; + let addr = socket.local_addr().ok()?; + Some(addr.ip().to_string()) +} + async fn health() -> Json { Json(HealthResponse { status: "ok", @@ -1562,6 +1635,8 @@ fn map_compat_stream_event(event: &crate::runtime_threads::RuntimeEventRecord) - } } "approval.required" => Some(sse_json("approval.required", payload.clone())), + "approval.decided" => Some(sse_json("approval.decided", payload.clone())), + "approval.timeout" => Some(sse_json("approval.timeout", payload.clone())), "sandbox.denied" => Some(sse_json("sandbox.denied", payload.clone())), "turn.completed" => { let usage = payload @@ -1742,6 +1817,8 @@ async fn get_usage( Ok(Json(json!(aggregation))) } +const MOBILE_HTML: &str = include_str!("runtime_mobile.html"); + /// Built-in dev origins always allowed by the runtime API (whalescale#255). const DEFAULT_CORS_ORIGINS: &[&str] = &[ "http://localhost:3000", @@ -1950,6 +2027,14 @@ mod tests { assert!(auth.token.is_some()); } + #[test] + fn url_query_component_percent_encodes_token() { + assert_eq!( + url_query_component("abc ABC+/?:=&%"), + "abc%20ABC%2B%2F%3F%3A%3D%26%25" + ); + } + async fn spawn_test_server_with_root( root: PathBuf, sessions_dir: PathBuf, @@ -1973,6 +2058,21 @@ mod tests { SharedRuntimeThreadManager, tokio::task::JoinHandle<()>, )>, + > { + spawn_test_server_with_root_token_and_mobile(root, sessions_dir, runtime_token, false).await + } + + async fn spawn_test_server_with_root_token_and_mobile( + root: PathBuf, + sessions_dir: PathBuf, + runtime_token: Option, + mobile_enabled: bool, + ) -> Result< + Option<( + SocketAddr, + SharedRuntimeThreadManager, + tokio::task::JoinHandle<()>, + )>, > { fs::create_dir_all(&sessions_dir)?; let manager = TaskManager::start_with_executor( @@ -2035,6 +2135,7 @@ mod tests { auth_required, bind_host: "127.0.0.1".to_string(), bind_port: 0, + mobile_enabled, }; let app = build_router(state); let listener = match TcpListener::bind("127.0.0.1:0").await { @@ -3600,6 +3701,44 @@ mod tests { Ok(()) } + #[tokio::test] + async fn mobile_page_is_available_only_when_enabled() -> Result<()> { + let tmp = tempfile::tempdir()?; + let root = tmp.path().to_path_buf(); + let sessions_dir = root.join("sessions"); + let Some((addr, _runtime_threads, handle)) = spawn_test_server_with_root_token_and_mobile( + root.clone(), + sessions_dir.clone(), + None, + false, + ) + .await? + else { + return Ok(()); + }; + let client = reqwest::Client::new(); + let disabled = client.get(format!("http://{addr}/mobile")).send().await?; + assert_eq!(disabled.status(), StatusCode::NOT_FOUND); + handle.abort(); + + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_root_token_and_mobile(root, sessions_dir, None, true).await? + else { + return Ok(()); + }; + let enabled = client + .get(format!("http://{addr}/mobile")) + .send() + .await? + .error_for_status()?; + let html = enabled.text().await?; + assert!(html.contains("CodeWhale Mobile")); + assert!(html.contains("/v1/approvals/")); + + handle.abort(); + Ok(()) + } + #[tokio::test] async fn decide_approval_404s_when_nothing_pending() -> Result<()> { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { diff --git a/crates/tui/src/runtime_mobile.html b/crates/tui/src/runtime_mobile.html new file mode 100644 index 000000000..f7ca0af03 --- /dev/null +++ b/crates/tui/src/runtime_mobile.html @@ -0,0 +1,543 @@ + + + + + + CodeWhale Mobile + + + +
+

CodeWhale Mobile

+ +
+ +
+
+
+ Connection + +
+
+ +
Not connected
+
+
+ +
+
+ Threads + +
+
+
+ +
+
+ No thread selected + 0 events +
+
+
+ +
+
+ Composer + +
+
+ +
+ + + +
+
+ + +
+
+
+
+ + + + diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 504154f0c..3fdfb7a94 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -117,6 +117,7 @@ codewhale doctor --json ```bash codewhale serve --http [--host 127.0.0.1] [--port 7878] [--workers 2] [--auth-token TOKEN] +codewhale serve --mobile [--port 7878] [--auth-token TOKEN] ``` Defaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 1–8). @@ -124,16 +125,30 @@ Defaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 1–8). The server binds to `localhost` by default. Configuration is via CLI flags — there is no `[app_server]` config section. -By default, existing local behavior is unchanged and `/v1/*` routes are not -authenticated. To require a bearer token for `/v1/*` routes, pass -`--auth-token TOKEN` or set `DEEPSEEK_RUNTIME_TOKEN=TOKEN` before starting the -server. `/health` remains public for local process supervision and readiness -checks. +`/v1/*` routes require a bearer token unless `--insecure` is explicitly set. +Pass `--auth-token TOKEN` or set `DEEPSEEK_RUNTIME_TOKEN=TOKEN` before starting +the server. If neither is set, the process generates a one-time token and prints +it at startup. `/health`, `/mobile`, and `/v1/runtime/info` remain public for +local supervision and bootstrap. Authenticated clients can provide the token as `Authorization: Bearer TOKEN`, `X-DeepSeek-Runtime-Token: TOKEN`, or `?token=TOKEN` for EventSource-style clients that cannot set custom headers. +### Mobile control page + +`codewhale serve --mobile` starts the same HTTP/SSE runtime API and serves a +phone-friendly control page at `/mobile`. When the bind host is left at the +default, mobile mode binds to `0.0.0.0` and prints local/LAN URLs. If a runtime +token is generated or supplied, the printed mobile URL includes it as a query +parameter; the page stores it locally and removes it from the address bar. + +The mobile page can list/create threads, send prompts, follow live SSE events, +steer or interrupt an active turn, and resolve normal tool approvals through +`POST /v1/approvals/{approval_id}`. It is still a local/LAN convenience surface: +do not expose it directly to the public internet without TLS and a trusted +fronting layer. + ### Endpoints **Health** @@ -188,6 +203,10 @@ accept an empty string to clear a previously-set value. Added in v0.8.10 (#562): - `POST /v1/threads/{id}/turns/{turn_id}/interrupt` - `POST /v1/threads/{id}/compact` (manual compaction) +**Approvals** +- `POST /v1/approvals/{approval_id}` with body + `{ "decision": "allow" | "deny", "remember": false }` + **Events** (SSE replay + live stream) - `GET /v1/threads/{id}/events?since_seq=` @@ -306,14 +325,16 @@ The SSE event payload shape: Common event names: `thread.started`, `thread.forked`, `turn.started`, `turn.lifecycle`, `turn.steered`, `turn.interrupt_requested`, `turn.completed`, `item.started`, `item.delta`, `item.completed`, -`item.failed`, `item.interrupted`, `approval.required`, `sandbox.denied`, -`coherence.state`. +`item.failed`, `item.interrupted`, `approval.required`, `approval.decided`, +`approval.timeout`, `sandbox.denied`, `coherence.state`. ## Security boundary -- **Localhost only**. The server binds to `127.0.0.1` by default. Set - `--host 0.0.0.0` only when you have a reverse-proxy / VPN that - authenticates. The runtime does not provide user isolation or TLS. +- **Localhost by default**. The server binds to `127.0.0.1` by default. + `--mobile` binds to `0.0.0.0` when the host is left at the default so phones + on the same LAN can reach it. Set a non-loopback host only when you trust the + network path or have a reverse-proxy / VPN that authenticates. The runtime + does not provide user isolation or TLS. - **Optional token guard**. `--auth-token` or `DEEPSEEK_RUNTIME_TOKEN` requires a matching bearer token for `/v1/*` routes. This is a local convenience guard, not a replacement for TLS, VPN, or a trusted reverse