Skip to main content

codewhale_app_server/
lib.rs

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