diff --git a/README.md b/README.md index 2c33994d8..c4cf961c2 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,7 @@ deepseek resume --last # resume the most recent sessio deepseek resume # resume a specific session by UUID deepseek fork # fork a session at a chosen turn deepseek serve --http # HTTP/SSE API server +deepseek serve --mobile # phone-friendly local remote control page deepseek serve --acp # ACP stdio adapter for Zed/custom agents deepseek pr # fetch PR and pre-seed review prompt deepseek mcp list # list configured MCP servers diff --git a/README.zh-CN.md b/README.zh-CN.md index ed2b62830..f11be39bd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -209,6 +209,7 @@ deepseek resume --last # 恢复最近会话 deepseek resume # 按 UUID 恢复指定会话 deepseek fork # 在指定轮次分叉会话 deepseek serve --http # HTTP/SSE API 服务 +deepseek serve --mobile # 适配手机的本地远程控制页面 deepseek pr # 获取 PR 并预填审查提示 deepseek mcp list # 列出已配置 MCP 服务器 deepseek mcp validate # 校验 MCP 配置和连接 diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index dcd5a4751..74bf6c99a 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -389,6 +389,9 @@ struct ServeArgs { /// Start runtime HTTP/SSE API server #[arg(long)] http: bool, + /// Start runtime HTTP 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, @@ -407,6 +410,10 @@ struct ServeArgs { /// `[runtime_api] cors_origins` from `config.toml`. Whalescale#255. #[arg(long = "cors-origin", value_name = "URL")] cors_origin: Vec, + /// Bearer token for HTTP runtime API access. If omitted in --mobile mode, + /// a one-time token is generated and printed at startup. + #[arg(long = "auth-token", value_name = "TOKEN")] + auth_token: Option, } #[derive(Subcommand, Debug, Clone)] @@ -694,26 +701,34 @@ 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, + mobile: args.mobile, + auth_token: args.auth_token, }, ) .await diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 1810e17d8..fa9c80050 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; use std::convert::Infallible; use std::fs; -use std::net::SocketAddr; +use std::net::{SocketAddr, UdpSocket}; use std::path::PathBuf; use std::process::Command; use std::sync::Arc; @@ -11,8 +11,10 @@ use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; use async_stream::stream; -use axum::extract::{Path, Query, State}; -use axum::http::{HeaderValue, Method, StatusCode}; +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}; @@ -24,6 +26,7 @@ use tokio::net::TcpListener; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; use tower_http::cors::{Any, CorsLayer}; +use uuid::Uuid; use crate::automation_manager::{ AutomationManager, AutomationRecord, AutomationRunRecord, AutomationSchedulerConfig, @@ -52,6 +55,8 @@ pub struct RuntimeApiState { sessions_dir: PathBuf, mcp_config_path: PathBuf, automations: SharedAutomationManager, + runtime_token: Option, + mobile_enabled: bool, } #[derive(Debug, Clone)] @@ -65,6 +70,11 @@ pub struct RuntimeApiOptions { /// `DEEPSEEK_CORS_ORIGINS` (comma-separated), and `[runtime_api] /// cors_origins` in `config.toml`. Whalescale#255 / #561. pub cors_origins: Vec, + /// Enables the built-in mobile control page at `/mobile`. + pub mobile: bool, + /// Optional bearer token required by API routes. If `mobile` is enabled and + /// this is absent, a one-time process-local token is generated. + pub auth_token: Option, } impl Default for RuntimeApiOptions { @@ -74,6 +84,8 @@ impl Default for RuntimeApiOptions { port: 7878, workers: 2, cors_origins: Vec::new(), + mobile: false, + auth_token: None, } } } @@ -87,6 +99,7 @@ struct StreamTurnRequest { allow_shell: Option, trust_mode: Option, auto_approve: Option, + manual_approval: Option, } #[derive(Debug, Serialize)] @@ -301,6 +314,19 @@ pub async fn run_http_server( .map(|h| h.join(".deepseek").join("sessions")) .unwrap_or_else(|| PathBuf::from(".deepseek").join("sessions")) }); + + let mut runtime_token = options + .auth_token + .clone() + .or_else(|| std::env::var("DEEPSEEK_RUNTIME_TOKEN").ok()) + .filter(|token| !token.trim().is_empty()); + if options.mobile && runtime_token.is_none() { + runtime_token = Some(format!( + "dsrt_{}", + Uuid::new_v4().to_string().replace('-', "") + )); + } + let state = RuntimeApiState { config: config.clone(), workspace, @@ -310,6 +336,13 @@ pub async fn run_http_server( sessions_dir, mcp_config_path: config.mcp_config_path(), automations, + runtime_token, + mobile_enabled: options.mobile, + }; + let mobile_token_for_log = if options.mobile { + state.runtime_token.clone() + } else { + None }; let app = build_router(state); @@ -322,6 +355,12 @@ pub async fn run_http_server( println!("Runtime API listening on http://{addr}"); println!("Security: this server is local-first. Do not expose it to untrusted networks."); + if options.auth_token.is_some() || std::env::var("DEEPSEEK_RUNTIME_TOKEN").is_ok() { + println!("Auth: API routes require a bearer token."); + } + if options.mobile { + print_mobile_urls(addr, mobile_token_for_log.as_deref()); + } let serve_result = axum::serve(listener, app) .await .map_err(|e| anyhow!("Runtime API server error: {e}")); @@ -331,8 +370,7 @@ pub async fn run_http_server( } pub fn build_router(state: RuntimeApiState) -> Router { - Router::new() - .route("/health", get(health)) + let api_routes = Router::new() .route("/v1/sessions", get(list_sessions)) .route("/v1/sessions/{id}", get(get_session).delete(delete_session)) .route( @@ -355,6 +393,18 @@ pub fn build_router(state: RuntimeApiState) -> Router { "/v1/threads/{id}/turns/{turn_id}/interrupt", post(interrupt_thread_turn), ) + .route( + "/v1/threads/{id}/turns/{turn_id}/approvals", + get(list_thread_approvals), + ) + .route( + "/v1/threads/{id}/turns/{turn_id}/approvals/{approval_id}/approve", + post(approve_thread_approval), + ) + .route( + "/v1/threads/{id}/turns/{turn_id}/approvals/{approval_id}/deny", + post(deny_thread_approval), + ) .route("/v1/threads/{id}/compact", post(compact_thread)) .route("/v1/threads/{id}/events", get(stream_thread_events)) .route("/v1/tasks", get(list_tasks).post(create_task)) @@ -378,6 +428,16 @@ pub fn build_router(state: RuntimeApiState) -> Router { .route("/v1/automations/{id}/resume", post(resume_automation)) .route("/v1/automations/{id}/runs", get(list_automation_runs)) .route("/v1/usage", get(get_usage)) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_runtime_token, + )); + + Router::new() + .route("/health", get(health)) + .route("/mobile", get(mobile_page)) + .route("/mobile/", get(mobile_page)) + .merge(api_routes) .layer(cors_layer(&state.cors_origins)) .with_state(state) } @@ -390,6 +450,108 @@ async fn health() -> Json { }) } +async fn mobile_page(State(state): State) -> Response { + if !state.mobile_enabled { + return ( + StatusCode::NOT_FOUND, + "mobile control is disabled; start with `deepseek serve --mobile`", + ) + .into_response(); + } + Html(MOBILE_HTML).into_response() +} + +async fn require_runtime_token( + State(state): State, + request: Request, + next: Next, +) -> Response { + let Some(expected) = state.runtime_token.as_deref() else { + return next.run(request).await; + }; + + let header_token = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|raw| raw.strip_prefix("Bearer ")) + .map(str::trim) + .or_else(|| { + request + .headers() + .get("x-deepseek-runtime-token") + .and_then(|value| value.to_str().ok()) + .map(str::trim) + }); + let query_token = token_from_query(request.uri().query()); + + let authorized = header_token.is_some_and(|token| token == expected) + || query_token + .as_deref() + .is_some_and(|token| token == expected); + + if authorized { + next.run(request).await + } else { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": { + "message": "runtime API token required", + "status": StatusCode::UNAUTHORIZED.as_u16(), + } + })), + ) + .into_response() + } +} + +fn token_from_query(query: Option<&str>) -> Option { + let query = query?; + for pair in query.split('&') { + let mut parts = pair.splitn(2, '='); + if parts.next()? != "token" { + continue; + } + let value = parts.next().unwrap_or_default().trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + None +} + +fn print_mobile_urls(addr: SocketAddr, token: Option<&str>) { + let token_query = token + .filter(|t| !t.trim().is_empty()) + .map(|t| format!("?token={t}")) + .unwrap_or_default(); + println!("Mobile control page enabled."); + println!("Mobile auth: bearer token required for API routes."); + + let port = addr.port(); + if addr.ip().is_unspecified() { + println!(" Local: http://127.0.0.1:{port}/mobile{token_query}"); + if let Some(ip) = detect_lan_ip() { + println!(" LAN: http://{ip}:{port}/mobile{token_query}"); + } else { + println!( + " LAN: bind is 0.0.0.0; open http://:{port}/mobile{token_query}" + ); + } + } else { + println!(" URL: http://{addr}/mobile{token_query}"); + } + println!("Do not expose this port directly to the public internet."); +} + +fn detect_lan_ip() -> Option { + 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 list_sessions( State(state): State, Query(query): Query, @@ -979,6 +1141,42 @@ async fn interrupt_thread_turn( Ok(Json(turn)) } +async fn list_thread_approvals( + State(state): State, + Path((id, turn_id)): Path<(String, String)>, +) -> Result>, ApiError> { + let approvals = state + .runtime_threads + .list_pending_approvals(&id, &turn_id) + .await + .map_err(map_thread_err)?; + Ok(Json(approvals)) +} + +async fn approve_thread_approval( + State(state): State, + Path((id, turn_id, approval_id)): Path<(String, String, String)>, +) -> Result, ApiError> { + let approval = state + .runtime_threads + .approve_pending_approval(&id, &turn_id, &approval_id) + .await + .map_err(map_thread_err)?; + Ok(Json(approval)) +} + +async fn deny_thread_approval( + State(state): State, + Path((id, turn_id, approval_id)): Path<(String, String, String)>, +) -> Result, ApiError> { + let approval = state + .runtime_threads + .deny_pending_approval(&id, &turn_id, &approval_id) + .await + .map_err(map_thread_err)?; + Ok(Json(approval)) +} + async fn compact_thread( State(state): State, Path(id): Path, @@ -1137,6 +1335,7 @@ async fn stream_turn( allow_shell: Some(allow_shell), trust_mode: Some(trust_mode), auto_approve: Some(auto_approve), + manual_approval: req.manual_approval, }, ) .await @@ -1468,6 +1667,427 @@ async fn get_usage( Ok(Json(json!(aggregation))) } +const MOBILE_HTML: &str = r#" + + + + + DeepSeek Mobile Remote + + + +
+

DeepSeek Mobile Remote

+
Local-first remote control for the runtime API
+
+
+
+
+ Connection + +
+
+ +
Not connected
+
+
+ +
+
+ Threads + +
+
+
+ +
+
+ No thread selected + +
+
+
+ +
+
+ Composer + +
+
+ +
+ + +
+
+ + +
+

Keep auto approve off unless this server is on a trusted network.

+
+
+
+ + + +"#; + /// Built-in dev origins always allowed by the runtime API (whalescale#255). const DEFAULT_CORS_ORIGINS: &[&str] = &[ "http://localhost:3000", @@ -1695,6 +2315,8 @@ mod tests { sessions_dir, mcp_config_path: root.join("mcp.json"), automations, + runtime_token: None, + mobile_enabled: false, }; let app = build_router(state); let listener = match TcpListener::bind("127.0.0.1:0").await { diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index f1598cbee..2c89d23c3 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -563,6 +563,8 @@ pub struct StartTurnRequest { pub allow_shell: Option, pub trust_mode: Option, pub auto_approve: Option, + #[serde(default)] + pub manual_approval: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -576,6 +578,14 @@ pub struct CompactThreadRequest { pub reason: Option, } +#[derive(Debug, Clone, Serialize)] +pub struct PendingApprovalRecord { + pub id: String, + pub tool_name: String, + pub description: String, + pub requires_full_access: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ThreadDetail { pub thread: ThreadRecord, @@ -638,12 +648,33 @@ fn provider_label_for_model(model: &str) -> &'static str { } } +#[derive(Debug, Clone)] +struct PendingApprovalState { + id: String, + tool_name: String, + description: String, + requires_full_access: bool, +} + +impl PendingApprovalState { + fn into_record(self) -> PendingApprovalRecord { + PendingApprovalRecord { + id: self.id, + tool_name: self.tool_name, + description: self.description, + requires_full_access: self.requires_full_access, + } + } +} + #[derive(Debug, Clone)] struct ActiveTurnState { turn_id: String, interrupt_requested: bool, auto_approve: bool, trust_mode: bool, + manual_approval: bool, + pending_approvals: HashMap, } #[derive(Clone)] @@ -1452,6 +1483,8 @@ impl RuntimeThreadManager { interrupt_requested: false, auto_approve: req.auto_approve.unwrap_or(thread.auto_approve), trust_mode: req.trust_mode.unwrap_or(thread.trust_mode), + manual_approval: req.manual_approval.unwrap_or(false), + pending_approvals: HashMap::new(), }); touch_lru(&mut active.lru, thread_id); } @@ -1650,6 +1683,131 @@ impl RuntimeThreadManager { Ok(turn) } + pub async fn list_pending_approvals( + &self, + thread_id: &str, + turn_id: &str, + ) -> Result> { + let active = self.active.lock().await; + let Some(active_thread) = active.engines.get(thread_id) else { + bail!("Thread is not loaded"); + }; + let Some(active_turn) = active_thread.active_turn.as_ref() else { + bail!("No active turn on thread {thread_id}"); + }; + if active_turn.turn_id != turn_id { + bail!("Turn {turn_id} is not active on thread {thread_id}"); + } + Ok(active_turn + .pending_approvals + .values() + .cloned() + .map(PendingApprovalState::into_record) + .collect()) + } + + pub async fn approve_pending_approval( + &self, + thread_id: &str, + turn_id: &str, + approval_id: &str, + ) -> Result { + let (engine, pending) = { + let mut active = self.active.lock().await; + let Some(active_thread) = active.engines.get_mut(thread_id) else { + bail!("Thread is not loaded"); + }; + let Some(active_turn) = active_thread.active_turn.as_mut() else { + bail!("No active turn on thread {thread_id}"); + }; + if active_turn.turn_id != turn_id { + bail!("Turn {turn_id} is not active on thread {thread_id}"); + } + let Some(pending) = active_turn.pending_approvals.remove(approval_id) else { + bail!("Approval {approval_id} is not pending on turn {turn_id}"); + }; + let engine = active_thread.engine.clone(); + touch_lru(&mut active.lru, thread_id); + (engine, pending) + }; + + let record = pending.clone().into_record(); + if pending.requires_full_access { + engine + .retry_tool_with_policy( + pending.id.clone(), + crate::sandbox::SandboxPolicy::DangerFullAccess, + ) + .await + .map_err(|e| anyhow!("Failed to retry tool with full access: {e}"))?; + } else { + engine + .approve_tool_call(pending.id.clone()) + .await + .map_err(|e| anyhow!("Failed to approve tool call: {e}"))?; + } + self.emit_event( + thread_id, + Some(turn_id), + None, + "approval.resolved", + json!({ + "id": record.id.clone(), + "tool_name": record.tool_name.clone(), + "requires_full_access": record.requires_full_access, + "decision": if record.requires_full_access { "retry_full_access" } else { "approved" }, + }), + ) + .await?; + Ok(record) + } + + pub async fn deny_pending_approval( + &self, + thread_id: &str, + turn_id: &str, + approval_id: &str, + ) -> Result { + let (engine, pending) = { + let mut active = self.active.lock().await; + let Some(active_thread) = active.engines.get_mut(thread_id) else { + bail!("Thread is not loaded"); + }; + let Some(active_turn) = active_thread.active_turn.as_mut() else { + bail!("No active turn on thread {thread_id}"); + }; + if active_turn.turn_id != turn_id { + bail!("Turn {turn_id} is not active on thread {thread_id}"); + } + let Some(pending) = active_turn.pending_approvals.remove(approval_id) else { + bail!("Approval {approval_id} is not pending on turn {turn_id}"); + }; + let engine = active_thread.engine.clone(); + touch_lru(&mut active.lru, thread_id); + (engine, pending) + }; + + let record = pending.clone().into_record(); + engine + .deny_tool_call(pending.id.clone()) + .await + .map_err(|e| anyhow!("Failed to deny tool call: {e}"))?; + self.emit_event( + thread_id, + Some(turn_id), + None, + "approval.resolved", + json!({ + "id": record.id.clone(), + "tool_name": record.tool_name.clone(), + "requires_full_access": record.requires_full_access, + "decision": "denied", + }), + ) + .await?; + Ok(record) + } + pub async fn compact_thread( &self, thread_id: &str, @@ -1704,6 +1862,8 @@ impl RuntimeThreadManager { interrupt_requested: false, auto_approve: thread.auto_approve, trust_mode: thread.trust_mode, + manual_approval: false, + pending_approvals: HashMap::new(), }); touch_lru(&mut active.lru, thread_id); } @@ -2435,6 +2595,24 @@ impl RuntimeThreadManager { description, .. } => { + let (auto_approve, trust_mode, manual_approval) = self + .active_turn_policy(&thread_id, &turn_id) + .await + .unwrap_or((false, false, false)); + let pending = manual_approval && !auto_approve; + if pending { + self.register_pending_approval( + &thread_id, + &turn_id, + PendingApprovalState { + id: id.clone(), + tool_name: tool_name.clone(), + description: description.clone(), + requires_full_access: false, + }, + ) + .await?; + } self.emit_event( &thread_id, Some(&turn_id), @@ -2444,14 +2622,14 @@ impl RuntimeThreadManager { "id": id, "tool_name": tool_name, "description": description, + "pending": pending, }), ) .await?; - let (auto_approve, trust_mode) = self - .active_turn_flags(&thread_id, &turn_id) - .await - .unwrap_or((false, false)); + if pending { + continue; + } match Self::approval_decision(auto_approve, trust_mode, false) { RuntimeApprovalDecision::ApproveTool => { let _ = engine.approve_tool_call(id).await; @@ -2468,6 +2646,24 @@ impl RuntimeThreadManager { denial_reason, .. } => { + let (auto_approve, trust_mode, manual_approval) = self + .active_turn_policy(&thread_id, &turn_id) + .await + .unwrap_or((false, false, false)); + let pending = manual_approval && !auto_approve; + if pending { + self.register_pending_approval( + &thread_id, + &turn_id, + PendingApprovalState { + id: tool_id.clone(), + tool_name: tool_name.clone(), + description: denial_reason.clone(), + requires_full_access: true, + }, + ) + .await?; + } self.emit_event( &thread_id, Some(&turn_id), @@ -2477,13 +2673,13 @@ impl RuntimeThreadManager { "tool_id": tool_id, "tool_name": tool_name, "reason": denial_reason, + "pending": pending, }), ) .await?; - let (auto_approve, trust_mode) = self - .active_turn_flags(&thread_id, &turn_id) - .await - .unwrap_or((false, false)); + if pending { + continue; + } match Self::approval_decision(auto_approve, trust_mode, true) { RuntimeApprovalDecision::RetryWithFullAccess => { let _ = engine @@ -2664,14 +2860,47 @@ impl RuntimeThreadManager { Ok(turn.turn_id == turn_id && turn.interrupt_requested) } + #[cfg(test)] async fn active_turn_flags(&self, thread_id: &str, turn_id: &str) -> Option<(bool, bool)> { + self.active_turn_policy(thread_id, turn_id) + .await + .map(|policy| (policy.0, policy.1)) + } + + async fn active_turn_policy( + &self, + thread_id: &str, + turn_id: &str, + ) -> Option<(bool, bool, bool)> { let active = self.active.lock().await; let state = active.engines.get(thread_id)?; let turn = state.active_turn.as_ref()?; if turn.turn_id != turn_id { return None; } - Some((turn.auto_approve, turn.trust_mode)) + Some((turn.auto_approve, turn.trust_mode, turn.manual_approval)) + } + + async fn register_pending_approval( + &self, + thread_id: &str, + turn_id: &str, + pending: PendingApprovalState, + ) -> Result<()> { + let mut active = self.active.lock().await; + let Some(active_thread) = active.engines.get_mut(thread_id) else { + bail!("Thread is not loaded"); + }; + let Some(active_turn) = active_thread.active_turn.as_mut() else { + bail!("No active turn on thread {thread_id}"); + }; + if active_turn.turn_id != turn_id { + bail!("Turn {turn_id} is not active on thread {thread_id}"); + } + active_turn + .pending_approvals + .insert(pending.id.clone(), pending); + Ok(()) } fn approval_decision( @@ -3147,6 +3376,8 @@ mod tests { interrupt_requested: false, auto_approve: true, trust_mode: false, + manual_approval: false, + pending_approvals: HashMap::new(), }), }, ); @@ -3159,6 +3390,8 @@ mod tests { interrupt_requested: false, auto_approve: true, trust_mode: false, + manual_approval: false, + pending_approvals: HashMap::new(), }), }, ); @@ -3340,6 +3573,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: None, + manual_approval: None, }, ) .await?; @@ -3425,6 +3659,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: Some(true), + manual_approval: None, }, ) .await?; @@ -3468,6 +3703,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: Some(false), + manual_approval: None, }, ) .await?; @@ -3635,6 +3871,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: None, + manual_approval: None, }, ) .await?; @@ -3652,6 +3889,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: None, + manual_approval: None, }, ) .await?; @@ -3739,6 +3977,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: None, + manual_approval: None, }, ) .await?; @@ -3777,6 +4016,102 @@ mod tests { Ok(()) } + #[tokio::test] + async fn manual_approval_waits_until_api_resolution() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: None, + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: Some(false), + archived: false, + system_prompt: None, + task_id: None, + }) + .await?; + + let mut harness = install_mock_engine(&manager, &thread.id).await; + let turn = manager + .start_turn( + &thread.id, + StartTurnRequest { + prompt: "needs manual approval".to_string(), + input_summary: None, + model: None, + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: Some(false), + manual_approval: Some(true), + }, + ) + .await?; + + assert!(matches!( + harness.rx_op.recv().await, + Some(Op::SendMessage { .. }) + )); + + harness + .tx_event + .send(EngineEvent::ApprovalRequired { + approval_key: "test_key".to_string(), + id: "tool_manual".to_string(), + tool_name: "exec_command".to_string(), + description: "manual approval".to_string(), + }) + .await?; + + assert!( + tokio::time::timeout(Duration::from_millis(50), harness.recv_approval_event()) + .await + .is_err(), + "manual approval should not auto-deny before API resolution" + ); + + let pending = manager.list_pending_approvals(&thread.id, &turn.id).await?; + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, "tool_manual"); + assert!(!pending[0].requires_full_access); + + let resolved = manager + .approve_pending_approval(&thread.id, &turn.id, "tool_manual") + .await?; + assert_eq!(resolved.id, "tool_manual"); + assert_eq!( + harness.recv_approval_event().await, + Some(MockApprovalEvent::Approved { + id: "tool_manual".to_string(), + }) + ); + assert!( + manager + .list_pending_approvals(&thread.id, &turn.id) + .await? + .is_empty() + ); + + harness + .tx_event + .send(EngineEvent::TurnComplete { + usage: Usage { + input_tokens: 0, + output_tokens: 0, + ..Usage::default() + }, + status: TurnOutcomeStatus::Completed, + error: None, + }) + .await?; + + let terminal = wait_for_terminal_turn(&manager, &turn.id, Duration::from_secs(2)).await?; + assert_eq!(terminal.status, RuntimeTurnStatus::Completed); + Ok(()) + } + #[tokio::test] async fn approval_required_with_stale_active_turn_is_denied() -> Result<()> { let manager = test_manager(test_runtime_dir())?; @@ -3806,6 +4141,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: Some(true), + manual_approval: None, }, ) .await?; @@ -3887,6 +4223,7 @@ mod tests { allow_shell: None, trust_mode: Some(true), auto_approve: Some(true), + manual_approval: None, }, ) .await?; @@ -4010,6 +4347,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: None, + manual_approval: None, }, ) .await?; @@ -4156,6 +4494,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: None, + manual_approval: None, }, ) .await?; diff --git a/crates/tui/src/task_manager.rs b/crates/tui/src/task_manager.rs index e19d146e4..a3b6ecebf 100644 --- a/crates/tui/src/task_manager.rs +++ b/crates/tui/src/task_manager.rs @@ -466,6 +466,7 @@ impl TaskExecutor for EngineTaskExecutor { allow_shell: Some(task.allow_shell), trust_mode: Some(task.trust_mode), auto_approve: Some(task.auto_approve), + manual_approval: None, }, ) .await diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index eab389118..01861f69e 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -123,6 +123,27 @@ 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. +### Mobile control page + +```bash +deepseek serve --mobile [--port 7878] +``` + +`--mobile` starts the same HTTP/SSE runtime API and serves a phone-friendly +control page at `/mobile`. When `--host` is not supplied, mobile mode binds to +`0.0.0.0` so a phone on the same LAN can open the printed URL. + +Mobile mode requires an API bearer token. Use `--auth-token ` or +`DEEPSEEK_RUNTIME_TOKEN=` to provide one. If neither is set, the process +generates a one-time token and prints a local/LAN URL that includes it. + +The mobile page can list/create threads, send prompts, follow live SSE events, +steer an active turn, interrupt an active turn, and optionally pass +`allow_shell` / `auto_approve` flags for a turn. With `auto_approve` off, the +mobile page submits turns with `manual_approval=true` and shows Allow / Deny +buttons for runtime tool approvals. Keep `auto_approve` off unless the server +is reachable only from a trusted network. + ### Endpoints **Health** @@ -168,6 +189,9 @@ accept an empty string to clear a previously-set value. Added in v0.8.10 (#562): - `POST /v1/threads/{id}/turns` - `POST /v1/threads/{id}/turns/{turn_id}/steer` - `POST /v1/threads/{id}/turns/{turn_id}/interrupt` +- `GET /v1/threads/{id}/turns/{turn_id}/approvals` +- `POST /v1/threads/{id}/turns/{turn_id}/approvals/{approval_id}/approve` +- `POST /v1/threads/{id}/turns/{turn_id}/approvals/{approval_id}/deny` - `POST /v1/threads/{id}/compact` (manual compaction) **Events** (SSE replay + live stream) @@ -265,6 +289,13 @@ Events are append-only with a global monotonic `seq` for replay/resume. are auto-approved in the non-interactive runtime path, shell safety checks run in auto-approved mode, and spawned sub-agents inherit that setting. - When omitted, `auto_approve` defaults to `false`. +- `manual_approval=true` can be supplied on `POST /v1/threads/{id}/turns`. + When `auto_approve=false`, approval-required tools are held as pending + approvals instead of being immediately denied. Clients can list pending + approvals and resolve them through the approval endpoints above. +- Approving a regular pending approval calls the engine approval bridge. + Approving a pending sandbox elevation retries that tool with full-access + sandbox policy. Denying either kind sends the normal deny decision. ### SSE event stream @@ -288,14 +319,17 @@ 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.resolved`, +`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 — there is no built-in auth, user isolation, or TLS. + authenticates. The runtime does not provide user isolation or TLS. +- **Mobile mode token**. `deepseek serve --mobile` enables bearer-token + protection for `/v1/*` API routes. This is a local/LAN convenience guard, + not a replacement for TLS, VPN, or a trusted reverse proxy on public networks. - **No provider-token custody**. The server never returns the API key. The `api_key.source` capability field reports `env`, `config`, or `missing` — never the key itself.