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        // Pass the runtime auth token out-of-band via env (not argv) so local
825        // `ps` cannot read credential material from the child command line.
826        // The TUI/runtime server already accepts CODEWHALE_RUNTIME_TOKEN /
827        // DEEPSEEK_RUNTIME_TOKEN when --auth-token is absent.
828        command
829            .arg("app-server")
830            .arg("--http")
831            .arg("--host")
832            .arg("127.0.0.1")
833            .arg("--port")
834            .arg(port.to_string())
835            .env("CODEWHALE_RUNTIME_TOKEN", auth_token)
836            .env("DEEPSEEK_RUNTIME_TOKEN", auth_token)
837            .stdin(Stdio::null())
838            .stdout(Stdio::null())
839            .stderr(Stdio::null());
840        if let Some(config_path) = config_path {
841            command.arg("--config").arg(config_path);
842        }
843        Ok(command)
844    }
845
846    async fn wait_until_ready(&mut self) -> Result<()> {
847        let deadline = Instant::now() + Duration::from_secs(15);
848        loop {
849            if let Some(child) = self.child.as_mut()
850                && let Some(status) = child.try_wait()?
851            {
852                return Err(anyhow!(
853                    "runtime API bridge exited before becoming ready (status {status})"
854                ));
855            }
856
857            match self
858                .client
859                .get(format!("{}/health", self.base_url))
860                .send()
861                .await
862            {
863                Ok(response) if response.status().is_success() => return Ok(()),
864                _ if Instant::now() >= deadline => {
865                    bail!(
866                        "timed out waiting for runtime API bridge at {}/health",
867                        self.base_url
868                    )
869                }
870                _ => tokio::time::sleep(Duration::from_millis(50)).await,
871            }
872        }
873    }
874
875    fn authed(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
876        match self.auth_token.as_deref() {
877            Some(token) => builder.bearer_auth(token),
878            None => builder,
879        }
880    }
881
882    async fn request_json(&self, builder: reqwest::RequestBuilder) -> Result<Value> {
883        let response = builder.send().await?;
884        let status = response.status();
885        let body = response.text().await?;
886        if !status.is_success() {
887            let detail = body.trim();
888            if detail.is_empty() {
889                bail!("runtime API returned {status}");
890            }
891            bail!("runtime API returned {status}: {detail}");
892        }
893        serde_json::from_str(&body).with_context(|| format!("invalid runtime API json: {body}"))
894    }
895
896    async fn ensure_runtime_thread(
897        &mut self,
898        stdio_thread_id: &str,
899        hint: Option<RuntimeThreadHint>,
900    ) -> Result<String> {
901        if let Some(runtime_thread_id) = self.thread_map.get(stdio_thread_id) {
902            return Ok(runtime_thread_id.clone());
903        }
904        let hint = hint.unwrap_or_default();
905        let runtime_thread_id = self
906            .create_runtime_thread(hint.model, hint.workspace)
907            .await?;
908        self.thread_map
909            .insert(stdio_thread_id.to_string(), runtime_thread_id.clone());
910        Ok(runtime_thread_id)
911    }
912
913    async fn create_runtime_thread(
914        &mut self,
915        model: Option<String>,
916        workspace: Option<PathBuf>,
917    ) -> Result<String> {
918        let record = self
919            .request_json(
920                self.authed(self.client.post(format!("{}/v1/threads", self.base_url)))
921                    .json(&json!({
922                        "model": model,
923                        "workspace": workspace,
924                        "mode": "agent",
925                        "archived": false,
926                    })),
927            )
928            .await?;
929        let thread_id = extract_runtime_thread_id(&record)?.to_string();
930        self.last_seq_by_thread
931            .entry(thread_id.clone())
932            .or_insert(0);
933        Ok(thread_id)
934    }
935
936    async fn message_thread<W: AsyncWrite + Unpin>(
937        &mut self,
938        thread_id: &str,
939        input: &str,
940        writer: &mut W,
941    ) -> Result<Value> {
942        let turn = self
943            .request_json(
944                self.authed(
945                    self.client
946                        .post(format!("{}/v1/threads/{thread_id}/turns", self.base_url)),
947                )
948                .json(&json!({ "prompt": input })),
949            )
950            .await?;
951        let turn_id = turn
952            .pointer("/turn/id")
953            .and_then(Value::as_str)
954            .ok_or_else(|| anyhow!("runtime API turn response missing turn.id"))?
955            .to_string();
956        let response_id = format!("{thread_id}:{turn_id}");
957
958        emit_stdio_event(
959            writer,
960            json!({
961                "type": "response_start",
962                "response_id": response_id,
963            }),
964        )
965        .await?;
966
967        let since_seq = self.last_seq_by_thread.get(thread_id).copied().unwrap_or(0);
968        let stream_result = self
969            .stream_turn_events(thread_id, &turn_id, &response_id, writer, since_seq)
970            .await;
971
972        let _ = emit_stdio_event(
973            writer,
974            json!({
975                "type": "response_end",
976                "response_id": response_id,
977            }),
978        )
979        .await;
980
981        let (last_seq, status, error) = stream_result?;
982        self.last_seq_by_thread
983            .insert(thread_id.to_string(), last_seq);
984
985        match status {
986            TurnTerminalStatus::Completed => Ok(json!({
987                "thread_id": thread_id,
988                "status": "accepted",
989                "thread": Value::Null,
990                "threads": [],
991                "model": Value::Null,
992                "model_provider": Value::Null,
993                "cwd": Value::Null,
994                "approval_policy": Value::Null,
995                "sandbox": Value::Null,
996                "events": [],
997                "data": { "turn_id": turn_id },
998            })),
999            TurnTerminalStatus::Failed => Err(anyhow!(
1000                "{}",
1001                error.unwrap_or_else(|| "turn failed".to_string())
1002            )),
1003            TurnTerminalStatus::Interrupted => Err(anyhow!(
1004                "{}",
1005                error.unwrap_or_else(|| "turn interrupted".to_string())
1006            )),
1007            TurnTerminalStatus::Canceled => Err(anyhow!(
1008                "{}",
1009                error.unwrap_or_else(|| "turn canceled".to_string())
1010            )),
1011        }
1012    }
1013
1014    async fn stream_turn_events<W: AsyncWrite + Unpin>(
1015        &self,
1016        thread_id: &str,
1017        turn_id: &str,
1018        response_id: &str,
1019        writer: &mut W,
1020        since_seq: u64,
1021    ) -> Result<(u64, TurnTerminalStatus, Option<String>)> {
1022        let mut response = self
1023            .authed(self.client.get(format!(
1024                "{}/v1/threads/{thread_id}/events?since_seq={since_seq}",
1025                self.base_url
1026            )))
1027            .send()
1028            .await?
1029            .error_for_status()?;
1030
1031        let mut buffer = Vec::new();
1032        let mut last_seq = since_seq;
1033
1034        while let Some(chunk) = response.chunk().await? {
1035            buffer.extend_from_slice(&chunk);
1036            if buffer.len() > MAX_SSE_FRAME_BYTES {
1037                bail!(
1038                    "runtime SSE frame exceeded {MAX_SSE_FRAME_BYTES} bytes without a frame delimiter"
1039                );
1040            }
1041            while let Some(frame_bytes) = take_sse_frame(&mut buffer) {
1042                let Some((event_name, frame_data)) = parse_sse_frame(&frame_bytes) else {
1043                    continue;
1044                };
1045                let envelope: Value = serde_json::from_str(&frame_data)
1046                    .with_context(|| format!("invalid SSE json for {event_name}: {frame_data}"))?;
1047                if let Some(seq) = envelope.get("seq").and_then(Value::as_u64) {
1048                    last_seq = last_seq.max(seq);
1049                }
1050                if envelope.get("turn_id").and_then(Value::as_str) != Some(turn_id) {
1051                    continue;
1052                }
1053                let payload = envelope.get("payload").cloned().unwrap_or(Value::Null);
1054                match event_name.as_str() {
1055                    "item.delta" => {
1056                        let kind = payload
1057                            .get("kind")
1058                            .and_then(Value::as_str)
1059                            .unwrap_or_default();
1060                        if kind == "agent_message"
1061                            && let Some(delta) = payload.get("delta").and_then(Value::as_str)
1062                            && !delta.is_empty()
1063                        {
1064                            emit_stdio_event(
1065                                writer,
1066                                json!({
1067                                    "type": "response_delta",
1068                                    "response_id": response_id,
1069                                    "delta": delta,
1070                                }),
1071                            )
1072                            .await?;
1073                        }
1074                    }
1075                    "turn.completed" => {
1076                        let status = turn_terminal_status(&payload);
1077                        let error = payload
1078                            .pointer("/turn/error")
1079                            .and_then(Value::as_str)
1080                            .map(str::to_string);
1081                        return Ok((last_seq, status, error));
1082                    }
1083                    _ => {}
1084                }
1085            }
1086        }
1087
1088        bail!("runtime event stream ended before turn.completed")
1089    }
1090
1091    #[cfg(test)]
1092    fn from_base_url_for_test(base_url: String) -> Self {
1093        install_rustls_crypto_provider();
1094        Self {
1095            base_url,
1096            client: codewhale_release::platform_http_client_builder()
1097                .timeout(Duration::from_secs(5))
1098                .build()
1099                .expect("build reqwest test client"),
1100            auth_token: None,
1101            child: None,
1102            thread_map: HashMap::new(),
1103            last_seq_by_thread: HashMap::new(),
1104        }
1105    }
1106}
1107
1108impl RuntimeBridge {
1109    /// Kills the managed runtime child and reaps it on a detached thread so
1110    /// neither an explicit shutdown nor Drop blocks a Tokio runtime thread.
1111    fn shutdown_child(&mut self) {
1112        if let Some(mut child) = self.child.take() {
1113            let _ = child.kill();
1114            std::thread::spawn(move || {
1115                let _ = child.wait();
1116            });
1117        }
1118    }
1119}
1120
1121impl Drop for RuntimeBridge {
1122    fn drop(&mut self) {
1123        self.shutdown_child();
1124    }
1125}
1126
1127fn reserve_runtime_port() -> Result<u16> {
1128    let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
1129    Ok(listener.local_addr()?.port())
1130}
1131
1132fn install_rustls_crypto_provider() {
1133    let _ = rustls::crypto::ring::default_provider().install_default();
1134}
1135
1136fn extract_runtime_thread_id(record: &Value) -> Result<&str> {
1137    record
1138        .get("id")
1139        .and_then(Value::as_str)
1140        .ok_or_else(|| anyhow!("runtime API thread response missing id"))
1141}
1142
1143fn turn_terminal_status(payload: &Value) -> TurnTerminalStatus {
1144    match payload
1145        .pointer("/turn/status")
1146        .and_then(Value::as_str)
1147        .unwrap_or("completed")
1148        .to_ascii_lowercase()
1149        .as_str()
1150    {
1151        "failed" => TurnTerminalStatus::Failed,
1152        "interrupted" => TurnTerminalStatus::Interrupted,
1153        "canceled" | "cancelled" => TurnTerminalStatus::Canceled,
1154        _ => TurnTerminalStatus::Completed,
1155    }
1156}
1157
1158async fn emit_stdio_event<W: AsyncWrite + Unpin>(writer: &mut W, event: Value) -> Result<()> {
1159    writer.write_all(&serde_json::to_vec(&event)?).await?;
1160    writer.write_all(b"\n").await?;
1161    writer.flush().await?;
1162    Ok(())
1163}
1164
1165fn take_sse_frame(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
1166    if let Some(pos) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
1167        return Some(buffer.drain(..pos + 4).collect());
1168    }
1169    buffer
1170        .windows(2)
1171        .position(|window| window == b"\n\n")
1172        .map(|pos| buffer.drain(..pos + 2).collect())
1173}
1174
1175fn parse_sse_frame(frame_bytes: &[u8]) -> Option<(String, String)> {
1176    let text = String::from_utf8(frame_bytes.to_vec()).ok()?;
1177    let mut event_name = None;
1178    let mut data_lines = Vec::new();
1179    for raw_line in text.lines() {
1180        let line = raw_line.trim_end_matches('\r');
1181        if let Some(value) = line.strip_prefix("event:") {
1182            event_name = Some(value.trim().to_string());
1183        } else if let Some(value) = line.strip_prefix("data:") {
1184            data_lines.push(value.trim_start().to_string());
1185        }
1186    }
1187    match (event_name, data_lines.is_empty()) {
1188        (Some(event), false) => Some((event, data_lines.join("\n"))),
1189        _ => None,
1190    }
1191}
1192
1193#[cfg(test)]
1194async fn dispatch_stdio_request(
1195    state: &AppState,
1196    method: &str,
1197    params: Value,
1198) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
1199    let mut sink = tokio::io::sink();
1200    dispatch_stdio_request_with_writer(state, &mut sink, method, params).await
1201}
1202
1203async fn dispatch_stdio_app_request(
1204    state: &AppState,
1205    request: AppRequest,
1206) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
1207    let response = Box::pin(process_app_request(state, request, AppTransport::Stdio)).await;
1208    Ok(StdioDispatchResult {
1209        result: serde_json::to_value(response)
1210            .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1211        should_exit: false,
1212    })
1213}
1214
1215async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
1216    state: &AppState,
1217    writer: &mut W,
1218    method: &str,
1219    params: Value,
1220) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
1221    let outcome = match method {
1222        "healthz" | "app/healthz" => StdioDispatchResult {
1223            result: json!({
1224                "status": "ok",
1225                "service": legacy_deepseek_compat::SERVICE_NAME,
1226                "transport": "stdio"
1227            }),
1228            should_exit: false,
1229        },
1230        "capabilities" => StdioDispatchResult {
1231            result: json!({
1232                "transport": "stdio",
1233                "families": ["thread/*", "app/*", "prompt/*"],
1234                "methods": [
1235                    "healthz",
1236                    "thread/capabilities",
1237                    "thread/request",
1238                    "thread/create",
1239                    "thread/start",
1240                    "thread/resume",
1241                    "thread/fork",
1242                    "thread/list",
1243                    "thread/read",
1244                    "thread/set_name",
1245                    "thread/goal/set",
1246                    "thread/goal/get",
1247                    "thread/goal/clear",
1248                    "thread/archive",
1249                    "thread/unarchive",
1250                    "thread/message",
1251                    "app/capabilities",
1252                    "app/request",
1253                    "app/config/get",
1254                    "app/config/set",
1255                    "app/config/unset",
1256                    "app/config/list",
1257                    "app/config/reload",
1258                    "app/models",
1259                    "app/thread_loaded_list",
1260                    "prompt/capabilities",
1261                    "prompt/request",
1262                    "prompt/run",
1263                    "shutdown"
1264                ]
1265            }),
1266            should_exit: false,
1267        },
1268        "thread/capabilities" => StdioDispatchResult {
1269            result: json!({
1270                "methods": [
1271                    "thread/request",
1272                    "thread/create",
1273                    "thread/start",
1274                    "thread/resume",
1275                    "thread/fork",
1276                    "thread/list",
1277                    "thread/read",
1278                    "thread/set_name",
1279                    "thread/goal/set",
1280                    "thread/goal/get",
1281                    "thread/goal/clear",
1282                    "thread/archive",
1283                    "thread/unarchive",
1284                    "thread/message"
1285                ]
1286            }),
1287            should_exit: false,
1288        },
1289        "thread/request" => {
1290            let request: ThreadRequest = parse_params(params)?;
1291            if let ThreadRequest::Message { thread_id, input } = request {
1292                let response = handle_stdio_thread_message(
1293                    state,
1294                    writer,
1295                    ThreadMessageParams { thread_id, input },
1296                )
1297                .await?;
1298                return Ok(StdioDispatchResult {
1299                    result: response,
1300                    should_exit: false,
1301                });
1302            }
1303            let should_record_hint = matches!(
1304                &request,
1305                ThreadRequest::Create { .. }
1306                    | ThreadRequest::Start(_)
1307                    | ThreadRequest::Resume(_)
1308                    | ThreadRequest::Fork(_)
1309            );
1310            let response = handle_thread_request(state, request).await?;
1311            if should_record_hint {
1312                record_stdio_thread_hint(state, &response).await;
1313            }
1314            StdioDispatchResult {
1315                result: serde_json::to_value(response)
1316                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1317                should_exit: false,
1318            }
1319        }
1320        "thread/create" => {
1321            #[derive(Debug, Deserialize)]
1322            struct CreateParams {
1323                #[serde(default)]
1324                metadata: Value,
1325            }
1326            let parsed: CreateParams = parse_params(params_or_object(params))?;
1327            let response = handle_thread_request(
1328                state,
1329                ThreadRequest::Create {
1330                    metadata: parsed.metadata,
1331                },
1332            )
1333            .await?;
1334            record_stdio_thread_hint(state, &response).await;
1335            StdioDispatchResult {
1336                result: serde_json::to_value(response)
1337                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1338                should_exit: false,
1339            }
1340        }
1341        "thread/start" => {
1342            let request = ThreadRequest::Start(parse_params(params_or_object(params))?);
1343            let response = handle_thread_request(state, request).await?;
1344            record_stdio_thread_hint(state, &response).await;
1345            StdioDispatchResult {
1346                result: serde_json::to_value(response)
1347                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1348                should_exit: false,
1349            }
1350        }
1351        "thread/resume" => {
1352            let request = ThreadRequest::Resume(parse_params(params_or_object(params))?);
1353            let response = handle_thread_request(state, request).await?;
1354            record_stdio_thread_hint(state, &response).await;
1355            StdioDispatchResult {
1356                result: serde_json::to_value(response)
1357                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1358                should_exit: false,
1359            }
1360        }
1361        "thread/fork" => {
1362            let request = ThreadRequest::Fork(parse_params(params_or_object(params))?);
1363            let response = handle_thread_request(state, request).await?;
1364            record_stdio_thread_hint(state, &response).await;
1365            StdioDispatchResult {
1366                result: serde_json::to_value(response)
1367                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1368                should_exit: false,
1369            }
1370        }
1371        "thread/list" => {
1372            let request = ThreadRequest::List(parse_params(params_or_object(params))?);
1373            let response = handle_thread_request(state, request).await?;
1374            StdioDispatchResult {
1375                result: serde_json::to_value(response)
1376                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1377                should_exit: false,
1378            }
1379        }
1380        "thread/read" => {
1381            let request = ThreadRequest::Read(parse_params(params_or_object(params))?);
1382            let response = handle_thread_request(state, request).await?;
1383            StdioDispatchResult {
1384                result: serde_json::to_value(response)
1385                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1386                should_exit: false,
1387            }
1388        }
1389        "thread/set_name" | "thread/set-name" => {
1390            let request = ThreadRequest::SetName(parse_params(params_or_object(params))?);
1391            let response = handle_thread_request(state, request).await?;
1392            StdioDispatchResult {
1393                result: serde_json::to_value(response)
1394                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1395                should_exit: false,
1396            }
1397        }
1398        "thread/goal/set" | "thread/goal_set" | "thread/goal-set" => {
1399            let request = ThreadRequest::GoalSet(parse_params::<ThreadGoalSetParams>(
1400                params_or_object(params),
1401            )?);
1402            let response = handle_thread_request(state, request).await?;
1403            StdioDispatchResult {
1404                result: serde_json::to_value(response)
1405                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1406                should_exit: false,
1407            }
1408        }
1409        "thread/goal/get" | "thread/goal_get" | "thread/goal-get" => {
1410            let request = ThreadRequest::GoalGet(parse_params::<ThreadGoalGetParams>(
1411                params_or_object(params),
1412            )?);
1413            let response = handle_thread_request(state, request).await?;
1414            StdioDispatchResult {
1415                result: serde_json::to_value(response)
1416                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1417                should_exit: false,
1418            }
1419        }
1420        "thread/goal/clear" | "thread/goal_clear" | "thread/goal-clear" => {
1421            let request = ThreadRequest::GoalClear(parse_params::<ThreadGoalClearParams>(
1422                params_or_object(params),
1423            )?);
1424            let response = handle_thread_request(state, request).await?;
1425            StdioDispatchResult {
1426                result: serde_json::to_value(response)
1427                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1428                should_exit: false,
1429            }
1430        }
1431        "thread/archive" => {
1432            let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
1433            let response = handle_thread_request(
1434                state,
1435                ThreadRequest::Archive {
1436                    thread_id: parsed.thread_id,
1437                },
1438            )
1439            .await?;
1440            StdioDispatchResult {
1441                result: serde_json::to_value(response)
1442                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1443                should_exit: false,
1444            }
1445        }
1446        "thread/unarchive" => {
1447            let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
1448            let response = handle_thread_request(
1449                state,
1450                ThreadRequest::Unarchive {
1451                    thread_id: parsed.thread_id,
1452                },
1453            )
1454            .await?;
1455            StdioDispatchResult {
1456                result: serde_json::to_value(response)
1457                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1458                should_exit: false,
1459            }
1460        }
1461        "thread/message" => {
1462            let parsed: ThreadMessageParams = parse_params(params_or_object(params))?;
1463            let response = handle_stdio_thread_message(state, writer, parsed).await?;
1464            StdioDispatchResult {
1465                result: response,
1466                should_exit: false,
1467            }
1468        }
1469        "app/capabilities" => dispatch_stdio_app_request(state, AppRequest::Capabilities).await?,
1470        "app/request" => {
1471            let request: AppRequest = parse_params(params)?;
1472            dispatch_stdio_app_request(state, request).await?
1473        }
1474        "app/config/get" => {
1475            let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
1476            dispatch_stdio_app_request(state, AppRequest::ConfigGet { key: parsed.key }).await?
1477        }
1478        "app/config/set" => {
1479            let parsed: ConfigSetParams = parse_params(params_or_object(params))?;
1480            dispatch_stdio_app_request(
1481                state,
1482                AppRequest::ConfigSet {
1483                    key: parsed.key,
1484                    value: parsed.value,
1485                },
1486            )
1487            .await?
1488        }
1489        "app/config/unset" => {
1490            let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
1491            dispatch_stdio_app_request(state, AppRequest::ConfigUnset { key: parsed.key }).await?
1492        }
1493        "app/config/list" => dispatch_stdio_app_request(state, AppRequest::ConfigList).await?,
1494        "app/config/reload" => dispatch_stdio_app_request(state, AppRequest::ConfigReload).await?,
1495        "app/models" => dispatch_stdio_app_request(state, AppRequest::Models).await?,
1496        "app/thread_loaded_list" | "app/thread-loaded-list" => {
1497            dispatch_stdio_app_request(state, AppRequest::ThreadLoadedList).await?
1498        }
1499        "prompt/capabilities" => StdioDispatchResult {
1500            result: json!({
1501                "methods": ["prompt/request", "prompt/run"]
1502            }),
1503            should_exit: false,
1504        },
1505        "prompt/request" | "prompt/run" => {
1506            let request: PromptRequest = parse_params(params)?;
1507            let response = handle_prompt_request(state, request).await?;
1508            StdioDispatchResult {
1509                result: serde_json::to_value(response)
1510                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1511                should_exit: false,
1512            }
1513        }
1514        "shutdown" => {
1515            if let Some(bridge) = state.stdio_bridge.lock().await.take() {
1516                bridge.lock().await.shutdown_child();
1517            }
1518            StdioDispatchResult {
1519                result: json!({"ok": true, "status": "stopped"}),
1520                should_exit: true,
1521            }
1522        }
1523        _ => return Err(JsonRpcError::method_not_found(method)),
1524    };
1525    Ok(outcome)
1526}
1527
1528async fn process_app_request(
1529    state: &AppState,
1530    req: AppRequest,
1531    _transport: AppTransport,
1532) -> AppResponse {
1533    match req {
1534        AppRequest::Capabilities => AppResponse {
1535            ok: true,
1536            data: json!({
1537                "routes": ["/thread", "/app", "/prompt", "/tool", "/jobs", "/mcp/startup"],
1538                "config": ["get", "set", "unset", "list", "reload"],
1539                "events": ["response_start", "response_delta", "response_end", "tool_call_start", "tool_call_result", "mcp_startup_update", "mcp_startup_complete"],
1540                "transport": "stdio+http",
1541                "config_path": state.config_path.as_ref().map(|p| p.display().to_string()),
1542            }),
1543            events: Vec::new(),
1544        },
1545        AppRequest::ConfigGet { key } => {
1546            let cfg = state.config.read().await;
1547            let value = cfg.get_display_value(&key);
1548            AppResponse {
1549                ok: true,
1550                data: json!({ "key": key, "value": value }),
1551                events: Vec::new(),
1552            }
1553        }
1554        AppRequest::ConfigSet { key, value } => {
1555            let (result, snapshot) = {
1556                let mut cfg = state.config.write().await;
1557                let result = cfg.set_value(&key, &value);
1558                (result, cfg.clone())
1559            };
1560            let ok = result.is_ok();
1561            let message = result.err().map(|e| e.to_string());
1562            apply_config_update(state, snapshot, None, true).await;
1563            AppResponse {
1564                ok,
1565                data: json!({ "key": key, "value": value, "error": message }),
1566                events: Vec::new(),
1567            }
1568        }
1569        AppRequest::ConfigUnset { key } => {
1570            let (result, snapshot) = {
1571                let mut cfg = state.config.write().await;
1572                let result = cfg.unset_value(&key);
1573                (result, cfg.clone())
1574            };
1575            let ok = result.is_ok();
1576            let message = result.err().map(|e| e.to_string());
1577            apply_config_update(state, snapshot, None, true).await;
1578            AppResponse {
1579                ok,
1580                data: json!({ "key": key, "error": message }),
1581                events: Vec::new(),
1582            }
1583        }
1584        AppRequest::ConfigList => {
1585            let cfg = state.config.read().await;
1586            AppResponse {
1587                ok: true,
1588                data: json!({ "values": cfg.list_values() }),
1589                events: Vec::new(),
1590            }
1591        }
1592        AppRequest::ConfigReload => {
1593            // Re-read both `config.toml` and the sibling `permissions.toml`
1594            // from disk (the headless equivalent of the TUI
1595            // `reload_runtime_config` codepath) and push the fresh
1596            // snapshots into `state.config` and the live `Runtime`.
1597            //
1598            // `ConfigStore::load` resolves the same default config path
1599            // that `build_state` used at startup when `config_path` is
1600            // `None`, so a `None` here reloads from the same on-disk file
1601            // the server booted from.
1602            let store = match ConfigStore::load(state.config_path.clone()) {
1603                Ok(store) => store,
1604                Err(e) => {
1605                    return AppResponse {
1606                        ok: false,
1607                        data: json!({ "error": format!("failed to load config: {e}") }),
1608                        events: Vec::new(),
1609                    };
1610                }
1611            };
1612            let new_config = store.config.clone();
1613            let new_exec_policy = store.exec_policy_engine();
1614
1615            // Disk is already the source of truth here, so nothing to
1616            // persist; the exec policy rides along so the runtime picks up
1617            // external `permissions.toml` edits too.
1618            apply_config_update(state, new_config, Some(new_exec_policy), false).await;
1619
1620            AppResponse {
1621                ok: true,
1622                data: json!({ "reloaded": true }),
1623                events: Vec::new(),
1624            }
1625        }
1626        AppRequest::Models => AppResponse {
1627            ok: true,
1628            data: json!({ "models": state.registry.list() }),
1629            events: Vec::new(),
1630        },
1631        AppRequest::ThreadLoadedList => {
1632            let mut runtime = state.runtime.write().await;
1633            let response = runtime
1634                .handle_thread(codewhale_protocol::ThreadRequest::List(
1635                    codewhale_protocol::ThreadListParams {
1636                        include_archived: false,
1637                        limit: Some(50),
1638                    },
1639                ))
1640                .await;
1641            match response {
1642                Ok(thread_resp) => AppResponse {
1643                    ok: true,
1644                    data: json!({ "threads": thread_resp.threads }),
1645                    events: thread_resp.events,
1646                },
1647                Err(err) => AppResponse {
1648                    ok: false,
1649                    data: json!({ "error": err.to_string() }),
1650                    events: Vec::new(),
1651                },
1652            }
1653        }
1654        AppRequest::SubmitUserInput {
1655            request_id,
1656            answers,
1657        } => {
1658            // Record the user's answers against the pending clarification
1659            // request so a driver can retrieve them. The headless runtime does
1660            // not block on `request_user_input` (fire-and-return, like
1661            // approval), so there is no in-flight turn to resume here — the
1662            // caller is expected to feed these answers into the next turn.
1663            let mut pending = state.pending_user_input.lock().await;
1664            if pending.contains_key(&request_id) {
1665                return AppResponse {
1666                    ok: false,
1667                    data: json!({
1668                        "error": "request_id already resolved",
1669                        "request_id": request_id,
1670                    }),
1671                    events: Vec::new(),
1672                };
1673            }
1674            pending.insert(request_id.clone(), answers);
1675            AppResponse {
1676                ok: true,
1677                data: json!({ "request_id": request_id, "resolved": true }),
1678                events: Vec::new(),
1679            }
1680        }
1681    }
1682}
1683
1684/// Propagate a new config snapshot to every place that must observe it:
1685/// optionally persist it to disk, install it in the shared `state.config`,
1686/// push it into the live [`Runtime`], and invalidate the cached stdio
1687/// bridge so the next stdio request spawns a fresh child that reads the
1688/// new on-disk config. Shared by `ConfigSet` / `ConfigUnset` / `ConfigReload`.
1689///
1690/// `exec_policy` is `Some` only on the reload path, which re-reads
1691/// `permissions.toml` from disk; set/unset intentionally leave the live
1692/// exec policy alone (use `ConfigReload` to pick up external permission
1693/// edits). `persist` is false on the reload path because disk is already
1694/// the source of truth there.
1695async fn apply_config_update(
1696    state: &AppState,
1697    snapshot: codewhale_config::ConfigToml,
1698    exec_policy: Option<codewhale_execpolicy::ExecPolicyEngine>,
1699    persist: bool,
1700) {
1701    if persist && let Err(e) = persist_config(state, snapshot.clone()).await {
1702        tracing::error!("Failed to persist config update: {e}");
1703    }
1704    {
1705        let mut cfg = state.config.write().await;
1706        *cfg = snapshot.clone();
1707    }
1708    // Sync into the live Runtime so the next turn picks up the change
1709    // without a restart. MCP server connections are NOT refreshed here —
1710    // see `Runtime::reload_config_and_policy` for the headless boundary;
1711    // the TUI's explicit `/mcp reload` operation is a separate path.
1712    {
1713        let mut runtime = state.runtime.write().await;
1714        match exec_policy {
1715            Some(policy) => runtime.reload_config_and_policy(snapshot, policy),
1716            None => runtime.update_config(snapshot),
1717        }
1718    }
1719    invalidate_stdio_bridge(state).await;
1720}
1721
1722async fn persist_config(state: &AppState, config: codewhale_config::ConfigToml) -> Result<()> {
1723    if state.config_path.is_none() {
1724        return Ok(());
1725    }
1726    let mut store = ConfigStore::load(state.config_path.clone())?;
1727    store.config = config;
1728    store.save()
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733    use super::*;
1734    use axum::body::{Body, to_bytes};
1735    use axum::extract::{Path as AxumPath, Query};
1736    use axum::http::header;
1737    use codewhale_protocol::AppRequest;
1738    use std::collections::HashMap;
1739    use std::fs;
1740    use tokio::io::AsyncReadExt;
1741    use tower::ServiceExt;
1742
1743    fn app_with_config(auth_token: Option<&str>) -> (Router, tempfile::TempDir) {
1744        let tmp = tempfile::tempdir().expect("tempdir");
1745        let config_path = tmp.path().join("config.toml");
1746        fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1747        let state = build_state(
1748            Some(config_path),
1749            auth_token.map(std::string::ToString::to_string),
1750        )
1751        .expect("state");
1752        (app_router(state, &[]), tmp)
1753    }
1754
1755    #[test]
1756    fn build_state_keeps_resolved_explicit_config_path() {
1757        let tmp = tempfile::tempdir().expect("tempdir");
1758        let config_dir = tmp.path().join("config-dir");
1759        fs::create_dir_all(&config_dir).expect("config dir");
1760        let config_path = config_dir.join("config.toml");
1761        fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1762
1763        let state = build_state(Some(config_path.clone()), None).expect("state");
1764
1765        assert_eq!(
1766            state.config_path.as_deref(),
1767            Some(
1768                config_path
1769                    .canonicalize()
1770                    .expect("canonical config")
1771                    .as_path()
1772            )
1773        );
1774    }
1775
1776    async fn response_body_json(response: Response) -> Value {
1777        let bytes = to_bytes(response.into_body(), usize::MAX)
1778            .await
1779            .expect("body bytes");
1780        serde_json::from_slice(&bytes).expect("json response")
1781    }
1782
1783    #[tokio::test]
1784    async fn http_app_routes_require_bearer_token_when_auth_enabled() {
1785        let (app, _tmp) = app_with_config(Some("test-token"));
1786        let response = app
1787            .oneshot(
1788                Request::builder()
1789                    .method(Method::POST)
1790                    .uri("/app")
1791                    .header(header::CONTENT_TYPE, "application/json")
1792                    .body(Body::from(
1793                        serde_json::to_vec(&AppRequest::ConfigGet {
1794                            key: "api_key".to_string(),
1795                        })
1796                        .expect("request json"),
1797                    ))
1798                    .expect("request"),
1799            )
1800            .await
1801            .expect("response");
1802
1803        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1804    }
1805
1806    #[tokio::test]
1807    async fn http_config_get_redacts_sensitive_values_after_auth() {
1808        let (app, _tmp) = app_with_config(Some("test-token"));
1809        let response = app
1810            .oneshot(
1811                Request::builder()
1812                    .method(Method::POST)
1813                    .uri("/app")
1814                    .header(header::AUTHORIZATION, "Bearer test-token")
1815                    .header(header::CONTENT_TYPE, "application/json")
1816                    .body(Body::from(
1817                        serde_json::to_vec(&AppRequest::ConfigGet {
1818                            key: "api_key".to_string(),
1819                        })
1820                        .expect("request json"),
1821                    ))
1822                    .expect("request"),
1823            )
1824            .await
1825            .expect("response");
1826
1827        assert_eq!(response.status(), StatusCode::OK);
1828        let body = response_body_json(response).await;
1829        assert_eq!(body["data"]["value"], "sk-d***cret");
1830    }
1831
1832    #[tokio::test]
1833    async fn cors_does_not_allow_arbitrary_origins() {
1834        let (app, _tmp) = app_with_config(Some("test-token"));
1835        let response = app
1836            .oneshot(
1837                Request::builder()
1838                    .method(Method::GET)
1839                    .uri("/healthz")
1840                    .header(header::ORIGIN, "https://attacker.example")
1841                    .body(Body::empty())
1842                    .expect("request"),
1843            )
1844            .await
1845            .expect("response");
1846
1847        assert_eq!(response.status(), StatusCode::OK);
1848        assert!(
1849            response
1850                .headers()
1851                .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
1852                .is_none()
1853        );
1854    }
1855
1856    #[tokio::test]
1857    async fn build_state_loads_permissions_into_runtime_policy() {
1858        let tmp = tempfile::tempdir().expect("tempdir");
1859        let config_path = tmp.path().join("config.toml");
1860        fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1861        fs::write(
1862            tmp.path().join("permissions.toml"),
1863            r#"
1864            [[rules]]
1865            tool = "exec_shell"
1866            command = "cargo test"
1867            "#,
1868        )
1869        .expect("write permissions");
1870
1871        let state = build_state(Some(config_path), None).expect("state");
1872        let runtime = state.runtime.read().await;
1873        let decision = runtime
1874            .exec_policy
1875            .check(codewhale_execpolicy::ExecPolicyContext {
1876                command: "cargo test --workspace",
1877                cwd: "/workspace",
1878                tool: Some("exec_shell"),
1879                path: None,
1880                ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1881                sandbox_mode: Some("workspace-write"),
1882            })
1883            .expect("policy check");
1884
1885        assert!(decision.allow);
1886        assert!(decision.requires_approval);
1887        assert_eq!(
1888            decision.matched_rule.as_deref(),
1889            Some("tool=exec_shell command=cargo test")
1890        );
1891    }
1892
1893    #[tokio::test]
1894    async fn config_reload_refreshes_runtime_config_and_exec_policy_from_disk() {
1895        let tmp = tempfile::tempdir().expect("tempdir");
1896        let config_path = tmp.path().join("config.toml");
1897        fs::write(
1898            &config_path,
1899            "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
1900        )
1901        .expect("write config");
1902        // No permissions.toml at startup → exec_policy starts empty.
1903        let state = build_state(Some(config_path.clone()), None).expect("state");
1904
1905        // Sanity: initial runtime sees the on-disk model and has no rule.
1906        {
1907            let runtime = state.runtime.read().await;
1908            assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat"));
1909            let decision = runtime
1910                .exec_policy
1911                .check(codewhale_execpolicy::ExecPolicyContext {
1912                    command: "cargo test",
1913                    cwd: "/workspace",
1914                    tool: Some("exec_shell"),
1915                    path: None,
1916                    ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1917                    sandbox_mode: Some("workspace-write"),
1918                })
1919                .expect("policy check");
1920            assert!(decision.matched_rule.is_none());
1921        }
1922
1923        // Edit both files on disk: new model + a permission rule.
1924        fs::write(
1925            &config_path,
1926            "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-reasoner\"\n",
1927        )
1928        .expect("rewrite config");
1929        fs::write(
1930            tmp.path().join("permissions.toml"),
1931            r#"
1932            [[rules]]
1933            tool = "exec_shell"
1934            command = "cargo test"
1935            "#,
1936        )
1937        .expect("write permissions");
1938
1939        // ConfigReload must re-read both files and push them into the
1940        // live Runtime without a restart.
1941        let response =
1942            process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await;
1943        assert!(response.ok, "reload should succeed");
1944        assert_eq!(response.data["reloaded"], true);
1945
1946        // The shared config lock reflects the new model.
1947        {
1948            let cfg = state.config.read().await;
1949            assert_eq!(cfg.model.as_deref(), Some("deepseek-reasoner"));
1950        }
1951        // The live Runtime reflects both the new model and the new rule.
1952        {
1953            let runtime = state.runtime.read().await;
1954            assert_eq!(runtime.config.model.as_deref(), Some("deepseek-reasoner"));
1955            let decision = runtime
1956                .exec_policy
1957                .check(codewhale_execpolicy::ExecPolicyContext {
1958                    command: "cargo test --workspace",
1959                    cwd: "/workspace",
1960                    tool: Some("exec_shell"),
1961                    path: None,
1962                    ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1963                    sandbox_mode: Some("workspace-write"),
1964                })
1965                .expect("policy check");
1966            assert!(decision.allow);
1967            assert!(decision.requires_approval);
1968            assert_eq!(
1969                decision.matched_rule.as_deref(),
1970                Some("tool=exec_shell command=cargo test")
1971            );
1972        }
1973    }
1974
1975    #[tokio::test]
1976    async fn config_set_propagates_to_runtime_config_without_touching_exec_policy() {
1977        let tmp = tempfile::tempdir().expect("tempdir");
1978        let config_path = tmp.path().join("config.toml");
1979        fs::write(
1980            &config_path,
1981            "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
1982        )
1983        .expect("write config");
1984        let state = build_state(Some(config_path.clone()), None).expect("state");
1985
1986        // Set a new model via the API. Only config.toml is touched; no
1987        // permissions.toml exists, so exec_policy must stay empty.
1988        let response = process_app_request(
1989            &state,
1990            AppRequest::ConfigSet {
1991                key: "model".to_string(),
1992                value: "deepseek-reasoner".to_string(),
1993            },
1994            AppTransport::Stdio,
1995        )
1996        .await;
1997        assert!(response.ok, "set should succeed");
1998
1999        // Live runtime sees the new model.
2000        {
2001            let runtime = state.runtime.read().await;
2002            assert_eq!(runtime.config.model.as_deref(), Some("deepseek-reasoner"));
2003            // exec_policy was empty at startup and must remain empty.
2004            let decision = runtime
2005                .exec_policy
2006                .check(codewhale_execpolicy::ExecPolicyContext {
2007                    command: "cargo test",
2008                    cwd: "/workspace",
2009                    tool: Some("exec_shell"),
2010                    path: None,
2011                    ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
2012                    sandbox_mode: Some("workspace-write"),
2013                })
2014                .expect("policy check");
2015            assert!(decision.matched_rule.is_none());
2016        }
2017        // The on-disk file was persisted.
2018        let persisted = fs::read_to_string(&config_path).expect("read config");
2019        assert!(persisted.contains("deepseek-reasoner"));
2020    }
2021
2022    #[tokio::test]
2023    async fn config_unset_propagates_to_runtime_config() {
2024        let tmp = tempfile::tempdir().expect("tempdir");
2025        let config_path = tmp.path().join("config.toml");
2026        fs::write(
2027            &config_path,
2028            "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
2029        )
2030        .expect("write config");
2031        let state = build_state(Some(config_path.clone()), None).expect("state");
2032
2033        // Sanity: runtime starts with the on-disk model.
2034        {
2035            let runtime = state.runtime.read().await;
2036            assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat"));
2037        }
2038
2039        // Unset the model via the API. This walks a separate code path
2040        // from ConfigSet (unset_value + update_config), so it needs its
2041        // own regression coverage.
2042        let response = process_app_request(
2043            &state,
2044            AppRequest::ConfigUnset {
2045                key: "model".to_string(),
2046            },
2047            AppTransport::Stdio,
2048        )
2049        .await;
2050        assert!(response.ok, "unset should succeed");
2051
2052        // Live runtime sees the cleared model.
2053        {
2054            let runtime = state.runtime.read().await;
2055            assert!(runtime.config.model.is_none());
2056        }
2057        // Shared config lock agrees.
2058        {
2059            let cfg = state.config.read().await;
2060            assert!(cfg.model.is_none());
2061        }
2062        // The on-disk file no longer carries the model value.
2063        let persisted = fs::read_to_string(&config_path).expect("read config");
2064        assert!(!persisted.contains("deepseek-chat"));
2065    }
2066
2067    #[tokio::test]
2068    async fn config_reload_returns_error_when_disk_config_is_invalid() {
2069        let tmp = tempfile::tempdir().expect("tempdir");
2070        let config_path = tmp.path().join("config.toml");
2071        fs::write(
2072            &config_path,
2073            "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
2074        )
2075        .expect("write config");
2076        let state = build_state(Some(config_path.clone()), None).expect("state");
2077
2078        // Corrupt the on-disk config so ConfigStore::load fails to parse.
2079        fs::write(&config_path, "api_key = \"unterminated\n").expect("corrupt config");
2080
2081        let response =
2082            process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await;
2083        assert!(!response.ok, "reload of corrupt config must fail");
2084        let err = response.data["error"]
2085            .as_str()
2086            .expect("error message present")
2087            .to_string();
2088        assert!(
2089            err.contains("failed to load config"),
2090            "error should mention load failure, got: {err}"
2091        );
2092
2093        // Live state is untouched: the early-return on load error must
2094        // not have clobbered runtime.config or state.config.
2095        {
2096            let runtime = state.runtime.read().await;
2097            assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat"));
2098        }
2099        {
2100            let cfg = state.config.read().await;
2101            assert_eq!(cfg.model.as_deref(), Some("deepseek-chat"));
2102        }
2103    }
2104
2105    async fn seed_test_bridge(state: &AppState) -> SharedRuntimeBridge {
2106        let bridge = Arc::new(Mutex::new(RuntimeBridge::from_base_url_for_test(
2107            "http://127.0.0.1:9".to_string(),
2108        )));
2109        *state.stdio_bridge.lock().await = Some(bridge.clone());
2110        bridge
2111    }
2112
2113    #[tokio::test]
2114    async fn config_set_invalidates_cached_stdio_bridge() {
2115        let tmp = tempfile::tempdir().expect("tempdir");
2116        let config_path = tmp.path().join("config.toml");
2117        fs::write(&config_path, "model = \"deepseek-chat\"\n").expect("write config");
2118        let state = build_state(Some(config_path), None).expect("state");
2119        seed_test_bridge(&state).await;
2120
2121        let response = process_app_request(
2122            &state,
2123            AppRequest::ConfigSet {
2124                key: "model".to_string(),
2125                value: "deepseek-reasoner".to_string(),
2126            },
2127            AppTransport::Stdio,
2128        )
2129        .await;
2130        assert!(response.ok, "set should succeed");
2131
2132        // The cached bridge child must be dropped so the next stdio request
2133        // spawns a fresh runtime that reads the persisted config.
2134        assert!(state.stdio_bridge.lock().await.is_none());
2135    }
2136
2137    #[tokio::test]
2138    async fn config_reload_invalidates_cached_stdio_bridge() {
2139        let tmp = tempfile::tempdir().expect("tempdir");
2140        let config_path = tmp.path().join("config.toml");
2141        fs::write(&config_path, "model = \"deepseek-chat\"\n").expect("write config");
2142        let state = build_state(Some(config_path), None).expect("state");
2143        seed_test_bridge(&state).await;
2144
2145        let response =
2146            process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await;
2147        assert!(response.ok, "reload should succeed");
2148
2149        assert!(state.stdio_bridge.lock().await.is_none());
2150    }
2151
2152    #[tokio::test]
2153    async fn stdio_bridge_invalidation_not_blocked_by_in_flight_turn() {
2154        let (state, _tmp) = capability_test_state();
2155        let bridge = seed_test_bridge(&state).await;
2156
2157        // Simulate a long streaming turn holding the inner bridge lock.
2158        let _in_flight = bridge.lock().await;
2159
2160        // Invalidation only touches the cache slot, so it must complete
2161        // without waiting for the in-flight turn to release the bridge.
2162        tokio::time::timeout(Duration::from_secs(1), invalidate_stdio_bridge(&state))
2163            .await
2164            .expect("invalidation must not wait on bridge traffic");
2165        assert!(state.stdio_bridge.lock().await.is_none());
2166    }
2167
2168    #[tokio::test]
2169    async fn runtime_read_paths_run_concurrently() {
2170        // Tool/status/mcp handlers take read guards; two must coexist so a
2171        // long-running tool call cannot serialize unrelated requests. With
2172        // the old `Mutex<Runtime>` this pattern would deadlock.
2173        let (state, _tmp) = capability_test_state();
2174        let first = state.runtime.read().await;
2175        let second = state.runtime.read().await;
2176        assert!(first.app_status().ok);
2177        assert!(second.app_status().ok);
2178    }
2179
2180    #[tokio::test]
2181    async fn health_probes_advertise_legacy_deepseek_service_name() {
2182        // External probes still key off the DeepSeek-era service name; both
2183        // transports must serve it from the single compat shim.
2184        let (app, _tmp) = app_with_config(None);
2185        let response = app
2186            .oneshot(
2187                Request::builder()
2188                    .method(Method::GET)
2189                    .uri("/healthz")
2190                    .body(Body::empty())
2191                    .expect("request"),
2192            )
2193            .await
2194            .expect("response");
2195        let body = response_body_json(response).await;
2196        assert_eq!(body["service"], legacy_deepseek_compat::SERVICE_NAME);
2197        assert_eq!(body["service"], "deepseek-app-server");
2198
2199        let (state, _tmp) = capability_test_state();
2200        let stdio = dispatch_stdio_request(&state, "healthz", json!({}))
2201            .await
2202            .expect("stdio healthz");
2203        assert_eq!(
2204            stdio.result["service"],
2205            legacy_deepseek_compat::SERVICE_NAME
2206        );
2207    }
2208
2209    #[test]
2210    fn non_loopback_bind_without_auth_fails_fast() {
2211        let options = AppServerOptions {
2212            listen: "0.0.0.0:8787".parse().expect("socket addr"),
2213            config_path: None,
2214            auth_token: None,
2215            insecure_no_auth: false,
2216            cors_origins: Vec::new(),
2217        };
2218
2219        let err =
2220            resolve_auth_token(&options).expect_err("non-loopback generated auth should fail");
2221        assert!(err.to_string().contains("without explicit auth token"));
2222    }
2223
2224    #[tokio::test]
2225    async fn stdio_transport_redacts_config_get_secrets() {
2226        let tmp = tempfile::tempdir().expect("tempdir");
2227        let config_path = tmp.path().join("config.toml");
2228        fs::write(&config_path, "").expect("write config");
2229        let state = build_state(Some(config_path), None).expect("state");
2230        {
2231            let mut cfg = state.config.write().await;
2232            cfg.api_key = Some("sk-deepseek-secret".to_string());
2233        }
2234
2235        let response = process_app_request(
2236            &state,
2237            AppRequest::ConfigGet {
2238                key: "api_key".to_string(),
2239            },
2240            AppTransport::Stdio,
2241        )
2242        .await;
2243
2244        assert_eq!(response.data["value"], "sk-d***cret");
2245    }
2246
2247    #[tokio::test]
2248    async fn stdio_thread_goal_methods_round_trip_persisted_goal() {
2249        let tmp = tempfile::tempdir().expect("tempdir");
2250        let config_path = tmp.path().join("config.toml");
2251        fs::write(&config_path, "").expect("write config");
2252        let state = build_state(Some(config_path), None).expect("state");
2253
2254        let capabilities = dispatch_stdio_request(&state, "thread/capabilities", json!({}))
2255            .await
2256            .expect("thread capabilities");
2257        assert!(
2258            capabilities.result["methods"]
2259                .as_array()
2260                .expect("methods")
2261                .iter()
2262                .any(|method| method == "thread/goal/set")
2263        );
2264
2265        let started = dispatch_stdio_request(&state, "thread/start", json!({}))
2266            .await
2267            .expect("start thread");
2268        let thread_id = started.result["thread_id"]
2269            .as_str()
2270            .expect("thread id")
2271            .to_string();
2272
2273        let set = dispatch_stdio_request(
2274            &state,
2275            "thread/goal/set",
2276            json!({
2277                "thread_id": thread_id,
2278                "objective": "Release 0.8.59",
2279                "token_budget": 59000
2280            }),
2281        )
2282        .await
2283        .expect("set goal");
2284        assert_eq!(set.result["status"], "ok");
2285        assert_eq!(set.result["goal"]["objective"], "Release 0.8.59");
2286        assert_eq!(set.result["goal"]["status"], "active");
2287
2288        let got = dispatch_stdio_request(
2289            &state,
2290            "thread/goal/get",
2291            json!({
2292                "thread_id": thread_id
2293            }),
2294        )
2295        .await
2296        .expect("get goal");
2297        assert_eq!(got.result["goal"]["token_budget"], 59000);
2298
2299        let cleared = dispatch_stdio_request(
2300            &state,
2301            "thread/goal/clear",
2302            json!({
2303                "thread_id": thread_id
2304            }),
2305        )
2306        .await
2307        .expect("clear goal");
2308        assert_eq!(cleared.result["status"], "cleared");
2309        assert_eq!(cleared.result["data"]["cleared"], true);
2310    }
2311
2312    fn sse_frame(event: &str, payload: Value) -> String {
2313        format!("event: {event}\ndata: {payload}\n\n")
2314    }
2315
2316    #[tokio::test]
2317    async fn stdio_runtime_bridge_streams_response_delta_events() {
2318        async fn create_turn(AxumPath(thread_id): AxumPath<String>) -> Json<Value> {
2319            Json(json!({
2320                "thread": { "id": thread_id },
2321                "turn": { "id": "turn_test" },
2322            }))
2323        }
2324
2325        async fn thread_events(
2326            AxumPath(thread_id): AxumPath<String>,
2327            Query(query): Query<HashMap<String, String>>,
2328        ) -> ([(header::HeaderName, &'static str); 1], String) {
2329            assert_eq!(thread_id, "thr_test");
2330            assert_eq!(query.get("since_seq").map(String::as_str), Some("0"));
2331
2332            let body = [
2333                sse_frame(
2334                    "item.delta",
2335                    json!({
2336                        "seq": 1,
2337                        "turn_id": "turn_test",
2338                        "payload": {
2339                            "kind": "agent_message",
2340                            "delta": "hello"
2341                        }
2342                    }),
2343                ),
2344                sse_frame(
2345                    "turn.completed",
2346                    json!({
2347                        "seq": 2,
2348                        "turn_id": "turn_test",
2349                        "payload": {
2350                            "turn": {
2351                                "status": "completed"
2352                            }
2353                        }
2354                    }),
2355                ),
2356            ]
2357            .concat();
2358
2359            ([(header::CONTENT_TYPE, "text/event-stream")], body)
2360        }
2361
2362        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2363            .await
2364            .expect("bind test listener");
2365        let addr = listener.local_addr().expect("listener addr");
2366        let app = Router::new()
2367            .route("/v1/threads/{thread_id}/turns", post(create_turn))
2368            .route("/v1/threads/{thread_id}/events", get(thread_events));
2369
2370        let server = tokio::spawn(async move {
2371            axum::serve(listener, app)
2372                .await
2373                .expect("serve test runtime");
2374        });
2375
2376        let mut bridge = RuntimeBridge::from_base_url_for_test(format!("http://{addr}"));
2377        let (mut reader, mut writer) = tokio::io::duplex(4096);
2378
2379        let result = bridge
2380            .message_thread("thr_test", "hello", &mut writer)
2381            .await
2382            .expect("message_thread should succeed");
2383        drop(writer);
2384
2385        let mut stdout = Vec::new();
2386        reader
2387            .read_to_end(&mut stdout)
2388            .await
2389            .expect("read stdio output");
2390        server.abort();
2391        let _ = server.await;
2392
2393        let lines: Vec<Value> = String::from_utf8(stdout)
2394            .expect("utf8 output")
2395            .lines()
2396            .map(|line| serde_json::from_str(line).expect("json line"))
2397            .collect();
2398
2399        assert_eq!(
2400            result.get("status").and_then(Value::as_str),
2401            Some("accepted")
2402        );
2403        assert_eq!(
2404            result.pointer("/data/turn_id").and_then(Value::as_str),
2405            Some("turn_test")
2406        );
2407        assert_eq!(bridge.last_seq_by_thread.get("thr_test"), Some(&2));
2408
2409        let event_types: Vec<&str> = lines
2410            .iter()
2411            .map(|line| {
2412                line.get("type")
2413                    .and_then(Value::as_str)
2414                    .expect("event type")
2415            })
2416            .collect();
2417        assert_eq!(
2418            event_types,
2419            vec!["response_start", "response_delta", "response_end"]
2420        );
2421        assert_eq!(lines[1]["delta"], "hello");
2422    }
2423
2424    #[tokio::test]
2425    async fn stdio_runtime_bridge_applies_thread_start_hints() {
2426        async fn create_thread(Json(body): Json<Value>) -> Json<Value> {
2427            assert_eq!(body["model"], "deepseek-v4");
2428            assert_eq!(body["workspace"], "/tmp/codewhale-stdio");
2429            Json(json!({
2430                "id": "thr_runtime",
2431                "model": body["model"].clone(),
2432                "workspace": body["workspace"].clone(),
2433            }))
2434        }
2435
2436        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2437            .await
2438            .expect("bind test listener");
2439        let addr = listener.local_addr().expect("listener addr");
2440        let app = Router::new().route("/v1/threads", post(create_thread));
2441
2442        let server = tokio::spawn(async move {
2443            axum::serve(listener, app)
2444                .await
2445                .expect("serve test runtime");
2446        });
2447
2448        let mut bridge = RuntimeBridge::from_base_url_for_test(format!("http://{addr}"));
2449        let runtime_id = bridge
2450            .ensure_runtime_thread(
2451                "legacy_thread",
2452                Some(RuntimeThreadHint {
2453                    model: Some("deepseek-v4".to_string()),
2454                    workspace: Some(PathBuf::from("/tmp/codewhale-stdio")),
2455                }),
2456            )
2457            .await
2458            .expect("runtime thread");
2459        server.abort();
2460        let _ = server.await;
2461
2462        assert_eq!(runtime_id, "thr_runtime");
2463        assert_eq!(
2464            bridge.thread_map.get("legacy_thread").map(String::as_str),
2465            Some("thr_runtime")
2466        );
2467    }
2468
2469    // ── capability drift guard ─────────────────────────────────────────
2470    //
2471    // The stdio `capabilities` method is the benchmark/SDK contract: external
2472    // harnesses probe it (without spending model tokens) to learn what the
2473    // app-server can do. Pin the advertised method set so any change forces a
2474    // deliberate update here, in the dispatcher, and in docs/RUNTIME_API.md.
2475
2476    /// Methods advertised by the top-level `capabilities` probe, in order.
2477    const EXPECTED_CAPABILITY_METHODS: &[&str] = &[
2478        "healthz",
2479        "thread/capabilities",
2480        "thread/request",
2481        "thread/create",
2482        "thread/start",
2483        "thread/resume",
2484        "thread/fork",
2485        "thread/list",
2486        "thread/read",
2487        "thread/set_name",
2488        "thread/goal/set",
2489        "thread/goal/get",
2490        "thread/goal/clear",
2491        "thread/archive",
2492        "thread/unarchive",
2493        "thread/message",
2494        "app/capabilities",
2495        "app/request",
2496        "app/config/get",
2497        "app/config/set",
2498        "app/config/unset",
2499        "app/config/list",
2500        "app/config/reload",
2501        "app/models",
2502        "app/thread_loaded_list",
2503        "prompt/capabilities",
2504        "prompt/request",
2505        "prompt/run",
2506        "shutdown",
2507    ];
2508
2509    fn capability_test_state() -> (AppState, tempfile::TempDir) {
2510        let tmp = tempfile::tempdir().expect("tempdir");
2511        let config_path = tmp.path().join("config.toml");
2512        fs::write(&config_path, "").expect("write config");
2513        let state = build_state(Some(config_path), None).expect("state");
2514        (state, tmp)
2515    }
2516
2517    #[tokio::test]
2518    async fn capabilities_method_set_is_stable() {
2519        let (state, _tmp) = capability_test_state();
2520        let caps = dispatch_stdio_request(&state, "capabilities", json!({}))
2521            .await
2522            .expect("capabilities dispatch");
2523        let methods: Vec<String> = caps.result["methods"]
2524            .as_array()
2525            .expect("methods array")
2526            .iter()
2527            .map(|m| m.as_str().expect("method string").to_string())
2528            .collect();
2529        assert_eq!(
2530            methods, EXPECTED_CAPABILITY_METHODS,
2531            "app-server stdio capability set drifted; update the dispatcher, this \
2532             snapshot, and docs/RUNTIME_API.md together"
2533        );
2534    }
2535
2536    #[tokio::test]
2537    async fn every_advertised_capability_is_dispatchable() {
2538        let (state, _tmp) = capability_test_state();
2539        // Empty params: methods may fail validation (-32602), but none may report
2540        // method-not-found (-32601). Required fields (e.g. PromptRequest.prompt)
2541        // make the prompt routes fail at parse time, so no model tokens are spent.
2542        for method in EXPECTED_CAPABILITY_METHODS {
2543            if let Err(err) = dispatch_stdio_request(&state, method, json!({})).await {
2544                assert_ne!(
2545                    err.code,
2546                    JsonRpcError::method_not_found(method).code,
2547                    "advertised capability `{method}` is not dispatchable"
2548                );
2549            }
2550        }
2551    }
2552
2553    // ── resolve_auth_token ─────────────────────────────────────────────
2554
2555    #[test]
2556    fn auth_token_empty_string_fails() {
2557        let options = AppServerOptions {
2558            listen: "127.0.0.1:0".parse().expect("addr"),
2559            config_path: None,
2560            auth_token: Some("  ".to_string()),
2561            insecure_no_auth: false,
2562            cors_origins: Vec::new(),
2563        };
2564        let err = resolve_auth_token(&options).expect_err("empty token should fail");
2565        assert!(err.to_string().contains("cannot be empty"));
2566    }
2567
2568    #[test]
2569    fn auth_token_generated_when_none_provided() {
2570        let options = AppServerOptions {
2571            listen: "127.0.0.1:0".parse().expect("addr"),
2572            config_path: None,
2573            auth_token: None,
2574            insecure_no_auth: false,
2575            cors_origins: Vec::new(),
2576        };
2577        let token = resolve_auth_token(&options).unwrap();
2578        assert!(token.is_some());
2579        assert!(token.unwrap().starts_with("cwapp_"));
2580    }
2581
2582    #[test]
2583    fn runtime_bridge_command_keeps_auth_token_out_of_argv() {
2584        // FR001-C001: runtime auth token must not appear on the child argv
2585        // (visible via local `ps`); pass it via env instead.
2586        let token = "cwrt_unit_test_secret_token_not_for_argv";
2587        let cmd = RuntimeBridge::runtime_command(None, 18787, token).expect("command");
2588        let argv: Vec<String> = cmd
2589            .get_args()
2590            .map(|a| a.to_string_lossy().into_owned())
2591            .collect();
2592        assert!(
2593            !argv
2594                .iter()
2595                .any(|a| a.contains(token) || a == "--auth-token"),
2596            "auth token must not be present in child argv: {argv:?}"
2597        );
2598        let envs: Vec<(String, String)> = cmd
2599            .get_envs()
2600            .filter_map(|(k, v)| {
2601                Some((
2602                    k.to_string_lossy().into_owned(),
2603                    v?.to_string_lossy().into_owned(),
2604                ))
2605            })
2606            .collect();
2607        assert!(
2608            envs.iter()
2609                .any(|(k, v)| k == "CODEWHALE_RUNTIME_TOKEN" && v == token),
2610            "token must be carried via CODEWHALE_RUNTIME_TOKEN: {envs:?}"
2611        );
2612        assert!(
2613            envs.iter()
2614                .any(|(k, v)| k == "DEEPSEEK_RUNTIME_TOKEN" && v == token),
2615            "legacy alias DEEPSEEK_RUNTIME_TOKEN must also carry the token: {envs:?}"
2616        );
2617    }
2618
2619    #[test]
2620    fn generated_auth_status_does_not_render_token() {
2621        let rendered = app_server_auth_status_lines(false).join("\n");
2622
2623        assert!(!rendered.contains("Authorization: Bearer"));
2624        assert!(rendered.contains("not printed"));
2625        assert!(rendered.contains("CODEWHALE_APP_SERVER_TOKEN"));
2626    }
2627
2628    #[test]
2629    fn auth_token_explicit_is_preserved() {
2630        let options = AppServerOptions {
2631            listen: "127.0.0.1:0".parse().expect("addr"),
2632            config_path: None,
2633            auth_token: Some("my-secret".to_string()),
2634            insecure_no_auth: false,
2635            cors_origins: Vec::new(),
2636        };
2637        let token = resolve_auth_token(&options).unwrap();
2638        assert_eq!(token.as_deref(), Some("my-secret"));
2639    }
2640
2641    #[test]
2642    fn auth_token_explicit_allows_non_loopback_bind() {
2643        let options = AppServerOptions {
2644            listen: "0.0.0.0:8787".parse().expect("socket addr"),
2645            config_path: None,
2646            auth_token: Some("my-secret".to_string()),
2647            insecure_no_auth: false,
2648            cors_origins: Vec::new(),
2649        };
2650        let token = resolve_auth_token(&options).unwrap();
2651        assert_eq!(token.as_deref(), Some("my-secret"));
2652    }
2653
2654    #[test]
2655    fn insecure_no_auth_on_loopback_returns_none() {
2656        let options = AppServerOptions {
2657            listen: "127.0.0.1:0".parse().expect("addr"),
2658            config_path: None,
2659            auth_token: None,
2660            insecure_no_auth: true,
2661            cors_origins: Vec::new(),
2662        };
2663        let token = resolve_auth_token(&options).unwrap();
2664        assert!(token.is_none());
2665    }
2666
2667    #[test]
2668    fn insecure_no_auth_on_non_loopback_fails_fast() {
2669        let options = AppServerOptions {
2670            listen: "0.0.0.0:8787".parse().expect("socket addr"),
2671            config_path: None,
2672            auth_token: None,
2673            insecure_no_auth: true,
2674            cors_origins: Vec::new(),
2675        };
2676
2677        let err = resolve_auth_token(&options).expect_err("non-loopback unauth should fail");
2678        assert!(
2679            err.to_string()
2680                .contains("refusing unauthenticated app-server bind")
2681        );
2682    }
2683
2684    // ── cors_layer ─────────────────────────────────────────────────────
2685
2686    #[test]
2687    fn cors_layer_includes_default_origins() {
2688        let layer = cors_layer(&[]);
2689        // Just verify it doesn't panic and creates successfully
2690        let _ = layer;
2691    }
2692
2693    #[test]
2694    fn cors_layer_adds_extra_origins() {
2695        let extras = vec!["https://example.com".to_string()];
2696        let layer = cors_layer(&extras);
2697        let _ = layer;
2698    }
2699
2700    #[test]
2701    fn cors_layer_skips_empty_origins() {
2702        let extras = vec!["".to_string(), "  ".to_string()];
2703        let layer = cors_layer(&extras);
2704        let _ = layer;
2705    }
2706
2707    // ── JsonRpc helpers ────────────────────────────────────────────────
2708
2709    #[test]
2710    fn params_or_object_returns_object_for_null() {
2711        let result = params_or_object(Value::Null);
2712        assert_eq!(result, json!({}));
2713    }
2714
2715    #[test]
2716    fn params_or_object_passthrough_for_non_null() {
2717        let input = json!({"key": "value"});
2718        let result = params_or_object(input.clone());
2719        assert_eq!(result, input);
2720    }
2721
2722    #[test]
2723    fn jsonrpc_result_format() {
2724        let result = jsonrpc_result(Some(json!(1)), json!({"ok": true}));
2725        assert_eq!(result["jsonrpc"], "2.0");
2726        assert_eq!(result["id"], 1);
2727        assert_eq!(result["result"]["ok"], true);
2728    }
2729
2730    #[test]
2731    fn jsonrpc_result_null_id() {
2732        let result = jsonrpc_result(None, json!(null));
2733        assert_eq!(result["id"], Value::Null);
2734    }
2735
2736    #[test]
2737    fn jsonrpc_error_format() {
2738        let err = jsonrpc_error(Some(json!(2)), JsonRpcError::internal("oops"));
2739        assert_eq!(err["jsonrpc"], "2.0");
2740        assert_eq!(err["id"], 2);
2741        assert_eq!(err["error"]["code"], -32603);
2742        assert_eq!(err["error"]["message"], "oops");
2743    }
2744
2745    #[test]
2746    fn jsonrpc_error_codes() {
2747        assert_eq!(JsonRpcError::parse_error("").code, -32700);
2748        assert_eq!(JsonRpcError::invalid_request("").code, -32600);
2749        assert_eq!(JsonRpcError::method_not_found("x").code, -32601);
2750        assert_eq!(JsonRpcError::invalid_params("").code, -32602);
2751        assert_eq!(JsonRpcError::internal("").code, -32603);
2752    }
2753
2754    // ── AppServerOptions ───────────────────────────────────────────────
2755
2756    #[test]
2757    fn app_server_options_debug_does_not_leak_token() {
2758        let options = AppServerOptions {
2759            listen: "127.0.0.1:8080".parse().expect("addr"),
2760            config_path: None,
2761            auth_token: Some("secret-token".to_string()),
2762            insecure_no_auth: false,
2763            cors_origins: vec!["https://example.com".to_string()],
2764        };
2765        let debug = format!("{options:?}");
2766        assert!(!debug.contains("secret-token"));
2767        assert!(debug.contains("<redacted>"));
2768        assert!(debug.contains("8080"));
2769    }
2770
2771    // ── Default CORS origins ──────────────────────────────────────────
2772
2773    #[test]
2774    fn default_cors_origins_include_common_dev_ports() {
2775        assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:3000"));
2776        assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:5173"));
2777        assert!(DEFAULT_CORS_ORIGINS.contains(&"tauri://localhost"));
2778    }
2779}