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
829 .arg("app-server")
830 .arg("--http")
831 .arg("--host")
832 .arg("127.0.0.1")
833 .arg("--port")
834 .arg(port.to_string())
835 .env("CODEWHALE_RUNTIME_TOKEN", auth_token)
836 .env("DEEPSEEK_RUNTIME_TOKEN", auth_token)
837 .stdin(Stdio::null())
838 .stdout(Stdio::null())
839 .stderr(Stdio::null());
840 if let Some(config_path) = config_path {
841 command.arg("--config").arg(config_path);
842 }
843 Ok(command)
844 }
845
846 async fn wait_until_ready(&mut self) -> Result<()> {
847 let deadline = Instant::now() + Duration::from_secs(15);
848 loop {
849 if let Some(child) = self.child.as_mut()
850 && let Some(status) = child.try_wait()?
851 {
852 return Err(anyhow!(
853 "runtime API bridge exited before becoming ready (status {status})"
854 ));
855 }
856
857 match self
858 .client
859 .get(format!("{}/health", self.base_url))
860 .send()
861 .await
862 {
863 Ok(response) if response.status().is_success() => return Ok(()),
864 _ if Instant::now() >= deadline => {
865 bail!(
866 "timed out waiting for runtime API bridge at {}/health",
867 self.base_url
868 )
869 }
870 _ => tokio::time::sleep(Duration::from_millis(50)).await,
871 }
872 }
873 }
874
875 fn authed(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
876 match self.auth_token.as_deref() {
877 Some(token) => builder.bearer_auth(token),
878 None => builder,
879 }
880 }
881
882 async fn request_json(&self, builder: reqwest::RequestBuilder) -> Result<Value> {
883 let response = builder.send().await?;
884 let status = response.status();
885 let body = response.text().await?;
886 if !status.is_success() {
887 let detail = body.trim();
888 if detail.is_empty() {
889 bail!("runtime API returned {status}");
890 }
891 bail!("runtime API returned {status}: {detail}");
892 }
893 serde_json::from_str(&body).with_context(|| format!("invalid runtime API json: {body}"))
894 }
895
896 async fn ensure_runtime_thread(
897 &mut self,
898 stdio_thread_id: &str,
899 hint: Option<RuntimeThreadHint>,
900 ) -> Result<String> {
901 if let Some(runtime_thread_id) = self.thread_map.get(stdio_thread_id) {
902 return Ok(runtime_thread_id.clone());
903 }
904 let hint = hint.unwrap_or_default();
905 let runtime_thread_id = self
906 .create_runtime_thread(hint.model, hint.workspace)
907 .await?;
908 self.thread_map
909 .insert(stdio_thread_id.to_string(), runtime_thread_id.clone());
910 Ok(runtime_thread_id)
911 }
912
913 async fn create_runtime_thread(
914 &mut self,
915 model: Option<String>,
916 workspace: Option<PathBuf>,
917 ) -> Result<String> {
918 let record = self
919 .request_json(
920 self.authed(self.client.post(format!("{}/v1/threads", self.base_url)))
921 .json(&json!({
922 "model": model,
923 "workspace": workspace,
924 "mode": "agent",
925 "archived": false,
926 })),
927 )
928 .await?;
929 let thread_id = extract_runtime_thread_id(&record)?.to_string();
930 self.last_seq_by_thread
931 .entry(thread_id.clone())
932 .or_insert(0);
933 Ok(thread_id)
934 }
935
936 async fn message_thread<W: AsyncWrite + Unpin>(
937 &mut self,
938 thread_id: &str,
939 input: &str,
940 writer: &mut W,
941 ) -> Result<Value> {
942 let turn = self
943 .request_json(
944 self.authed(
945 self.client
946 .post(format!("{}/v1/threads/{thread_id}/turns", self.base_url)),
947 )
948 .json(&json!({ "prompt": input })),
949 )
950 .await?;
951 let turn_id = turn
952 .pointer("/turn/id")
953 .and_then(Value::as_str)
954 .ok_or_else(|| anyhow!("runtime API turn response missing turn.id"))?
955 .to_string();
956 let response_id = format!("{thread_id}:{turn_id}");
957
958 emit_stdio_event(
959 writer,
960 json!({
961 "type": "response_start",
962 "response_id": response_id,
963 }),
964 )
965 .await?;
966
967 let since_seq = self.last_seq_by_thread.get(thread_id).copied().unwrap_or(0);
968 let stream_result = self
969 .stream_turn_events(thread_id, &turn_id, &response_id, writer, since_seq)
970 .await;
971
972 let _ = emit_stdio_event(
973 writer,
974 json!({
975 "type": "response_end",
976 "response_id": response_id,
977 }),
978 )
979 .await;
980
981 let (last_seq, status, error) = stream_result?;
982 self.last_seq_by_thread
983 .insert(thread_id.to_string(), last_seq);
984
985 match status {
986 TurnTerminalStatus::Completed => Ok(json!({
987 "thread_id": thread_id,
988 "status": "accepted",
989 "thread": Value::Null,
990 "threads": [],
991 "model": Value::Null,
992 "model_provider": Value::Null,
993 "cwd": Value::Null,
994 "approval_policy": Value::Null,
995 "sandbox": Value::Null,
996 "events": [],
997 "data": { "turn_id": turn_id },
998 })),
999 TurnTerminalStatus::Failed => Err(anyhow!(
1000 "{}",
1001 error.unwrap_or_else(|| "turn failed".to_string())
1002 )),
1003 TurnTerminalStatus::Interrupted => Err(anyhow!(
1004 "{}",
1005 error.unwrap_or_else(|| "turn interrupted".to_string())
1006 )),
1007 TurnTerminalStatus::Canceled => Err(anyhow!(
1008 "{}",
1009 error.unwrap_or_else(|| "turn canceled".to_string())
1010 )),
1011 }
1012 }
1013
1014 async fn stream_turn_events<W: AsyncWrite + Unpin>(
1015 &self,
1016 thread_id: &str,
1017 turn_id: &str,
1018 response_id: &str,
1019 writer: &mut W,
1020 since_seq: u64,
1021 ) -> Result<(u64, TurnTerminalStatus, Option<String>)> {
1022 let mut response = self
1023 .authed(self.client.get(format!(
1024 "{}/v1/threads/{thread_id}/events?since_seq={since_seq}",
1025 self.base_url
1026 )))
1027 .send()
1028 .await?
1029 .error_for_status()?;
1030
1031 let mut buffer = Vec::new();
1032 let mut last_seq = since_seq;
1033
1034 while let Some(chunk) = response.chunk().await? {
1035 buffer.extend_from_slice(&chunk);
1036 if buffer.len() > MAX_SSE_FRAME_BYTES {
1037 bail!(
1038 "runtime SSE frame exceeded {MAX_SSE_FRAME_BYTES} bytes without a frame delimiter"
1039 );
1040 }
1041 while let Some(frame_bytes) = take_sse_frame(&mut buffer) {
1042 let Some((event_name, frame_data)) = parse_sse_frame(&frame_bytes) else {
1043 continue;
1044 };
1045 let envelope: Value = serde_json::from_str(&frame_data)
1046 .with_context(|| format!("invalid SSE json for {event_name}: {frame_data}"))?;
1047 if let Some(seq) = envelope.get("seq").and_then(Value::as_u64) {
1048 last_seq = last_seq.max(seq);
1049 }
1050 if envelope.get("turn_id").and_then(Value::as_str) != Some(turn_id) {
1051 continue;
1052 }
1053 let payload = envelope.get("payload").cloned().unwrap_or(Value::Null);
1054 match event_name.as_str() {
1055 "item.delta" => {
1056 let kind = payload
1057 .get("kind")
1058 .and_then(Value::as_str)
1059 .unwrap_or_default();
1060 if kind == "agent_message"
1061 && let Some(delta) = payload.get("delta").and_then(Value::as_str)
1062 && !delta.is_empty()
1063 {
1064 emit_stdio_event(
1065 writer,
1066 json!({
1067 "type": "response_delta",
1068 "response_id": response_id,
1069 "delta": delta,
1070 }),
1071 )
1072 .await?;
1073 }
1074 }
1075 "turn.completed" => {
1076 let status = turn_terminal_status(&payload);
1077 let error = payload
1078 .pointer("/turn/error")
1079 .and_then(Value::as_str)
1080 .map(str::to_string);
1081 return Ok((last_seq, status, error));
1082 }
1083 _ => {}
1084 }
1085 }
1086 }
1087
1088 bail!("runtime event stream ended before turn.completed")
1089 }
1090
1091 #[cfg(test)]
1092 fn from_base_url_for_test(base_url: String) -> Self {
1093 install_rustls_crypto_provider();
1094 Self {
1095 base_url,
1096 client: codewhale_release::platform_http_client_builder()
1097 .timeout(Duration::from_secs(5))
1098 .build()
1099 .expect("build reqwest test client"),
1100 auth_token: None,
1101 child: None,
1102 thread_map: HashMap::new(),
1103 last_seq_by_thread: HashMap::new(),
1104 }
1105 }
1106}
1107
1108impl RuntimeBridge {
1109 fn shutdown_child(&mut self) {
1112 if let Some(mut child) = self.child.take() {
1113 let _ = child.kill();
1114 std::thread::spawn(move || {
1115 let _ = child.wait();
1116 });
1117 }
1118 }
1119}
1120
1121impl Drop for RuntimeBridge {
1122 fn drop(&mut self) {
1123 self.shutdown_child();
1124 }
1125}
1126
1127fn reserve_runtime_port() -> Result<u16> {
1128 let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
1129 Ok(listener.local_addr()?.port())
1130}
1131
1132fn install_rustls_crypto_provider() {
1133 let _ = rustls::crypto::ring::default_provider().install_default();
1134}
1135
1136fn extract_runtime_thread_id(record: &Value) -> Result<&str> {
1137 record
1138 .get("id")
1139 .and_then(Value::as_str)
1140 .ok_or_else(|| anyhow!("runtime API thread response missing id"))
1141}
1142
1143fn turn_terminal_status(payload: &Value) -> TurnTerminalStatus {
1144 match payload
1145 .pointer("/turn/status")
1146 .and_then(Value::as_str)
1147 .unwrap_or("completed")
1148 .to_ascii_lowercase()
1149 .as_str()
1150 {
1151 "failed" => TurnTerminalStatus::Failed,
1152 "interrupted" => TurnTerminalStatus::Interrupted,
1153 "canceled" | "cancelled" => TurnTerminalStatus::Canceled,
1154 _ => TurnTerminalStatus::Completed,
1155 }
1156}
1157
1158async fn emit_stdio_event<W: AsyncWrite + Unpin>(writer: &mut W, event: Value) -> Result<()> {
1159 writer.write_all(&serde_json::to_vec(&event)?).await?;
1160 writer.write_all(b"\n").await?;
1161 writer.flush().await?;
1162 Ok(())
1163}
1164
1165fn take_sse_frame(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
1166 if let Some(pos) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
1167 return Some(buffer.drain(..pos + 4).collect());
1168 }
1169 buffer
1170 .windows(2)
1171 .position(|window| window == b"\n\n")
1172 .map(|pos| buffer.drain(..pos + 2).collect())
1173}
1174
1175fn parse_sse_frame(frame_bytes: &[u8]) -> Option<(String, String)> {
1176 let text = String::from_utf8(frame_bytes.to_vec()).ok()?;
1177 let mut event_name = None;
1178 let mut data_lines = Vec::new();
1179 for raw_line in text.lines() {
1180 let line = raw_line.trim_end_matches('\r');
1181 if let Some(value) = line.strip_prefix("event:") {
1182 event_name = Some(value.trim().to_string());
1183 } else if let Some(value) = line.strip_prefix("data:") {
1184 data_lines.push(value.trim_start().to_string());
1185 }
1186 }
1187 match (event_name, data_lines.is_empty()) {
1188 (Some(event), false) => Some((event, data_lines.join("\n"))),
1189 _ => None,
1190 }
1191}
1192
1193#[cfg(test)]
1194async fn dispatch_stdio_request(
1195 state: &AppState,
1196 method: &str,
1197 params: Value,
1198) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
1199 let mut sink = tokio::io::sink();
1200 dispatch_stdio_request_with_writer(state, &mut sink, method, params).await
1201}
1202
1203async fn dispatch_stdio_app_request(
1204 state: &AppState,
1205 request: AppRequest,
1206) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
1207 let response = Box::pin(process_app_request(state, request, AppTransport::Stdio)).await;
1208 Ok(StdioDispatchResult {
1209 result: serde_json::to_value(response)
1210 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1211 should_exit: false,
1212 })
1213}
1214
1215async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
1216 state: &AppState,
1217 writer: &mut W,
1218 method: &str,
1219 params: Value,
1220) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
1221 let outcome = match method {
1222 "healthz" | "app/healthz" => StdioDispatchResult {
1223 result: json!({
1224 "status": "ok",
1225 "service": legacy_deepseek_compat::SERVICE_NAME,
1226 "transport": "stdio"
1227 }),
1228 should_exit: false,
1229 },
1230 "capabilities" => StdioDispatchResult {
1231 result: json!({
1232 "transport": "stdio",
1233 "families": ["thread/*", "app/*", "prompt/*"],
1234 "methods": [
1235 "healthz",
1236 "thread/capabilities",
1237 "thread/request",
1238 "thread/create",
1239 "thread/start",
1240 "thread/resume",
1241 "thread/fork",
1242 "thread/list",
1243 "thread/read",
1244 "thread/set_name",
1245 "thread/goal/set",
1246 "thread/goal/get",
1247 "thread/goal/clear",
1248 "thread/archive",
1249 "thread/unarchive",
1250 "thread/message",
1251 "app/capabilities",
1252 "app/request",
1253 "app/config/get",
1254 "app/config/set",
1255 "app/config/unset",
1256 "app/config/list",
1257 "app/config/reload",
1258 "app/models",
1259 "app/thread_loaded_list",
1260 "prompt/capabilities",
1261 "prompt/request",
1262 "prompt/run",
1263 "shutdown"
1264 ]
1265 }),
1266 should_exit: false,
1267 },
1268 "thread/capabilities" => StdioDispatchResult {
1269 result: json!({
1270 "methods": [
1271 "thread/request",
1272 "thread/create",
1273 "thread/start",
1274 "thread/resume",
1275 "thread/fork",
1276 "thread/list",
1277 "thread/read",
1278 "thread/set_name",
1279 "thread/goal/set",
1280 "thread/goal/get",
1281 "thread/goal/clear",
1282 "thread/archive",
1283 "thread/unarchive",
1284 "thread/message"
1285 ]
1286 }),
1287 should_exit: false,
1288 },
1289 "thread/request" => {
1290 let request: ThreadRequest = parse_params(params)?;
1291 if let ThreadRequest::Message { thread_id, input } = request {
1292 let response = handle_stdio_thread_message(
1293 state,
1294 writer,
1295 ThreadMessageParams { thread_id, input },
1296 )
1297 .await?;
1298 return Ok(StdioDispatchResult {
1299 result: response,
1300 should_exit: false,
1301 });
1302 }
1303 let should_record_hint = matches!(
1304 &request,
1305 ThreadRequest::Create { .. }
1306 | ThreadRequest::Start(_)
1307 | ThreadRequest::Resume(_)
1308 | ThreadRequest::Fork(_)
1309 );
1310 let response = handle_thread_request(state, request).await?;
1311 if should_record_hint {
1312 record_stdio_thread_hint(state, &response).await;
1313 }
1314 StdioDispatchResult {
1315 result: serde_json::to_value(response)
1316 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1317 should_exit: false,
1318 }
1319 }
1320 "thread/create" => {
1321 #[derive(Debug, Deserialize)]
1322 struct CreateParams {
1323 #[serde(default)]
1324 metadata: Value,
1325 }
1326 let parsed: CreateParams = parse_params(params_or_object(params))?;
1327 let response = handle_thread_request(
1328 state,
1329 ThreadRequest::Create {
1330 metadata: parsed.metadata,
1331 },
1332 )
1333 .await?;
1334 record_stdio_thread_hint(state, &response).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/start" => {
1342 let request = ThreadRequest::Start(parse_params(params_or_object(params))?);
1343 let response = handle_thread_request(state, request).await?;
1344 record_stdio_thread_hint(state, &response).await;
1345 StdioDispatchResult {
1346 result: serde_json::to_value(response)
1347 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1348 should_exit: false,
1349 }
1350 }
1351 "thread/resume" => {
1352 let request = ThreadRequest::Resume(parse_params(params_or_object(params))?);
1353 let response = handle_thread_request(state, request).await?;
1354 record_stdio_thread_hint(state, &response).await;
1355 StdioDispatchResult {
1356 result: serde_json::to_value(response)
1357 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1358 should_exit: false,
1359 }
1360 }
1361 "thread/fork" => {
1362 let request = ThreadRequest::Fork(parse_params(params_or_object(params))?);
1363 let response = handle_thread_request(state, request).await?;
1364 record_stdio_thread_hint(state, &response).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/list" => {
1372 let request = ThreadRequest::List(parse_params(params_or_object(params))?);
1373 let response = handle_thread_request(state, request).await?;
1374 StdioDispatchResult {
1375 result: serde_json::to_value(response)
1376 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1377 should_exit: false,
1378 }
1379 }
1380 "thread/read" => {
1381 let request = ThreadRequest::Read(parse_params(params_or_object(params))?);
1382 let response = handle_thread_request(state, request).await?;
1383 StdioDispatchResult {
1384 result: serde_json::to_value(response)
1385 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1386 should_exit: false,
1387 }
1388 }
1389 "thread/set_name" | "thread/set-name" => {
1390 let request = ThreadRequest::SetName(parse_params(params_or_object(params))?);
1391 let response = handle_thread_request(state, request).await?;
1392 StdioDispatchResult {
1393 result: serde_json::to_value(response)
1394 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1395 should_exit: false,
1396 }
1397 }
1398 "thread/goal/set" | "thread/goal_set" | "thread/goal-set" => {
1399 let request = ThreadRequest::GoalSet(parse_params::<ThreadGoalSetParams>(
1400 params_or_object(params),
1401 )?);
1402 let response = handle_thread_request(state, request).await?;
1403 StdioDispatchResult {
1404 result: serde_json::to_value(response)
1405 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1406 should_exit: false,
1407 }
1408 }
1409 "thread/goal/get" | "thread/goal_get" | "thread/goal-get" => {
1410 let request = ThreadRequest::GoalGet(parse_params::<ThreadGoalGetParams>(
1411 params_or_object(params),
1412 )?);
1413 let response = handle_thread_request(state, request).await?;
1414 StdioDispatchResult {
1415 result: serde_json::to_value(response)
1416 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1417 should_exit: false,
1418 }
1419 }
1420 "thread/goal/clear" | "thread/goal_clear" | "thread/goal-clear" => {
1421 let request = ThreadRequest::GoalClear(parse_params::<ThreadGoalClearParams>(
1422 params_or_object(params),
1423 )?);
1424 let response = handle_thread_request(state, request).await?;
1425 StdioDispatchResult {
1426 result: serde_json::to_value(response)
1427 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1428 should_exit: false,
1429 }
1430 }
1431 "thread/archive" => {
1432 let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
1433 let response = handle_thread_request(
1434 state,
1435 ThreadRequest::Archive {
1436 thread_id: parsed.thread_id,
1437 },
1438 )
1439 .await?;
1440 StdioDispatchResult {
1441 result: serde_json::to_value(response)
1442 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1443 should_exit: false,
1444 }
1445 }
1446 "thread/unarchive" => {
1447 let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
1448 let response = handle_thread_request(
1449 state,
1450 ThreadRequest::Unarchive {
1451 thread_id: parsed.thread_id,
1452 },
1453 )
1454 .await?;
1455 StdioDispatchResult {
1456 result: serde_json::to_value(response)
1457 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1458 should_exit: false,
1459 }
1460 }
1461 "thread/message" => {
1462 let parsed: ThreadMessageParams = parse_params(params_or_object(params))?;
1463 let response = handle_stdio_thread_message(state, writer, parsed).await?;
1464 StdioDispatchResult {
1465 result: response,
1466 should_exit: false,
1467 }
1468 }
1469 "app/capabilities" => dispatch_stdio_app_request(state, AppRequest::Capabilities).await?,
1470 "app/request" => {
1471 let request: AppRequest = parse_params(params)?;
1472 dispatch_stdio_app_request(state, request).await?
1473 }
1474 "app/config/get" => {
1475 let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
1476 dispatch_stdio_app_request(state, AppRequest::ConfigGet { key: parsed.key }).await?
1477 }
1478 "app/config/set" => {
1479 let parsed: ConfigSetParams = parse_params(params_or_object(params))?;
1480 dispatch_stdio_app_request(
1481 state,
1482 AppRequest::ConfigSet {
1483 key: parsed.key,
1484 value: parsed.value,
1485 },
1486 )
1487 .await?
1488 }
1489 "app/config/unset" => {
1490 let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
1491 dispatch_stdio_app_request(state, AppRequest::ConfigUnset { key: parsed.key }).await?
1492 }
1493 "app/config/list" => dispatch_stdio_app_request(state, AppRequest::ConfigList).await?,
1494 "app/config/reload" => dispatch_stdio_app_request(state, AppRequest::ConfigReload).await?,
1495 "app/models" => dispatch_stdio_app_request(state, AppRequest::Models).await?,
1496 "app/thread_loaded_list" | "app/thread-loaded-list" => {
1497 dispatch_stdio_app_request(state, AppRequest::ThreadLoadedList).await?
1498 }
1499 "prompt/capabilities" => StdioDispatchResult {
1500 result: json!({
1501 "methods": ["prompt/request", "prompt/run"]
1502 }),
1503 should_exit: false,
1504 },
1505 "prompt/request" | "prompt/run" => {
1506 let request: PromptRequest = parse_params(params)?;
1507 let response = handle_prompt_request(state, request).await?;
1508 StdioDispatchResult {
1509 result: serde_json::to_value(response)
1510 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1511 should_exit: false,
1512 }
1513 }
1514 "shutdown" => {
1515 if let Some(bridge) = state.stdio_bridge.lock().await.take() {
1516 bridge.lock().await.shutdown_child();
1517 }
1518 StdioDispatchResult {
1519 result: json!({"ok": true, "status": "stopped"}),
1520 should_exit: true,
1521 }
1522 }
1523 _ => return Err(JsonRpcError::method_not_found(method)),
1524 };
1525 Ok(outcome)
1526}
1527
1528async fn process_app_request(
1529 state: &AppState,
1530 req: AppRequest,
1531 _transport: AppTransport,
1532) -> AppResponse {
1533 match req {
1534 AppRequest::Capabilities => AppResponse {
1535 ok: true,
1536 data: json!({
1537 "routes": ["/thread", "/app", "/prompt", "/tool", "/jobs", "/mcp/startup"],
1538 "config": ["get", "set", "unset", "list", "reload"],
1539 "events": ["response_start", "response_delta", "response_end", "tool_call_start", "tool_call_result", "mcp_startup_update", "mcp_startup_complete"],
1540 "transport": "stdio+http",
1541 "config_path": state.config_path.as_ref().map(|p| p.display().to_string()),
1542 }),
1543 events: Vec::new(),
1544 },
1545 AppRequest::ConfigGet { key } => {
1546 let cfg = state.config.read().await;
1547 let value = cfg.get_display_value(&key);
1548 AppResponse {
1549 ok: true,
1550 data: json!({ "key": key, "value": value }),
1551 events: Vec::new(),
1552 }
1553 }
1554 AppRequest::ConfigSet { key, value } => {
1555 let (result, snapshot) = {
1556 let mut cfg = state.config.write().await;
1557 let result = cfg.set_value(&key, &value);
1558 (result, cfg.clone())
1559 };
1560 let ok = result.is_ok();
1561 let message = result.err().map(|e| e.to_string());
1562 apply_config_update(state, snapshot, None, true).await;
1563 AppResponse {
1564 ok,
1565 data: json!({ "key": key, "value": value, "error": message }),
1566 events: Vec::new(),
1567 }
1568 }
1569 AppRequest::ConfigUnset { key } => {
1570 let (result, snapshot) = {
1571 let mut cfg = state.config.write().await;
1572 let result = cfg.unset_value(&key);
1573 (result, cfg.clone())
1574 };
1575 let ok = result.is_ok();
1576 let message = result.err().map(|e| e.to_string());
1577 apply_config_update(state, snapshot, None, true).await;
1578 AppResponse {
1579 ok,
1580 data: json!({ "key": key, "error": message }),
1581 events: Vec::new(),
1582 }
1583 }
1584 AppRequest::ConfigList => {
1585 let cfg = state.config.read().await;
1586 AppResponse {
1587 ok: true,
1588 data: json!({ "values": cfg.list_values() }),
1589 events: Vec::new(),
1590 }
1591 }
1592 AppRequest::ConfigReload => {
1593 let store = match ConfigStore::load(state.config_path.clone()) {
1603 Ok(store) => store,
1604 Err(e) => {
1605 return AppResponse {
1606 ok: false,
1607 data: json!({ "error": format!("failed to load config: {e}") }),
1608 events: Vec::new(),
1609 };
1610 }
1611 };
1612 let new_config = store.config.clone();
1613 let new_exec_policy = store.exec_policy_engine();
1614
1615 apply_config_update(state, new_config, Some(new_exec_policy), false).await;
1619
1620 AppResponse {
1621 ok: true,
1622 data: json!({ "reloaded": true }),
1623 events: Vec::new(),
1624 }
1625 }
1626 AppRequest::Models => AppResponse {
1627 ok: true,
1628 data: json!({ "models": state.registry.list() }),
1629 events: Vec::new(),
1630 },
1631 AppRequest::ThreadLoadedList => {
1632 let mut runtime = state.runtime.write().await;
1633 let response = runtime
1634 .handle_thread(codewhale_protocol::ThreadRequest::List(
1635 codewhale_protocol::ThreadListParams {
1636 include_archived: false,
1637 limit: Some(50),
1638 },
1639 ))
1640 .await;
1641 match response {
1642 Ok(thread_resp) => AppResponse {
1643 ok: true,
1644 data: json!({ "threads": thread_resp.threads }),
1645 events: thread_resp.events,
1646 },
1647 Err(err) => AppResponse {
1648 ok: false,
1649 data: json!({ "error": err.to_string() }),
1650 events: Vec::new(),
1651 },
1652 }
1653 }
1654 AppRequest::SubmitUserInput {
1655 request_id,
1656 answers,
1657 } => {
1658 let mut pending = state.pending_user_input.lock().await;
1664 if pending.contains_key(&request_id) {
1665 return AppResponse {
1666 ok: false,
1667 data: json!({
1668 "error": "request_id already resolved",
1669 "request_id": request_id,
1670 }),
1671 events: Vec::new(),
1672 };
1673 }
1674 pending.insert(request_id.clone(), answers);
1675 AppResponse {
1676 ok: true,
1677 data: json!({ "request_id": request_id, "resolved": true }),
1678 events: Vec::new(),
1679 }
1680 }
1681 }
1682}
1683
1684async fn apply_config_update(
1696 state: &AppState,
1697 snapshot: codewhale_config::ConfigToml,
1698 exec_policy: Option<codewhale_execpolicy::ExecPolicyEngine>,
1699 persist: bool,
1700) {
1701 if persist && let Err(e) = persist_config(state, snapshot.clone()).await {
1702 tracing::error!("Failed to persist config update: {e}");
1703 }
1704 {
1705 let mut cfg = state.config.write().await;
1706 *cfg = snapshot.clone();
1707 }
1708 {
1713 let mut runtime = state.runtime.write().await;
1714 match exec_policy {
1715 Some(policy) => runtime.reload_config_and_policy(snapshot, policy),
1716 None => runtime.update_config(snapshot),
1717 }
1718 }
1719 invalidate_stdio_bridge(state).await;
1720}
1721
1722async fn persist_config(state: &AppState, config: codewhale_config::ConfigToml) -> Result<()> {
1723 if state.config_path.is_none() {
1724 return Ok(());
1725 }
1726 let mut store = ConfigStore::load(state.config_path.clone())?;
1727 store.config = config;
1728 store.save()
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733 use super::*;
1734 use axum::body::{Body, to_bytes};
1735 use axum::extract::{Path as AxumPath, Query};
1736 use axum::http::header;
1737 use codewhale_protocol::AppRequest;
1738 use std::collections::HashMap;
1739 use std::fs;
1740 use tokio::io::AsyncReadExt;
1741 use tower::ServiceExt;
1742
1743 fn app_with_config(auth_token: Option<&str>) -> (Router, tempfile::TempDir) {
1744 let tmp = tempfile::tempdir().expect("tempdir");
1745 let config_path = tmp.path().join("config.toml");
1746 fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1747 let state = build_state(
1748 Some(config_path),
1749 auth_token.map(std::string::ToString::to_string),
1750 )
1751 .expect("state");
1752 (app_router(state, &[]), tmp)
1753 }
1754
1755 #[test]
1756 fn build_state_keeps_resolved_explicit_config_path() {
1757 let tmp = tempfile::tempdir().expect("tempdir");
1758 let config_dir = tmp.path().join("config-dir");
1759 fs::create_dir_all(&config_dir).expect("config dir");
1760 let config_path = config_dir.join("config.toml");
1761 fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1762
1763 let state = build_state(Some(config_path.clone()), None).expect("state");
1764
1765 assert_eq!(
1766 state.config_path.as_deref(),
1767 Some(
1768 config_path
1769 .canonicalize()
1770 .expect("canonical config")
1771 .as_path()
1772 )
1773 );
1774 }
1775
1776 async fn response_body_json(response: Response) -> Value {
1777 let bytes = to_bytes(response.into_body(), usize::MAX)
1778 .await
1779 .expect("body bytes");
1780 serde_json::from_slice(&bytes).expect("json response")
1781 }
1782
1783 #[tokio::test]
1784 async fn http_app_routes_require_bearer_token_when_auth_enabled() {
1785 let (app, _tmp) = app_with_config(Some("test-token"));
1786 let response = app
1787 .oneshot(
1788 Request::builder()
1789 .method(Method::POST)
1790 .uri("/app")
1791 .header(header::CONTENT_TYPE, "application/json")
1792 .body(Body::from(
1793 serde_json::to_vec(&AppRequest::ConfigGet {
1794 key: "api_key".to_string(),
1795 })
1796 .expect("request json"),
1797 ))
1798 .expect("request"),
1799 )
1800 .await
1801 .expect("response");
1802
1803 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1804 }
1805
1806 #[tokio::test]
1807 async fn http_config_get_redacts_sensitive_values_after_auth() {
1808 let (app, _tmp) = app_with_config(Some("test-token"));
1809 let response = app
1810 .oneshot(
1811 Request::builder()
1812 .method(Method::POST)
1813 .uri("/app")
1814 .header(header::AUTHORIZATION, "Bearer test-token")
1815 .header(header::CONTENT_TYPE, "application/json")
1816 .body(Body::from(
1817 serde_json::to_vec(&AppRequest::ConfigGet {
1818 key: "api_key".to_string(),
1819 })
1820 .expect("request json"),
1821 ))
1822 .expect("request"),
1823 )
1824 .await
1825 .expect("response");
1826
1827 assert_eq!(response.status(), StatusCode::OK);
1828 let body = response_body_json(response).await;
1829 assert_eq!(body["data"]["value"], "sk-d***cret");
1830 }
1831
1832 #[tokio::test]
1833 async fn cors_does_not_allow_arbitrary_origins() {
1834 let (app, _tmp) = app_with_config(Some("test-token"));
1835 let response = app
1836 .oneshot(
1837 Request::builder()
1838 .method(Method::GET)
1839 .uri("/healthz")
1840 .header(header::ORIGIN, "https://attacker.example")
1841 .body(Body::empty())
1842 .expect("request"),
1843 )
1844 .await
1845 .expect("response");
1846
1847 assert_eq!(response.status(), StatusCode::OK);
1848 assert!(
1849 response
1850 .headers()
1851 .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
1852 .is_none()
1853 );
1854 }
1855
1856 #[tokio::test]
1857 async fn build_state_loads_permissions_into_runtime_policy() {
1858 let tmp = tempfile::tempdir().expect("tempdir");
1859 let config_path = tmp.path().join("config.toml");
1860 fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1861 fs::write(
1862 tmp.path().join("permissions.toml"),
1863 r#"
1864 [[rules]]
1865 tool = "exec_shell"
1866 command = "cargo test"
1867 "#,
1868 )
1869 .expect("write permissions");
1870
1871 let state = build_state(Some(config_path), None).expect("state");
1872 let runtime = state.runtime.read().await;
1873 let decision = runtime
1874 .exec_policy
1875 .check(codewhale_execpolicy::ExecPolicyContext {
1876 command: "cargo test --workspace",
1877 cwd: "/workspace",
1878 tool: Some("exec_shell"),
1879 path: None,
1880 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1881 sandbox_mode: Some("workspace-write"),
1882 })
1883 .expect("policy check");
1884
1885 assert!(decision.allow);
1886 assert!(decision.requires_approval);
1887 assert_eq!(
1888 decision.matched_rule.as_deref(),
1889 Some("tool=exec_shell command=cargo test")
1890 );
1891 }
1892
1893 #[tokio::test]
1894 async fn config_reload_refreshes_runtime_config_and_exec_policy_from_disk() {
1895 let tmp = tempfile::tempdir().expect("tempdir");
1896 let config_path = tmp.path().join("config.toml");
1897 fs::write(
1898 &config_path,
1899 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
1900 )
1901 .expect("write config");
1902 let state = build_state(Some(config_path.clone()), None).expect("state");
1904
1905 {
1907 let runtime = state.runtime.read().await;
1908 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat"));
1909 let decision = runtime
1910 .exec_policy
1911 .check(codewhale_execpolicy::ExecPolicyContext {
1912 command: "cargo test",
1913 cwd: "/workspace",
1914 tool: Some("exec_shell"),
1915 path: None,
1916 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1917 sandbox_mode: Some("workspace-write"),
1918 })
1919 .expect("policy check");
1920 assert!(decision.matched_rule.is_none());
1921 }
1922
1923 fs::write(
1925 &config_path,
1926 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-reasoner\"\n",
1927 )
1928 .expect("rewrite config");
1929 fs::write(
1930 tmp.path().join("permissions.toml"),
1931 r#"
1932 [[rules]]
1933 tool = "exec_shell"
1934 command = "cargo test"
1935 "#,
1936 )
1937 .expect("write permissions");
1938
1939 let response =
1942 process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await;
1943 assert!(response.ok, "reload should succeed");
1944 assert_eq!(response.data["reloaded"], true);
1945
1946 {
1948 let cfg = state.config.read().await;
1949 assert_eq!(cfg.model.as_deref(), Some("deepseek-reasoner"));
1950 }
1951 {
1953 let runtime = state.runtime.read().await;
1954 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-reasoner"));
1955 let decision = runtime
1956 .exec_policy
1957 .check(codewhale_execpolicy::ExecPolicyContext {
1958 command: "cargo test --workspace",
1959 cwd: "/workspace",
1960 tool: Some("exec_shell"),
1961 path: None,
1962 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
1963 sandbox_mode: Some("workspace-write"),
1964 })
1965 .expect("policy check");
1966 assert!(decision.allow);
1967 assert!(decision.requires_approval);
1968 assert_eq!(
1969 decision.matched_rule.as_deref(),
1970 Some("tool=exec_shell command=cargo test")
1971 );
1972 }
1973 }
1974
1975 #[tokio::test]
1976 async fn config_set_propagates_to_runtime_config_without_touching_exec_policy() {
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 let response = process_app_request(
1989 &state,
1990 AppRequest::ConfigSet {
1991 key: "model".to_string(),
1992 value: "deepseek-reasoner".to_string(),
1993 },
1994 AppTransport::Stdio,
1995 )
1996 .await;
1997 assert!(response.ok, "set should succeed");
1998
1999 {
2001 let runtime = state.runtime.read().await;
2002 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-reasoner"));
2003 let decision = runtime
2005 .exec_policy
2006 .check(codewhale_execpolicy::ExecPolicyContext {
2007 command: "cargo test",
2008 cwd: "/workspace",
2009 tool: Some("exec_shell"),
2010 path: None,
2011 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
2012 sandbox_mode: Some("workspace-write"),
2013 })
2014 .expect("policy check");
2015 assert!(decision.matched_rule.is_none());
2016 }
2017 let persisted = fs::read_to_string(&config_path).expect("read config");
2019 assert!(persisted.contains("deepseek-reasoner"));
2020 }
2021
2022 #[tokio::test]
2023 async fn config_unset_propagates_to_runtime_config() {
2024 let tmp = tempfile::tempdir().expect("tempdir");
2025 let config_path = tmp.path().join("config.toml");
2026 fs::write(
2027 &config_path,
2028 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
2029 )
2030 .expect("write config");
2031 let state = build_state(Some(config_path.clone()), None).expect("state");
2032
2033 {
2035 let runtime = state.runtime.read().await;
2036 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat"));
2037 }
2038
2039 let response = process_app_request(
2043 &state,
2044 AppRequest::ConfigUnset {
2045 key: "model".to_string(),
2046 },
2047 AppTransport::Stdio,
2048 )
2049 .await;
2050 assert!(response.ok, "unset should succeed");
2051
2052 {
2054 let runtime = state.runtime.read().await;
2055 assert!(runtime.config.model.is_none());
2056 }
2057 {
2059 let cfg = state.config.read().await;
2060 assert!(cfg.model.is_none());
2061 }
2062 let persisted = fs::read_to_string(&config_path).expect("read config");
2064 assert!(!persisted.contains("deepseek-chat"));
2065 }
2066
2067 #[tokio::test]
2068 async fn config_reload_returns_error_when_disk_config_is_invalid() {
2069 let tmp = tempfile::tempdir().expect("tempdir");
2070 let config_path = tmp.path().join("config.toml");
2071 fs::write(
2072 &config_path,
2073 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
2074 )
2075 .expect("write config");
2076 let state = build_state(Some(config_path.clone()), None).expect("state");
2077
2078 fs::write(&config_path, "api_key = \"unterminated\n").expect("corrupt config");
2080
2081 let response =
2082 process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await;
2083 assert!(!response.ok, "reload of corrupt config must fail");
2084 let err = response.data["error"]
2085 .as_str()
2086 .expect("error message present")
2087 .to_string();
2088 assert!(
2089 err.contains("failed to load config"),
2090 "error should mention load failure, got: {err}"
2091 );
2092
2093 {
2096 let runtime = state.runtime.read().await;
2097 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat"));
2098 }
2099 {
2100 let cfg = state.config.read().await;
2101 assert_eq!(cfg.model.as_deref(), Some("deepseek-chat"));
2102 }
2103 }
2104
2105 async fn seed_test_bridge(state: &AppState) -> SharedRuntimeBridge {
2106 let bridge = Arc::new(Mutex::new(RuntimeBridge::from_base_url_for_test(
2107 "http://127.0.0.1:9".to_string(),
2108 )));
2109 *state.stdio_bridge.lock().await = Some(bridge.clone());
2110 bridge
2111 }
2112
2113 #[tokio::test]
2114 async fn config_set_invalidates_cached_stdio_bridge() {
2115 let tmp = tempfile::tempdir().expect("tempdir");
2116 let config_path = tmp.path().join("config.toml");
2117 fs::write(&config_path, "model = \"deepseek-chat\"\n").expect("write config");
2118 let state = build_state(Some(config_path), None).expect("state");
2119 seed_test_bridge(&state).await;
2120
2121 let response = process_app_request(
2122 &state,
2123 AppRequest::ConfigSet {
2124 key: "model".to_string(),
2125 value: "deepseek-reasoner".to_string(),
2126 },
2127 AppTransport::Stdio,
2128 )
2129 .await;
2130 assert!(response.ok, "set should succeed");
2131
2132 assert!(state.stdio_bridge.lock().await.is_none());
2135 }
2136
2137 #[tokio::test]
2138 async fn config_reload_invalidates_cached_stdio_bridge() {
2139 let tmp = tempfile::tempdir().expect("tempdir");
2140 let config_path = tmp.path().join("config.toml");
2141 fs::write(&config_path, "model = \"deepseek-chat\"\n").expect("write config");
2142 let state = build_state(Some(config_path), None).expect("state");
2143 seed_test_bridge(&state).await;
2144
2145 let response =
2146 process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await;
2147 assert!(response.ok, "reload should succeed");
2148
2149 assert!(state.stdio_bridge.lock().await.is_none());
2150 }
2151
2152 #[tokio::test]
2153 async fn stdio_bridge_invalidation_not_blocked_by_in_flight_turn() {
2154 let (state, _tmp) = capability_test_state();
2155 let bridge = seed_test_bridge(&state).await;
2156
2157 let _in_flight = bridge.lock().await;
2159
2160 tokio::time::timeout(Duration::from_secs(1), invalidate_stdio_bridge(&state))
2163 .await
2164 .expect("invalidation must not wait on bridge traffic");
2165 assert!(state.stdio_bridge.lock().await.is_none());
2166 }
2167
2168 #[tokio::test]
2169 async fn runtime_read_paths_run_concurrently() {
2170 let (state, _tmp) = capability_test_state();
2174 let first = state.runtime.read().await;
2175 let second = state.runtime.read().await;
2176 assert!(first.app_status().ok);
2177 assert!(second.app_status().ok);
2178 }
2179
2180 #[tokio::test]
2181 async fn health_probes_advertise_legacy_deepseek_service_name() {
2182 let (app, _tmp) = app_with_config(None);
2185 let response = app
2186 .oneshot(
2187 Request::builder()
2188 .method(Method::GET)
2189 .uri("/healthz")
2190 .body(Body::empty())
2191 .expect("request"),
2192 )
2193 .await
2194 .expect("response");
2195 let body = response_body_json(response).await;
2196 assert_eq!(body["service"], legacy_deepseek_compat::SERVICE_NAME);
2197 assert_eq!(body["service"], "deepseek-app-server");
2198
2199 let (state, _tmp) = capability_test_state();
2200 let stdio = dispatch_stdio_request(&state, "healthz", json!({}))
2201 .await
2202 .expect("stdio healthz");
2203 assert_eq!(
2204 stdio.result["service"],
2205 legacy_deepseek_compat::SERVICE_NAME
2206 );
2207 }
2208
2209 #[test]
2210 fn non_loopback_bind_without_auth_fails_fast() {
2211 let options = AppServerOptions {
2212 listen: "0.0.0.0:8787".parse().expect("socket addr"),
2213 config_path: None,
2214 auth_token: None,
2215 insecure_no_auth: false,
2216 cors_origins: Vec::new(),
2217 };
2218
2219 let err =
2220 resolve_auth_token(&options).expect_err("non-loopback generated auth should fail");
2221 assert!(err.to_string().contains("without explicit auth token"));
2222 }
2223
2224 #[tokio::test]
2225 async fn stdio_transport_redacts_config_get_secrets() {
2226 let tmp = tempfile::tempdir().expect("tempdir");
2227 let config_path = tmp.path().join("config.toml");
2228 fs::write(&config_path, "").expect("write config");
2229 let state = build_state(Some(config_path), None).expect("state");
2230 {
2231 let mut cfg = state.config.write().await;
2232 cfg.api_key = Some("sk-deepseek-secret".to_string());
2233 }
2234
2235 let response = process_app_request(
2236 &state,
2237 AppRequest::ConfigGet {
2238 key: "api_key".to_string(),
2239 },
2240 AppTransport::Stdio,
2241 )
2242 .await;
2243
2244 assert_eq!(response.data["value"], "sk-d***cret");
2245 }
2246
2247 #[tokio::test]
2248 async fn stdio_thread_goal_methods_round_trip_persisted_goal() {
2249 let tmp = tempfile::tempdir().expect("tempdir");
2250 let config_path = tmp.path().join("config.toml");
2251 fs::write(&config_path, "").expect("write config");
2252 let state = build_state(Some(config_path), None).expect("state");
2253
2254 let capabilities = dispatch_stdio_request(&state, "thread/capabilities", json!({}))
2255 .await
2256 .expect("thread capabilities");
2257 assert!(
2258 capabilities.result["methods"]
2259 .as_array()
2260 .expect("methods")
2261 .iter()
2262 .any(|method| method == "thread/goal/set")
2263 );
2264
2265 let started = dispatch_stdio_request(&state, "thread/start", json!({}))
2266 .await
2267 .expect("start thread");
2268 let thread_id = started.result["thread_id"]
2269 .as_str()
2270 .expect("thread id")
2271 .to_string();
2272
2273 let set = dispatch_stdio_request(
2274 &state,
2275 "thread/goal/set",
2276 json!({
2277 "thread_id": thread_id,
2278 "objective": "Release 0.8.59",
2279 "token_budget": 59000
2280 }),
2281 )
2282 .await
2283 .expect("set goal");
2284 assert_eq!(set.result["status"], "ok");
2285 assert_eq!(set.result["goal"]["objective"], "Release 0.8.59");
2286 assert_eq!(set.result["goal"]["status"], "active");
2287
2288 let got = dispatch_stdio_request(
2289 &state,
2290 "thread/goal/get",
2291 json!({
2292 "thread_id": thread_id
2293 }),
2294 )
2295 .await
2296 .expect("get goal");
2297 assert_eq!(got.result["goal"]["token_budget"], 59000);
2298
2299 let cleared = dispatch_stdio_request(
2300 &state,
2301 "thread/goal/clear",
2302 json!({
2303 "thread_id": thread_id
2304 }),
2305 )
2306 .await
2307 .expect("clear goal");
2308 assert_eq!(cleared.result["status"], "cleared");
2309 assert_eq!(cleared.result["data"]["cleared"], true);
2310 }
2311
2312 fn sse_frame(event: &str, payload: Value) -> String {
2313 format!("event: {event}\ndata: {payload}\n\n")
2314 }
2315
2316 #[tokio::test]
2317 async fn stdio_runtime_bridge_streams_response_delta_events() {
2318 async fn create_turn(AxumPath(thread_id): AxumPath<String>) -> Json<Value> {
2319 Json(json!({
2320 "thread": { "id": thread_id },
2321 "turn": { "id": "turn_test" },
2322 }))
2323 }
2324
2325 async fn thread_events(
2326 AxumPath(thread_id): AxumPath<String>,
2327 Query(query): Query<HashMap<String, String>>,
2328 ) -> ([(header::HeaderName, &'static str); 1], String) {
2329 assert_eq!(thread_id, "thr_test");
2330 assert_eq!(query.get("since_seq").map(String::as_str), Some("0"));
2331
2332 let body = [
2333 sse_frame(
2334 "item.delta",
2335 json!({
2336 "seq": 1,
2337 "turn_id": "turn_test",
2338 "payload": {
2339 "kind": "agent_message",
2340 "delta": "hello"
2341 }
2342 }),
2343 ),
2344 sse_frame(
2345 "turn.completed",
2346 json!({
2347 "seq": 2,
2348 "turn_id": "turn_test",
2349 "payload": {
2350 "turn": {
2351 "status": "completed"
2352 }
2353 }
2354 }),
2355 ),
2356 ]
2357 .concat();
2358
2359 ([(header::CONTENT_TYPE, "text/event-stream")], body)
2360 }
2361
2362 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2363 .await
2364 .expect("bind test listener");
2365 let addr = listener.local_addr().expect("listener addr");
2366 let app = Router::new()
2367 .route("/v1/threads/{thread_id}/turns", post(create_turn))
2368 .route("/v1/threads/{thread_id}/events", get(thread_events));
2369
2370 let server = tokio::spawn(async move {
2371 axum::serve(listener, app)
2372 .await
2373 .expect("serve test runtime");
2374 });
2375
2376 let mut bridge = RuntimeBridge::from_base_url_for_test(format!("http://{addr}"));
2377 let (mut reader, mut writer) = tokio::io::duplex(4096);
2378
2379 let result = bridge
2380 .message_thread("thr_test", "hello", &mut writer)
2381 .await
2382 .expect("message_thread should succeed");
2383 drop(writer);
2384
2385 let mut stdout = Vec::new();
2386 reader
2387 .read_to_end(&mut stdout)
2388 .await
2389 .expect("read stdio output");
2390 server.abort();
2391 let _ = server.await;
2392
2393 let lines: Vec<Value> = String::from_utf8(stdout)
2394 .expect("utf8 output")
2395 .lines()
2396 .map(|line| serde_json::from_str(line).expect("json line"))
2397 .collect();
2398
2399 assert_eq!(
2400 result.get("status").and_then(Value::as_str),
2401 Some("accepted")
2402 );
2403 assert_eq!(
2404 result.pointer("/data/turn_id").and_then(Value::as_str),
2405 Some("turn_test")
2406 );
2407 assert_eq!(bridge.last_seq_by_thread.get("thr_test"), Some(&2));
2408
2409 let event_types: Vec<&str> = lines
2410 .iter()
2411 .map(|line| {
2412 line.get("type")
2413 .and_then(Value::as_str)
2414 .expect("event type")
2415 })
2416 .collect();
2417 assert_eq!(
2418 event_types,
2419 vec!["response_start", "response_delta", "response_end"]
2420 );
2421 assert_eq!(lines[1]["delta"], "hello");
2422 }
2423
2424 #[tokio::test]
2425 async fn stdio_runtime_bridge_applies_thread_start_hints() {
2426 async fn create_thread(Json(body): Json<Value>) -> Json<Value> {
2427 assert_eq!(body["model"], "deepseek-v4");
2428 assert_eq!(body["workspace"], "/tmp/codewhale-stdio");
2429 Json(json!({
2430 "id": "thr_runtime",
2431 "model": body["model"].clone(),
2432 "workspace": body["workspace"].clone(),
2433 }))
2434 }
2435
2436 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2437 .await
2438 .expect("bind test listener");
2439 let addr = listener.local_addr().expect("listener addr");
2440 let app = Router::new().route("/v1/threads", post(create_thread));
2441
2442 let server = tokio::spawn(async move {
2443 axum::serve(listener, app)
2444 .await
2445 .expect("serve test runtime");
2446 });
2447
2448 let mut bridge = RuntimeBridge::from_base_url_for_test(format!("http://{addr}"));
2449 let runtime_id = bridge
2450 .ensure_runtime_thread(
2451 "legacy_thread",
2452 Some(RuntimeThreadHint {
2453 model: Some("deepseek-v4".to_string()),
2454 workspace: Some(PathBuf::from("/tmp/codewhale-stdio")),
2455 }),
2456 )
2457 .await
2458 .expect("runtime thread");
2459 server.abort();
2460 let _ = server.await;
2461
2462 assert_eq!(runtime_id, "thr_runtime");
2463 assert_eq!(
2464 bridge.thread_map.get("legacy_thread").map(String::as_str),
2465 Some("thr_runtime")
2466 );
2467 }
2468
2469 const EXPECTED_CAPABILITY_METHODS: &[&str] = &[
2478 "healthz",
2479 "thread/capabilities",
2480 "thread/request",
2481 "thread/create",
2482 "thread/start",
2483 "thread/resume",
2484 "thread/fork",
2485 "thread/list",
2486 "thread/read",
2487 "thread/set_name",
2488 "thread/goal/set",
2489 "thread/goal/get",
2490 "thread/goal/clear",
2491 "thread/archive",
2492 "thread/unarchive",
2493 "thread/message",
2494 "app/capabilities",
2495 "app/request",
2496 "app/config/get",
2497 "app/config/set",
2498 "app/config/unset",
2499 "app/config/list",
2500 "app/config/reload",
2501 "app/models",
2502 "app/thread_loaded_list",
2503 "prompt/capabilities",
2504 "prompt/request",
2505 "prompt/run",
2506 "shutdown",
2507 ];
2508
2509 fn capability_test_state() -> (AppState, tempfile::TempDir) {
2510 let tmp = tempfile::tempdir().expect("tempdir");
2511 let config_path = tmp.path().join("config.toml");
2512 fs::write(&config_path, "").expect("write config");
2513 let state = build_state(Some(config_path), None).expect("state");
2514 (state, tmp)
2515 }
2516
2517 #[tokio::test]
2518 async fn capabilities_method_set_is_stable() {
2519 let (state, _tmp) = capability_test_state();
2520 let caps = dispatch_stdio_request(&state, "capabilities", json!({}))
2521 .await
2522 .expect("capabilities dispatch");
2523 let methods: Vec<String> = caps.result["methods"]
2524 .as_array()
2525 .expect("methods array")
2526 .iter()
2527 .map(|m| m.as_str().expect("method string").to_string())
2528 .collect();
2529 assert_eq!(
2530 methods, EXPECTED_CAPABILITY_METHODS,
2531 "app-server stdio capability set drifted; update the dispatcher, this \
2532 snapshot, and docs/RUNTIME_API.md together"
2533 );
2534 }
2535
2536 #[tokio::test]
2537 async fn every_advertised_capability_is_dispatchable() {
2538 let (state, _tmp) = capability_test_state();
2539 for method in EXPECTED_CAPABILITY_METHODS {
2543 if let Err(err) = dispatch_stdio_request(&state, method, json!({})).await {
2544 assert_ne!(
2545 err.code,
2546 JsonRpcError::method_not_found(method).code,
2547 "advertised capability `{method}` is not dispatchable"
2548 );
2549 }
2550 }
2551 }
2552
2553 #[test]
2556 fn auth_token_empty_string_fails() {
2557 let options = AppServerOptions {
2558 listen: "127.0.0.1:0".parse().expect("addr"),
2559 config_path: None,
2560 auth_token: Some(" ".to_string()),
2561 insecure_no_auth: false,
2562 cors_origins: Vec::new(),
2563 };
2564 let err = resolve_auth_token(&options).expect_err("empty token should fail");
2565 assert!(err.to_string().contains("cannot be empty"));
2566 }
2567
2568 #[test]
2569 fn auth_token_generated_when_none_provided() {
2570 let options = AppServerOptions {
2571 listen: "127.0.0.1:0".parse().expect("addr"),
2572 config_path: None,
2573 auth_token: None,
2574 insecure_no_auth: false,
2575 cors_origins: Vec::new(),
2576 };
2577 let token = resolve_auth_token(&options).unwrap();
2578 assert!(token.is_some());
2579 assert!(token.unwrap().starts_with("cwapp_"));
2580 }
2581
2582 #[test]
2583 fn runtime_bridge_command_keeps_auth_token_out_of_argv() {
2584 let token = "cwrt_unit_test_secret_token_not_for_argv";
2587 let cmd = RuntimeBridge::runtime_command(None, 18787, token).expect("command");
2588 let argv: Vec<String> = cmd
2589 .get_args()
2590 .map(|a| a.to_string_lossy().into_owned())
2591 .collect();
2592 assert!(
2593 !argv
2594 .iter()
2595 .any(|a| a.contains(token) || a == "--auth-token"),
2596 "auth token must not be present in child argv: {argv:?}"
2597 );
2598 let envs: Vec<(String, String)> = cmd
2599 .get_envs()
2600 .filter_map(|(k, v)| {
2601 Some((
2602 k.to_string_lossy().into_owned(),
2603 v?.to_string_lossy().into_owned(),
2604 ))
2605 })
2606 .collect();
2607 assert!(
2608 envs.iter()
2609 .any(|(k, v)| k == "CODEWHALE_RUNTIME_TOKEN" && v == token),
2610 "token must be carried via CODEWHALE_RUNTIME_TOKEN: {envs:?}"
2611 );
2612 assert!(
2613 envs.iter()
2614 .any(|(k, v)| k == "DEEPSEEK_RUNTIME_TOKEN" && v == token),
2615 "legacy alias DEEPSEEK_RUNTIME_TOKEN must also carry the token: {envs:?}"
2616 );
2617 }
2618
2619 #[test]
2620 fn generated_auth_status_does_not_render_token() {
2621 let rendered = app_server_auth_status_lines(false).join("\n");
2622
2623 assert!(!rendered.contains("Authorization: Bearer"));
2624 assert!(rendered.contains("not printed"));
2625 assert!(rendered.contains("CODEWHALE_APP_SERVER_TOKEN"));
2626 }
2627
2628 #[test]
2629 fn auth_token_explicit_is_preserved() {
2630 let options = AppServerOptions {
2631 listen: "127.0.0.1:0".parse().expect("addr"),
2632 config_path: None,
2633 auth_token: Some("my-secret".to_string()),
2634 insecure_no_auth: false,
2635 cors_origins: Vec::new(),
2636 };
2637 let token = resolve_auth_token(&options).unwrap();
2638 assert_eq!(token.as_deref(), Some("my-secret"));
2639 }
2640
2641 #[test]
2642 fn auth_token_explicit_allows_non_loopback_bind() {
2643 let options = AppServerOptions {
2644 listen: "0.0.0.0:8787".parse().expect("socket addr"),
2645 config_path: None,
2646 auth_token: Some("my-secret".to_string()),
2647 insecure_no_auth: false,
2648 cors_origins: Vec::new(),
2649 };
2650 let token = resolve_auth_token(&options).unwrap();
2651 assert_eq!(token.as_deref(), Some("my-secret"));
2652 }
2653
2654 #[test]
2655 fn insecure_no_auth_on_loopback_returns_none() {
2656 let options = AppServerOptions {
2657 listen: "127.0.0.1:0".parse().expect("addr"),
2658 config_path: None,
2659 auth_token: None,
2660 insecure_no_auth: true,
2661 cors_origins: Vec::new(),
2662 };
2663 let token = resolve_auth_token(&options).unwrap();
2664 assert!(token.is_none());
2665 }
2666
2667 #[test]
2668 fn insecure_no_auth_on_non_loopback_fails_fast() {
2669 let options = AppServerOptions {
2670 listen: "0.0.0.0:8787".parse().expect("socket addr"),
2671 config_path: None,
2672 auth_token: None,
2673 insecure_no_auth: true,
2674 cors_origins: Vec::new(),
2675 };
2676
2677 let err = resolve_auth_token(&options).expect_err("non-loopback unauth should fail");
2678 assert!(
2679 err.to_string()
2680 .contains("refusing unauthenticated app-server bind")
2681 );
2682 }
2683
2684 #[test]
2687 fn cors_layer_includes_default_origins() {
2688 let layer = cors_layer(&[]);
2689 let _ = layer;
2691 }
2692
2693 #[test]
2694 fn cors_layer_adds_extra_origins() {
2695 let extras = vec!["https://example.com".to_string()];
2696 let layer = cors_layer(&extras);
2697 let _ = layer;
2698 }
2699
2700 #[test]
2701 fn cors_layer_skips_empty_origins() {
2702 let extras = vec!["".to_string(), " ".to_string()];
2703 let layer = cors_layer(&extras);
2704 let _ = layer;
2705 }
2706
2707 #[test]
2710 fn params_or_object_returns_object_for_null() {
2711 let result = params_or_object(Value::Null);
2712 assert_eq!(result, json!({}));
2713 }
2714
2715 #[test]
2716 fn params_or_object_passthrough_for_non_null() {
2717 let input = json!({"key": "value"});
2718 let result = params_or_object(input.clone());
2719 assert_eq!(result, input);
2720 }
2721
2722 #[test]
2723 fn jsonrpc_result_format() {
2724 let result = jsonrpc_result(Some(json!(1)), json!({"ok": true}));
2725 assert_eq!(result["jsonrpc"], "2.0");
2726 assert_eq!(result["id"], 1);
2727 assert_eq!(result["result"]["ok"], true);
2728 }
2729
2730 #[test]
2731 fn jsonrpc_result_null_id() {
2732 let result = jsonrpc_result(None, json!(null));
2733 assert_eq!(result["id"], Value::Null);
2734 }
2735
2736 #[test]
2737 fn jsonrpc_error_format() {
2738 let err = jsonrpc_error(Some(json!(2)), JsonRpcError::internal("oops"));
2739 assert_eq!(err["jsonrpc"], "2.0");
2740 assert_eq!(err["id"], 2);
2741 assert_eq!(err["error"]["code"], -32603);
2742 assert_eq!(err["error"]["message"], "oops");
2743 }
2744
2745 #[test]
2746 fn jsonrpc_error_codes() {
2747 assert_eq!(JsonRpcError::parse_error("").code, -32700);
2748 assert_eq!(JsonRpcError::invalid_request("").code, -32600);
2749 assert_eq!(JsonRpcError::method_not_found("x").code, -32601);
2750 assert_eq!(JsonRpcError::invalid_params("").code, -32602);
2751 assert_eq!(JsonRpcError::internal("").code, -32603);
2752 }
2753
2754 #[test]
2757 fn app_server_options_debug_does_not_leak_token() {
2758 let options = AppServerOptions {
2759 listen: "127.0.0.1:8080".parse().expect("addr"),
2760 config_path: None,
2761 auth_token: Some("secret-token".to_string()),
2762 insecure_no_auth: false,
2763 cors_origins: vec!["https://example.com".to_string()],
2764 };
2765 let debug = format!("{options:?}");
2766 assert!(!debug.contains("secret-token"));
2767 assert!(debug.contains("<redacted>"));
2768 assert!(debug.contains("8080"));
2769 }
2770
2771 #[test]
2774 fn default_cors_origins_include_common_dev_ports() {
2775 assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:3000"));
2776 assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:5173"));
2777 assert!(DEFAULT_CORS_ORIGINS.contains(&"tauri://localhost"));
2778 }
2779}