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