Merge pull request #1968 from axobase001/feat/mobile-remote-control

feat(runtime): restore mobile control page
This commit is contained in:
Hunter Bown
2026-05-30 23:22:11 -07:00
committed by GitHub
6 changed files with 972 additions and 44 deletions
+2 -1
View File
@@ -383,6 +383,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 # LAN mobile control page; token-gated by default
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
@@ -577,7 +578,7 @@ without recreating skills the user deliberately deleted.
| [PROVIDERS.md](docs/PROVIDERS.md) | Provider IDs, auth, model defaults, and capability metadata |
| [MODES.md](docs/MODES.md) | Plan / Agent / YOLO modes |
| [MCP.md](docs/MCP.md) | Model Context Protocol integration |
| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API server |
| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API server and mobile control page |
| [INSTALL.md](docs/INSTALL.md) | Platform-specific install guide |
| [DOCKER.md](docs/DOCKER.md) | GHCR image, volumes, and Docker usage |
| [CNB_MIRROR.md](docs/CNB_MIRROR.md) | CNB mirror and China-friendly install notes |
+2 -1
View File
@@ -322,6 +322,7 @@ codewhale resume --last # 恢复最近会话
codewhale resume <SESSION_ID> # 按 UUID 恢复指定会话
codewhale fork <SESSION_ID> # 将已保存会话分叉为兄弟路径
codewhale serve --http # HTTP/SSE API 服务
codewhale serve --mobile # 局域网移动端控制页,默认启用 token 保护
codewhale serve --acp # Zed/自定义智能体的 ACP stdio 适配器
codewhale run pr <N> # 获取 PR 并预填审查提示
codewhale mcp list # 列出已配置 MCP 服务器
@@ -501,7 +502,7 @@ description: 当 DeepSeek 需要遵循我的自定义工作流时使用这个技
| [CONFIGURATION.md](docs/CONFIGURATION.md) | 完整配置参考 |
| [MODES.md](docs/MODES.md) | Plan / Agent / YOLO 模式 |
| [MCP.md](docs/MCP.md) | Model Context Protocol 集成 |
| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API 服务 |
| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API 服务和移动端控制页 |
| [INSTALL.md](docs/INSTALL.md) | 各平台安装指南 |
| [DOCKER.md](docs/DOCKER.md) | GHCR 镜像、volume 和 Docker 用法 |
| [CNB_MIRROR.md](docs/CNB_MIRROR.md) | CNB 镜像和中国大陆友好安装说明 |
+102 -12
View File
@@ -579,12 +579,15 @@ 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,
/// Bind host for HTTP server (default localhost)
#[arg(long, default_value = "127.0.0.1")]
host: String,
/// Bind host for HTTP server (default localhost; --mobile defaults to 0.0.0.0)
#[arg(long)]
host: Option<String>,
/// Bind port for HTTP server
#[arg(long, default_value_t = 7878)]
port: u16,
@@ -606,6 +609,44 @@ struct ServeArgs {
insecure_no_auth: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ServeBindHost {
host: String,
mobile_rebound_to_lan: bool,
}
fn resolve_serve_bind_host(mobile: bool, host: Option<String>) -> ServeBindHost {
match (mobile, host) {
(true, None) => ServeBindHost {
host: "0.0.0.0".to_string(),
mobile_rebound_to_lan: true,
},
(_, Some(host)) => ServeBindHost {
host,
mobile_rebound_to_lan: false,
},
(false, None) => ServeBindHost {
host: "127.0.0.1".to_string(),
mobile_rebound_to_lan: false,
},
}
}
fn validate_serve_mode_selection(mcp: bool, http: bool, mobile: bool, acp: bool) -> Result<bool> {
if http && mobile {
bail!("--http and --mobile are mutually exclusive; choose one");
}
let http_selected = http || mobile;
let selected_modes = [mcp, http_selected, acp]
.into_iter()
.filter(|selected| *selected)
.count();
if selected_modes != 1 {
bail!("Choose exactly one server mode: --mcp, --http/--mobile, or --acp");
}
Ok(http_selected)
}
#[derive(Subcommand, Debug, Clone)]
enum McpCommand {
/// List configured MCP servers
@@ -936,28 +977,30 @@ 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]
.into_iter()
.filter(|selected| *selected)
.count();
if selected_modes != 1 {
bail!("Choose exactly one server mode: --mcp, --http, or --acp");
}
let http_selected =
validate_serve_mode_selection(args.mcp, args.http, args.mobile, args.acp)?;
if args.mcp {
tokio::task::block_in_place(|| 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 bind_host = resolve_serve_bind_host(args.mobile, args.host);
if bind_host.mobile_rebound_to_lan {
println!(
"WARNING: --mobile is binding to 0.0.0.0 so LAN devices can reach the mobile control page. Use --host 127.0.0.1 to keep mobile loopback-only."
);
}
runtime_api::run_http_server(
config,
workspace,
runtime_api::RuntimeApiOptions {
host: args.host,
host: bind_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
@@ -5657,6 +5700,53 @@ async fn run_exec_agent(
Ok(())
}
#[cfg(test)]
mod serve_bind_host_tests {
use super::*;
#[test]
fn http_defaults_to_loopback() {
assert_eq!(
resolve_serve_bind_host(false, None),
ServeBindHost {
host: "127.0.0.1".to_string(),
mobile_rebound_to_lan: false,
}
);
}
#[test]
fn mobile_default_rebinds_to_lan_with_warning_flag() {
assert_eq!(
resolve_serve_bind_host(true, None),
ServeBindHost {
host: "0.0.0.0".to_string(),
mobile_rebound_to_lan: true,
}
);
}
#[test]
fn mobile_respects_explicit_loopback_host() {
assert_eq!(
resolve_serve_bind_host(true, Some("127.0.0.1".to_string())),
ServeBindHost {
host: "127.0.0.1".to_string(),
mobile_rebound_to_lan: false,
}
);
}
#[test]
fn http_and_mobile_are_mutually_exclusive() {
let err = validate_serve_mode_selection(false, true, true, false).unwrap_err();
assert!(
err.to_string()
.contains("--http and --mobile are mutually exclusive")
);
}
}
#[cfg(test)]
mod doctor_endpoint_tests {
use super::*;
+281 -21
View File
@@ -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::{Path as FsPath, 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};
@@ -59,6 +60,7 @@ pub struct RuntimeApiState {
auth_required: bool,
bind_host: String,
bind_port: u16,
mobile_enabled: bool,
}
#[derive(Debug, Clone)]
@@ -77,6 +79,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 {
@@ -88,6 +92,7 @@ impl Default for RuntimeApiOptions {
cors_origins: Vec::new(),
auth_token: None,
insecure_no_auth: false,
mobile: false,
}
}
}
@@ -443,6 +448,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);
@@ -465,6 +471,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.");
@@ -553,6 +562,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))
@@ -567,8 +578,17 @@ async fn require_runtime_token(
let Some(expected) = state.runtime_token.as_deref() else {
return next.run(req).await;
};
let authorized = req
.headers()
let authorized = request_has_runtime_token(&req, expected);
if authorized {
next.run(req).await
} else {
runtime_token_required_response()
}
}
fn request_has_runtime_token(req: &Request, expected: &str) -> bool {
req.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|raw| raw.strip_prefix("Bearer "))
@@ -578,33 +598,127 @@ async fn require_runtime_token(
.get("x-deepseek-runtime-token")
.and_then(|value| value.to_str().ok())
.is_some_and(|token| token == expected)
|| token_from_query(req.uri().query()).is_some_and(|token| token == expected);
if authorized {
next.run(req).await
} else {
(
StatusCode::UNAUTHORIZED,
Json(json!({
"error": {
"message": "runtime API bearer token required",
"status": StatusCode::UNAUTHORIZED.as_u16(),
}
})),
)
.into_response()
}
|| token_from_query(req.uri().query()).is_some_and(|token| token == expected)
}
fn token_from_query(query: Option<&str>) -> Option<&str> {
fn runtime_token_required_response() -> Response {
(
StatusCode::UNAUTHORIZED,
Json(json!({
"error": {
"message": "runtime API bearer token required",
"status": StatusCode::UNAUTHORIZED.as_u16(),
}
})),
)
.into_response()
}
fn token_from_query(query: Option<&str>) -> Option<String> {
query.and_then(|query| {
query.split('&').find_map(|pair| {
let (key, value) = pair.split_once('=')?;
(key == "token").then_some(value)
(key == "token")
.then(|| percent_decode_query_component(value))
.flatten()
})
})
}
fn percent_decode_query_component(value: &str) -> Option<String> {
let bytes = value.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'%' => {
let hi = *bytes.get(index + 1)?;
let lo = *bytes.get(index + 2)?;
let hi = (hi as char).to_digit(16)? as u8;
let lo = (lo as char).to_digit(16)? as u8;
decoded.push((hi << 4) | lo);
index += 3;
}
b'+' => {
decoded.push(b' ');
index += 1;
}
byte => {
decoded.push(byte);
index += 1;
}
}
}
String::from_utf8(decoded).ok()
}
async fn mobile_page(State(state): State<RuntimeApiState>, req: Request) -> Response {
if !state.mobile_enabled {
return (
StatusCode::NOT_FOUND,
"mobile control is disabled; start with `codewhale serve --mobile`",
)
.into_response();
}
if let Some(expected) = state.runtime_token.as_deref()
&& !request_has_runtime_token(&req, expected)
{
return runtime_token_required_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",
@@ -1647,6 +1761,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
@@ -1871,6 +1987,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",
@@ -2151,6 +2269,23 @@ 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"
);
}
#[test]
fn token_from_query_decodes_percent_encoded_token() {
assert_eq!(
token_from_query(Some("since_seq=0&token=abc%20ABC%2B%2F%3F%3A%3D%26%25")),
Some("abc ABC+/?:=&%".to_string())
);
assert_eq!(token_from_query(Some("token=bad%ZZ")), None);
}
async fn spawn_test_server_with_root(
root: PathBuf,
sessions_dir: PathBuf,
@@ -2174,6 +2309,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(
@@ -2236,6 +2386,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 {
@@ -3801,6 +3952,115 @@ 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 mobile_page_requires_runtime_token_when_auth_enabled() -> Result<()> {
let tmp = tempfile::tempdir()?;
let root = tmp.path().to_path_buf();
let sessions_dir = root.join("sessions");
let token = "abc ABC+/?:=&%".to_string();
let Some((addr, _runtime_threads, handle)) = spawn_test_server_with_root_token_and_mobile(
root,
sessions_dir,
Some(token.clone()),
true,
)
.await?
else {
return Ok(());
};
let client = reqwest::Client::new();
let unauthorized = client.get(format!("http://{addr}/mobile")).send().await?;
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
let encoded = url_query_component(&token);
let query = client
.get(format!("http://{addr}/mobile?token={encoded}"))
.send()
.await?
.error_for_status()?;
assert!(query.text().await?.contains("CodeWhale Mobile"));
let bearer = client
.get(format!("http://{addr}/mobile"))
.bearer_auth(&token)
.send()
.await?
.error_for_status()?;
assert!(bearer.text().await?.contains("CodeWhale Mobile"));
handle.abort();
Ok(())
}
#[tokio::test]
async fn mobile_insecure_mode_allows_page_and_v1_routes_without_token() -> 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, sessions_dir, None, true).await?
else {
return Ok(());
};
let client = reqwest::Client::new();
let page = client
.get(format!("http://{addr}/mobile"))
.send()
.await?
.error_for_status()?;
assert!(page.text().await?.contains("CodeWhale Mobile"));
let summary = client
.get(format!("http://{addr}/v1/threads/summary"))
.send()
.await?
.error_for_status()?;
assert_eq!(summary.status(), StatusCode::OK);
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 {
+549
View File
@@ -0,0 +1,549 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>CodeWhale Mobile</title>
<style>
:root {
color-scheme: dark;
--bg: #101214;
--panel: #181b1f;
--panel-2: #20242a;
--line: #323840;
--text: #f4f1e8;
--muted: #a8a49a;
--accent: #48c2a8;
--accent-2: #c89543;
--danger: #e86458;
--ok: #76c46b;
}
* { box-sizing: border-box; }
html, body { min-height: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
header {
position: sticky;
top: 0;
z-index: 4;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: calc(12px + env(safe-area-inset-top)) 14px 12px;
background: rgba(16, 18, 20, 0.96);
border-bottom: 1px solid var(--line);
backdrop-filter: blur(12px);
}
h1 {
margin: 0;
font-size: 18px;
font-weight: 740;
}
main {
display: grid;
gap: 12px;
padding: 12px 12px calc(14px + env(safe-area-inset-bottom));
}
section {
border: 1px solid var(--line);
background: var(--panel);
border-radius: 8px;
overflow: hidden;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
min-height: 46px;
}
.body { padding: 12px; }
.grid { display: grid; gap: 10px; }
.row { display: flex; align-items: center; gap: 8px; }
.row > * { flex: 1; min-width: 0; }
button, input, textarea, select {
color: var(--text);
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 8px;
font: inherit;
letter-spacing: 0;
}
button {
min-height: 40px;
padding: 8px 11px;
font-weight: 680;
cursor: pointer;
}
button.primary {
background: #173d36;
border-color: #2a8b77;
}
button.warn {
background: #3c2b14;
border-color: #8b682b;
}
button.danger {
background: #421f1d;
border-color: #9b4038;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
input, textarea, select {
width: 100%;
padding: 10px;
}
textarea {
min-height: 96px;
resize: vertical;
}
label.toggle {
display: flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 8px 10px;
color: var(--muted);
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 8px;
font-size: 13px;
}
label.toggle input { width: auto; }
.status {
min-height: 38px;
padding: 9px 10px;
border-radius: 8px;
background: #121518;
border: 1px solid var(--line);
color: var(--muted);
overflow-wrap: anywhere;
}
.status.ok { color: var(--ok); }
.status.bad { color: var(--danger); }
.status.warn { color: var(--accent-2); }
#threads {
display: grid;
gap: 8px;
max-height: 260px;
overflow: auto;
}
.thread {
width: 100%;
padding: 10px;
text-align: left;
border-radius: 8px;
background: var(--panel-2);
}
.thread.active { border-color: var(--accent); }
.thread-title {
font-weight: 720;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta {
margin-top: 3px;
color: var(--muted);
font-size: 12px;
overflow-wrap: anywhere;
}
#events {
display: grid;
gap: 8px;
max-height: 54vh;
overflow: auto;
padding-right: 2px;
}
.event {
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #121518;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.event.agent { border-color: #357969; }
.event.tool { border-color: #8b682b; }
.event.error { border-color: #9b4038; }
.event.ok { border-color: #4d8048; }
.approval-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 9px;
}
.approval-actions button {
flex: 0 0 auto;
min-width: 84px;
}
.empty {
color: var(--muted);
font-size: 13px;
}
@media (min-width: 900px) {
main {
grid-template-columns: 330px minmax(0, 1fr);
align-items: start;
}
section.chat {
grid-column: 2;
grid-row: 1 / span 3;
}
#threads { max-height: 420px; }
#events { max-height: calc(100vh - 150px); }
}
</style>
</head>
<body>
<header>
<h1>CodeWhale Mobile</h1>
<button id="refresh" title="Refresh threads">Refresh</button>
</header>
<main>
<section>
<div class="head">
<strong>Connection</strong>
<button id="save-token">Save</button>
</div>
<div class="body grid">
<input id="token" autocomplete="off" spellcheck="false" placeholder="Runtime token">
<div id="conn" class="status">Not connected</div>
</div>
</section>
<section>
<div class="head">
<strong>Threads</strong>
<button id="new-thread" class="primary">New</button>
</div>
<div class="body"><div id="threads"></div></div>
</section>
<section class="chat">
<div class="head">
<strong id="active-title">No thread selected</strong>
<span id="event-count" class="meta">0 events</span>
</div>
<div class="body"><div id="events"></div></div>
</section>
<section>
<div class="head">
<strong>Composer</strong>
<button id="interrupt" class="danger">Interrupt</button>
</div>
<div class="body grid">
<textarea id="prompt" spellcheck="true" placeholder="Message"></textarea>
<div class="row">
<label class="toggle"><input id="allow-shell" type="checkbox"> shell</label>
<label class="toggle"><input id="auto-approve" type="checkbox"> auto</label>
<label class="toggle"><input id="remember-approval" type="checkbox"> remember</label>
</div>
<div class="row">
<button id="send" class="primary">Send</button>
<button id="steer" class="warn">Steer</button>
</div>
</div>
</section>
</main>
<script>
const $ = (id) => document.getElementById(id);
const state = {
threadId: "",
activeTurnId: "",
source: null,
eventCount: 0
};
function setStatus(message, tone = "") {
const el = $("conn");
el.textContent = message;
el.className = "status" + (tone ? " " + tone : "");
}
function token() {
return $("token").value;
}
function takeTokenFromUrl() {
const params = new URLSearchParams(location.search);
const urlToken = params.get("token");
if (!urlToken) return;
localStorage.setItem("codewhale_runtime_token", urlToken);
params.delete("token");
const qs = params.toString();
history.replaceState(null, "", location.pathname + (qs ? "?" + qs : ""));
}
function headers(extra = {}) {
const out = Object.assign({ "Content-Type": "application/json" }, extra);
if (token()) out.Authorization = "Bearer " + token();
return out;
}
async function api(path, options = {}) {
const res = await fetch(path, Object.assign({}, options, {
headers: headers(options.headers || {})
}));
if (!res.ok) {
let detail = await res.text();
try {
const parsed = JSON.parse(detail);
detail = parsed.error?.message || detail;
} catch (_) {}
throw new Error(detail || ("HTTP " + res.status));
}
if (res.status === 204) return null;
return res.json();
}
function escapeHtml(raw) {
return String(raw).replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#039;"
}[char]));
}
function eventPayload(data) {
return data && typeof data === "object" && "payload" in data ? data.payload : data;
}
function eventText(name, data) {
const payload = eventPayload(data) || {};
if (name === "item.delta") return payload.delta || "";
if (name === "item.started") {
const tool = payload.tool;
if (tool) return "Tool started: " + tool.name + "\n" + JSON.stringify(tool.input || {}, null, 2);
}
if (name === "item.completed" || name === "item.failed") {
const item = payload.item || {};
return (name === "item.completed" ? "Completed: " : "Failed: ") + (item.summary || item.kind || "");
}
if (name === "approval.required") {
return "Approval required: " + (payload.tool_name || "") + "\n" + (payload.description || "");
}
if (name === "approval.decided") {
return "Approval " + (payload.decision || "decided") + ": " + (payload.approval_id || "");
}
if (name === "approval.timeout") {
return "Approval timed out: " + (payload.approval_id || "");
}
if (name === "sandbox.denied") {
return "Sandbox denied: " + (payload.tool_name || "") + "\n" + (payload.reason || "");
}
if (name === "turn.lifecycle") return "Turn status: " + (payload.status || "");
if (name === "turn.completed") return "Turn completed";
if (name === "coherence.state") return JSON.stringify(payload, null, 2);
return JSON.stringify(payload, null, 2);
}
function approvalInfo(name, data) {
if (name !== "approval.required") return null;
const payload = eventPayload(data) || {};
const approvalId = payload.approval_id || payload.id;
if (!approvalId) return null;
return { id: approvalId };
}
async function decideApproval(approvalId, decision, container) {
for (const button of container.querySelectorAll("button")) button.disabled = true;
const remember = $("remember-approval").checked;
try {
const result = await api("/v1/approvals/" + encodeURIComponent(approvalId), {
method: "POST",
body: JSON.stringify({ decision, remember })
});
const decided = result?.decision ?? decision;
container.innerHTML = "<span class='meta'>Decision sent: " + escapeHtml(decided) + "</span>";
} catch (err) {
for (const button of container.querySelectorAll("button")) button.disabled = false;
throw err;
}
}
function appendEvent(name, data) {
const text = eventText(name, data);
if (!text) return;
const item = document.createElement("div");
item.className = "event";
if (name === "item.delta") item.classList.add("agent");
if (name === "item.started" || name.includes("tool")) item.classList.add("tool");
if (name.includes("failed") || name.includes("denied") || name.includes("timeout")) item.classList.add("error");
if (name === "turn.completed" || name === "approval.decided") item.classList.add("ok");
item.innerHTML = "<div class='meta'>" + escapeHtml(name) + "</div>" + escapeHtml(text);
const approval = approvalInfo(name, data);
if (approval) {
const actions = document.createElement("div");
actions.className = "approval-actions";
const allow = document.createElement("button");
allow.className = "primary";
allow.textContent = "Allow";
allow.onclick = () => decideApproval(approval.id, "allow", actions)
.catch((err) => setStatus(err.message, "bad"));
const deny = document.createElement("button");
deny.className = "danger";
deny.textContent = "Deny";
deny.onclick = () => decideApproval(approval.id, "deny", actions)
.catch((err) => setStatus(err.message, "bad"));
actions.appendChild(allow);
actions.appendChild(deny);
item.appendChild(actions);
}
$("events").appendChild(item);
state.eventCount += 1;
$("event-count").textContent = state.eventCount + " events";
$("events").scrollTop = $("events").scrollHeight;
}
async function loadThreads() {
const threads = await api("/v1/threads/summary?limit=60&include_archived=false");
const list = $("threads");
list.innerHTML = "";
if (!threads || !threads.length) {
list.innerHTML = "<div class='empty'>No active threads.</div>";
return;
}
for (const thread of threads) {
const el = document.createElement("button");
el.className = "thread" + (thread.id === state.threadId ? " active" : "");
el.innerHTML =
"<div class='thread-title'>" + escapeHtml(thread.title || thread.id) + "</div>" +
"<div class='meta'>" + escapeHtml(thread.mode || "") + " / " +
escapeHtml(thread.model || "") + "</div>";
el.onclick = () => selectThread(thread.id, thread.title || thread.id);
list.appendChild(el);
}
}
async function newThread() {
const thread = await api("/v1/threads", {
method: "POST",
body: JSON.stringify({
mode: "agent",
allow_shell: $("allow-shell").checked,
trust_mode: false,
auto_approve: $("auto-approve").checked
})
});
await loadThreads();
selectThread(thread.id, thread.title || thread.id);
}
async function selectThread(id, title) {
state.threadId = id;
state.activeTurnId = "";
state.eventCount = 0;
$("event-count").textContent = "0 events";
$("active-title").textContent = title || id;
$("events").innerHTML = "";
if (state.source) state.source.close();
const qs = "?since_seq=0" + (token() ? "&token=" + encodeURIComponent(token()) : "");
const source = new EventSource("/v1/threads/" + encodeURIComponent(id) + "/events" + qs);
state.source = source;
const names = [
"thread.started",
"turn.started",
"turn.lifecycle",
"turn.steered",
"turn.interrupt_requested",
"turn.completed",
"item.started",
"item.delta",
"item.completed",
"item.failed",
"approval.required",
"approval.decided",
"approval.timeout",
"sandbox.denied",
"coherence.state"
];
for (const name of names) {
source.addEventListener(name, (ev) => {
let data = {};
try { data = JSON.parse(ev.data || "{}"); } catch (_) {}
if (data.turn_id) state.activeTurnId = data.turn_id;
appendEvent(name, data);
});
}
source.onopen = () => setStatus("Connected", "ok");
source.onerror = () => setStatus("Event stream disconnected", "warn");
await loadThreads();
}
async function sendPrompt() {
if (!state.threadId) await newThread();
const prompt = $("prompt").value.trim();
if (!prompt) return;
const res = await api("/v1/threads/" + encodeURIComponent(state.threadId) + "/turns", {
method: "POST",
body: JSON.stringify({
prompt,
allow_shell: $("allow-shell").checked,
trust_mode: false,
auto_approve: $("auto-approve").checked
})
});
state.activeTurnId = res.turn?.id || state.activeTurnId;
$("prompt").value = "";
await loadThreads();
}
async function steerTurn() {
if (!state.threadId || !state.activeTurnId) throw new Error("No active turn");
const prompt = $("prompt").value.trim();
if (!prompt) return;
await api(
"/v1/threads/" + encodeURIComponent(state.threadId) +
"/turns/" + encodeURIComponent(state.activeTurnId) + "/steer",
{ method: "POST", body: JSON.stringify({ prompt }) }
);
$("prompt").value = "";
}
async function interruptTurn() {
if (!state.threadId || !state.activeTurnId) throw new Error("No active turn");
await api(
"/v1/threads/" + encodeURIComponent(state.threadId) +
"/turns/" + encodeURIComponent(state.activeTurnId) + "/interrupt",
{ method: "POST", body: "{}" }
);
}
async function boot() {
takeTokenFromUrl();
$("token").value = localStorage.getItem("codewhale_runtime_token") || "";
$("save-token").onclick = async () => {
localStorage.setItem("codewhale_runtime_token", token());
await loadThreads().catch((err) => setStatus(err.message, "bad"));
};
$("refresh").onclick = () => loadThreads().catch((err) => setStatus(err.message, "bad"));
$("new-thread").onclick = () => newThread().catch((err) => setStatus(err.message, "bad"));
$("send").onclick = () => sendPrompt().catch((err) => setStatus(err.message, "bad"));
$("steer").onclick = () => steerTurn().catch((err) => setStatus(err.message, "bad"));
$("interrupt").onclick = () => interruptTurn().catch((err) => setStatus(err.message, "bad"));
await loadThreads();
setStatus("Connected", "ok");
}
boot().catch((err) => setStatus(err.message, "bad"));
</script>
</body>
</html>
+36 -9
View File
@@ -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 [--host 0.0.0.0] [--port 7878] [--auth-token TOKEN]
```
Defaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 18).
@@ -124,16 +125,35 @@ Defaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 18).
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` and `/v1/runtime/info` remain public for local
supervision and bootstrap. `/mobile` returns 404 when mobile mode is disabled;
when mobile mode is enabled and auth is enabled, `/mobile` returns 401 unless
the request supplies the runtime token.
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`, prints a warning, and prints local/LAN
URLs. Pass `--host 127.0.0.1` to keep the mobile page loopback-only. 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 static HTML page contains no secrets, but it is still token-gated when auth
is enabled so unauthenticated LAN clients cannot fingerprint the mobile surface.
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 +208,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=<u64>`
@@ -306,13 +330,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
- **Localhost by default**. The server binds to `127.0.0.1` by default.
`--mobile` binds to `0.0.0.0` when no host is supplied so phones on the same
LAN can reach it, and the CLI prints a warning for that rebind. Pass
`--host 127.0.0.1` for a loopback-only mobile page. 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