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 #[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 #[test]
1146 fn cors_layer_includes_default_origins() {
1147 let layer = cors_layer(&[]);
1148 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 #[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 #[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 #[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}