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