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