Skip to main content

codewhale_app_server/
lib.rs

1use std::collections::HashMap;
2use std::net::SocketAddr;
3use std::path::{Path, PathBuf};
4use std::process::{Child, Command, Stdio};
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use anyhow::{Context, Result, anyhow, bail};
9use axum::extract::{Request, State};
10use axum::http::{HeaderValue, Method, StatusCode, header};
11use axum::middleware::{self, Next};
12use axum::response::{IntoResponse, Response};
13use axum::routing::{get, post};
14use axum::{Json, Router};
15use codewhale_agent::ModelRegistry;
16use codewhale_config::{CliRuntimeOverrides, ConfigStore};
17use codewhale_core::Runtime;
18use codewhale_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink, UnixSocketHookSink};
19use codewhale_mcp::McpManager;
20use codewhale_protocol::{
21    AppRequest, AppResponse, PromptRequest, PromptResponse, ThreadGoalClearParams,
22    ThreadGoalGetParams, ThreadGoalSetParams, ThreadRequest, ThreadResponse, UserInputAnswerEvent,
23};
24use codewhale_state::StateStore;
25use codewhale_tools::{ToolCall, ToolRegistry};
26use serde::de::DeserializeOwned;
27use serde::{Deserialize, Serialize};
28use serde_json::{Value, json};
29use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader};
30use tokio::sync::{Mutex, RwLock};
31use tower_http::cors::CorsLayer;
32use uuid::Uuid;
33
34/// Answers submitted for a pending `request_user_input` clarification.
35///
36/// The headless runtime emits [`codewhale_protocol::EventFrame::UserInputRequest`]
37/// fire-and-return (it has no resume channel, mirroring headless approval).
38/// Clients POST answers back via [`AppRequest::SubmitUserInput`]; we record
39/// them here keyed by `request_id` so a driver can retrieve and feed them into
40/// the next turn as structured context. True in-flight resume would require an
41/// awaiter in `invoke_tool` and is left as a follow-up.
42type PendingUserInputAnswers = Vec<UserInputAnswerEvent>;
43
44mod chat_completions;
45
46/// Legacy DeepSeek-era naming kept for external compatibility.
47///
48/// CodeWhale began life as a DeepSeek CLI; existing health probes, SDK
49/// harnesses, and on-disk layouts still key off these names. Every remaining
50/// legacy reference in this crate routes through this shim so a future
51/// coordinated migration touches exactly one place (repo policy: preserve
52/// legacy migration care).
53mod legacy_deepseek_compat {
54    use std::path::PathBuf;
55
56    /// Service name advertised by the HTTP and stdio health probes.
57    pub(crate) const SERVICE_NAME: &str = "deepseek-app-server";
58
59    /// Fallback hook-event log location used when no config path is
60    /// provided (legacy `.deepseek/` dot-directory layout).
61    pub(crate) fn default_events_log_path() -> PathBuf {
62        PathBuf::from(".deepseek/events.jsonl")
63    }
64}
65
66const DEFAULT_CORS_ORIGINS: &[&str] = &[
67    "http://localhost",
68    "http://localhost:1420",
69    "http://localhost:3000",
70    "http://localhost:5173",
71    "http://127.0.0.1",
72    "http://127.0.0.1:1420",
73    "tauri://localhost",
74];
75
76#[derive(Clone)]
77pub struct AppServerOptions {
78    pub listen: SocketAddr,
79    pub config_path: Option<PathBuf>,
80    pub auth_token: Option<String>,
81    pub insecure_no_auth: bool,
82    pub cors_origins: Vec<String>,
83}
84
85impl std::fmt::Debug for AppServerOptions {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.debug_struct("AppServerOptions")
88            .field("listen", &self.listen)
89            .field("config_path", &self.config_path)
90            .field(
91                "auth_token",
92                &self.auth_token.as_ref().map(|_| "<redacted>"),
93            )
94            .field("insecure_no_auth", &self.insecure_no_auth)
95            .field("cors_origins", &self.cors_origins)
96            .finish()
97    }
98}
99
100/// Cached stdio→runtime bridge handle.
101///
102/// The outer [`AppState::stdio_bridge`] mutex guards only the cache slot;
103/// this inner mutex serializes traffic on one bridge (single child process
104/// plus per-thread seq bookkeeping requires ordered access).
105type SharedRuntimeBridge = Arc<Mutex<RuntimeBridge>>;
106
107#[derive(Clone)]
108struct AppState {
109    config_path: Option<PathBuf>,
110    config: Arc<RwLock<codewhale_config::ConfigToml>>,
111    /// Read/write split mirrors [`Runtime`]'s own receivers: `&self`
112    /// operations (tool calls, status, MCP startup) share a read guard and
113    /// run concurrently; `&mut self` turns (prompt/thread) and config pushes
114    /// take the write guard because the runtime genuinely requires
115    /// exclusivity there.
116    runtime: Arc<RwLock<Runtime>>,
117    registry: ModelRegistry,
118    auth_token: Option<String>,
119    stdio_bridge: Arc<Mutex<Option<SharedRuntimeBridge>>>,
120    stdio_thread_hints: Arc<Mutex<HashMap<String, RuntimeThreadHint>>>,
121    /// Answers submitted via `AppRequest::SubmitUserInput`, keyed by
122    /// `request_id`. A driver polls this to resolve clarification questions
123    /// raised by the model during a headless run.
124    pending_user_input: Arc<Mutex<std::collections::HashMap<String, PendingUserInputAnswers>>>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128struct ToolCallRequest {
129    call: ToolCall,
130    #[serde(default)]
131    cwd: Option<PathBuf>,
132}
133
134#[derive(Debug, Deserialize)]
135struct JsonRpcRequest {
136    #[serde(default)]
137    jsonrpc: Option<String>,
138    #[serde(default)]
139    id: Option<Value>,
140    method: String,
141    #[serde(default)]
142    params: Value,
143}
144
145#[derive(Debug)]
146struct JsonRpcError {
147    code: i64,
148    message: String,
149    data: Option<Value>,
150}
151
152#[derive(Debug)]
153struct StdioDispatchResult {
154    result: Value,
155    should_exit: bool,
156}
157
158#[derive(Debug)]
159struct RuntimeBridge {
160    base_url: String,
161    client: reqwest::Client,
162    auth_token: Option<String>,
163    child: Option<Child>,
164    thread_map: HashMap<String, String>,
165    last_seq_by_thread: HashMap<String, u64>,
166}
167
168#[derive(Debug, Clone, Default)]
169struct RuntimeThreadHint {
170    model: Option<String>,
171    workspace: Option<PathBuf>,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175enum TurnTerminalStatus {
176    Completed,
177    Failed,
178    Interrupted,
179    Canceled,
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183enum AppTransport {
184    Http,
185    Stdio,
186}
187
188#[derive(Debug, Deserialize)]
189struct ConfigGetParams {
190    key: String,
191}
192
193#[derive(Debug, Deserialize)]
194struct ConfigSetParams {
195    key: String,
196    value: String,
197}
198
199#[derive(Debug, Deserialize)]
200struct ThreadIdParams {
201    thread_id: String,
202}
203
204#[derive(Debug, Deserialize)]
205struct ThreadMessageParams {
206    thread_id: String,
207    input: String,
208}
209
210pub async fn run(options: AppServerOptions) -> Result<()> {
211    let auth_token = resolve_auth_token(&options)?;
212    let state = build_state(options.config_path.clone(), auth_token)?;
213    let app = app_router(state, &options.cors_origins);
214
215    let listener = tokio::net::TcpListener::bind(options.listen).await?;
216    axum::serve(listener, app).await?;
217    Ok(())
218}
219
220fn app_router(state: AppState, cors_origins: &[String]) -> Router {
221    let protected_routes = Router::new()
222        .route("/thread", post(thread_handler))
223        .route("/app", post(app_handler))
224        .route("/prompt", post(prompt_handler))
225        .route("/tool", post(tool_handler))
226        .route("/jobs", get(jobs_handler))
227        .route("/mcp/startup", post(mcp_startup_handler))
228        .route_layer(middleware::from_fn_with_state(
229            state.clone(),
230            require_app_server_token,
231        ));
232
233    Router::new()
234        .route("/healthz", get(healthz))
235        .route(
236            "/v1/chat/completions",
237            post(chat_completions::chat_completions_handler),
238        )
239        .merge(protected_routes)
240        .layer(cors_layer(cors_origins))
241        .with_state(state)
242}
243
244pub async fn run_stdio(config_path: Option<PathBuf>) -> Result<()> {
245    let state = build_state(config_path, None)?;
246    let stdin = tokio::io::stdin();
247    let stdout = tokio::io::stdout();
248    let mut reader = BufReader::new(stdin).lines();
249    let mut writer = tokio::io::BufWriter::new(stdout);
250    while let Some(line) = reader.next_line().await? {
251        if line.trim().is_empty() {
252            continue;
253        }
254
255        let request: JsonRpcRequest = match serde_json::from_str(&line) {
256            Ok(value) => value,
257            Err(err) => {
258                let response = jsonrpc_error(
259                    None,
260                    JsonRpcError::parse_error(format!("invalid json: {err}")),
261                );
262                writer.write_all(response.to_string().as_bytes()).await?;
263                writer.write_all(b"\n").await?;
264                writer.flush().await?;
265                continue;
266            }
267        };
268
269        if request
270            .jsonrpc
271            .as_deref()
272            .is_some_and(|version| version != "2.0")
273        {
274            let response = jsonrpc_error(
275                request.id,
276                JsonRpcError::invalid_request("jsonrpc version must be 2.0"),
277            );
278            writer.write_all(response.to_string().as_bytes()).await?;
279            writer.write_all(b"\n").await?;
280            writer.flush().await?;
281            continue;
282        }
283
284        let response = match dispatch_stdio_request_with_writer(
285            &state,
286            &mut writer,
287            &request.method,
288            request.params,
289        )
290        .await
291        {
292            Ok(dispatch) => {
293                let encoded = jsonrpc_result(request.id, dispatch.result);
294                writer.write_all(encoded.to_string().as_bytes()).await?;
295                writer.write_all(b"\n").await?;
296                writer.flush().await?;
297                if dispatch.should_exit {
298                    break;
299                }
300                continue;
301            }
302            Err(err) => jsonrpc_error(request.id, err),
303        };
304
305        writer.write_all(response.to_string().as_bytes()).await?;
306        writer.write_all(b"\n").await?;
307        writer.flush().await?;
308    }
309
310    Ok(())
311}
312
313async fn healthz() -> Json<Value> {
314    Json(json!({
315        "status": "ok",
316        "protocol": "v2",
317        "service": legacy_deepseek_compat::SERVICE_NAME
318    }))
319}
320
321async fn thread_handler(
322    State(state): State<AppState>,
323    Json(req): Json<ThreadRequest>,
324) -> Json<ThreadResponse> {
325    let mut runtime = state.runtime.write().await;
326    match runtime.handle_thread(req).await {
327        Ok(res) => Json(res),
328        Err(err) => Json(ThreadResponse {
329            thread_id: "error".to_string(),
330            status: format!("error:{err}"),
331            thread: None,
332            threads: Vec::new(),
333            goal: None,
334            model: None,
335            model_provider: None,
336            cwd: None,
337            approval_policy: None,
338            sandbox: None,
339            events: Vec::new(),
340            data: json!({}),
341        }),
342    }
343}
344
345async fn prompt_handler(
346    State(state): State<AppState>,
347    Json(req): Json<PromptRequest>,
348) -> Json<PromptResponse> {
349    let mut runtime = state.runtime.write().await;
350    let overrides = CliRuntimeOverrides::default();
351    match runtime.handle_prompt(req, &overrides).await {
352        Ok(res) => Json(res),
353        Err(err) => Json(PromptResponse {
354            output: err.to_string(),
355            model: "unknown".to_string(),
356            events: Vec::new(),
357        }),
358    }
359}
360
361async fn tool_handler(
362    State(state): State<AppState>,
363    Json(req): Json<ToolCallRequest>,
364) -> Json<Value> {
365    let cwd = req
366        .cwd
367        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
368    // Resolve approval policy from config instead of hardcoding.
369    let approval_mode = {
370        let cfg = state.config.read().await;
371        cfg.approval_policy
372            .as_deref()
373            .and_then(|p| match p.trim().to_ascii_lowercase().as_str() {
374                "auto" | "yolo" => Some(codewhale_execpolicy::AskForApproval::UnlessTrusted),
375                "never" | "deny" => Some(codewhale_execpolicy::AskForApproval::Never),
376                _ => None,
377            })
378            .unwrap_or(codewhale_execpolicy::AskForApproval::OnRequest)
379    };
380    // `invoke_tool` takes `&self`, so long-running tool executions share a
381    // read guard: they run concurrently with each other and with status
382    // reads instead of serializing every request behind one Mutex.
383    let runtime = state.runtime.read().await;
384    match runtime.invoke_tool(req.call, approval_mode, &cwd).await {
385        Ok(value) => Json(value),
386        Err(err) => Json(json!({ "ok": false, "error": err.to_string() })),
387    }
388}
389
390async fn jobs_handler(State(state): State<AppState>) -> Json<AppResponse> {
391    let runtime = state.runtime.read().await;
392    Json(runtime.app_status())
393}
394
395async fn mcp_startup_handler(State(state): State<AppState>) -> Json<Value> {
396    let runtime = state.runtime.read().await;
397    let summary = runtime.mcp_startup().await;
398    Json(json!({
399        "ok": true,
400        "summary": summary
401    }))
402}
403
404async fn app_handler(
405    State(state): State<AppState>,
406    Json(req): Json<AppRequest>,
407) -> Json<AppResponse> {
408    Json(process_app_request(&state, req, AppTransport::Http).await)
409}
410
411fn build_state(config_path: Option<PathBuf>, auth_token: Option<String>) -> Result<AppState> {
412    let has_explicit_config_path = config_path.is_some();
413    let store = ConfigStore::load(config_path)?;
414    let config_path = has_explicit_config_path.then(|| store.path().to_path_buf());
415    let config = store.config.clone();
416    let exec_policy = store.exec_policy_engine();
417    let registry = ModelRegistry::default();
418
419    let state_db_path = config_path
420        .as_ref()
421        .and_then(|p| p.parent().map(|parent| parent.join("state.db")));
422    let state_store = StateStore::open(state_db_path)?;
423
424    let mut hooks = HookDispatcher::default();
425    hooks.add_sink(Arc::new(StdoutHookSink));
426    let hook_log_path = config_path
427        .as_ref()
428        .and_then(|p| p.parent().map(|parent| parent.join("events.jsonl")))
429        .unwrap_or_else(legacy_deepseek_compat::default_events_log_path);
430    hooks.add_sink(Arc::new(JsonlHookSink::new(hook_log_path)));
431
432    if let Some(socket_path) = config
433        .hook_sinks
434        .as_ref()
435        .and_then(|sinks| sinks.unix_socket_path.as_ref())
436        .filter(|path| !path.as_os_str().is_empty())
437    {
438        hooks.add_sink(Arc::new(UnixSocketHookSink::new(socket_path.clone())));
439    }
440
441    let runtime = Runtime::new(
442        config.clone(),
443        registry.clone(),
444        state_store,
445        Arc::new(ToolRegistry::default()),
446        Arc::new(McpManager::default()),
447        exec_policy,
448        hooks,
449    );
450
451    Ok(AppState {
452        config_path,
453        config: Arc::new(RwLock::new(config)),
454        runtime: Arc::new(RwLock::new(runtime)),
455        registry,
456        auth_token,
457        stdio_bridge: Arc::new(Mutex::new(None)),
458        stdio_thread_hints: Arc::new(Mutex::new(HashMap::new())),
459        pending_user_input: Arc::new(Mutex::new(std::collections::HashMap::new())),
460    })
461}
462
463fn resolve_auth_token(options: &AppServerOptions) -> Result<Option<String>> {
464    let configured = options.auth_token.as_ref().map(|token| token.trim());
465    if let Some(token) = configured
466        && token.is_empty()
467    {
468        bail!("app-server auth token cannot be empty");
469    }
470    let has_explicit_token = configured.is_some();
471
472    if options.insecure_no_auth {
473        if !options.listen.ip().is_loopback() {
474            bail!("refusing unauthenticated app-server bind on non-loopback address");
475        }
476        eprintln!("warning: app-server HTTP auth disabled by --insecure-no-auth");
477        return Ok(None);
478    }
479
480    if !has_explicit_token && !options.listen.ip().is_loopback() {
481        bail!(
482            "refusing non-loopback app-server bind without explicit auth token; pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN"
483        );
484    }
485
486    let token = configured
487        .map(str::to_string)
488        .unwrap_or_else(|| format!("cwapp_{}", Uuid::new_v4().simple()));
489    for line in app_server_auth_status_lines(has_explicit_token) {
490        eprintln!("{line}");
491    }
492    Ok(Some(token))
493}
494
495fn app_server_auth_status_lines(has_explicit_token: bool) -> Vec<&'static str> {
496    if has_explicit_token {
497        return vec!["app-server auth: bearer token required for HTTP routes."];
498    }
499    vec![
500        "app-server auth: generated bearer token for this process (not printed).",
501        "  Pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN when another client needs to connect.",
502    ]
503}
504
505fn cors_layer(extra_origins: &[String]) -> CorsLayer {
506    let mut origins: Vec<HeaderValue> = DEFAULT_CORS_ORIGINS
507        .iter()
508        .filter_map(|origin| HeaderValue::from_str(origin).ok())
509        .collect();
510    for raw in extra_origins {
511        let trimmed = raw.trim();
512        if trimmed.is_empty() {
513            continue;
514        }
515        match HeaderValue::from_str(trimmed) {
516            Ok(value) if !origins.contains(&value) => origins.push(value),
517            Ok(_) => {}
518            Err(err) => {
519                eprintln!("warning: ignoring invalid app-server CORS origin `{trimmed}`: {err}")
520            }
521        }
522    }
523
524    CorsLayer::new()
525        .allow_origin(origins)
526        .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
527        .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
528}
529
530async fn require_app_server_token(
531    State(state): State<AppState>,
532    req: Request,
533    next: Next,
534) -> Response {
535    let Some(expected) = state.auth_token.as_deref() else {
536        return next.run(req).await;
537    };
538    let authorized = req
539        .headers()
540        .get(header::AUTHORIZATION)
541        .and_then(|value| value.to_str().ok())
542        .and_then(|raw| raw.strip_prefix("Bearer "))
543        .is_some_and(|token| token == expected);
544
545    if authorized {
546        next.run(req).await
547    } else {
548        (
549            StatusCode::UNAUTHORIZED,
550            Json(json!({
551                "error": {
552                    "message": "app-server bearer token required",
553                    "status": StatusCode::UNAUTHORIZED.as_u16(),
554                }
555            })),
556        )
557            .into_response()
558    }
559}
560
561fn params_or_object(params: Value) -> Value {
562    if params.is_null() { json!({}) } else { params }
563}
564
565fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
566    serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
567}
568
569fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
570    json!({
571        "jsonrpc": "2.0",
572        "id": id.unwrap_or(Value::Null),
573        "result": result
574    })
575}
576
577fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
578    json!({
579        "jsonrpc": "2.0",
580        "id": id.unwrap_or(Value::Null),
581        "error": {
582            "code": err.code,
583            "message": err.message,
584            "data": err.data
585        }
586    })
587}
588
589impl JsonRpcError {
590    fn parse_error(message: impl Into<String>) -> Self {
591        Self {
592            code: -32700,
593            message: message.into(),
594            data: None,
595        }
596    }
597
598    fn invalid_request(message: impl Into<String>) -> Self {
599        Self {
600            code: -32600,
601            message: message.into(),
602            data: None,
603        }
604    }
605
606    fn method_not_found(method: &str) -> Self {
607        Self {
608            code: -32601,
609            message: format!("unsupported method: {method}"),
610            data: None,
611        }
612    }
613
614    fn invalid_params(message: impl Into<String>) -> Self {
615        Self {
616            code: -32602,
617            message: message.into(),
618            data: None,
619        }
620    }
621
622    fn internal(message: impl Into<String>) -> Self {
623        Self {
624            code: -32603,
625            message: message.into(),
626            data: None,
627        }
628    }
629}
630
631async fn handle_thread_request(
632    state: &AppState,
633    req: ThreadRequest,
634) -> std::result::Result<ThreadResponse, JsonRpcError> {
635    let mut runtime = state.runtime.write().await;
636    runtime
637        .handle_thread(req)
638        .await
639        .map_err(|err| JsonRpcError::internal(err.to_string()))
640}
641
642async fn handle_prompt_request(
643    state: &AppState,
644    req: PromptRequest,
645) -> std::result::Result<PromptResponse, JsonRpcError> {
646    let mut runtime = state.runtime.write().await;
647    runtime
648        .handle_prompt(req, &CliRuntimeOverrides::default())
649        .await
650        .map_err(|err| JsonRpcError::internal(err.to_string()))
651}
652
653async fn handle_stdio_thread_message<W: AsyncWrite + Unpin>(
654    state: &AppState,
655    writer: &mut W,
656    parsed: ThreadMessageParams,
657) -> std::result::Result<Value, JsonRpcError> {
658    let hint = {
659        let hints = state.stdio_thread_hints.lock().await;
660        hints.get(&parsed.thread_id).cloned()
661    };
662    let bridge = acquire_stdio_bridge(state).await?;
663    // The inner bridge lock is held for the whole turn: one child process
664    // serves all threads and per-thread seq tracking requires ordered
665    // access. The cache slot itself stays unlocked, so config updates and
666    // bridge invalidation are never queued behind a streaming turn.
667    let mut bridge = bridge.lock().await;
668    let runtime_thread_id = bridge
669        .ensure_runtime_thread(&parsed.thread_id, hint)
670        .await
671        .map_err(|err| JsonRpcError::internal(err.to_string()))?;
672    let mut result = bridge
673        .message_thread(&runtime_thread_id, &parsed.input, writer)
674        .await
675        .map_err(|err| JsonRpcError::internal(err.to_string()))?;
676    if let Some(object) = result.as_object_mut() {
677        object.insert("thread_id".to_string(), Value::String(parsed.thread_id));
678    }
679    Ok(result)
680}
681
682async fn record_stdio_thread_hint(state: &AppState, response: &ThreadResponse) {
683    let mut hints = state.stdio_thread_hints.lock().await;
684    hints.insert(
685        response.thread_id.clone(),
686        RuntimeThreadHint {
687            model: response.model.clone(),
688            workspace: response.cwd.clone(),
689        },
690    );
691}
692
693/// Fetch the cached stdio→runtime bridge, spawning one on first use.
694///
695/// The cache-slot lock is held only for the lookup/insert — never across
696/// the child spawn or any request traffic — so [`invalidate_stdio_bridge`]
697/// and other slot users are never blocked behind a slow bridge operation.
698async fn acquire_stdio_bridge(
699    state: &AppState,
700) -> std::result::Result<SharedRuntimeBridge, JsonRpcError> {
701    if let Some(bridge) = state.stdio_bridge.lock().await.as_ref() {
702        return Ok(bridge.clone());
703    }
704    let bridge = Arc::new(Mutex::new(
705        RuntimeBridge::start(state.config_path.as_deref())
706            .await
707            .map_err(|err| JsonRpcError::internal(err.to_string()))?,
708    ));
709    let mut slot = state.stdio_bridge.lock().await;
710    // Prefer a bridge cached by a concurrent caller while we were spawning;
711    // dropping our unused one kills the extra child via `Drop`.
712    Ok(slot.get_or_insert_with(|| bridge.clone()).clone())
713}
714
715/// Drop the cached runtime bridge so the next stdio thread message spawns a
716/// fresh child that re-reads the persisted config. An in-flight message
717/// keeps its own [`SharedRuntimeBridge`] clone and finishes against the old
718/// child, which is killed when the last clone drops.
719async fn invalidate_stdio_bridge(state: &AppState) {
720    let mut bridge = state.stdio_bridge.lock().await;
721    *bridge = None;
722}
723
724impl RuntimeBridge {
725    async fn start(config_path: Option<&Path>) -> Result<Self> {
726        install_rustls_crypto_provider();
727        let port = reserve_runtime_port()?;
728        let auth_token = format!("cwrt_{}", Uuid::new_v4().simple());
729        let child = Self::runtime_command(config_path, port, &auth_token)?
730            .spawn()
731            .context("failed to start runtime API bridge")?;
732        let mut bridge = Self {
733            base_url: format!("http://127.0.0.1:{port}"),
734            client: reqwest::Client::builder()
735                .build()
736                .context("failed to build runtime API client")?,
737            auth_token: Some(auth_token),
738            child: Some(child),
739            thread_map: HashMap::new(),
740            last_seq_by_thread: HashMap::new(),
741        };
742        bridge.wait_until_ready().await?;
743        Ok(bridge)
744    }
745
746    fn runtime_command(config_path: Option<&Path>, port: u16, auth_token: &str) -> Result<Command> {
747        let current_exe = std::env::current_exe().ok();
748        let mut command = if let Some(path) = current_exe {
749            Command::new(path)
750        } else {
751            Command::new("codewhale")
752        };
753        command
754            .arg("app-server")
755            .arg("--http")
756            .arg("--host")
757            .arg("127.0.0.1")
758            .arg("--port")
759            .arg(port.to_string())
760            .arg("--auth-token")
761            .arg(auth_token)
762            .stdin(Stdio::null())
763            .stdout(Stdio::null())
764            .stderr(Stdio::null());
765        if let Some(config_path) = config_path {
766            command.arg("--config").arg(config_path);
767        }
768        Ok(command)
769    }
770
771    async fn wait_until_ready(&mut self) -> Result<()> {
772        let deadline = Instant::now() + Duration::from_secs(15);
773        loop {
774            if let Some(child) = self.child.as_mut()
775                && let Some(status) = child.try_wait()?
776            {
777                return Err(anyhow!(
778                    "runtime API bridge exited before becoming ready (status {status})"
779                ));
780            }
781
782            match self
783                .client
784                .get(format!("{}/health", self.base_url))
785                .send()
786                .await
787            {
788                Ok(response) if response.status().is_success() => return Ok(()),
789                _ if Instant::now() >= deadline => {
790                    bail!(
791                        "timed out waiting for runtime API bridge at {}/health",
792                        self.base_url
793                    )
794                }
795                _ => tokio::time::sleep(Duration::from_millis(50)).await,
796            }
797        }
798    }
799
800    fn authed(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
801        match self.auth_token.as_deref() {
802            Some(token) => builder.bearer_auth(token),
803            None => builder,
804        }
805    }
806
807    async fn request_json(&self, builder: reqwest::RequestBuilder) -> Result<Value> {
808        let response = builder.send().await?;
809        let status = response.status();
810        let body = response.text().await?;
811        if !status.is_success() {
812            let detail = body.trim();
813            if detail.is_empty() {
814                bail!("runtime API returned {status}");
815            }
816            bail!("runtime API returned {status}: {detail}");
817        }
818        serde_json::from_str(&body).with_context(|| format!("invalid runtime API json: {body}"))
819    }
820
821    async fn ensure_runtime_thread(
822        &mut self,
823        stdio_thread_id: &str,
824        hint: Option<RuntimeThreadHint>,
825    ) -> Result<String> {
826        if let Some(runtime_thread_id) = self.thread_map.get(stdio_thread_id) {
827            return Ok(runtime_thread_id.clone());
828        }
829        let hint = hint.unwrap_or_default();
830        let runtime_thread_id = self
831            .create_runtime_thread(hint.model, hint.workspace)
832            .await?;
833        self.thread_map
834            .insert(stdio_thread_id.to_string(), runtime_thread_id.clone());
835        Ok(runtime_thread_id)
836    }
837
838    async fn create_runtime_thread(
839        &mut self,
840        model: Option<String>,
841        workspace: Option<PathBuf>,
842    ) -> Result<String> {
843        let record = self
844            .request_json(
845                self.authed(self.client.post(format!("{}/v1/threads", self.base_url)))
846                    .json(&json!({
847                        "model": model,
848                        "workspace": workspace,
849                        "mode": "agent",
850                        "archived": false,
851                    })),
852            )
853            .await?;
854        let thread_id = extract_runtime_thread_id(&record)?.to_string();
855        self.last_seq_by_thread
856            .entry(thread_id.clone())
857            .or_insert(0);
858        Ok(thread_id)
859    }
860
861    async fn message_thread<W: AsyncWrite + Unpin>(
862        &mut self,
863        thread_id: &str,
864        input: &str,
865        writer: &mut W,
866    ) -> Result<Value> {
867        let turn = self
868            .request_json(
869                self.authed(
870                    self.client
871                        .post(format!("{}/v1/threads/{thread_id}/turns", self.base_url)),
872                )
873                .json(&json!({ "prompt": input })),
874            )
875            .await?;
876        let turn_id = turn
877            .pointer("/turn/id")
878            .and_then(Value::as_str)
879            .ok_or_else(|| anyhow!("runtime API turn response missing turn.id"))?
880            .to_string();
881        let response_id = format!("{thread_id}:{turn_id}");
882
883        emit_stdio_event(
884            writer,
885            json!({
886                "type": "response_start",
887                "response_id": response_id,
888            }),
889        )
890        .await?;
891
892        let since_seq = self.last_seq_by_thread.get(thread_id).copied().unwrap_or(0);
893        let stream_result = self
894            .stream_turn_events(thread_id, &turn_id, &response_id, writer, since_seq)
895            .await;
896
897        let _ = emit_stdio_event(
898            writer,
899            json!({
900                "type": "response_end",
901                "response_id": response_id,
902            }),
903        )
904        .await;
905
906        let (last_seq, status, error) = stream_result?;
907        self.last_seq_by_thread
908            .insert(thread_id.to_string(), last_seq);
909
910        match status {
911            TurnTerminalStatus::Completed => Ok(json!({
912                "thread_id": thread_id,
913                "status": "accepted",
914                "thread": Value::Null,
915                "threads": [],
916                "model": Value::Null,
917                "model_provider": Value::Null,
918                "cwd": Value::Null,
919                "approval_policy": Value::Null,
920                "sandbox": Value::Null,
921                "events": [],
922                "data": { "turn_id": turn_id },
923            })),
924            TurnTerminalStatus::Failed => Err(anyhow!(
925                "{}",
926                error.unwrap_or_else(|| "turn failed".to_string())
927            )),
928            TurnTerminalStatus::Interrupted => Err(anyhow!(
929                "{}",
930                error.unwrap_or_else(|| "turn interrupted".to_string())
931            )),
932            TurnTerminalStatus::Canceled => Err(anyhow!(
933                "{}",
934                error.unwrap_or_else(|| "turn canceled".to_string())
935            )),
936        }
937    }
938
939    async fn stream_turn_events<W: AsyncWrite + Unpin>(
940        &self,
941        thread_id: &str,
942        turn_id: &str,
943        response_id: &str,
944        writer: &mut W,
945        since_seq: u64,
946    ) -> Result<(u64, TurnTerminalStatus, Option<String>)> {
947        let mut response = self
948            .authed(self.client.get(format!(
949                "{}/v1/threads/{thread_id}/events?since_seq={since_seq}",
950                self.base_url
951            )))
952            .send()
953            .await?
954            .error_for_status()?;
955
956        let mut buffer = Vec::new();
957        let mut last_seq = since_seq;
958
959        while let Some(chunk) = response.chunk().await? {
960            buffer.extend_from_slice(&chunk);
961            while let Some(frame_bytes) = take_sse_frame(&mut buffer) {
962                let Some((event_name, frame_data)) = parse_sse_frame(&frame_bytes) else {
963                    continue;
964                };
965                let envelope: Value = serde_json::from_str(&frame_data)
966                    .with_context(|| format!("invalid SSE json for {event_name}: {frame_data}"))?;
967                if let Some(seq) = envelope.get("seq").and_then(Value::as_u64) {
968                    last_seq = last_seq.max(seq);
969                }
970                if envelope.get("turn_id").and_then(Value::as_str) != Some(turn_id) {
971                    continue;
972                }
973                let payload = envelope.get("payload").cloned().unwrap_or(Value::Null);
974                match event_name.as_str() {
975                    "item.delta" => {
976                        let kind = payload
977                            .get("kind")
978                            .and_then(Value::as_str)
979                            .unwrap_or_default();
980                        if kind == "agent_message"
981                            && let Some(delta) = payload.get("delta").and_then(Value::as_str)
982                            && !delta.is_empty()
983                        {
984                            emit_stdio_event(
985                                writer,
986                                json!({
987                                    "type": "response_delta",
988                                    "response_id": response_id,
989                                    "delta": delta,
990                                }),
991                            )
992                            .await?;
993                        }
994                    }
995                    "turn.completed" => {
996                        let status = turn_terminal_status(&payload);
997                        let error = payload
998                            .pointer("/turn/error")
999                            .and_then(Value::as_str)
1000                            .map(str::to_string);
1001                        return Ok((last_seq, status, error));
1002                    }
1003                    _ => {}
1004                }
1005            }
1006        }
1007
1008        bail!("runtime event stream ended before turn.completed")
1009    }
1010
1011    #[cfg(test)]
1012    fn from_base_url_for_test(base_url: String) -> Self {
1013        install_rustls_crypto_provider();
1014        Self {
1015            base_url,
1016            client: reqwest::Client::builder()
1017                .timeout(Duration::from_secs(5))
1018                .build()
1019                .expect("build reqwest test client"),
1020            auth_token: None,
1021            child: None,
1022            thread_map: HashMap::new(),
1023            last_seq_by_thread: HashMap::new(),
1024        }
1025    }
1026}
1027
1028impl Drop for RuntimeBridge {
1029    fn drop(&mut self) {
1030        if let Some(child) = self.child.as_mut() {
1031            let _ = child.kill();
1032            let _ = child.wait();
1033        }
1034    }
1035}
1036
1037fn reserve_runtime_port() -> Result<u16> {
1038    let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
1039    Ok(listener.local_addr()?.port())
1040}
1041
1042fn install_rustls_crypto_provider() {
1043    let _ = rustls::crypto::ring::default_provider().install_default();
1044}
1045
1046fn extract_runtime_thread_id(record: &Value) -> Result<&str> {
1047    record
1048        .get("id")
1049        .and_then(Value::as_str)
1050        .ok_or_else(|| anyhow!("runtime API thread response missing id"))
1051}
1052
1053fn turn_terminal_status(payload: &Value) -> TurnTerminalStatus {
1054    match payload
1055        .pointer("/turn/status")
1056        .and_then(Value::as_str)
1057        .unwrap_or("completed")
1058        .to_ascii_lowercase()
1059        .as_str()
1060    {
1061        "failed" => TurnTerminalStatus::Failed,
1062        "interrupted" => TurnTerminalStatus::Interrupted,
1063        "canceled" | "cancelled" => TurnTerminalStatus::Canceled,
1064        _ => TurnTerminalStatus::Completed,
1065    }
1066}
1067
1068async fn emit_stdio_event<W: AsyncWrite + Unpin>(writer: &mut W, event: Value) -> Result<()> {
1069    writer.write_all(event.to_string().as_bytes()).await?;
1070    writer.write_all(b"\n").await?;
1071    writer.flush().await?;
1072    Ok(())
1073}
1074
1075fn take_sse_frame(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
1076    if let Some(pos) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
1077        return Some(buffer.drain(..pos + 4).collect());
1078    }
1079    buffer
1080        .windows(2)
1081        .position(|window| window == b"\n\n")
1082        .map(|pos| buffer.drain(..pos + 2).collect())
1083}
1084
1085fn parse_sse_frame(frame_bytes: &[u8]) -> Option<(String, String)> {
1086    let text = String::from_utf8(frame_bytes.to_vec()).ok()?;
1087    let mut event_name = None;
1088    let mut data_lines = Vec::new();
1089    for raw_line in text.lines() {
1090        let line = raw_line.trim_end_matches('\r');
1091        if let Some(value) = line.strip_prefix("event:") {
1092            event_name = Some(value.trim().to_string());
1093        } else if let Some(value) = line.strip_prefix("data:") {
1094            data_lines.push(value.trim_start().to_string());
1095        }
1096    }
1097    match (event_name, data_lines.is_empty()) {
1098        (Some(event), false) => Some((event, data_lines.join("\n"))),
1099        _ => None,
1100    }
1101}
1102
1103#[cfg(test)]
1104async fn dispatch_stdio_request(
1105    state: &AppState,
1106    method: &str,
1107    params: Value,
1108) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
1109    let mut sink = tokio::io::sink();
1110    dispatch_stdio_request_with_writer(state, &mut sink, method, params).await
1111}
1112
1113async fn dispatch_stdio_app_request(
1114    state: &AppState,
1115    request: AppRequest,
1116) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
1117    let response = Box::pin(process_app_request(state, request, AppTransport::Stdio)).await;
1118    Ok(StdioDispatchResult {
1119        result: serde_json::to_value(response)
1120            .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1121        should_exit: false,
1122    })
1123}
1124
1125async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
1126    state: &AppState,
1127    writer: &mut W,
1128    method: &str,
1129    params: Value,
1130) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
1131    let outcome = match method {
1132        "healthz" | "app/healthz" => StdioDispatchResult {
1133            result: json!({
1134                "status": "ok",
1135                "service": legacy_deepseek_compat::SERVICE_NAME,
1136                "transport": "stdio"
1137            }),
1138            should_exit: false,
1139        },
1140        "capabilities" => StdioDispatchResult {
1141            result: json!({
1142                "transport": "stdio",
1143                "families": ["thread/*", "app/*", "prompt/*"],
1144                "methods": [
1145                    "healthz",
1146                    "thread/capabilities",
1147                    "thread/request",
1148                    "thread/create",
1149                    "thread/start",
1150                    "thread/resume",
1151                    "thread/fork",
1152                    "thread/list",
1153                    "thread/read",
1154                    "thread/set_name",
1155                    "thread/goal/set",
1156                    "thread/goal/get",
1157                    "thread/goal/clear",
1158                    "thread/archive",
1159                    "thread/unarchive",
1160                    "thread/message",
1161                    "app/capabilities",
1162                    "app/request",
1163                    "app/config/get",
1164                    "app/config/set",
1165                    "app/config/unset",
1166                    "app/config/list",
1167                    "app/config/reload",
1168                    "app/models",
1169                    "app/thread_loaded_list",
1170                    "prompt/capabilities",
1171                    "prompt/request",
1172                    "prompt/run",
1173                    "shutdown"
1174                ]
1175            }),
1176            should_exit: false,
1177        },
1178        "thread/capabilities" => StdioDispatchResult {
1179            result: json!({
1180                "methods": [
1181                    "thread/request",
1182                    "thread/create",
1183                    "thread/start",
1184                    "thread/resume",
1185                    "thread/fork",
1186                    "thread/list",
1187                    "thread/read",
1188                    "thread/set_name",
1189                    "thread/goal/set",
1190                    "thread/goal/get",
1191                    "thread/goal/clear",
1192                    "thread/archive",
1193                    "thread/unarchive",
1194                    "thread/message"
1195                ]
1196            }),
1197            should_exit: false,
1198        },
1199        "thread/request" => {
1200            let request: ThreadRequest = parse_params(params)?;
1201            if let ThreadRequest::Message { thread_id, input } = request {
1202                let response = handle_stdio_thread_message(
1203                    state,
1204                    writer,
1205                    ThreadMessageParams { thread_id, input },
1206                )
1207                .await?;
1208                return Ok(StdioDispatchResult {
1209                    result: response,
1210                    should_exit: false,
1211                });
1212            }
1213            let should_record_hint = matches!(
1214                &request,
1215                ThreadRequest::Create { .. }
1216                    | ThreadRequest::Start(_)
1217                    | ThreadRequest::Resume(_)
1218                    | ThreadRequest::Fork(_)
1219            );
1220            let response = handle_thread_request(state, request).await?;
1221            if should_record_hint {
1222                record_stdio_thread_hint(state, &response).await;
1223            }
1224            StdioDispatchResult {
1225                result: serde_json::to_value(response)
1226                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1227                should_exit: false,
1228            }
1229        }
1230        "thread/create" => {
1231            #[derive(Debug, Deserialize)]
1232            struct CreateParams {
1233                #[serde(default)]
1234                metadata: Value,
1235            }
1236            let parsed: CreateParams = parse_params(params_or_object(params))?;
1237            let response = handle_thread_request(
1238                state,
1239                ThreadRequest::Create {
1240                    metadata: parsed.metadata,
1241                },
1242            )
1243            .await?;
1244            record_stdio_thread_hint(state, &response).await;
1245            StdioDispatchResult {
1246                result: serde_json::to_value(response)
1247                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1248                should_exit: false,
1249            }
1250        }
1251        "thread/start" => {
1252            let request = ThreadRequest::Start(parse_params(params_or_object(params))?);
1253            let response = handle_thread_request(state, request).await?;
1254            record_stdio_thread_hint(state, &response).await;
1255            StdioDispatchResult {
1256                result: serde_json::to_value(response)
1257                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1258                should_exit: false,
1259            }
1260        }
1261        "thread/resume" => {
1262            let request = ThreadRequest::Resume(parse_params(params_or_object(params))?);
1263            let response = handle_thread_request(state, request).await?;
1264            record_stdio_thread_hint(state, &response).await;
1265            StdioDispatchResult {
1266                result: serde_json::to_value(response)
1267                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1268                should_exit: false,
1269            }
1270        }
1271        "thread/fork" => {
1272            let request = ThreadRequest::Fork(parse_params(params_or_object(params))?);
1273            let response = handle_thread_request(state, request).await?;
1274            record_stdio_thread_hint(state, &response).await;
1275            StdioDispatchResult {
1276                result: serde_json::to_value(response)
1277                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1278                should_exit: false,
1279            }
1280        }
1281        "thread/list" => {
1282            let request = ThreadRequest::List(parse_params(params_or_object(params))?);
1283            let response = handle_thread_request(state, request).await?;
1284            StdioDispatchResult {
1285                result: serde_json::to_value(response)
1286                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1287                should_exit: false,
1288            }
1289        }
1290        "thread/read" => {
1291            let request = ThreadRequest::Read(parse_params(params_or_object(params))?);
1292            let response = handle_thread_request(state, request).await?;
1293            StdioDispatchResult {
1294                result: serde_json::to_value(response)
1295                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1296                should_exit: false,
1297            }
1298        }
1299        "thread/set_name" | "thread/set-name" => {
1300            let request = ThreadRequest::SetName(parse_params(params_or_object(params))?);
1301            let response = handle_thread_request(state, request).await?;
1302            StdioDispatchResult {
1303                result: serde_json::to_value(response)
1304                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1305                should_exit: false,
1306            }
1307        }
1308        "thread/goal/set" | "thread/goal_set" | "thread/goal-set" => {
1309            let request = ThreadRequest::GoalSet(parse_params::<ThreadGoalSetParams>(
1310                params_or_object(params),
1311            )?);
1312            let response = handle_thread_request(state, request).await?;
1313            StdioDispatchResult {
1314                result: serde_json::to_value(response)
1315                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1316                should_exit: false,
1317            }
1318        }
1319        "thread/goal/get" | "thread/goal_get" | "thread/goal-get" => {
1320            let request = ThreadRequest::GoalGet(parse_params::<ThreadGoalGetParams>(
1321                params_or_object(params),
1322            )?);
1323            let response = handle_thread_request(state, request).await?;
1324            StdioDispatchResult {
1325                result: serde_json::to_value(response)
1326                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1327                should_exit: false,
1328            }
1329        }
1330        "thread/goal/clear" | "thread/goal_clear" | "thread/goal-clear" => {
1331            let request = ThreadRequest::GoalClear(parse_params::<ThreadGoalClearParams>(
1332                params_or_object(params),
1333            )?);
1334            let response = handle_thread_request(state, request).await?;
1335            StdioDispatchResult {
1336                result: serde_json::to_value(response)
1337                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1338                should_exit: false,
1339            }
1340        }
1341        "thread/archive" => {
1342            let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
1343            let response = handle_thread_request(
1344                state,
1345                ThreadRequest::Archive {
1346                    thread_id: parsed.thread_id,
1347                },
1348            )
1349            .await?;
1350            StdioDispatchResult {
1351                result: serde_json::to_value(response)
1352                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1353                should_exit: false,
1354            }
1355        }
1356        "thread/unarchive" => {
1357            let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
1358            let response = handle_thread_request(
1359                state,
1360                ThreadRequest::Unarchive {
1361                    thread_id: parsed.thread_id,
1362                },
1363            )
1364            .await?;
1365            StdioDispatchResult {
1366                result: serde_json::to_value(response)
1367                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1368                should_exit: false,
1369            }
1370        }
1371        "thread/message" => {
1372            let parsed: ThreadMessageParams = parse_params(params_or_object(params))?;
1373            let response = handle_stdio_thread_message(state, writer, parsed).await?;
1374            StdioDispatchResult {
1375                result: response,
1376                should_exit: false,
1377            }
1378        }
1379        "app/capabilities" => dispatch_stdio_app_request(state, AppRequest::Capabilities).await?,
1380        "app/request" => {
1381            let request: AppRequest = parse_params(params)?;
1382            dispatch_stdio_app_request(state, request).await?
1383        }
1384        "app/config/get" => {
1385            let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
1386            dispatch_stdio_app_request(state, AppRequest::ConfigGet { key: parsed.key }).await?
1387        }
1388        "app/config/set" => {
1389            let parsed: ConfigSetParams = parse_params(params_or_object(params))?;
1390            dispatch_stdio_app_request(
1391                state,
1392                AppRequest::ConfigSet {
1393                    key: parsed.key,
1394                    value: parsed.value,
1395                },
1396            )
1397            .await?
1398        }
1399        "app/config/unset" => {
1400            let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
1401            dispatch_stdio_app_request(state, AppRequest::ConfigUnset { key: parsed.key }).await?
1402        }
1403        "app/config/list" => dispatch_stdio_app_request(state, AppRequest::ConfigList).await?,
1404        "app/config/reload" => dispatch_stdio_app_request(state, AppRequest::ConfigReload).await?,
1405        "app/models" => dispatch_stdio_app_request(state, AppRequest::Models).await?,
1406        "app/thread_loaded_list" | "app/thread-loaded-list" => {
1407            dispatch_stdio_app_request(state, AppRequest::ThreadLoadedList).await?
1408        }
1409        "prompt/capabilities" => StdioDispatchResult {
1410            result: json!({
1411                "methods": ["prompt/request", "prompt/run"]
1412            }),
1413            should_exit: false,
1414        },
1415        "prompt/request" | "prompt/run" => {
1416            let request: PromptRequest = parse_params(params)?;
1417            let response = handle_prompt_request(state, request).await?;
1418            StdioDispatchResult {
1419                result: serde_json::to_value(response)
1420                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1421                should_exit: false,
1422            }
1423        }
1424        "shutdown" => StdioDispatchResult {
1425            result: json!({"ok": true, "status": "stopped"}),
1426            should_exit: true,
1427        },
1428        _ => return Err(JsonRpcError::method_not_found(method)),
1429    };
1430    Ok(outcome)
1431}
1432
1433async fn process_app_request(
1434    state: &AppState,
1435    req: AppRequest,
1436    transport: AppTransport,
1437) -> AppResponse {
1438    match req {
1439        AppRequest::Capabilities => AppResponse {
1440            ok: true,
1441            data: json!({
1442                "routes": ["/thread", "/app", "/prompt", "/tool", "/jobs", "/mcp/startup"],
1443                "config": ["get", "set", "unset", "list", "reload"],
1444                "events": ["response_start", "response_delta", "response_end", "tool_call_start", "tool_call_result", "mcp_startup_update", "mcp_startup_complete"],
1445                "transport": "stdio+http",
1446                "config_path": state.config_path.as_ref().map(|p| p.display().to_string()),
1447            }),
1448            events: Vec::new(),
1449        },
1450        AppRequest::ConfigGet { key } => {
1451            let cfg = state.config.read().await;
1452            let value = match transport {
1453                AppTransport::Http => cfg.get_display_value(&key),
1454                AppTransport::Stdio => cfg.get_value(&key),
1455            };
1456            AppResponse {
1457                ok: true,
1458                data: json!({ "key": key, "value": value }),
1459                events: Vec::new(),
1460            }
1461        }
1462        AppRequest::ConfigSet { key, value } => {
1463            let (result, snapshot) = {
1464                let mut cfg = state.config.write().await;
1465                let result = cfg.set_value(&key, &value);
1466                (result, cfg.clone())
1467            };
1468            let ok = result.is_ok();
1469            let message = result.err().map(|e| e.to_string());
1470            apply_config_update(state, snapshot, None, true).await;
1471            AppResponse {
1472                ok,
1473                data: json!({ "key": key, "value": value, "error": message }),
1474                events: Vec::new(),
1475            }
1476        }
1477        AppRequest::ConfigUnset { key } => {
1478            let (result, snapshot) = {
1479                let mut cfg = state.config.write().await;
1480                let result = cfg.unset_value(&key);
1481                (result, cfg.clone())
1482            };
1483            let ok = result.is_ok();
1484            let message = result.err().map(|e| e.to_string());
1485            apply_config_update(state, snapshot, None, true).await;
1486            AppResponse {
1487                ok,
1488                data: json!({ "key": key, "error": message }),
1489                events: Vec::new(),
1490            }
1491        }
1492        AppRequest::ConfigList => {
1493            let cfg = state.config.read().await;
1494            AppResponse {
1495                ok: true,
1496                data: json!({ "values": cfg.list_values() }),
1497                events: Vec::new(),
1498            }
1499        }
1500        AppRequest::ConfigReload => {
1501            // Re-read both `config.toml` and the sibling `permissions.toml`
1502            // from disk (the headless equivalent of the TUI
1503            // `reload_runtime_config` codepath) and push the fresh
1504            // snapshots into `state.config` and the live `Runtime`.
1505            //
1506            // `ConfigStore::load` resolves the same default config path
1507            // that `build_state` used at startup when `config_path` is
1508            // `None`, so a `None` here reloads from the same on-disk file
1509            // the server booted from.
1510            let store = match ConfigStore::load(state.config_path.clone()) {
1511                Ok(store) => store,
1512                Err(e) => {
1513                    return AppResponse {
1514                        ok: false,
1515                        data: json!({ "error": format!("failed to load config: {e}") }),
1516                        events: Vec::new(),
1517                    };
1518                }
1519            };
1520            let new_config = store.config.clone();
1521            let new_exec_policy = store.exec_policy_engine();
1522
1523            // Disk is already the source of truth here, so nothing to
1524            // persist; the exec policy rides along so the runtime picks up
1525            // external `permissions.toml` edits too.
1526            apply_config_update(state, new_config, Some(new_exec_policy), false).await;
1527
1528            AppResponse {
1529                ok: true,
1530                data: json!({ "reloaded": true }),
1531                events: Vec::new(),
1532            }
1533        }
1534        AppRequest::Models => AppResponse {
1535            ok: true,
1536            data: json!({ "models": state.registry.list() }),
1537            events: Vec::new(),
1538        },
1539        AppRequest::ThreadLoadedList => {
1540            let mut runtime = state.runtime.write().await;
1541            let response = runtime
1542                .handle_thread(codewhale_protocol::ThreadRequest::List(
1543                    codewhale_protocol::ThreadListParams {
1544                        include_archived: false,
1545                        limit: Some(50),
1546                    },
1547                ))
1548                .await;
1549            match response {
1550                Ok(thread_resp) => AppResponse {
1551                    ok: true,
1552                    data: json!({ "threads": thread_resp.threads }),
1553                    events: thread_resp.events,
1554                },
1555                Err(err) => AppResponse {
1556                    ok: false,
1557                    data: json!({ "error": err.to_string() }),
1558                    events: Vec::new(),
1559                },
1560            }
1561        }
1562        AppRequest::SubmitUserInput {
1563            request_id,
1564            answers,
1565        } => {
1566            // Record the user's answers against the pending clarification
1567            // request so a driver can retrieve them. The headless runtime does
1568            // not block on `request_user_input` (fire-and-return, like
1569            // approval), so there is no in-flight turn to resume here — the
1570            // caller is expected to feed these answers into the next turn.
1571            let mut pending = state.pending_user_input.lock().await;
1572            if pending.contains_key(&request_id) {
1573                return AppResponse {
1574                    ok: false,
1575                    data: json!({
1576                        "error": "request_id already resolved",
1577                        "request_id": request_id,
1578                    }),
1579                    events: Vec::new(),
1580                };
1581            }
1582            pending.insert(request_id.clone(), answers);
1583            AppResponse {
1584                ok: true,
1585                data: json!({ "request_id": request_id, "resolved": true }),
1586                events: Vec::new(),
1587            }
1588        }
1589    }
1590}
1591
1592/// Propagate a new config snapshot to every place that must observe it:
1593/// optionally persist it to disk, install it in the shared `state.config`,
1594/// push it into the live [`Runtime`], and invalidate the cached stdio
1595/// bridge so the next stdio request spawns a fresh child that reads the
1596/// new on-disk config. Shared by `ConfigSet` / `ConfigUnset` / `ConfigReload`.
1597///
1598/// `exec_policy` is `Some` only on the reload path, which re-reads
1599/// `permissions.toml` from disk; set/unset intentionally leave the live
1600/// exec policy alone (use `ConfigReload` to pick up external permission
1601/// edits). `persist` is false on the reload path because disk is already
1602/// the source of truth there.
1603async fn apply_config_update(
1604    state: &AppState,
1605    snapshot: codewhale_config::ConfigToml,
1606    exec_policy: Option<codewhale_execpolicy::ExecPolicyEngine>,
1607    persist: bool,
1608) {
1609    if persist && let Err(e) = persist_config(state, snapshot.clone()).await {
1610        tracing::error!("Failed to persist config update: {e}");
1611    }
1612    {
1613        let mut cfg = state.config.write().await;
1614        *cfg = snapshot.clone();
1615    }
1616    // Sync into the live Runtime so the next turn picks up the change
1617    // without a restart. MCP server connections are NOT refreshed here —
1618    // see `Runtime::reload_config_and_policy` for the rationale and the
1619    // matching TUI `mcp_restart_required` note.
1620    {
1621        let mut runtime = state.runtime.write().await;
1622        match exec_policy {
1623            Some(policy) => runtime.reload_config_and_policy(snapshot, policy),
1624            None => runtime.update_config(snapshot),
1625        }
1626    }
1627    invalidate_stdio_bridge(state).await;
1628}
1629
1630async fn persist_config(state: &AppState, config: codewhale_config::ConfigToml) -> Result<()> {
1631    if state.config_path.is_none() {
1632        return Ok(());
1633    }
1634    let mut store = ConfigStore::load(state.config_path.clone())?;
1635    store.config = config;
1636    store.save()
1637}
1638
1639#[cfg(test)]
1640mod tests {
1641    use super::*;
1642    use axum::body::{Body, to_bytes};
1643    use axum::extract::{Path as AxumPath, Query};
1644    use axum::http::header;
1645    use codewhale_protocol::AppRequest;
1646    use std::collections::HashMap;
1647    use std::fs;
1648    use tokio::io::AsyncReadExt;
1649    use tower::ServiceExt;
1650
1651    fn app_with_config(auth_token: Option<&str>) -> (Router, tempfile::TempDir) {
1652        let tmp = tempfile::tempdir().expect("tempdir");
1653        let config_path = tmp.path().join("config.toml");
1654        fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1655        let state = build_state(
1656            Some(config_path),
1657            auth_token.map(std::string::ToString::to_string),
1658        )
1659        .expect("state");
1660        (app_router(state, &[]), tmp)
1661    }
1662
1663    #[test]
1664    fn build_state_keeps_resolved_explicit_config_path() {
1665        let tmp = tempfile::tempdir().expect("tempdir");
1666        let config_dir = tmp.path().join("config-dir");
1667        fs::create_dir_all(&config_dir).expect("config dir");
1668        let config_path = config_dir.join("config.toml");
1669        fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1670
1671        let state = build_state(Some(config_path.clone()), None).expect("state");
1672
1673        assert_eq!(
1674            state.config_path.as_deref(),
1675            Some(
1676                config_path
1677                    .canonicalize()
1678                    .expect("canonical config")
1679                    .as_path()
1680            )
1681        );
1682    }
1683
1684    async fn response_body_json(response: Response) -> Value {
1685        let bytes = to_bytes(response.into_body(), usize::MAX)
1686            .await
1687            .expect("body bytes");
1688        serde_json::from_slice(&bytes).expect("json response")
1689    }
1690
1691    #[tokio::test]
1692    async fn http_app_routes_require_bearer_token_when_auth_enabled() {
1693        let (app, _tmp) = app_with_config(Some("test-token"));
1694        let response = app
1695            .oneshot(
1696                Request::builder()
1697                    .method(Method::POST)
1698                    .uri("/app")
1699                    .header(header::CONTENT_TYPE, "application/json")
1700                    .body(Body::from(
1701                        serde_json::to_vec(&AppRequest::ConfigGet {
1702                            key: "api_key".to_string(),
1703                        })
1704                        .expect("request json"),
1705                    ))
1706                    .expect("request"),
1707            )
1708            .await
1709            .expect("response");
1710
1711        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1712    }
1713
1714    #[tokio::test]
1715    async fn http_config_get_redacts_sensitive_values_after_auth() {
1716        let (app, _tmp) = app_with_config(Some("test-token"));
1717        let response = app
1718            .oneshot(
1719                Request::builder()
1720                    .method(Method::POST)
1721                    .uri("/app")
1722                    .header(header::AUTHORIZATION, "Bearer test-token")
1723                    .header(header::CONTENT_TYPE, "application/json")
1724                    .body(Body::from(
1725                        serde_json::to_vec(&AppRequest::ConfigGet {
1726                            key: "api_key".to_string(),
1727                        })
1728                        .expect("request json"),
1729                    ))
1730                    .expect("request"),
1731            )
1732            .await
1733            .expect("response");
1734
1735        assert_eq!(response.status(), StatusCode::OK);
1736        let body = response_body_json(response).await;
1737        assert_eq!(body["data"]["value"], "sk-d***cret");
1738    }
1739
1740    #[tokio::test]
1741    async fn cors_does_not_allow_arbitrary_origins() {
1742        let (app, _tmp) = app_with_config(Some("test-token"));
1743        let response = app
1744            .oneshot(
1745                Request::builder()
1746                    .method(Method::GET)
1747                    .uri("/healthz")
1748                    .header(header::ORIGIN, "https://attacker.example")
1749                    .body(Body::empty())
1750                    .expect("request"),
1751            )
1752            .await
1753            .expect("response");
1754
1755        assert_eq!(response.status(), StatusCode::OK);
1756        assert!(
1757            response
1758                .headers()
1759                .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
1760                .is_none()
1761        );
1762    }
1763
1764    #[tokio::test]
1765    async fn build_state_loads_permissions_into_runtime_policy() {
1766        let tmp = tempfile::tempdir().expect("tempdir");
1767        let config_path = tmp.path().join("config.toml");
1768        fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1769        fs::write(
1770            tmp.path().join("permissions.toml"),
1771            r#"
1772            [[rules]]
1773            tool = "exec_shell"
1774            command = "cargo test"
1775            "#,
1776        )
1777        .expect("write permissions");
1778
1779        let state = build_state(Some(config_path), None).expect("state");
1780        let runtime = state.runtime.read().await;
1781        let decision = runtime
1782            .exec_policy
1783            .check(codewhale_execpolicy::ExecPolicyContext {
1784                command: "cargo test --workspace",
1785                cwd: "/workspace",
1786                tool: Some("exec_shell"),
1787                path: None,
1788                ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1789                sandbox_mode: Some("workspace-write"),
1790            })
1791            .expect("policy check");
1792
1793        assert!(decision.allow);
1794        assert!(decision.requires_approval);
1795        assert_eq!(
1796            decision.matched_rule.as_deref(),
1797            Some("tool=exec_shell command=cargo test")
1798        );
1799    }
1800
1801    #[tokio::test]
1802    async fn config_reload_refreshes_runtime_config_and_exec_policy_from_disk() {
1803        let tmp = tempfile::tempdir().expect("tempdir");
1804        let config_path = tmp.path().join("config.toml");
1805        fs::write(
1806            &config_path,
1807            "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
1808        )
1809        .expect("write config");
1810        // No permissions.toml at startup → exec_policy starts empty.
1811        let state = build_state(Some(config_path.clone()), None).expect("state");
1812
1813        // Sanity: initial runtime sees the on-disk model and has no rule.
1814        {
1815            let runtime = state.runtime.read().await;
1816            assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat"));
1817            let decision = runtime
1818                .exec_policy
1819                .check(codewhale_execpolicy::ExecPolicyContext {
1820                    command: "cargo test",
1821                    cwd: "/workspace",
1822                    tool: Some("exec_shell"),
1823                    path: None,
1824                    ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1825                    sandbox_mode: Some("workspace-write"),
1826                })
1827                .expect("policy check");
1828            assert!(decision.matched_rule.is_none());
1829        }
1830
1831        // Edit both files on disk: new model + a permission rule.
1832        fs::write(
1833            &config_path,
1834            "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-reasoner\"\n",
1835        )
1836        .expect("rewrite config");
1837        fs::write(
1838            tmp.path().join("permissions.toml"),
1839            r#"
1840            [[rules]]
1841            tool = "exec_shell"
1842            command = "cargo test"
1843            "#,
1844        )
1845        .expect("write permissions");
1846
1847        // ConfigReload must re-read both files and push them into the
1848        // live Runtime without a restart.
1849        let response =
1850            process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await;
1851        assert!(response.ok, "reload should succeed");
1852        assert_eq!(response.data["reloaded"], true);
1853
1854        // The shared config lock reflects the new model.
1855        {
1856            let cfg = state.config.read().await;
1857            assert_eq!(cfg.model.as_deref(), Some("deepseek-reasoner"));
1858        }
1859        // The live Runtime reflects both the new model and the new rule.
1860        {
1861            let runtime = state.runtime.read().await;
1862            assert_eq!(runtime.config.model.as_deref(), Some("deepseek-reasoner"));
1863            let decision = runtime
1864                .exec_policy
1865                .check(codewhale_execpolicy::ExecPolicyContext {
1866                    command: "cargo test --workspace",
1867                    cwd: "/workspace",
1868                    tool: Some("exec_shell"),
1869                    path: None,
1870                    ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1871                    sandbox_mode: Some("workspace-write"),
1872                })
1873                .expect("policy check");
1874            assert!(decision.allow);
1875            assert!(decision.requires_approval);
1876            assert_eq!(
1877                decision.matched_rule.as_deref(),
1878                Some("tool=exec_shell command=cargo test")
1879            );
1880        }
1881    }
1882
1883    #[tokio::test]
1884    async fn config_set_propagates_to_runtime_config_without_touching_exec_policy() {
1885        let tmp = tempfile::tempdir().expect("tempdir");
1886        let config_path = tmp.path().join("config.toml");
1887        fs::write(
1888            &config_path,
1889            "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
1890        )
1891        .expect("write config");
1892        let state = build_state(Some(config_path.clone()), None).expect("state");
1893
1894        // Set a new model via the API. Only config.toml is touched; no
1895        // permissions.toml exists, so exec_policy must stay empty.
1896        let response = process_app_request(
1897            &state,
1898            AppRequest::ConfigSet {
1899                key: "model".to_string(),
1900                value: "deepseek-reasoner".to_string(),
1901            },
1902            AppTransport::Stdio,
1903        )
1904        .await;
1905        assert!(response.ok, "set should succeed");
1906
1907        // Live runtime sees the new model.
1908        {
1909            let runtime = state.runtime.read().await;
1910            assert_eq!(runtime.config.model.as_deref(), Some("deepseek-reasoner"));
1911            // exec_policy was empty at startup and must remain empty.
1912            let decision = runtime
1913                .exec_policy
1914                .check(codewhale_execpolicy::ExecPolicyContext {
1915                    command: "cargo test",
1916                    cwd: "/workspace",
1917                    tool: Some("exec_shell"),
1918                    path: None,
1919                    ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1920                    sandbox_mode: Some("workspace-write"),
1921                })
1922                .expect("policy check");
1923            assert!(decision.matched_rule.is_none());
1924        }
1925        // The on-disk file was persisted.
1926        let persisted = fs::read_to_string(&config_path).expect("read config");
1927        assert!(persisted.contains("deepseek-reasoner"));
1928    }
1929
1930    #[tokio::test]
1931    async fn config_unset_propagates_to_runtime_config() {
1932        let tmp = tempfile::tempdir().expect("tempdir");
1933        let config_path = tmp.path().join("config.toml");
1934        fs::write(
1935            &config_path,
1936            "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
1937        )
1938        .expect("write config");
1939        let state = build_state(Some(config_path.clone()), None).expect("state");
1940
1941        // Sanity: runtime starts with the on-disk model.
1942        {
1943            let runtime = state.runtime.read().await;
1944            assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat"));
1945        }
1946
1947        // Unset the model via the API. This walks a separate code path
1948        // from ConfigSet (unset_value + update_config), so it needs its
1949        // own regression coverage.
1950        let response = process_app_request(
1951            &state,
1952            AppRequest::ConfigUnset {
1953                key: "model".to_string(),
1954            },
1955            AppTransport::Stdio,
1956        )
1957        .await;
1958        assert!(response.ok, "unset should succeed");
1959
1960        // Live runtime sees the cleared model.
1961        {
1962            let runtime = state.runtime.read().await;
1963            assert!(runtime.config.model.is_none());
1964        }
1965        // Shared config lock agrees.
1966        {
1967            let cfg = state.config.read().await;
1968            assert!(cfg.model.is_none());
1969        }
1970        // The on-disk file no longer carries the model value.
1971        let persisted = fs::read_to_string(&config_path).expect("read config");
1972        assert!(!persisted.contains("deepseek-chat"));
1973    }
1974
1975    #[tokio::test]
1976    async fn config_reload_returns_error_when_disk_config_is_invalid() {
1977        let tmp = tempfile::tempdir().expect("tempdir");
1978        let config_path = tmp.path().join("config.toml");
1979        fs::write(
1980            &config_path,
1981            "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
1982        )
1983        .expect("write config");
1984        let state = build_state(Some(config_path.clone()), None).expect("state");
1985
1986        // Corrupt the on-disk config so ConfigStore::load fails to parse.
1987        fs::write(&config_path, "api_key = \"unterminated\n").expect("corrupt config");
1988
1989        let response =
1990            process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await;
1991        assert!(!response.ok, "reload of corrupt config must fail");
1992        let err = response.data["error"]
1993            .as_str()
1994            .expect("error message present")
1995            .to_string();
1996        assert!(
1997            err.contains("failed to load config"),
1998            "error should mention load failure, got: {err}"
1999        );
2000
2001        // Live state is untouched: the early-return on load error must
2002        // not have clobbered runtime.config or state.config.
2003        {
2004            let runtime = state.runtime.read().await;
2005            assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat"));
2006        }
2007        {
2008            let cfg = state.config.read().await;
2009            assert_eq!(cfg.model.as_deref(), Some("deepseek-chat"));
2010        }
2011    }
2012
2013    async fn seed_test_bridge(state: &AppState) -> SharedRuntimeBridge {
2014        let bridge = Arc::new(Mutex::new(RuntimeBridge::from_base_url_for_test(
2015            "http://127.0.0.1:9".to_string(),
2016        )));
2017        *state.stdio_bridge.lock().await = Some(bridge.clone());
2018        bridge
2019    }
2020
2021    #[tokio::test]
2022    async fn config_set_invalidates_cached_stdio_bridge() {
2023        let tmp = tempfile::tempdir().expect("tempdir");
2024        let config_path = tmp.path().join("config.toml");
2025        fs::write(&config_path, "model = \"deepseek-chat\"\n").expect("write config");
2026        let state = build_state(Some(config_path), None).expect("state");
2027        seed_test_bridge(&state).await;
2028
2029        let response = process_app_request(
2030            &state,
2031            AppRequest::ConfigSet {
2032                key: "model".to_string(),
2033                value: "deepseek-reasoner".to_string(),
2034            },
2035            AppTransport::Stdio,
2036        )
2037        .await;
2038        assert!(response.ok, "set should succeed");
2039
2040        // The cached bridge child must be dropped so the next stdio request
2041        // spawns a fresh runtime that reads the persisted config.
2042        assert!(state.stdio_bridge.lock().await.is_none());
2043    }
2044
2045    #[tokio::test]
2046    async fn config_reload_invalidates_cached_stdio_bridge() {
2047        let tmp = tempfile::tempdir().expect("tempdir");
2048        let config_path = tmp.path().join("config.toml");
2049        fs::write(&config_path, "model = \"deepseek-chat\"\n").expect("write config");
2050        let state = build_state(Some(config_path), None).expect("state");
2051        seed_test_bridge(&state).await;
2052
2053        let response =
2054            process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await;
2055        assert!(response.ok, "reload should succeed");
2056
2057        assert!(state.stdio_bridge.lock().await.is_none());
2058    }
2059
2060    #[tokio::test]
2061    async fn stdio_bridge_invalidation_not_blocked_by_in_flight_turn() {
2062        let (state, _tmp) = capability_test_state();
2063        let bridge = seed_test_bridge(&state).await;
2064
2065        // Simulate a long streaming turn holding the inner bridge lock.
2066        let _in_flight = bridge.lock().await;
2067
2068        // Invalidation only touches the cache slot, so it must complete
2069        // without waiting for the in-flight turn to release the bridge.
2070        tokio::time::timeout(Duration::from_secs(1), invalidate_stdio_bridge(&state))
2071            .await
2072            .expect("invalidation must not wait on bridge traffic");
2073        assert!(state.stdio_bridge.lock().await.is_none());
2074    }
2075
2076    #[tokio::test]
2077    async fn runtime_read_paths_run_concurrently() {
2078        // Tool/status/mcp handlers take read guards; two must coexist so a
2079        // long-running tool call cannot serialize unrelated requests. With
2080        // the old `Mutex<Runtime>` this pattern would deadlock.
2081        let (state, _tmp) = capability_test_state();
2082        let first = state.runtime.read().await;
2083        let second = state.runtime.read().await;
2084        assert!(first.app_status().ok);
2085        assert!(second.app_status().ok);
2086    }
2087
2088    #[tokio::test]
2089    async fn health_probes_advertise_legacy_deepseek_service_name() {
2090        // External probes still key off the DeepSeek-era service name; both
2091        // transports must serve it from the single compat shim.
2092        let (app, _tmp) = app_with_config(None);
2093        let response = app
2094            .oneshot(
2095                Request::builder()
2096                    .method(Method::GET)
2097                    .uri("/healthz")
2098                    .body(Body::empty())
2099                    .expect("request"),
2100            )
2101            .await
2102            .expect("response");
2103        let body = response_body_json(response).await;
2104        assert_eq!(body["service"], legacy_deepseek_compat::SERVICE_NAME);
2105        assert_eq!(body["service"], "deepseek-app-server");
2106
2107        let (state, _tmp) = capability_test_state();
2108        let stdio = dispatch_stdio_request(&state, "healthz", json!({}))
2109            .await
2110            .expect("stdio healthz");
2111        assert_eq!(
2112            stdio.result["service"],
2113            legacy_deepseek_compat::SERVICE_NAME
2114        );
2115    }
2116
2117    #[test]
2118    fn non_loopback_bind_without_auth_fails_fast() {
2119        let options = AppServerOptions {
2120            listen: "0.0.0.0:8787".parse().expect("socket addr"),
2121            config_path: None,
2122            auth_token: None,
2123            insecure_no_auth: false,
2124            cors_origins: Vec::new(),
2125        };
2126
2127        let err =
2128            resolve_auth_token(&options).expect_err("non-loopback generated auth should fail");
2129        assert!(err.to_string().contains("without explicit auth token"));
2130    }
2131
2132    #[tokio::test]
2133    async fn stdio_transport_keeps_raw_config_get_for_legacy_clients() {
2134        let tmp = tempfile::tempdir().expect("tempdir");
2135        let config_path = tmp.path().join("config.toml");
2136        fs::write(&config_path, "").expect("write config");
2137        let state = build_state(Some(config_path), None).expect("state");
2138        {
2139            let mut cfg = state.config.write().await;
2140            cfg.api_key = Some("sk-deepseek-secret".to_string());
2141        }
2142
2143        let response = process_app_request(
2144            &state,
2145            AppRequest::ConfigGet {
2146                key: "api_key".to_string(),
2147            },
2148            AppTransport::Stdio,
2149        )
2150        .await;
2151
2152        assert_eq!(response.data["value"], "sk-deepseek-secret");
2153    }
2154
2155    #[tokio::test]
2156    async fn stdio_thread_goal_methods_round_trip_persisted_goal() {
2157        let tmp = tempfile::tempdir().expect("tempdir");
2158        let config_path = tmp.path().join("config.toml");
2159        fs::write(&config_path, "").expect("write config");
2160        let state = build_state(Some(config_path), None).expect("state");
2161
2162        let capabilities = dispatch_stdio_request(&state, "thread/capabilities", json!({}))
2163            .await
2164            .expect("thread capabilities");
2165        assert!(
2166            capabilities.result["methods"]
2167                .as_array()
2168                .expect("methods")
2169                .iter()
2170                .any(|method| method == "thread/goal/set")
2171        );
2172
2173        let started = dispatch_stdio_request(&state, "thread/start", json!({}))
2174            .await
2175            .expect("start thread");
2176        let thread_id = started.result["thread_id"]
2177            .as_str()
2178            .expect("thread id")
2179            .to_string();
2180
2181        let set = dispatch_stdio_request(
2182            &state,
2183            "thread/goal/set",
2184            json!({
2185                "thread_id": thread_id,
2186                "objective": "Release 0.8.59",
2187                "token_budget": 59000
2188            }),
2189        )
2190        .await
2191        .expect("set goal");
2192        assert_eq!(set.result["status"], "ok");
2193        assert_eq!(set.result["goal"]["objective"], "Release 0.8.59");
2194        assert_eq!(set.result["goal"]["status"], "active");
2195
2196        let got = dispatch_stdio_request(
2197            &state,
2198            "thread/goal/get",
2199            json!({
2200                "thread_id": thread_id
2201            }),
2202        )
2203        .await
2204        .expect("get goal");
2205        assert_eq!(got.result["goal"]["token_budget"], 59000);
2206
2207        let cleared = dispatch_stdio_request(
2208            &state,
2209            "thread/goal/clear",
2210            json!({
2211                "thread_id": thread_id
2212            }),
2213        )
2214        .await
2215        .expect("clear goal");
2216        assert_eq!(cleared.result["status"], "cleared");
2217        assert_eq!(cleared.result["data"]["cleared"], true);
2218    }
2219
2220    fn sse_frame(event: &str, payload: Value) -> String {
2221        format!("event: {event}\ndata: {payload}\n\n")
2222    }
2223
2224    #[tokio::test]
2225    async fn stdio_runtime_bridge_streams_response_delta_events() {
2226        async fn create_turn(AxumPath(thread_id): AxumPath<String>) -> Json<Value> {
2227            Json(json!({
2228                "thread": { "id": thread_id },
2229                "turn": { "id": "turn_test" },
2230            }))
2231        }
2232
2233        async fn thread_events(
2234            AxumPath(thread_id): AxumPath<String>,
2235            Query(query): Query<HashMap<String, String>>,
2236        ) -> ([(header::HeaderName, &'static str); 1], String) {
2237            assert_eq!(thread_id, "thr_test");
2238            assert_eq!(query.get("since_seq").map(String::as_str), Some("0"));
2239
2240            let body = [
2241                sse_frame(
2242                    "item.delta",
2243                    json!({
2244                        "seq": 1,
2245                        "turn_id": "turn_test",
2246                        "payload": {
2247                            "kind": "agent_message",
2248                            "delta": "hello"
2249                        }
2250                    }),
2251                ),
2252                sse_frame(
2253                    "turn.completed",
2254                    json!({
2255                        "seq": 2,
2256                        "turn_id": "turn_test",
2257                        "payload": {
2258                            "turn": {
2259                                "status": "completed"
2260                            }
2261                        }
2262                    }),
2263                ),
2264            ]
2265            .concat();
2266
2267            ([(header::CONTENT_TYPE, "text/event-stream")], body)
2268        }
2269
2270        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2271            .await
2272            .expect("bind test listener");
2273        let addr = listener.local_addr().expect("listener addr");
2274        let app = Router::new()
2275            .route("/v1/threads/{thread_id}/turns", post(create_turn))
2276            .route("/v1/threads/{thread_id}/events", get(thread_events));
2277
2278        let server = tokio::spawn(async move {
2279            axum::serve(listener, app)
2280                .await
2281                .expect("serve test runtime");
2282        });
2283
2284        let mut bridge = RuntimeBridge::from_base_url_for_test(format!("http://{addr}"));
2285        let (mut reader, mut writer) = tokio::io::duplex(4096);
2286
2287        let result = bridge
2288            .message_thread("thr_test", "hello", &mut writer)
2289            .await
2290            .expect("message_thread should succeed");
2291        drop(writer);
2292
2293        let mut stdout = Vec::new();
2294        reader
2295            .read_to_end(&mut stdout)
2296            .await
2297            .expect("read stdio output");
2298        server.abort();
2299        let _ = server.await;
2300
2301        let lines: Vec<Value> = String::from_utf8(stdout)
2302            .expect("utf8 output")
2303            .lines()
2304            .map(|line| serde_json::from_str(line).expect("json line"))
2305            .collect();
2306
2307        assert_eq!(
2308            result.get("status").and_then(Value::as_str),
2309            Some("accepted")
2310        );
2311        assert_eq!(
2312            result.pointer("/data/turn_id").and_then(Value::as_str),
2313            Some("turn_test")
2314        );
2315        assert_eq!(bridge.last_seq_by_thread.get("thr_test"), Some(&2));
2316
2317        let event_types: Vec<&str> = lines
2318            .iter()
2319            .map(|line| {
2320                line.get("type")
2321                    .and_then(Value::as_str)
2322                    .expect("event type")
2323            })
2324            .collect();
2325        assert_eq!(
2326            event_types,
2327            vec!["response_start", "response_delta", "response_end"]
2328        );
2329        assert_eq!(lines[1]["delta"], "hello");
2330    }
2331
2332    #[tokio::test]
2333    async fn stdio_runtime_bridge_applies_thread_start_hints() {
2334        async fn create_thread(Json(body): Json<Value>) -> Json<Value> {
2335            assert_eq!(body["model"], "deepseek-v4");
2336            assert_eq!(body["workspace"], "/tmp/codewhale-stdio");
2337            Json(json!({
2338                "id": "thr_runtime",
2339                "model": body["model"].clone(),
2340                "workspace": body["workspace"].clone(),
2341            }))
2342        }
2343
2344        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2345            .await
2346            .expect("bind test listener");
2347        let addr = listener.local_addr().expect("listener addr");
2348        let app = Router::new().route("/v1/threads", post(create_thread));
2349
2350        let server = tokio::spawn(async move {
2351            axum::serve(listener, app)
2352                .await
2353                .expect("serve test runtime");
2354        });
2355
2356        let mut bridge = RuntimeBridge::from_base_url_for_test(format!("http://{addr}"));
2357        let runtime_id = bridge
2358            .ensure_runtime_thread(
2359                "legacy_thread",
2360                Some(RuntimeThreadHint {
2361                    model: Some("deepseek-v4".to_string()),
2362                    workspace: Some(PathBuf::from("/tmp/codewhale-stdio")),
2363                }),
2364            )
2365            .await
2366            .expect("runtime thread");
2367        server.abort();
2368        let _ = server.await;
2369
2370        assert_eq!(runtime_id, "thr_runtime");
2371        assert_eq!(
2372            bridge.thread_map.get("legacy_thread").map(String::as_str),
2373            Some("thr_runtime")
2374        );
2375    }
2376
2377    // ── capability drift guard ─────────────────────────────────────────
2378    //
2379    // The stdio `capabilities` method is the benchmark/SDK contract: external
2380    // harnesses probe it (without spending model tokens) to learn what the
2381    // app-server can do. Pin the advertised method set so any change forces a
2382    // deliberate update here, in the dispatcher, and in docs/RUNTIME_API.md.
2383
2384    /// Methods advertised by the top-level `capabilities` probe, in order.
2385    const EXPECTED_CAPABILITY_METHODS: &[&str] = &[
2386        "healthz",
2387        "thread/capabilities",
2388        "thread/request",
2389        "thread/create",
2390        "thread/start",
2391        "thread/resume",
2392        "thread/fork",
2393        "thread/list",
2394        "thread/read",
2395        "thread/set_name",
2396        "thread/goal/set",
2397        "thread/goal/get",
2398        "thread/goal/clear",
2399        "thread/archive",
2400        "thread/unarchive",
2401        "thread/message",
2402        "app/capabilities",
2403        "app/request",
2404        "app/config/get",
2405        "app/config/set",
2406        "app/config/unset",
2407        "app/config/list",
2408        "app/config/reload",
2409        "app/models",
2410        "app/thread_loaded_list",
2411        "prompt/capabilities",
2412        "prompt/request",
2413        "prompt/run",
2414        "shutdown",
2415    ];
2416
2417    fn capability_test_state() -> (AppState, tempfile::TempDir) {
2418        let tmp = tempfile::tempdir().expect("tempdir");
2419        let config_path = tmp.path().join("config.toml");
2420        fs::write(&config_path, "").expect("write config");
2421        let state = build_state(Some(config_path), None).expect("state");
2422        (state, tmp)
2423    }
2424
2425    #[tokio::test]
2426    async fn capabilities_method_set_is_stable() {
2427        let (state, _tmp) = capability_test_state();
2428        let caps = dispatch_stdio_request(&state, "capabilities", json!({}))
2429            .await
2430            .expect("capabilities dispatch");
2431        let methods: Vec<String> = caps.result["methods"]
2432            .as_array()
2433            .expect("methods array")
2434            .iter()
2435            .map(|m| m.as_str().expect("method string").to_string())
2436            .collect();
2437        assert_eq!(
2438            methods, EXPECTED_CAPABILITY_METHODS,
2439            "app-server stdio capability set drifted; update the dispatcher, this \
2440             snapshot, and docs/RUNTIME_API.md together"
2441        );
2442    }
2443
2444    #[tokio::test]
2445    async fn every_advertised_capability_is_dispatchable() {
2446        let (state, _tmp) = capability_test_state();
2447        // Empty params: methods may fail validation (-32602), but none may report
2448        // method-not-found (-32601). Required fields (e.g. PromptRequest.prompt)
2449        // make the prompt routes fail at parse time, so no model tokens are spent.
2450        for method in EXPECTED_CAPABILITY_METHODS {
2451            if let Err(err) = dispatch_stdio_request(&state, method, json!({})).await {
2452                assert_ne!(
2453                    err.code,
2454                    JsonRpcError::method_not_found(method).code,
2455                    "advertised capability `{method}` is not dispatchable"
2456                );
2457            }
2458        }
2459    }
2460
2461    // ── resolve_auth_token ─────────────────────────────────────────────
2462
2463    #[test]
2464    fn auth_token_empty_string_fails() {
2465        let options = AppServerOptions {
2466            listen: "127.0.0.1:0".parse().expect("addr"),
2467            config_path: None,
2468            auth_token: Some("  ".to_string()),
2469            insecure_no_auth: false,
2470            cors_origins: Vec::new(),
2471        };
2472        let err = resolve_auth_token(&options).expect_err("empty token should fail");
2473        assert!(err.to_string().contains("cannot be empty"));
2474    }
2475
2476    #[test]
2477    fn auth_token_generated_when_none_provided() {
2478        let options = AppServerOptions {
2479            listen: "127.0.0.1:0".parse().expect("addr"),
2480            config_path: None,
2481            auth_token: None,
2482            insecure_no_auth: false,
2483            cors_origins: Vec::new(),
2484        };
2485        let token = resolve_auth_token(&options).unwrap();
2486        assert!(token.is_some());
2487        assert!(token.unwrap().starts_with("cwapp_"));
2488    }
2489
2490    #[test]
2491    fn generated_auth_status_does_not_render_token() {
2492        let rendered = app_server_auth_status_lines(false).join("\n");
2493
2494        assert!(!rendered.contains("Authorization: Bearer"));
2495        assert!(rendered.contains("not printed"));
2496        assert!(rendered.contains("CODEWHALE_APP_SERVER_TOKEN"));
2497    }
2498
2499    #[test]
2500    fn auth_token_explicit_is_preserved() {
2501        let options = AppServerOptions {
2502            listen: "127.0.0.1:0".parse().expect("addr"),
2503            config_path: None,
2504            auth_token: Some("my-secret".to_string()),
2505            insecure_no_auth: false,
2506            cors_origins: Vec::new(),
2507        };
2508        let token = resolve_auth_token(&options).unwrap();
2509        assert_eq!(token.as_deref(), Some("my-secret"));
2510    }
2511
2512    #[test]
2513    fn auth_token_explicit_allows_non_loopback_bind() {
2514        let options = AppServerOptions {
2515            listen: "0.0.0.0:8787".parse().expect("socket addr"),
2516            config_path: None,
2517            auth_token: Some("my-secret".to_string()),
2518            insecure_no_auth: false,
2519            cors_origins: Vec::new(),
2520        };
2521        let token = resolve_auth_token(&options).unwrap();
2522        assert_eq!(token.as_deref(), Some("my-secret"));
2523    }
2524
2525    #[test]
2526    fn insecure_no_auth_on_loopback_returns_none() {
2527        let options = AppServerOptions {
2528            listen: "127.0.0.1:0".parse().expect("addr"),
2529            config_path: None,
2530            auth_token: None,
2531            insecure_no_auth: true,
2532            cors_origins: Vec::new(),
2533        };
2534        let token = resolve_auth_token(&options).unwrap();
2535        assert!(token.is_none());
2536    }
2537
2538    #[test]
2539    fn insecure_no_auth_on_non_loopback_fails_fast() {
2540        let options = AppServerOptions {
2541            listen: "0.0.0.0:8787".parse().expect("socket addr"),
2542            config_path: None,
2543            auth_token: None,
2544            insecure_no_auth: true,
2545            cors_origins: Vec::new(),
2546        };
2547
2548        let err = resolve_auth_token(&options).expect_err("non-loopback unauth should fail");
2549        assert!(
2550            err.to_string()
2551                .contains("refusing unauthenticated app-server bind")
2552        );
2553    }
2554
2555    // ── cors_layer ─────────────────────────────────────────────────────
2556
2557    #[test]
2558    fn cors_layer_includes_default_origins() {
2559        let layer = cors_layer(&[]);
2560        // Just verify it doesn't panic and creates successfully
2561        let _ = layer;
2562    }
2563
2564    #[test]
2565    fn cors_layer_adds_extra_origins() {
2566        let extras = vec!["https://example.com".to_string()];
2567        let layer = cors_layer(&extras);
2568        let _ = layer;
2569    }
2570
2571    #[test]
2572    fn cors_layer_skips_empty_origins() {
2573        let extras = vec!["".to_string(), "  ".to_string()];
2574        let layer = cors_layer(&extras);
2575        let _ = layer;
2576    }
2577
2578    // ── JsonRpc helpers ────────────────────────────────────────────────
2579
2580    #[test]
2581    fn params_or_object_returns_object_for_null() {
2582        let result = params_or_object(Value::Null);
2583        assert_eq!(result, json!({}));
2584    }
2585
2586    #[test]
2587    fn params_or_object_passthrough_for_non_null() {
2588        let input = json!({"key": "value"});
2589        let result = params_or_object(input.clone());
2590        assert_eq!(result, input);
2591    }
2592
2593    #[test]
2594    fn jsonrpc_result_format() {
2595        let result = jsonrpc_result(Some(json!(1)), json!({"ok": true}));
2596        assert_eq!(result["jsonrpc"], "2.0");
2597        assert_eq!(result["id"], 1);
2598        assert_eq!(result["result"]["ok"], true);
2599    }
2600
2601    #[test]
2602    fn jsonrpc_result_null_id() {
2603        let result = jsonrpc_result(None, json!(null));
2604        assert_eq!(result["id"], Value::Null);
2605    }
2606
2607    #[test]
2608    fn jsonrpc_error_format() {
2609        let err = jsonrpc_error(Some(json!(2)), JsonRpcError::internal("oops"));
2610        assert_eq!(err["jsonrpc"], "2.0");
2611        assert_eq!(err["id"], 2);
2612        assert_eq!(err["error"]["code"], -32603);
2613        assert_eq!(err["error"]["message"], "oops");
2614    }
2615
2616    #[test]
2617    fn jsonrpc_error_codes() {
2618        assert_eq!(JsonRpcError::parse_error("").code, -32700);
2619        assert_eq!(JsonRpcError::invalid_request("").code, -32600);
2620        assert_eq!(JsonRpcError::method_not_found("x").code, -32601);
2621        assert_eq!(JsonRpcError::invalid_params("").code, -32602);
2622        assert_eq!(JsonRpcError::internal("").code, -32603);
2623    }
2624
2625    // ── AppServerOptions ───────────────────────────────────────────────
2626
2627    #[test]
2628    fn app_server_options_debug_does_not_leak_token() {
2629        let options = AppServerOptions {
2630            listen: "127.0.0.1:8080".parse().expect("addr"),
2631            config_path: None,
2632            auth_token: Some("secret-token".to_string()),
2633            insecure_no_auth: false,
2634            cors_origins: vec!["https://example.com".to_string()],
2635        };
2636        let debug = format!("{options:?}");
2637        assert!(!debug.contains("secret-token"));
2638        assert!(debug.contains("<redacted>"));
2639        assert!(debug.contains("8080"));
2640    }
2641
2642    // ── Default CORS origins ──────────────────────────────────────────
2643
2644    #[test]
2645    fn default_cors_origins_include_common_dev_ports() {
2646        assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:3000"));
2647        assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:5173"));
2648        assert!(DEFAULT_CORS_ORIGINS.contains(&"tauri://localhost"));
2649    }
2650}