feat(runtime): restore mobile control page
This commit is contained in:
+14
-4
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"\"": """,
|
||||
"'": "'"
|
||||
}[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
@@ -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=<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
|
||||
|
||||
Reference in New Issue
Block a user