Skip to main content

codewhale_app_server/
lib.rs

1use std::net::SocketAddr;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use anyhow::{Result, bail};
6use axum::extract::{Request, State};
7use axum::http::{HeaderValue, Method, StatusCode, header};
8use axum::middleware::{self, Next};
9use axum::response::{IntoResponse, Response};
10use axum::routing::{get, post};
11use axum::{Json, Router};
12use codewhale_agent::ModelRegistry;
13use codewhale_config::{CliRuntimeOverrides, ConfigStore};
14use codewhale_core::Runtime;
15use codewhale_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink, UnixSocketHookSink};
16use codewhale_mcp::McpManager;
17use codewhale_protocol::{
18    AppRequest, AppResponse, PromptRequest, PromptResponse, ThreadGoalClearParams,
19    ThreadGoalGetParams, ThreadGoalSetParams, ThreadRequest, ThreadResponse, UserInputAnswerEvent,
20};
21use codewhale_state::StateStore;
22use codewhale_tools::{ToolCall, ToolRegistry};
23use serde::de::DeserializeOwned;
24use serde::{Deserialize, Serialize};
25use serde_json::{Value, json};
26use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
27use tokio::sync::{Mutex, RwLock};
28use tower_http::cors::CorsLayer;
29use uuid::Uuid;
30
31/// Answers submitted for a pending `request_user_input` clarification.
32///
33/// The headless runtime emits [`codewhale_protocol::EventFrame::UserInputRequest`]
34/// fire-and-return (it has no resume channel, mirroring headless approval).
35/// Clients POST answers back via [`AppRequest::SubmitUserInput`]; we record
36/// them here keyed by `request_id` so a driver can retrieve and feed them into
37/// the next turn as structured context. True in-flight resume would require an
38/// awaiter in `invoke_tool` and is left as a follow-up.
39type PendingUserInputAnswers = Vec<UserInputAnswerEvent>;
40
41mod chat_completions;
42
43const DEFAULT_CORS_ORIGINS: &[&str] = &[
44    "http://localhost",
45    "http://localhost:1420",
46    "http://localhost:3000",
47    "http://localhost:5173",
48    "http://127.0.0.1",
49    "http://127.0.0.1:1420",
50    "tauri://localhost",
51];
52
53#[derive(Clone)]
54pub struct AppServerOptions {
55    pub listen: SocketAddr,
56    pub config_path: Option<PathBuf>,
57    pub auth_token: Option<String>,
58    pub insecure_no_auth: bool,
59    pub cors_origins: Vec<String>,
60}
61
62impl std::fmt::Debug for AppServerOptions {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.debug_struct("AppServerOptions")
65            .field("listen", &self.listen)
66            .field("config_path", &self.config_path)
67            .field(
68                "auth_token",
69                &self.auth_token.as_ref().map(|_| "<redacted>"),
70            )
71            .field("insecure_no_auth", &self.insecure_no_auth)
72            .field("cors_origins", &self.cors_origins)
73            .finish()
74    }
75}
76
77#[derive(Clone)]
78struct AppState {
79    config_path: Option<PathBuf>,
80    config: Arc<RwLock<codewhale_config::ConfigToml>>,
81    runtime: Arc<Mutex<Runtime>>,
82    registry: ModelRegistry,
83    auth_token: Option<String>,
84    /// Answers submitted via `AppRequest::SubmitUserInput`, keyed by
85    /// `request_id`. A driver polls this to resolve clarification questions
86    /// raised by the model during a headless run.
87    pending_user_input: Arc<Mutex<std::collections::HashMap<String, PendingUserInputAnswers>>>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91struct ToolCallRequest {
92    call: ToolCall,
93    #[serde(default)]
94    cwd: Option<PathBuf>,
95}
96
97#[derive(Debug, Deserialize)]
98struct JsonRpcRequest {
99    #[serde(default)]
100    jsonrpc: Option<String>,
101    #[serde(default)]
102    id: Option<Value>,
103    method: String,
104    #[serde(default)]
105    params: Value,
106}
107
108#[derive(Debug)]
109struct JsonRpcError {
110    code: i64,
111    message: String,
112    data: Option<Value>,
113}
114
115#[derive(Debug)]
116struct StdioDispatchResult {
117    result: Value,
118    should_exit: bool,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122enum AppTransport {
123    Http,
124    Stdio,
125}
126
127#[derive(Debug, Deserialize)]
128struct ConfigGetParams {
129    key: String,
130}
131
132#[derive(Debug, Deserialize)]
133struct ConfigSetParams {
134    key: String,
135    value: String,
136}
137
138#[derive(Debug, Deserialize)]
139struct ThreadIdParams {
140    thread_id: String,
141}
142
143#[derive(Debug, Deserialize)]
144struct ThreadMessageParams {
145    thread_id: String,
146    input: String,
147}
148
149pub async fn run(options: AppServerOptions) -> Result<()> {
150    let auth_token = resolve_auth_token(&options)?;
151    let state = build_state(options.config_path.clone(), auth_token)?;
152    let app = app_router(state, &options.cors_origins);
153
154    let listener = tokio::net::TcpListener::bind(options.listen).await?;
155    axum::serve(listener, app).await?;
156    Ok(())
157}
158
159fn app_router(state: AppState, cors_origins: &[String]) -> Router {
160    let protected_routes = Router::new()
161        .route("/thread", post(thread_handler))
162        .route("/app", post(app_handler))
163        .route("/prompt", post(prompt_handler))
164        .route("/tool", post(tool_handler))
165        .route("/jobs", get(jobs_handler))
166        .route("/mcp/startup", post(mcp_startup_handler))
167        .route_layer(middleware::from_fn_with_state(
168            state.clone(),
169            require_app_server_token,
170        ));
171
172    Router::new()
173        .route("/healthz", get(healthz))
174        .route(
175            "/v1/chat/completions",
176            post(chat_completions::chat_completions_handler),
177        )
178        .merge(protected_routes)
179        .layer(cors_layer(cors_origins))
180        .with_state(state)
181}
182
183pub async fn run_stdio(config_path: Option<PathBuf>) -> Result<()> {
184    let state = build_state(config_path, None)?;
185    let stdin = tokio::io::stdin();
186    let stdout = tokio::io::stdout();
187    let mut reader = BufReader::new(stdin).lines();
188    let mut writer = tokio::io::BufWriter::new(stdout);
189    while let Some(line) = reader.next_line().await? {
190        if line.trim().is_empty() {
191            continue;
192        }
193
194        let request: JsonRpcRequest = match serde_json::from_str(&line) {
195            Ok(value) => value,
196            Err(err) => {
197                let response = jsonrpc_error(
198                    None,
199                    JsonRpcError::parse_error(format!("invalid json: {err}")),
200                );
201                writer.write_all(response.to_string().as_bytes()).await?;
202                writer.write_all(b"\n").await?;
203                writer.flush().await?;
204                continue;
205            }
206        };
207
208        if request
209            .jsonrpc
210            .as_deref()
211            .is_some_and(|version| version != "2.0")
212        {
213            let response = jsonrpc_error(
214                request.id,
215                JsonRpcError::invalid_request("jsonrpc version must be 2.0"),
216            );
217            writer.write_all(response.to_string().as_bytes()).await?;
218            writer.write_all(b"\n").await?;
219            writer.flush().await?;
220            continue;
221        }
222
223        let response = match dispatch_stdio_request(&state, &request.method, request.params).await {
224            Ok(dispatch) => {
225                let encoded = jsonrpc_result(request.id, dispatch.result);
226                writer.write_all(encoded.to_string().as_bytes()).await?;
227                writer.write_all(b"\n").await?;
228                writer.flush().await?;
229                if dispatch.should_exit {
230                    break;
231                }
232                continue;
233            }
234            Err(err) => jsonrpc_error(request.id, err),
235        };
236
237        writer.write_all(response.to_string().as_bytes()).await?;
238        writer.write_all(b"\n").await?;
239        writer.flush().await?;
240    }
241
242    Ok(())
243}
244
245async fn healthz() -> Json<Value> {
246    Json(json!({
247        "status": "ok",
248        "protocol": "v2",
249        "service": "deepseek-app-server"
250    }))
251}
252
253async fn thread_handler(
254    State(state): State<AppState>,
255    Json(req): Json<ThreadRequest>,
256) -> Json<ThreadResponse> {
257    let mut runtime = state.runtime.lock().await;
258    match runtime.handle_thread(req).await {
259        Ok(res) => Json(res),
260        Err(err) => Json(ThreadResponse {
261            thread_id: "error".to_string(),
262            status: format!("error:{err}"),
263            thread: None,
264            threads: Vec::new(),
265            goal: None,
266            model: None,
267            model_provider: None,
268            cwd: None,
269            approval_policy: None,
270            sandbox: None,
271            events: Vec::new(),
272            data: json!({}),
273        }),
274    }
275}
276
277async fn prompt_handler(
278    State(state): State<AppState>,
279    Json(req): Json<PromptRequest>,
280) -> Json<PromptResponse> {
281    let mut runtime = state.runtime.lock().await;
282    let overrides = CliRuntimeOverrides::default();
283    match runtime.handle_prompt(req, &overrides).await {
284        Ok(res) => Json(res),
285        Err(err) => Json(PromptResponse {
286            output: err.to_string(),
287            model: "unknown".to_string(),
288            events: Vec::new(),
289        }),
290    }
291}
292
293async fn tool_handler(
294    State(state): State<AppState>,
295    Json(req): Json<ToolCallRequest>,
296) -> Json<Value> {
297    let runtime = state.runtime.lock().await;
298    let cwd = req
299        .cwd
300        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
301    // Resolve approval policy from config instead of hardcoding.
302    let approval_mode = {
303        let cfg = state.config.read().await;
304        cfg.approval_policy
305            .as_deref()
306            .and_then(|p| match p.trim().to_ascii_lowercase().as_str() {
307                "auto" | "yolo" => Some(codewhale_execpolicy::AskForApproval::UnlessTrusted),
308                "never" | "deny" => Some(codewhale_execpolicy::AskForApproval::Never),
309                _ => None,
310            })
311            .unwrap_or(codewhale_execpolicy::AskForApproval::OnRequest)
312    };
313    match runtime.invoke_tool(req.call, approval_mode, &cwd).await {
314        Ok(value) => Json(value),
315        Err(err) => Json(json!({ "ok": false, "error": err.to_string() })),
316    }
317}
318
319async fn jobs_handler(State(state): State<AppState>) -> Json<AppResponse> {
320    let runtime = state.runtime.lock().await;
321    Json(runtime.app_status())
322}
323
324async fn mcp_startup_handler(State(state): State<AppState>) -> Json<Value> {
325    let runtime = state.runtime.lock().await;
326    let summary = runtime.mcp_startup().await;
327    Json(json!({
328        "ok": true,
329        "summary": summary
330    }))
331}
332
333async fn app_handler(
334    State(state): State<AppState>,
335    Json(req): Json<AppRequest>,
336) -> Json<AppResponse> {
337    Json(process_app_request(&state, req, AppTransport::Http).await)
338}
339
340fn build_state(config_path: Option<PathBuf>, auth_token: Option<String>) -> Result<AppState> {
341    let store = ConfigStore::load(config_path.clone())?;
342    let config = store.config.clone();
343    let exec_policy = store.exec_policy_engine();
344    let registry = ModelRegistry::default();
345
346    let state_db_path = config_path
347        .as_ref()
348        .and_then(|p| p.parent().map(|parent| parent.join("state.db")));
349    let state_store = StateStore::open(state_db_path)?;
350
351    let mut hooks = HookDispatcher::default();
352    hooks.add_sink(Arc::new(StdoutHookSink));
353    let hook_log_path = config_path
354        .as_ref()
355        .and_then(|p| p.parent().map(|parent| parent.join("events.jsonl")))
356        .unwrap_or_else(|| PathBuf::from(".deepseek/events.jsonl"));
357    hooks.add_sink(Arc::new(JsonlHookSink::new(hook_log_path)));
358
359    if let Some(socket_path) = config
360        .hook_sinks
361        .as_ref()
362        .and_then(|sinks| sinks.unix_socket_path.as_ref())
363        .filter(|path| !path.as_os_str().is_empty())
364    {
365        hooks.add_sink(Arc::new(UnixSocketHookSink::new(socket_path.clone())));
366    }
367
368    let runtime = Runtime::new(
369        config.clone(),
370        registry.clone(),
371        state_store,
372        Arc::new(ToolRegistry::default()),
373        Arc::new(McpManager::default()),
374        exec_policy,
375        hooks,
376    );
377
378    Ok(AppState {
379        config_path,
380        config: Arc::new(RwLock::new(config)),
381        runtime: Arc::new(Mutex::new(runtime)),
382        registry,
383        auth_token,
384        pending_user_input: Arc::new(Mutex::new(std::collections::HashMap::new())),
385    })
386}
387
388fn resolve_auth_token(options: &AppServerOptions) -> Result<Option<String>> {
389    let configured = options.auth_token.as_ref().map(|token| token.trim());
390    if let Some(token) = configured
391        && token.is_empty()
392    {
393        bail!("app-server auth token cannot be empty");
394    }
395    let has_explicit_token = configured.is_some();
396
397    if options.insecure_no_auth {
398        if !options.listen.ip().is_loopback() {
399            bail!("refusing unauthenticated app-server bind on non-loopback address");
400        }
401        eprintln!("warning: app-server HTTP auth disabled by --insecure-no-auth");
402        return Ok(None);
403    }
404
405    if !has_explicit_token && !options.listen.ip().is_loopback() {
406        bail!(
407            "refusing non-loopback app-server bind without explicit auth token; pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN"
408        );
409    }
410
411    let token = configured
412        .map(str::to_string)
413        .unwrap_or_else(|| format!("cwapp_{}", Uuid::new_v4().simple()));
414    if has_explicit_token {
415        eprintln!("app-server auth: bearer token required for HTTP routes.");
416    } else {
417        eprintln!("app-server auth: generated bearer token for this process.");
418        eprintln!("  Authorization: Bearer {token}");
419        eprintln!("  Pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN for a stable token.");
420    }
421    Ok(Some(token))
422}
423
424fn cors_layer(extra_origins: &[String]) -> CorsLayer {
425    let mut origins: Vec<HeaderValue> = DEFAULT_CORS_ORIGINS
426        .iter()
427        .filter_map(|origin| HeaderValue::from_str(origin).ok())
428        .collect();
429    for raw in extra_origins {
430        let trimmed = raw.trim();
431        if trimmed.is_empty() {
432            continue;
433        }
434        match HeaderValue::from_str(trimmed) {
435            Ok(value) if !origins.contains(&value) => origins.push(value),
436            Ok(_) => {}
437            Err(err) => {
438                eprintln!("warning: ignoring invalid app-server CORS origin `{trimmed}`: {err}")
439            }
440        }
441    }
442
443    CorsLayer::new()
444        .allow_origin(origins)
445        .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
446        .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
447}
448
449async fn require_app_server_token(
450    State(state): State<AppState>,
451    req: Request,
452    next: Next,
453) -> Response {
454    let Some(expected) = state.auth_token.as_deref() else {
455        return next.run(req).await;
456    };
457    let authorized = req
458        .headers()
459        .get(header::AUTHORIZATION)
460        .and_then(|value| value.to_str().ok())
461        .and_then(|raw| raw.strip_prefix("Bearer "))
462        .is_some_and(|token| token == expected);
463
464    if authorized {
465        next.run(req).await
466    } else {
467        (
468            StatusCode::UNAUTHORIZED,
469            Json(json!({
470                "error": {
471                    "message": "app-server bearer token required",
472                    "status": StatusCode::UNAUTHORIZED.as_u16(),
473                }
474            })),
475        )
476            .into_response()
477    }
478}
479
480fn params_or_object(params: Value) -> Value {
481    if params.is_null() { json!({}) } else { params }
482}
483
484fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
485    serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
486}
487
488fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
489    json!({
490        "jsonrpc": "2.0",
491        "id": id.unwrap_or(Value::Null),
492        "result": result
493    })
494}
495
496fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
497    json!({
498        "jsonrpc": "2.0",
499        "id": id.unwrap_or(Value::Null),
500        "error": {
501            "code": err.code,
502            "message": err.message,
503            "data": err.data
504        }
505    })
506}
507
508impl JsonRpcError {
509    fn parse_error(message: impl Into<String>) -> Self {
510        Self {
511            code: -32700,
512            message: message.into(),
513            data: None,
514        }
515    }
516
517    fn invalid_request(message: impl Into<String>) -> Self {
518        Self {
519            code: -32600,
520            message: message.into(),
521            data: None,
522        }
523    }
524
525    fn method_not_found(method: &str) -> Self {
526        Self {
527            code: -32601,
528            message: format!("unsupported method: {method}"),
529            data: None,
530        }
531    }
532
533    fn invalid_params(message: impl Into<String>) -> Self {
534        Self {
535            code: -32602,
536            message: message.into(),
537            data: None,
538        }
539    }
540
541    fn internal(message: impl Into<String>) -> Self {
542        Self {
543            code: -32603,
544            message: message.into(),
545            data: None,
546        }
547    }
548}
549
550async fn handle_thread_request(
551    state: &AppState,
552    req: ThreadRequest,
553) -> std::result::Result<ThreadResponse, JsonRpcError> {
554    let mut runtime = state.runtime.lock().await;
555    runtime
556        .handle_thread(req)
557        .await
558        .map_err(|err| JsonRpcError::internal(err.to_string()))
559}
560
561async fn handle_prompt_request(
562    state: &AppState,
563    req: PromptRequest,
564) -> std::result::Result<PromptResponse, JsonRpcError> {
565    let mut runtime = state.runtime.lock().await;
566    runtime
567        .handle_prompt(req, &CliRuntimeOverrides::default())
568        .await
569        .map_err(|err| JsonRpcError::internal(err.to_string()))
570}
571
572async fn dispatch_stdio_request(
573    state: &AppState,
574    method: &str,
575    params: Value,
576) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
577    let outcome = match method {
578        "healthz" | "app/healthz" => StdioDispatchResult {
579            result: json!({
580                "status": "ok",
581                "service": "deepseek-app-server",
582                "transport": "stdio"
583            }),
584            should_exit: false,
585        },
586        "capabilities" => StdioDispatchResult {
587            result: json!({
588                "transport": "stdio",
589                "families": ["thread/*", "app/*", "prompt/*"],
590                "methods": [
591                    "healthz",
592                    "thread/capabilities",
593                    "thread/request",
594                    "thread/create",
595                    "thread/start",
596                    "thread/resume",
597                    "thread/fork",
598                    "thread/list",
599                    "thread/read",
600                    "thread/set_name",
601                    "thread/goal/set",
602                    "thread/goal/get",
603                    "thread/goal/clear",
604                    "thread/archive",
605                    "thread/unarchive",
606                    "thread/message",
607                    "app/capabilities",
608                    "app/request",
609                    "app/config/get",
610                    "app/config/set",
611                    "app/config/unset",
612                    "app/config/list",
613                    "app/models",
614                    "app/thread_loaded_list",
615                    "prompt/capabilities",
616                    "prompt/request",
617                    "prompt/run",
618                    "shutdown"
619                ]
620            }),
621            should_exit: false,
622        },
623        "thread/capabilities" => StdioDispatchResult {
624            result: json!({
625                "methods": [
626                    "thread/request",
627                    "thread/create",
628                    "thread/start",
629                    "thread/resume",
630                    "thread/fork",
631                    "thread/list",
632                    "thread/read",
633                    "thread/set_name",
634                    "thread/goal/set",
635                    "thread/goal/get",
636                    "thread/goal/clear",
637                    "thread/archive",
638                    "thread/unarchive",
639                    "thread/message"
640                ]
641            }),
642            should_exit: false,
643        },
644        "thread/request" => {
645            let request: ThreadRequest = parse_params(params)?;
646            let response = handle_thread_request(state, request).await?;
647            StdioDispatchResult {
648                result: serde_json::to_value(response)
649                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
650                should_exit: false,
651            }
652        }
653        "thread/create" => {
654            #[derive(Debug, Deserialize)]
655            struct CreateParams {
656                #[serde(default)]
657                metadata: Value,
658            }
659            let parsed: CreateParams = parse_params(params_or_object(params))?;
660            let response = handle_thread_request(
661                state,
662                ThreadRequest::Create {
663                    metadata: parsed.metadata,
664                },
665            )
666            .await?;
667            StdioDispatchResult {
668                result: serde_json::to_value(response)
669                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
670                should_exit: false,
671            }
672        }
673        "thread/start" => {
674            let request = ThreadRequest::Start(parse_params(params_or_object(params))?);
675            let response = handle_thread_request(state, request).await?;
676            StdioDispatchResult {
677                result: serde_json::to_value(response)
678                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
679                should_exit: false,
680            }
681        }
682        "thread/resume" => {
683            let request = ThreadRequest::Resume(parse_params(params_or_object(params))?);
684            let response = handle_thread_request(state, request).await?;
685            StdioDispatchResult {
686                result: serde_json::to_value(response)
687                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
688                should_exit: false,
689            }
690        }
691        "thread/fork" => {
692            let request = ThreadRequest::Fork(parse_params(params_or_object(params))?);
693            let response = handle_thread_request(state, request).await?;
694            StdioDispatchResult {
695                result: serde_json::to_value(response)
696                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
697                should_exit: false,
698            }
699        }
700        "thread/list" => {
701            let request = ThreadRequest::List(parse_params(params_or_object(params))?);
702            let response = handle_thread_request(state, request).await?;
703            StdioDispatchResult {
704                result: serde_json::to_value(response)
705                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
706                should_exit: false,
707            }
708        }
709        "thread/read" => {
710            let request = ThreadRequest::Read(parse_params(params_or_object(params))?);
711            let response = handle_thread_request(state, request).await?;
712            StdioDispatchResult {
713                result: serde_json::to_value(response)
714                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
715                should_exit: false,
716            }
717        }
718        "thread/set_name" | "thread/set-name" => {
719            let request = ThreadRequest::SetName(parse_params(params_or_object(params))?);
720            let response = handle_thread_request(state, request).await?;
721            StdioDispatchResult {
722                result: serde_json::to_value(response)
723                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
724                should_exit: false,
725            }
726        }
727        "thread/goal/set" | "thread/goal_set" | "thread/goal-set" => {
728            let request = ThreadRequest::GoalSet(parse_params::<ThreadGoalSetParams>(
729                params_or_object(params),
730            )?);
731            let response = handle_thread_request(state, request).await?;
732            StdioDispatchResult {
733                result: serde_json::to_value(response)
734                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
735                should_exit: false,
736            }
737        }
738        "thread/goal/get" | "thread/goal_get" | "thread/goal-get" => {
739            let request = ThreadRequest::GoalGet(parse_params::<ThreadGoalGetParams>(
740                params_or_object(params),
741            )?);
742            let response = handle_thread_request(state, request).await?;
743            StdioDispatchResult {
744                result: serde_json::to_value(response)
745                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
746                should_exit: false,
747            }
748        }
749        "thread/goal/clear" | "thread/goal_clear" | "thread/goal-clear" => {
750            let request = ThreadRequest::GoalClear(parse_params::<ThreadGoalClearParams>(
751                params_or_object(params),
752            )?);
753            let response = handle_thread_request(state, request).await?;
754            StdioDispatchResult {
755                result: serde_json::to_value(response)
756                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
757                should_exit: false,
758            }
759        }
760        "thread/archive" => {
761            let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
762            let response = handle_thread_request(
763                state,
764                ThreadRequest::Archive {
765                    thread_id: parsed.thread_id,
766                },
767            )
768            .await?;
769            StdioDispatchResult {
770                result: serde_json::to_value(response)
771                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
772                should_exit: false,
773            }
774        }
775        "thread/unarchive" => {
776            let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
777            let response = handle_thread_request(
778                state,
779                ThreadRequest::Unarchive {
780                    thread_id: parsed.thread_id,
781                },
782            )
783            .await?;
784            StdioDispatchResult {
785                result: serde_json::to_value(response)
786                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
787                should_exit: false,
788            }
789        }
790        "thread/message" => {
791            let parsed: ThreadMessageParams = parse_params(params_or_object(params))?;
792            let response = handle_thread_request(
793                state,
794                ThreadRequest::Message {
795                    thread_id: parsed.thread_id,
796                    input: parsed.input,
797                },
798            )
799            .await?;
800            StdioDispatchResult {
801                result: serde_json::to_value(response)
802                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
803                should_exit: false,
804            }
805        }
806        "app/capabilities" => {
807            let response =
808                process_app_request(state, AppRequest::Capabilities, AppTransport::Stdio).await;
809            StdioDispatchResult {
810                result: serde_json::to_value(response)
811                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
812                should_exit: false,
813            }
814        }
815        "app/request" => {
816            let request: AppRequest = parse_params(params)?;
817            let response = process_app_request(state, request, AppTransport::Stdio).await;
818            StdioDispatchResult {
819                result: serde_json::to_value(response)
820                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
821                should_exit: false,
822            }
823        }
824        "app/config/get" => {
825            let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
826            let response = process_app_request(
827                state,
828                AppRequest::ConfigGet { key: parsed.key },
829                AppTransport::Stdio,
830            )
831            .await;
832            StdioDispatchResult {
833                result: serde_json::to_value(response)
834                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
835                should_exit: false,
836            }
837        }
838        "app/config/set" => {
839            let parsed: ConfigSetParams = parse_params(params_or_object(params))?;
840            let response = process_app_request(
841                state,
842                AppRequest::ConfigSet {
843                    key: parsed.key,
844                    value: parsed.value,
845                },
846                AppTransport::Stdio,
847            )
848            .await;
849            StdioDispatchResult {
850                result: serde_json::to_value(response)
851                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
852                should_exit: false,
853            }
854        }
855        "app/config/unset" => {
856            let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
857            let response = process_app_request(
858                state,
859                AppRequest::ConfigUnset { key: parsed.key },
860                AppTransport::Stdio,
861            )
862            .await;
863            StdioDispatchResult {
864                result: serde_json::to_value(response)
865                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
866                should_exit: false,
867            }
868        }
869        "app/config/list" => {
870            let response =
871                process_app_request(state, AppRequest::ConfigList, AppTransport::Stdio).await;
872            StdioDispatchResult {
873                result: serde_json::to_value(response)
874                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
875                should_exit: false,
876            }
877        }
878        "app/models" => {
879            let response =
880                process_app_request(state, AppRequest::Models, AppTransport::Stdio).await;
881            StdioDispatchResult {
882                result: serde_json::to_value(response)
883                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
884                should_exit: false,
885            }
886        }
887        "app/thread_loaded_list" | "app/thread-loaded-list" => {
888            let response =
889                process_app_request(state, AppRequest::ThreadLoadedList, AppTransport::Stdio).await;
890            StdioDispatchResult {
891                result: serde_json::to_value(response)
892                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
893                should_exit: false,
894            }
895        }
896        "prompt/capabilities" => StdioDispatchResult {
897            result: json!({
898                "methods": ["prompt/request", "prompt/run"]
899            }),
900            should_exit: false,
901        },
902        "prompt/request" | "prompt/run" => {
903            let request: PromptRequest = parse_params(params)?;
904            let response = handle_prompt_request(state, request).await?;
905            StdioDispatchResult {
906                result: serde_json::to_value(response)
907                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
908                should_exit: false,
909            }
910        }
911        "shutdown" => StdioDispatchResult {
912            result: json!({"ok": true, "status": "stopped"}),
913            should_exit: true,
914        },
915        _ => return Err(JsonRpcError::method_not_found(method)),
916    };
917    Ok(outcome)
918}
919
920async fn process_app_request(
921    state: &AppState,
922    req: AppRequest,
923    transport: AppTransport,
924) -> AppResponse {
925    match req {
926        AppRequest::Capabilities => AppResponse {
927            ok: true,
928            data: json!({
929                "routes": ["/thread", "/app", "/prompt", "/tool", "/jobs", "/mcp/startup"],
930                "config": ["get", "set", "unset", "list"],
931                "events": ["response_start", "response_delta", "response_end", "tool_call_start", "tool_call_result", "mcp_startup_update", "mcp_startup_complete"],
932                "transport": "stdio+http",
933                "config_path": state.config_path.as_ref().map(|p| p.display().to_string()),
934            }),
935            events: Vec::new(),
936        },
937        AppRequest::ConfigGet { key } => {
938            let cfg = state.config.read().await;
939            let value = match transport {
940                AppTransport::Http => cfg.get_display_value(&key),
941                AppTransport::Stdio => cfg.get_value(&key),
942            };
943            AppResponse {
944                ok: true,
945                data: json!({ "key": key, "value": value }),
946                events: Vec::new(),
947            }
948        }
949        AppRequest::ConfigSet { key, value } => {
950            let mut cfg = state.config.write().await;
951            let result = cfg.set_value(&key, &value);
952            let ok = result.is_ok();
953            let message = result.err().map(|e| e.to_string());
954            let snapshot = cfg.clone();
955            drop(cfg);
956            if let Err(e) = persist_config(state, snapshot).await {
957                tracing::error!("Failed to persist config after set: {e}");
958            }
959            AppResponse {
960                ok,
961                data: json!({ "key": key, "value": value, "error": message }),
962                events: Vec::new(),
963            }
964        }
965        AppRequest::ConfigUnset { key } => {
966            let mut cfg = state.config.write().await;
967            let result = cfg.unset_value(&key);
968            let ok = result.is_ok();
969            let message = result.err().map(|e| e.to_string());
970            let snapshot = cfg.clone();
971            drop(cfg);
972            if let Err(e) = persist_config(state, snapshot).await {
973                tracing::error!("Failed to persist config after unset: {e}");
974            }
975            AppResponse {
976                ok,
977                data: json!({ "key": key, "error": message }),
978                events: Vec::new(),
979            }
980        }
981        AppRequest::ConfigList => {
982            let cfg = state.config.read().await;
983            AppResponse {
984                ok: true,
985                data: json!({ "values": cfg.list_values() }),
986                events: Vec::new(),
987            }
988        }
989        AppRequest::Models => AppResponse {
990            ok: true,
991            data: json!({ "models": state.registry.list() }),
992            events: Vec::new(),
993        },
994        AppRequest::ThreadLoadedList => {
995            let mut runtime = state.runtime.lock().await;
996            let response = runtime
997                .handle_thread(codewhale_protocol::ThreadRequest::List(
998                    codewhale_protocol::ThreadListParams {
999                        include_archived: false,
1000                        limit: Some(50),
1001                    },
1002                ))
1003                .await;
1004            match response {
1005                Ok(thread_resp) => AppResponse {
1006                    ok: true,
1007                    data: json!({ "threads": thread_resp.threads }),
1008                    events: thread_resp.events,
1009                },
1010                Err(err) => AppResponse {
1011                    ok: false,
1012                    data: json!({ "error": err.to_string() }),
1013                    events: Vec::new(),
1014                },
1015            }
1016        }
1017        AppRequest::SubmitUserInput {
1018            request_id,
1019            answers,
1020        } => {
1021            // Record the user's answers against the pending clarification
1022            // request so a driver can retrieve them. The headless runtime does
1023            // not block on `request_user_input` (fire-and-return, like
1024            // approval), so there is no in-flight turn to resume here — the
1025            // caller is expected to feed these answers into the next turn.
1026            let mut pending = state.pending_user_input.lock().await;
1027            if pending.contains_key(&request_id) {
1028                return AppResponse {
1029                    ok: false,
1030                    data: json!({
1031                        "error": "request_id already resolved",
1032                        "request_id": request_id,
1033                    }),
1034                    events: Vec::new(),
1035                };
1036            }
1037            pending.insert(request_id.clone(), answers);
1038            AppResponse {
1039                ok: true,
1040                data: json!({ "request_id": request_id, "resolved": true }),
1041                events: Vec::new(),
1042            }
1043        }
1044    }
1045}
1046
1047async fn persist_config(state: &AppState, config: codewhale_config::ConfigToml) -> Result<()> {
1048    if state.config_path.is_none() {
1049        return Ok(());
1050    }
1051    let mut store = ConfigStore::load(state.config_path.clone())?;
1052    store.config = config;
1053    store.save()
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058    use super::*;
1059    use axum::body::{Body, to_bytes};
1060    use codewhale_protocol::AppRequest;
1061    use std::fs;
1062    use tower::ServiceExt;
1063
1064    fn app_with_config(auth_token: Option<&str>) -> (Router, tempfile::TempDir) {
1065        let tmp = tempfile::tempdir().expect("tempdir");
1066        let config_path = tmp.path().join("config.toml");
1067        fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1068        let state = build_state(
1069            Some(config_path),
1070            auth_token.map(std::string::ToString::to_string),
1071        )
1072        .expect("state");
1073        (app_router(state, &[]), tmp)
1074    }
1075
1076    async fn response_body_json(response: Response) -> Value {
1077        let bytes = to_bytes(response.into_body(), usize::MAX)
1078            .await
1079            .expect("body bytes");
1080        serde_json::from_slice(&bytes).expect("json response")
1081    }
1082
1083    #[tokio::test]
1084    async fn http_app_routes_require_bearer_token_when_auth_enabled() {
1085        let (app, _tmp) = app_with_config(Some("test-token"));
1086        let response = app
1087            .oneshot(
1088                Request::builder()
1089                    .method(Method::POST)
1090                    .uri("/app")
1091                    .header(header::CONTENT_TYPE, "application/json")
1092                    .body(Body::from(
1093                        serde_json::to_vec(&AppRequest::ConfigGet {
1094                            key: "api_key".to_string(),
1095                        })
1096                        .expect("request json"),
1097                    ))
1098                    .expect("request"),
1099            )
1100            .await
1101            .expect("response");
1102
1103        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1104    }
1105
1106    #[tokio::test]
1107    async fn http_config_get_redacts_sensitive_values_after_auth() {
1108        let (app, _tmp) = app_with_config(Some("test-token"));
1109        let response = app
1110            .oneshot(
1111                Request::builder()
1112                    .method(Method::POST)
1113                    .uri("/app")
1114                    .header(header::AUTHORIZATION, "Bearer test-token")
1115                    .header(header::CONTENT_TYPE, "application/json")
1116                    .body(Body::from(
1117                        serde_json::to_vec(&AppRequest::ConfigGet {
1118                            key: "api_key".to_string(),
1119                        })
1120                        .expect("request json"),
1121                    ))
1122                    .expect("request"),
1123            )
1124            .await
1125            .expect("response");
1126
1127        assert_eq!(response.status(), StatusCode::OK);
1128        let body = response_body_json(response).await;
1129        assert_eq!(body["data"]["value"], "sk-d***cret");
1130    }
1131
1132    #[tokio::test]
1133    async fn cors_does_not_allow_arbitrary_origins() {
1134        let (app, _tmp) = app_with_config(Some("test-token"));
1135        let response = app
1136            .oneshot(
1137                Request::builder()
1138                    .method(Method::GET)
1139                    .uri("/healthz")
1140                    .header(header::ORIGIN, "https://attacker.example")
1141                    .body(Body::empty())
1142                    .expect("request"),
1143            )
1144            .await
1145            .expect("response");
1146
1147        assert_eq!(response.status(), StatusCode::OK);
1148        assert!(
1149            response
1150                .headers()
1151                .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
1152                .is_none()
1153        );
1154    }
1155
1156    #[tokio::test]
1157    async fn build_state_loads_permissions_into_runtime_policy() {
1158        let tmp = tempfile::tempdir().expect("tempdir");
1159        let config_path = tmp.path().join("config.toml");
1160        fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1161        fs::write(
1162            tmp.path().join("permissions.toml"),
1163            r#"
1164            [[rules]]
1165            tool = "exec_shell"
1166            command = "cargo test"
1167            "#,
1168        )
1169        .expect("write permissions");
1170
1171        let state = build_state(Some(config_path), None).expect("state");
1172        let runtime = state.runtime.lock().await;
1173        let decision = runtime
1174            .exec_policy
1175            .check(codewhale_execpolicy::ExecPolicyContext {
1176                command: "cargo test --workspace",
1177                cwd: "/workspace",
1178                tool: Some("exec_shell"),
1179                path: None,
1180                ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1181                sandbox_mode: Some("workspace-write"),
1182            })
1183            .expect("policy check");
1184
1185        assert!(decision.allow);
1186        assert!(decision.requires_approval);
1187        assert_eq!(
1188            decision.matched_rule.as_deref(),
1189            Some("tool=exec_shell command=cargo test")
1190        );
1191    }
1192
1193    #[test]
1194    fn non_loopback_bind_without_auth_fails_fast() {
1195        let options = AppServerOptions {
1196            listen: "0.0.0.0:8787".parse().expect("socket addr"),
1197            config_path: None,
1198            auth_token: None,
1199            insecure_no_auth: false,
1200            cors_origins: Vec::new(),
1201        };
1202
1203        let err =
1204            resolve_auth_token(&options).expect_err("non-loopback generated auth should fail");
1205        assert!(err.to_string().contains("without explicit auth token"));
1206    }
1207
1208    #[tokio::test]
1209    async fn stdio_transport_keeps_raw_config_get_for_legacy_clients() {
1210        let tmp = tempfile::tempdir().expect("tempdir");
1211        let config_path = tmp.path().join("config.toml");
1212        fs::write(&config_path, "").expect("write config");
1213        let state = build_state(Some(config_path), None).expect("state");
1214        {
1215            let mut cfg = state.config.write().await;
1216            cfg.api_key = Some("sk-deepseek-secret".to_string());
1217        }
1218
1219        let response = process_app_request(
1220            &state,
1221            AppRequest::ConfigGet {
1222                key: "api_key".to_string(),
1223            },
1224            AppTransport::Stdio,
1225        )
1226        .await;
1227
1228        assert_eq!(response.data["value"], "sk-deepseek-secret");
1229    }
1230
1231    #[tokio::test]
1232    async fn stdio_thread_goal_methods_round_trip_persisted_goal() {
1233        let tmp = tempfile::tempdir().expect("tempdir");
1234        let config_path = tmp.path().join("config.toml");
1235        fs::write(&config_path, "").expect("write config");
1236        let state = build_state(Some(config_path), None).expect("state");
1237
1238        let capabilities = dispatch_stdio_request(&state, "thread/capabilities", json!({}))
1239            .await
1240            .expect("thread capabilities");
1241        assert!(
1242            capabilities.result["methods"]
1243                .as_array()
1244                .expect("methods")
1245                .iter()
1246                .any(|method| method == "thread/goal/set")
1247        );
1248
1249        let started = dispatch_stdio_request(&state, "thread/start", json!({}))
1250            .await
1251            .expect("start thread");
1252        let thread_id = started.result["thread_id"]
1253            .as_str()
1254            .expect("thread id")
1255            .to_string();
1256
1257        let set = dispatch_stdio_request(
1258            &state,
1259            "thread/goal/set",
1260            json!({
1261                "thread_id": thread_id,
1262                "objective": "Release 0.8.59",
1263                "token_budget": 59000
1264            }),
1265        )
1266        .await
1267        .expect("set goal");
1268        assert_eq!(set.result["status"], "ok");
1269        assert_eq!(set.result["goal"]["objective"], "Release 0.8.59");
1270        assert_eq!(set.result["goal"]["status"], "active");
1271
1272        let got = dispatch_stdio_request(
1273            &state,
1274            "thread/goal/get",
1275            json!({
1276                "thread_id": thread_id
1277            }),
1278        )
1279        .await
1280        .expect("get goal");
1281        assert_eq!(got.result["goal"]["token_budget"], 59000);
1282
1283        let cleared = dispatch_stdio_request(
1284            &state,
1285            "thread/goal/clear",
1286            json!({
1287                "thread_id": thread_id
1288            }),
1289        )
1290        .await
1291        .expect("clear goal");
1292        assert_eq!(cleared.result["status"], "cleared");
1293        assert_eq!(cleared.result["data"]["cleared"], true);
1294    }
1295
1296    // ── capability drift guard ─────────────────────────────────────────
1297    //
1298    // The stdio `capabilities` method is the benchmark/SDK contract: external
1299    // harnesses probe it (without spending model tokens) to learn what the
1300    // app-server can do. Pin the advertised method set so any change forces a
1301    // deliberate update here, in the dispatcher, and in docs/RUNTIME_API.md.
1302
1303    /// Methods advertised by the top-level `capabilities` probe, in order.
1304    const EXPECTED_CAPABILITY_METHODS: &[&str] = &[
1305        "healthz",
1306        "thread/capabilities",
1307        "thread/request",
1308        "thread/create",
1309        "thread/start",
1310        "thread/resume",
1311        "thread/fork",
1312        "thread/list",
1313        "thread/read",
1314        "thread/set_name",
1315        "thread/goal/set",
1316        "thread/goal/get",
1317        "thread/goal/clear",
1318        "thread/archive",
1319        "thread/unarchive",
1320        "thread/message",
1321        "app/capabilities",
1322        "app/request",
1323        "app/config/get",
1324        "app/config/set",
1325        "app/config/unset",
1326        "app/config/list",
1327        "app/models",
1328        "app/thread_loaded_list",
1329        "prompt/capabilities",
1330        "prompt/request",
1331        "prompt/run",
1332        "shutdown",
1333    ];
1334
1335    fn capability_test_state() -> (AppState, tempfile::TempDir) {
1336        let tmp = tempfile::tempdir().expect("tempdir");
1337        let config_path = tmp.path().join("config.toml");
1338        fs::write(&config_path, "").expect("write config");
1339        let state = build_state(Some(config_path), None).expect("state");
1340        (state, tmp)
1341    }
1342
1343    #[tokio::test]
1344    async fn capabilities_method_set_is_stable() {
1345        let (state, _tmp) = capability_test_state();
1346        let caps = dispatch_stdio_request(&state, "capabilities", json!({}))
1347            .await
1348            .expect("capabilities dispatch");
1349        let methods: Vec<String> = caps.result["methods"]
1350            .as_array()
1351            .expect("methods array")
1352            .iter()
1353            .map(|m| m.as_str().expect("method string").to_string())
1354            .collect();
1355        assert_eq!(
1356            methods, EXPECTED_CAPABILITY_METHODS,
1357            "app-server stdio capability set drifted; update the dispatcher, this \
1358             snapshot, and docs/RUNTIME_API.md together"
1359        );
1360    }
1361
1362    #[tokio::test]
1363    async fn every_advertised_capability_is_dispatchable() {
1364        let (state, _tmp) = capability_test_state();
1365        // Empty params: methods may fail validation (-32602), but none may report
1366        // method-not-found (-32601). Required fields (e.g. PromptRequest.prompt)
1367        // make the prompt routes fail at parse time, so no model tokens are spent.
1368        for method in EXPECTED_CAPABILITY_METHODS {
1369            if let Err(err) = dispatch_stdio_request(&state, method, json!({})).await {
1370                assert_ne!(
1371                    err.code,
1372                    JsonRpcError::method_not_found(method).code,
1373                    "advertised capability `{method}` is not dispatchable"
1374                );
1375            }
1376        }
1377    }
1378
1379    // ── resolve_auth_token ─────────────────────────────────────────────
1380
1381    #[test]
1382    fn auth_token_empty_string_fails() {
1383        let options = AppServerOptions {
1384            listen: "127.0.0.1:0".parse().expect("addr"),
1385            config_path: None,
1386            auth_token: Some("  ".to_string()),
1387            insecure_no_auth: false,
1388            cors_origins: Vec::new(),
1389        };
1390        let err = resolve_auth_token(&options).expect_err("empty token should fail");
1391        assert!(err.to_string().contains("cannot be empty"));
1392    }
1393
1394    #[test]
1395    fn auth_token_generated_when_none_provided() {
1396        let options = AppServerOptions {
1397            listen: "127.0.0.1:0".parse().expect("addr"),
1398            config_path: None,
1399            auth_token: None,
1400            insecure_no_auth: false,
1401            cors_origins: Vec::new(),
1402        };
1403        let token = resolve_auth_token(&options).unwrap();
1404        assert!(token.is_some());
1405        assert!(token.unwrap().starts_with("cwapp_"));
1406    }
1407
1408    #[test]
1409    fn auth_token_explicit_is_preserved() {
1410        let options = AppServerOptions {
1411            listen: "127.0.0.1:0".parse().expect("addr"),
1412            config_path: None,
1413            auth_token: Some("my-secret".to_string()),
1414            insecure_no_auth: false,
1415            cors_origins: Vec::new(),
1416        };
1417        let token = resolve_auth_token(&options).unwrap();
1418        assert_eq!(token.as_deref(), Some("my-secret"));
1419    }
1420
1421    #[test]
1422    fn auth_token_explicit_allows_non_loopback_bind() {
1423        let options = AppServerOptions {
1424            listen: "0.0.0.0:8787".parse().expect("socket addr"),
1425            config_path: None,
1426            auth_token: Some("my-secret".to_string()),
1427            insecure_no_auth: false,
1428            cors_origins: Vec::new(),
1429        };
1430        let token = resolve_auth_token(&options).unwrap();
1431        assert_eq!(token.as_deref(), Some("my-secret"));
1432    }
1433
1434    #[test]
1435    fn insecure_no_auth_on_loopback_returns_none() {
1436        let options = AppServerOptions {
1437            listen: "127.0.0.1:0".parse().expect("addr"),
1438            config_path: None,
1439            auth_token: None,
1440            insecure_no_auth: true,
1441            cors_origins: Vec::new(),
1442        };
1443        let token = resolve_auth_token(&options).unwrap();
1444        assert!(token.is_none());
1445    }
1446
1447    #[test]
1448    fn insecure_no_auth_on_non_loopback_fails_fast() {
1449        let options = AppServerOptions {
1450            listen: "0.0.0.0:8787".parse().expect("socket addr"),
1451            config_path: None,
1452            auth_token: None,
1453            insecure_no_auth: true,
1454            cors_origins: Vec::new(),
1455        };
1456
1457        let err = resolve_auth_token(&options).expect_err("non-loopback unauth should fail");
1458        assert!(
1459            err.to_string()
1460                .contains("refusing unauthenticated app-server bind")
1461        );
1462    }
1463
1464    // ── cors_layer ─────────────────────────────────────────────────────
1465
1466    #[test]
1467    fn cors_layer_includes_default_origins() {
1468        let layer = cors_layer(&[]);
1469        // Just verify it doesn't panic and creates successfully
1470        let _ = layer;
1471    }
1472
1473    #[test]
1474    fn cors_layer_adds_extra_origins() {
1475        let extras = vec!["https://example.com".to_string()];
1476        let layer = cors_layer(&extras);
1477        let _ = layer;
1478    }
1479
1480    #[test]
1481    fn cors_layer_skips_empty_origins() {
1482        let extras = vec!["".to_string(), "  ".to_string()];
1483        let layer = cors_layer(&extras);
1484        let _ = layer;
1485    }
1486
1487    // ── JsonRpc helpers ────────────────────────────────────────────────
1488
1489    #[test]
1490    fn params_or_object_returns_object_for_null() {
1491        let result = params_or_object(Value::Null);
1492        assert_eq!(result, json!({}));
1493    }
1494
1495    #[test]
1496    fn params_or_object_passthrough_for_non_null() {
1497        let input = json!({"key": "value"});
1498        let result = params_or_object(input.clone());
1499        assert_eq!(result, input);
1500    }
1501
1502    #[test]
1503    fn jsonrpc_result_format() {
1504        let result = jsonrpc_result(Some(json!(1)), json!({"ok": true}));
1505        assert_eq!(result["jsonrpc"], "2.0");
1506        assert_eq!(result["id"], 1);
1507        assert_eq!(result["result"]["ok"], true);
1508    }
1509
1510    #[test]
1511    fn jsonrpc_result_null_id() {
1512        let result = jsonrpc_result(None, json!(null));
1513        assert_eq!(result["id"], Value::Null);
1514    }
1515
1516    #[test]
1517    fn jsonrpc_error_format() {
1518        let err = jsonrpc_error(Some(json!(2)), JsonRpcError::internal("oops"));
1519        assert_eq!(err["jsonrpc"], "2.0");
1520        assert_eq!(err["id"], 2);
1521        assert_eq!(err["error"]["code"], -32603);
1522        assert_eq!(err["error"]["message"], "oops");
1523    }
1524
1525    #[test]
1526    fn jsonrpc_error_codes() {
1527        assert_eq!(JsonRpcError::parse_error("").code, -32700);
1528        assert_eq!(JsonRpcError::invalid_request("").code, -32600);
1529        assert_eq!(JsonRpcError::method_not_found("x").code, -32601);
1530        assert_eq!(JsonRpcError::invalid_params("").code, -32602);
1531        assert_eq!(JsonRpcError::internal("").code, -32603);
1532    }
1533
1534    // ── AppServerOptions ───────────────────────────────────────────────
1535
1536    #[test]
1537    fn app_server_options_debug_does_not_leak_token() {
1538        let options = AppServerOptions {
1539            listen: "127.0.0.1:8080".parse().expect("addr"),
1540            config_path: None,
1541            auth_token: Some("secret-token".to_string()),
1542            insecure_no_auth: false,
1543            cors_origins: vec!["https://example.com".to_string()],
1544        };
1545        let debug = format!("{options:?}");
1546        assert!(!debug.contains("secret-token"));
1547        assert!(debug.contains("<redacted>"));
1548        assert!(debug.contains("8080"));
1549    }
1550
1551    // ── Default CORS origins ──────────────────────────────────────────
1552
1553    #[test]
1554    fn default_cors_origins_include_common_dev_ports() {
1555        assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:3000"));
1556        assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:5173"));
1557        assert!(DEFAULT_CORS_ORIGINS.contains(&"tauri://localhost"));
1558    }
1559}