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