Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -322,6 +322,7 @@ codewhale resume --last # resume the most recent sessi
codewhale resume <SESSION_ID> # resume a specific session by UUID
codewhale fork <SESSION_ID> # 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 <N> # fetch PR and pre-seed review prompt
codewhale mcp list # list configured MCP servers
Expand Down
3 changes: 2 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 提供内联错误/警告
- **用户记忆** —— 可选的持久化笔记文件注入系统提示,实现跨会话偏好保持
Expand Down Expand Up @@ -305,6 +305,7 @@ codewhale resume --last # 恢复最近会话
codewhale resume <SESSION_ID> # 按 UUID 恢复指定会话
codewhale fork <SESSION_ID> # 将已保存会话分叉为兄弟路径
codewhale serve --http # HTTP/SSE API 服务
codewhale serve --mobile # HTTP/SSE API + 手机控制页
codewhale serve --acp # Zed/自定义智能体的 ACP stdio 适配器
codewhale run pr <N> # 获取 PR 并预填审查提示
codewhale mcp list # 列出已配置 MCP 服务器
Expand Down
18 changes: 14 additions & 4 deletions crates/tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
141 changes: 140 additions & 1 deletion crates/tui/src/runtime_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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};
Expand Down Expand Up @@ -60,6 +61,7 @@ pub struct RuntimeApiState {
auth_required: bool,
bind_host: String,
bind_port: u16,
mobile_enabled: bool,
}

#[derive(Debug, Clone)]
Expand All @@ -78,6 +80,8 @@ pub struct RuntimeApiOptions {
pub auth_token: Option<String>,
/// 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 {
Expand All @@ -89,6 +93,7 @@ impl Default for RuntimeApiOptions {
cors_origins: Vec::new(),
auth_token: None,
insecure_no_auth: false,
mobile: false,
}
}
}
Expand Down Expand Up @@ -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);

Expand All @@ -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.");
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -581,6 +592,68 @@ fn token_from_query(query: Option<&str>) -> Option<&str> {
})
}

async fn mobile_page(State(state): State<RuntimeApiState>) -> 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://<this-machine-ip>:{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<String> {
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<HealthResponse> {
Json(HealthResponse {
status: "ok",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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<String>,
mobile_enabled: bool,
) -> Result<
Option<(
SocketAddr,
SharedRuntimeThreadManager,
tokio::task::JoinHandle<()>,
)>,
> {
fs::create_dir_all(&sessions_dir)?;
let manager = TaskManager::start_with_executor(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading