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}