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_execpolicy::ExecPolicyEngine;
16use codewhale_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink, UnixSocketHookSink};
17use codewhale_mcp::McpManager;
18use codewhale_protocol::{
19    AppRequest, AppResponse, PromptRequest, PromptResponse, ThreadRequest, ThreadResponse,
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
31const DEFAULT_CORS_ORIGINS: &[&str] = &[
32    "http://localhost",
33    "http://localhost:1420",
34    "http://localhost:3000",
35    "http://localhost:5173",
36    "http://127.0.0.1",
37    "http://127.0.0.1:1420",
38    "tauri://localhost",
39];
40
41#[derive(Clone)]
42pub struct AppServerOptions {
43    pub listen: SocketAddr,
44    pub config_path: Option<PathBuf>,
45    pub auth_token: Option<String>,
46    pub insecure_no_auth: bool,
47    pub cors_origins: Vec<String>,
48}
49
50impl std::fmt::Debug for AppServerOptions {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.debug_struct("AppServerOptions")
53            .field("listen", &self.listen)
54            .field("config_path", &self.config_path)
55            .field(
56                "auth_token",
57                &self.auth_token.as_ref().map(|_| "<redacted>"),
58            )
59            .field("insecure_no_auth", &self.insecure_no_auth)
60            .field("cors_origins", &self.cors_origins)
61            .finish()
62    }
63}
64
65#[derive(Clone)]
66struct AppState {
67    config_path: Option<PathBuf>,
68    config: Arc<RwLock<codewhale_config::ConfigToml>>,
69    runtime: Arc<Mutex<Runtime>>,
70    registry: ModelRegistry,
71    auth_token: Option<String>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75struct ToolCallRequest {
76    call: ToolCall,
77    #[serde(default)]
78    cwd: Option<PathBuf>,
79}
80
81#[derive(Debug, Deserialize)]
82struct JsonRpcRequest {
83    #[serde(default)]
84    jsonrpc: Option<String>,
85    #[serde(default)]
86    id: Option<Value>,
87    method: String,
88    #[serde(default)]
89    params: Value,
90}
91
92#[derive(Debug)]
93struct JsonRpcError {
94    code: i64,
95    message: String,
96    data: Option<Value>,
97}
98
99#[derive(Debug)]
100struct StdioDispatchResult {
101    result: Value,
102    should_exit: bool,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106enum AppTransport {
107    Http,
108    Stdio,
109}
110
111#[derive(Debug, Deserialize)]
112struct ConfigGetParams {
113    key: String,
114}
115
116#[derive(Debug, Deserialize)]
117struct ConfigSetParams {
118    key: String,
119    value: String,
120}
121
122#[derive(Debug, Deserialize)]
123struct ThreadIdParams {
124    thread_id: String,
125}
126
127#[derive(Debug, Deserialize)]
128struct ThreadMessageParams {
129    thread_id: String,
130    input: String,
131}
132
133pub async fn run(options: AppServerOptions) -> Result<()> {
134    let auth_token = resolve_auth_token(&options)?;
135    let state = build_state(options.config_path.clone(), auth_token)?;
136    let app = app_router(state, &options.cors_origins);
137
138    let listener = tokio::net::TcpListener::bind(options.listen).await?;
139    axum::serve(listener, app).await?;
140    Ok(())
141}
142
143fn app_router(state: AppState, cors_origins: &[String]) -> Router {
144    let protected_routes = Router::new()
145        .route("/thread", post(thread_handler))
146        .route("/app", post(app_handler))
147        .route("/prompt", post(prompt_handler))
148        .route("/tool", post(tool_handler))
149        .route("/jobs", get(jobs_handler))
150        .route("/mcp/startup", post(mcp_startup_handler))
151        .route_layer(middleware::from_fn_with_state(
152            state.clone(),
153            require_app_server_token,
154        ));
155
156    Router::new()
157        .route("/healthz", get(healthz))
158        .merge(protected_routes)
159        .layer(cors_layer(cors_origins))
160        .with_state(state)
161}
162
163pub async fn run_stdio(config_path: Option<PathBuf>) -> Result<()> {
164    let state = build_state(config_path, None)?;
165    let stdin = tokio::io::stdin();
166    let stdout = tokio::io::stdout();
167    let mut reader = BufReader::new(stdin).lines();
168    let mut writer = tokio::io::BufWriter::new(stdout);
169    while let Some(line) = reader.next_line().await? {
170        if line.trim().is_empty() {
171            continue;
172        }
173
174        let request: JsonRpcRequest = match serde_json::from_str(&line) {
175            Ok(value) => value,
176            Err(err) => {
177                let response = jsonrpc_error(
178                    None,
179                    JsonRpcError::parse_error(format!("invalid json: {err}")),
180                );
181                writer.write_all(response.to_string().as_bytes()).await?;
182                writer.write_all(b"\n").await?;
183                writer.flush().await?;
184                continue;
185            }
186        };
187
188        if request
189            .jsonrpc
190            .as_deref()
191            .is_some_and(|version| version != "2.0")
192        {
193            let response = jsonrpc_error(
194                request.id,
195                JsonRpcError::invalid_request("jsonrpc version must be 2.0"),
196            );
197            writer.write_all(response.to_string().as_bytes()).await?;
198            writer.write_all(b"\n").await?;
199            writer.flush().await?;
200            continue;
201        }
202
203        let response = match dispatch_stdio_request(&state, &request.method, request.params).await {
204            Ok(dispatch) => {
205                let encoded = jsonrpc_result(request.id, dispatch.result);
206                writer.write_all(encoded.to_string().as_bytes()).await?;
207                writer.write_all(b"\n").await?;
208                writer.flush().await?;
209                if dispatch.should_exit {
210                    break;
211                }
212                continue;
213            }
214            Err(err) => jsonrpc_error(request.id, err),
215        };
216
217        writer.write_all(response.to_string().as_bytes()).await?;
218        writer.write_all(b"\n").await?;
219        writer.flush().await?;
220    }
221
222    Ok(())
223}
224
225async fn healthz() -> Json<Value> {
226    Json(json!({
227        "status": "ok",
228        "protocol": "v2",
229        "service": "deepseek-app-server"
230    }))
231}
232
233async fn thread_handler(
234    State(state): State<AppState>,
235    Json(req): Json<ThreadRequest>,
236) -> Json<ThreadResponse> {
237    let mut runtime = state.runtime.lock().await;
238    match runtime.handle_thread(req).await {
239        Ok(res) => Json(res),
240        Err(err) => Json(ThreadResponse {
241            thread_id: "error".to_string(),
242            status: format!("error:{err}"),
243            thread: None,
244            threads: Vec::new(),
245            model: None,
246            model_provider: None,
247            cwd: None,
248            approval_policy: None,
249            sandbox: None,
250            events: Vec::new(),
251            data: json!({}),
252        }),
253    }
254}
255
256async fn prompt_handler(
257    State(state): State<AppState>,
258    Json(req): Json<PromptRequest>,
259) -> Json<PromptResponse> {
260    let mut runtime = state.runtime.lock().await;
261    let overrides = CliRuntimeOverrides::default();
262    match runtime.handle_prompt(req, &overrides).await {
263        Ok(res) => Json(res),
264        Err(err) => Json(PromptResponse {
265            output: err.to_string(),
266            model: "unknown".to_string(),
267            events: Vec::new(),
268        }),
269    }
270}
271
272async fn tool_handler(
273    State(state): State<AppState>,
274    Json(req): Json<ToolCallRequest>,
275) -> Json<Value> {
276    let runtime = state.runtime.lock().await;
277    let cwd = req
278        .cwd
279        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
280    match runtime
281        .invoke_tool(
282            req.call,
283            codewhale_execpolicy::AskForApproval::OnRequest,
284            &cwd,
285        )
286        .await
287    {
288        Ok(value) => Json(value),
289        Err(err) => Json(json!({ "ok": false, "error": err.to_string() })),
290    }
291}
292
293async fn jobs_handler(State(state): State<AppState>) -> Json<AppResponse> {
294    let runtime = state.runtime.lock().await;
295    Json(runtime.app_status())
296}
297
298async fn mcp_startup_handler(State(state): State<AppState>) -> Json<Value> {
299    let runtime = state.runtime.lock().await;
300    let summary = runtime.mcp_startup().await;
301    Json(json!({
302        "ok": true,
303        "summary": summary
304    }))
305}
306
307async fn app_handler(
308    State(state): State<AppState>,
309    Json(req): Json<AppRequest>,
310) -> Json<AppResponse> {
311    Json(process_app_request(&state, req, AppTransport::Http).await)
312}
313
314fn build_state(config_path: Option<PathBuf>, auth_token: Option<String>) -> Result<AppState> {
315    let store = ConfigStore::load(config_path.clone())?;
316    let config = store.config.clone();
317    let registry = ModelRegistry::default();
318
319    let state_db_path = config_path
320        .as_ref()
321        .and_then(|p| p.parent().map(|parent| parent.join("state.db")));
322    let state_store = StateStore::open(state_db_path)?;
323
324    let mut hooks = HookDispatcher::default();
325    hooks.add_sink(Arc::new(StdoutHookSink));
326    let hook_log_path = config_path
327        .as_ref()
328        .and_then(|p| p.parent().map(|parent| parent.join("events.jsonl")))
329        .unwrap_or_else(|| PathBuf::from(".deepseek/events.jsonl"));
330    hooks.add_sink(Arc::new(JsonlHookSink::new(hook_log_path)));
331
332    if let Some(socket_path) = config
333        .hook_sinks
334        .as_ref()
335        .and_then(|sinks| sinks.unix_socket_path.as_ref())
336        .filter(|path| !path.as_os_str().is_empty())
337    {
338        hooks.add_sink(Arc::new(UnixSocketHookSink::new(socket_path.clone())));
339    }
340
341    let runtime = Runtime::new(
342        config.clone(),
343        registry.clone(),
344        state_store,
345        Arc::new(ToolRegistry::default()),
346        Arc::new(McpManager::default()),
347        ExecPolicyEngine::new(Vec::new(), Vec::new()),
348        hooks,
349    );
350
351    Ok(AppState {
352        config_path,
353        config: Arc::new(RwLock::new(config)),
354        runtime: Arc::new(Mutex::new(runtime)),
355        registry,
356        auth_token,
357    })
358}
359
360fn resolve_auth_token(options: &AppServerOptions) -> Result<Option<String>> {
361    let configured = options.auth_token.as_ref().map(|token| token.trim());
362    if let Some(token) = configured
363        && token.is_empty()
364    {
365        bail!("app-server auth token cannot be empty");
366    }
367
368    if options.insecure_no_auth {
369        if !options.listen.ip().is_loopback() {
370            bail!("refusing unauthenticated app-server bind on non-loopback address");
371        }
372        eprintln!("warning: app-server HTTP auth disabled by --insecure-no-auth");
373        return Ok(None);
374    }
375
376    let token = configured
377        .map(str::to_string)
378        .unwrap_or_else(|| format!("cwapp_{}", Uuid::new_v4().simple()));
379    if options.auth_token.is_some() {
380        eprintln!("app-server auth: bearer token required for HTTP routes.");
381    } else {
382        eprintln!("app-server auth: generated bearer token for this process.");
383        eprintln!("  Authorization: Bearer {token}");
384        eprintln!("  Pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN for a stable token.");
385    }
386    Ok(Some(token))
387}
388
389fn cors_layer(extra_origins: &[String]) -> CorsLayer {
390    let mut origins: Vec<HeaderValue> = DEFAULT_CORS_ORIGINS
391        .iter()
392        .filter_map(|origin| HeaderValue::from_str(origin).ok())
393        .collect();
394    for raw in extra_origins {
395        let trimmed = raw.trim();
396        if trimmed.is_empty() {
397            continue;
398        }
399        match HeaderValue::from_str(trimmed) {
400            Ok(value) if !origins.contains(&value) => origins.push(value),
401            Ok(_) => {}
402            Err(err) => {
403                eprintln!("warning: ignoring invalid app-server CORS origin `{trimmed}`: {err}")
404            }
405        }
406    }
407
408    CorsLayer::new()
409        .allow_origin(origins)
410        .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
411        .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
412}
413
414async fn require_app_server_token(
415    State(state): State<AppState>,
416    req: Request,
417    next: Next,
418) -> Response {
419    let Some(expected) = state.auth_token.as_deref() else {
420        return next.run(req).await;
421    };
422    let authorized = req
423        .headers()
424        .get(header::AUTHORIZATION)
425        .and_then(|value| value.to_str().ok())
426        .and_then(|raw| raw.strip_prefix("Bearer "))
427        .is_some_and(|token| token == expected);
428
429    if authorized {
430        next.run(req).await
431    } else {
432        (
433            StatusCode::UNAUTHORIZED,
434            Json(json!({
435                "error": {
436                    "message": "app-server bearer token required",
437                    "status": StatusCode::UNAUTHORIZED.as_u16(),
438                }
439            })),
440        )
441            .into_response()
442    }
443}
444
445fn params_or_object(params: Value) -> Value {
446    if params.is_null() { json!({}) } else { params }
447}
448
449fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
450    serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
451}
452
453fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
454    json!({
455        "jsonrpc": "2.0",
456        "id": id.unwrap_or(Value::Null),
457        "result": result
458    })
459}
460
461fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
462    json!({
463        "jsonrpc": "2.0",
464        "id": id.unwrap_or(Value::Null),
465        "error": {
466            "code": err.code,
467            "message": err.message,
468            "data": err.data
469        }
470    })
471}
472
473impl JsonRpcError {
474    fn parse_error(message: impl Into<String>) -> Self {
475        Self {
476            code: -32700,
477            message: message.into(),
478            data: None,
479        }
480    }
481
482    fn invalid_request(message: impl Into<String>) -> Self {
483        Self {
484            code: -32600,
485            message: message.into(),
486            data: None,
487        }
488    }
489
490    fn method_not_found(method: &str) -> Self {
491        Self {
492            code: -32601,
493            message: format!("unsupported method: {method}"),
494            data: None,
495        }
496    }
497
498    fn invalid_params(message: impl Into<String>) -> Self {
499        Self {
500            code: -32602,
501            message: message.into(),
502            data: None,
503        }
504    }
505
506    fn internal(message: impl Into<String>) -> Self {
507        Self {
508            code: -32603,
509            message: message.into(),
510            data: None,
511        }
512    }
513}
514
515async fn handle_thread_request(
516    state: &AppState,
517    req: ThreadRequest,
518) -> std::result::Result<ThreadResponse, JsonRpcError> {
519    let mut runtime = state.runtime.lock().await;
520    runtime
521        .handle_thread(req)
522        .await
523        .map_err(|err| JsonRpcError::internal(err.to_string()))
524}
525
526async fn handle_prompt_request(
527    state: &AppState,
528    req: PromptRequest,
529) -> std::result::Result<PromptResponse, JsonRpcError> {
530    let mut runtime = state.runtime.lock().await;
531    runtime
532        .handle_prompt(req, &CliRuntimeOverrides::default())
533        .await
534        .map_err(|err| JsonRpcError::internal(err.to_string()))
535}
536
537async fn dispatch_stdio_request(
538    state: &AppState,
539    method: &str,
540    params: Value,
541) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
542    let outcome = match method {
543        "healthz" | "app/healthz" => StdioDispatchResult {
544            result: json!({
545                "status": "ok",
546                "service": "deepseek-app-server",
547                "transport": "stdio"
548            }),
549            should_exit: false,
550        },
551        "capabilities" => StdioDispatchResult {
552            result: json!({
553                "transport": "stdio",
554                "families": ["thread/*", "app/*", "prompt/*"],
555                "methods": [
556                    "healthz",
557                    "thread/capabilities",
558                    "thread/request",
559                    "thread/create",
560                    "thread/start",
561                    "thread/resume",
562                    "thread/fork",
563                    "thread/list",
564                    "thread/read",
565                    "thread/set_name",
566                    "thread/archive",
567                    "thread/unarchive",
568                    "thread/message",
569                    "app/capabilities",
570                    "app/request",
571                    "app/config/get",
572                    "app/config/set",
573                    "app/config/unset",
574                    "app/config/list",
575                    "app/models",
576                    "app/thread_loaded_list",
577                    "prompt/capabilities",
578                    "prompt/request",
579                    "prompt/run",
580                    "shutdown"
581                ]
582            }),
583            should_exit: false,
584        },
585        "thread/capabilities" => StdioDispatchResult {
586            result: json!({
587                "methods": [
588                    "thread/request",
589                    "thread/create",
590                    "thread/start",
591                    "thread/resume",
592                    "thread/fork",
593                    "thread/list",
594                    "thread/read",
595                    "thread/set_name",
596                    "thread/archive",
597                    "thread/unarchive",
598                    "thread/message"
599                ]
600            }),
601            should_exit: false,
602        },
603        "thread/request" => {
604            let request: ThreadRequest = parse_params(params)?;
605            let response = handle_thread_request(state, request).await?;
606            StdioDispatchResult {
607                result: serde_json::to_value(response)
608                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
609                should_exit: false,
610            }
611        }
612        "thread/create" => {
613            #[derive(Debug, Deserialize)]
614            struct CreateParams {
615                #[serde(default)]
616                metadata: Value,
617            }
618            let parsed: CreateParams = parse_params(params_or_object(params))?;
619            let response = handle_thread_request(
620                state,
621                ThreadRequest::Create {
622                    metadata: parsed.metadata,
623                },
624            )
625            .await?;
626            StdioDispatchResult {
627                result: serde_json::to_value(response)
628                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
629                should_exit: false,
630            }
631        }
632        "thread/start" => {
633            let request = ThreadRequest::Start(parse_params(params_or_object(params))?);
634            let response = handle_thread_request(state, request).await?;
635            StdioDispatchResult {
636                result: serde_json::to_value(response)
637                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
638                should_exit: false,
639            }
640        }
641        "thread/resume" => {
642            let request = ThreadRequest::Resume(parse_params(params_or_object(params))?);
643            let response = handle_thread_request(state, request).await?;
644            StdioDispatchResult {
645                result: serde_json::to_value(response)
646                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
647                should_exit: false,
648            }
649        }
650        "thread/fork" => {
651            let request = ThreadRequest::Fork(parse_params(params_or_object(params))?);
652            let response = handle_thread_request(state, request).await?;
653            StdioDispatchResult {
654                result: serde_json::to_value(response)
655                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
656                should_exit: false,
657            }
658        }
659        "thread/list" => {
660            let request = ThreadRequest::List(parse_params(params_or_object(params))?);
661            let response = handle_thread_request(state, request).await?;
662            StdioDispatchResult {
663                result: serde_json::to_value(response)
664                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
665                should_exit: false,
666            }
667        }
668        "thread/read" => {
669            let request = ThreadRequest::Read(parse_params(params_or_object(params))?);
670            let response = handle_thread_request(state, request).await?;
671            StdioDispatchResult {
672                result: serde_json::to_value(response)
673                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
674                should_exit: false,
675            }
676        }
677        "thread/set_name" | "thread/set-name" => {
678            let request = ThreadRequest::SetName(parse_params(params_or_object(params))?);
679            let response = handle_thread_request(state, request).await?;
680            StdioDispatchResult {
681                result: serde_json::to_value(response)
682                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
683                should_exit: false,
684            }
685        }
686        "thread/archive" => {
687            let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
688            let response = handle_thread_request(
689                state,
690                ThreadRequest::Archive {
691                    thread_id: parsed.thread_id,
692                },
693            )
694            .await?;
695            StdioDispatchResult {
696                result: serde_json::to_value(response)
697                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
698                should_exit: false,
699            }
700        }
701        "thread/unarchive" => {
702            let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
703            let response = handle_thread_request(
704                state,
705                ThreadRequest::Unarchive {
706                    thread_id: parsed.thread_id,
707                },
708            )
709            .await?;
710            StdioDispatchResult {
711                result: serde_json::to_value(response)
712                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
713                should_exit: false,
714            }
715        }
716        "thread/message" => {
717            let parsed: ThreadMessageParams = parse_params(params_or_object(params))?;
718            let response = handle_thread_request(
719                state,
720                ThreadRequest::Message {
721                    thread_id: parsed.thread_id,
722                    input: parsed.input,
723                },
724            )
725            .await?;
726            StdioDispatchResult {
727                result: serde_json::to_value(response)
728                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
729                should_exit: false,
730            }
731        }
732        "app/capabilities" => {
733            let response =
734                process_app_request(state, AppRequest::Capabilities, AppTransport::Stdio).await;
735            StdioDispatchResult {
736                result: serde_json::to_value(response)
737                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
738                should_exit: false,
739            }
740        }
741        "app/request" => {
742            let request: AppRequest = parse_params(params)?;
743            let response = process_app_request(state, request, AppTransport::Stdio).await;
744            StdioDispatchResult {
745                result: serde_json::to_value(response)
746                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
747                should_exit: false,
748            }
749        }
750        "app/config/get" => {
751            let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
752            let response = process_app_request(
753                state,
754                AppRequest::ConfigGet { key: parsed.key },
755                AppTransport::Stdio,
756            )
757            .await;
758            StdioDispatchResult {
759                result: serde_json::to_value(response)
760                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
761                should_exit: false,
762            }
763        }
764        "app/config/set" => {
765            let parsed: ConfigSetParams = parse_params(params_or_object(params))?;
766            let response = process_app_request(
767                state,
768                AppRequest::ConfigSet {
769                    key: parsed.key,
770                    value: parsed.value,
771                },
772                AppTransport::Stdio,
773            )
774            .await;
775            StdioDispatchResult {
776                result: serde_json::to_value(response)
777                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
778                should_exit: false,
779            }
780        }
781        "app/config/unset" => {
782            let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
783            let response = process_app_request(
784                state,
785                AppRequest::ConfigUnset { key: parsed.key },
786                AppTransport::Stdio,
787            )
788            .await;
789            StdioDispatchResult {
790                result: serde_json::to_value(response)
791                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
792                should_exit: false,
793            }
794        }
795        "app/config/list" => {
796            let response =
797                process_app_request(state, AppRequest::ConfigList, AppTransport::Stdio).await;
798            StdioDispatchResult {
799                result: serde_json::to_value(response)
800                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
801                should_exit: false,
802            }
803        }
804        "app/models" => {
805            let response =
806                process_app_request(state, AppRequest::Models, AppTransport::Stdio).await;
807            StdioDispatchResult {
808                result: serde_json::to_value(response)
809                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
810                should_exit: false,
811            }
812        }
813        "app/thread_loaded_list" | "app/thread-loaded-list" => {
814            let response =
815                process_app_request(state, AppRequest::ThreadLoadedList, AppTransport::Stdio).await;
816            StdioDispatchResult {
817                result: serde_json::to_value(response)
818                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
819                should_exit: false,
820            }
821        }
822        "prompt/capabilities" => StdioDispatchResult {
823            result: json!({
824                "methods": ["prompt/request", "prompt/run"]
825            }),
826            should_exit: false,
827        },
828        "prompt/request" | "prompt/run" => {
829            let request: PromptRequest = parse_params(params)?;
830            let response = handle_prompt_request(state, request).await?;
831            StdioDispatchResult {
832                result: serde_json::to_value(response)
833                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
834                should_exit: false,
835            }
836        }
837        "shutdown" => StdioDispatchResult {
838            result: json!({"ok": true, "status": "stopped"}),
839            should_exit: true,
840        },
841        _ => return Err(JsonRpcError::method_not_found(method)),
842    };
843    Ok(outcome)
844}
845
846async fn process_app_request(
847    state: &AppState,
848    req: AppRequest,
849    transport: AppTransport,
850) -> AppResponse {
851    match req {
852        AppRequest::Capabilities => AppResponse {
853            ok: true,
854            data: json!({
855                "routes": ["/thread", "/app", "/prompt", "/tool", "/jobs", "/mcp/startup"],
856                "config": ["get", "set", "unset", "list"],
857                "events": ["response_start", "response_delta", "response_end", "tool_call_start", "tool_call_result", "mcp_startup_update", "mcp_startup_complete"],
858                "transport": "stdio+http",
859                "config_path": state.config_path.as_ref().map(|p| p.display().to_string()),
860            }),
861            events: Vec::new(),
862        },
863        AppRequest::ConfigGet { key } => {
864            let cfg = state.config.read().await;
865            let value = match transport {
866                AppTransport::Http => cfg.get_display_value(&key),
867                AppTransport::Stdio => cfg.get_value(&key),
868            };
869            AppResponse {
870                ok: true,
871                data: json!({ "key": key, "value": value }),
872                events: Vec::new(),
873            }
874        }
875        AppRequest::ConfigSet { key, value } => {
876            let mut cfg = state.config.write().await;
877            let result = cfg.set_value(&key, &value);
878            let ok = result.is_ok();
879            let message = result.err().map(|e| e.to_string());
880            let snapshot = cfg.clone();
881            drop(cfg);
882            let _ = persist_config(state, snapshot).await;
883            AppResponse {
884                ok,
885                data: json!({ "key": key, "value": value, "error": message }),
886                events: Vec::new(),
887            }
888        }
889        AppRequest::ConfigUnset { key } => {
890            let mut cfg = state.config.write().await;
891            let result = cfg.unset_value(&key);
892            let ok = result.is_ok();
893            let message = result.err().map(|e| e.to_string());
894            let snapshot = cfg.clone();
895            drop(cfg);
896            let _ = persist_config(state, snapshot).await;
897            AppResponse {
898                ok,
899                data: json!({ "key": key, "error": message }),
900                events: Vec::new(),
901            }
902        }
903        AppRequest::ConfigList => {
904            let cfg = state.config.read().await;
905            AppResponse {
906                ok: true,
907                data: json!({ "values": cfg.list_values() }),
908                events: Vec::new(),
909            }
910        }
911        AppRequest::Models => AppResponse {
912            ok: true,
913            data: json!({ "models": state.registry.list() }),
914            events: Vec::new(),
915        },
916        AppRequest::ThreadLoadedList => {
917            let mut runtime = state.runtime.lock().await;
918            let response = runtime
919                .handle_thread(codewhale_protocol::ThreadRequest::List(
920                    codewhale_protocol::ThreadListParams {
921                        include_archived: false,
922                        limit: Some(50),
923                    },
924                ))
925                .await;
926            match response {
927                Ok(thread_resp) => AppResponse {
928                    ok: true,
929                    data: json!({ "threads": thread_resp.threads }),
930                    events: thread_resp.events,
931                },
932                Err(err) => AppResponse {
933                    ok: false,
934                    data: json!({ "error": err.to_string() }),
935                    events: Vec::new(),
936                },
937            }
938        }
939    }
940}
941
942async fn persist_config(state: &AppState, config: codewhale_config::ConfigToml) -> Result<()> {
943    if state.config_path.is_none() {
944        return Ok(());
945    }
946    let mut store = ConfigStore::load(state.config_path.clone())?;
947    store.config = config;
948    store.save()
949}
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954    use axum::body::{Body, to_bytes};
955    use codewhale_protocol::AppRequest;
956    use std::fs;
957    use tower::ServiceExt;
958
959    fn app_with_config(auth_token: Option<&str>) -> (Router, tempfile::TempDir) {
960        let tmp = tempfile::tempdir().expect("tempdir");
961        let config_path = tmp.path().join("config.toml");
962        fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
963        let state = build_state(
964            Some(config_path),
965            auth_token.map(std::string::ToString::to_string),
966        )
967        .expect("state");
968        (app_router(state, &[]), tmp)
969    }
970
971    async fn response_body_json(response: Response) -> Value {
972        let bytes = to_bytes(response.into_body(), usize::MAX)
973            .await
974            .expect("body bytes");
975        serde_json::from_slice(&bytes).expect("json response")
976    }
977
978    #[tokio::test]
979    async fn http_app_routes_require_bearer_token_when_auth_enabled() {
980        let (app, _tmp) = app_with_config(Some("test-token"));
981        let response = app
982            .oneshot(
983                Request::builder()
984                    .method(Method::POST)
985                    .uri("/app")
986                    .header(header::CONTENT_TYPE, "application/json")
987                    .body(Body::from(
988                        serde_json::to_vec(&AppRequest::ConfigGet {
989                            key: "api_key".to_string(),
990                        })
991                        .expect("request json"),
992                    ))
993                    .expect("request"),
994            )
995            .await
996            .expect("response");
997
998        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
999    }
1000
1001    #[tokio::test]
1002    async fn http_config_get_redacts_sensitive_values_after_auth() {
1003        let (app, _tmp) = app_with_config(Some("test-token"));
1004        let response = app
1005            .oneshot(
1006                Request::builder()
1007                    .method(Method::POST)
1008                    .uri("/app")
1009                    .header(header::AUTHORIZATION, "Bearer test-token")
1010                    .header(header::CONTENT_TYPE, "application/json")
1011                    .body(Body::from(
1012                        serde_json::to_vec(&AppRequest::ConfigGet {
1013                            key: "api_key".to_string(),
1014                        })
1015                        .expect("request json"),
1016                    ))
1017                    .expect("request"),
1018            )
1019            .await
1020            .expect("response");
1021
1022        assert_eq!(response.status(), StatusCode::OK);
1023        let body = response_body_json(response).await;
1024        assert_eq!(body["data"]["value"], "sk-d***cret");
1025    }
1026
1027    #[tokio::test]
1028    async fn cors_does_not_allow_arbitrary_origins() {
1029        let (app, _tmp) = app_with_config(Some("test-token"));
1030        let response = app
1031            .oneshot(
1032                Request::builder()
1033                    .method(Method::GET)
1034                    .uri("/healthz")
1035                    .header(header::ORIGIN, "https://attacker.example")
1036                    .body(Body::empty())
1037                    .expect("request"),
1038            )
1039            .await
1040            .expect("response");
1041
1042        assert_eq!(response.status(), StatusCode::OK);
1043        assert!(
1044            response
1045                .headers()
1046                .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
1047                .is_none()
1048        );
1049    }
1050
1051    #[test]
1052    fn non_loopback_bind_without_auth_fails_fast() {
1053        let options = AppServerOptions {
1054            listen: "0.0.0.0:8787".parse().expect("socket addr"),
1055            config_path: None,
1056            auth_token: None,
1057            insecure_no_auth: true,
1058            cors_origins: Vec::new(),
1059        };
1060
1061        let err = resolve_auth_token(&options).expect_err("non-loopback unauth should fail");
1062        assert!(
1063            err.to_string()
1064                .contains("refusing unauthenticated app-server bind")
1065        );
1066    }
1067
1068    #[tokio::test]
1069    async fn stdio_transport_keeps_raw_config_get_for_legacy_clients() {
1070        let state = build_state(None, None).expect("state");
1071        {
1072            let mut cfg = state.config.write().await;
1073            cfg.api_key = Some("sk-deepseek-secret".to_string());
1074        }
1075
1076        let response = process_app_request(
1077            &state,
1078            AppRequest::ConfigGet {
1079                key: "api_key".to_string(),
1080            },
1081            AppTransport::Stdio,
1082        )
1083        .await;
1084
1085        assert_eq!(response.data["value"], "sk-deepseek-secret");
1086    }
1087
1088    // ── resolve_auth_token ─────────────────────────────────────────────
1089
1090    #[test]
1091    fn auth_token_empty_string_fails() {
1092        let options = AppServerOptions {
1093            listen: "127.0.0.1:0".parse().expect("addr"),
1094            config_path: None,
1095            auth_token: Some("  ".to_string()),
1096            insecure_no_auth: false,
1097            cors_origins: Vec::new(),
1098        };
1099        let err = resolve_auth_token(&options).expect_err("empty token should fail");
1100        assert!(err.to_string().contains("cannot be empty"));
1101    }
1102
1103    #[test]
1104    fn auth_token_generated_when_none_provided() {
1105        let options = AppServerOptions {
1106            listen: "127.0.0.1:0".parse().expect("addr"),
1107            config_path: None,
1108            auth_token: None,
1109            insecure_no_auth: false,
1110            cors_origins: Vec::new(),
1111        };
1112        let token = resolve_auth_token(&options).unwrap();
1113        assert!(token.is_some());
1114        assert!(token.unwrap().starts_with("cwapp_"));
1115    }
1116
1117    #[test]
1118    fn auth_token_explicit_is_preserved() {
1119        let options = AppServerOptions {
1120            listen: "127.0.0.1:0".parse().expect("addr"),
1121            config_path: None,
1122            auth_token: Some("my-secret".to_string()),
1123            insecure_no_auth: false,
1124            cors_origins: Vec::new(),
1125        };
1126        let token = resolve_auth_token(&options).unwrap();
1127        assert_eq!(token.as_deref(), Some("my-secret"));
1128    }
1129
1130    #[test]
1131    fn insecure_no_auth_on_loopback_returns_none() {
1132        let options = AppServerOptions {
1133            listen: "127.0.0.1:0".parse().expect("addr"),
1134            config_path: None,
1135            auth_token: None,
1136            insecure_no_auth: true,
1137            cors_origins: Vec::new(),
1138        };
1139        let token = resolve_auth_token(&options).unwrap();
1140        assert!(token.is_none());
1141    }
1142
1143    // ── cors_layer ─────────────────────────────────────────────────────
1144
1145    #[test]
1146    fn cors_layer_includes_default_origins() {
1147        let layer = cors_layer(&[]);
1148        // Just verify it doesn't panic and creates successfully
1149        let _ = layer;
1150    }
1151
1152    #[test]
1153    fn cors_layer_adds_extra_origins() {
1154        let extras = vec!["https://example.com".to_string()];
1155        let layer = cors_layer(&extras);
1156        let _ = layer;
1157    }
1158
1159    #[test]
1160    fn cors_layer_skips_empty_origins() {
1161        let extras = vec!["".to_string(), "  ".to_string()];
1162        let layer = cors_layer(&extras);
1163        let _ = layer;
1164    }
1165
1166    // ── JsonRpc helpers ────────────────────────────────────────────────
1167
1168    #[test]
1169    fn params_or_object_returns_object_for_null() {
1170        let result = params_or_object(Value::Null);
1171        assert_eq!(result, json!({}));
1172    }
1173
1174    #[test]
1175    fn params_or_object_passthrough_for_non_null() {
1176        let input = json!({"key": "value"});
1177        let result = params_or_object(input.clone());
1178        assert_eq!(result, input);
1179    }
1180
1181    #[test]
1182    fn jsonrpc_result_format() {
1183        let result = jsonrpc_result(Some(json!(1)), json!({"ok": true}));
1184        assert_eq!(result["jsonrpc"], "2.0");
1185        assert_eq!(result["id"], 1);
1186        assert_eq!(result["result"]["ok"], true);
1187    }
1188
1189    #[test]
1190    fn jsonrpc_result_null_id() {
1191        let result = jsonrpc_result(None, json!(null));
1192        assert_eq!(result["id"], Value::Null);
1193    }
1194
1195    #[test]
1196    fn jsonrpc_error_format() {
1197        let err = jsonrpc_error(Some(json!(2)), JsonRpcError::internal("oops"));
1198        assert_eq!(err["jsonrpc"], "2.0");
1199        assert_eq!(err["id"], 2);
1200        assert_eq!(err["error"]["code"], -32603);
1201        assert_eq!(err["error"]["message"], "oops");
1202    }
1203
1204    #[test]
1205    fn jsonrpc_error_codes() {
1206        assert_eq!(JsonRpcError::parse_error("").code, -32700);
1207        assert_eq!(JsonRpcError::invalid_request("").code, -32600);
1208        assert_eq!(JsonRpcError::method_not_found("x").code, -32601);
1209        assert_eq!(JsonRpcError::invalid_params("").code, -32602);
1210        assert_eq!(JsonRpcError::internal("").code, -32603);
1211    }
1212
1213    // ── AppServerOptions ───────────────────────────────────────────────
1214
1215    #[test]
1216    fn app_server_options_debug_does_not_leak_token() {
1217        let options = AppServerOptions {
1218            listen: "127.0.0.1:8080".parse().expect("addr"),
1219            config_path: None,
1220            auth_token: Some("secret-token".to_string()),
1221            insecure_no_auth: false,
1222            cors_origins: vec!["https://example.com".to_string()],
1223        };
1224        let debug = format!("{options:?}");
1225        assert!(!debug.contains("secret-token"));
1226        assert!(debug.contains("<redacted>"));
1227        assert!(debug.contains("8080"));
1228    }
1229
1230    // ── Default CORS origins ──────────────────────────────────────────
1231
1232    #[test]
1233    fn default_cors_origins_include_common_dev_ports() {
1234        assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:3000"));
1235        assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:5173"));
1236        assert!(DEFAULT_CORS_ORIGINS.contains(&"tauri://localhost"));
1237    }
1238}