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 has_explicit_config_path = config_path.is_some();
342 let store = ConfigStore::load(config_path)?;
343 let config_path = has_explicit_config_path.then(|| store.path().to_path_buf());
344 let config = store.config.clone();
345 let exec_policy = store.exec_policy_engine();
346 let registry = ModelRegistry::default();
347
348 let state_db_path = config_path
349 .as_ref()
350 .and_then(|p| p.parent().map(|parent| parent.join("state.db")));
351 let state_store = StateStore::open(state_db_path)?;
352
353 let mut hooks = HookDispatcher::default();
354 hooks.add_sink(Arc::new(StdoutHookSink));
355 let hook_log_path = config_path
356 .as_ref()
357 .and_then(|p| p.parent().map(|parent| parent.join("events.jsonl")))
358 .unwrap_or_else(|| PathBuf::from(".deepseek/events.jsonl"));
359 hooks.add_sink(Arc::new(JsonlHookSink::new(hook_log_path)));
360
361 if let Some(socket_path) = config
362 .hook_sinks
363 .as_ref()
364 .and_then(|sinks| sinks.unix_socket_path.as_ref())
365 .filter(|path| !path.as_os_str().is_empty())
366 {
367 hooks.add_sink(Arc::new(UnixSocketHookSink::new(socket_path.clone())));
368 }
369
370 let runtime = Runtime::new(
371 config.clone(),
372 registry.clone(),
373 state_store,
374 Arc::new(ToolRegistry::default()),
375 Arc::new(McpManager::default()),
376 exec_policy,
377 hooks,
378 );
379
380 Ok(AppState {
381 config_path,
382 config: Arc::new(RwLock::new(config)),
383 runtime: Arc::new(Mutex::new(runtime)),
384 registry,
385 auth_token,
386 pending_user_input: Arc::new(Mutex::new(std::collections::HashMap::new())),
387 })
388}
389
390fn resolve_auth_token(options: &AppServerOptions) -> Result<Option<String>> {
391 let configured = options.auth_token.as_ref().map(|token| token.trim());
392 if let Some(token) = configured
393 && token.is_empty()
394 {
395 bail!("app-server auth token cannot be empty");
396 }
397 let has_explicit_token = configured.is_some();
398
399 if options.insecure_no_auth {
400 if !options.listen.ip().is_loopback() {
401 bail!("refusing unauthenticated app-server bind on non-loopback address");
402 }
403 eprintln!("warning: app-server HTTP auth disabled by --insecure-no-auth");
404 return Ok(None);
405 }
406
407 if !has_explicit_token && !options.listen.ip().is_loopback() {
408 bail!(
409 "refusing non-loopback app-server bind without explicit auth token; pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN"
410 );
411 }
412
413 let token = configured
414 .map(str::to_string)
415 .unwrap_or_else(|| format!("cwapp_{}", Uuid::new_v4().simple()));
416 for line in app_server_auth_status_lines(has_explicit_token) {
417 eprintln!("{line}");
418 }
419 Ok(Some(token))
420}
421
422fn app_server_auth_status_lines(has_explicit_token: bool) -> Vec<&'static str> {
423 if has_explicit_token {
424 return vec!["app-server auth: bearer token required for HTTP routes."];
425 }
426 vec![
427 "app-server auth: generated bearer token for this process (not printed).",
428 " Pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN when another client needs to connect.",
429 ]
430}
431
432fn cors_layer(extra_origins: &[String]) -> CorsLayer {
433 let mut origins: Vec<HeaderValue> = DEFAULT_CORS_ORIGINS
434 .iter()
435 .filter_map(|origin| HeaderValue::from_str(origin).ok())
436 .collect();
437 for raw in extra_origins {
438 let trimmed = raw.trim();
439 if trimmed.is_empty() {
440 continue;
441 }
442 match HeaderValue::from_str(trimmed) {
443 Ok(value) if !origins.contains(&value) => origins.push(value),
444 Ok(_) => {}
445 Err(err) => {
446 eprintln!("warning: ignoring invalid app-server CORS origin `{trimmed}`: {err}")
447 }
448 }
449 }
450
451 CorsLayer::new()
452 .allow_origin(origins)
453 .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
454 .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
455}
456
457async fn require_app_server_token(
458 State(state): State<AppState>,
459 req: Request,
460 next: Next,
461) -> Response {
462 let Some(expected) = state.auth_token.as_deref() else {
463 return next.run(req).await;
464 };
465 let authorized = req
466 .headers()
467 .get(header::AUTHORIZATION)
468 .and_then(|value| value.to_str().ok())
469 .and_then(|raw| raw.strip_prefix("Bearer "))
470 .is_some_and(|token| token == expected);
471
472 if authorized {
473 next.run(req).await
474 } else {
475 (
476 StatusCode::UNAUTHORIZED,
477 Json(json!({
478 "error": {
479 "message": "app-server bearer token required",
480 "status": StatusCode::UNAUTHORIZED.as_u16(),
481 }
482 })),
483 )
484 .into_response()
485 }
486}
487
488fn params_or_object(params: Value) -> Value {
489 if params.is_null() { json!({}) } else { params }
490}
491
492fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
493 serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
494}
495
496fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
497 json!({
498 "jsonrpc": "2.0",
499 "id": id.unwrap_or(Value::Null),
500 "result": result
501 })
502}
503
504fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
505 json!({
506 "jsonrpc": "2.0",
507 "id": id.unwrap_or(Value::Null),
508 "error": {
509 "code": err.code,
510 "message": err.message,
511 "data": err.data
512 }
513 })
514}
515
516impl JsonRpcError {
517 fn parse_error(message: impl Into<String>) -> Self {
518 Self {
519 code: -32700,
520 message: message.into(),
521 data: None,
522 }
523 }
524
525 fn invalid_request(message: impl Into<String>) -> Self {
526 Self {
527 code: -32600,
528 message: message.into(),
529 data: None,
530 }
531 }
532
533 fn method_not_found(method: &str) -> Self {
534 Self {
535 code: -32601,
536 message: format!("unsupported method: {method}"),
537 data: None,
538 }
539 }
540
541 fn invalid_params(message: impl Into<String>) -> Self {
542 Self {
543 code: -32602,
544 message: message.into(),
545 data: None,
546 }
547 }
548
549 fn internal(message: impl Into<String>) -> Self {
550 Self {
551 code: -32603,
552 message: message.into(),
553 data: None,
554 }
555 }
556}
557
558async fn handle_thread_request(
559 state: &AppState,
560 req: ThreadRequest,
561) -> std::result::Result<ThreadResponse, JsonRpcError> {
562 let mut runtime = state.runtime.lock().await;
563 runtime
564 .handle_thread(req)
565 .await
566 .map_err(|err| JsonRpcError::internal(err.to_string()))
567}
568
569async fn handle_prompt_request(
570 state: &AppState,
571 req: PromptRequest,
572) -> std::result::Result<PromptResponse, JsonRpcError> {
573 let mut runtime = state.runtime.lock().await;
574 runtime
575 .handle_prompt(req, &CliRuntimeOverrides::default())
576 .await
577 .map_err(|err| JsonRpcError::internal(err.to_string()))
578}
579
580async fn dispatch_stdio_request(
581 state: &AppState,
582 method: &str,
583 params: Value,
584) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
585 let outcome = match method {
586 "healthz" | "app/healthz" => StdioDispatchResult {
587 result: json!({
588 "status": "ok",
589 "service": "deepseek-app-server",
590 "transport": "stdio"
591 }),
592 should_exit: false,
593 },
594 "capabilities" => StdioDispatchResult {
595 result: json!({
596 "transport": "stdio",
597 "families": ["thread/*", "app/*", "prompt/*"],
598 "methods": [
599 "healthz",
600 "thread/capabilities",
601 "thread/request",
602 "thread/create",
603 "thread/start",
604 "thread/resume",
605 "thread/fork",
606 "thread/list",
607 "thread/read",
608 "thread/set_name",
609 "thread/goal/set",
610 "thread/goal/get",
611 "thread/goal/clear",
612 "thread/archive",
613 "thread/unarchive",
614 "thread/message",
615 "app/capabilities",
616 "app/request",
617 "app/config/get",
618 "app/config/set",
619 "app/config/unset",
620 "app/config/list",
621 "app/models",
622 "app/thread_loaded_list",
623 "prompt/capabilities",
624 "prompt/request",
625 "prompt/run",
626 "shutdown"
627 ]
628 }),
629 should_exit: false,
630 },
631 "thread/capabilities" => StdioDispatchResult {
632 result: json!({
633 "methods": [
634 "thread/request",
635 "thread/create",
636 "thread/start",
637 "thread/resume",
638 "thread/fork",
639 "thread/list",
640 "thread/read",
641 "thread/set_name",
642 "thread/goal/set",
643 "thread/goal/get",
644 "thread/goal/clear",
645 "thread/archive",
646 "thread/unarchive",
647 "thread/message"
648 ]
649 }),
650 should_exit: false,
651 },
652 "thread/request" => {
653 let request: ThreadRequest = parse_params(params)?;
654 let response = handle_thread_request(state, request).await?;
655 StdioDispatchResult {
656 result: serde_json::to_value(response)
657 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
658 should_exit: false,
659 }
660 }
661 "thread/create" => {
662 #[derive(Debug, Deserialize)]
663 struct CreateParams {
664 #[serde(default)]
665 metadata: Value,
666 }
667 let parsed: CreateParams = parse_params(params_or_object(params))?;
668 let response = handle_thread_request(
669 state,
670 ThreadRequest::Create {
671 metadata: parsed.metadata,
672 },
673 )
674 .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/start" => {
682 let request = ThreadRequest::Start(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/resume" => {
691 let request = ThreadRequest::Resume(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/fork" => {
700 let request = ThreadRequest::Fork(parse_params(params_or_object(params))?);
701 let response = handle_thread_request(state, request).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 "thread/list" => {
709 let request = ThreadRequest::List(parse_params(params_or_object(params))?);
710 let response = handle_thread_request(state, request).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 "thread/read" => {
718 let request = ThreadRequest::Read(parse_params(params_or_object(params))?);
719 let response = handle_thread_request(state, request).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 "thread/set_name" | "thread/set-name" => {
727 let request = ThreadRequest::SetName(parse_params(params_or_object(params))?);
728 let response = handle_thread_request(state, request).await?;
729 StdioDispatchResult {
730 result: serde_json::to_value(response)
731 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
732 should_exit: false,
733 }
734 }
735 "thread/goal/set" | "thread/goal_set" | "thread/goal-set" => {
736 let request = ThreadRequest::GoalSet(parse_params::<ThreadGoalSetParams>(
737 params_or_object(params),
738 )?);
739 let response = handle_thread_request(state, request).await?;
740 StdioDispatchResult {
741 result: serde_json::to_value(response)
742 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
743 should_exit: false,
744 }
745 }
746 "thread/goal/get" | "thread/goal_get" | "thread/goal-get" => {
747 let request = ThreadRequest::GoalGet(parse_params::<ThreadGoalGetParams>(
748 params_or_object(params),
749 )?);
750 let response = handle_thread_request(state, request).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 "thread/goal/clear" | "thread/goal_clear" | "thread/goal-clear" => {
758 let request = ThreadRequest::GoalClear(parse_params::<ThreadGoalClearParams>(
759 params_or_object(params),
760 )?);
761 let response = handle_thread_request(state, request).await?;
762 StdioDispatchResult {
763 result: serde_json::to_value(response)
764 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
765 should_exit: false,
766 }
767 }
768 "thread/archive" => {
769 let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
770 let response = handle_thread_request(
771 state,
772 ThreadRequest::Archive {
773 thread_id: parsed.thread_id,
774 },
775 )
776 .await?;
777 StdioDispatchResult {
778 result: serde_json::to_value(response)
779 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
780 should_exit: false,
781 }
782 }
783 "thread/unarchive" => {
784 let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
785 let response = handle_thread_request(
786 state,
787 ThreadRequest::Unarchive {
788 thread_id: parsed.thread_id,
789 },
790 )
791 .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 "thread/message" => {
799 let parsed: ThreadMessageParams = parse_params(params_or_object(params))?;
800 let response = handle_thread_request(
801 state,
802 ThreadRequest::Message {
803 thread_id: parsed.thread_id,
804 input: parsed.input,
805 },
806 )
807 .await?;
808 StdioDispatchResult {
809 result: serde_json::to_value(response)
810 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
811 should_exit: false,
812 }
813 }
814 "app/capabilities" => {
815 let response =
816 process_app_request(state, AppRequest::Capabilities, AppTransport::Stdio).await;
817 StdioDispatchResult {
818 result: serde_json::to_value(response)
819 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
820 should_exit: false,
821 }
822 }
823 "app/request" => {
824 let request: AppRequest = parse_params(params)?;
825 let response = process_app_request(state, request, AppTransport::Stdio).await;
826 StdioDispatchResult {
827 result: serde_json::to_value(response)
828 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
829 should_exit: false,
830 }
831 }
832 "app/config/get" => {
833 let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
834 let response = process_app_request(
835 state,
836 AppRequest::ConfigGet { key: parsed.key },
837 AppTransport::Stdio,
838 )
839 .await;
840 StdioDispatchResult {
841 result: serde_json::to_value(response)
842 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
843 should_exit: false,
844 }
845 }
846 "app/config/set" => {
847 let parsed: ConfigSetParams = parse_params(params_or_object(params))?;
848 let response = process_app_request(
849 state,
850 AppRequest::ConfigSet {
851 key: parsed.key,
852 value: parsed.value,
853 },
854 AppTransport::Stdio,
855 )
856 .await;
857 StdioDispatchResult {
858 result: serde_json::to_value(response)
859 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
860 should_exit: false,
861 }
862 }
863 "app/config/unset" => {
864 let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
865 let response = process_app_request(
866 state,
867 AppRequest::ConfigUnset { key: parsed.key },
868 AppTransport::Stdio,
869 )
870 .await;
871 StdioDispatchResult {
872 result: serde_json::to_value(response)
873 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
874 should_exit: false,
875 }
876 }
877 "app/config/list" => {
878 let response =
879 process_app_request(state, AppRequest::ConfigList, AppTransport::Stdio).await;
880 StdioDispatchResult {
881 result: serde_json::to_value(response)
882 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
883 should_exit: false,
884 }
885 }
886 "app/models" => {
887 let response =
888 process_app_request(state, AppRequest::Models, AppTransport::Stdio).await;
889 StdioDispatchResult {
890 result: serde_json::to_value(response)
891 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
892 should_exit: false,
893 }
894 }
895 "app/thread_loaded_list" | "app/thread-loaded-list" => {
896 let response =
897 process_app_request(state, AppRequest::ThreadLoadedList, AppTransport::Stdio).await;
898 StdioDispatchResult {
899 result: serde_json::to_value(response)
900 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
901 should_exit: false,
902 }
903 }
904 "prompt/capabilities" => StdioDispatchResult {
905 result: json!({
906 "methods": ["prompt/request", "prompt/run"]
907 }),
908 should_exit: false,
909 },
910 "prompt/request" | "prompt/run" => {
911 let request: PromptRequest = parse_params(params)?;
912 let response = handle_prompt_request(state, request).await?;
913 StdioDispatchResult {
914 result: serde_json::to_value(response)
915 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
916 should_exit: false,
917 }
918 }
919 "shutdown" => StdioDispatchResult {
920 result: json!({"ok": true, "status": "stopped"}),
921 should_exit: true,
922 },
923 _ => return Err(JsonRpcError::method_not_found(method)),
924 };
925 Ok(outcome)
926}
927
928async fn process_app_request(
929 state: &AppState,
930 req: AppRequest,
931 transport: AppTransport,
932) -> AppResponse {
933 match req {
934 AppRequest::Capabilities => AppResponse {
935 ok: true,
936 data: json!({
937 "routes": ["/thread", "/app", "/prompt", "/tool", "/jobs", "/mcp/startup"],
938 "config": ["get", "set", "unset", "list"],
939 "events": ["response_start", "response_delta", "response_end", "tool_call_start", "tool_call_result", "mcp_startup_update", "mcp_startup_complete"],
940 "transport": "stdio+http",
941 "config_path": state.config_path.as_ref().map(|p| p.display().to_string()),
942 }),
943 events: Vec::new(),
944 },
945 AppRequest::ConfigGet { key } => {
946 let cfg = state.config.read().await;
947 let value = match transport {
948 AppTransport::Http => cfg.get_display_value(&key),
949 AppTransport::Stdio => cfg.get_value(&key),
950 };
951 AppResponse {
952 ok: true,
953 data: json!({ "key": key, "value": value }),
954 events: Vec::new(),
955 }
956 }
957 AppRequest::ConfigSet { key, value } => {
958 let mut cfg = state.config.write().await;
959 let result = cfg.set_value(&key, &value);
960 let ok = result.is_ok();
961 let message = result.err().map(|e| e.to_string());
962 let snapshot = cfg.clone();
963 drop(cfg);
964 if let Err(e) = persist_config(state, snapshot).await {
965 tracing::error!("Failed to persist config after set: {e}");
966 }
967 AppResponse {
968 ok,
969 data: json!({ "key": key, "value": value, "error": message }),
970 events: Vec::new(),
971 }
972 }
973 AppRequest::ConfigUnset { key } => {
974 let mut cfg = state.config.write().await;
975 let result = cfg.unset_value(&key);
976 let ok = result.is_ok();
977 let message = result.err().map(|e| e.to_string());
978 let snapshot = cfg.clone();
979 drop(cfg);
980 if let Err(e) = persist_config(state, snapshot).await {
981 tracing::error!("Failed to persist config after unset: {e}");
982 }
983 AppResponse {
984 ok,
985 data: json!({ "key": key, "error": message }),
986 events: Vec::new(),
987 }
988 }
989 AppRequest::ConfigList => {
990 let cfg = state.config.read().await;
991 AppResponse {
992 ok: true,
993 data: json!({ "values": cfg.list_values() }),
994 events: Vec::new(),
995 }
996 }
997 AppRequest::Models => AppResponse {
998 ok: true,
999 data: json!({ "models": state.registry.list() }),
1000 events: Vec::new(),
1001 },
1002 AppRequest::ThreadLoadedList => {
1003 let mut runtime = state.runtime.lock().await;
1004 let response = runtime
1005 .handle_thread(codewhale_protocol::ThreadRequest::List(
1006 codewhale_protocol::ThreadListParams {
1007 include_archived: false,
1008 limit: Some(50),
1009 },
1010 ))
1011 .await;
1012 match response {
1013 Ok(thread_resp) => AppResponse {
1014 ok: true,
1015 data: json!({ "threads": thread_resp.threads }),
1016 events: thread_resp.events,
1017 },
1018 Err(err) => AppResponse {
1019 ok: false,
1020 data: json!({ "error": err.to_string() }),
1021 events: Vec::new(),
1022 },
1023 }
1024 }
1025 AppRequest::SubmitUserInput {
1026 request_id,
1027 answers,
1028 } => {
1029 let mut pending = state.pending_user_input.lock().await;
1035 if pending.contains_key(&request_id) {
1036 return AppResponse {
1037 ok: false,
1038 data: json!({
1039 "error": "request_id already resolved",
1040 "request_id": request_id,
1041 }),
1042 events: Vec::new(),
1043 };
1044 }
1045 pending.insert(request_id.clone(), answers);
1046 AppResponse {
1047 ok: true,
1048 data: json!({ "request_id": request_id, "resolved": true }),
1049 events: Vec::new(),
1050 }
1051 }
1052 }
1053}
1054
1055async fn persist_config(state: &AppState, config: codewhale_config::ConfigToml) -> Result<()> {
1056 if state.config_path.is_none() {
1057 return Ok(());
1058 }
1059 let mut store = ConfigStore::load(state.config_path.clone())?;
1060 store.config = config;
1061 store.save()
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066 use super::*;
1067 use axum::body::{Body, to_bytes};
1068 use codewhale_protocol::AppRequest;
1069 use std::fs;
1070 use tower::ServiceExt;
1071
1072 fn app_with_config(auth_token: Option<&str>) -> (Router, tempfile::TempDir) {
1073 let tmp = tempfile::tempdir().expect("tempdir");
1074 let config_path = tmp.path().join("config.toml");
1075 fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1076 let state = build_state(
1077 Some(config_path),
1078 auth_token.map(std::string::ToString::to_string),
1079 )
1080 .expect("state");
1081 (app_router(state, &[]), tmp)
1082 }
1083
1084 #[test]
1085 fn build_state_keeps_resolved_explicit_config_path() {
1086 let tmp = tempfile::tempdir().expect("tempdir");
1087 let config_dir = tmp.path().join("config-dir");
1088 fs::create_dir_all(&config_dir).expect("config dir");
1089 let config_path = config_dir.join("config.toml");
1090 fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1091
1092 let state = build_state(Some(config_path.clone()), None).expect("state");
1093
1094 assert_eq!(
1095 state.config_path.as_deref(),
1096 Some(
1097 config_path
1098 .canonicalize()
1099 .expect("canonical config")
1100 .as_path()
1101 )
1102 );
1103 }
1104
1105 async fn response_body_json(response: Response) -> Value {
1106 let bytes = to_bytes(response.into_body(), usize::MAX)
1107 .await
1108 .expect("body bytes");
1109 serde_json::from_slice(&bytes).expect("json response")
1110 }
1111
1112 #[tokio::test]
1113 async fn http_app_routes_require_bearer_token_when_auth_enabled() {
1114 let (app, _tmp) = app_with_config(Some("test-token"));
1115 let response = app
1116 .oneshot(
1117 Request::builder()
1118 .method(Method::POST)
1119 .uri("/app")
1120 .header(header::CONTENT_TYPE, "application/json")
1121 .body(Body::from(
1122 serde_json::to_vec(&AppRequest::ConfigGet {
1123 key: "api_key".to_string(),
1124 })
1125 .expect("request json"),
1126 ))
1127 .expect("request"),
1128 )
1129 .await
1130 .expect("response");
1131
1132 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1133 }
1134
1135 #[tokio::test]
1136 async fn http_config_get_redacts_sensitive_values_after_auth() {
1137 let (app, _tmp) = app_with_config(Some("test-token"));
1138 let response = app
1139 .oneshot(
1140 Request::builder()
1141 .method(Method::POST)
1142 .uri("/app")
1143 .header(header::AUTHORIZATION, "Bearer test-token")
1144 .header(header::CONTENT_TYPE, "application/json")
1145 .body(Body::from(
1146 serde_json::to_vec(&AppRequest::ConfigGet {
1147 key: "api_key".to_string(),
1148 })
1149 .expect("request json"),
1150 ))
1151 .expect("request"),
1152 )
1153 .await
1154 .expect("response");
1155
1156 assert_eq!(response.status(), StatusCode::OK);
1157 let body = response_body_json(response).await;
1158 assert_eq!(body["data"]["value"], "sk-d***cret");
1159 }
1160
1161 #[tokio::test]
1162 async fn cors_does_not_allow_arbitrary_origins() {
1163 let (app, _tmp) = app_with_config(Some("test-token"));
1164 let response = app
1165 .oneshot(
1166 Request::builder()
1167 .method(Method::GET)
1168 .uri("/healthz")
1169 .header(header::ORIGIN, "https://attacker.example")
1170 .body(Body::empty())
1171 .expect("request"),
1172 )
1173 .await
1174 .expect("response");
1175
1176 assert_eq!(response.status(), StatusCode::OK);
1177 assert!(
1178 response
1179 .headers()
1180 .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
1181 .is_none()
1182 );
1183 }
1184
1185 #[tokio::test]
1186 async fn build_state_loads_permissions_into_runtime_policy() {
1187 let tmp = tempfile::tempdir().expect("tempdir");
1188 let config_path = tmp.path().join("config.toml");
1189 fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1190 fs::write(
1191 tmp.path().join("permissions.toml"),
1192 r#"
1193 [[rules]]
1194 tool = "exec_shell"
1195 command = "cargo test"
1196 "#,
1197 )
1198 .expect("write permissions");
1199
1200 let state = build_state(Some(config_path), None).expect("state");
1201 let runtime = state.runtime.lock().await;
1202 let decision = runtime
1203 .exec_policy
1204 .check(codewhale_execpolicy::ExecPolicyContext {
1205 command: "cargo test --workspace",
1206 cwd: "/workspace",
1207 tool: Some("exec_shell"),
1208 path: None,
1209 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1210 sandbox_mode: Some("workspace-write"),
1211 })
1212 .expect("policy check");
1213
1214 assert!(decision.allow);
1215 assert!(decision.requires_approval);
1216 assert_eq!(
1217 decision.matched_rule.as_deref(),
1218 Some("tool=exec_shell command=cargo test")
1219 );
1220 }
1221
1222 #[test]
1223 fn non_loopback_bind_without_auth_fails_fast() {
1224 let options = AppServerOptions {
1225 listen: "0.0.0.0:8787".parse().expect("socket addr"),
1226 config_path: None,
1227 auth_token: None,
1228 insecure_no_auth: false,
1229 cors_origins: Vec::new(),
1230 };
1231
1232 let err =
1233 resolve_auth_token(&options).expect_err("non-loopback generated auth should fail");
1234 assert!(err.to_string().contains("without explicit auth token"));
1235 }
1236
1237 #[tokio::test]
1238 async fn stdio_transport_keeps_raw_config_get_for_legacy_clients() {
1239 let tmp = tempfile::tempdir().expect("tempdir");
1240 let config_path = tmp.path().join("config.toml");
1241 fs::write(&config_path, "").expect("write config");
1242 let state = build_state(Some(config_path), None).expect("state");
1243 {
1244 let mut cfg = state.config.write().await;
1245 cfg.api_key = Some("sk-deepseek-secret".to_string());
1246 }
1247
1248 let response = process_app_request(
1249 &state,
1250 AppRequest::ConfigGet {
1251 key: "api_key".to_string(),
1252 },
1253 AppTransport::Stdio,
1254 )
1255 .await;
1256
1257 assert_eq!(response.data["value"], "sk-deepseek-secret");
1258 }
1259
1260 #[tokio::test]
1261 async fn stdio_thread_goal_methods_round_trip_persisted_goal() {
1262 let tmp = tempfile::tempdir().expect("tempdir");
1263 let config_path = tmp.path().join("config.toml");
1264 fs::write(&config_path, "").expect("write config");
1265 let state = build_state(Some(config_path), None).expect("state");
1266
1267 let capabilities = dispatch_stdio_request(&state, "thread/capabilities", json!({}))
1268 .await
1269 .expect("thread capabilities");
1270 assert!(
1271 capabilities.result["methods"]
1272 .as_array()
1273 .expect("methods")
1274 .iter()
1275 .any(|method| method == "thread/goal/set")
1276 );
1277
1278 let started = dispatch_stdio_request(&state, "thread/start", json!({}))
1279 .await
1280 .expect("start thread");
1281 let thread_id = started.result["thread_id"]
1282 .as_str()
1283 .expect("thread id")
1284 .to_string();
1285
1286 let set = dispatch_stdio_request(
1287 &state,
1288 "thread/goal/set",
1289 json!({
1290 "thread_id": thread_id,
1291 "objective": "Release 0.8.59",
1292 "token_budget": 59000
1293 }),
1294 )
1295 .await
1296 .expect("set goal");
1297 assert_eq!(set.result["status"], "ok");
1298 assert_eq!(set.result["goal"]["objective"], "Release 0.8.59");
1299 assert_eq!(set.result["goal"]["status"], "active");
1300
1301 let got = dispatch_stdio_request(
1302 &state,
1303 "thread/goal/get",
1304 json!({
1305 "thread_id": thread_id
1306 }),
1307 )
1308 .await
1309 .expect("get goal");
1310 assert_eq!(got.result["goal"]["token_budget"], 59000);
1311
1312 let cleared = dispatch_stdio_request(
1313 &state,
1314 "thread/goal/clear",
1315 json!({
1316 "thread_id": thread_id
1317 }),
1318 )
1319 .await
1320 .expect("clear goal");
1321 assert_eq!(cleared.result["status"], "cleared");
1322 assert_eq!(cleared.result["data"]["cleared"], true);
1323 }
1324
1325 const EXPECTED_CAPABILITY_METHODS: &[&str] = &[
1334 "healthz",
1335 "thread/capabilities",
1336 "thread/request",
1337 "thread/create",
1338 "thread/start",
1339 "thread/resume",
1340 "thread/fork",
1341 "thread/list",
1342 "thread/read",
1343 "thread/set_name",
1344 "thread/goal/set",
1345 "thread/goal/get",
1346 "thread/goal/clear",
1347 "thread/archive",
1348 "thread/unarchive",
1349 "thread/message",
1350 "app/capabilities",
1351 "app/request",
1352 "app/config/get",
1353 "app/config/set",
1354 "app/config/unset",
1355 "app/config/list",
1356 "app/models",
1357 "app/thread_loaded_list",
1358 "prompt/capabilities",
1359 "prompt/request",
1360 "prompt/run",
1361 "shutdown",
1362 ];
1363
1364 fn capability_test_state() -> (AppState, tempfile::TempDir) {
1365 let tmp = tempfile::tempdir().expect("tempdir");
1366 let config_path = tmp.path().join("config.toml");
1367 fs::write(&config_path, "").expect("write config");
1368 let state = build_state(Some(config_path), None).expect("state");
1369 (state, tmp)
1370 }
1371
1372 #[tokio::test]
1373 async fn capabilities_method_set_is_stable() {
1374 let (state, _tmp) = capability_test_state();
1375 let caps = dispatch_stdio_request(&state, "capabilities", json!({}))
1376 .await
1377 .expect("capabilities dispatch");
1378 let methods: Vec<String> = caps.result["methods"]
1379 .as_array()
1380 .expect("methods array")
1381 .iter()
1382 .map(|m| m.as_str().expect("method string").to_string())
1383 .collect();
1384 assert_eq!(
1385 methods, EXPECTED_CAPABILITY_METHODS,
1386 "app-server stdio capability set drifted; update the dispatcher, this \
1387 snapshot, and docs/RUNTIME_API.md together"
1388 );
1389 }
1390
1391 #[tokio::test]
1392 async fn every_advertised_capability_is_dispatchable() {
1393 let (state, _tmp) = capability_test_state();
1394 for method in EXPECTED_CAPABILITY_METHODS {
1398 if let Err(err) = dispatch_stdio_request(&state, method, json!({})).await {
1399 assert_ne!(
1400 err.code,
1401 JsonRpcError::method_not_found(method).code,
1402 "advertised capability `{method}` is not dispatchable"
1403 );
1404 }
1405 }
1406 }
1407
1408 #[test]
1411 fn auth_token_empty_string_fails() {
1412 let options = AppServerOptions {
1413 listen: "127.0.0.1:0".parse().expect("addr"),
1414 config_path: None,
1415 auth_token: Some(" ".to_string()),
1416 insecure_no_auth: false,
1417 cors_origins: Vec::new(),
1418 };
1419 let err = resolve_auth_token(&options).expect_err("empty token should fail");
1420 assert!(err.to_string().contains("cannot be empty"));
1421 }
1422
1423 #[test]
1424 fn auth_token_generated_when_none_provided() {
1425 let options = AppServerOptions {
1426 listen: "127.0.0.1:0".parse().expect("addr"),
1427 config_path: None,
1428 auth_token: None,
1429 insecure_no_auth: false,
1430 cors_origins: Vec::new(),
1431 };
1432 let token = resolve_auth_token(&options).unwrap();
1433 assert!(token.is_some());
1434 assert!(token.unwrap().starts_with("cwapp_"));
1435 }
1436
1437 #[test]
1438 fn generated_auth_status_does_not_render_token() {
1439 let rendered = app_server_auth_status_lines(false).join("\n");
1440
1441 assert!(!rendered.contains("Authorization: Bearer"));
1442 assert!(rendered.contains("not printed"));
1443 assert!(rendered.contains("CODEWHALE_APP_SERVER_TOKEN"));
1444 }
1445
1446 #[test]
1447 fn auth_token_explicit_is_preserved() {
1448 let options = AppServerOptions {
1449 listen: "127.0.0.1:0".parse().expect("addr"),
1450 config_path: None,
1451 auth_token: Some("my-secret".to_string()),
1452 insecure_no_auth: false,
1453 cors_origins: Vec::new(),
1454 };
1455 let token = resolve_auth_token(&options).unwrap();
1456 assert_eq!(token.as_deref(), Some("my-secret"));
1457 }
1458
1459 #[test]
1460 fn auth_token_explicit_allows_non_loopback_bind() {
1461 let options = AppServerOptions {
1462 listen: "0.0.0.0:8787".parse().expect("socket addr"),
1463 config_path: None,
1464 auth_token: Some("my-secret".to_string()),
1465 insecure_no_auth: false,
1466 cors_origins: Vec::new(),
1467 };
1468 let token = resolve_auth_token(&options).unwrap();
1469 assert_eq!(token.as_deref(), Some("my-secret"));
1470 }
1471
1472 #[test]
1473 fn insecure_no_auth_on_loopback_returns_none() {
1474 let options = AppServerOptions {
1475 listen: "127.0.0.1:0".parse().expect("addr"),
1476 config_path: None,
1477 auth_token: None,
1478 insecure_no_auth: true,
1479 cors_origins: Vec::new(),
1480 };
1481 let token = resolve_auth_token(&options).unwrap();
1482 assert!(token.is_none());
1483 }
1484
1485 #[test]
1486 fn insecure_no_auth_on_non_loopback_fails_fast() {
1487 let options = AppServerOptions {
1488 listen: "0.0.0.0:8787".parse().expect("socket addr"),
1489 config_path: None,
1490 auth_token: None,
1491 insecure_no_auth: true,
1492 cors_origins: Vec::new(),
1493 };
1494
1495 let err = resolve_auth_token(&options).expect_err("non-loopback unauth should fail");
1496 assert!(
1497 err.to_string()
1498 .contains("refusing unauthenticated app-server bind")
1499 );
1500 }
1501
1502 #[test]
1505 fn cors_layer_includes_default_origins() {
1506 let layer = cors_layer(&[]);
1507 let _ = layer;
1509 }
1510
1511 #[test]
1512 fn cors_layer_adds_extra_origins() {
1513 let extras = vec!["https://example.com".to_string()];
1514 let layer = cors_layer(&extras);
1515 let _ = layer;
1516 }
1517
1518 #[test]
1519 fn cors_layer_skips_empty_origins() {
1520 let extras = vec!["".to_string(), " ".to_string()];
1521 let layer = cors_layer(&extras);
1522 let _ = layer;
1523 }
1524
1525 #[test]
1528 fn params_or_object_returns_object_for_null() {
1529 let result = params_or_object(Value::Null);
1530 assert_eq!(result, json!({}));
1531 }
1532
1533 #[test]
1534 fn params_or_object_passthrough_for_non_null() {
1535 let input = json!({"key": "value"});
1536 let result = params_or_object(input.clone());
1537 assert_eq!(result, input);
1538 }
1539
1540 #[test]
1541 fn jsonrpc_result_format() {
1542 let result = jsonrpc_result(Some(json!(1)), json!({"ok": true}));
1543 assert_eq!(result["jsonrpc"], "2.0");
1544 assert_eq!(result["id"], 1);
1545 assert_eq!(result["result"]["ok"], true);
1546 }
1547
1548 #[test]
1549 fn jsonrpc_result_null_id() {
1550 let result = jsonrpc_result(None, json!(null));
1551 assert_eq!(result["id"], Value::Null);
1552 }
1553
1554 #[test]
1555 fn jsonrpc_error_format() {
1556 let err = jsonrpc_error(Some(json!(2)), JsonRpcError::internal("oops"));
1557 assert_eq!(err["jsonrpc"], "2.0");
1558 assert_eq!(err["id"], 2);
1559 assert_eq!(err["error"]["code"], -32603);
1560 assert_eq!(err["error"]["message"], "oops");
1561 }
1562
1563 #[test]
1564 fn jsonrpc_error_codes() {
1565 assert_eq!(JsonRpcError::parse_error("").code, -32700);
1566 assert_eq!(JsonRpcError::invalid_request("").code, -32600);
1567 assert_eq!(JsonRpcError::method_not_found("x").code, -32601);
1568 assert_eq!(JsonRpcError::invalid_params("").code, -32602);
1569 assert_eq!(JsonRpcError::internal("").code, -32603);
1570 }
1571
1572 #[test]
1575 fn app_server_options_debug_does_not_leak_token() {
1576 let options = AppServerOptions {
1577 listen: "127.0.0.1:8080".parse().expect("addr"),
1578 config_path: None,
1579 auth_token: Some("secret-token".to_string()),
1580 insecure_no_auth: false,
1581 cors_origins: vec!["https://example.com".to_string()],
1582 };
1583 let debug = format!("{options:?}");
1584 assert!(!debug.contains("secret-token"));
1585 assert!(debug.contains("<redacted>"));
1586 assert!(debug.contains("8080"));
1587 }
1588
1589 #[test]
1592 fn default_cors_origins_include_common_dev_ports() {
1593 assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:3000"));
1594 assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:5173"));
1595 assert!(DEFAULT_CORS_ORIGINS.contains(&"tauri://localhost"));
1596 }
1597}