Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
}

#[derive(Subcommand, Debug, Clone)]
Expand Down Expand Up @@ -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
Expand Down
136 changes: 132 additions & 4 deletions crates/tui/src/runtime_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -52,6 +53,7 @@ pub struct RuntimeApiState {
sessions_dir: PathBuf,
mcp_config_path: PathBuf,
automations: SharedAutomationManager,
runtime_token: Option<String>,
}

#[derive(Debug, Clone)]
Expand All @@ -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<String>,
/// Optional bearer token required for `/v1/*` routes. If omitted here,
/// `run_http_server` also checks `DEEPSEEK_RUNTIME_TOKEN`.
pub auth_token: Option<String>,
}

impl Default for RuntimeApiOptions {
Expand All @@ -74,6 +79,7 @@ impl Default for RuntimeApiOptions {
port: 7878,
workers: 2,
cors_origins: Vec::new(),
auth_token: None,
}
}
}
Expand Down Expand Up @@ -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,
Expand All @@ -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);

Expand All @@ -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}"));
Expand All @@ -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(
Expand Down Expand Up @@ -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<RuntimeApiState>,
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<HealthResponse> {
Json(HealthResponse {
status: "ok",
Expand Down Expand Up @@ -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<String>,
) -> Result<
Option<(
SocketAddr,
SharedRuntimeThreadManager,
tokio::task::JoinHandle<()>,
)>,
> {
fs::create_dir_all(&sessions_dir)?;
let manager = TaskManager::start_with_executor(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 16 additions & 2 deletions docs/RUNTIME_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,14 +116,24 @@ 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).

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**
Expand Down Expand Up @@ -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.
Expand Down
Loading