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