feat(runtime): restore mobile control page

This commit is contained in:
Zhuoran Deng
2026-05-24 10:51:14 +08:00
parent 54151a4bc9
commit a964d86b4b
4 changed files with 703 additions and 15 deletions
+14 -4
View File
@@ -572,6 +572,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,
@@ -926,28 +929,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
+115 -1
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::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<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 {
@@ -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,51 @@ 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={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 detect_lan_ip() -> Option<String> {
let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
socket.connect("8.8.8.8:80").ok()?;
let addr = socket.local_addr().ok()?;
Some(addr.ip().to_string())
}
async fn health() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok",
@@ -1562,6 +1618,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 +1800,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",
@@ -1973,6 +2033,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(
@@ -2035,6 +2110,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 +3676,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 {
+543
View File
@@ -0,0 +1,543 @@
<!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.trim();
}
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;
const result = await api("/v1/approvals/" + encodeURIComponent(approvalId), {
method: "POST",
body: JSON.stringify({ decision, remember })
});
container.innerHTML = "<span class='meta'>Decision sent: " + escapeHtml(result.decision) + "</span>";
}
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.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>
+31 -10
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 [--port 7878] [--auth-token TOKEN]
```
Defaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 18).
@@ -124,16 +125,30 @@ 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`, `/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=<u64>`
@@ -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