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,
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            goal: None,
246            model: None,
247            model_provider: None,
248            cwd: None,
249            approval_policy: None,
250            sandbox: None,
251            events: Vec::new(),
252            data: json!({}),
253        }),
254    }
255}
256
257async fn prompt_handler(
258    State(state): State<AppState>,
259    Json(req): Json<PromptRequest>,
260) -> Json<PromptResponse> {
261    let mut runtime = state.runtime.lock().await;
262    let overrides = CliRuntimeOverrides::default();
263    match runtime.handle_prompt(req, &overrides).await {
264        Ok(res) => Json(res),
265        Err(err) => Json(PromptResponse {
266            output: err.to_string(),
267            model: "unknown".to_string(),
268            events: Vec::new(),
269        }),
270    }
271}
272
273async fn tool_handler(
274    State(state): State<AppState>,
275    Json(req): Json<ToolCallRequest>,
276) -> Json<Value> {
277    let runtime = state.runtime.lock().await;
278    let cwd = req
279        .cwd
280        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
281    // Resolve approval policy from config instead of hardcoding.
282    let approval_mode = {
283        let cfg = state.config.read().await;
284        cfg.approval_policy
285            .as_deref()
286            .and_then(|p| match p.trim().to_ascii_lowercase().as_str() {
287                "auto" | "yolo" => Some(codewhale_execpolicy::AskForApproval::UnlessTrusted),
288                "never" | "deny" => Some(codewhale_execpolicy::AskForApproval::Never),
289                _ => None,
290            })
291            .unwrap_or(codewhale_execpolicy::AskForApproval::OnRequest)
292    };
293    match runtime.invoke_tool(req.call, approval_mode, &cwd).await {
294        Ok(value) => Json(value),
295        Err(err) => Json(json!({ "ok": false, "error": err.to_string() })),
296    }
297}
298
299async fn jobs_handler(State(state): State<AppState>) -> Json<AppResponse> {
300    let runtime = state.runtime.lock().await;
301    Json(runtime.app_status())
302}
303
304async fn mcp_startup_handler(State(state): State<AppState>) -> Json<Value> {
305    let runtime = state.runtime.lock().await;
306    let summary = runtime.mcp_startup().await;
307    Json(json!({
308        "ok": true,
309        "summary": summary
310    }))
311}
312
313async fn app_handler(
314    State(state): State<AppState>,
315    Json(req): Json<AppRequest>,
316) -> Json<AppResponse> {
317    Json(process_app_request(&state, req, AppTransport::Http).await)
318}
319
320fn build_state(config_path: Option<PathBuf>, auth_token: Option<String>) -> Result<AppState> {
321    let store = ConfigStore::load(config_path.clone())?;
322    let config = store.config.clone();
323    let exec_policy = store.exec_policy_engine();
324    let registry = ModelRegistry::default();
325
326    let state_db_path = config_path
327        .as_ref()
328        .and_then(|p| p.parent().map(|parent| parent.join("state.db")));
329    let state_store = StateStore::open(state_db_path)?;
330
331    let mut hooks = HookDispatcher::default();
332    hooks.add_sink(Arc::new(StdoutHookSink));
333    let hook_log_path = config_path
334        .as_ref()
335        .and_then(|p| p.parent().map(|parent| parent.join("events.jsonl")))
336        .unwrap_or_else(|| PathBuf::from(".deepseek/events.jsonl"));
337    hooks.add_sink(Arc::new(JsonlHookSink::new(hook_log_path)));
338
339    if let Some(socket_path) = config
340        .hook_sinks
341        .as_ref()
342        .and_then(|sinks| sinks.unix_socket_path.as_ref())
343        .filter(|path| !path.as_os_str().is_empty())
344    {
345        hooks.add_sink(Arc::new(UnixSocketHookSink::new(socket_path.clone())));
346    }
347
348    let runtime = Runtime::new(
349        config.clone(),
350        registry.clone(),
351        state_store,
352        Arc::new(ToolRegistry::default()),
353        Arc::new(McpManager::default()),
354        exec_policy,
355        hooks,
356    );
357
358    Ok(AppState {
359        config_path,
360        config: Arc::new(RwLock::new(config)),
361        runtime: Arc::new(Mutex::new(runtime)),
362        registry,
363        auth_token,
364    })
365}
366
367fn resolve_auth_token(options: &AppServerOptions) -> Result<Option<String>> {
368    let configured = options.auth_token.as_ref().map(|token| token.trim());
369    if let Some(token) = configured
370        && token.is_empty()
371    {
372        bail!("app-server auth token cannot be empty");
373    }
374
375    if options.insecure_no_auth {
376        if !options.listen.ip().is_loopback() {
377            bail!("refusing unauthenticated app-server bind on non-loopback address");
378        }
379        eprintln!("warning: app-server HTTP auth disabled by --insecure-no-auth");
380        return Ok(None);
381    }
382
383    let token = configured
384        .map(str::to_string)
385        .unwrap_or_else(|| format!("cwapp_{}", Uuid::new_v4().simple()));
386    if options.auth_token.is_some() {
387        eprintln!("app-server auth: bearer token required for HTTP routes.");
388    } else {
389        eprintln!("app-server auth: generated bearer token for this process.");
390        eprintln!("  Authorization: Bearer {token}");
391        eprintln!("  Pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN for a stable token.");
392    }
393    Ok(Some(token))
394}
395
396fn cors_layer(extra_origins: &[String]) -> CorsLayer {
397    let mut origins: Vec<HeaderValue> = DEFAULT_CORS_ORIGINS
398        .iter()
399        .filter_map(|origin| HeaderValue::from_str(origin).ok())
400        .collect();
401    for raw in extra_origins {
402        let trimmed = raw.trim();
403        if trimmed.is_empty() {
404            continue;
405        }
406        match HeaderValue::from_str(trimmed) {
407            Ok(value) if !origins.contains(&value) => origins.push(value),
408            Ok(_) => {}
409            Err(err) => {
410                eprintln!("warning: ignoring invalid app-server CORS origin `{trimmed}`: {err}")
411            }
412        }
413    }
414
415    CorsLayer::new()
416        .allow_origin(origins)
417        .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
418        .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
419}
420
421async fn require_app_server_token(
422    State(state): State<AppState>,
423    req: Request,
424    next: Next,
425) -> Response {
426    let Some(expected) = state.auth_token.as_deref() else {
427        return next.run(req).await;
428    };
429    let authorized = req
430        .headers()
431        .get(header::AUTHORIZATION)
432        .and_then(|value| value.to_str().ok())
433        .and_then(|raw| raw.strip_prefix("Bearer "))
434        .is_some_and(|token| token == expected);
435
436    if authorized {
437        next.run(req).await
438    } else {
439        (
440            StatusCode::UNAUTHORIZED,
441            Json(json!({
442                "error": {
443                    "message": "app-server bearer token required",
444                    "status": StatusCode::UNAUTHORIZED.as_u16(),
445                }
446            })),
447        )
448            .into_response()
449    }
450}
451
452fn params_or_object(params: Value) -> Value {
453    if params.is_null() { json!({}) } else { params }
454}
455
456fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
457    serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
458}
459
460fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
461    json!({
462        "jsonrpc": "2.0",
463        "id": id.unwrap_or(Value::Null),
464        "result": result
465    })
466}
467
468fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
469    json!({
470        "jsonrpc": "2.0",
471        "id": id.unwrap_or(Value::Null),
472        "error": {
473            "code": err.code,
474            "message": err.message,
475            "data": err.data
476        }
477    })
478}
479
480impl JsonRpcError {
481    fn parse_error(message: impl Into<String>) -> Self {
482        Self {
483            code: -32700,
484            message: message.into(),
485            data: None,
486        }
487    }
488
489    fn invalid_request(message: impl Into<String>) -> Self {
490        Self {
491            code: -32600,
492            message: message.into(),
493            data: None,
494        }
495    }
496
497    fn method_not_found(method: &str) -> Self {
498        Self {
499            code: -32601,
500            message: format!("unsupported method: {method}"),
501            data: None,
502        }
503    }
504
505    fn invalid_params(message: impl Into<String>) -> Self {
506        Self {
507            code: -32602,
508            message: message.into(),
509            data: None,
510        }
511    }
512
513    fn internal(message: impl Into<String>) -> Self {
514        Self {
515            code: -32603,
516            message: message.into(),
517            data: None,
518        }
519    }
520}
521
522async fn handle_thread_request(
523    state: &AppState,
524    req: ThreadRequest,
525) -> std::result::Result<ThreadResponse, JsonRpcError> {
526    let mut runtime = state.runtime.lock().await;
527    runtime
528        .handle_thread(req)
529        .await
530        .map_err(|err| JsonRpcError::internal(err.to_string()))
531}
532
533async fn handle_prompt_request(
534    state: &AppState,
535    req: PromptRequest,
536) -> std::result::Result<PromptResponse, JsonRpcError> {
537    let mut runtime = state.runtime.lock().await;
538    runtime
539        .handle_prompt(req, &CliRuntimeOverrides::default())
540        .await
541        .map_err(|err| JsonRpcError::internal(err.to_string()))
542}
543
544async fn dispatch_stdio_request(
545    state: &AppState,
546    method: &str,
547    params: Value,
548) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
549    let outcome = match method {
550        "healthz" | "app/healthz" => StdioDispatchResult {
551            result: json!({
552                "status": "ok",
553                "service": "deepseek-app-server",
554                "transport": "stdio"
555            }),
556            should_exit: false,
557        },
558        "capabilities" => StdioDispatchResult {
559            result: json!({
560                "transport": "stdio",
561                "families": ["thread/*", "app/*", "prompt/*"],
562                "methods": [
563                    "healthz",
564                    "thread/capabilities",
565                    "thread/request",
566                    "thread/create",
567                    "thread/start",
568                    "thread/resume",
569                    "thread/fork",
570                    "thread/list",
571                    "thread/read",
572                    "thread/set_name",
573                    "thread/goal/set",
574                    "thread/goal/get",
575                    "thread/goal/clear",
576                    "thread/archive",
577                    "thread/unarchive",
578                    "thread/message",
579                    "app/capabilities",
580                    "app/request",
581                    "app/config/get",
582                    "app/config/set",
583                    "app/config/unset",
584                    "app/config/list",
585                    "app/models",
586                    "app/thread_loaded_list",
587                    "prompt/capabilities",
588                    "prompt/request",
589                    "prompt/run",
590                    "shutdown"
591                ]
592            }),
593            should_exit: false,
594        },
595        "thread/capabilities" => StdioDispatchResult {
596            result: json!({
597                "methods": [
598                    "thread/request",
599                    "thread/create",
600                    "thread/start",
601                    "thread/resume",
602                    "thread/fork",
603                    "thread/list",
604                    "thread/read",
605                    "thread/set_name",
606                    "thread/goal/set",
607                    "thread/goal/get",
608                    "thread/goal/clear",
609                    "thread/archive",
610                    "thread/unarchive",
611                    "thread/message"
612                ]
613            }),
614            should_exit: false,
615        },
616        "thread/request" => {
617            let request: ThreadRequest = parse_params(params)?;
618            let response = handle_thread_request(state, request).await?;
619            StdioDispatchResult {
620                result: serde_json::to_value(response)
621                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
622                should_exit: false,
623            }
624        }
625        "thread/create" => {
626            #[derive(Debug, Deserialize)]
627            struct CreateParams {
628                #[serde(default)]
629                metadata: Value,
630            }
631            let parsed: CreateParams = parse_params(params_or_object(params))?;
632            let response = handle_thread_request(
633                state,
634                ThreadRequest::Create {
635                    metadata: parsed.metadata,
636                },
637            )
638            .await?;
639            StdioDispatchResult {
640                result: serde_json::to_value(response)
641                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
642                should_exit: false,
643            }
644        }
645        "thread/start" => {
646            let request = ThreadRequest::Start(parse_params(params_or_object(params))?);
647            let response = handle_thread_request(state, request).await?;
648            StdioDispatchResult {
649                result: serde_json::to_value(response)
650                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
651                should_exit: false,
652            }
653        }
654        "thread/resume" => {
655            let request = ThreadRequest::Resume(parse_params(params_or_object(params))?);
656            let response = handle_thread_request(state, request).await?;
657            StdioDispatchResult {
658                result: serde_json::to_value(response)
659                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
660                should_exit: false,
661            }
662        }
663        "thread/fork" => {
664            let request = ThreadRequest::Fork(parse_params(params_or_object(params))?);
665            let response = handle_thread_request(state, request).await?;
666            StdioDispatchResult {
667                result: serde_json::to_value(response)
668                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
669                should_exit: false,
670            }
671        }
672        "thread/list" => {
673            let request = ThreadRequest::List(parse_params(params_or_object(params))?);
674            let response = handle_thread_request(state, request).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/read" => {
682            let request = ThreadRequest::Read(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/set_name" | "thread/set-name" => {
691            let request = ThreadRequest::SetName(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/goal/set" | "thread/goal_set" | "thread/goal-set" => {
700            let request = ThreadRequest::GoalSet(parse_params::<ThreadGoalSetParams>(
701                params_or_object(params),
702            )?);
703            let response = handle_thread_request(state, request).await?;
704            StdioDispatchResult {
705                result: serde_json::to_value(response)
706                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
707                should_exit: false,
708            }
709        }
710        "thread/goal/get" | "thread/goal_get" | "thread/goal-get" => {
711            let request = ThreadRequest::GoalGet(parse_params::<ThreadGoalGetParams>(
712                params_or_object(params),
713            )?);
714            let response = handle_thread_request(state, request).await?;
715            StdioDispatchResult {
716                result: serde_json::to_value(response)
717                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
718                should_exit: false,
719            }
720        }
721        "thread/goal/clear" | "thread/goal_clear" | "thread/goal-clear" => {
722            let request = ThreadRequest::GoalClear(parse_params::<ThreadGoalClearParams>(
723                params_or_object(params),
724            )?);
725            let response = handle_thread_request(state, request).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        "thread/archive" => {
733            let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
734            let response = handle_thread_request(
735                state,
736                ThreadRequest::Archive {
737                    thread_id: parsed.thread_id,
738                },
739            )
740            .await?;
741            StdioDispatchResult {
742                result: serde_json::to_value(response)
743                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
744                should_exit: false,
745            }
746        }
747        "thread/unarchive" => {
748            let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
749            let response = handle_thread_request(
750                state,
751                ThreadRequest::Unarchive {
752                    thread_id: parsed.thread_id,
753                },
754            )
755            .await?;
756            StdioDispatchResult {
757                result: serde_json::to_value(response)
758                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
759                should_exit: false,
760            }
761        }
762        "thread/message" => {
763            let parsed: ThreadMessageParams = parse_params(params_or_object(params))?;
764            let response = handle_thread_request(
765                state,
766                ThreadRequest::Message {
767                    thread_id: parsed.thread_id,
768                    input: parsed.input,
769                },
770            )
771            .await?;
772            StdioDispatchResult {
773                result: serde_json::to_value(response)
774                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
775                should_exit: false,
776            }
777        }
778        "app/capabilities" => {
779            let response =
780                process_app_request(state, AppRequest::Capabilities, AppTransport::Stdio).await;
781            StdioDispatchResult {
782                result: serde_json::to_value(response)
783                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
784                should_exit: false,
785            }
786        }
787        "app/request" => {
788            let request: AppRequest = parse_params(params)?;
789            let response = process_app_request(state, request, AppTransport::Stdio).await;
790            StdioDispatchResult {
791                result: serde_json::to_value(response)
792                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
793                should_exit: false,
794            }
795        }
796        "app/config/get" => {
797            let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
798            let response = process_app_request(
799                state,
800                AppRequest::ConfigGet { key: parsed.key },
801                AppTransport::Stdio,
802            )
803            .await;
804            StdioDispatchResult {
805                result: serde_json::to_value(response)
806                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
807                should_exit: false,
808            }
809        }
810        "app/config/set" => {
811            let parsed: ConfigSetParams = parse_params(params_or_object(params))?;
812            let response = process_app_request(
813                state,
814                AppRequest::ConfigSet {
815                    key: parsed.key,
816                    value: parsed.value,
817                },
818                AppTransport::Stdio,
819            )
820            .await;
821            StdioDispatchResult {
822                result: serde_json::to_value(response)
823                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
824                should_exit: false,
825            }
826        }
827        "app/config/unset" => {
828            let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
829            let response = process_app_request(
830                state,
831                AppRequest::ConfigUnset { key: parsed.key },
832                AppTransport::Stdio,
833            )
834            .await;
835            StdioDispatchResult {
836                result: serde_json::to_value(response)
837                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
838                should_exit: false,
839            }
840        }
841        "app/config/list" => {
842            let response =
843                process_app_request(state, AppRequest::ConfigList, AppTransport::Stdio).await;
844            StdioDispatchResult {
845                result: serde_json::to_value(response)
846                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
847                should_exit: false,
848            }
849        }
850        "app/models" => {
851            let response =
852                process_app_request(state, AppRequest::Models, AppTransport::Stdio).await;
853            StdioDispatchResult {
854                result: serde_json::to_value(response)
855                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
856                should_exit: false,
857            }
858        }
859        "app/thread_loaded_list" | "app/thread-loaded-list" => {
860            let response =
861                process_app_request(state, AppRequest::ThreadLoadedList, AppTransport::Stdio).await;
862            StdioDispatchResult {
863                result: serde_json::to_value(response)
864                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
865                should_exit: false,
866            }
867        }
868        "prompt/capabilities" => StdioDispatchResult {
869            result: json!({
870                "methods": ["prompt/request", "prompt/run"]
871            }),
872            should_exit: false,
873        },
874        "prompt/request" | "prompt/run" => {
875            let request: PromptRequest = parse_params(params)?;
876            let response = handle_prompt_request(state, request).await?;
877            StdioDispatchResult {
878                result: serde_json::to_value(response)
879                    .map_err(|err| JsonRpcError::internal(err.to_string()))?,
880                should_exit: false,
881            }
882        }
883        "shutdown" => StdioDispatchResult {
884            result: json!({"ok": true, "status": "stopped"}),
885            should_exit: true,
886        },
887        _ => return Err(JsonRpcError::method_not_found(method)),
888    };
889    Ok(outcome)
890}
891
892async fn process_app_request(
893    state: &AppState,
894    req: AppRequest,
895    transport: AppTransport,
896) -> AppResponse {
897    match req {
898        AppRequest::Capabilities => AppResponse {
899            ok: true,
900            data: json!({
901                "routes": ["/thread", "/app", "/prompt", "/tool", "/jobs", "/mcp/startup"],
902                "config": ["get", "set", "unset", "list"],
903                "events": ["response_start", "response_delta", "response_end", "tool_call_start", "tool_call_result", "mcp_startup_update", "mcp_startup_complete"],
904                "transport": "stdio+http",
905                "config_path": state.config_path.as_ref().map(|p| p.display().to_string()),
906            }),
907            events: Vec::new(),
908        },
909        AppRequest::ConfigGet { key } => {
910            let cfg = state.config.read().await;
911            let value = match transport {
912                AppTransport::Http => cfg.get_display_value(&key),
913                AppTransport::Stdio => cfg.get_value(&key),
914            };
915            AppResponse {
916                ok: true,
917                data: json!({ "key": key, "value": value }),
918                events: Vec::new(),
919            }
920        }
921        AppRequest::ConfigSet { key, value } => {
922            let mut cfg = state.config.write().await;
923            let result = cfg.set_value(&key, &value);
924            let ok = result.is_ok();
925            let message = result.err().map(|e| e.to_string());
926            let snapshot = cfg.clone();
927            drop(cfg);
928            if let Err(e) = persist_config(state, snapshot).await {
929                tracing::error!("Failed to persist config after set: {e}");
930            }
931            AppResponse {
932                ok,
933                data: json!({ "key": key, "value": value, "error": message }),
934                events: Vec::new(),
935            }
936        }
937        AppRequest::ConfigUnset { key } => {
938            let mut cfg = state.config.write().await;
939            let result = cfg.unset_value(&key);
940            let ok = result.is_ok();
941            let message = result.err().map(|e| e.to_string());
942            let snapshot = cfg.clone();
943            drop(cfg);
944            if let Err(e) = persist_config(state, snapshot).await {
945                tracing::error!("Failed to persist config after unset: {e}");
946            }
947            AppResponse {
948                ok,
949                data: json!({ "key": key, "error": message }),
950                events: Vec::new(),
951            }
952        }
953        AppRequest::ConfigList => {
954            let cfg = state.config.read().await;
955            AppResponse {
956                ok: true,
957                data: json!({ "values": cfg.list_values() }),
958                events: Vec::new(),
959            }
960        }
961        AppRequest::Models => AppResponse {
962            ok: true,
963            data: json!({ "models": state.registry.list() }),
964            events: Vec::new(),
965        },
966        AppRequest::ThreadLoadedList => {
967            let mut runtime = state.runtime.lock().await;
968            let response = runtime
969                .handle_thread(codewhale_protocol::ThreadRequest::List(
970                    codewhale_protocol::ThreadListParams {
971                        include_archived: false,
972                        limit: Some(50),
973                    },
974                ))
975                .await;
976            match response {
977                Ok(thread_resp) => AppResponse {
978                    ok: true,
979                    data: json!({ "threads": thread_resp.threads }),
980                    events: thread_resp.events,
981                },
982                Err(err) => AppResponse {
983                    ok: false,
984                    data: json!({ "error": err.to_string() }),
985                    events: Vec::new(),
986                },
987            }
988        }
989    }
990}
991
992async fn persist_config(state: &AppState, config: codewhale_config::ConfigToml) -> Result<()> {
993    if state.config_path.is_none() {
994        return Ok(());
995    }
996    let mut store = ConfigStore::load(state.config_path.clone())?;
997    store.config = config;
998    store.save()
999}
1000
1001#[cfg(test)]
1002mod tests {
1003    use super::*;
1004    use axum::body::{Body, to_bytes};
1005    use codewhale_protocol::AppRequest;
1006    use std::fs;
1007    use tower::ServiceExt;
1008
1009    fn app_with_config(auth_token: Option<&str>) -> (Router, tempfile::TempDir) {
1010        let tmp = tempfile::tempdir().expect("tempdir");
1011        let config_path = tmp.path().join("config.toml");
1012        fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1013        let state = build_state(
1014            Some(config_path),
1015            auth_token.map(std::string::ToString::to_string),
1016        )
1017        .expect("state");
1018        (app_router(state, &[]), tmp)
1019    }
1020
1021    async fn response_body_json(response: Response) -> Value {
1022        let bytes = to_bytes(response.into_body(), usize::MAX)
1023            .await
1024            .expect("body bytes");
1025        serde_json::from_slice(&bytes).expect("json response")
1026    }
1027
1028    #[tokio::test]
1029    async fn http_app_routes_require_bearer_token_when_auth_enabled() {
1030        let (app, _tmp) = app_with_config(Some("test-token"));
1031        let response = app
1032            .oneshot(
1033                Request::builder()
1034                    .method(Method::POST)
1035                    .uri("/app")
1036                    .header(header::CONTENT_TYPE, "application/json")
1037                    .body(Body::from(
1038                        serde_json::to_vec(&AppRequest::ConfigGet {
1039                            key: "api_key".to_string(),
1040                        })
1041                        .expect("request json"),
1042                    ))
1043                    .expect("request"),
1044            )
1045            .await
1046            .expect("response");
1047
1048        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1049    }
1050
1051    #[tokio::test]
1052    async fn http_config_get_redacts_sensitive_values_after_auth() {
1053        let (app, _tmp) = app_with_config(Some("test-token"));
1054        let response = app
1055            .oneshot(
1056                Request::builder()
1057                    .method(Method::POST)
1058                    .uri("/app")
1059                    .header(header::AUTHORIZATION, "Bearer test-token")
1060                    .header(header::CONTENT_TYPE, "application/json")
1061                    .body(Body::from(
1062                        serde_json::to_vec(&AppRequest::ConfigGet {
1063                            key: "api_key".to_string(),
1064                        })
1065                        .expect("request json"),
1066                    ))
1067                    .expect("request"),
1068            )
1069            .await
1070            .expect("response");
1071
1072        assert_eq!(response.status(), StatusCode::OK);
1073        let body = response_body_json(response).await;
1074        assert_eq!(body["data"]["value"], "sk-d***cret");
1075    }
1076
1077    #[tokio::test]
1078    async fn cors_does_not_allow_arbitrary_origins() {
1079        let (app, _tmp) = app_with_config(Some("test-token"));
1080        let response = app
1081            .oneshot(
1082                Request::builder()
1083                    .method(Method::GET)
1084                    .uri("/healthz")
1085                    .header(header::ORIGIN, "https://attacker.example")
1086                    .body(Body::empty())
1087                    .expect("request"),
1088            )
1089            .await
1090            .expect("response");
1091
1092        assert_eq!(response.status(), StatusCode::OK);
1093        assert!(
1094            response
1095                .headers()
1096                .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
1097                .is_none()
1098        );
1099    }
1100
1101    #[tokio::test]
1102    async fn build_state_loads_permissions_into_runtime_policy() {
1103        let tmp = tempfile::tempdir().expect("tempdir");
1104        let config_path = tmp.path().join("config.toml");
1105        fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1106        fs::write(
1107            tmp.path().join("permissions.toml"),
1108            r#"
1109            [[rules]]
1110            tool = "exec_shell"
1111            command = "cargo test"
1112            "#,
1113        )
1114        .expect("write permissions");
1115
1116        let state = build_state(Some(config_path), None).expect("state");
1117        let runtime = state.runtime.lock().await;
1118        let decision = runtime
1119            .exec_policy
1120            .check(codewhale_execpolicy::ExecPolicyContext {
1121                command: "cargo test --workspace",
1122                cwd: "/workspace",
1123                tool: Some("exec_shell"),
1124                path: None,
1125                ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1126                sandbox_mode: Some("workspace-write"),
1127            })
1128            .expect("policy check");
1129
1130        assert!(decision.allow);
1131        assert!(decision.requires_approval);
1132        assert_eq!(
1133            decision.matched_rule.as_deref(),
1134            Some("tool=exec_shell command=cargo test")
1135        );
1136    }
1137
1138    #[test]
1139    fn non_loopback_bind_without_auth_fails_fast() {
1140        let options = AppServerOptions {
1141            listen: "0.0.0.0:8787".parse().expect("socket addr"),
1142            config_path: None,
1143            auth_token: None,
1144            insecure_no_auth: true,
1145            cors_origins: Vec::new(),
1146        };
1147
1148        let err = resolve_auth_token(&options).expect_err("non-loopback unauth should fail");
1149        assert!(
1150            err.to_string()
1151                .contains("refusing unauthenticated app-server bind")
1152        );
1153    }
1154
1155    #[tokio::test]
1156    async fn stdio_transport_keeps_raw_config_get_for_legacy_clients() {
1157        let tmp = tempfile::tempdir().expect("tempdir");
1158        let config_path = tmp.path().join("config.toml");
1159        fs::write(&config_path, "").expect("write config");
1160        let state = build_state(Some(config_path), None).expect("state");
1161        {
1162            let mut cfg = state.config.write().await;
1163            cfg.api_key = Some("sk-deepseek-secret".to_string());
1164        }
1165
1166        let response = process_app_request(
1167            &state,
1168            AppRequest::ConfigGet {
1169                key: "api_key".to_string(),
1170            },
1171            AppTransport::Stdio,
1172        )
1173        .await;
1174
1175        assert_eq!(response.data["value"], "sk-deepseek-secret");
1176    }
1177
1178    #[tokio::test]
1179    async fn stdio_thread_goal_methods_round_trip_persisted_goal() {
1180        let tmp = tempfile::tempdir().expect("tempdir");
1181        let config_path = tmp.path().join("config.toml");
1182        fs::write(&config_path, "").expect("write config");
1183        let state = build_state(Some(config_path), None).expect("state");
1184
1185        let capabilities = dispatch_stdio_request(&state, "thread/capabilities", json!({}))
1186            .await
1187            .expect("thread capabilities");
1188        assert!(
1189            capabilities.result["methods"]
1190                .as_array()
1191                .expect("methods")
1192                .iter()
1193                .any(|method| method == "thread/goal/set")
1194        );
1195
1196        let started = dispatch_stdio_request(&state, "thread/start", json!({}))
1197            .await
1198            .expect("start thread");
1199        let thread_id = started.result["thread_id"]
1200            .as_str()
1201            .expect("thread id")
1202            .to_string();
1203
1204        let set = dispatch_stdio_request(
1205            &state,
1206            "thread/goal/set",
1207            json!({
1208                "thread_id": thread_id,
1209                "objective": "Release 0.8.59",
1210                "token_budget": 59000
1211            }),
1212        )
1213        .await
1214        .expect("set goal");
1215        assert_eq!(set.result["status"], "ok");
1216        assert_eq!(set.result["goal"]["objective"], "Release 0.8.59");
1217        assert_eq!(set.result["goal"]["status"], "active");
1218
1219        let got = dispatch_stdio_request(
1220            &state,
1221            "thread/goal/get",
1222            json!({
1223                "thread_id": thread_id
1224            }),
1225        )
1226        .await
1227        .expect("get goal");
1228        assert_eq!(got.result["goal"]["token_budget"], 59000);
1229
1230        let cleared = dispatch_stdio_request(
1231            &state,
1232            "thread/goal/clear",
1233            json!({
1234                "thread_id": thread_id
1235            }),
1236        )
1237        .await
1238        .expect("clear goal");
1239        assert_eq!(cleared.result["status"], "cleared");
1240        assert_eq!(cleared.result["data"]["cleared"], true);
1241    }
1242
1243    // ── resolve_auth_token ─────────────────────────────────────────────
1244
1245    #[test]
1246    fn auth_token_empty_string_fails() {
1247        let options = AppServerOptions {
1248            listen: "127.0.0.1:0".parse().expect("addr"),
1249            config_path: None,
1250            auth_token: Some("  ".to_string()),
1251            insecure_no_auth: false,
1252            cors_origins: Vec::new(),
1253        };
1254        let err = resolve_auth_token(&options).expect_err("empty token should fail");
1255        assert!(err.to_string().contains("cannot be empty"));
1256    }
1257
1258    #[test]
1259    fn auth_token_generated_when_none_provided() {
1260        let options = AppServerOptions {
1261            listen: "127.0.0.1:0".parse().expect("addr"),
1262            config_path: None,
1263            auth_token: None,
1264            insecure_no_auth: false,
1265            cors_origins: Vec::new(),
1266        };
1267        let token = resolve_auth_token(&options).unwrap();
1268        assert!(token.is_some());
1269        assert!(token.unwrap().starts_with("cwapp_"));
1270    }
1271
1272    #[test]
1273    fn auth_token_explicit_is_preserved() {
1274        let options = AppServerOptions {
1275            listen: "127.0.0.1:0".parse().expect("addr"),
1276            config_path: None,
1277            auth_token: Some("my-secret".to_string()),
1278            insecure_no_auth: false,
1279            cors_origins: Vec::new(),
1280        };
1281        let token = resolve_auth_token(&options).unwrap();
1282        assert_eq!(token.as_deref(), Some("my-secret"));
1283    }
1284
1285    #[test]
1286    fn insecure_no_auth_on_loopback_returns_none() {
1287        let options = AppServerOptions {
1288            listen: "127.0.0.1:0".parse().expect("addr"),
1289            config_path: None,
1290            auth_token: None,
1291            insecure_no_auth: true,
1292            cors_origins: Vec::new(),
1293        };
1294        let token = resolve_auth_token(&options).unwrap();
1295        assert!(token.is_none());
1296    }
1297
1298    // ── cors_layer ─────────────────────────────────────────────────────
1299
1300    #[test]
1301    fn cors_layer_includes_default_origins() {
1302        let layer = cors_layer(&[]);
1303        // Just verify it doesn't panic and creates successfully
1304        let _ = layer;
1305    }
1306
1307    #[test]
1308    fn cors_layer_adds_extra_origins() {
1309        let extras = vec!["https://example.com".to_string()];
1310        let layer = cors_layer(&extras);
1311        let _ = layer;
1312    }
1313
1314    #[test]
1315    fn cors_layer_skips_empty_origins() {
1316        let extras = vec!["".to_string(), "  ".to_string()];
1317        let layer = cors_layer(&extras);
1318        let _ = layer;
1319    }
1320
1321    // ── JsonRpc helpers ────────────────────────────────────────────────
1322
1323    #[test]
1324    fn params_or_object_returns_object_for_null() {
1325        let result = params_or_object(Value::Null);
1326        assert_eq!(result, json!({}));
1327    }
1328
1329    #[test]
1330    fn params_or_object_passthrough_for_non_null() {
1331        let input = json!({"key": "value"});
1332        let result = params_or_object(input.clone());
1333        assert_eq!(result, input);
1334    }
1335
1336    #[test]
1337    fn jsonrpc_result_format() {
1338        let result = jsonrpc_result(Some(json!(1)), json!({"ok": true}));
1339        assert_eq!(result["jsonrpc"], "2.0");
1340        assert_eq!(result["id"], 1);
1341        assert_eq!(result["result"]["ok"], true);
1342    }
1343
1344    #[test]
1345    fn jsonrpc_result_null_id() {
1346        let result = jsonrpc_result(None, json!(null));
1347        assert_eq!(result["id"], Value::Null);
1348    }
1349
1350    #[test]
1351    fn jsonrpc_error_format() {
1352        let err = jsonrpc_error(Some(json!(2)), JsonRpcError::internal("oops"));
1353        assert_eq!(err["jsonrpc"], "2.0");
1354        assert_eq!(err["id"], 2);
1355        assert_eq!(err["error"]["code"], -32603);
1356        assert_eq!(err["error"]["message"], "oops");
1357    }
1358
1359    #[test]
1360    fn jsonrpc_error_codes() {
1361        assert_eq!(JsonRpcError::parse_error("").code, -32700);
1362        assert_eq!(JsonRpcError::invalid_request("").code, -32600);
1363        assert_eq!(JsonRpcError::method_not_found("x").code, -32601);
1364        assert_eq!(JsonRpcError::invalid_params("").code, -32602);
1365        assert_eq!(JsonRpcError::internal("").code, -32603);
1366    }
1367
1368    // ── AppServerOptions ───────────────────────────────────────────────
1369
1370    #[test]
1371    fn app_server_options_debug_does_not_leak_token() {
1372        let options = AppServerOptions {
1373            listen: "127.0.0.1:8080".parse().expect("addr"),
1374            config_path: None,
1375            auth_token: Some("secret-token".to_string()),
1376            insecure_no_auth: false,
1377            cors_origins: vec!["https://example.com".to_string()],
1378        };
1379        let debug = format!("{options:?}");
1380        assert!(!debug.contains("secret-token"));
1381        assert!(debug.contains("<redacted>"));
1382        assert!(debug.contains("8080"));
1383    }
1384
1385    // ── Default CORS origins ──────────────────────────────────────────
1386
1387    #[test]
1388    fn default_cors_origins_include_common_dev_ports() {
1389        assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:3000"));
1390        assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:5173"));
1391        assert!(DEFAULT_CORS_ORIGINS.contains(&"tauri://localhost"));
1392    }
1393}