Skip to main content

codewhale_app_server/
lib.rs

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