diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index d68e31a48..cac9ec939 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -421,6 +421,10 @@ struct ServeArgs { /// `[runtime_api] cors_origins` from `config.toml`. Whalescale#255. #[arg(long = "cors-origin", value_name = "URL")] cors_origin: Vec, + /// Require this bearer token for `/v1/*` runtime API routes. Also reads + /// `DEEPSEEK_RUNTIME_TOKEN` when omitted. + #[arg(long = "auth-token", value_name = "TOKEN")] + auth_token: Option, } #[derive(Subcommand, Debug, Clone)] @@ -730,6 +734,7 @@ async fn main() -> Result<()> { port: args.port, workers: args.workers.clamp(1, 8), cors_origins, + auth_token: args.auth_token, }, ) .await diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 1810e17d8..e2fcb5af5 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -11,8 +11,9 @@ 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::sse::{Event as SseEvent, KeepAlive, Sse}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; @@ -52,6 +53,7 @@ pub struct RuntimeApiState { sessions_dir: PathBuf, mcp_config_path: PathBuf, automations: SharedAutomationManager, + runtime_token: Option, } #[derive(Debug, Clone)] @@ -65,6 +67,9 @@ pub struct RuntimeApiOptions { /// `DEEPSEEK_CORS_ORIGINS` (comma-separated), and `[runtime_api] /// cors_origins` in `config.toml`. Whalescale#255 / #561. pub cors_origins: Vec, + /// Optional bearer token required for `/v1/*` routes. If omitted here, + /// `run_http_server` also checks `DEEPSEEK_RUNTIME_TOKEN`. + pub auth_token: Option, } impl Default for RuntimeApiOptions { @@ -74,6 +79,7 @@ impl Default for RuntimeApiOptions { port: 7878, workers: 2, cors_origins: Vec::new(), + auth_token: None, } } } @@ -301,6 +307,12 @@ pub async fn run_http_server( .map(|h| h.join(".deepseek").join("sessions")) .unwrap_or_else(|| PathBuf::from(".deepseek").join("sessions")) }); + let runtime_token = options + .auth_token + .clone() + .or_else(|| std::env::var("DEEPSEEK_RUNTIME_TOKEN").ok()) + .filter(|token| !token.trim().is_empty()); + let auth_enabled = runtime_token.is_some(); let state = RuntimeApiState { config: config.clone(), workspace, @@ -310,6 +322,7 @@ pub async fn run_http_server( sessions_dir, mcp_config_path: config.mcp_config_path(), automations, + runtime_token, }; let app = build_router(state); @@ -322,6 +335,9 @@ 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 auth_enabled { + println!("Runtime API auth: bearer token required for /v1/* routes."); + } let serve_result = axum::serve(listener, app) .await .map_err(|e| anyhow!("Runtime API server error: {e}")); @@ -331,8 +347,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( @@ -378,10 +393,64 @@ 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)) + .merge(api_routes) .layer(cors_layer(&state.cors_origins)) .with_state(state) } +async fn require_runtime_token( + State(state): State, + req: Request, + next: Next, +) -> Response { + let Some(expected) = state.runtime_token.as_deref() else { + return next.run(req).await; + }; + let authorized = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|raw| raw.strip_prefix("Bearer ")) + .is_some_and(|token| token == expected) + || req + .headers() + .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() + } +} + +fn token_from_query(query: Option<&str>) -> Option<&str> { + query.and_then(|query| { + query.split('&').find_map(|pair| { + let (key, value) = pair.split_once('=')?; + (key == "token").then_some(value) + }) + }) +} + async fn health() -> Json { Json(HealthResponse { status: "ok", @@ -1641,6 +1710,20 @@ mod tests { SharedRuntimeThreadManager, tokio::task::JoinHandle<()>, )>, + > { + spawn_test_server_with_root_and_token(root, sessions_dir, None).await + } + + async fn spawn_test_server_with_root_and_token( + root: PathBuf, + sessions_dir: PathBuf, + runtime_token: Option, + ) -> Result< + Option<( + SocketAddr, + SharedRuntimeThreadManager, + tokio::task::JoinHandle<()>, + )>, > { fs::create_dir_all(&sessions_dir)?; let manager = TaskManager::start_with_executor( @@ -1695,6 +1778,7 @@ mod tests { sessions_dir, mcp_config_path: root.join("mcp.json"), automations, + runtime_token, }; let app = build_router(state); let listener = match TcpListener::bind("127.0.0.1:0").await { @@ -1858,6 +1942,50 @@ mod tests { Ok(()) } + #[tokio::test] + async fn runtime_token_guard_protects_v1_routes() -> Result<()> { + let root = std::env::temp_dir().join(format!("deepseek-runtime-api-{}", Uuid::new_v4())); + let sessions_dir = root.join("sessions"); + let token = "local-test-token".to_string(); + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_root_and_token(root, sessions_dir, Some(token.clone())).await? + else { + return Ok(()); + }; + let client = reqwest::Client::new(); + + let health = client + .get(format!("http://{addr}/health")) + .send() + .await? + .error_for_status()?; + assert_eq!(health.status(), StatusCode::OK); + + let unauthorized = client + .get(format!("http://{addr}/v1/threads/summary")) + .send() + .await?; + assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); + + let bearer = client + .get(format!("http://{addr}/v1/threads/summary")) + .bearer_auth(&token) + .send() + .await? + .error_for_status()?; + assert_eq!(bearer.status(), StatusCode::OK); + + let query_token = client + .get(format!("http://{addr}/v1/threads/summary?token={token}")) + .send() + .await? + .error_for_status()?; + assert_eq!(query_token.status(), StatusCode::OK); + + handle.abort(); + Ok(()) + } + #[tokio::test] async fn workspace_and_automation_endpoints_work() -> Result<()> { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 9b6f638c9..44e5eb0b5 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -116,7 +116,7 @@ deepseek doctor --json ## HTTP/SSE runtime API: `deepseek serve --http` ```bash -deepseek serve --http [--host 127.0.0.1] [--port 7878] [--workers 2] +deepseek serve --http [--host 127.0.0.1] [--port 7878] [--workers 2] [--auth-token TOKEN] ``` Defaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 1–8). @@ -124,6 +124,16 @@ 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. + +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. + ### Endpoints **Health** @@ -296,7 +306,11 @@ Common event names: `thread.started`, `thread.forked`, `turn.started`, - **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. +- **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 + 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.