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 has_explicit_config_path = config_path.is_some();
342    let store = ConfigStore::load(config_path)?;
343    let config_path = has_explicit_config_path.then(|| store.path().to_path_buf());
344    let config = store.config.clone();
345    let exec_policy = store.exec_policy_engine();
346    let registry = ModelRegistry::default();
347
348    let state_db_path = config_path
349        .as_ref()
350        .and_then(|p| p.parent().map(|parent| parent.join("state.db")));
351    let state_store = StateStore::open(state_db_path)?;
352
353    let mut hooks = HookDispatcher::default();
354    hooks.add_sink(Arc::new(StdoutHookSink));
355    let hook_log_path = config_path
356        .as_ref()
357        .and_then(|p| p.parent().map(|parent| parent.join("events.jsonl")))
358        .unwrap_or_else(|| PathBuf::from(".deepseek/events.jsonl"));
359    hooks.add_sink(Arc::new(JsonlHookSink::new(hook_log_path)));
360
361    if let Some(socket_path) = config
362        .hook_sinks
363        .as_ref()
364        .and_then(|sinks| sinks.unix_socket_path.as_ref())
365        .filter(|path| !path.as_os_str().is_empty())
366    {
367        hooks.add_sink(Arc::new(UnixSocketHookSink::new(socket_path.clone())));
368    }
369
370    let runtime = Runtime::new(
371        config.clone(),
372        registry.clone(),
373        state_store,
374        Arc::new(ToolRegistry::default()),
375        Arc::new(McpManager::default()),
376        exec_policy,
377        hooks,
378    );
379
380    Ok(AppState {
381        config_path,
382        config: Arc::new(RwLock::new(config)),
383        runtime: Arc::new(Mutex::new(runtime)),
384        registry,
385        auth_token,
386        pending_user_input: Arc::new(Mutex::new(std::collections::HashMap::new())),
387    })
388}
389
390fn resolve_auth_token(options: &AppServerOptions) -> Result<Option<String>> {
391    let configured = options.auth_token.as_ref().map(|token| token.trim());
392    if let Some(token) = configured
393        && token.is_empty()
394    {
395        bail!("app-server auth token cannot be empty");
396    }
397    let has_explicit_token = configured.is_some();
398
399    if options.insecure_no_auth {
400        if !options.listen.ip().is_loopback() {
401            bail!("refusing unauthenticated app-server bind on non-loopback address");
402        }
403        eprintln!("warning: app-server HTTP auth disabled by --insecure-no-auth");
404        return Ok(None);
405    }
406
407    if !has_explicit_token && !options.listen.ip().is_loopback() {
408        bail!(
409            "refusing non-loopback app-server bind without explicit auth token; pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN"
410        );
411    }
412
413    let token = configured
414        .map(str::to_string)
415        .unwrap_or_else(|| format!("cwapp_{}", Uuid::new_v4().simple()));
416    for line in app_server_auth_status_lines(has_explicit_token) {
417        eprintln!("{line}");
418    }
419    Ok(Some(token))
420}
421
422fn app_server_auth_status_lines(has_explicit_token: bool) -> Vec<&'static str> {
423    if has_explicit_token {
424        return vec!["app-server auth: bearer token required for HTTP routes."];
425    }
426    vec![
427        "app-server auth: generated bearer token for this process (not printed).",
428        "  Pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN when another client needs to connect.",
429    ]
430}
431
432fn cors_layer(extra_origins: &[String]) -> CorsLayer {
433    let mut origins: Vec<HeaderValue> = DEFAULT_CORS_ORIGINS
434        .iter()
435        .filter_map(|origin| HeaderValue::from_str(origin).ok())
436        .collect();
437    for raw in extra_origins {
438        let trimmed = raw.trim();
439        if trimmed.is_empty() {
440            continue;
441        }
442        match HeaderValue::from_str(trimmed) {
443            Ok(value) if !origins.contains(&value) => origins.push(value),
444            Ok(_) => {}
445            Err(err) => {
446                eprintln!("warning: ignoring invalid app-server CORS origin `{trimmed}`: {err}")
447            }
448        }
449    }
450
451    CorsLayer::new()
452        .allow_origin(origins)
453        .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
454        .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
455}
456
457async fn require_app_server_token(
458    State(state): State<AppState>,
459    req: Request,
460    next: Next,
461) -> Response {
462    let Some(expected) = state.auth_token.as_deref() else {
463        return next.run(req).await;
464    };
465    let authorized = req
466        .headers()
467        .get(header::AUTHORIZATION)
468        .and_then(|value| value.to_str().ok())
469        .and_then(|raw| raw.strip_prefix("Bearer "))
470        .is_some_and(|token| token == expected);
471
472    if authorized {
473        next.run(req).await
474    } else {
475        (
476            StatusCode::UNAUTHORIZED,
477            Json(json!({
478                "error": {
479                    "message": "app-server bearer token required",
480                    "status": StatusCode::UNAUTHORIZED.as_u16(),
481                }
482            })),
483        )
484            .into_response()
485    }
486}
487
488fn params_or_object(params: Value) -> Value {
489    if params.is_null() { json!({}) } else { params }
490}
491
492fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
493    serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
494}
495
496fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
497    json!({
498        "jsonrpc": "2.0",
499        "id": id.unwrap_or(Value::Null),
500        "result": result
501    })
502}
503
504fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
505    json!({
506        "jsonrpc": "2.0",
507        "id": id.unwrap_or(Value::Null),
508        "error": {
509            "code": err.code,
510            "message": err.message,
511            "data": err.data
512        }
513    })
514}
515
516impl JsonRpcError {
517    fn parse_error(message: impl Into<String>) -> Self {
518        Self {
519            code: -32700,
520            message: message.into(),
521            data: None,
522        }
523    }
524
525    fn invalid_request(message: impl Into<String>) -> Self {
526        Self {
527            code: -32600,
528            message: message.into(),
529            data: None,
530        }
531    }
532
533    fn method_not_found(method: &str) -> Self {
534        Self {
535            code: -32601,
536            message: format!("unsupported method: {method}"),
537            data: None,
538        }
539    }
540
541    fn invalid_params(message: impl Into<String>) -> Self {
542        Self {
543            code: -32602,
544            message: message.into(),
545            data: None,
546        }
547    }
548
549    fn internal(message: impl Into<String>) -> Self {
550        Self {
551            code: -32603,
552            message: message.into(),
553            data: None,
554        }
555    }
556}
557
558async fn handle_thread_request(
559    state: &AppState,
560    req: ThreadRequest,
561) -> std::result::Result<ThreadResponse, JsonRpcError> {
562    let mut runtime = state.runtime.lock().await;
563    runtime
564        .handle_thread(req)
565        .await
566        .map_err(|err| JsonRpcError::internal(err.to_string()))
567}
568
569async fn handle_prompt_request(
570    state: &AppState,
571    req: PromptRequest,
572) -> std::result::Result<PromptResponse, JsonRpcError> {
573    let mut runtime = state.runtime.lock().await;
574    runtime
575        .handle_prompt(req, &CliRuntimeOverrides::default())
576        .await
577        .map_err(|err| JsonRpcError::internal(err.to_string()))
578}
579
580async fn dispatch_stdio_request(
581    state: &AppState,
582    method: &str,
583    params: Value,
584) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
585    let outcome = match method {
586        "healthz" | "app/healthz" => StdioDispatchResult {
587            result: json!({
588                "status": "ok",
589                "service": "deepseek-app-server",
590                "transport": "stdio"
591            }),
592            should_exit: false,
593        },
594        "capabilities" => StdioDispatchResult {
595            result: json!({
596                "transport": "stdio",
597                "families": ["thread/*", "app/*", "prompt/*"],
598                "methods": [
599                    "healthz",
600                    "thread/capabilities",
601                    "thread/request",
602                    "thread/create",
603                    "thread/start",
604                    "thread/resume",
605                    "thread/fork",
606                    "thread/list",
607                    "thread/read",
608                    "thread/set_name",
609                    "thread/goal/set",
610                    "thread/goal/get",
611                    "thread/goal/clear",
612                    "thread/archive",
613                    "thread/unarchive",
614                    "thread/message",
615                    "app/capabilities",
616                    "app/request",
617                    "app/config/get",
618                    "app/config/set",
619                    "app/config/unset",
620                    "app/config/list",
621                    "app/models",
622                    "app/thread_loaded_list",
623                    "prompt/capabilities",
624                    "prompt/request",
625                    "prompt/run",
626                    "shutdown"
627                ]
628            }),
629            should_exit: false,
630        },
631        "thread/capabilities" => StdioDispatchResult {
632            result: json!({
633                "methods": [
634                    "thread/request",
635                    "thread/create",
636                    "thread/start",
637                    "thread/resume",
638                    "thread/fork",
639                    "thread/list",
640                    "thread/read",
641                    "thread/set_name",
642                    "thread/goal/set",
643                    "thread/goal/get",
644                    "thread/goal/clear",
645                    "thread/archive",
646                    "thread/unarchive",
647                    "thread/message"
648                ]
649            }),
650            should_exit: false,
651        },
652        "thread/request" => {
653            let request: ThreadRequest = parse_params(params)?;
654            let response = handle_thread_request(state, request).await?;
655            StdioDispatchResult {
656                result: serde_json::to_value(response)
657                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
658                should_exit: false,
659            }
660        }
661        "thread/create" => {
662            #[derive(Debug, Deserialize)]
663            struct CreateParams {
664                #[serde(default)]
665                metadata: Value,
666            }
667            let parsed: CreateParams = parse_params(params_or_object(params))?;
668            let response = handle_thread_request(
669                state,
670                ThreadRequest::Create {
671                    metadata: parsed.metadata,
672                },
673            )
674            .await?;
675            StdioDispatchResult {
676                result: serde_json::to_value(response)
677                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
678                should_exit: false,
679            }
680        }
681        "thread/start" => {
682            let request = ThreadRequest::Start(parse_params(params_or_object(params))?);
683            let response = handle_thread_request(state, request).await?;
684            StdioDispatchResult {
685                result: serde_json::to_value(response)
686                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
687                should_exit: false,
688            }
689        }
690        "thread/resume" => {
691            let request = ThreadRequest::Resume(parse_params(params_or_object(params))?);
692            let response = handle_thread_request(state, request).await?;
693            StdioDispatchResult {
694                result: serde_json::to_value(response)
695                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
696                should_exit: false,
697            }
698        }
699        "thread/fork" => {
700            let request = ThreadRequest::Fork(parse_params(params_or_object(params))?);
701            let response = handle_thread_request(state, request).await?;
702            StdioDispatchResult {
703                result: serde_json::to_value(response)
704                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
705                should_exit: false,
706            }
707        }
708        "thread/list" => {
709            let request = ThreadRequest::List(parse_params(params_or_object(params))?);
710            let response = handle_thread_request(state, request).await?;
711            StdioDispatchResult {
712                result: serde_json::to_value(response)
713                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
714                should_exit: false,
715            }
716        }
717        "thread/read" => {
718            let request = ThreadRequest::Read(parse_params(params_or_object(params))?);
719            let response = handle_thread_request(state, request).await?;
720            StdioDispatchResult {
721                result: serde_json::to_value(response)
722                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
723                should_exit: false,
724            }
725        }
726        "thread/set_name" | "thread/set-name" => {
727            let request = ThreadRequest::SetName(parse_params(params_or_object(params))?);
728            let response = handle_thread_request(state, request).await?;
729            StdioDispatchResult {
730                result: serde_json::to_value(response)
731                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
732                should_exit: false,
733            }
734        }
735        "thread/goal/set" | "thread/goal_set" | "thread/goal-set" => {
736            let request = ThreadRequest::GoalSet(parse_params::<ThreadGoalSetParams>(
737                params_or_object(params),
738            )?);
739            let response = handle_thread_request(state, request).await?;
740            StdioDispatchResult {
741                result: serde_json::to_value(response)
742                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
743                should_exit: false,
744            }
745        }
746        "thread/goal/get" | "thread/goal_get" | "thread/goal-get" => {
747            let request = ThreadRequest::GoalGet(parse_params::<ThreadGoalGetParams>(
748                params_or_object(params),
749            )?);
750            let response = handle_thread_request(state, request).await?;
751            StdioDispatchResult {
752                result: serde_json::to_value(response)
753                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
754                should_exit: false,
755            }
756        }
757        "thread/goal/clear" | "thread/goal_clear" | "thread/goal-clear" => {
758            let request = ThreadRequest::GoalClear(parse_params::<ThreadGoalClearParams>(
759                params_or_object(params),
760            )?);
761            let response = handle_thread_request(state, request).await?;
762            StdioDispatchResult {
763                result: serde_json::to_value(response)
764                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
765                should_exit: false,
766            }
767        }
768        "thread/archive" => {
769            let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
770            let response = handle_thread_request(
771                state,
772                ThreadRequest::Archive {
773                    thread_id: parsed.thread_id,
774                },
775            )
776            .await?;
777            StdioDispatchResult {
778                result: serde_json::to_value(response)
779                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
780                should_exit: false,
781            }
782        }
783        "thread/unarchive" => {
784            let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
785            let response = handle_thread_request(
786                state,
787                ThreadRequest::Unarchive {
788                    thread_id: parsed.thread_id,
789                },
790            )
791            .await?;
792            StdioDispatchResult {
793                result: serde_json::to_value(response)
794                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
795                should_exit: false,
796            }
797        }
798        "thread/message" => {
799            let parsed: ThreadMessageParams = parse_params(params_or_object(params))?;
800            let response = handle_thread_request(
801                state,
802                ThreadRequest::Message {
803                    thread_id: parsed.thread_id,
804                    input: parsed.input,
805                },
806            )
807            .await?;
808            StdioDispatchResult {
809                result: serde_json::to_value(response)
810                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
811                should_exit: false,
812            }
813        }
814        "app/capabilities" => {
815            let response =
816                process_app_request(state, AppRequest::Capabilities, AppTransport::Stdio).await;
817            StdioDispatchResult {
818                result: serde_json::to_value(response)
819                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
820                should_exit: false,
821            }
822        }
823        "app/request" => {
824            let request: AppRequest = parse_params(params)?;
825            let response = process_app_request(state, request, AppTransport::Stdio).await;
826            StdioDispatchResult {
827                result: serde_json::to_value(response)
828                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
829                should_exit: false,
830            }
831        }
832        "app/config/get" => {
833            let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
834            let response = process_app_request(
835                state,
836                AppRequest::ConfigGet { key: parsed.key },
837                AppTransport::Stdio,
838            )
839            .await;
840            StdioDispatchResult {
841                result: serde_json::to_value(response)
842                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
843                should_exit: false,
844            }
845        }
846        "app/config/set" => {
847            let parsed: ConfigSetParams = parse_params(params_or_object(params))?;
848            let response = process_app_request(
849                state,
850                AppRequest::ConfigSet {
851                    key: parsed.key,
852                    value: parsed.value,
853                },
854                AppTransport::Stdio,
855            )
856            .await;
857            StdioDispatchResult {
858                result: serde_json::to_value(response)
859                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
860                should_exit: false,
861            }
862        }
863        "app/config/unset" => {
864            let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
865            let response = process_app_request(
866                state,
867                AppRequest::ConfigUnset { key: parsed.key },
868                AppTransport::Stdio,
869            )
870            .await;
871            StdioDispatchResult {
872                result: serde_json::to_value(response)
873                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
874                should_exit: false,
875            }
876        }
877        "app/config/list" => {
878            let response =
879                process_app_request(state, AppRequest::ConfigList, AppTransport::Stdio).await;
880            StdioDispatchResult {
881                result: serde_json::to_value(response)
882                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
883                should_exit: false,
884            }
885        }
886        "app/models" => {
887            let response =
888                process_app_request(state, AppRequest::Models, AppTransport::Stdio).await;
889            StdioDispatchResult {
890                result: serde_json::to_value(response)
891                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
892                should_exit: false,
893            }
894        }
895        "app/thread_loaded_list" | "app/thread-loaded-list" => {
896            let response =
897                process_app_request(state, AppRequest::ThreadLoadedList, AppTransport::Stdio).await;
898            StdioDispatchResult {
899                result: serde_json::to_value(response)
900                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
901                should_exit: false,
902            }
903        }
904        "prompt/capabilities" => StdioDispatchResult {
905            result: json!({
906                "methods": ["prompt/request", "prompt/run"]
907            }),
908            should_exit: false,
909        },
910        "prompt/request" | "prompt/run" => {
911            let request: PromptRequest = parse_params(params)?;
912            let response = handle_prompt_request(state, request).await?;
913            StdioDispatchResult {
914                result: serde_json::to_value(response)
915                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
916                should_exit: false,
917            }
918        }
919        "shutdown" => StdioDispatchResult {
920            result: json!({"ok": true, "status": "stopped"}),
921            should_exit: true,
922        },
923        _ => return Err(JsonRpcError::method_not_found(method)),
924    };
925    Ok(outcome)
926}
927
928async fn process_app_request(
929    state: &AppState,
930    req: AppRequest,
931    transport: AppTransport,
932) -> AppResponse {
933    match req {
934        AppRequest::Capabilities => AppResponse {
935            ok: true,
936            data: json!({
937                "routes": ["/thread", "/app", "/prompt", "/tool", "/jobs", "/mcp/startup"],
938                "config": ["get", "set", "unset", "list"],
939                "events": ["response_start", "response_delta", "response_end", "tool_call_start", "tool_call_result", "mcp_startup_update", "mcp_startup_complete"],
940                "transport": "stdio+http",
941                "config_path": state.config_path.as_ref().map(|p| p.display().to_string()),
942            }),
943            events: Vec::new(),
944        },
945        AppRequest::ConfigGet { key } => {
946            let cfg = state.config.read().await;
947            let value = match transport {
948                AppTransport::Http => cfg.get_display_value(&key),
949                AppTransport::Stdio => cfg.get_value(&key),
950            };
951            AppResponse {
952                ok: true,
953                data: json!({ "key": key, "value": value }),
954                events: Vec::new(),
955            }
956        }
957        AppRequest::ConfigSet { key, value } => {
958            let mut cfg = state.config.write().await;
959            let result = cfg.set_value(&key, &value);
960            let ok = result.is_ok();
961            let message = result.err().map(|e| e.to_string());
962            let snapshot = cfg.clone();
963            drop(cfg);
964            if let Err(e) = persist_config(state, snapshot).await {
965                tracing::error!("Failed to persist config after set: {e}");
966            }
967            AppResponse {
968                ok,
969                data: json!({ "key": key, "value": value, "error": message }),
970                events: Vec::new(),
971            }
972        }
973        AppRequest::ConfigUnset { key } => {
974            let mut cfg = state.config.write().await;
975            let result = cfg.unset_value(&key);
976            let ok = result.is_ok();
977            let message = result.err().map(|e| e.to_string());
978            let snapshot = cfg.clone();
979            drop(cfg);
980            if let Err(e) = persist_config(state, snapshot).await {
981                tracing::error!("Failed to persist config after unset: {e}");
982            }
983            AppResponse {
984                ok,
985                data: json!({ "key": key, "error": message }),
986                events: Vec::new(),
987            }
988        }
989        AppRequest::ConfigList => {
990            let cfg = state.config.read().await;
991            AppResponse {
992                ok: true,
993                data: json!({ "values": cfg.list_values() }),
994                events: Vec::new(),
995            }
996        }
997        AppRequest::Models => AppResponse {
998            ok: true,
999            data: json!({ "models": state.registry.list() }),
1000            events: Vec::new(),
1001        },
1002        AppRequest::ThreadLoadedList => {
1003            let mut runtime = state.runtime.lock().await;
1004            let response = runtime
1005                .handle_thread(codewhale_protocol::ThreadRequest::List(
1006                    codewhale_protocol::ThreadListParams {
1007                        include_archived: false,
1008                        limit: Some(50),
1009                    },
1010                ))
1011                .await;
1012            match response {
1013                Ok(thread_resp) => AppResponse {
1014                    ok: true,
1015                    data: json!({ "threads": thread_resp.threads }),
1016                    events: thread_resp.events,
1017                },
1018                Err(err) => AppResponse {
1019                    ok: false,
1020                    data: json!({ "error": err.to_string() }),
1021                    events: Vec::new(),
1022                },
1023            }
1024        }
1025        AppRequest::SubmitUserInput {
1026            request_id,
1027            answers,
1028        } => {
1029            // Record the user's answers against the pending clarification
1030            // request so a driver can retrieve them. The headless runtime does
1031            // not block on `request_user_input` (fire-and-return, like
1032            // approval), so there is no in-flight turn to resume here — the
1033            // caller is expected to feed these answers into the next turn.
1034            let mut pending = state.pending_user_input.lock().await;
1035            if pending.contains_key(&request_id) {
1036                return AppResponse {
1037                    ok: false,
1038                    data: json!({
1039                        "error": "request_id already resolved",
1040                        "request_id": request_id,
1041                    }),
1042                    events: Vec::new(),
1043                };
1044            }
1045            pending.insert(request_id.clone(), answers);
1046            AppResponse {
1047                ok: true,
1048                data: json!({ "request_id": request_id, "resolved": true }),
1049                events: Vec::new(),
1050            }
1051        }
1052    }
1053}
1054
1055async fn persist_config(state: &AppState, config: codewhale_config::ConfigToml) -> Result<()> {
1056    if state.config_path.is_none() {
1057        return Ok(());
1058    }
1059    let mut store = ConfigStore::load(state.config_path.clone())?;
1060    store.config = config;
1061    store.save()
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066    use super::*;
1067    use axum::body::{Body, to_bytes};
1068    use codewhale_protocol::AppRequest;
1069    use std::fs;
1070    use tower::ServiceExt;
1071
1072    fn app_with_config(auth_token: Option<&str>) -> (Router, tempfile::TempDir) {
1073        let tmp = tempfile::tempdir().expect("tempdir");
1074        let config_path = tmp.path().join("config.toml");
1075        fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1076        let state = build_state(
1077            Some(config_path),
1078            auth_token.map(std::string::ToString::to_string),
1079        )
1080        .expect("state");
1081        (app_router(state, &[]), tmp)
1082    }
1083
1084    #[test]
1085    fn build_state_keeps_resolved_explicit_config_path() {
1086        let tmp = tempfile::tempdir().expect("tempdir");
1087        let config_dir = tmp.path().join("config-dir");
1088        fs::create_dir_all(&config_dir).expect("config dir");
1089        let config_path = config_dir.join("config.toml");
1090        fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1091
1092        let state = build_state(Some(config_path.clone()), None).expect("state");
1093
1094        assert_eq!(
1095            state.config_path.as_deref(),
1096            Some(
1097                config_path
1098                    .canonicalize()
1099                    .expect("canonical config")
1100                    .as_path()
1101            )
1102        );
1103    }
1104
1105    async fn response_body_json(response: Response) -> Value {
1106        let bytes = to_bytes(response.into_body(), usize::MAX)
1107            .await
1108            .expect("body bytes");
1109        serde_json::from_slice(&bytes).expect("json response")
1110    }
1111
1112    #[tokio::test]
1113    async fn http_app_routes_require_bearer_token_when_auth_enabled() {
1114        let (app, _tmp) = app_with_config(Some("test-token"));
1115        let response = app
1116            .oneshot(
1117                Request::builder()
1118                    .method(Method::POST)
1119                    .uri("/app")
1120                    .header(header::CONTENT_TYPE, "application/json")
1121                    .body(Body::from(
1122                        serde_json::to_vec(&AppRequest::ConfigGet {
1123                            key: "api_key".to_string(),
1124                        })
1125                        .expect("request json"),
1126                    ))
1127                    .expect("request"),
1128            )
1129            .await
1130            .expect("response");
1131
1132        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1133    }
1134
1135    #[tokio::test]
1136    async fn http_config_get_redacts_sensitive_values_after_auth() {
1137        let (app, _tmp) = app_with_config(Some("test-token"));
1138        let response = app
1139            .oneshot(
1140                Request::builder()
1141                    .method(Method::POST)
1142                    .uri("/app")
1143                    .header(header::AUTHORIZATION, "Bearer test-token")
1144                    .header(header::CONTENT_TYPE, "application/json")
1145                    .body(Body::from(
1146                        serde_json::to_vec(&AppRequest::ConfigGet {
1147                            key: "api_key".to_string(),
1148                        })
1149                        .expect("request json"),
1150                    ))
1151                    .expect("request"),
1152            )
1153            .await
1154            .expect("response");
1155
1156        assert_eq!(response.status(), StatusCode::OK);
1157        let body = response_body_json(response).await;
1158        assert_eq!(body["data"]["value"], "sk-d***cret");
1159    }
1160
1161    #[tokio::test]
1162    async fn cors_does_not_allow_arbitrary_origins() {
1163        let (app, _tmp) = app_with_config(Some("test-token"));
1164        let response = app
1165            .oneshot(
1166                Request::builder()
1167                    .method(Method::GET)
1168                    .uri("/healthz")
1169                    .header(header::ORIGIN, "https://attacker.example")
1170                    .body(Body::empty())
1171                    .expect("request"),
1172            )
1173            .await
1174            .expect("response");
1175
1176        assert_eq!(response.status(), StatusCode::OK);
1177        assert!(
1178            response
1179                .headers()
1180                .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
1181                .is_none()
1182        );
1183    }
1184
1185    #[tokio::test]
1186    async fn build_state_loads_permissions_into_runtime_policy() {
1187        let tmp = tempfile::tempdir().expect("tempdir");
1188        let config_path = tmp.path().join("config.toml");
1189        fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1190        fs::write(
1191            tmp.path().join("permissions.toml"),
1192            r#"
1193            [[rules]]
1194            tool = "exec_shell"
1195            command = "cargo test"
1196            "#,
1197        )
1198        .expect("write permissions");
1199
1200        let state = build_state(Some(config_path), None).expect("state");
1201        let runtime = state.runtime.lock().await;
1202        let decision = runtime
1203            .exec_policy
1204            .check(codewhale_execpolicy::ExecPolicyContext {
1205                command: "cargo test --workspace",
1206                cwd: "/workspace",
1207                tool: Some("exec_shell"),
1208                path: None,
1209                ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1210                sandbox_mode: Some("workspace-write"),
1211            })
1212            .expect("policy check");
1213
1214        assert!(decision.allow);
1215        assert!(decision.requires_approval);
1216        assert_eq!(
1217            decision.matched_rule.as_deref(),
1218            Some("tool=exec_shell command=cargo test")
1219        );
1220    }
1221
1222    #[test]
1223    fn non_loopback_bind_without_auth_fails_fast() {
1224        let options = AppServerOptions {
1225            listen: "0.0.0.0:8787".parse().expect("socket addr"),
1226            config_path: None,
1227            auth_token: None,
1228            insecure_no_auth: false,
1229            cors_origins: Vec::new(),
1230        };
1231
1232        let err =
1233            resolve_auth_token(&options).expect_err("non-loopback generated auth should fail");
1234        assert!(err.to_string().contains("without explicit auth token"));
1235    }
1236
1237    #[tokio::test]
1238    async fn stdio_transport_keeps_raw_config_get_for_legacy_clients() {
1239        let tmp = tempfile::tempdir().expect("tempdir");
1240        let config_path = tmp.path().join("config.toml");
1241        fs::write(&config_path, "").expect("write config");
1242        let state = build_state(Some(config_path), None).expect("state");
1243        {
1244            let mut cfg = state.config.write().await;
1245            cfg.api_key = Some("sk-deepseek-secret".to_string());
1246        }
1247
1248        let response = process_app_request(
1249            &state,
1250            AppRequest::ConfigGet {
1251                key: "api_key".to_string(),
1252            },
1253            AppTransport::Stdio,
1254        )
1255        .await;
1256
1257        assert_eq!(response.data["value"], "sk-deepseek-secret");
1258    }
1259
1260    #[tokio::test]
1261    async fn stdio_thread_goal_methods_round_trip_persisted_goal() {
1262        let tmp = tempfile::tempdir().expect("tempdir");
1263        let config_path = tmp.path().join("config.toml");
1264        fs::write(&config_path, "").expect("write config");
1265        let state = build_state(Some(config_path), None).expect("state");
1266
1267        let capabilities = dispatch_stdio_request(&state, "thread/capabilities", json!({}))
1268            .await
1269            .expect("thread capabilities");
1270        assert!(
1271            capabilities.result["methods"]
1272                .as_array()
1273                .expect("methods")
1274                .iter()
1275                .any(|method| method == "thread/goal/set")
1276        );
1277
1278        let started = dispatch_stdio_request(&state, "thread/start", json!({}))
1279            .await
1280            .expect("start thread");
1281        let thread_id = started.result["thread_id"]
1282            .as_str()
1283            .expect("thread id")
1284            .to_string();
1285
1286        let set = dispatch_stdio_request(
1287            &state,
1288            "thread/goal/set",
1289            json!({
1290                "thread_id": thread_id,
1291                "objective": "Release 0.8.59",
1292                "token_budget": 59000
1293            }),
1294        )
1295        .await
1296        .expect("set goal");
1297        assert_eq!(set.result["status"], "ok");
1298        assert_eq!(set.result["goal"]["objective"], "Release 0.8.59");
1299        assert_eq!(set.result["goal"]["status"], "active");
1300
1301        let got = dispatch_stdio_request(
1302            &state,
1303            "thread/goal/get",
1304            json!({
1305                "thread_id": thread_id
1306            }),
1307        )
1308        .await
1309        .expect("get goal");
1310        assert_eq!(got.result["goal"]["token_budget"], 59000);
1311
1312        let cleared = dispatch_stdio_request(
1313            &state,
1314            "thread/goal/clear",
1315            json!({
1316                "thread_id": thread_id
1317            }),
1318        )
1319        .await
1320        .expect("clear goal");
1321        assert_eq!(cleared.result["status"], "cleared");
1322        assert_eq!(cleared.result["data"]["cleared"], true);
1323    }
1324
1325    // ── capability drift guard ─────────────────────────────────────────
1326    //
1327    // The stdio `capabilities` method is the benchmark/SDK contract: external
1328    // harnesses probe it (without spending model tokens) to learn what the
1329    // app-server can do. Pin the advertised method set so any change forces a
1330    // deliberate update here, in the dispatcher, and in docs/RUNTIME_API.md.
1331
1332    /// Methods advertised by the top-level `capabilities` probe, in order.
1333    const EXPECTED_CAPABILITY_METHODS: &[&str] = &[
1334        "healthz",
1335        "thread/capabilities",
1336        "thread/request",
1337        "thread/create",
1338        "thread/start",
1339        "thread/resume",
1340        "thread/fork",
1341        "thread/list",
1342        "thread/read",
1343        "thread/set_name",
1344        "thread/goal/set",
1345        "thread/goal/get",
1346        "thread/goal/clear",
1347        "thread/archive",
1348        "thread/unarchive",
1349        "thread/message",
1350        "app/capabilities",
1351        "app/request",
1352        "app/config/get",
1353        "app/config/set",
1354        "app/config/unset",
1355        "app/config/list",
1356        "app/models",
1357        "app/thread_loaded_list",
1358        "prompt/capabilities",
1359        "prompt/request",
1360        "prompt/run",
1361        "shutdown",
1362    ];
1363
1364    fn capability_test_state() -> (AppState, tempfile::TempDir) {
1365        let tmp = tempfile::tempdir().expect("tempdir");
1366        let config_path = tmp.path().join("config.toml");
1367        fs::write(&config_path, "").expect("write config");
1368        let state = build_state(Some(config_path), None).expect("state");
1369        (state, tmp)
1370    }
1371
1372    #[tokio::test]
1373    async fn capabilities_method_set_is_stable() {
1374        let (state, _tmp) = capability_test_state();
1375        let caps = dispatch_stdio_request(&state, "capabilities", json!({}))
1376            .await
1377            .expect("capabilities dispatch");
1378        let methods: Vec<String> = caps.result["methods"]
1379            .as_array()
1380            .expect("methods array")
1381            .iter()
1382            .map(|m| m.as_str().expect("method string").to_string())
1383            .collect();
1384        assert_eq!(
1385            methods, EXPECTED_CAPABILITY_METHODS,
1386            "app-server stdio capability set drifted; update the dispatcher, this \
1387             snapshot, and docs/RUNTIME_API.md together"
1388        );
1389    }
1390
1391    #[tokio::test]
1392    async fn every_advertised_capability_is_dispatchable() {
1393        let (state, _tmp) = capability_test_state();
1394        // Empty params: methods may fail validation (-32602), but none may report
1395        // method-not-found (-32601). Required fields (e.g. PromptRequest.prompt)
1396        // make the prompt routes fail at parse time, so no model tokens are spent.
1397        for method in EXPECTED_CAPABILITY_METHODS {
1398            if let Err(err) = dispatch_stdio_request(&state, method, json!({})).await {
1399                assert_ne!(
1400                    err.code,
1401                    JsonRpcError::method_not_found(method).code,
1402                    "advertised capability `{method}` is not dispatchable"
1403                );
1404            }
1405        }
1406    }
1407
1408    // ── resolve_auth_token ─────────────────────────────────────────────
1409
1410    #[test]
1411    fn auth_token_empty_string_fails() {
1412        let options = AppServerOptions {
1413            listen: "127.0.0.1:0".parse().expect("addr"),
1414            config_path: None,
1415            auth_token: Some("  ".to_string()),
1416            insecure_no_auth: false,
1417            cors_origins: Vec::new(),
1418        };
1419        let err = resolve_auth_token(&options).expect_err("empty token should fail");
1420        assert!(err.to_string().contains("cannot be empty"));
1421    }
1422
1423    #[test]
1424    fn auth_token_generated_when_none_provided() {
1425        let options = AppServerOptions {
1426            listen: "127.0.0.1:0".parse().expect("addr"),
1427            config_path: None,
1428            auth_token: None,
1429            insecure_no_auth: false,
1430            cors_origins: Vec::new(),
1431        };
1432        let token = resolve_auth_token(&options).unwrap();
1433        assert!(token.is_some());
1434        assert!(token.unwrap().starts_with("cwapp_"));
1435    }
1436
1437    #[test]
1438    fn generated_auth_status_does_not_render_token() {
1439        let rendered = app_server_auth_status_lines(false).join("\n");
1440
1441        assert!(!rendered.contains("Authorization: Bearer"));
1442        assert!(rendered.contains("not printed"));
1443        assert!(rendered.contains("CODEWHALE_APP_SERVER_TOKEN"));
1444    }
1445
1446    #[test]
1447    fn auth_token_explicit_is_preserved() {
1448        let options = AppServerOptions {
1449            listen: "127.0.0.1:0".parse().expect("addr"),
1450            config_path: None,
1451            auth_token: Some("my-secret".to_string()),
1452            insecure_no_auth: false,
1453            cors_origins: Vec::new(),
1454        };
1455        let token = resolve_auth_token(&options).unwrap();
1456        assert_eq!(token.as_deref(), Some("my-secret"));
1457    }
1458
1459    #[test]
1460    fn auth_token_explicit_allows_non_loopback_bind() {
1461        let options = AppServerOptions {
1462            listen: "0.0.0.0:8787".parse().expect("socket addr"),
1463            config_path: None,
1464            auth_token: Some("my-secret".to_string()),
1465            insecure_no_auth: false,
1466            cors_origins: Vec::new(),
1467        };
1468        let token = resolve_auth_token(&options).unwrap();
1469        assert_eq!(token.as_deref(), Some("my-secret"));
1470    }
1471
1472    #[test]
1473    fn insecure_no_auth_on_loopback_returns_none() {
1474        let options = AppServerOptions {
1475            listen: "127.0.0.1:0".parse().expect("addr"),
1476            config_path: None,
1477            auth_token: None,
1478            insecure_no_auth: true,
1479            cors_origins: Vec::new(),
1480        };
1481        let token = resolve_auth_token(&options).unwrap();
1482        assert!(token.is_none());
1483    }
1484
1485    #[test]
1486    fn insecure_no_auth_on_non_loopback_fails_fast() {
1487        let options = AppServerOptions {
1488            listen: "0.0.0.0:8787".parse().expect("socket addr"),
1489            config_path: None,
1490            auth_token: None,
1491            insecure_no_auth: true,
1492            cors_origins: Vec::new(),
1493        };
1494
1495        let err = resolve_auth_token(&options).expect_err("non-loopback unauth should fail");
1496        assert!(
1497            err.to_string()
1498                .contains("refusing unauthenticated app-server bind")
1499        );
1500    }
1501
1502    // ── cors_layer ─────────────────────────────────────────────────────
1503
1504    #[test]
1505    fn cors_layer_includes_default_origins() {
1506        let layer = cors_layer(&[]);
1507        // Just verify it doesn't panic and creates successfully
1508        let _ = layer;
1509    }
1510
1511    #[test]
1512    fn cors_layer_adds_extra_origins() {
1513        let extras = vec!["https://example.com".to_string()];
1514        let layer = cors_layer(&extras);
1515        let _ = layer;
1516    }
1517
1518    #[test]
1519    fn cors_layer_skips_empty_origins() {
1520        let extras = vec!["".to_string(), "  ".to_string()];
1521        let layer = cors_layer(&extras);
1522        let _ = layer;
1523    }
1524
1525    // ── JsonRpc helpers ────────────────────────────────────────────────
1526
1527    #[test]
1528    fn params_or_object_returns_object_for_null() {
1529        let result = params_or_object(Value::Null);
1530        assert_eq!(result, json!({}));
1531    }
1532
1533    #[test]
1534    fn params_or_object_passthrough_for_non_null() {
1535        let input = json!({"key": "value"});
1536        let result = params_or_object(input.clone());
1537        assert_eq!(result, input);
1538    }
1539
1540    #[test]
1541    fn jsonrpc_result_format() {
1542        let result = jsonrpc_result(Some(json!(1)), json!({"ok": true}));
1543        assert_eq!(result["jsonrpc"], "2.0");
1544        assert_eq!(result["id"], 1);
1545        assert_eq!(result["result"]["ok"], true);
1546    }
1547
1548    #[test]
1549    fn jsonrpc_result_null_id() {
1550        let result = jsonrpc_result(None, json!(null));
1551        assert_eq!(result["id"], Value::Null);
1552    }
1553
1554    #[test]
1555    fn jsonrpc_error_format() {
1556        let err = jsonrpc_error(Some(json!(2)), JsonRpcError::internal("oops"));
1557        assert_eq!(err["jsonrpc"], "2.0");
1558        assert_eq!(err["id"], 2);
1559        assert_eq!(err["error"]["code"], -32603);
1560        assert_eq!(err["error"]["message"], "oops");
1561    }
1562
1563    #[test]
1564    fn jsonrpc_error_codes() {
1565        assert_eq!(JsonRpcError::parse_error("").code, -32700);
1566        assert_eq!(JsonRpcError::invalid_request("").code, -32600);
1567        assert_eq!(JsonRpcError::method_not_found("x").code, -32601);
1568        assert_eq!(JsonRpcError::invalid_params("").code, -32602);
1569        assert_eq!(JsonRpcError::internal("").code, -32603);
1570    }
1571
1572    // ── AppServerOptions ───────────────────────────────────────────────
1573
1574    #[test]
1575    fn app_server_options_debug_does_not_leak_token() {
1576        let options = AppServerOptions {
1577            listen: "127.0.0.1:8080".parse().expect("addr"),
1578            config_path: None,
1579            auth_token: Some("secret-token".to_string()),
1580            insecure_no_auth: false,
1581            cors_origins: vec!["https://example.com".to_string()],
1582        };
1583        let debug = format!("{options:?}");
1584        assert!(!debug.contains("secret-token"));
1585        assert!(debug.contains("<redacted>"));
1586        assert!(debug.contains("8080"));
1587    }
1588
1589    // ── Default CORS origins ──────────────────────────────────────────
1590
1591    #[test]
1592    fn default_cors_origins_include_common_dev_ports() {
1593        assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:3000"));
1594        assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:5173"));
1595        assert!(DEFAULT_CORS_ORIGINS.contains(&"tauri://localhost"));
1596    }
1597}