1use std::collections::HashMap;
2use std::net::SocketAddr;
3use std::path::{Path, PathBuf};
4use std::process::{Child, Command, Stdio};
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use anyhow::{Context, Result, anyhow, bail};
9use axum::extract::{Request, State};
10use axum::http::{HeaderValue, Method, StatusCode, header};
11use axum::middleware::{self, Next};
12use axum::response::{IntoResponse, Response};
13use axum::routing::{get, post};
14use axum::{Json, Router};
15use codewhale_agent::ModelRegistry;
16use codewhale_config::{CliRuntimeOverrides, ConfigStore};
17use codewhale_core::Runtime;
18use codewhale_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink, UnixSocketHookSink};
19use codewhale_mcp::McpManager;
20use codewhale_protocol::{
21 AppRequest, AppResponse, PromptRequest, PromptResponse, ThreadGoalClearParams,
22 ThreadGoalGetParams, ThreadGoalSetParams, ThreadRequest, ThreadResponse, UserInputAnswerEvent,
23};
24use codewhale_state::StateStore;
25use codewhale_tools::{ToolCall, ToolRegistry};
26use serde::de::DeserializeOwned;
27use serde::{Deserialize, Serialize};
28use serde_json::{Value, json};
29use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader};
30use tokio::sync::{Mutex, RwLock};
31use tower_http::cors::CorsLayer;
32use uuid::Uuid;
33
34type PendingUserInputAnswers = Vec<UserInputAnswerEvent>;
43
44mod chat_completions;
45
46const DEFAULT_CORS_ORIGINS: &[&str] = &[
47 "http://localhost",
48 "http://localhost:1420",
49 "http://localhost:3000",
50 "http://localhost:5173",
51 "http://127.0.0.1",
52 "http://127.0.0.1:1420",
53 "tauri://localhost",
54];
55
56#[derive(Clone)]
57pub struct AppServerOptions {
58 pub listen: SocketAddr,
59 pub config_path: Option<PathBuf>,
60 pub auth_token: Option<String>,
61 pub insecure_no_auth: bool,
62 pub cors_origins: Vec<String>,
63}
64
65impl std::fmt::Debug for AppServerOptions {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 f.debug_struct("AppServerOptions")
68 .field("listen", &self.listen)
69 .field("config_path", &self.config_path)
70 .field(
71 "auth_token",
72 &self.auth_token.as_ref().map(|_| "<redacted>"),
73 )
74 .field("insecure_no_auth", &self.insecure_no_auth)
75 .field("cors_origins", &self.cors_origins)
76 .finish()
77 }
78}
79
80#[derive(Clone)]
81struct AppState {
82 config_path: Option<PathBuf>,
83 config: Arc<RwLock<codewhale_config::ConfigToml>>,
84 runtime: Arc<Mutex<Runtime>>,
85 registry: ModelRegistry,
86 auth_token: Option<String>,
87 stdio_bridge: Arc<Mutex<Option<RuntimeBridge>>>,
88 stdio_thread_hints: Arc<Mutex<HashMap<String, RuntimeThreadHint>>>,
89 pending_user_input: Arc<Mutex<std::collections::HashMap<String, PendingUserInputAnswers>>>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96struct ToolCallRequest {
97 call: ToolCall,
98 #[serde(default)]
99 cwd: Option<PathBuf>,
100}
101
102#[derive(Debug, Deserialize)]
103struct JsonRpcRequest {
104 #[serde(default)]
105 jsonrpc: Option<String>,
106 #[serde(default)]
107 id: Option<Value>,
108 method: String,
109 #[serde(default)]
110 params: Value,
111}
112
113#[derive(Debug)]
114struct JsonRpcError {
115 code: i64,
116 message: String,
117 data: Option<Value>,
118}
119
120#[derive(Debug)]
121struct StdioDispatchResult {
122 result: Value,
123 should_exit: bool,
124}
125
126#[derive(Debug)]
127struct RuntimeBridge {
128 base_url: String,
129 client: reqwest::Client,
130 auth_token: Option<String>,
131 child: Option<Child>,
132 thread_map: HashMap<String, String>,
133 last_seq_by_thread: HashMap<String, u64>,
134}
135
136#[derive(Debug, Clone, Default)]
137struct RuntimeThreadHint {
138 model: Option<String>,
139 workspace: Option<PathBuf>,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143enum TurnTerminalStatus {
144 Completed,
145 Failed,
146 Interrupted,
147 Canceled,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151enum AppTransport {
152 Http,
153 Stdio,
154}
155
156#[derive(Debug, Deserialize)]
157struct ConfigGetParams {
158 key: String,
159}
160
161#[derive(Debug, Deserialize)]
162struct ConfigSetParams {
163 key: String,
164 value: String,
165}
166
167#[derive(Debug, Deserialize)]
168struct ThreadIdParams {
169 thread_id: String,
170}
171
172#[derive(Debug, Deserialize)]
173struct ThreadMessageParams {
174 thread_id: String,
175 input: String,
176}
177
178pub async fn run(options: AppServerOptions) -> Result<()> {
179 let auth_token = resolve_auth_token(&options)?;
180 let state = build_state(options.config_path.clone(), auth_token)?;
181 let app = app_router(state, &options.cors_origins);
182
183 let listener = tokio::net::TcpListener::bind(options.listen).await?;
184 axum::serve(listener, app).await?;
185 Ok(())
186}
187
188fn app_router(state: AppState, cors_origins: &[String]) -> Router {
189 let protected_routes = Router::new()
190 .route("/thread", post(thread_handler))
191 .route("/app", post(app_handler))
192 .route("/prompt", post(prompt_handler))
193 .route("/tool", post(tool_handler))
194 .route("/jobs", get(jobs_handler))
195 .route("/mcp/startup", post(mcp_startup_handler))
196 .route_layer(middleware::from_fn_with_state(
197 state.clone(),
198 require_app_server_token,
199 ));
200
201 Router::new()
202 .route("/healthz", get(healthz))
203 .route(
204 "/v1/chat/completions",
205 post(chat_completions::chat_completions_handler),
206 )
207 .merge(protected_routes)
208 .layer(cors_layer(cors_origins))
209 .with_state(state)
210}
211
212pub async fn run_stdio(config_path: Option<PathBuf>) -> Result<()> {
213 let state = build_state(config_path, None)?;
214 let stdin = tokio::io::stdin();
215 let stdout = tokio::io::stdout();
216 let mut reader = BufReader::new(stdin).lines();
217 let mut writer = tokio::io::BufWriter::new(stdout);
218 while let Some(line) = reader.next_line().await? {
219 if line.trim().is_empty() {
220 continue;
221 }
222
223 let request: JsonRpcRequest = match serde_json::from_str(&line) {
224 Ok(value) => value,
225 Err(err) => {
226 let response = jsonrpc_error(
227 None,
228 JsonRpcError::parse_error(format!("invalid json: {err}")),
229 );
230 writer.write_all(response.to_string().as_bytes()).await?;
231 writer.write_all(b"\n").await?;
232 writer.flush().await?;
233 continue;
234 }
235 };
236
237 if request
238 .jsonrpc
239 .as_deref()
240 .is_some_and(|version| version != "2.0")
241 {
242 let response = jsonrpc_error(
243 request.id,
244 JsonRpcError::invalid_request("jsonrpc version must be 2.0"),
245 );
246 writer.write_all(response.to_string().as_bytes()).await?;
247 writer.write_all(b"\n").await?;
248 writer.flush().await?;
249 continue;
250 }
251
252 let response = match dispatch_stdio_request_with_writer(
253 &state,
254 &mut writer,
255 &request.method,
256 request.params,
257 )
258 .await
259 {
260 Ok(dispatch) => {
261 let encoded = jsonrpc_result(request.id, dispatch.result);
262 writer.write_all(encoded.to_string().as_bytes()).await?;
263 writer.write_all(b"\n").await?;
264 writer.flush().await?;
265 if dispatch.should_exit {
266 break;
267 }
268 continue;
269 }
270 Err(err) => jsonrpc_error(request.id, err),
271 };
272
273 writer.write_all(response.to_string().as_bytes()).await?;
274 writer.write_all(b"\n").await?;
275 writer.flush().await?;
276 }
277
278 Ok(())
279}
280
281async fn healthz() -> Json<Value> {
282 Json(json!({
283 "status": "ok",
284 "protocol": "v2",
285 "service": "deepseek-app-server"
286 }))
287}
288
289async fn thread_handler(
290 State(state): State<AppState>,
291 Json(req): Json<ThreadRequest>,
292) -> Json<ThreadResponse> {
293 let mut runtime = state.runtime.lock().await;
294 match runtime.handle_thread(req).await {
295 Ok(res) => Json(res),
296 Err(err) => Json(ThreadResponse {
297 thread_id: "error".to_string(),
298 status: format!("error:{err}"),
299 thread: None,
300 threads: Vec::new(),
301 goal: None,
302 model: None,
303 model_provider: None,
304 cwd: None,
305 approval_policy: None,
306 sandbox: None,
307 events: Vec::new(),
308 data: json!({}),
309 }),
310 }
311}
312
313async fn prompt_handler(
314 State(state): State<AppState>,
315 Json(req): Json<PromptRequest>,
316) -> Json<PromptResponse> {
317 let mut runtime = state.runtime.lock().await;
318 let overrides = CliRuntimeOverrides::default();
319 match runtime.handle_prompt(req, &overrides).await {
320 Ok(res) => Json(res),
321 Err(err) => Json(PromptResponse {
322 output: err.to_string(),
323 model: "unknown".to_string(),
324 events: Vec::new(),
325 }),
326 }
327}
328
329async fn tool_handler(
330 State(state): State<AppState>,
331 Json(req): Json<ToolCallRequest>,
332) -> Json<Value> {
333 let runtime = state.runtime.lock().await;
334 let cwd = req
335 .cwd
336 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
337 let approval_mode = {
339 let cfg = state.config.read().await;
340 cfg.approval_policy
341 .as_deref()
342 .and_then(|p| match p.trim().to_ascii_lowercase().as_str() {
343 "auto" | "yolo" => Some(codewhale_execpolicy::AskForApproval::UnlessTrusted),
344 "never" | "deny" => Some(codewhale_execpolicy::AskForApproval::Never),
345 _ => None,
346 })
347 .unwrap_or(codewhale_execpolicy::AskForApproval::OnRequest)
348 };
349 match runtime.invoke_tool(req.call, approval_mode, &cwd).await {
350 Ok(value) => Json(value),
351 Err(err) => Json(json!({ "ok": false, "error": err.to_string() })),
352 }
353}
354
355async fn jobs_handler(State(state): State<AppState>) -> Json<AppResponse> {
356 let runtime = state.runtime.lock().await;
357 Json(runtime.app_status())
358}
359
360async fn mcp_startup_handler(State(state): State<AppState>) -> Json<Value> {
361 let runtime = state.runtime.lock().await;
362 let summary = runtime.mcp_startup().await;
363 Json(json!({
364 "ok": true,
365 "summary": summary
366 }))
367}
368
369async fn app_handler(
370 State(state): State<AppState>,
371 Json(req): Json<AppRequest>,
372) -> Json<AppResponse> {
373 Json(process_app_request(&state, req, AppTransport::Http).await)
374}
375
376fn build_state(config_path: Option<PathBuf>, auth_token: Option<String>) -> Result<AppState> {
377 let has_explicit_config_path = config_path.is_some();
378 let store = ConfigStore::load(config_path)?;
379 let config_path = has_explicit_config_path.then(|| store.path().to_path_buf());
380 let config = store.config.clone();
381 let exec_policy = store.exec_policy_engine();
382 let registry = ModelRegistry::default();
383
384 let state_db_path = config_path
385 .as_ref()
386 .and_then(|p| p.parent().map(|parent| parent.join("state.db")));
387 let state_store = StateStore::open(state_db_path)?;
388
389 let mut hooks = HookDispatcher::default();
390 hooks.add_sink(Arc::new(StdoutHookSink));
391 let hook_log_path = config_path
392 .as_ref()
393 .and_then(|p| p.parent().map(|parent| parent.join("events.jsonl")))
394 .unwrap_or_else(|| PathBuf::from(".deepseek/events.jsonl"));
395 hooks.add_sink(Arc::new(JsonlHookSink::new(hook_log_path)));
396
397 if let Some(socket_path) = config
398 .hook_sinks
399 .as_ref()
400 .and_then(|sinks| sinks.unix_socket_path.as_ref())
401 .filter(|path| !path.as_os_str().is_empty())
402 {
403 hooks.add_sink(Arc::new(UnixSocketHookSink::new(socket_path.clone())));
404 }
405
406 let runtime = Runtime::new(
407 config.clone(),
408 registry.clone(),
409 state_store,
410 Arc::new(ToolRegistry::default()),
411 Arc::new(McpManager::default()),
412 exec_policy,
413 hooks,
414 );
415
416 Ok(AppState {
417 config_path,
418 config: Arc::new(RwLock::new(config)),
419 runtime: Arc::new(Mutex::new(runtime)),
420 registry,
421 auth_token,
422 stdio_bridge: Arc::new(Mutex::new(None)),
423 stdio_thread_hints: Arc::new(Mutex::new(HashMap::new())),
424 pending_user_input: Arc::new(Mutex::new(std::collections::HashMap::new())),
425 })
426}
427
428fn resolve_auth_token(options: &AppServerOptions) -> Result<Option<String>> {
429 let configured = options.auth_token.as_ref().map(|token| token.trim());
430 if let Some(token) = configured
431 && token.is_empty()
432 {
433 bail!("app-server auth token cannot be empty");
434 }
435 let has_explicit_token = configured.is_some();
436
437 if options.insecure_no_auth {
438 if !options.listen.ip().is_loopback() {
439 bail!("refusing unauthenticated app-server bind on non-loopback address");
440 }
441 eprintln!("warning: app-server HTTP auth disabled by --insecure-no-auth");
442 return Ok(None);
443 }
444
445 if !has_explicit_token && !options.listen.ip().is_loopback() {
446 bail!(
447 "refusing non-loopback app-server bind without explicit auth token; pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN"
448 );
449 }
450
451 let token = configured
452 .map(str::to_string)
453 .unwrap_or_else(|| format!("cwapp_{}", Uuid::new_v4().simple()));
454 for line in app_server_auth_status_lines(has_explicit_token) {
455 eprintln!("{line}");
456 }
457 Ok(Some(token))
458}
459
460fn app_server_auth_status_lines(has_explicit_token: bool) -> Vec<&'static str> {
461 if has_explicit_token {
462 return vec!["app-server auth: bearer token required for HTTP routes."];
463 }
464 vec![
465 "app-server auth: generated bearer token for this process (not printed).",
466 " Pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN when another client needs to connect.",
467 ]
468}
469
470fn cors_layer(extra_origins: &[String]) -> CorsLayer {
471 let mut origins: Vec<HeaderValue> = DEFAULT_CORS_ORIGINS
472 .iter()
473 .filter_map(|origin| HeaderValue::from_str(origin).ok())
474 .collect();
475 for raw in extra_origins {
476 let trimmed = raw.trim();
477 if trimmed.is_empty() {
478 continue;
479 }
480 match HeaderValue::from_str(trimmed) {
481 Ok(value) if !origins.contains(&value) => origins.push(value),
482 Ok(_) => {}
483 Err(err) => {
484 eprintln!("warning: ignoring invalid app-server CORS origin `{trimmed}`: {err}")
485 }
486 }
487 }
488
489 CorsLayer::new()
490 .allow_origin(origins)
491 .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
492 .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
493}
494
495async fn require_app_server_token(
496 State(state): State<AppState>,
497 req: Request,
498 next: Next,
499) -> Response {
500 let Some(expected) = state.auth_token.as_deref() else {
501 return next.run(req).await;
502 };
503 let authorized = req
504 .headers()
505 .get(header::AUTHORIZATION)
506 .and_then(|value| value.to_str().ok())
507 .and_then(|raw| raw.strip_prefix("Bearer "))
508 .is_some_and(|token| token == expected);
509
510 if authorized {
511 next.run(req).await
512 } else {
513 (
514 StatusCode::UNAUTHORIZED,
515 Json(json!({
516 "error": {
517 "message": "app-server bearer token required",
518 "status": StatusCode::UNAUTHORIZED.as_u16(),
519 }
520 })),
521 )
522 .into_response()
523 }
524}
525
526fn params_or_object(params: Value) -> Value {
527 if params.is_null() { json!({}) } else { params }
528}
529
530fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
531 serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
532}
533
534fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
535 json!({
536 "jsonrpc": "2.0",
537 "id": id.unwrap_or(Value::Null),
538 "result": result
539 })
540}
541
542fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
543 json!({
544 "jsonrpc": "2.0",
545 "id": id.unwrap_or(Value::Null),
546 "error": {
547 "code": err.code,
548 "message": err.message,
549 "data": err.data
550 }
551 })
552}
553
554impl JsonRpcError {
555 fn parse_error(message: impl Into<String>) -> Self {
556 Self {
557 code: -32700,
558 message: message.into(),
559 data: None,
560 }
561 }
562
563 fn invalid_request(message: impl Into<String>) -> Self {
564 Self {
565 code: -32600,
566 message: message.into(),
567 data: None,
568 }
569 }
570
571 fn method_not_found(method: &str) -> Self {
572 Self {
573 code: -32601,
574 message: format!("unsupported method: {method}"),
575 data: None,
576 }
577 }
578
579 fn invalid_params(message: impl Into<String>) -> Self {
580 Self {
581 code: -32602,
582 message: message.into(),
583 data: None,
584 }
585 }
586
587 fn internal(message: impl Into<String>) -> Self {
588 Self {
589 code: -32603,
590 message: message.into(),
591 data: None,
592 }
593 }
594}
595
596async fn handle_thread_request(
597 state: &AppState,
598 req: ThreadRequest,
599) -> std::result::Result<ThreadResponse, JsonRpcError> {
600 let mut runtime = state.runtime.lock().await;
601 runtime
602 .handle_thread(req)
603 .await
604 .map_err(|err| JsonRpcError::internal(err.to_string()))
605}
606
607async fn handle_prompt_request(
608 state: &AppState,
609 req: PromptRequest,
610) -> std::result::Result<PromptResponse, JsonRpcError> {
611 let mut runtime = state.runtime.lock().await;
612 runtime
613 .handle_prompt(req, &CliRuntimeOverrides::default())
614 .await
615 .map_err(|err| JsonRpcError::internal(err.to_string()))
616}
617
618async fn handle_stdio_thread_message<W: AsyncWrite + Unpin>(
619 state: &AppState,
620 writer: &mut W,
621 parsed: ThreadMessageParams,
622) -> std::result::Result<Value, JsonRpcError> {
623 let hint = {
624 let hints = state.stdio_thread_hints.lock().await;
625 hints.get(&parsed.thread_id).cloned()
626 };
627 let mut bridge_slot = state.stdio_bridge.lock().await;
628 if bridge_slot.is_none() {
629 let bridge = RuntimeBridge::start(state.config_path.as_deref())
630 .await
631 .map_err(|err| JsonRpcError::internal(err.to_string()))?;
632 *bridge_slot = Some(bridge);
633 }
634 let bridge = bridge_slot
635 .as_mut()
636 .ok_or_else(|| JsonRpcError::internal("failed to initialize runtime bridge"))?;
637 let runtime_thread_id = bridge
638 .ensure_runtime_thread(&parsed.thread_id, hint)
639 .await
640 .map_err(|err| JsonRpcError::internal(err.to_string()))?;
641 let mut result = bridge
642 .message_thread(&runtime_thread_id, &parsed.input, writer)
643 .await
644 .map_err(|err| JsonRpcError::internal(err.to_string()))?;
645 if let Some(object) = result.as_object_mut() {
646 object.insert("thread_id".to_string(), Value::String(parsed.thread_id));
647 }
648 Ok(result)
649}
650
651async fn record_stdio_thread_hint(state: &AppState, response: &ThreadResponse) {
652 let mut hints = state.stdio_thread_hints.lock().await;
653 hints.insert(
654 response.thread_id.clone(),
655 RuntimeThreadHint {
656 model: response.model.clone(),
657 workspace: response.cwd.clone(),
658 },
659 );
660}
661
662async fn invalidate_stdio_bridge(state: &AppState) {
663 let mut bridge = state.stdio_bridge.lock().await;
664 *bridge = None;
665}
666
667impl RuntimeBridge {
668 async fn start(config_path: Option<&Path>) -> Result<Self> {
669 install_rustls_crypto_provider();
670 let port = reserve_runtime_port()?;
671 let auth_token = format!("cwrt_{}", Uuid::new_v4().simple());
672 let child = Self::runtime_command(config_path, port, &auth_token)?
673 .spawn()
674 .context("failed to start runtime API bridge")?;
675 let mut bridge = Self {
676 base_url: format!("http://127.0.0.1:{port}"),
677 client: reqwest::Client::builder()
678 .build()
679 .context("failed to build runtime API client")?,
680 auth_token: Some(auth_token),
681 child: Some(child),
682 thread_map: HashMap::new(),
683 last_seq_by_thread: HashMap::new(),
684 };
685 bridge.wait_until_ready().await?;
686 Ok(bridge)
687 }
688
689 fn runtime_command(config_path: Option<&Path>, port: u16, auth_token: &str) -> Result<Command> {
690 let current_exe = std::env::current_exe().ok();
691 let mut command = if let Some(path) = current_exe {
692 Command::new(path)
693 } else {
694 Command::new("codewhale")
695 };
696 command
697 .arg("app-server")
698 .arg("--http")
699 .arg("--host")
700 .arg("127.0.0.1")
701 .arg("--port")
702 .arg(port.to_string())
703 .arg("--auth-token")
704 .arg(auth_token)
705 .stdin(Stdio::null())
706 .stdout(Stdio::null())
707 .stderr(Stdio::null());
708 if let Some(config_path) = config_path {
709 command.arg("--config").arg(config_path);
710 }
711 Ok(command)
712 }
713
714 async fn wait_until_ready(&mut self) -> Result<()> {
715 let deadline = Instant::now() + Duration::from_secs(15);
716 loop {
717 if let Some(child) = self.child.as_mut()
718 && let Some(status) = child.try_wait()?
719 {
720 return Err(anyhow!(
721 "runtime API bridge exited before becoming ready (status {status})"
722 ));
723 }
724
725 match self
726 .client
727 .get(format!("{}/health", self.base_url))
728 .send()
729 .await
730 {
731 Ok(response) if response.status().is_success() => return Ok(()),
732 _ if Instant::now() >= deadline => {
733 bail!(
734 "timed out waiting for runtime API bridge at {}/health",
735 self.base_url
736 )
737 }
738 _ => tokio::time::sleep(Duration::from_millis(50)).await,
739 }
740 }
741 }
742
743 fn authed(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
744 match self.auth_token.as_deref() {
745 Some(token) => builder.bearer_auth(token),
746 None => builder,
747 }
748 }
749
750 async fn request_json(&self, builder: reqwest::RequestBuilder) -> Result<Value> {
751 let response = builder.send().await?;
752 let status = response.status();
753 let body = response.text().await?;
754 if !status.is_success() {
755 let detail = body.trim();
756 if detail.is_empty() {
757 bail!("runtime API returned {status}");
758 }
759 bail!("runtime API returned {status}: {detail}");
760 }
761 serde_json::from_str(&body).with_context(|| format!("invalid runtime API json: {body}"))
762 }
763
764 async fn ensure_runtime_thread(
765 &mut self,
766 stdio_thread_id: &str,
767 hint: Option<RuntimeThreadHint>,
768 ) -> Result<String> {
769 if let Some(runtime_thread_id) = self.thread_map.get(stdio_thread_id) {
770 return Ok(runtime_thread_id.clone());
771 }
772 let hint = hint.unwrap_or_default();
773 let runtime_thread_id = self
774 .create_runtime_thread(hint.model, hint.workspace)
775 .await?;
776 self.thread_map
777 .insert(stdio_thread_id.to_string(), runtime_thread_id.clone());
778 Ok(runtime_thread_id)
779 }
780
781 async fn create_runtime_thread(
782 &mut self,
783 model: Option<String>,
784 workspace: Option<PathBuf>,
785 ) -> Result<String> {
786 let record = self
787 .request_json(
788 self.authed(self.client.post(format!("{}/v1/threads", self.base_url)))
789 .json(&json!({
790 "model": model,
791 "workspace": workspace,
792 "mode": "agent",
793 "archived": false,
794 })),
795 )
796 .await?;
797 let thread_id = extract_runtime_thread_id(&record)?.to_string();
798 self.last_seq_by_thread
799 .entry(thread_id.clone())
800 .or_insert(0);
801 Ok(thread_id)
802 }
803
804 async fn message_thread<W: AsyncWrite + Unpin>(
805 &mut self,
806 thread_id: &str,
807 input: &str,
808 writer: &mut W,
809 ) -> Result<Value> {
810 let turn = self
811 .request_json(
812 self.authed(
813 self.client
814 .post(format!("{}/v1/threads/{thread_id}/turns", self.base_url)),
815 )
816 .json(&json!({ "prompt": input })),
817 )
818 .await?;
819 let turn_id = turn
820 .pointer("/turn/id")
821 .and_then(Value::as_str)
822 .ok_or_else(|| anyhow!("runtime API turn response missing turn.id"))?
823 .to_string();
824 let response_id = format!("{thread_id}:{turn_id}");
825
826 emit_stdio_event(
827 writer,
828 json!({
829 "type": "response_start",
830 "response_id": response_id,
831 }),
832 )
833 .await?;
834
835 let since_seq = self.last_seq_by_thread.get(thread_id).copied().unwrap_or(0);
836 let stream_result = self
837 .stream_turn_events(thread_id, &turn_id, &response_id, writer, since_seq)
838 .await;
839
840 let _ = emit_stdio_event(
841 writer,
842 json!({
843 "type": "response_end",
844 "response_id": response_id,
845 }),
846 )
847 .await;
848
849 let (last_seq, status, error) = stream_result?;
850 self.last_seq_by_thread
851 .insert(thread_id.to_string(), last_seq);
852
853 match status {
854 TurnTerminalStatus::Completed => Ok(json!({
855 "thread_id": thread_id,
856 "status": "accepted",
857 "thread": Value::Null,
858 "threads": [],
859 "model": Value::Null,
860 "model_provider": Value::Null,
861 "cwd": Value::Null,
862 "approval_policy": Value::Null,
863 "sandbox": Value::Null,
864 "events": [],
865 "data": { "turn_id": turn_id },
866 })),
867 TurnTerminalStatus::Failed => Err(anyhow!(
868 "{}",
869 error.unwrap_or_else(|| "turn failed".to_string())
870 )),
871 TurnTerminalStatus::Interrupted => Err(anyhow!(
872 "{}",
873 error.unwrap_or_else(|| "turn interrupted".to_string())
874 )),
875 TurnTerminalStatus::Canceled => Err(anyhow!(
876 "{}",
877 error.unwrap_or_else(|| "turn canceled".to_string())
878 )),
879 }
880 }
881
882 async fn stream_turn_events<W: AsyncWrite + Unpin>(
883 &self,
884 thread_id: &str,
885 turn_id: &str,
886 response_id: &str,
887 writer: &mut W,
888 since_seq: u64,
889 ) -> Result<(u64, TurnTerminalStatus, Option<String>)> {
890 let mut response = self
891 .authed(self.client.get(format!(
892 "{}/v1/threads/{thread_id}/events?since_seq={since_seq}",
893 self.base_url
894 )))
895 .send()
896 .await?
897 .error_for_status()?;
898
899 let mut buffer = Vec::new();
900 let mut last_seq = since_seq;
901
902 while let Some(chunk) = response.chunk().await? {
903 buffer.extend_from_slice(&chunk);
904 while let Some(frame_bytes) = take_sse_frame(&mut buffer) {
905 let Some((event_name, frame_data)) = parse_sse_frame(&frame_bytes) else {
906 continue;
907 };
908 let envelope: Value = serde_json::from_str(&frame_data)
909 .with_context(|| format!("invalid SSE json for {event_name}: {frame_data}"))?;
910 if let Some(seq) = envelope.get("seq").and_then(Value::as_u64) {
911 last_seq = last_seq.max(seq);
912 }
913 if envelope.get("turn_id").and_then(Value::as_str) != Some(turn_id) {
914 continue;
915 }
916 let payload = envelope.get("payload").cloned().unwrap_or(Value::Null);
917 match event_name.as_str() {
918 "item.delta" => {
919 let kind = payload
920 .get("kind")
921 .and_then(Value::as_str)
922 .unwrap_or_default();
923 if kind == "agent_message"
924 && let Some(delta) = payload.get("delta").and_then(Value::as_str)
925 && !delta.is_empty()
926 {
927 emit_stdio_event(
928 writer,
929 json!({
930 "type": "response_delta",
931 "response_id": response_id,
932 "delta": delta,
933 }),
934 )
935 .await?;
936 }
937 }
938 "turn.completed" => {
939 let status = turn_terminal_status(&payload);
940 let error = payload
941 .pointer("/turn/error")
942 .and_then(Value::as_str)
943 .map(str::to_string);
944 return Ok((last_seq, status, error));
945 }
946 _ => {}
947 }
948 }
949 }
950
951 bail!("runtime event stream ended before turn.completed")
952 }
953
954 #[cfg(test)]
955 fn from_base_url_for_test(base_url: String) -> Self {
956 install_rustls_crypto_provider();
957 Self {
958 base_url,
959 client: reqwest::Client::builder()
960 .timeout(Duration::from_secs(5))
961 .build()
962 .expect("build reqwest test client"),
963 auth_token: None,
964 child: None,
965 thread_map: HashMap::new(),
966 last_seq_by_thread: HashMap::new(),
967 }
968 }
969}
970
971impl Drop for RuntimeBridge {
972 fn drop(&mut self) {
973 if let Some(child) = self.child.as_mut() {
974 let _ = child.kill();
975 let _ = child.wait();
976 }
977 }
978}
979
980fn reserve_runtime_port() -> Result<u16> {
981 let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
982 Ok(listener.local_addr()?.port())
983}
984
985fn install_rustls_crypto_provider() {
986 let _ = rustls::crypto::ring::default_provider().install_default();
987}
988
989fn extract_runtime_thread_id(record: &Value) -> Result<&str> {
990 record
991 .get("id")
992 .and_then(Value::as_str)
993 .ok_or_else(|| anyhow!("runtime API thread response missing id"))
994}
995
996fn turn_terminal_status(payload: &Value) -> TurnTerminalStatus {
997 match payload
998 .pointer("/turn/status")
999 .and_then(Value::as_str)
1000 .unwrap_or("completed")
1001 .to_ascii_lowercase()
1002 .as_str()
1003 {
1004 "failed" => TurnTerminalStatus::Failed,
1005 "interrupted" => TurnTerminalStatus::Interrupted,
1006 "canceled" | "cancelled" => TurnTerminalStatus::Canceled,
1007 _ => TurnTerminalStatus::Completed,
1008 }
1009}
1010
1011async fn emit_stdio_event<W: AsyncWrite + Unpin>(writer: &mut W, event: Value) -> Result<()> {
1012 writer.write_all(event.to_string().as_bytes()).await?;
1013 writer.write_all(b"\n").await?;
1014 writer.flush().await?;
1015 Ok(())
1016}
1017
1018fn take_sse_frame(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
1019 if let Some(pos) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
1020 return Some(buffer.drain(..pos + 4).collect());
1021 }
1022 buffer
1023 .windows(2)
1024 .position(|window| window == b"\n\n")
1025 .map(|pos| buffer.drain(..pos + 2).collect())
1026}
1027
1028fn parse_sse_frame(frame_bytes: &[u8]) -> Option<(String, String)> {
1029 let text = String::from_utf8(frame_bytes.to_vec()).ok()?;
1030 let mut event_name = None;
1031 let mut data_lines = Vec::new();
1032 for raw_line in text.lines() {
1033 let line = raw_line.trim_end_matches('\r');
1034 if let Some(value) = line.strip_prefix("event:") {
1035 event_name = Some(value.trim().to_string());
1036 } else if let Some(value) = line.strip_prefix("data:") {
1037 data_lines.push(value.trim_start().to_string());
1038 }
1039 }
1040 match (event_name, data_lines.is_empty()) {
1041 (Some(event), false) => Some((event, data_lines.join("\n"))),
1042 _ => None,
1043 }
1044}
1045
1046#[cfg(test)]
1047async fn dispatch_stdio_request(
1048 state: &AppState,
1049 method: &str,
1050 params: Value,
1051) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
1052 let mut sink = tokio::io::sink();
1053 dispatch_stdio_request_with_writer(state, &mut sink, method, params).await
1054}
1055
1056async fn dispatch_stdio_app_request(
1057 state: &AppState,
1058 request: AppRequest,
1059) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
1060 let response = Box::pin(process_app_request(state, request, AppTransport::Stdio)).await;
1061 Ok(StdioDispatchResult {
1062 result: serde_json::to_value(response)
1063 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1064 should_exit: false,
1065 })
1066}
1067
1068async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
1069 state: &AppState,
1070 writer: &mut W,
1071 method: &str,
1072 params: Value,
1073) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
1074 let outcome = match method {
1075 "healthz" | "app/healthz" => StdioDispatchResult {
1076 result: json!({
1077 "status": "ok",
1078 "service": "deepseek-app-server",
1079 "transport": "stdio"
1080 }),
1081 should_exit: false,
1082 },
1083 "capabilities" => StdioDispatchResult {
1084 result: json!({
1085 "transport": "stdio",
1086 "families": ["thread/*", "app/*", "prompt/*"],
1087 "methods": [
1088 "healthz",
1089 "thread/capabilities",
1090 "thread/request",
1091 "thread/create",
1092 "thread/start",
1093 "thread/resume",
1094 "thread/fork",
1095 "thread/list",
1096 "thread/read",
1097 "thread/set_name",
1098 "thread/goal/set",
1099 "thread/goal/get",
1100 "thread/goal/clear",
1101 "thread/archive",
1102 "thread/unarchive",
1103 "thread/message",
1104 "app/capabilities",
1105 "app/request",
1106 "app/config/get",
1107 "app/config/set",
1108 "app/config/unset",
1109 "app/config/list",
1110 "app/config/reload",
1111 "app/models",
1112 "app/thread_loaded_list",
1113 "prompt/capabilities",
1114 "prompt/request",
1115 "prompt/run",
1116 "shutdown"
1117 ]
1118 }),
1119 should_exit: false,
1120 },
1121 "thread/capabilities" => StdioDispatchResult {
1122 result: json!({
1123 "methods": [
1124 "thread/request",
1125 "thread/create",
1126 "thread/start",
1127 "thread/resume",
1128 "thread/fork",
1129 "thread/list",
1130 "thread/read",
1131 "thread/set_name",
1132 "thread/goal/set",
1133 "thread/goal/get",
1134 "thread/goal/clear",
1135 "thread/archive",
1136 "thread/unarchive",
1137 "thread/message"
1138 ]
1139 }),
1140 should_exit: false,
1141 },
1142 "thread/request" => {
1143 let request: ThreadRequest = parse_params(params)?;
1144 if let ThreadRequest::Message { thread_id, input } = request {
1145 let response = handle_stdio_thread_message(
1146 state,
1147 writer,
1148 ThreadMessageParams { thread_id, input },
1149 )
1150 .await?;
1151 return Ok(StdioDispatchResult {
1152 result: response,
1153 should_exit: false,
1154 });
1155 }
1156 let should_record_hint = matches!(
1157 &request,
1158 ThreadRequest::Create { .. }
1159 | ThreadRequest::Start(_)
1160 | ThreadRequest::Resume(_)
1161 | ThreadRequest::Fork(_)
1162 );
1163 let response = handle_thread_request(state, request).await?;
1164 if should_record_hint {
1165 record_stdio_thread_hint(state, &response).await;
1166 }
1167 StdioDispatchResult {
1168 result: serde_json::to_value(response)
1169 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1170 should_exit: false,
1171 }
1172 }
1173 "thread/create" => {
1174 #[derive(Debug, Deserialize)]
1175 struct CreateParams {
1176 #[serde(default)]
1177 metadata: Value,
1178 }
1179 let parsed: CreateParams = parse_params(params_or_object(params))?;
1180 let response = handle_thread_request(
1181 state,
1182 ThreadRequest::Create {
1183 metadata: parsed.metadata,
1184 },
1185 )
1186 .await?;
1187 record_stdio_thread_hint(state, &response).await;
1188 StdioDispatchResult {
1189 result: serde_json::to_value(response)
1190 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1191 should_exit: false,
1192 }
1193 }
1194 "thread/start" => {
1195 let request = ThreadRequest::Start(parse_params(params_or_object(params))?);
1196 let response = handle_thread_request(state, request).await?;
1197 record_stdio_thread_hint(state, &response).await;
1198 StdioDispatchResult {
1199 result: serde_json::to_value(response)
1200 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1201 should_exit: false,
1202 }
1203 }
1204 "thread/resume" => {
1205 let request = ThreadRequest::Resume(parse_params(params_or_object(params))?);
1206 let response = handle_thread_request(state, request).await?;
1207 record_stdio_thread_hint(state, &response).await;
1208 StdioDispatchResult {
1209 result: serde_json::to_value(response)
1210 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1211 should_exit: false,
1212 }
1213 }
1214 "thread/fork" => {
1215 let request = ThreadRequest::Fork(parse_params(params_or_object(params))?);
1216 let response = handle_thread_request(state, request).await?;
1217 record_stdio_thread_hint(state, &response).await;
1218 StdioDispatchResult {
1219 result: serde_json::to_value(response)
1220 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1221 should_exit: false,
1222 }
1223 }
1224 "thread/list" => {
1225 let request = ThreadRequest::List(parse_params(params_or_object(params))?);
1226 let response = handle_thread_request(state, request).await?;
1227 StdioDispatchResult {
1228 result: serde_json::to_value(response)
1229 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1230 should_exit: false,
1231 }
1232 }
1233 "thread/read" => {
1234 let request = ThreadRequest::Read(parse_params(params_or_object(params))?);
1235 let response = handle_thread_request(state, request).await?;
1236 StdioDispatchResult {
1237 result: serde_json::to_value(response)
1238 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1239 should_exit: false,
1240 }
1241 }
1242 "thread/set_name" | "thread/set-name" => {
1243 let request = ThreadRequest::SetName(parse_params(params_or_object(params))?);
1244 let response = handle_thread_request(state, request).await?;
1245 StdioDispatchResult {
1246 result: serde_json::to_value(response)
1247 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1248 should_exit: false,
1249 }
1250 }
1251 "thread/goal/set" | "thread/goal_set" | "thread/goal-set" => {
1252 let request = ThreadRequest::GoalSet(parse_params::<ThreadGoalSetParams>(
1253 params_or_object(params),
1254 )?);
1255 let response = handle_thread_request(state, request).await?;
1256 StdioDispatchResult {
1257 result: serde_json::to_value(response)
1258 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1259 should_exit: false,
1260 }
1261 }
1262 "thread/goal/get" | "thread/goal_get" | "thread/goal-get" => {
1263 let request = ThreadRequest::GoalGet(parse_params::<ThreadGoalGetParams>(
1264 params_or_object(params),
1265 )?);
1266 let response = handle_thread_request(state, request).await?;
1267 StdioDispatchResult {
1268 result: serde_json::to_value(response)
1269 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1270 should_exit: false,
1271 }
1272 }
1273 "thread/goal/clear" | "thread/goal_clear" | "thread/goal-clear" => {
1274 let request = ThreadRequest::GoalClear(parse_params::<ThreadGoalClearParams>(
1275 params_or_object(params),
1276 )?);
1277 let response = handle_thread_request(state, request).await?;
1278 StdioDispatchResult {
1279 result: serde_json::to_value(response)
1280 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1281 should_exit: false,
1282 }
1283 }
1284 "thread/archive" => {
1285 let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
1286 let response = handle_thread_request(
1287 state,
1288 ThreadRequest::Archive {
1289 thread_id: parsed.thread_id,
1290 },
1291 )
1292 .await?;
1293 StdioDispatchResult {
1294 result: serde_json::to_value(response)
1295 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1296 should_exit: false,
1297 }
1298 }
1299 "thread/unarchive" => {
1300 let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
1301 let response = handle_thread_request(
1302 state,
1303 ThreadRequest::Unarchive {
1304 thread_id: parsed.thread_id,
1305 },
1306 )
1307 .await?;
1308 StdioDispatchResult {
1309 result: serde_json::to_value(response)
1310 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1311 should_exit: false,
1312 }
1313 }
1314 "thread/message" => {
1315 let parsed: ThreadMessageParams = parse_params(params_or_object(params))?;
1316 let response = handle_stdio_thread_message(state, writer, parsed).await?;
1317 StdioDispatchResult {
1318 result: response,
1319 should_exit: false,
1320 }
1321 }
1322 "app/capabilities" => dispatch_stdio_app_request(state, AppRequest::Capabilities).await?,
1323 "app/request" => {
1324 let request: AppRequest = parse_params(params)?;
1325 dispatch_stdio_app_request(state, request).await?
1326 }
1327 "app/config/get" => {
1328 let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
1329 dispatch_stdio_app_request(state, AppRequest::ConfigGet { key: parsed.key }).await?
1330 }
1331 "app/config/set" => {
1332 let parsed: ConfigSetParams = parse_params(params_or_object(params))?;
1333 dispatch_stdio_app_request(
1334 state,
1335 AppRequest::ConfigSet {
1336 key: parsed.key,
1337 value: parsed.value,
1338 },
1339 )
1340 .await?
1341 }
1342 "app/config/unset" => {
1343 let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
1344 dispatch_stdio_app_request(state, AppRequest::ConfigUnset { key: parsed.key }).await?
1345 }
1346 "app/config/list" => dispatch_stdio_app_request(state, AppRequest::ConfigList).await?,
1347 "app/config/reload" => dispatch_stdio_app_request(state, AppRequest::ConfigReload).await?,
1348 "app/models" => dispatch_stdio_app_request(state, AppRequest::Models).await?,
1349 "app/thread_loaded_list" | "app/thread-loaded-list" => {
1350 dispatch_stdio_app_request(state, AppRequest::ThreadLoadedList).await?
1351 }
1352 "prompt/capabilities" => StdioDispatchResult {
1353 result: json!({
1354 "methods": ["prompt/request", "prompt/run"]
1355 }),
1356 should_exit: false,
1357 },
1358 "prompt/request" | "prompt/run" => {
1359 let request: PromptRequest = parse_params(params)?;
1360 let response = handle_prompt_request(state, request).await?;
1361 StdioDispatchResult {
1362 result: serde_json::to_value(response)
1363 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1364 should_exit: false,
1365 }
1366 }
1367 "shutdown" => StdioDispatchResult {
1368 result: json!({"ok": true, "status": "stopped"}),
1369 should_exit: true,
1370 },
1371 _ => return Err(JsonRpcError::method_not_found(method)),
1372 };
1373 Ok(outcome)
1374}
1375
1376async fn process_app_request(
1377 state: &AppState,
1378 req: AppRequest,
1379 transport: AppTransport,
1380) -> AppResponse {
1381 match req {
1382 AppRequest::Capabilities => AppResponse {
1383 ok: true,
1384 data: json!({
1385 "routes": ["/thread", "/app", "/prompt", "/tool", "/jobs", "/mcp/startup"],
1386 "config": ["get", "set", "unset", "list", "reload"],
1387 "events": ["response_start", "response_delta", "response_end", "tool_call_start", "tool_call_result", "mcp_startup_update", "mcp_startup_complete"],
1388 "transport": "stdio+http",
1389 "config_path": state.config_path.as_ref().map(|p| p.display().to_string()),
1390 }),
1391 events: Vec::new(),
1392 },
1393 AppRequest::ConfigGet { key } => {
1394 let cfg = state.config.read().await;
1395 let value = match transport {
1396 AppTransport::Http => cfg.get_display_value(&key),
1397 AppTransport::Stdio => cfg.get_value(&key),
1398 };
1399 AppResponse {
1400 ok: true,
1401 data: json!({ "key": key, "value": value }),
1402 events: Vec::new(),
1403 }
1404 }
1405 AppRequest::ConfigSet { key, value } => {
1406 let mut cfg = state.config.write().await;
1407 let result = cfg.set_value(&key, &value);
1408 let ok = result.is_ok();
1409 let message = result.err().map(|e| e.to_string());
1410 let snapshot = cfg.clone();
1411 drop(cfg);
1412 let runtime_snapshot = snapshot.clone();
1414 if let Err(e) = persist_config(state, snapshot).await {
1415 tracing::error!("Failed to persist config after set: {e}");
1416 }
1417 let mut runtime = state.runtime.lock().await;
1423 runtime.update_config(runtime_snapshot);
1424 drop(runtime);
1425 invalidate_stdio_bridge(state).await;
1429 AppResponse {
1430 ok,
1431 data: json!({ "key": key, "value": value, "error": message }),
1432 events: Vec::new(),
1433 }
1434 }
1435 AppRequest::ConfigUnset { key } => {
1436 let mut cfg = state.config.write().await;
1437 let result = cfg.unset_value(&key);
1438 let ok = result.is_ok();
1439 let message = result.err().map(|e| e.to_string());
1440 let snapshot = cfg.clone();
1441 drop(cfg);
1442 let runtime_snapshot = snapshot.clone();
1444 if let Err(e) = persist_config(state, snapshot).await {
1445 tracing::error!("Failed to persist config after unset: {e}");
1446 }
1447 let mut runtime = state.runtime.lock().await;
1453 runtime.update_config(runtime_snapshot);
1454 drop(runtime);
1455 invalidate_stdio_bridge(state).await;
1459 AppResponse {
1460 ok,
1461 data: json!({ "key": key, "error": message }),
1462 events: Vec::new(),
1463 }
1464 }
1465 AppRequest::ConfigList => {
1466 let cfg = state.config.read().await;
1467 AppResponse {
1468 ok: true,
1469 data: json!({ "values": cfg.list_values() }),
1470 events: Vec::new(),
1471 }
1472 }
1473 AppRequest::ConfigReload => {
1474 let store = match ConfigStore::load(state.config_path.clone()) {
1484 Ok(store) => store,
1485 Err(e) => {
1486 return AppResponse {
1487 ok: false,
1488 data: json!({ "error": format!("failed to load config: {e}") }),
1489 events: Vec::new(),
1490 };
1491 }
1492 };
1493 let new_config = store.config.clone();
1494 let new_exec_policy = store.exec_policy_engine();
1495
1496 {
1499 let mut cfg = state.config.write().await;
1500 *cfg = new_config.clone();
1501 }
1502
1503 {
1509 let mut runtime = state.runtime.lock().await;
1510 runtime.reload_config_and_policy(new_config, new_exec_policy);
1511 }
1512
1513 AppResponse {
1514 ok: true,
1515 data: json!({ "reloaded": true }),
1516 events: Vec::new(),
1517 }
1518 }
1519 AppRequest::Models => AppResponse {
1520 ok: true,
1521 data: json!({ "models": state.registry.list() }),
1522 events: Vec::new(),
1523 },
1524 AppRequest::ThreadLoadedList => {
1525 let mut runtime = state.runtime.lock().await;
1526 let response = runtime
1527 .handle_thread(codewhale_protocol::ThreadRequest::List(
1528 codewhale_protocol::ThreadListParams {
1529 include_archived: false,
1530 limit: Some(50),
1531 },
1532 ))
1533 .await;
1534 match response {
1535 Ok(thread_resp) => AppResponse {
1536 ok: true,
1537 data: json!({ "threads": thread_resp.threads }),
1538 events: thread_resp.events,
1539 },
1540 Err(err) => AppResponse {
1541 ok: false,
1542 data: json!({ "error": err.to_string() }),
1543 events: Vec::new(),
1544 },
1545 }
1546 }
1547 AppRequest::SubmitUserInput {
1548 request_id,
1549 answers,
1550 } => {
1551 let mut pending = state.pending_user_input.lock().await;
1557 if pending.contains_key(&request_id) {
1558 return AppResponse {
1559 ok: false,
1560 data: json!({
1561 "error": "request_id already resolved",
1562 "request_id": request_id,
1563 }),
1564 events: Vec::new(),
1565 };
1566 }
1567 pending.insert(request_id.clone(), answers);
1568 AppResponse {
1569 ok: true,
1570 data: json!({ "request_id": request_id, "resolved": true }),
1571 events: Vec::new(),
1572 }
1573 }
1574 }
1575}
1576
1577async fn persist_config(state: &AppState, config: codewhale_config::ConfigToml) -> Result<()> {
1578 if state.config_path.is_none() {
1579 return Ok(());
1580 }
1581 let mut store = ConfigStore::load(state.config_path.clone())?;
1582 store.config = config;
1583 store.save()
1584}
1585
1586#[cfg(test)]
1587mod tests {
1588 use super::*;
1589 use axum::body::{Body, to_bytes};
1590 use axum::extract::{Path as AxumPath, Query};
1591 use axum::http::header;
1592 use codewhale_protocol::AppRequest;
1593 use std::collections::HashMap;
1594 use std::fs;
1595 use tokio::io::AsyncReadExt;
1596 use tower::ServiceExt;
1597
1598 fn app_with_config(auth_token: Option<&str>) -> (Router, tempfile::TempDir) {
1599 let tmp = tempfile::tempdir().expect("tempdir");
1600 let config_path = tmp.path().join("config.toml");
1601 fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1602 let state = build_state(
1603 Some(config_path),
1604 auth_token.map(std::string::ToString::to_string),
1605 )
1606 .expect("state");
1607 (app_router(state, &[]), tmp)
1608 }
1609
1610 #[test]
1611 fn build_state_keeps_resolved_explicit_config_path() {
1612 let tmp = tempfile::tempdir().expect("tempdir");
1613 let config_dir = tmp.path().join("config-dir");
1614 fs::create_dir_all(&config_dir).expect("config dir");
1615 let config_path = config_dir.join("config.toml");
1616 fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1617
1618 let state = build_state(Some(config_path.clone()), None).expect("state");
1619
1620 assert_eq!(
1621 state.config_path.as_deref(),
1622 Some(
1623 config_path
1624 .canonicalize()
1625 .expect("canonical config")
1626 .as_path()
1627 )
1628 );
1629 }
1630
1631 async fn response_body_json(response: Response) -> Value {
1632 let bytes = to_bytes(response.into_body(), usize::MAX)
1633 .await
1634 .expect("body bytes");
1635 serde_json::from_slice(&bytes).expect("json response")
1636 }
1637
1638 #[tokio::test]
1639 async fn http_app_routes_require_bearer_token_when_auth_enabled() {
1640 let (app, _tmp) = app_with_config(Some("test-token"));
1641 let response = app
1642 .oneshot(
1643 Request::builder()
1644 .method(Method::POST)
1645 .uri("/app")
1646 .header(header::CONTENT_TYPE, "application/json")
1647 .body(Body::from(
1648 serde_json::to_vec(&AppRequest::ConfigGet {
1649 key: "api_key".to_string(),
1650 })
1651 .expect("request json"),
1652 ))
1653 .expect("request"),
1654 )
1655 .await
1656 .expect("response");
1657
1658 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1659 }
1660
1661 #[tokio::test]
1662 async fn http_config_get_redacts_sensitive_values_after_auth() {
1663 let (app, _tmp) = app_with_config(Some("test-token"));
1664 let response = app
1665 .oneshot(
1666 Request::builder()
1667 .method(Method::POST)
1668 .uri("/app")
1669 .header(header::AUTHORIZATION, "Bearer test-token")
1670 .header(header::CONTENT_TYPE, "application/json")
1671 .body(Body::from(
1672 serde_json::to_vec(&AppRequest::ConfigGet {
1673 key: "api_key".to_string(),
1674 })
1675 .expect("request json"),
1676 ))
1677 .expect("request"),
1678 )
1679 .await
1680 .expect("response");
1681
1682 assert_eq!(response.status(), StatusCode::OK);
1683 let body = response_body_json(response).await;
1684 assert_eq!(body["data"]["value"], "sk-d***cret");
1685 }
1686
1687 #[tokio::test]
1688 async fn cors_does_not_allow_arbitrary_origins() {
1689 let (app, _tmp) = app_with_config(Some("test-token"));
1690 let response = app
1691 .oneshot(
1692 Request::builder()
1693 .method(Method::GET)
1694 .uri("/healthz")
1695 .header(header::ORIGIN, "https://attacker.example")
1696 .body(Body::empty())
1697 .expect("request"),
1698 )
1699 .await
1700 .expect("response");
1701
1702 assert_eq!(response.status(), StatusCode::OK);
1703 assert!(
1704 response
1705 .headers()
1706 .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
1707 .is_none()
1708 );
1709 }
1710
1711 #[tokio::test]
1712 async fn build_state_loads_permissions_into_runtime_policy() {
1713 let tmp = tempfile::tempdir().expect("tempdir");
1714 let config_path = tmp.path().join("config.toml");
1715 fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1716 fs::write(
1717 tmp.path().join("permissions.toml"),
1718 r#"
1719 [[rules]]
1720 tool = "exec_shell"
1721 command = "cargo test"
1722 "#,
1723 )
1724 .expect("write permissions");
1725
1726 let state = build_state(Some(config_path), None).expect("state");
1727 let runtime = state.runtime.lock().await;
1728 let decision = runtime
1729 .exec_policy
1730 .check(codewhale_execpolicy::ExecPolicyContext {
1731 command: "cargo test --workspace",
1732 cwd: "/workspace",
1733 tool: Some("exec_shell"),
1734 path: None,
1735 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1736 sandbox_mode: Some("workspace-write"),
1737 })
1738 .expect("policy check");
1739
1740 assert!(decision.allow);
1741 assert!(decision.requires_approval);
1742 assert_eq!(
1743 decision.matched_rule.as_deref(),
1744 Some("tool=exec_shell command=cargo test")
1745 );
1746 }
1747
1748 #[tokio::test]
1749 async fn config_reload_refreshes_runtime_config_and_exec_policy_from_disk() {
1750 let tmp = tempfile::tempdir().expect("tempdir");
1751 let config_path = tmp.path().join("config.toml");
1752 fs::write(
1753 &config_path,
1754 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
1755 )
1756 .expect("write config");
1757 let state = build_state(Some(config_path.clone()), None).expect("state");
1759
1760 {
1762 let runtime = state.runtime.lock().await;
1763 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat"));
1764 let decision = runtime
1765 .exec_policy
1766 .check(codewhale_execpolicy::ExecPolicyContext {
1767 command: "cargo test",
1768 cwd: "/workspace",
1769 tool: Some("exec_shell"),
1770 path: None,
1771 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1772 sandbox_mode: Some("workspace-write"),
1773 })
1774 .expect("policy check");
1775 assert!(decision.matched_rule.is_none());
1776 }
1777
1778 fs::write(
1780 &config_path,
1781 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-reasoner\"\n",
1782 )
1783 .expect("rewrite config");
1784 fs::write(
1785 tmp.path().join("permissions.toml"),
1786 r#"
1787 [[rules]]
1788 tool = "exec_shell"
1789 command = "cargo test"
1790 "#,
1791 )
1792 .expect("write permissions");
1793
1794 let response =
1797 process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await;
1798 assert!(response.ok, "reload should succeed");
1799 assert_eq!(response.data["reloaded"], true);
1800
1801 {
1803 let cfg = state.config.read().await;
1804 assert_eq!(cfg.model.as_deref(), Some("deepseek-reasoner"));
1805 }
1806 {
1808 let runtime = state.runtime.lock().await;
1809 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-reasoner"));
1810 let decision = runtime
1811 .exec_policy
1812 .check(codewhale_execpolicy::ExecPolicyContext {
1813 command: "cargo test --workspace",
1814 cwd: "/workspace",
1815 tool: Some("exec_shell"),
1816 path: None,
1817 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1818 sandbox_mode: Some("workspace-write"),
1819 })
1820 .expect("policy check");
1821 assert!(decision.allow);
1822 assert!(decision.requires_approval);
1823 assert_eq!(
1824 decision.matched_rule.as_deref(),
1825 Some("tool=exec_shell command=cargo test")
1826 );
1827 }
1828 }
1829
1830 #[tokio::test]
1831 async fn config_set_propagates_to_runtime_config_without_touching_exec_policy() {
1832 let tmp = tempfile::tempdir().expect("tempdir");
1833 let config_path = tmp.path().join("config.toml");
1834 fs::write(
1835 &config_path,
1836 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
1837 )
1838 .expect("write config");
1839 let state = build_state(Some(config_path.clone()), None).expect("state");
1840
1841 let response = process_app_request(
1844 &state,
1845 AppRequest::ConfigSet {
1846 key: "model".to_string(),
1847 value: "deepseek-reasoner".to_string(),
1848 },
1849 AppTransport::Stdio,
1850 )
1851 .await;
1852 assert!(response.ok, "set should succeed");
1853
1854 {
1856 let runtime = state.runtime.lock().await;
1857 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-reasoner"));
1858 let decision = runtime
1860 .exec_policy
1861 .check(codewhale_execpolicy::ExecPolicyContext {
1862 command: "cargo test",
1863 cwd: "/workspace",
1864 tool: Some("exec_shell"),
1865 path: None,
1866 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1867 sandbox_mode: Some("workspace-write"),
1868 })
1869 .expect("policy check");
1870 assert!(decision.matched_rule.is_none());
1871 }
1872 let persisted = fs::read_to_string(&config_path).expect("read config");
1874 assert!(persisted.contains("deepseek-reasoner"));
1875 }
1876
1877 #[tokio::test]
1878 async fn config_unset_propagates_to_runtime_config() {
1879 let tmp = tempfile::tempdir().expect("tempdir");
1880 let config_path = tmp.path().join("config.toml");
1881 fs::write(
1882 &config_path,
1883 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
1884 )
1885 .expect("write config");
1886 let state = build_state(Some(config_path.clone()), None).expect("state");
1887
1888 {
1890 let runtime = state.runtime.lock().await;
1891 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat"));
1892 }
1893
1894 let response = process_app_request(
1898 &state,
1899 AppRequest::ConfigUnset {
1900 key: "model".to_string(),
1901 },
1902 AppTransport::Stdio,
1903 )
1904 .await;
1905 assert!(response.ok, "unset should succeed");
1906
1907 {
1909 let runtime = state.runtime.lock().await;
1910 assert!(runtime.config.model.is_none());
1911 }
1912 {
1914 let cfg = state.config.read().await;
1915 assert!(cfg.model.is_none());
1916 }
1917 let persisted = fs::read_to_string(&config_path).expect("read config");
1919 assert!(!persisted.contains("deepseek-chat"));
1920 }
1921
1922 #[tokio::test]
1923 async fn config_reload_returns_error_when_disk_config_is_invalid() {
1924 let tmp = tempfile::tempdir().expect("tempdir");
1925 let config_path = tmp.path().join("config.toml");
1926 fs::write(
1927 &config_path,
1928 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
1929 )
1930 .expect("write config");
1931 let state = build_state(Some(config_path.clone()), None).expect("state");
1932
1933 fs::write(&config_path, "api_key = \"unterminated\n").expect("corrupt config");
1935
1936 let response =
1937 process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await;
1938 assert!(!response.ok, "reload of corrupt config must fail");
1939 let err = response.data["error"]
1940 .as_str()
1941 .expect("error message present")
1942 .to_string();
1943 assert!(
1944 err.contains("failed to load config"),
1945 "error should mention load failure, got: {err}"
1946 );
1947
1948 {
1951 let runtime = state.runtime.lock().await;
1952 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat"));
1953 }
1954 {
1955 let cfg = state.config.read().await;
1956 assert_eq!(cfg.model.as_deref(), Some("deepseek-chat"));
1957 }
1958 }
1959
1960 #[test]
1961 fn non_loopback_bind_without_auth_fails_fast() {
1962 let options = AppServerOptions {
1963 listen: "0.0.0.0:8787".parse().expect("socket addr"),
1964 config_path: None,
1965 auth_token: None,
1966 insecure_no_auth: false,
1967 cors_origins: Vec::new(),
1968 };
1969
1970 let err =
1971 resolve_auth_token(&options).expect_err("non-loopback generated auth should fail");
1972 assert!(err.to_string().contains("without explicit auth token"));
1973 }
1974
1975 #[tokio::test]
1976 async fn stdio_transport_keeps_raw_config_get_for_legacy_clients() {
1977 let tmp = tempfile::tempdir().expect("tempdir");
1978 let config_path = tmp.path().join("config.toml");
1979 fs::write(&config_path, "").expect("write config");
1980 let state = build_state(Some(config_path), None).expect("state");
1981 {
1982 let mut cfg = state.config.write().await;
1983 cfg.api_key = Some("sk-deepseek-secret".to_string());
1984 }
1985
1986 let response = process_app_request(
1987 &state,
1988 AppRequest::ConfigGet {
1989 key: "api_key".to_string(),
1990 },
1991 AppTransport::Stdio,
1992 )
1993 .await;
1994
1995 assert_eq!(response.data["value"], "sk-deepseek-secret");
1996 }
1997
1998 #[tokio::test]
1999 async fn stdio_thread_goal_methods_round_trip_persisted_goal() {
2000 let tmp = tempfile::tempdir().expect("tempdir");
2001 let config_path = tmp.path().join("config.toml");
2002 fs::write(&config_path, "").expect("write config");
2003 let state = build_state(Some(config_path), None).expect("state");
2004
2005 let capabilities = dispatch_stdio_request(&state, "thread/capabilities", json!({}))
2006 .await
2007 .expect("thread capabilities");
2008 assert!(
2009 capabilities.result["methods"]
2010 .as_array()
2011 .expect("methods")
2012 .iter()
2013 .any(|method| method == "thread/goal/set")
2014 );
2015
2016 let started = dispatch_stdio_request(&state, "thread/start", json!({}))
2017 .await
2018 .expect("start thread");
2019 let thread_id = started.result["thread_id"]
2020 .as_str()
2021 .expect("thread id")
2022 .to_string();
2023
2024 let set = dispatch_stdio_request(
2025 &state,
2026 "thread/goal/set",
2027 json!({
2028 "thread_id": thread_id,
2029 "objective": "Release 0.8.59",
2030 "token_budget": 59000
2031 }),
2032 )
2033 .await
2034 .expect("set goal");
2035 assert_eq!(set.result["status"], "ok");
2036 assert_eq!(set.result["goal"]["objective"], "Release 0.8.59");
2037 assert_eq!(set.result["goal"]["status"], "active");
2038
2039 let got = dispatch_stdio_request(
2040 &state,
2041 "thread/goal/get",
2042 json!({
2043 "thread_id": thread_id
2044 }),
2045 )
2046 .await
2047 .expect("get goal");
2048 assert_eq!(got.result["goal"]["token_budget"], 59000);
2049
2050 let cleared = dispatch_stdio_request(
2051 &state,
2052 "thread/goal/clear",
2053 json!({
2054 "thread_id": thread_id
2055 }),
2056 )
2057 .await
2058 .expect("clear goal");
2059 assert_eq!(cleared.result["status"], "cleared");
2060 assert_eq!(cleared.result["data"]["cleared"], true);
2061 }
2062
2063 fn sse_frame(event: &str, payload: Value) -> String {
2064 format!("event: {event}\ndata: {payload}\n\n")
2065 }
2066
2067 #[tokio::test]
2068 async fn stdio_runtime_bridge_streams_response_delta_events() {
2069 async fn create_turn(AxumPath(thread_id): AxumPath<String>) -> Json<Value> {
2070 Json(json!({
2071 "thread": { "id": thread_id },
2072 "turn": { "id": "turn_test" },
2073 }))
2074 }
2075
2076 async fn thread_events(
2077 AxumPath(thread_id): AxumPath<String>,
2078 Query(query): Query<HashMap<String, String>>,
2079 ) -> ([(header::HeaderName, &'static str); 1], String) {
2080 assert_eq!(thread_id, "thr_test");
2081 assert_eq!(query.get("since_seq").map(String::as_str), Some("0"));
2082
2083 let body = [
2084 sse_frame(
2085 "item.delta",
2086 json!({
2087 "seq": 1,
2088 "turn_id": "turn_test",
2089 "payload": {
2090 "kind": "agent_message",
2091 "delta": "hello"
2092 }
2093 }),
2094 ),
2095 sse_frame(
2096 "turn.completed",
2097 json!({
2098 "seq": 2,
2099 "turn_id": "turn_test",
2100 "payload": {
2101 "turn": {
2102 "status": "completed"
2103 }
2104 }
2105 }),
2106 ),
2107 ]
2108 .concat();
2109
2110 ([(header::CONTENT_TYPE, "text/event-stream")], body)
2111 }
2112
2113 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2114 .await
2115 .expect("bind test listener");
2116 let addr = listener.local_addr().expect("listener addr");
2117 let app = Router::new()
2118 .route("/v1/threads/{thread_id}/turns", post(create_turn))
2119 .route("/v1/threads/{thread_id}/events", get(thread_events));
2120
2121 let server = tokio::spawn(async move {
2122 axum::serve(listener, app)
2123 .await
2124 .expect("serve test runtime");
2125 });
2126
2127 let mut bridge = RuntimeBridge::from_base_url_for_test(format!("http://{addr}"));
2128 let (mut reader, mut writer) = tokio::io::duplex(4096);
2129
2130 let result = bridge
2131 .message_thread("thr_test", "hello", &mut writer)
2132 .await
2133 .expect("message_thread should succeed");
2134 drop(writer);
2135
2136 let mut stdout = Vec::new();
2137 reader
2138 .read_to_end(&mut stdout)
2139 .await
2140 .expect("read stdio output");
2141 server.abort();
2142 let _ = server.await;
2143
2144 let lines: Vec<Value> = String::from_utf8(stdout)
2145 .expect("utf8 output")
2146 .lines()
2147 .map(|line| serde_json::from_str(line).expect("json line"))
2148 .collect();
2149
2150 assert_eq!(
2151 result.get("status").and_then(Value::as_str),
2152 Some("accepted")
2153 );
2154 assert_eq!(
2155 result.pointer("/data/turn_id").and_then(Value::as_str),
2156 Some("turn_test")
2157 );
2158 assert_eq!(bridge.last_seq_by_thread.get("thr_test"), Some(&2));
2159
2160 let event_types: Vec<&str> = lines
2161 .iter()
2162 .map(|line| {
2163 line.get("type")
2164 .and_then(Value::as_str)
2165 .expect("event type")
2166 })
2167 .collect();
2168 assert_eq!(
2169 event_types,
2170 vec!["response_start", "response_delta", "response_end"]
2171 );
2172 assert_eq!(lines[1]["delta"], "hello");
2173 }
2174
2175 #[tokio::test]
2176 async fn stdio_runtime_bridge_applies_thread_start_hints() {
2177 async fn create_thread(Json(body): Json<Value>) -> Json<Value> {
2178 assert_eq!(body["model"], "deepseek-v4");
2179 assert_eq!(body["workspace"], "/tmp/codewhale-stdio");
2180 Json(json!({
2181 "id": "thr_runtime",
2182 "model": body["model"].clone(),
2183 "workspace": body["workspace"].clone(),
2184 }))
2185 }
2186
2187 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2188 .await
2189 .expect("bind test listener");
2190 let addr = listener.local_addr().expect("listener addr");
2191 let app = Router::new().route("/v1/threads", post(create_thread));
2192
2193 let server = tokio::spawn(async move {
2194 axum::serve(listener, app)
2195 .await
2196 .expect("serve test runtime");
2197 });
2198
2199 let mut bridge = RuntimeBridge::from_base_url_for_test(format!("http://{addr}"));
2200 let runtime_id = bridge
2201 .ensure_runtime_thread(
2202 "legacy_thread",
2203 Some(RuntimeThreadHint {
2204 model: Some("deepseek-v4".to_string()),
2205 workspace: Some(PathBuf::from("/tmp/codewhale-stdio")),
2206 }),
2207 )
2208 .await
2209 .expect("runtime thread");
2210 server.abort();
2211 let _ = server.await;
2212
2213 assert_eq!(runtime_id, "thr_runtime");
2214 assert_eq!(
2215 bridge.thread_map.get("legacy_thread").map(String::as_str),
2216 Some("thr_runtime")
2217 );
2218 }
2219
2220 const EXPECTED_CAPABILITY_METHODS: &[&str] = &[
2229 "healthz",
2230 "thread/capabilities",
2231 "thread/request",
2232 "thread/create",
2233 "thread/start",
2234 "thread/resume",
2235 "thread/fork",
2236 "thread/list",
2237 "thread/read",
2238 "thread/set_name",
2239 "thread/goal/set",
2240 "thread/goal/get",
2241 "thread/goal/clear",
2242 "thread/archive",
2243 "thread/unarchive",
2244 "thread/message",
2245 "app/capabilities",
2246 "app/request",
2247 "app/config/get",
2248 "app/config/set",
2249 "app/config/unset",
2250 "app/config/list",
2251 "app/config/reload",
2252 "app/models",
2253 "app/thread_loaded_list",
2254 "prompt/capabilities",
2255 "prompt/request",
2256 "prompt/run",
2257 "shutdown",
2258 ];
2259
2260 fn capability_test_state() -> (AppState, tempfile::TempDir) {
2261 let tmp = tempfile::tempdir().expect("tempdir");
2262 let config_path = tmp.path().join("config.toml");
2263 fs::write(&config_path, "").expect("write config");
2264 let state = build_state(Some(config_path), None).expect("state");
2265 (state, tmp)
2266 }
2267
2268 #[tokio::test]
2269 async fn capabilities_method_set_is_stable() {
2270 let (state, _tmp) = capability_test_state();
2271 let caps = dispatch_stdio_request(&state, "capabilities", json!({}))
2272 .await
2273 .expect("capabilities dispatch");
2274 let methods: Vec<String> = caps.result["methods"]
2275 .as_array()
2276 .expect("methods array")
2277 .iter()
2278 .map(|m| m.as_str().expect("method string").to_string())
2279 .collect();
2280 assert_eq!(
2281 methods, EXPECTED_CAPABILITY_METHODS,
2282 "app-server stdio capability set drifted; update the dispatcher, this \
2283 snapshot, and docs/RUNTIME_API.md together"
2284 );
2285 }
2286
2287 #[tokio::test]
2288 async fn every_advertised_capability_is_dispatchable() {
2289 let (state, _tmp) = capability_test_state();
2290 for method in EXPECTED_CAPABILITY_METHODS {
2294 if let Err(err) = dispatch_stdio_request(&state, method, json!({})).await {
2295 assert_ne!(
2296 err.code,
2297 JsonRpcError::method_not_found(method).code,
2298 "advertised capability `{method}` is not dispatchable"
2299 );
2300 }
2301 }
2302 }
2303
2304 #[test]
2307 fn auth_token_empty_string_fails() {
2308 let options = AppServerOptions {
2309 listen: "127.0.0.1:0".parse().expect("addr"),
2310 config_path: None,
2311 auth_token: Some(" ".to_string()),
2312 insecure_no_auth: false,
2313 cors_origins: Vec::new(),
2314 };
2315 let err = resolve_auth_token(&options).expect_err("empty token should fail");
2316 assert!(err.to_string().contains("cannot be empty"));
2317 }
2318
2319 #[test]
2320 fn auth_token_generated_when_none_provided() {
2321 let options = AppServerOptions {
2322 listen: "127.0.0.1:0".parse().expect("addr"),
2323 config_path: None,
2324 auth_token: None,
2325 insecure_no_auth: false,
2326 cors_origins: Vec::new(),
2327 };
2328 let token = resolve_auth_token(&options).unwrap();
2329 assert!(token.is_some());
2330 assert!(token.unwrap().starts_with("cwapp_"));
2331 }
2332
2333 #[test]
2334 fn generated_auth_status_does_not_render_token() {
2335 let rendered = app_server_auth_status_lines(false).join("\n");
2336
2337 assert!(!rendered.contains("Authorization: Bearer"));
2338 assert!(rendered.contains("not printed"));
2339 assert!(rendered.contains("CODEWHALE_APP_SERVER_TOKEN"));
2340 }
2341
2342 #[test]
2343 fn auth_token_explicit_is_preserved() {
2344 let options = AppServerOptions {
2345 listen: "127.0.0.1:0".parse().expect("addr"),
2346 config_path: None,
2347 auth_token: Some("my-secret".to_string()),
2348 insecure_no_auth: false,
2349 cors_origins: Vec::new(),
2350 };
2351 let token = resolve_auth_token(&options).unwrap();
2352 assert_eq!(token.as_deref(), Some("my-secret"));
2353 }
2354
2355 #[test]
2356 fn auth_token_explicit_allows_non_loopback_bind() {
2357 let options = AppServerOptions {
2358 listen: "0.0.0.0:8787".parse().expect("socket addr"),
2359 config_path: None,
2360 auth_token: Some("my-secret".to_string()),
2361 insecure_no_auth: false,
2362 cors_origins: Vec::new(),
2363 };
2364 let token = resolve_auth_token(&options).unwrap();
2365 assert_eq!(token.as_deref(), Some("my-secret"));
2366 }
2367
2368 #[test]
2369 fn insecure_no_auth_on_loopback_returns_none() {
2370 let options = AppServerOptions {
2371 listen: "127.0.0.1:0".parse().expect("addr"),
2372 config_path: None,
2373 auth_token: None,
2374 insecure_no_auth: true,
2375 cors_origins: Vec::new(),
2376 };
2377 let token = resolve_auth_token(&options).unwrap();
2378 assert!(token.is_none());
2379 }
2380
2381 #[test]
2382 fn insecure_no_auth_on_non_loopback_fails_fast() {
2383 let options = AppServerOptions {
2384 listen: "0.0.0.0:8787".parse().expect("socket addr"),
2385 config_path: None,
2386 auth_token: None,
2387 insecure_no_auth: true,
2388 cors_origins: Vec::new(),
2389 };
2390
2391 let err = resolve_auth_token(&options).expect_err("non-loopback unauth should fail");
2392 assert!(
2393 err.to_string()
2394 .contains("refusing unauthenticated app-server bind")
2395 );
2396 }
2397
2398 #[test]
2401 fn cors_layer_includes_default_origins() {
2402 let layer = cors_layer(&[]);
2403 let _ = layer;
2405 }
2406
2407 #[test]
2408 fn cors_layer_adds_extra_origins() {
2409 let extras = vec!["https://example.com".to_string()];
2410 let layer = cors_layer(&extras);
2411 let _ = layer;
2412 }
2413
2414 #[test]
2415 fn cors_layer_skips_empty_origins() {
2416 let extras = vec!["".to_string(), " ".to_string()];
2417 let layer = cors_layer(&extras);
2418 let _ = layer;
2419 }
2420
2421 #[test]
2424 fn params_or_object_returns_object_for_null() {
2425 let result = params_or_object(Value::Null);
2426 assert_eq!(result, json!({}));
2427 }
2428
2429 #[test]
2430 fn params_or_object_passthrough_for_non_null() {
2431 let input = json!({"key": "value"});
2432 let result = params_or_object(input.clone());
2433 assert_eq!(result, input);
2434 }
2435
2436 #[test]
2437 fn jsonrpc_result_format() {
2438 let result = jsonrpc_result(Some(json!(1)), json!({"ok": true}));
2439 assert_eq!(result["jsonrpc"], "2.0");
2440 assert_eq!(result["id"], 1);
2441 assert_eq!(result["result"]["ok"], true);
2442 }
2443
2444 #[test]
2445 fn jsonrpc_result_null_id() {
2446 let result = jsonrpc_result(None, json!(null));
2447 assert_eq!(result["id"], Value::Null);
2448 }
2449
2450 #[test]
2451 fn jsonrpc_error_format() {
2452 let err = jsonrpc_error(Some(json!(2)), JsonRpcError::internal("oops"));
2453 assert_eq!(err["jsonrpc"], "2.0");
2454 assert_eq!(err["id"], 2);
2455 assert_eq!(err["error"]["code"], -32603);
2456 assert_eq!(err["error"]["message"], "oops");
2457 }
2458
2459 #[test]
2460 fn jsonrpc_error_codes() {
2461 assert_eq!(JsonRpcError::parse_error("").code, -32700);
2462 assert_eq!(JsonRpcError::invalid_request("").code, -32600);
2463 assert_eq!(JsonRpcError::method_not_found("x").code, -32601);
2464 assert_eq!(JsonRpcError::invalid_params("").code, -32602);
2465 assert_eq!(JsonRpcError::internal("").code, -32603);
2466 }
2467
2468 #[test]
2471 fn app_server_options_debug_does_not_leak_token() {
2472 let options = AppServerOptions {
2473 listen: "127.0.0.1:8080".parse().expect("addr"),
2474 config_path: None,
2475 auth_token: Some("secret-token".to_string()),
2476 insecure_no_auth: false,
2477 cors_origins: vec!["https://example.com".to_string()],
2478 };
2479 let debug = format!("{options:?}");
2480 assert!(!debug.contains("secret-token"));
2481 assert!(debug.contains("<redacted>"));
2482 assert!(debug.contains("8080"));
2483 }
2484
2485 #[test]
2488 fn default_cors_origins_include_common_dev_ports() {
2489 assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:3000"));
2490 assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:5173"));
2491 assert!(DEFAULT_CORS_ORIGINS.contains(&"tauri://localhost"));
2492 }
2493}