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, UserInputAnswerEvent,
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
31type PendingUserInputAnswers = Vec<UserInputAnswerEvent>;
40
41mod chat_completions;
42
43const DEFAULT_CORS_ORIGINS: &[&str] = &[
44 "http://localhost",
45 "http://localhost:1420",
46 "http://localhost:3000",
47 "http://localhost:5173",
48 "http://127.0.0.1",
49 "http://127.0.0.1:1420",
50 "tauri://localhost",
51];
52
53#[derive(Clone)]
54pub struct AppServerOptions {
55 pub listen: SocketAddr,
56 pub config_path: Option<PathBuf>,
57 pub auth_token: Option<String>,
58 pub insecure_no_auth: bool,
59 pub cors_origins: Vec<String>,
60}
61
62impl std::fmt::Debug for AppServerOptions {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.debug_struct("AppServerOptions")
65 .field("listen", &self.listen)
66 .field("config_path", &self.config_path)
67 .field(
68 "auth_token",
69 &self.auth_token.as_ref().map(|_| "<redacted>"),
70 )
71 .field("insecure_no_auth", &self.insecure_no_auth)
72 .field("cors_origins", &self.cors_origins)
73 .finish()
74 }
75}
76
77#[derive(Clone)]
78struct AppState {
79 config_path: Option<PathBuf>,
80 config: Arc<RwLock<codewhale_config::ConfigToml>>,
81 runtime: Arc<Mutex<Runtime>>,
82 registry: ModelRegistry,
83 auth_token: Option<String>,
84 pending_user_input: Arc<Mutex<std::collections::HashMap<String, PendingUserInputAnswers>>>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91struct ToolCallRequest {
92 call: ToolCall,
93 #[serde(default)]
94 cwd: Option<PathBuf>,
95}
96
97#[derive(Debug, Deserialize)]
98struct JsonRpcRequest {
99 #[serde(default)]
100 jsonrpc: Option<String>,
101 #[serde(default)]
102 id: Option<Value>,
103 method: String,
104 #[serde(default)]
105 params: Value,
106}
107
108#[derive(Debug)]
109struct JsonRpcError {
110 code: i64,
111 message: String,
112 data: Option<Value>,
113}
114
115#[derive(Debug)]
116struct StdioDispatchResult {
117 result: Value,
118 should_exit: bool,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122enum AppTransport {
123 Http,
124 Stdio,
125}
126
127#[derive(Debug, Deserialize)]
128struct ConfigGetParams {
129 key: String,
130}
131
132#[derive(Debug, Deserialize)]
133struct ConfigSetParams {
134 key: String,
135 value: String,
136}
137
138#[derive(Debug, Deserialize)]
139struct ThreadIdParams {
140 thread_id: String,
141}
142
143#[derive(Debug, Deserialize)]
144struct ThreadMessageParams {
145 thread_id: String,
146 input: String,
147}
148
149pub async fn run(options: AppServerOptions) -> Result<()> {
150 let auth_token = resolve_auth_token(&options)?;
151 let state = build_state(options.config_path.clone(), auth_token)?;
152 let app = app_router(state, &options.cors_origins);
153
154 let listener = tokio::net::TcpListener::bind(options.listen).await?;
155 axum::serve(listener, app).await?;
156 Ok(())
157}
158
159fn app_router(state: AppState, cors_origins: &[String]) -> Router {
160 let protected_routes = Router::new()
161 .route("/thread", post(thread_handler))
162 .route("/app", post(app_handler))
163 .route("/prompt", post(prompt_handler))
164 .route("/tool", post(tool_handler))
165 .route("/jobs", get(jobs_handler))
166 .route("/mcp/startup", post(mcp_startup_handler))
167 .route_layer(middleware::from_fn_with_state(
168 state.clone(),
169 require_app_server_token,
170 ));
171
172 Router::new()
173 .route("/healthz", get(healthz))
174 .route(
175 "/v1/chat/completions",
176 post(chat_completions::chat_completions_handler),
177 )
178 .merge(protected_routes)
179 .layer(cors_layer(cors_origins))
180 .with_state(state)
181}
182
183pub async fn run_stdio(config_path: Option<PathBuf>) -> Result<()> {
184 let state = build_state(config_path, None)?;
185 let stdin = tokio::io::stdin();
186 let stdout = tokio::io::stdout();
187 let mut reader = BufReader::new(stdin).lines();
188 let mut writer = tokio::io::BufWriter::new(stdout);
189 while let Some(line) = reader.next_line().await? {
190 if line.trim().is_empty() {
191 continue;
192 }
193
194 let request: JsonRpcRequest = match serde_json::from_str(&line) {
195 Ok(value) => value,
196 Err(err) => {
197 let response = jsonrpc_error(
198 None,
199 JsonRpcError::parse_error(format!("invalid json: {err}")),
200 );
201 writer.write_all(response.to_string().as_bytes()).await?;
202 writer.write_all(b"\n").await?;
203 writer.flush().await?;
204 continue;
205 }
206 };
207
208 if request
209 .jsonrpc
210 .as_deref()
211 .is_some_and(|version| version != "2.0")
212 {
213 let response = jsonrpc_error(
214 request.id,
215 JsonRpcError::invalid_request("jsonrpc version must be 2.0"),
216 );
217 writer.write_all(response.to_string().as_bytes()).await?;
218 writer.write_all(b"\n").await?;
219 writer.flush().await?;
220 continue;
221 }
222
223 let response = match dispatch_stdio_request(&state, &request.method, request.params).await {
224 Ok(dispatch) => {
225 let encoded = jsonrpc_result(request.id, dispatch.result);
226 writer.write_all(encoded.to_string().as_bytes()).await?;
227 writer.write_all(b"\n").await?;
228 writer.flush().await?;
229 if dispatch.should_exit {
230 break;
231 }
232 continue;
233 }
234 Err(err) => jsonrpc_error(request.id, err),
235 };
236
237 writer.write_all(response.to_string().as_bytes()).await?;
238 writer.write_all(b"\n").await?;
239 writer.flush().await?;
240 }
241
242 Ok(())
243}
244
245async fn healthz() -> Json<Value> {
246 Json(json!({
247 "status": "ok",
248 "protocol": "v2",
249 "service": "deepseek-app-server"
250 }))
251}
252
253async fn thread_handler(
254 State(state): State<AppState>,
255 Json(req): Json<ThreadRequest>,
256) -> Json<ThreadResponse> {
257 let mut runtime = state.runtime.lock().await;
258 match runtime.handle_thread(req).await {
259 Ok(res) => Json(res),
260 Err(err) => Json(ThreadResponse {
261 thread_id: "error".to_string(),
262 status: format!("error:{err}"),
263 thread: None,
264 threads: Vec::new(),
265 goal: None,
266 model: None,
267 model_provider: None,
268 cwd: None,
269 approval_policy: None,
270 sandbox: None,
271 events: Vec::new(),
272 data: json!({}),
273 }),
274 }
275}
276
277async fn prompt_handler(
278 State(state): State<AppState>,
279 Json(req): Json<PromptRequest>,
280) -> Json<PromptResponse> {
281 let mut runtime = state.runtime.lock().await;
282 let overrides = CliRuntimeOverrides::default();
283 match runtime.handle_prompt(req, &overrides).await {
284 Ok(res) => Json(res),
285 Err(err) => Json(PromptResponse {
286 output: err.to_string(),
287 model: "unknown".to_string(),
288 events: Vec::new(),
289 }),
290 }
291}
292
293async fn tool_handler(
294 State(state): State<AppState>,
295 Json(req): Json<ToolCallRequest>,
296) -> Json<Value> {
297 let runtime = state.runtime.lock().await;
298 let cwd = req
299 .cwd
300 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
301 let approval_mode = {
303 let cfg = state.config.read().await;
304 cfg.approval_policy
305 .as_deref()
306 .and_then(|p| match p.trim().to_ascii_lowercase().as_str() {
307 "auto" | "yolo" => Some(codewhale_execpolicy::AskForApproval::UnlessTrusted),
308 "never" | "deny" => Some(codewhale_execpolicy::AskForApproval::Never),
309 _ => None,
310 })
311 .unwrap_or(codewhale_execpolicy::AskForApproval::OnRequest)
312 };
313 match runtime.invoke_tool(req.call, approval_mode, &cwd).await {
314 Ok(value) => Json(value),
315 Err(err) => Json(json!({ "ok": false, "error": err.to_string() })),
316 }
317}
318
319async fn jobs_handler(State(state): State<AppState>) -> Json<AppResponse> {
320 let runtime = state.runtime.lock().await;
321 Json(runtime.app_status())
322}
323
324async fn mcp_startup_handler(State(state): State<AppState>) -> Json<Value> {
325 let runtime = state.runtime.lock().await;
326 let summary = runtime.mcp_startup().await;
327 Json(json!({
328 "ok": true,
329 "summary": summary
330 }))
331}
332
333async fn app_handler(
334 State(state): State<AppState>,
335 Json(req): Json<AppRequest>,
336) -> Json<AppResponse> {
337 Json(process_app_request(&state, req, AppTransport::Http).await)
338}
339
340fn build_state(config_path: Option<PathBuf>, auth_token: Option<String>) -> Result<AppState> {
341 let store = ConfigStore::load(config_path.clone())?;
342 let config = store.config.clone();
343 let exec_policy = store.exec_policy_engine();
344 let registry = ModelRegistry::default();
345
346 let state_db_path = config_path
347 .as_ref()
348 .and_then(|p| p.parent().map(|parent| parent.join("state.db")));
349 let state_store = StateStore::open(state_db_path)?;
350
351 let mut hooks = HookDispatcher::default();
352 hooks.add_sink(Arc::new(StdoutHookSink));
353 let hook_log_path = config_path
354 .as_ref()
355 .and_then(|p| p.parent().map(|parent| parent.join("events.jsonl")))
356 .unwrap_or_else(|| PathBuf::from(".deepseek/events.jsonl"));
357 hooks.add_sink(Arc::new(JsonlHookSink::new(hook_log_path)));
358
359 if let Some(socket_path) = config
360 .hook_sinks
361 .as_ref()
362 .and_then(|sinks| sinks.unix_socket_path.as_ref())
363 .filter(|path| !path.as_os_str().is_empty())
364 {
365 hooks.add_sink(Arc::new(UnixSocketHookSink::new(socket_path.clone())));
366 }
367
368 let runtime = Runtime::new(
369 config.clone(),
370 registry.clone(),
371 state_store,
372 Arc::new(ToolRegistry::default()),
373 Arc::new(McpManager::default()),
374 exec_policy,
375 hooks,
376 );
377
378 Ok(AppState {
379 config_path,
380 config: Arc::new(RwLock::new(config)),
381 runtime: Arc::new(Mutex::new(runtime)),
382 registry,
383 auth_token,
384 pending_user_input: Arc::new(Mutex::new(std::collections::HashMap::new())),
385 })
386}
387
388fn resolve_auth_token(options: &AppServerOptions) -> Result<Option<String>> {
389 let configured = options.auth_token.as_ref().map(|token| token.trim());
390 if let Some(token) = configured
391 && token.is_empty()
392 {
393 bail!("app-server auth token cannot be empty");
394 }
395 let has_explicit_token = configured.is_some();
396
397 if options.insecure_no_auth {
398 if !options.listen.ip().is_loopback() {
399 bail!("refusing unauthenticated app-server bind on non-loopback address");
400 }
401 eprintln!("warning: app-server HTTP auth disabled by --insecure-no-auth");
402 return Ok(None);
403 }
404
405 if !has_explicit_token && !options.listen.ip().is_loopback() {
406 bail!(
407 "refusing non-loopback app-server bind without explicit auth token; pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN"
408 );
409 }
410
411 let token = configured
412 .map(str::to_string)
413 .unwrap_or_else(|| format!("cwapp_{}", Uuid::new_v4().simple()));
414 if has_explicit_token {
415 eprintln!("app-server auth: bearer token required for HTTP routes.");
416 } else {
417 eprintln!("app-server auth: generated bearer token for this process.");
418 eprintln!(" Authorization: Bearer {token}");
419 eprintln!(" Pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN for a stable token.");
420 }
421 Ok(Some(token))
422}
423
424fn cors_layer(extra_origins: &[String]) -> CorsLayer {
425 let mut origins: Vec<HeaderValue> = DEFAULT_CORS_ORIGINS
426 .iter()
427 .filter_map(|origin| HeaderValue::from_str(origin).ok())
428 .collect();
429 for raw in extra_origins {
430 let trimmed = raw.trim();
431 if trimmed.is_empty() {
432 continue;
433 }
434 match HeaderValue::from_str(trimmed) {
435 Ok(value) if !origins.contains(&value) => origins.push(value),
436 Ok(_) => {}
437 Err(err) => {
438 eprintln!("warning: ignoring invalid app-server CORS origin `{trimmed}`: {err}")
439 }
440 }
441 }
442
443 CorsLayer::new()
444 .allow_origin(origins)
445 .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
446 .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
447}
448
449async fn require_app_server_token(
450 State(state): State<AppState>,
451 req: Request,
452 next: Next,
453) -> Response {
454 let Some(expected) = state.auth_token.as_deref() else {
455 return next.run(req).await;
456 };
457 let authorized = req
458 .headers()
459 .get(header::AUTHORIZATION)
460 .and_then(|value| value.to_str().ok())
461 .and_then(|raw| raw.strip_prefix("Bearer "))
462 .is_some_and(|token| token == expected);
463
464 if authorized {
465 next.run(req).await
466 } else {
467 (
468 StatusCode::UNAUTHORIZED,
469 Json(json!({
470 "error": {
471 "message": "app-server bearer token required",
472 "status": StatusCode::UNAUTHORIZED.as_u16(),
473 }
474 })),
475 )
476 .into_response()
477 }
478}
479
480fn params_or_object(params: Value) -> Value {
481 if params.is_null() { json!({}) } else { params }
482}
483
484fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
485 serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
486}
487
488fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
489 json!({
490 "jsonrpc": "2.0",
491 "id": id.unwrap_or(Value::Null),
492 "result": result
493 })
494}
495
496fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
497 json!({
498 "jsonrpc": "2.0",
499 "id": id.unwrap_or(Value::Null),
500 "error": {
501 "code": err.code,
502 "message": err.message,
503 "data": err.data
504 }
505 })
506}
507
508impl JsonRpcError {
509 fn parse_error(message: impl Into<String>) -> Self {
510 Self {
511 code: -32700,
512 message: message.into(),
513 data: None,
514 }
515 }
516
517 fn invalid_request(message: impl Into<String>) -> Self {
518 Self {
519 code: -32600,
520 message: message.into(),
521 data: None,
522 }
523 }
524
525 fn method_not_found(method: &str) -> Self {
526 Self {
527 code: -32601,
528 message: format!("unsupported method: {method}"),
529 data: None,
530 }
531 }
532
533 fn invalid_params(message: impl Into<String>) -> Self {
534 Self {
535 code: -32602,
536 message: message.into(),
537 data: None,
538 }
539 }
540
541 fn internal(message: impl Into<String>) -> Self {
542 Self {
543 code: -32603,
544 message: message.into(),
545 data: None,
546 }
547 }
548}
549
550async fn handle_thread_request(
551 state: &AppState,
552 req: ThreadRequest,
553) -> std::result::Result<ThreadResponse, JsonRpcError> {
554 let mut runtime = state.runtime.lock().await;
555 runtime
556 .handle_thread(req)
557 .await
558 .map_err(|err| JsonRpcError::internal(err.to_string()))
559}
560
561async fn handle_prompt_request(
562 state: &AppState,
563 req: PromptRequest,
564) -> std::result::Result<PromptResponse, JsonRpcError> {
565 let mut runtime = state.runtime.lock().await;
566 runtime
567 .handle_prompt(req, &CliRuntimeOverrides::default())
568 .await
569 .map_err(|err| JsonRpcError::internal(err.to_string()))
570}
571
572async fn dispatch_stdio_request(
573 state: &AppState,
574 method: &str,
575 params: Value,
576) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
577 let outcome = match method {
578 "healthz" | "app/healthz" => StdioDispatchResult {
579 result: json!({
580 "status": "ok",
581 "service": "deepseek-app-server",
582 "transport": "stdio"
583 }),
584 should_exit: false,
585 },
586 "capabilities" => StdioDispatchResult {
587 result: json!({
588 "transport": "stdio",
589 "families": ["thread/*", "app/*", "prompt/*"],
590 "methods": [
591 "healthz",
592 "thread/capabilities",
593 "thread/request",
594 "thread/create",
595 "thread/start",
596 "thread/resume",
597 "thread/fork",
598 "thread/list",
599 "thread/read",
600 "thread/set_name",
601 "thread/goal/set",
602 "thread/goal/get",
603 "thread/goal/clear",
604 "thread/archive",
605 "thread/unarchive",
606 "thread/message",
607 "app/capabilities",
608 "app/request",
609 "app/config/get",
610 "app/config/set",
611 "app/config/unset",
612 "app/config/list",
613 "app/models",
614 "app/thread_loaded_list",
615 "prompt/capabilities",
616 "prompt/request",
617 "prompt/run",
618 "shutdown"
619 ]
620 }),
621 should_exit: false,
622 },
623 "thread/capabilities" => StdioDispatchResult {
624 result: json!({
625 "methods": [
626 "thread/request",
627 "thread/create",
628 "thread/start",
629 "thread/resume",
630 "thread/fork",
631 "thread/list",
632 "thread/read",
633 "thread/set_name",
634 "thread/goal/set",
635 "thread/goal/get",
636 "thread/goal/clear",
637 "thread/archive",
638 "thread/unarchive",
639 "thread/message"
640 ]
641 }),
642 should_exit: false,
643 },
644 "thread/request" => {
645 let request: ThreadRequest = parse_params(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/create" => {
654 #[derive(Debug, Deserialize)]
655 struct CreateParams {
656 #[serde(default)]
657 metadata: Value,
658 }
659 let parsed: CreateParams = parse_params(params_or_object(params))?;
660 let response = handle_thread_request(
661 state,
662 ThreadRequest::Create {
663 metadata: parsed.metadata,
664 },
665 )
666 .await?;
667 StdioDispatchResult {
668 result: serde_json::to_value(response)
669 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
670 should_exit: false,
671 }
672 }
673 "thread/start" => {
674 let request = ThreadRequest::Start(parse_params(params_or_object(params))?);
675 let response = handle_thread_request(state, request).await?;
676 StdioDispatchResult {
677 result: serde_json::to_value(response)
678 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
679 should_exit: false,
680 }
681 }
682 "thread/resume" => {
683 let request = ThreadRequest::Resume(parse_params(params_or_object(params))?);
684 let response = handle_thread_request(state, request).await?;
685 StdioDispatchResult {
686 result: serde_json::to_value(response)
687 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
688 should_exit: false,
689 }
690 }
691 "thread/fork" => {
692 let request = ThreadRequest::Fork(parse_params(params_or_object(params))?);
693 let response = handle_thread_request(state, request).await?;
694 StdioDispatchResult {
695 result: serde_json::to_value(response)
696 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
697 should_exit: false,
698 }
699 }
700 "thread/list" => {
701 let request = ThreadRequest::List(parse_params(params_or_object(params))?);
702 let response = handle_thread_request(state, request).await?;
703 StdioDispatchResult {
704 result: serde_json::to_value(response)
705 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
706 should_exit: false,
707 }
708 }
709 "thread/read" => {
710 let request = ThreadRequest::Read(parse_params(params_or_object(params))?);
711 let response = handle_thread_request(state, request).await?;
712 StdioDispatchResult {
713 result: serde_json::to_value(response)
714 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
715 should_exit: false,
716 }
717 }
718 "thread/set_name" | "thread/set-name" => {
719 let request = ThreadRequest::SetName(parse_params(params_or_object(params))?);
720 let response = handle_thread_request(state, request).await?;
721 StdioDispatchResult {
722 result: serde_json::to_value(response)
723 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
724 should_exit: false,
725 }
726 }
727 "thread/goal/set" | "thread/goal_set" | "thread/goal-set" => {
728 let request = ThreadRequest::GoalSet(parse_params::<ThreadGoalSetParams>(
729 params_or_object(params),
730 )?);
731 let response = handle_thread_request(state, request).await?;
732 StdioDispatchResult {
733 result: serde_json::to_value(response)
734 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
735 should_exit: false,
736 }
737 }
738 "thread/goal/get" | "thread/goal_get" | "thread/goal-get" => {
739 let request = ThreadRequest::GoalGet(parse_params::<ThreadGoalGetParams>(
740 params_or_object(params),
741 )?);
742 let response = handle_thread_request(state, request).await?;
743 StdioDispatchResult {
744 result: serde_json::to_value(response)
745 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
746 should_exit: false,
747 }
748 }
749 "thread/goal/clear" | "thread/goal_clear" | "thread/goal-clear" => {
750 let request = ThreadRequest::GoalClear(parse_params::<ThreadGoalClearParams>(
751 params_or_object(params),
752 )?);
753 let response = handle_thread_request(state, request).await?;
754 StdioDispatchResult {
755 result: serde_json::to_value(response)
756 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
757 should_exit: false,
758 }
759 }
760 "thread/archive" => {
761 let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
762 let response = handle_thread_request(
763 state,
764 ThreadRequest::Archive {
765 thread_id: parsed.thread_id,
766 },
767 )
768 .await?;
769 StdioDispatchResult {
770 result: serde_json::to_value(response)
771 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
772 should_exit: false,
773 }
774 }
775 "thread/unarchive" => {
776 let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
777 let response = handle_thread_request(
778 state,
779 ThreadRequest::Unarchive {
780 thread_id: parsed.thread_id,
781 },
782 )
783 .await?;
784 StdioDispatchResult {
785 result: serde_json::to_value(response)
786 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
787 should_exit: false,
788 }
789 }
790 "thread/message" => {
791 let parsed: ThreadMessageParams = parse_params(params_or_object(params))?;
792 let response = handle_thread_request(
793 state,
794 ThreadRequest::Message {
795 thread_id: parsed.thread_id,
796 input: parsed.input,
797 },
798 )
799 .await?;
800 StdioDispatchResult {
801 result: serde_json::to_value(response)
802 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
803 should_exit: false,
804 }
805 }
806 "app/capabilities" => {
807 let response =
808 process_app_request(state, AppRequest::Capabilities, AppTransport::Stdio).await;
809 StdioDispatchResult {
810 result: serde_json::to_value(response)
811 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
812 should_exit: false,
813 }
814 }
815 "app/request" => {
816 let request: AppRequest = parse_params(params)?;
817 let response = process_app_request(state, request, AppTransport::Stdio).await;
818 StdioDispatchResult {
819 result: serde_json::to_value(response)
820 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
821 should_exit: false,
822 }
823 }
824 "app/config/get" => {
825 let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
826 let response = process_app_request(
827 state,
828 AppRequest::ConfigGet { key: parsed.key },
829 AppTransport::Stdio,
830 )
831 .await;
832 StdioDispatchResult {
833 result: serde_json::to_value(response)
834 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
835 should_exit: false,
836 }
837 }
838 "app/config/set" => {
839 let parsed: ConfigSetParams = parse_params(params_or_object(params))?;
840 let response = process_app_request(
841 state,
842 AppRequest::ConfigSet {
843 key: parsed.key,
844 value: parsed.value,
845 },
846 AppTransport::Stdio,
847 )
848 .await;
849 StdioDispatchResult {
850 result: serde_json::to_value(response)
851 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
852 should_exit: false,
853 }
854 }
855 "app/config/unset" => {
856 let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
857 let response = process_app_request(
858 state,
859 AppRequest::ConfigUnset { key: parsed.key },
860 AppTransport::Stdio,
861 )
862 .await;
863 StdioDispatchResult {
864 result: serde_json::to_value(response)
865 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
866 should_exit: false,
867 }
868 }
869 "app/config/list" => {
870 let response =
871 process_app_request(state, AppRequest::ConfigList, AppTransport::Stdio).await;
872 StdioDispatchResult {
873 result: serde_json::to_value(response)
874 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
875 should_exit: false,
876 }
877 }
878 "app/models" => {
879 let response =
880 process_app_request(state, AppRequest::Models, AppTransport::Stdio).await;
881 StdioDispatchResult {
882 result: serde_json::to_value(response)
883 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
884 should_exit: false,
885 }
886 }
887 "app/thread_loaded_list" | "app/thread-loaded-list" => {
888 let response =
889 process_app_request(state, AppRequest::ThreadLoadedList, AppTransport::Stdio).await;
890 StdioDispatchResult {
891 result: serde_json::to_value(response)
892 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
893 should_exit: false,
894 }
895 }
896 "prompt/capabilities" => StdioDispatchResult {
897 result: json!({
898 "methods": ["prompt/request", "prompt/run"]
899 }),
900 should_exit: false,
901 },
902 "prompt/request" | "prompt/run" => {
903 let request: PromptRequest = parse_params(params)?;
904 let response = handle_prompt_request(state, request).await?;
905 StdioDispatchResult {
906 result: serde_json::to_value(response)
907 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
908 should_exit: false,
909 }
910 }
911 "shutdown" => StdioDispatchResult {
912 result: json!({"ok": true, "status": "stopped"}),
913 should_exit: true,
914 },
915 _ => return Err(JsonRpcError::method_not_found(method)),
916 };
917 Ok(outcome)
918}
919
920async fn process_app_request(
921 state: &AppState,
922 req: AppRequest,
923 transport: AppTransport,
924) -> AppResponse {
925 match req {
926 AppRequest::Capabilities => AppResponse {
927 ok: true,
928 data: json!({
929 "routes": ["/thread", "/app", "/prompt", "/tool", "/jobs", "/mcp/startup"],
930 "config": ["get", "set", "unset", "list"],
931 "events": ["response_start", "response_delta", "response_end", "tool_call_start", "tool_call_result", "mcp_startup_update", "mcp_startup_complete"],
932 "transport": "stdio+http",
933 "config_path": state.config_path.as_ref().map(|p| p.display().to_string()),
934 }),
935 events: Vec::new(),
936 },
937 AppRequest::ConfigGet { key } => {
938 let cfg = state.config.read().await;
939 let value = match transport {
940 AppTransport::Http => cfg.get_display_value(&key),
941 AppTransport::Stdio => cfg.get_value(&key),
942 };
943 AppResponse {
944 ok: true,
945 data: json!({ "key": key, "value": value }),
946 events: Vec::new(),
947 }
948 }
949 AppRequest::ConfigSet { key, value } => {
950 let mut cfg = state.config.write().await;
951 let result = cfg.set_value(&key, &value);
952 let ok = result.is_ok();
953 let message = result.err().map(|e| e.to_string());
954 let snapshot = cfg.clone();
955 drop(cfg);
956 if let Err(e) = persist_config(state, snapshot).await {
957 tracing::error!("Failed to persist config after set: {e}");
958 }
959 AppResponse {
960 ok,
961 data: json!({ "key": key, "value": value, "error": message }),
962 events: Vec::new(),
963 }
964 }
965 AppRequest::ConfigUnset { key } => {
966 let mut cfg = state.config.write().await;
967 let result = cfg.unset_value(&key);
968 let ok = result.is_ok();
969 let message = result.err().map(|e| e.to_string());
970 let snapshot = cfg.clone();
971 drop(cfg);
972 if let Err(e) = persist_config(state, snapshot).await {
973 tracing::error!("Failed to persist config after unset: {e}");
974 }
975 AppResponse {
976 ok,
977 data: json!({ "key": key, "error": message }),
978 events: Vec::new(),
979 }
980 }
981 AppRequest::ConfigList => {
982 let cfg = state.config.read().await;
983 AppResponse {
984 ok: true,
985 data: json!({ "values": cfg.list_values() }),
986 events: Vec::new(),
987 }
988 }
989 AppRequest::Models => AppResponse {
990 ok: true,
991 data: json!({ "models": state.registry.list() }),
992 events: Vec::new(),
993 },
994 AppRequest::ThreadLoadedList => {
995 let mut runtime = state.runtime.lock().await;
996 let response = runtime
997 .handle_thread(codewhale_protocol::ThreadRequest::List(
998 codewhale_protocol::ThreadListParams {
999 include_archived: false,
1000 limit: Some(50),
1001 },
1002 ))
1003 .await;
1004 match response {
1005 Ok(thread_resp) => AppResponse {
1006 ok: true,
1007 data: json!({ "threads": thread_resp.threads }),
1008 events: thread_resp.events,
1009 },
1010 Err(err) => AppResponse {
1011 ok: false,
1012 data: json!({ "error": err.to_string() }),
1013 events: Vec::new(),
1014 },
1015 }
1016 }
1017 AppRequest::SubmitUserInput {
1018 request_id,
1019 answers,
1020 } => {
1021 let mut pending = state.pending_user_input.lock().await;
1027 if pending.contains_key(&request_id) {
1028 return AppResponse {
1029 ok: false,
1030 data: json!({
1031 "error": "request_id already resolved",
1032 "request_id": request_id,
1033 }),
1034 events: Vec::new(),
1035 };
1036 }
1037 pending.insert(request_id.clone(), answers);
1038 AppResponse {
1039 ok: true,
1040 data: json!({ "request_id": request_id, "resolved": true }),
1041 events: Vec::new(),
1042 }
1043 }
1044 }
1045}
1046
1047async fn persist_config(state: &AppState, config: codewhale_config::ConfigToml) -> Result<()> {
1048 if state.config_path.is_none() {
1049 return Ok(());
1050 }
1051 let mut store = ConfigStore::load(state.config_path.clone())?;
1052 store.config = config;
1053 store.save()
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058 use super::*;
1059 use axum::body::{Body, to_bytes};
1060 use codewhale_protocol::AppRequest;
1061 use std::fs;
1062 use tower::ServiceExt;
1063
1064 fn app_with_config(auth_token: Option<&str>) -> (Router, tempfile::TempDir) {
1065 let tmp = tempfile::tempdir().expect("tempdir");
1066 let config_path = tmp.path().join("config.toml");
1067 fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1068 let state = build_state(
1069 Some(config_path),
1070 auth_token.map(std::string::ToString::to_string),
1071 )
1072 .expect("state");
1073 (app_router(state, &[]), tmp)
1074 }
1075
1076 async fn response_body_json(response: Response) -> Value {
1077 let bytes = to_bytes(response.into_body(), usize::MAX)
1078 .await
1079 .expect("body bytes");
1080 serde_json::from_slice(&bytes).expect("json response")
1081 }
1082
1083 #[tokio::test]
1084 async fn http_app_routes_require_bearer_token_when_auth_enabled() {
1085 let (app, _tmp) = app_with_config(Some("test-token"));
1086 let response = app
1087 .oneshot(
1088 Request::builder()
1089 .method(Method::POST)
1090 .uri("/app")
1091 .header(header::CONTENT_TYPE, "application/json")
1092 .body(Body::from(
1093 serde_json::to_vec(&AppRequest::ConfigGet {
1094 key: "api_key".to_string(),
1095 })
1096 .expect("request json"),
1097 ))
1098 .expect("request"),
1099 )
1100 .await
1101 .expect("response");
1102
1103 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1104 }
1105
1106 #[tokio::test]
1107 async fn http_config_get_redacts_sensitive_values_after_auth() {
1108 let (app, _tmp) = app_with_config(Some("test-token"));
1109 let response = app
1110 .oneshot(
1111 Request::builder()
1112 .method(Method::POST)
1113 .uri("/app")
1114 .header(header::AUTHORIZATION, "Bearer test-token")
1115 .header(header::CONTENT_TYPE, "application/json")
1116 .body(Body::from(
1117 serde_json::to_vec(&AppRequest::ConfigGet {
1118 key: "api_key".to_string(),
1119 })
1120 .expect("request json"),
1121 ))
1122 .expect("request"),
1123 )
1124 .await
1125 .expect("response");
1126
1127 assert_eq!(response.status(), StatusCode::OK);
1128 let body = response_body_json(response).await;
1129 assert_eq!(body["data"]["value"], "sk-d***cret");
1130 }
1131
1132 #[tokio::test]
1133 async fn cors_does_not_allow_arbitrary_origins() {
1134 let (app, _tmp) = app_with_config(Some("test-token"));
1135 let response = app
1136 .oneshot(
1137 Request::builder()
1138 .method(Method::GET)
1139 .uri("/healthz")
1140 .header(header::ORIGIN, "https://attacker.example")
1141 .body(Body::empty())
1142 .expect("request"),
1143 )
1144 .await
1145 .expect("response");
1146
1147 assert_eq!(response.status(), StatusCode::OK);
1148 assert!(
1149 response
1150 .headers()
1151 .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
1152 .is_none()
1153 );
1154 }
1155
1156 #[tokio::test]
1157 async fn build_state_loads_permissions_into_runtime_policy() {
1158 let tmp = tempfile::tempdir().expect("tempdir");
1159 let config_path = tmp.path().join("config.toml");
1160 fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1161 fs::write(
1162 tmp.path().join("permissions.toml"),
1163 r#"
1164 [[rules]]
1165 tool = "exec_shell"
1166 command = "cargo test"
1167 "#,
1168 )
1169 .expect("write permissions");
1170
1171 let state = build_state(Some(config_path), None).expect("state");
1172 let runtime = state.runtime.lock().await;
1173 let decision = runtime
1174 .exec_policy
1175 .check(codewhale_execpolicy::ExecPolicyContext {
1176 command: "cargo test --workspace",
1177 cwd: "/workspace",
1178 tool: Some("exec_shell"),
1179 path: None,
1180 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1181 sandbox_mode: Some("workspace-write"),
1182 })
1183 .expect("policy check");
1184
1185 assert!(decision.allow);
1186 assert!(decision.requires_approval);
1187 assert_eq!(
1188 decision.matched_rule.as_deref(),
1189 Some("tool=exec_shell command=cargo test")
1190 );
1191 }
1192
1193 #[test]
1194 fn non_loopback_bind_without_auth_fails_fast() {
1195 let options = AppServerOptions {
1196 listen: "0.0.0.0:8787".parse().expect("socket addr"),
1197 config_path: None,
1198 auth_token: None,
1199 insecure_no_auth: false,
1200 cors_origins: Vec::new(),
1201 };
1202
1203 let err =
1204 resolve_auth_token(&options).expect_err("non-loopback generated auth should fail");
1205 assert!(err.to_string().contains("without explicit auth token"));
1206 }
1207
1208 #[tokio::test]
1209 async fn stdio_transport_keeps_raw_config_get_for_legacy_clients() {
1210 let tmp = tempfile::tempdir().expect("tempdir");
1211 let config_path = tmp.path().join("config.toml");
1212 fs::write(&config_path, "").expect("write config");
1213 let state = build_state(Some(config_path), None).expect("state");
1214 {
1215 let mut cfg = state.config.write().await;
1216 cfg.api_key = Some("sk-deepseek-secret".to_string());
1217 }
1218
1219 let response = process_app_request(
1220 &state,
1221 AppRequest::ConfigGet {
1222 key: "api_key".to_string(),
1223 },
1224 AppTransport::Stdio,
1225 )
1226 .await;
1227
1228 assert_eq!(response.data["value"], "sk-deepseek-secret");
1229 }
1230
1231 #[tokio::test]
1232 async fn stdio_thread_goal_methods_round_trip_persisted_goal() {
1233 let tmp = tempfile::tempdir().expect("tempdir");
1234 let config_path = tmp.path().join("config.toml");
1235 fs::write(&config_path, "").expect("write config");
1236 let state = build_state(Some(config_path), None).expect("state");
1237
1238 let capabilities = dispatch_stdio_request(&state, "thread/capabilities", json!({}))
1239 .await
1240 .expect("thread capabilities");
1241 assert!(
1242 capabilities.result["methods"]
1243 .as_array()
1244 .expect("methods")
1245 .iter()
1246 .any(|method| method == "thread/goal/set")
1247 );
1248
1249 let started = dispatch_stdio_request(&state, "thread/start", json!({}))
1250 .await
1251 .expect("start thread");
1252 let thread_id = started.result["thread_id"]
1253 .as_str()
1254 .expect("thread id")
1255 .to_string();
1256
1257 let set = dispatch_stdio_request(
1258 &state,
1259 "thread/goal/set",
1260 json!({
1261 "thread_id": thread_id,
1262 "objective": "Release 0.8.59",
1263 "token_budget": 59000
1264 }),
1265 )
1266 .await
1267 .expect("set goal");
1268 assert_eq!(set.result["status"], "ok");
1269 assert_eq!(set.result["goal"]["objective"], "Release 0.8.59");
1270 assert_eq!(set.result["goal"]["status"], "active");
1271
1272 let got = dispatch_stdio_request(
1273 &state,
1274 "thread/goal/get",
1275 json!({
1276 "thread_id": thread_id
1277 }),
1278 )
1279 .await
1280 .expect("get goal");
1281 assert_eq!(got.result["goal"]["token_budget"], 59000);
1282
1283 let cleared = dispatch_stdio_request(
1284 &state,
1285 "thread/goal/clear",
1286 json!({
1287 "thread_id": thread_id
1288 }),
1289 )
1290 .await
1291 .expect("clear goal");
1292 assert_eq!(cleared.result["status"], "cleared");
1293 assert_eq!(cleared.result["data"]["cleared"], true);
1294 }
1295
1296 const EXPECTED_CAPABILITY_METHODS: &[&str] = &[
1305 "healthz",
1306 "thread/capabilities",
1307 "thread/request",
1308 "thread/create",
1309 "thread/start",
1310 "thread/resume",
1311 "thread/fork",
1312 "thread/list",
1313 "thread/read",
1314 "thread/set_name",
1315 "thread/goal/set",
1316 "thread/goal/get",
1317 "thread/goal/clear",
1318 "thread/archive",
1319 "thread/unarchive",
1320 "thread/message",
1321 "app/capabilities",
1322 "app/request",
1323 "app/config/get",
1324 "app/config/set",
1325 "app/config/unset",
1326 "app/config/list",
1327 "app/models",
1328 "app/thread_loaded_list",
1329 "prompt/capabilities",
1330 "prompt/request",
1331 "prompt/run",
1332 "shutdown",
1333 ];
1334
1335 fn capability_test_state() -> (AppState, tempfile::TempDir) {
1336 let tmp = tempfile::tempdir().expect("tempdir");
1337 let config_path = tmp.path().join("config.toml");
1338 fs::write(&config_path, "").expect("write config");
1339 let state = build_state(Some(config_path), None).expect("state");
1340 (state, tmp)
1341 }
1342
1343 #[tokio::test]
1344 async fn capabilities_method_set_is_stable() {
1345 let (state, _tmp) = capability_test_state();
1346 let caps = dispatch_stdio_request(&state, "capabilities", json!({}))
1347 .await
1348 .expect("capabilities dispatch");
1349 let methods: Vec<String> = caps.result["methods"]
1350 .as_array()
1351 .expect("methods array")
1352 .iter()
1353 .map(|m| m.as_str().expect("method string").to_string())
1354 .collect();
1355 assert_eq!(
1356 methods, EXPECTED_CAPABILITY_METHODS,
1357 "app-server stdio capability set drifted; update the dispatcher, this \
1358 snapshot, and docs/RUNTIME_API.md together"
1359 );
1360 }
1361
1362 #[tokio::test]
1363 async fn every_advertised_capability_is_dispatchable() {
1364 let (state, _tmp) = capability_test_state();
1365 for method in EXPECTED_CAPABILITY_METHODS {
1369 if let Err(err) = dispatch_stdio_request(&state, method, json!({})).await {
1370 assert_ne!(
1371 err.code,
1372 JsonRpcError::method_not_found(method).code,
1373 "advertised capability `{method}` is not dispatchable"
1374 );
1375 }
1376 }
1377 }
1378
1379 #[test]
1382 fn auth_token_empty_string_fails() {
1383 let options = AppServerOptions {
1384 listen: "127.0.0.1:0".parse().expect("addr"),
1385 config_path: None,
1386 auth_token: Some(" ".to_string()),
1387 insecure_no_auth: false,
1388 cors_origins: Vec::new(),
1389 };
1390 let err = resolve_auth_token(&options).expect_err("empty token should fail");
1391 assert!(err.to_string().contains("cannot be empty"));
1392 }
1393
1394 #[test]
1395 fn auth_token_generated_when_none_provided() {
1396 let options = AppServerOptions {
1397 listen: "127.0.0.1:0".parse().expect("addr"),
1398 config_path: None,
1399 auth_token: None,
1400 insecure_no_auth: false,
1401 cors_origins: Vec::new(),
1402 };
1403 let token = resolve_auth_token(&options).unwrap();
1404 assert!(token.is_some());
1405 assert!(token.unwrap().starts_with("cwapp_"));
1406 }
1407
1408 #[test]
1409 fn auth_token_explicit_is_preserved() {
1410 let options = AppServerOptions {
1411 listen: "127.0.0.1:0".parse().expect("addr"),
1412 config_path: None,
1413 auth_token: Some("my-secret".to_string()),
1414 insecure_no_auth: false,
1415 cors_origins: Vec::new(),
1416 };
1417 let token = resolve_auth_token(&options).unwrap();
1418 assert_eq!(token.as_deref(), Some("my-secret"));
1419 }
1420
1421 #[test]
1422 fn auth_token_explicit_allows_non_loopback_bind() {
1423 let options = AppServerOptions {
1424 listen: "0.0.0.0:8787".parse().expect("socket addr"),
1425 config_path: None,
1426 auth_token: Some("my-secret".to_string()),
1427 insecure_no_auth: false,
1428 cors_origins: Vec::new(),
1429 };
1430 let token = resolve_auth_token(&options).unwrap();
1431 assert_eq!(token.as_deref(), Some("my-secret"));
1432 }
1433
1434 #[test]
1435 fn insecure_no_auth_on_loopback_returns_none() {
1436 let options = AppServerOptions {
1437 listen: "127.0.0.1:0".parse().expect("addr"),
1438 config_path: None,
1439 auth_token: None,
1440 insecure_no_auth: true,
1441 cors_origins: Vec::new(),
1442 };
1443 let token = resolve_auth_token(&options).unwrap();
1444 assert!(token.is_none());
1445 }
1446
1447 #[test]
1448 fn insecure_no_auth_on_non_loopback_fails_fast() {
1449 let options = AppServerOptions {
1450 listen: "0.0.0.0:8787".parse().expect("socket addr"),
1451 config_path: None,
1452 auth_token: None,
1453 insecure_no_auth: true,
1454 cors_origins: Vec::new(),
1455 };
1456
1457 let err = resolve_auth_token(&options).expect_err("non-loopback unauth should fail");
1458 assert!(
1459 err.to_string()
1460 .contains("refusing unauthenticated app-server bind")
1461 );
1462 }
1463
1464 #[test]
1467 fn cors_layer_includes_default_origins() {
1468 let layer = cors_layer(&[]);
1469 let _ = layer;
1471 }
1472
1473 #[test]
1474 fn cors_layer_adds_extra_origins() {
1475 let extras = vec!["https://example.com".to_string()];
1476 let layer = cors_layer(&extras);
1477 let _ = layer;
1478 }
1479
1480 #[test]
1481 fn cors_layer_skips_empty_origins() {
1482 let extras = vec!["".to_string(), " ".to_string()];
1483 let layer = cors_layer(&extras);
1484 let _ = layer;
1485 }
1486
1487 #[test]
1490 fn params_or_object_returns_object_for_null() {
1491 let result = params_or_object(Value::Null);
1492 assert_eq!(result, json!({}));
1493 }
1494
1495 #[test]
1496 fn params_or_object_passthrough_for_non_null() {
1497 let input = json!({"key": "value"});
1498 let result = params_or_object(input.clone());
1499 assert_eq!(result, input);
1500 }
1501
1502 #[test]
1503 fn jsonrpc_result_format() {
1504 let result = jsonrpc_result(Some(json!(1)), json!({"ok": true}));
1505 assert_eq!(result["jsonrpc"], "2.0");
1506 assert_eq!(result["id"], 1);
1507 assert_eq!(result["result"]["ok"], true);
1508 }
1509
1510 #[test]
1511 fn jsonrpc_result_null_id() {
1512 let result = jsonrpc_result(None, json!(null));
1513 assert_eq!(result["id"], Value::Null);
1514 }
1515
1516 #[test]
1517 fn jsonrpc_error_format() {
1518 let err = jsonrpc_error(Some(json!(2)), JsonRpcError::internal("oops"));
1519 assert_eq!(err["jsonrpc"], "2.0");
1520 assert_eq!(err["id"], 2);
1521 assert_eq!(err["error"]["code"], -32603);
1522 assert_eq!(err["error"]["message"], "oops");
1523 }
1524
1525 #[test]
1526 fn jsonrpc_error_codes() {
1527 assert_eq!(JsonRpcError::parse_error("").code, -32700);
1528 assert_eq!(JsonRpcError::invalid_request("").code, -32600);
1529 assert_eq!(JsonRpcError::method_not_found("x").code, -32601);
1530 assert_eq!(JsonRpcError::invalid_params("").code, -32602);
1531 assert_eq!(JsonRpcError::internal("").code, -32603);
1532 }
1533
1534 #[test]
1537 fn app_server_options_debug_does_not_leak_token() {
1538 let options = AppServerOptions {
1539 listen: "127.0.0.1:8080".parse().expect("addr"),
1540 config_path: None,
1541 auth_token: Some("secret-token".to_string()),
1542 insecure_no_auth: false,
1543 cors_origins: vec!["https://example.com".to_string()],
1544 };
1545 let debug = format!("{options:?}");
1546 assert!(!debug.contains("secret-token"));
1547 assert!(debug.contains("<redacted>"));
1548 assert!(debug.contains("8080"));
1549 }
1550
1551 #[test]
1554 fn default_cors_origins_include_common_dev_ports() {
1555 assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:3000"));
1556 assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:5173"));
1557 assert!(DEFAULT_CORS_ORIGINS.contains(&"tauri://localhost"));
1558 }
1559}