1use std::collections::{HashMap, VecDeque};
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::{AsyncBufRead, 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 in_flight_turns: Arc<Mutex<HashMap<String, InFlightTurn>>>,
136}
137
138#[derive(Debug, Clone)]
140struct InFlightTurn {
141 base_url: String,
142 auth_token: Option<String>,
143 runtime_thread_id: String,
145 turn_id: String,
146}
147
148type TurnRegistry = Arc<Mutex<HashMap<String, InFlightTurn>>>;
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151struct ToolCallRequest {
152 call: ToolCall,
153 #[serde(default)]
154 cwd: Option<PathBuf>,
155}
156
157#[derive(Debug, Deserialize)]
158struct JsonRpcRequest {
159 #[serde(default)]
160 jsonrpc: Option<String>,
161 #[serde(default)]
162 id: Option<Value>,
163 method: String,
164 #[serde(default)]
165 params: Value,
166}
167
168#[derive(Debug)]
169struct JsonRpcError {
170 code: i64,
171 message: String,
172 data: Option<Value>,
173}
174
175#[derive(Debug)]
176struct StdioDispatchResult {
177 result: Value,
178 should_exit: bool,
179}
180
181#[derive(Debug)]
182struct RuntimeBridge {
183 base_url: String,
184 client: reqwest::Client,
185 auth_token: Option<String>,
186 child: Option<Child>,
187 thread_map: HashMap<String, String>,
188 last_seq_by_thread: HashMap<String, u64>,
189}
190
191#[derive(Debug, Clone, Default)]
192struct RuntimeThreadHint {
193 model: Option<String>,
194 workspace: Option<PathBuf>,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198enum TurnTerminalStatus {
199 Completed,
200 Failed,
201 Interrupted,
202 Canceled,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206enum AppTransport {
207 Http,
208 Stdio,
209}
210
211#[derive(Debug, Deserialize)]
212struct ConfigGetParams {
213 key: String,
214}
215
216#[derive(Debug, Deserialize)]
217struct ConfigSetParams {
218 key: String,
219 value: String,
220}
221
222#[derive(Debug, Deserialize)]
223struct ThreadIdParams {
224 thread_id: String,
225}
226
227#[derive(Debug, Deserialize)]
228struct ThreadMessageParams {
229 thread_id: String,
230 input: String,
231}
232
233#[derive(Debug, Deserialize)]
234struct ThreadInterruptParams {
235 thread_id: String,
236}
237
238pub async fn run(options: AppServerOptions) -> Result<()> {
239 let auth_token = resolve_auth_token(&options)?;
240 let state = build_state(options.config_path.clone(), auth_token)?;
241 let app = app_router(state, &options.cors_origins);
242
243 let listener = tokio::net::TcpListener::bind(options.listen).await?;
244 axum::serve(listener, app)
245 .with_graceful_shutdown(shutdown_signal())
246 .await?;
247 Ok(())
248}
249
250async fn shutdown_signal() {
251 let ctrl_c = async {
252 let _ = tokio::signal::ctrl_c().await;
253 };
254
255 #[cfg(unix)]
256 let terminate = async {
257 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
258 Ok(mut signal) => {
259 signal.recv().await;
260 }
261 Err(_) => std::future::pending::<()>().await,
262 }
263 };
264
265 #[cfg(not(unix))]
266 let terminate = std::future::pending::<()>();
267
268 tokio::select! {
269 _ = ctrl_c => {}
270 _ = terminate => {}
271 }
272}
273
274fn app_router(state: AppState, cors_origins: &[String]) -> Router {
275 let protected_routes = Router::new()
276 .route("/thread", post(thread_handler))
277 .route("/app", post(app_handler))
278 .route("/prompt", post(prompt_handler))
279 .route("/tool", post(tool_handler))
280 .route("/jobs", get(jobs_handler))
281 .route("/mcp/startup", post(mcp_startup_handler))
282 .route(
283 "/v1/chat/completions",
284 post(chat_completions::chat_completions_handler),
285 )
286 .route_layer(middleware::from_fn_with_state(
287 state.clone(),
288 require_app_server_token,
289 ));
290
291 Router::new()
292 .route("/healthz", get(healthz))
293 .merge(protected_routes)
294 .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
295 .layer(cors_layer(cors_origins))
296 .with_state(state)
297}
298
299pub async fn run_stdio(config_path: Option<PathBuf>) -> Result<()> {
300 let state = build_state_with_transport(config_path, None, AppTransport::Stdio)?;
301 let reader = BufReader::new(tokio::io::stdin()).lines();
302 let writer = tokio::io::BufWriter::new(tokio::io::stdout());
303 run_stdio_loop(&state, reader, writer).await
304}
305
306async fn run_stdio_loop<R, W>(
309 state: &AppState,
310 mut reader: tokio::io::Lines<R>,
311 mut writer: W,
312) -> Result<()>
313where
314 R: AsyncBufRead + Unpin,
315 W: AsyncWrite + Unpin,
316{
317 let mut pending: VecDeque<PendingStdioWork> = VecDeque::new();
321 let mut stdin_open = true;
322
323 loop {
324 let request = match pending.pop_front() {
325 Some(PendingStdioWork::Response(response)) => {
326 write_stdio_line(&mut writer, &response).await?;
327 continue;
328 }
329 Some(PendingStdioWork::Request(request)) => request,
330 None => {
331 if !stdin_open {
332 break;
333 }
334 let Some(line) = reader.next_line().await? else {
335 break;
336 };
337 match parse_stdio_line(&line) {
338 ParsedStdioLine::Blank => continue,
339 ParsedStdioLine::Rejected(response) => {
340 write_stdio_line(&mut writer, &response).await?;
341 continue;
342 }
343 ParsedStdioLine::Request(request) => request,
344 }
345 }
346 };
347
348 let id = request.id.clone();
349 let dispatched = if request.method == "thread/message" {
350 let dispatch = dispatch_stdio_request_with_writer(
354 state,
355 &mut writer,
356 &request.method,
357 request.params,
358 );
359 tokio::pin!(dispatch);
360 loop {
361 tokio::select! {
362 outcome = &mut dispatch => break outcome,
363 line = reader.next_line(), if stdin_open => {
364 match line? {
365 None => stdin_open = false,
366 Some(line) => {
367 handle_line_during_turn(state, &line, &mut pending).await;
368 }
369 }
370 }
371 }
372 }
373 } else {
374 dispatch_stdio_request_with_writer(state, &mut writer, &request.method, request.params)
375 .await
376 };
377
378 match dispatched {
379 Ok(dispatch) => {
380 write_stdio_line(&mut writer, &jsonrpc_result(id, dispatch.result)).await?;
381 if dispatch.should_exit {
382 break;
383 }
384 }
385 Err(err) => {
386 write_stdio_line(&mut writer, &jsonrpc_error(id, err)).await?;
387 }
388 }
389 }
390
391 Ok(())
392}
393
394enum PendingStdioWork {
396 Response(Value),
398 Request(JsonRpcRequest),
400}
401
402enum ParsedStdioLine {
403 Blank,
404 Request(JsonRpcRequest),
405 Rejected(Value),
406}
407
408fn parse_stdio_line(line: &str) -> ParsedStdioLine {
409 if line.trim().is_empty() {
410 return ParsedStdioLine::Blank;
411 }
412 let request: JsonRpcRequest = match serde_json::from_str(line) {
413 Ok(value) => value,
414 Err(err) => {
415 return ParsedStdioLine::Rejected(jsonrpc_error(
416 None,
417 JsonRpcError::parse_error(format!("invalid json: {err}")),
418 ));
419 }
420 };
421 if request
422 .jsonrpc
423 .as_deref()
424 .is_some_and(|version| version != "2.0")
425 {
426 return ParsedStdioLine::Rejected(jsonrpc_error(
427 request.id,
428 JsonRpcError::invalid_request("jsonrpc version must be 2.0"),
429 ));
430 }
431 ParsedStdioLine::Request(request)
432}
433
434async fn handle_line_during_turn(
443 state: &AppState,
444 line: &str,
445 pending: &mut VecDeque<PendingStdioWork>,
446) {
447 let request = match parse_stdio_line(line) {
448 ParsedStdioLine::Blank => return,
449 ParsedStdioLine::Rejected(response) => {
450 pending.push_back(PendingStdioWork::Response(response));
451 return;
452 }
453 ParsedStdioLine::Request(request) => request,
454 };
455
456 match request.method.as_str() {
457 "thread/interrupt" => {
458 let id = request.id.clone();
459 let response = match parse_params::<ThreadInterruptParams>(params_or_object(
460 request.params.clone(),
461 )) {
462 Ok(parsed) => match interrupt_stdio_turn(state, &parsed.thread_id).await {
463 Ok(interrupted) => jsonrpc_result(
464 id,
465 json!({ "thread_id": parsed.thread_id, "interrupted": interrupted }),
466 ),
467 Err(err) => jsonrpc_error(id, err),
468 },
469 Err(err) => jsonrpc_error(id, err),
470 };
471 pending.push_back(PendingStdioWork::Response(response));
472 }
473 "shutdown" => {
474 let live: Vec<String> = state.in_flight_turns.lock().await.keys().cloned().collect();
475 for thread_id in live {
476 let _ = interrupt_stdio_turn(state, &thread_id).await;
477 }
478 pending.push_back(PendingStdioWork::Request(request));
479 }
480 _ => pending.push_back(PendingStdioWork::Request(request)),
481 }
482}
483
484async fn write_stdio_line<W: AsyncWrite + Unpin>(writer: &mut W, response: &Value) -> Result<()> {
485 writer.write_all(&serde_json::to_vec(response)?).await?;
486 writer.write_all(b"\n").await?;
487 writer.flush().await?;
488 Ok(())
489}
490
491async fn healthz() -> Json<Value> {
492 Json(json!({
493 "status": "ok",
494 "protocol": "v2",
495 "service": legacy_deepseek_compat::SERVICE_NAME
496 }))
497}
498
499async fn thread_handler(
500 State(state): State<AppState>,
501 Json(req): Json<ThreadRequest>,
502) -> (StatusCode, Json<ThreadResponse>) {
503 let mut runtime = state.runtime.write().await;
504 match runtime.handle_thread(req).await {
505 Ok(res) => (StatusCode::OK, Json(res)),
506 Err(err) => (
507 StatusCode::INTERNAL_SERVER_ERROR,
508 Json(ThreadResponse {
509 thread_id: "error".to_string(),
510 status: format!("error:{err}"),
511 thread: None,
512 threads: Vec::new(),
513 goal: None,
514 model: None,
515 model_provider: None,
516 cwd: None,
517 approval_policy: None,
518 sandbox: None,
519 events: Vec::new(),
520 data: json!({}),
521 }),
522 ),
523 }
524}
525
526async fn prompt_handler(
527 State(state): State<AppState>,
528 Json(req): Json<PromptRequest>,
529) -> (StatusCode, Json<PromptResponse>) {
530 let mut runtime = state.runtime.write().await;
531 let overrides = CliRuntimeOverrides::default();
532 match runtime.handle_prompt(req, &overrides).await {
533 Ok(res) => (StatusCode::OK, Json(res)),
534 Err(err) => (
535 StatusCode::INTERNAL_SERVER_ERROR,
536 Json(PromptResponse {
537 output: err.to_string(),
538 model: "unknown".to_string(),
539 events: Vec::new(),
540 }),
541 ),
542 }
543}
544
545async fn tool_handler(
546 State(state): State<AppState>,
547 Json(req): Json<ToolCallRequest>,
548) -> (StatusCode, Json<Value>) {
549 let cwd = req
550 .cwd
551 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
552 let approval_mode = {
554 let cfg = state.config.read().await;
555 cfg.approval_policy
556 .as_deref()
557 .and_then(|p| match p.trim().to_ascii_lowercase().as_str() {
558 "auto" | "yolo" => Some(codewhale_execpolicy::AskForApproval::UnlessTrusted),
559 "never" | "deny" => Some(codewhale_execpolicy::AskForApproval::Never),
560 _ => None,
561 })
562 .unwrap_or(codewhale_execpolicy::AskForApproval::OnRequest)
563 };
564 let runtime = state.runtime.read().await;
568 match runtime.invoke_tool(req.call, approval_mode, &cwd).await {
569 Ok(value) => (StatusCode::OK, Json(value)),
570 Err(err) => (
571 StatusCode::INTERNAL_SERVER_ERROR,
572 Json(json!({ "ok": false, "error": err.to_string() })),
573 ),
574 }
575}
576
577async fn jobs_handler(State(state): State<AppState>) -> Json<AppResponse> {
578 let runtime = state.runtime.read().await;
579 Json(runtime.app_status())
580}
581
582async fn mcp_startup_handler(State(state): State<AppState>) -> Json<Value> {
583 let runtime = state.runtime.read().await;
584 let summary = runtime.mcp_startup().await;
585 Json(json!({
586 "ok": true,
587 "summary": summary
588 }))
589}
590
591async fn app_handler(
592 State(state): State<AppState>,
593 Json(req): Json<AppRequest>,
594) -> (StatusCode, Json<AppResponse>) {
595 let response = process_app_request(&state, req, AppTransport::Http).await;
596 (app_response_status(&response), Json(response))
597}
598
599fn app_response_status(response: &AppResponse) -> StatusCode {
600 if response.ok {
601 return StatusCode::OK;
602 }
603 if response.data.get("request_id").is_some() {
604 StatusCode::CONFLICT
605 } else if response
606 .data
607 .get("error")
608 .and_then(Value::as_str)
609 .is_some_and(|err| err.contains("failed to load config"))
610 {
611 StatusCode::INTERNAL_SERVER_ERROR
612 } else {
613 StatusCode::BAD_REQUEST
614 }
615}
616
617fn build_state(config_path: Option<PathBuf>, auth_token: Option<String>) -> Result<AppState> {
618 build_state_with_transport(config_path, auth_token, AppTransport::Http)
619}
620
621fn build_state_with_transport(
622 config_path: Option<PathBuf>,
623 auth_token: Option<String>,
624 transport: AppTransport,
625) -> Result<AppState> {
626 let has_explicit_config_path = config_path.is_some();
627 let store = ConfigStore::load(config_path)?;
628 let config_path = has_explicit_config_path.then(|| store.path().to_path_buf());
629 let config = store.config.clone();
630 let exec_policy = store.exec_policy_engine();
631 let registry = ModelRegistry::default();
632
633 let state_db_path = config_path
634 .as_ref()
635 .and_then(|p| p.parent().map(|parent| parent.join("state.db")));
636 let state_store = StateStore::open(state_db_path)?;
637
638 let mut hooks = HookDispatcher::default();
639 if transport == AppTransport::Http {
643 hooks.add_sink(Arc::new(StdoutHookSink));
644 }
645 let hook_log_path = config_path
646 .as_ref()
647 .and_then(|p| p.parent().map(|parent| parent.join("events.jsonl")))
648 .unwrap_or_else(legacy_deepseek_compat::default_events_log_path);
649 hooks.add_sink(Arc::new(JsonlHookSink::new(hook_log_path)));
650
651 if let Some(socket_path) = config
652 .hook_sinks
653 .as_ref()
654 .and_then(|sinks| sinks.unix_socket_path.as_ref())
655 .filter(|path| !path.as_os_str().is_empty())
656 {
657 hooks.add_sink(Arc::new(UnixSocketHookSink::new(socket_path.clone())));
658 }
659
660 let runtime = Runtime::new(
661 config.clone(),
662 registry.clone(),
663 state_store,
664 Arc::new(ToolRegistry::default()),
665 Arc::new(McpManager::default()),
666 exec_policy,
667 hooks,
668 );
669
670 Ok(AppState {
671 config_path,
672 config: Arc::new(RwLock::new(config)),
673 runtime: Arc::new(RwLock::new(runtime)),
674 registry,
675 auth_token,
676 stdio_bridge: Arc::new(Mutex::new(None)),
677 stdio_thread_hints: Arc::new(Mutex::new(HashMap::new())),
678 pending_user_input: Arc::new(Mutex::new(std::collections::HashMap::new())),
679 in_flight_turns: Arc::new(Mutex::new(HashMap::new())),
680 })
681}
682
683fn resolve_auth_token(options: &AppServerOptions) -> Result<Option<String>> {
684 let configured = options.auth_token.as_ref().map(|token| token.trim());
685 if let Some(token) = configured
686 && token.is_empty()
687 {
688 bail!("app-server auth token cannot be empty");
689 }
690 let has_explicit_token = configured.is_some();
691
692 if options.insecure_no_auth {
693 if !options.listen.ip().is_loopback() {
694 bail!("refusing unauthenticated app-server bind on non-loopback address");
695 }
696 eprintln!("warning: app-server HTTP auth disabled by --insecure-no-auth");
697 return Ok(None);
698 }
699
700 if !has_explicit_token && !options.listen.ip().is_loopback() {
701 bail!(
702 "refusing non-loopback app-server bind without explicit auth token; pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN"
703 );
704 }
705
706 let token = configured
707 .map(str::to_string)
708 .unwrap_or_else(|| format!("cwapp_{}", Uuid::new_v4().simple()));
709 for line in app_server_auth_status_lines(has_explicit_token) {
710 eprintln!("{line}");
711 }
712 Ok(Some(token))
713}
714
715fn app_server_auth_status_lines(has_explicit_token: bool) -> Vec<&'static str> {
716 if has_explicit_token {
717 return vec!["app-server auth: bearer token required for HTTP routes."];
718 }
719 vec![
720 "app-server auth: generated bearer token for this process (not printed).",
721 " Pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN when another client needs to connect.",
722 ]
723}
724
725fn cors_layer(extra_origins: &[String]) -> CorsLayer {
726 let mut origins: Vec<HeaderValue> = DEFAULT_CORS_ORIGINS
727 .iter()
728 .filter_map(|origin| HeaderValue::from_str(origin).ok())
729 .collect();
730 for raw in extra_origins {
731 let trimmed = raw.trim();
732 if trimmed.is_empty() {
733 continue;
734 }
735 match HeaderValue::from_str(trimmed) {
736 Ok(value) if !origins.contains(&value) => origins.push(value),
737 Ok(_) => {}
738 Err(err) => {
739 eprintln!("warning: ignoring invalid app-server CORS origin `{trimmed}`: {err}")
740 }
741 }
742 }
743
744 CorsLayer::new()
745 .allow_origin(origins)
746 .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
747 .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
748}
749
750async fn require_app_server_token(
751 State(state): State<AppState>,
752 req: Request,
753 next: Next,
754) -> Response {
755 let Some(expected) = state.auth_token.as_deref() else {
756 return next.run(req).await;
757 };
758 let authorized = req
759 .headers()
760 .get(header::AUTHORIZATION)
761 .and_then(|value| value.to_str().ok())
762 .and_then(|raw| raw.strip_prefix("Bearer "))
763 .is_some_and(|token| constant_time_eq(token.as_bytes(), expected.as_bytes()));
764
765 if authorized {
766 next.run(req).await
767 } else {
768 (
769 StatusCode::UNAUTHORIZED,
770 Json(json!({
771 "error": {
772 "message": "app-server bearer token required",
773 "status": StatusCode::UNAUTHORIZED.as_u16(),
774 }
775 })),
776 )
777 .into_response()
778 }
779}
780
781fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
784 let mut diff = a.len() ^ b.len();
785 for i in 0..a.len().max(b.len()) {
786 let x = a.get(i).copied().unwrap_or(0);
787 let y = b.get(i).copied().unwrap_or(0);
788 diff |= usize::from(x ^ y);
789 }
790 diff == 0
791}
792
793fn params_or_object(params: Value) -> Value {
794 if params.is_null() { json!({}) } else { params }
795}
796
797fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
798 serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
799}
800
801fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
802 json!({
803 "jsonrpc": "2.0",
804 "id": id.unwrap_or(Value::Null),
805 "result": result
806 })
807}
808
809fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
810 json!({
811 "jsonrpc": "2.0",
812 "id": id.unwrap_or(Value::Null),
813 "error": {
814 "code": err.code,
815 "message": err.message,
816 "data": err.data
817 }
818 })
819}
820
821impl JsonRpcError {
822 fn parse_error(message: impl Into<String>) -> Self {
823 Self {
824 code: -32700,
825 message: message.into(),
826 data: None,
827 }
828 }
829
830 fn invalid_request(message: impl Into<String>) -> Self {
831 Self {
832 code: -32600,
833 message: message.into(),
834 data: None,
835 }
836 }
837
838 fn method_not_found(method: &str) -> Self {
839 Self {
840 code: -32601,
841 message: format!("unsupported method: {method}"),
842 data: None,
843 }
844 }
845
846 fn invalid_params(message: impl Into<String>) -> Self {
847 Self {
848 code: -32602,
849 message: message.into(),
850 data: None,
851 }
852 }
853
854 fn thread_not_found(thread_id: &str) -> Self {
856 Self {
857 code: -32004,
858 message: format!("thread not found: {thread_id}"),
859 data: Some(json!({
860 "error": "thread_not_found",
861 "thread_id": thread_id,
862 })),
863 }
864 }
865
866 fn internal(message: impl Into<String>) -> Self {
867 Self {
868 code: -32603,
869 message: message.into(),
870 data: None,
871 }
872 }
873}
874
875async fn handle_thread_request(
876 state: &AppState,
877 req: ThreadRequest,
878) -> std::result::Result<ThreadResponse, JsonRpcError> {
879 let mut runtime = state.runtime.write().await;
880 runtime
881 .handle_thread(req)
882 .await
883 .map_err(|err| JsonRpcError::internal(err.to_string()))
884}
885
886async fn handle_prompt_request(
887 state: &AppState,
888 req: PromptRequest,
889) -> std::result::Result<PromptResponse, JsonRpcError> {
890 let mut runtime = state.runtime.write().await;
891 runtime
892 .handle_prompt(req, &CliRuntimeOverrides::default())
893 .await
894 .map_err(|err| JsonRpcError::internal(err.to_string()))
895}
896
897async fn handle_stdio_thread_message<W: AsyncWrite + Unpin>(
898 state: &AppState,
899 writer: &mut W,
900 parsed: ThreadMessageParams,
901) -> std::result::Result<Value, JsonRpcError> {
902 let hint = {
903 let hints = state.stdio_thread_hints.lock().await;
904 hints.get(&parsed.thread_id).cloned()
905 };
906 let bridge = acquire_stdio_bridge(state).await?;
907 let mut bridge = bridge.lock().await;
912 let runtime_thread_id = bridge
913 .ensure_runtime_thread(&parsed.thread_id, hint)
914 .await
915 .map_err(|err| JsonRpcError::internal(err.to_string()))?;
916 let mut result = bridge
917 .message_thread(
918 &runtime_thread_id,
919 &parsed.input,
920 writer,
921 Some((state.in_flight_turns.clone(), parsed.thread_id.clone())),
922 )
923 .await
924 .map_err(|err| JsonRpcError::internal(err.to_string()))?;
925 if let Some(object) = result.as_object_mut() {
926 object.insert("thread_id".to_string(), Value::String(parsed.thread_id));
927 }
928 Ok(result)
929}
930
931fn ensure_thread_found(response: &ThreadResponse) -> std::result::Result<(), JsonRpcError> {
936 if response.status == "missing" {
937 return Err(JsonRpcError::thread_not_found(&response.thread_id));
938 }
939 Ok(())
940}
941
942async fn record_stdio_thread_hint(state: &AppState, response: &ThreadResponse) {
943 let mut hints = state.stdio_thread_hints.lock().await;
944 hints.insert(
945 response.thread_id.clone(),
946 RuntimeThreadHint {
947 model: response.model.clone(),
948 workspace: response.cwd.clone(),
949 },
950 );
951}
952
953async fn acquire_stdio_bridge(
959 state: &AppState,
960) -> std::result::Result<SharedRuntimeBridge, JsonRpcError> {
961 if let Some(bridge) = state.stdio_bridge.lock().await.as_ref() {
962 return Ok(bridge.clone());
963 }
964 let bridge = Arc::new(Mutex::new(
965 RuntimeBridge::start(state.config_path.as_deref())
966 .await
967 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
968 ));
969 let mut slot = state.stdio_bridge.lock().await;
970 Ok(slot.get_or_insert_with(|| bridge.clone()).clone())
973}
974
975async fn interrupt_stdio_turn(
981 state: &AppState,
982 thread_id: &str,
983) -> std::result::Result<bool, JsonRpcError> {
984 let Some(turn) = state.in_flight_turns.lock().await.get(thread_id).cloned() else {
985 return Ok(false);
986 };
987 let mut request = codewhale_release::platform_http_client_builder()
988 .timeout(Duration::from_secs(10))
989 .build()
990 .map_err(|err| JsonRpcError::internal(err.to_string()))?
991 .post(format!(
992 "{}/v1/threads/{}/turns/{}/interrupt",
993 turn.base_url, turn.runtime_thread_id, turn.turn_id
994 ));
995 if let Some(token) = turn.auth_token.as_deref() {
996 request = request.bearer_auth(token);
997 }
998 request
999 .send()
1000 .await
1001 .and_then(reqwest::Response::error_for_status)
1002 .map_err(|err| JsonRpcError::internal(format!("interrupt failed: {err}")))?;
1003 Ok(true)
1004}
1005
1006async fn invalidate_stdio_bridge(state: &AppState) {
1011 let mut bridge = state.stdio_bridge.lock().await;
1012 *bridge = None;
1013}
1014
1015impl RuntimeBridge {
1016 async fn start(config_path: Option<&Path>) -> Result<Self> {
1017 install_rustls_crypto_provider();
1018 let port = reserve_runtime_port()?;
1019 let auth_token = format!("cwrt_{}", Uuid::new_v4().simple());
1020 let child = Self::runtime_command(config_path, port, &auth_token)?
1021 .spawn()
1022 .context("failed to start runtime API bridge")?;
1023 let mut bridge = Self {
1024 base_url: format!("http://127.0.0.1:{port}"),
1025 client: codewhale_release::platform_http_client_builder()
1026 .build()
1027 .context("failed to build runtime API client")?,
1028 auth_token: Some(auth_token),
1029 child: Some(child),
1030 thread_map: HashMap::new(),
1031 last_seq_by_thread: HashMap::new(),
1032 };
1033 bridge.wait_until_ready().await?;
1034 Ok(bridge)
1035 }
1036
1037 fn runtime_command(config_path: Option<&Path>, port: u16, auth_token: &str) -> Result<Command> {
1038 let current_exe = std::env::current_exe().ok();
1039 let mut command = if let Some(path) = current_exe {
1040 Command::new(path)
1041 } else {
1042 Command::new("codewhale")
1043 };
1044 command
1049 .arg("app-server")
1050 .arg("--http")
1051 .arg("--host")
1052 .arg("127.0.0.1")
1053 .arg("--port")
1054 .arg(port.to_string())
1055 .env("CODEWHALE_RUNTIME_TOKEN", auth_token)
1056 .env("DEEPSEEK_RUNTIME_TOKEN", auth_token)
1057 .stdin(Stdio::null())
1058 .stdout(Stdio::null())
1059 .stderr(Stdio::null());
1060 if let Some(config_path) = config_path {
1061 command.arg("--config").arg(config_path);
1062 }
1063 Ok(command)
1064 }
1065
1066 async fn wait_until_ready(&mut self) -> Result<()> {
1067 let deadline = Instant::now() + Duration::from_secs(15);
1068 loop {
1069 if let Some(child) = self.child.as_mut()
1070 && let Some(status) = child.try_wait()?
1071 {
1072 return Err(anyhow!(
1073 "runtime API bridge exited before becoming ready (status {status})"
1074 ));
1075 }
1076
1077 match self
1078 .client
1079 .get(format!("{}/health", self.base_url))
1080 .send()
1081 .await
1082 {
1083 Ok(response) if response.status().is_success() => return Ok(()),
1084 _ if Instant::now() >= deadline => {
1085 bail!(
1086 "timed out waiting for runtime API bridge at {}/health",
1087 self.base_url
1088 )
1089 }
1090 _ => tokio::time::sleep(Duration::from_millis(50)).await,
1091 }
1092 }
1093 }
1094
1095 fn authed(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
1096 match self.auth_token.as_deref() {
1097 Some(token) => builder.bearer_auth(token),
1098 None => builder,
1099 }
1100 }
1101
1102 async fn request_json(&self, builder: reqwest::RequestBuilder) -> Result<Value> {
1103 let response = builder.send().await?;
1104 let status = response.status();
1105 let body = response.text().await?;
1106 if !status.is_success() {
1107 let detail = body.trim();
1108 if detail.is_empty() {
1109 bail!("runtime API returned {status}");
1110 }
1111 bail!("runtime API returned {status}: {detail}");
1112 }
1113 serde_json::from_str(&body).with_context(|| format!("invalid runtime API json: {body}"))
1114 }
1115
1116 async fn ensure_runtime_thread(
1117 &mut self,
1118 stdio_thread_id: &str,
1119 hint: Option<RuntimeThreadHint>,
1120 ) -> Result<String> {
1121 if let Some(runtime_thread_id) = self.thread_map.get(stdio_thread_id) {
1122 return Ok(runtime_thread_id.clone());
1123 }
1124 let hint = hint.unwrap_or_default();
1125 let runtime_thread_id = self
1126 .create_runtime_thread(hint.model, hint.workspace)
1127 .await?;
1128 self.thread_map
1129 .insert(stdio_thread_id.to_string(), runtime_thread_id.clone());
1130 Ok(runtime_thread_id)
1131 }
1132
1133 async fn create_runtime_thread(
1134 &mut self,
1135 model: Option<String>,
1136 workspace: Option<PathBuf>,
1137 ) -> Result<String> {
1138 let record = self
1139 .request_json(
1140 self.authed(self.client.post(format!("{}/v1/threads", self.base_url)))
1141 .json(&json!({
1142 "model": model,
1143 "workspace": workspace,
1144 "mode": "agent",
1145 "archived": false,
1146 })),
1147 )
1148 .await?;
1149 let thread_id = extract_runtime_thread_id(&record)?.to_string();
1150 self.last_seq_by_thread
1151 .entry(thread_id.clone())
1152 .or_insert(0);
1153 Ok(thread_id)
1154 }
1155
1156 async fn message_thread<W: AsyncWrite + Unpin>(
1162 &mut self,
1163 thread_id: &str,
1164 input: &str,
1165 writer: &mut W,
1166 registration: Option<(TurnRegistry, String)>,
1167 ) -> Result<Value> {
1168 let turn = self
1169 .request_json(
1170 self.authed(
1171 self.client
1172 .post(format!("{}/v1/threads/{thread_id}/turns", self.base_url)),
1173 )
1174 .json(&json!({ "prompt": input })),
1175 )
1176 .await?;
1177 let turn_id = turn
1178 .pointer("/turn/id")
1179 .and_then(Value::as_str)
1180 .ok_or_else(|| anyhow!("runtime API turn response missing turn.id"))?
1181 .to_string();
1182 let response_id = format!("{thread_id}:{turn_id}");
1183
1184 emit_stdio_event(
1185 writer,
1186 json!({
1187 "type": "response_start",
1188 "response_id": response_id,
1189 }),
1190 )
1191 .await?;
1192
1193 if let Some((registry, key)) = registration.as_ref() {
1197 registry.lock().await.insert(
1198 key.clone(),
1199 InFlightTurn {
1200 base_url: self.base_url.clone(),
1201 auth_token: self.auth_token.clone(),
1202 runtime_thread_id: thread_id.to_string(),
1203 turn_id: turn_id.clone(),
1204 },
1205 );
1206 }
1207
1208 let since_seq = self.last_seq_by_thread.get(thread_id).copied().unwrap_or(0);
1209 let stream_result = self
1210 .stream_turn_events(thread_id, &turn_id, &response_id, writer, since_seq)
1211 .await;
1212
1213 if let Some((registry, key)) = registration.as_ref() {
1214 registry.lock().await.remove(key);
1215 }
1216
1217 let _ = emit_stdio_event(
1218 writer,
1219 json!({
1220 "type": "response_end",
1221 "response_id": response_id,
1222 }),
1223 )
1224 .await;
1225
1226 let (last_seq, status, error) = stream_result?;
1227 self.last_seq_by_thread
1228 .insert(thread_id.to_string(), last_seq);
1229
1230 match status {
1231 TurnTerminalStatus::Completed => Ok(json!({
1232 "thread_id": thread_id,
1233 "status": "accepted",
1234 "thread": Value::Null,
1235 "threads": [],
1236 "model": Value::Null,
1237 "model_provider": Value::Null,
1238 "cwd": Value::Null,
1239 "approval_policy": Value::Null,
1240 "sandbox": Value::Null,
1241 "events": [],
1242 "data": { "turn_id": turn_id },
1243 })),
1244 TurnTerminalStatus::Failed => Err(anyhow!(
1245 "{}",
1246 error.unwrap_or_else(|| "turn failed".to_string())
1247 )),
1248 TurnTerminalStatus::Interrupted => Err(anyhow!(
1249 "{}",
1250 error.unwrap_or_else(|| "turn interrupted".to_string())
1251 )),
1252 TurnTerminalStatus::Canceled => Err(anyhow!(
1253 "{}",
1254 error.unwrap_or_else(|| "turn canceled".to_string())
1255 )),
1256 }
1257 }
1258
1259 async fn stream_turn_events<W: AsyncWrite + Unpin>(
1260 &self,
1261 thread_id: &str,
1262 turn_id: &str,
1263 response_id: &str,
1264 writer: &mut W,
1265 since_seq: u64,
1266 ) -> Result<(u64, TurnTerminalStatus, Option<String>)> {
1267 let mut response = self
1268 .authed(self.client.get(format!(
1269 "{}/v1/threads/{thread_id}/events?since_seq={since_seq}",
1270 self.base_url
1271 )))
1272 .send()
1273 .await?
1274 .error_for_status()?;
1275
1276 let mut buffer = Vec::new();
1277 let mut last_seq = since_seq;
1278
1279 while let Some(chunk) = response.chunk().await? {
1280 buffer.extend_from_slice(&chunk);
1281 if buffer.len() > MAX_SSE_FRAME_BYTES {
1282 bail!(
1283 "runtime SSE frame exceeded {MAX_SSE_FRAME_BYTES} bytes without a frame delimiter"
1284 );
1285 }
1286 while let Some(frame_bytes) = take_sse_frame(&mut buffer) {
1287 let Some((event_name, frame_data)) = parse_sse_frame(&frame_bytes) else {
1288 continue;
1289 };
1290 let envelope: Value = serde_json::from_str(&frame_data)
1291 .with_context(|| format!("invalid SSE json for {event_name}: {frame_data}"))?;
1292 if let Some(seq) = envelope.get("seq").and_then(Value::as_u64) {
1293 last_seq = last_seq.max(seq);
1294 }
1295 if envelope.get("turn_id").and_then(Value::as_str) != Some(turn_id) {
1296 continue;
1297 }
1298 let payload = envelope.get("payload").cloned().unwrap_or(Value::Null);
1299 match event_name.as_str() {
1300 "item.delta" => {
1301 let kind = payload
1302 .get("kind")
1303 .and_then(Value::as_str)
1304 .unwrap_or_default();
1305 if kind == "agent_message"
1306 && let Some(delta) = payload.get("delta").and_then(Value::as_str)
1307 && !delta.is_empty()
1308 {
1309 emit_stdio_event(
1310 writer,
1311 json!({
1312 "type": "response_delta",
1313 "response_id": response_id,
1314 "delta": delta,
1315 }),
1316 )
1317 .await?;
1318 }
1319 }
1320 "turn.completed" => {
1321 let status = turn_terminal_status(&payload);
1322 let error = payload
1323 .pointer("/turn/error")
1324 .and_then(Value::as_str)
1325 .map(str::to_string);
1326 return Ok((last_seq, status, error));
1327 }
1328 _ => {}
1329 }
1330 }
1331 }
1332
1333 bail!("runtime event stream ended before turn.completed")
1334 }
1335
1336 #[cfg(test)]
1337 fn from_base_url_for_test(base_url: String) -> Self {
1338 install_rustls_crypto_provider();
1339 Self {
1340 base_url,
1341 client: codewhale_release::platform_http_client_builder()
1342 .timeout(Duration::from_secs(5))
1343 .build()
1344 .expect("build reqwest test client"),
1345 auth_token: None,
1346 child: None,
1347 thread_map: HashMap::new(),
1348 last_seq_by_thread: HashMap::new(),
1349 }
1350 }
1351}
1352
1353impl RuntimeBridge {
1354 fn shutdown_child(&mut self) {
1357 if let Some(mut child) = self.child.take() {
1358 let _ = child.kill();
1359 std::thread::spawn(move || {
1360 let _ = child.wait();
1361 });
1362 }
1363 }
1364}
1365
1366impl Drop for RuntimeBridge {
1367 fn drop(&mut self) {
1368 self.shutdown_child();
1369 }
1370}
1371
1372fn reserve_runtime_port() -> Result<u16> {
1373 let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
1374 Ok(listener.local_addr()?.port())
1375}
1376
1377fn install_rustls_crypto_provider() {
1378 let _ = rustls::crypto::ring::default_provider().install_default();
1379}
1380
1381fn extract_runtime_thread_id(record: &Value) -> Result<&str> {
1382 record
1383 .get("id")
1384 .and_then(Value::as_str)
1385 .ok_or_else(|| anyhow!("runtime API thread response missing id"))
1386}
1387
1388fn turn_terminal_status(payload: &Value) -> TurnTerminalStatus {
1389 match payload
1390 .pointer("/turn/status")
1391 .and_then(Value::as_str)
1392 .unwrap_or("completed")
1393 .to_ascii_lowercase()
1394 .as_str()
1395 {
1396 "failed" => TurnTerminalStatus::Failed,
1397 "interrupted" => TurnTerminalStatus::Interrupted,
1398 "canceled" | "cancelled" => TurnTerminalStatus::Canceled,
1399 _ => TurnTerminalStatus::Completed,
1400 }
1401}
1402
1403async fn emit_stdio_event<W: AsyncWrite + Unpin>(writer: &mut W, event: Value) -> Result<()> {
1404 writer.write_all(&serde_json::to_vec(&event)?).await?;
1405 writer.write_all(b"\n").await?;
1406 writer.flush().await?;
1407 Ok(())
1408}
1409
1410fn take_sse_frame(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
1411 if let Some(pos) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
1412 return Some(buffer.drain(..pos + 4).collect());
1413 }
1414 buffer
1415 .windows(2)
1416 .position(|window| window == b"\n\n")
1417 .map(|pos| buffer.drain(..pos + 2).collect())
1418}
1419
1420fn parse_sse_frame(frame_bytes: &[u8]) -> Option<(String, String)> {
1421 let text = String::from_utf8(frame_bytes.to_vec()).ok()?;
1422 let mut event_name = None;
1423 let mut data_lines = Vec::new();
1424 for raw_line in text.lines() {
1425 let line = raw_line.trim_end_matches('\r');
1426 if let Some(value) = line.strip_prefix("event:") {
1427 event_name = Some(value.trim().to_string());
1428 } else if let Some(value) = line.strip_prefix("data:") {
1429 data_lines.push(value.trim_start().to_string());
1430 }
1431 }
1432 match (event_name, data_lines.is_empty()) {
1433 (Some(event), false) => Some((event, data_lines.join("\n"))),
1434 _ => None,
1435 }
1436}
1437
1438#[cfg(test)]
1439async fn dispatch_stdio_request(
1440 state: &AppState,
1441 method: &str,
1442 params: Value,
1443) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
1444 let mut sink = tokio::io::sink();
1445 dispatch_stdio_request_with_writer(state, &mut sink, method, params).await
1446}
1447
1448async fn dispatch_stdio_app_request(
1449 state: &AppState,
1450 request: AppRequest,
1451) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
1452 let response = Box::pin(process_app_request(state, request, AppTransport::Stdio)).await;
1453 Ok(StdioDispatchResult {
1454 result: serde_json::to_value(response)
1455 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1456 should_exit: false,
1457 })
1458}
1459
1460async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
1461 state: &AppState,
1462 writer: &mut W,
1463 method: &str,
1464 params: Value,
1465) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
1466 let outcome = match method {
1467 "healthz" | "app/healthz" => StdioDispatchResult {
1468 result: json!({
1469 "status": "ok",
1470 "service": legacy_deepseek_compat::SERVICE_NAME,
1471 "transport": "stdio"
1472 }),
1473 should_exit: false,
1474 },
1475 "capabilities" => StdioDispatchResult {
1476 result: json!({
1477 "transport": "stdio",
1478 "families": ["thread/*", "app/*", "prompt/*"],
1479 "methods": [
1480 "healthz",
1481 "thread/capabilities",
1482 "thread/request",
1483 "thread/create",
1484 "thread/start",
1485 "thread/resume",
1486 "thread/fork",
1487 "thread/list",
1488 "thread/read",
1489 "thread/set_name",
1490 "thread/goal/set",
1491 "thread/goal/get",
1492 "thread/goal/clear",
1493 "thread/archive",
1494 "thread/unarchive",
1495 "thread/message",
1496 "thread/interrupt",
1497 "app/capabilities",
1498 "app/request",
1499 "app/config/get",
1500 "app/config/set",
1501 "app/config/unset",
1502 "app/config/list",
1503 "app/config/reload",
1504 "app/models",
1505 "app/thread_loaded_list",
1506 "prompt/capabilities",
1507 "prompt/request",
1508 "prompt/run",
1509 "shutdown"
1510 ]
1511 }),
1512 should_exit: false,
1513 },
1514 "thread/capabilities" => StdioDispatchResult {
1515 result: json!({
1516 "methods": [
1517 "thread/request",
1518 "thread/create",
1519 "thread/start",
1520 "thread/resume",
1521 "thread/fork",
1522 "thread/list",
1523 "thread/read",
1524 "thread/set_name",
1525 "thread/goal/set",
1526 "thread/goal/get",
1527 "thread/goal/clear",
1528 "thread/archive",
1529 "thread/unarchive",
1530 "thread/message",
1531 "thread/interrupt"
1532 ]
1533 }),
1534 should_exit: false,
1535 },
1536 "thread/request" => {
1537 let request: ThreadRequest = parse_params(params)?;
1538 if let ThreadRequest::Message { thread_id, input } = request {
1539 let response = handle_stdio_thread_message(
1540 state,
1541 writer,
1542 ThreadMessageParams { thread_id, input },
1543 )
1544 .await?;
1545 return Ok(StdioDispatchResult {
1546 result: response,
1547 should_exit: false,
1548 });
1549 }
1550 let should_record_hint = matches!(
1551 &request,
1552 ThreadRequest::Create { .. }
1553 | ThreadRequest::Start(_)
1554 | ThreadRequest::Resume(_)
1555 | ThreadRequest::Fork(_)
1556 );
1557 let response = handle_thread_request(state, request).await?;
1558 if should_record_hint {
1559 record_stdio_thread_hint(state, &response).await;
1560 }
1561 StdioDispatchResult {
1562 result: serde_json::to_value(response)
1563 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1564 should_exit: false,
1565 }
1566 }
1567 "thread/create" => {
1568 #[derive(Debug, Deserialize)]
1569 struct CreateParams {
1570 #[serde(default)]
1571 metadata: Value,
1572 }
1573 let parsed: CreateParams = parse_params(params_or_object(params))?;
1574 let response = handle_thread_request(
1575 state,
1576 ThreadRequest::Create {
1577 metadata: parsed.metadata,
1578 },
1579 )
1580 .await?;
1581 record_stdio_thread_hint(state, &response).await;
1582 StdioDispatchResult {
1583 result: serde_json::to_value(response)
1584 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1585 should_exit: false,
1586 }
1587 }
1588 "thread/start" => {
1589 let request = ThreadRequest::Start(parse_params(params_or_object(params))?);
1590 let response = handle_thread_request(state, request).await?;
1591 record_stdio_thread_hint(state, &response).await;
1592 StdioDispatchResult {
1593 result: serde_json::to_value(response)
1594 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1595 should_exit: false,
1596 }
1597 }
1598 "thread/resume" => {
1599 let request = ThreadRequest::Resume(parse_params(params_or_object(params))?);
1600 let response = handle_thread_request(state, request).await?;
1601 ensure_thread_found(&response)?;
1602 record_stdio_thread_hint(state, &response).await;
1603 StdioDispatchResult {
1604 result: serde_json::to_value(response)
1605 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1606 should_exit: false,
1607 }
1608 }
1609 "thread/fork" => {
1610 let request = ThreadRequest::Fork(parse_params(params_or_object(params))?);
1611 let response = handle_thread_request(state, request).await?;
1612 ensure_thread_found(&response)?;
1613 record_stdio_thread_hint(state, &response).await;
1614 StdioDispatchResult {
1615 result: serde_json::to_value(response)
1616 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1617 should_exit: false,
1618 }
1619 }
1620 "thread/list" => {
1621 let request = ThreadRequest::List(parse_params(params_or_object(params))?);
1622 let response = handle_thread_request(state, request).await?;
1623 StdioDispatchResult {
1624 result: serde_json::to_value(response)
1625 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1626 should_exit: false,
1627 }
1628 }
1629 "thread/read" => {
1630 let request = ThreadRequest::Read(parse_params(params_or_object(params))?);
1631 let response = handle_thread_request(state, request).await?;
1632 StdioDispatchResult {
1633 result: serde_json::to_value(response)
1634 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1635 should_exit: false,
1636 }
1637 }
1638 "thread/set_name" | "thread/set-name" => {
1639 let request = ThreadRequest::SetName(parse_params(params_or_object(params))?);
1640 let response = handle_thread_request(state, request).await?;
1641 StdioDispatchResult {
1642 result: serde_json::to_value(response)
1643 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1644 should_exit: false,
1645 }
1646 }
1647 "thread/goal/set" | "thread/goal_set" | "thread/goal-set" => {
1648 let request = ThreadRequest::GoalSet(parse_params::<ThreadGoalSetParams>(
1649 params_or_object(params),
1650 )?);
1651 let response = handle_thread_request(state, request).await?;
1652 StdioDispatchResult {
1653 result: serde_json::to_value(response)
1654 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1655 should_exit: false,
1656 }
1657 }
1658 "thread/goal/get" | "thread/goal_get" | "thread/goal-get" => {
1659 let request = ThreadRequest::GoalGet(parse_params::<ThreadGoalGetParams>(
1660 params_or_object(params),
1661 )?);
1662 let response = handle_thread_request(state, request).await?;
1663 StdioDispatchResult {
1664 result: serde_json::to_value(response)
1665 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1666 should_exit: false,
1667 }
1668 }
1669 "thread/goal/clear" | "thread/goal_clear" | "thread/goal-clear" => {
1670 let request = ThreadRequest::GoalClear(parse_params::<ThreadGoalClearParams>(
1671 params_or_object(params),
1672 )?);
1673 let response = handle_thread_request(state, request).await?;
1674 StdioDispatchResult {
1675 result: serde_json::to_value(response)
1676 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1677 should_exit: false,
1678 }
1679 }
1680 "thread/archive" => {
1681 let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
1682 let response = handle_thread_request(
1683 state,
1684 ThreadRequest::Archive {
1685 thread_id: parsed.thread_id,
1686 },
1687 )
1688 .await?;
1689 StdioDispatchResult {
1690 result: serde_json::to_value(response)
1691 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1692 should_exit: false,
1693 }
1694 }
1695 "thread/unarchive" => {
1696 let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
1697 let response = handle_thread_request(
1698 state,
1699 ThreadRequest::Unarchive {
1700 thread_id: parsed.thread_id,
1701 },
1702 )
1703 .await?;
1704 StdioDispatchResult {
1705 result: serde_json::to_value(response)
1706 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1707 should_exit: false,
1708 }
1709 }
1710 "thread/message" => {
1711 let parsed: ThreadMessageParams = parse_params(params_or_object(params))?;
1712 let response = handle_stdio_thread_message(state, writer, parsed).await?;
1713 StdioDispatchResult {
1714 result: response,
1715 should_exit: false,
1716 }
1717 }
1718 "app/capabilities" => dispatch_stdio_app_request(state, AppRequest::Capabilities).await?,
1719 "app/request" => {
1720 let request: AppRequest = parse_params(params)?;
1721 dispatch_stdio_app_request(state, request).await?
1722 }
1723 "app/config/get" => {
1724 let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
1725 dispatch_stdio_app_request(state, AppRequest::ConfigGet { key: parsed.key }).await?
1726 }
1727 "app/config/set" => {
1728 let parsed: ConfigSetParams = parse_params(params_or_object(params))?;
1729 dispatch_stdio_app_request(
1730 state,
1731 AppRequest::ConfigSet {
1732 key: parsed.key,
1733 value: parsed.value,
1734 },
1735 )
1736 .await?
1737 }
1738 "app/config/unset" => {
1739 let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
1740 dispatch_stdio_app_request(state, AppRequest::ConfigUnset { key: parsed.key }).await?
1741 }
1742 "app/config/list" => dispatch_stdio_app_request(state, AppRequest::ConfigList).await?,
1743 "app/config/reload" => dispatch_stdio_app_request(state, AppRequest::ConfigReload).await?,
1744 "app/models" => dispatch_stdio_app_request(state, AppRequest::Models).await?,
1745 "app/thread_loaded_list" | "app/thread-loaded-list" => {
1746 dispatch_stdio_app_request(state, AppRequest::ThreadLoadedList).await?
1747 }
1748 "prompt/capabilities" => StdioDispatchResult {
1749 result: json!({
1750 "methods": ["prompt/request", "prompt/run"]
1751 }),
1752 should_exit: false,
1753 },
1754 "prompt/request" | "prompt/run" => {
1755 let request: PromptRequest = parse_params(params)?;
1756 let response = handle_prompt_request(state, request).await?;
1757 StdioDispatchResult {
1758 result: serde_json::to_value(response)
1759 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1760 should_exit: false,
1761 }
1762 }
1763 "thread/interrupt" => {
1764 let parsed: ThreadInterruptParams = parse_params(params_or_object(params))?;
1765 let interrupted = interrupt_stdio_turn(state, &parsed.thread_id).await?;
1766 StdioDispatchResult {
1767 result: json!({
1768 "thread_id": parsed.thread_id,
1769 "interrupted": interrupted,
1770 }),
1771 should_exit: false,
1772 }
1773 }
1774 "shutdown" => {
1775 let live: Vec<String> = state.in_flight_turns.lock().await.keys().cloned().collect();
1780 for thread_id in live {
1781 let _ = interrupt_stdio_turn(state, &thread_id).await;
1782 }
1783 if let Some(bridge) = state.stdio_bridge.lock().await.take() {
1784 bridge.lock().await.shutdown_child();
1785 }
1786 StdioDispatchResult {
1787 result: json!({"ok": true, "status": "stopped"}),
1788 should_exit: true,
1789 }
1790 }
1791 _ => return Err(JsonRpcError::method_not_found(method)),
1792 };
1793 Ok(outcome)
1794}
1795
1796async fn process_app_request(
1797 state: &AppState,
1798 req: AppRequest,
1799 _transport: AppTransport,
1800) -> AppResponse {
1801 match req {
1802 AppRequest::Capabilities => AppResponse {
1803 ok: true,
1804 data: json!({
1805 "routes": ["/thread", "/app", "/prompt", "/tool", "/jobs", "/mcp/startup"],
1806 "config": ["get", "set", "unset", "list", "reload"],
1807 "events": ["response_start", "response_delta", "response_end", "tool_call_start", "tool_call_result", "mcp_startup_update", "mcp_startup_complete"],
1808 "transport": "stdio+http",
1809 "config_path": state.config_path.as_ref().map(|p| p.display().to_string()),
1810 }),
1811 events: Vec::new(),
1812 },
1813 AppRequest::ConfigGet { key } => {
1814 let cfg = state.config.read().await;
1815 let value = cfg.get_display_value(&key);
1816 AppResponse {
1817 ok: true,
1818 data: json!({ "key": key, "value": value }),
1819 events: Vec::new(),
1820 }
1821 }
1822 AppRequest::ConfigSet { key, value } => {
1823 let (result, snapshot) = {
1824 let mut cfg = state.config.write().await;
1825 let result = cfg.set_value(&key, &value);
1826 (result, cfg.clone())
1827 };
1828 let ok = result.is_ok();
1829 let message = result.err().map(|e| e.to_string());
1830 if ok {
1838 apply_config_update(state, snapshot, None, true).await;
1839 }
1840 AppResponse {
1841 ok,
1842 data: json!({ "key": key, "value": value, "error": message }),
1843 events: Vec::new(),
1844 }
1845 }
1846 AppRequest::ConfigUnset { key } => {
1847 let (result, snapshot) = {
1848 let mut cfg = state.config.write().await;
1849 let result = cfg.unset_value(&key);
1850 (result, cfg.clone())
1851 };
1852 let ok = result.is_ok();
1853 let message = result.err().map(|e| e.to_string());
1854 if ok {
1857 apply_config_update(state, snapshot, None, true).await;
1858 }
1859 AppResponse {
1860 ok,
1861 data: json!({ "key": key, "error": message }),
1862 events: Vec::new(),
1863 }
1864 }
1865 AppRequest::ConfigList => {
1866 let cfg = state.config.read().await;
1867 AppResponse {
1868 ok: true,
1869 data: json!({ "values": cfg.list_values() }),
1870 events: Vec::new(),
1871 }
1872 }
1873 AppRequest::ConfigReload => {
1874 let store = match ConfigStore::load(state.config_path.clone()) {
1884 Ok(store) => store,
1885 Err(e) => {
1886 return AppResponse {
1887 ok: false,
1888 data: json!({ "error": format!("failed to load config: {e}") }),
1889 events: Vec::new(),
1890 };
1891 }
1892 };
1893 let new_config = store.config.clone();
1894 let new_exec_policy = store.exec_policy_engine();
1895
1896 apply_config_update(state, new_config, Some(new_exec_policy), false).await;
1900
1901 AppResponse {
1902 ok: true,
1903 data: json!({ "reloaded": true }),
1904 events: Vec::new(),
1905 }
1906 }
1907 AppRequest::Models => AppResponse {
1908 ok: true,
1909 data: json!({ "models": state.registry.list() }),
1910 events: Vec::new(),
1911 },
1912 AppRequest::ThreadLoadedList => {
1913 let mut runtime = state.runtime.write().await;
1914 let response = runtime
1915 .handle_thread(codewhale_protocol::ThreadRequest::List(
1916 codewhale_protocol::ThreadListParams {
1917 include_archived: false,
1918 limit: Some(50),
1919 },
1920 ))
1921 .await;
1922 match response {
1923 Ok(thread_resp) => AppResponse {
1924 ok: true,
1925 data: json!({ "threads": thread_resp.threads }),
1926 events: thread_resp.events,
1927 },
1928 Err(err) => AppResponse {
1929 ok: false,
1930 data: json!({ "error": err.to_string() }),
1931 events: Vec::new(),
1932 },
1933 }
1934 }
1935 AppRequest::SubmitUserInput {
1936 request_id,
1937 answers,
1938 } => {
1939 let mut pending = state.pending_user_input.lock().await;
1945 if pending.contains_key(&request_id) {
1946 return AppResponse {
1947 ok: false,
1948 data: json!({
1949 "error": "request_id already resolved",
1950 "request_id": request_id,
1951 }),
1952 events: Vec::new(),
1953 };
1954 }
1955 pending.insert(request_id.clone(), answers);
1956 AppResponse {
1957 ok: true,
1958 data: json!({ "request_id": request_id, "resolved": true }),
1959 events: Vec::new(),
1960 }
1961 }
1962 }
1963}
1964
1965async fn apply_config_update(
1977 state: &AppState,
1978 snapshot: codewhale_config::ConfigToml,
1979 exec_policy: Option<codewhale_execpolicy::ExecPolicyEngine>,
1980 persist: bool,
1981) {
1982 if persist && let Err(e) = persist_config(state, snapshot.clone()).await {
1983 tracing::error!("Failed to persist config update: {e}");
1984 }
1985 {
1986 let mut cfg = state.config.write().await;
1987 *cfg = snapshot.clone();
1988 }
1989 {
1994 let mut runtime = state.runtime.write().await;
1995 match exec_policy {
1996 Some(policy) => runtime.reload_config_and_policy(snapshot, policy),
1997 None => runtime.update_config(snapshot),
1998 }
1999 }
2000 invalidate_stdio_bridge(state).await;
2001}
2002
2003async fn persist_config(state: &AppState, config: codewhale_config::ConfigToml) -> Result<()> {
2004 if state.config_path.is_none() {
2005 return Ok(());
2006 }
2007 let mut store = ConfigStore::load(state.config_path.clone())?;
2008 store.config = config;
2009 store.save()
2010}
2011
2012#[cfg(test)]
2017pub(crate) fn install_test_crypto_provider() {
2018 static INIT: std::sync::OnceLock<()> = std::sync::OnceLock::new();
2019 INIT.get_or_init(|| {
2020 let _ = rustls::crypto::ring::default_provider().install_default();
2021 });
2022}
2023
2024#[cfg(test)]
2025mod tests {
2026 use super::*;
2027 use axum::body::{Body, to_bytes};
2028 use axum::extract::{Path as AxumPath, Query};
2029 use axum::http::header;
2030 use codewhale_protocol::AppRequest;
2031 use std::collections::HashMap;
2032 use std::fs;
2033 use tokio::io::AsyncReadExt;
2034 use tower::ServiceExt;
2035
2036 fn app_with_config(auth_token: Option<&str>) -> (Router, tempfile::TempDir) {
2037 let tmp = tempfile::tempdir().expect("tempdir");
2038 let config_path = tmp.path().join("config.toml");
2039 fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
2040 let state = build_state(
2041 Some(config_path),
2042 auth_token.map(std::string::ToString::to_string),
2043 )
2044 .expect("state");
2045 (app_router(state, &[]), tmp)
2046 }
2047
2048 #[test]
2049 fn build_state_keeps_resolved_explicit_config_path() {
2050 let tmp = tempfile::tempdir().expect("tempdir");
2051 let config_dir = tmp.path().join("config-dir");
2052 fs::create_dir_all(&config_dir).expect("config dir");
2053 let config_path = config_dir.join("config.toml");
2054 fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
2055
2056 let state = build_state(Some(config_path.clone()), None).expect("state");
2057
2058 assert_eq!(
2059 state.config_path.as_deref(),
2060 Some(
2061 config_path
2062 .canonicalize()
2063 .expect("canonical config")
2064 .as_path()
2065 )
2066 );
2067 }
2068
2069 #[tokio::test]
2070 async fn stdio_transport_never_registers_the_stdout_hook_sink() {
2071 let tmp = tempfile::tempdir().expect("tempdir");
2072 let config_path = tmp.path().join("config.toml");
2073 fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
2074
2075 let http_state =
2076 build_state_with_transport(Some(config_path.clone()), None, AppTransport::Http)
2077 .expect("http state");
2078 let stdio_state = build_state_with_transport(Some(config_path), None, AppTransport::Stdio)
2079 .expect("stdio state");
2080
2081 let http_sinks = http_state.runtime.read().await.hooks.sink_count();
2082 let stdio_sinks = stdio_state.runtime.read().await.hooks.sink_count();
2083 assert_eq!(
2084 http_sinks,
2085 stdio_sinks + 1,
2086 "HTTP mode keeps StdoutHookSink + JsonlHookSink; stdio must drop the stdout sink (#5165)"
2087 );
2088 }
2089
2090 async fn response_body_json(response: Response) -> Value {
2091 let bytes = to_bytes(response.into_body(), usize::MAX)
2092 .await
2093 .expect("body bytes");
2094 serde_json::from_slice(&bytes).expect("json response")
2095 }
2096
2097 #[tokio::test]
2098 async fn http_app_routes_require_bearer_token_when_auth_enabled() {
2099 let (app, _tmp) = app_with_config(Some("test-token"));
2100 let response = app
2101 .oneshot(
2102 Request::builder()
2103 .method(Method::POST)
2104 .uri("/app")
2105 .header(header::CONTENT_TYPE, "application/json")
2106 .body(Body::from(
2107 serde_json::to_vec(&AppRequest::ConfigGet {
2108 key: "api_key".to_string(),
2109 })
2110 .expect("request json"),
2111 ))
2112 .expect("request"),
2113 )
2114 .await
2115 .expect("response");
2116
2117 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
2118 }
2119
2120 #[tokio::test]
2121 async fn http_config_get_redacts_sensitive_values_after_auth() {
2122 let (app, _tmp) = app_with_config(Some("test-token"));
2123 let response = app
2124 .oneshot(
2125 Request::builder()
2126 .method(Method::POST)
2127 .uri("/app")
2128 .header(header::AUTHORIZATION, "Bearer test-token")
2129 .header(header::CONTENT_TYPE, "application/json")
2130 .body(Body::from(
2131 serde_json::to_vec(&AppRequest::ConfigGet {
2132 key: "api_key".to_string(),
2133 })
2134 .expect("request json"),
2135 ))
2136 .expect("request"),
2137 )
2138 .await
2139 .expect("response");
2140
2141 assert_eq!(response.status(), StatusCode::OK);
2142 let body = response_body_json(response).await;
2143 assert_eq!(body["data"]["value"], "sk-d***cret");
2144 }
2145
2146 #[tokio::test]
2147 async fn cors_does_not_allow_arbitrary_origins() {
2148 let (app, _tmp) = app_with_config(Some("test-token"));
2149 let response = app
2150 .oneshot(
2151 Request::builder()
2152 .method(Method::GET)
2153 .uri("/healthz")
2154 .header(header::ORIGIN, "https://attacker.example")
2155 .body(Body::empty())
2156 .expect("request"),
2157 )
2158 .await
2159 .expect("response");
2160
2161 assert_eq!(response.status(), StatusCode::OK);
2162 assert!(
2163 response
2164 .headers()
2165 .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
2166 .is_none()
2167 );
2168 }
2169
2170 #[tokio::test]
2171 async fn build_state_loads_permissions_into_runtime_policy() {
2172 let tmp = tempfile::tempdir().expect("tempdir");
2173 let config_path = tmp.path().join("config.toml");
2174 fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
2175 fs::write(
2176 tmp.path().join("permissions.toml"),
2177 r#"
2178 [[rules]]
2179 tool = "exec_shell"
2180 command = "cargo test"
2181 "#,
2182 )
2183 .expect("write permissions");
2184
2185 let state = build_state(Some(config_path), None).expect("state");
2186 let runtime = state.runtime.read().await;
2187 let decision = runtime
2188 .exec_policy
2189 .check(codewhale_execpolicy::ExecPolicyContext {
2190 command: "cargo test --workspace",
2191 cwd: "/workspace",
2192 tool: Some("exec_shell"),
2193 path: None,
2194 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
2195 sandbox_mode: Some("workspace-write"),
2196 })
2197 .expect("policy check");
2198
2199 assert!(decision.allow);
2200 assert!(decision.requires_approval);
2201 assert_eq!(
2202 decision.matched_rule.as_deref(),
2203 Some("tool=exec_shell command=cargo test")
2204 );
2205 }
2206
2207 #[tokio::test]
2208 async fn config_reload_refreshes_runtime_config_and_exec_policy_from_disk() {
2209 let tmp = tempfile::tempdir().expect("tempdir");
2210 let config_path = tmp.path().join("config.toml");
2211 fs::write(
2212 &config_path,
2213 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
2214 )
2215 .expect("write config");
2216 let state = build_state(Some(config_path.clone()), None).expect("state");
2218
2219 {
2221 let runtime = state.runtime.read().await;
2222 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat"));
2223 let decision = runtime
2224 .exec_policy
2225 .check(codewhale_execpolicy::ExecPolicyContext {
2226 command: "cargo test",
2227 cwd: "/workspace",
2228 tool: Some("exec_shell"),
2229 path: None,
2230 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
2231 sandbox_mode: Some("workspace-write"),
2232 })
2233 .expect("policy check");
2234 assert!(decision.matched_rule.is_none());
2235 }
2236
2237 fs::write(
2239 &config_path,
2240 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-reasoner\"\n",
2241 )
2242 .expect("rewrite config");
2243 fs::write(
2244 tmp.path().join("permissions.toml"),
2245 r#"
2246 [[rules]]
2247 tool = "exec_shell"
2248 command = "cargo test"
2249 "#,
2250 )
2251 .expect("write permissions");
2252
2253 let response =
2256 process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await;
2257 assert!(response.ok, "reload should succeed");
2258 assert_eq!(response.data["reloaded"], true);
2259
2260 {
2262 let cfg = state.config.read().await;
2263 assert_eq!(cfg.model.as_deref(), Some("deepseek-reasoner"));
2264 }
2265 {
2267 let runtime = state.runtime.read().await;
2268 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-reasoner"));
2269 let decision = runtime
2270 .exec_policy
2271 .check(codewhale_execpolicy::ExecPolicyContext {
2272 command: "cargo test --workspace",
2273 cwd: "/workspace",
2274 tool: Some("exec_shell"),
2275 path: None,
2276 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
2277 sandbox_mode: Some("workspace-write"),
2278 })
2279 .expect("policy check");
2280 assert!(decision.allow);
2281 assert!(decision.requires_approval);
2282 assert_eq!(
2283 decision.matched_rule.as_deref(),
2284 Some("tool=exec_shell command=cargo test")
2285 );
2286 }
2287 }
2288
2289 #[tokio::test]
2290 async fn config_set_propagates_to_runtime_config_without_touching_exec_policy() {
2291 let tmp = tempfile::tempdir().expect("tempdir");
2292 let config_path = tmp.path().join("config.toml");
2293 fs::write(
2294 &config_path,
2295 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
2296 )
2297 .expect("write config");
2298 let state = build_state(Some(config_path.clone()), None).expect("state");
2299
2300 let response = process_app_request(
2303 &state,
2304 AppRequest::ConfigSet {
2305 key: "model".to_string(),
2306 value: "deepseek-reasoner".to_string(),
2307 },
2308 AppTransport::Stdio,
2309 )
2310 .await;
2311 assert!(response.ok, "set should succeed");
2312
2313 {
2315 let runtime = state.runtime.read().await;
2316 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-reasoner"));
2317 let decision = runtime
2319 .exec_policy
2320 .check(codewhale_execpolicy::ExecPolicyContext {
2321 command: "cargo test",
2322 cwd: "/workspace",
2323 tool: Some("exec_shell"),
2324 path: None,
2325 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
2326 sandbox_mode: Some("workspace-write"),
2327 })
2328 .expect("policy check");
2329 assert!(decision.matched_rule.is_none());
2330 }
2331 let persisted = fs::read_to_string(&config_path).expect("read config");
2333 assert!(persisted.contains("deepseek-reasoner"));
2334 }
2335
2336 fn sentinel_bridge() -> SharedRuntimeBridge {
2339 Arc::new(Mutex::new(RuntimeBridge {
2340 base_url: "http://127.0.0.1:0".to_string(),
2341 client: reqwest::Client::new(),
2342 auth_token: None,
2343 child: None,
2344 thread_map: HashMap::from([("stdio-1".to_string(), "runtime-1".to_string())]),
2345 last_seq_by_thread: HashMap::new(),
2346 }))
2347 }
2348
2349 #[tokio::test]
2350 async fn failed_config_set_keeps_the_stdio_bridge() {
2351 crate::install_test_crypto_provider();
2352 let tmp = tempfile::tempdir().expect("tempdir");
2362 let config_path = tmp.path().join("config.toml");
2363 fs::write(&config_path, "model = \"deepseek-chat\"\n").expect("write config");
2364 let state = build_state(Some(config_path.clone()), None).expect("state");
2365 *state.stdio_bridge.lock().await = Some(sentinel_bridge());
2366
2367 let response = process_app_request(
2368 &state,
2369 AppRequest::ConfigSet {
2370 key: "telemetry".to_string(),
2371 value: "not-a-bool".to_string(),
2372 },
2373 AppTransport::Stdio,
2374 )
2375 .await;
2376 assert!(!response.ok, "invalid value must fail: {response:?}");
2377
2378 let slot = state.stdio_bridge.lock().await;
2379 let kept = slot
2380 .as_ref()
2381 .expect("bridge must survive a failed config/set");
2382 assert_eq!(
2383 kept.lock()
2384 .await
2385 .thread_map
2386 .get("stdio-1")
2387 .map(String::as_str),
2388 Some("runtime-1"),
2389 "the live thread map must be intact",
2390 );
2391 }
2392
2393 #[tokio::test]
2394 async fn successful_config_set_still_invalidates_the_stdio_bridge() {
2395 crate::install_test_crypto_provider();
2396 let tmp = tempfile::tempdir().expect("tempdir");
2399 let config_path = tmp.path().join("config.toml");
2400 fs::write(&config_path, "model = \"deepseek-chat\"\n").expect("write config");
2401 let state = build_state(Some(config_path.clone()), None).expect("state");
2402 *state.stdio_bridge.lock().await = Some(sentinel_bridge());
2403
2404 let response = process_app_request(
2405 &state,
2406 AppRequest::ConfigSet {
2407 key: "model".to_string(),
2408 value: "deepseek-reasoner".to_string(),
2409 },
2410 AppTransport::Stdio,
2411 )
2412 .await;
2413 assert!(response.ok, "valid set should succeed: {response:?}");
2414 assert!(
2415 state.stdio_bridge.lock().await.is_none(),
2416 "a successful config change must invalidate the cached bridge",
2417 );
2418 }
2419
2420 #[tokio::test]
2421 async fn config_unset_propagates_to_runtime_config() {
2422 let tmp = tempfile::tempdir().expect("tempdir");
2423 let config_path = tmp.path().join("config.toml");
2424 fs::write(
2425 &config_path,
2426 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
2427 )
2428 .expect("write config");
2429 let state = build_state(Some(config_path.clone()), None).expect("state");
2430
2431 {
2433 let runtime = state.runtime.read().await;
2434 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat"));
2435 }
2436
2437 let response = process_app_request(
2441 &state,
2442 AppRequest::ConfigUnset {
2443 key: "model".to_string(),
2444 },
2445 AppTransport::Stdio,
2446 )
2447 .await;
2448 assert!(response.ok, "unset should succeed");
2449
2450 {
2452 let runtime = state.runtime.read().await;
2453 assert!(runtime.config.model.is_none());
2454 }
2455 {
2457 let cfg = state.config.read().await;
2458 assert!(cfg.model.is_none());
2459 }
2460 let persisted = fs::read_to_string(&config_path).expect("read config");
2462 assert!(!persisted.contains("deepseek-chat"));
2463 }
2464
2465 #[tokio::test]
2466 async fn config_reload_returns_error_when_disk_config_is_invalid() {
2467 let tmp = tempfile::tempdir().expect("tempdir");
2468 let config_path = tmp.path().join("config.toml");
2469 fs::write(
2470 &config_path,
2471 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
2472 )
2473 .expect("write config");
2474 let state = build_state(Some(config_path.clone()), None).expect("state");
2475
2476 fs::write(&config_path, "api_key = \"unterminated\n").expect("corrupt config");
2478
2479 let response =
2480 process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await;
2481 assert!(!response.ok, "reload of corrupt config must fail");
2482 let err = response.data["error"]
2483 .as_str()
2484 .expect("error message present")
2485 .to_string();
2486 assert!(
2487 err.contains("failed to load config"),
2488 "error should mention load failure, got: {err}"
2489 );
2490
2491 {
2494 let runtime = state.runtime.read().await;
2495 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat"));
2496 }
2497 {
2498 let cfg = state.config.read().await;
2499 assert_eq!(cfg.model.as_deref(), Some("deepseek-chat"));
2500 }
2501 }
2502
2503 async fn seed_test_bridge(state: &AppState) -> SharedRuntimeBridge {
2504 let bridge = Arc::new(Mutex::new(RuntimeBridge::from_base_url_for_test(
2505 "http://127.0.0.1:9".to_string(),
2506 )));
2507 *state.stdio_bridge.lock().await = Some(bridge.clone());
2508 bridge
2509 }
2510
2511 #[tokio::test]
2512 async fn config_set_invalidates_cached_stdio_bridge() {
2513 let tmp = tempfile::tempdir().expect("tempdir");
2514 let config_path = tmp.path().join("config.toml");
2515 fs::write(&config_path, "model = \"deepseek-chat\"\n").expect("write config");
2516 let state = build_state(Some(config_path), None).expect("state");
2517 seed_test_bridge(&state).await;
2518
2519 let response = process_app_request(
2520 &state,
2521 AppRequest::ConfigSet {
2522 key: "model".to_string(),
2523 value: "deepseek-reasoner".to_string(),
2524 },
2525 AppTransport::Stdio,
2526 )
2527 .await;
2528 assert!(response.ok, "set should succeed");
2529
2530 assert!(state.stdio_bridge.lock().await.is_none());
2533 }
2534
2535 #[tokio::test]
2536 async fn config_reload_invalidates_cached_stdio_bridge() {
2537 let tmp = tempfile::tempdir().expect("tempdir");
2538 let config_path = tmp.path().join("config.toml");
2539 fs::write(&config_path, "model = \"deepseek-chat\"\n").expect("write config");
2540 let state = build_state(Some(config_path), None).expect("state");
2541 seed_test_bridge(&state).await;
2542
2543 let response =
2544 process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await;
2545 assert!(response.ok, "reload should succeed");
2546
2547 assert!(state.stdio_bridge.lock().await.is_none());
2548 }
2549
2550 #[tokio::test]
2551 async fn stdio_bridge_invalidation_not_blocked_by_in_flight_turn() {
2552 let (state, _tmp) = capability_test_state();
2553 let bridge = seed_test_bridge(&state).await;
2554
2555 let _in_flight = bridge.lock().await;
2557
2558 tokio::time::timeout(Duration::from_secs(1), invalidate_stdio_bridge(&state))
2561 .await
2562 .expect("invalidation must not wait on bridge traffic");
2563 assert!(state.stdio_bridge.lock().await.is_none());
2564 }
2565
2566 #[tokio::test]
2567 async fn runtime_read_paths_run_concurrently() {
2568 let (state, _tmp) = capability_test_state();
2572 let first = state.runtime.read().await;
2573 let second = state.runtime.read().await;
2574 assert!(first.app_status().ok);
2575 assert!(second.app_status().ok);
2576 }
2577
2578 #[tokio::test]
2579 async fn health_probes_advertise_legacy_deepseek_service_name() {
2580 let (app, _tmp) = app_with_config(None);
2583 let response = app
2584 .oneshot(
2585 Request::builder()
2586 .method(Method::GET)
2587 .uri("/healthz")
2588 .body(Body::empty())
2589 .expect("request"),
2590 )
2591 .await
2592 .expect("response");
2593 let body = response_body_json(response).await;
2594 assert_eq!(body["service"], legacy_deepseek_compat::SERVICE_NAME);
2595 assert_eq!(body["service"], "deepseek-app-server");
2596
2597 let (state, _tmp) = capability_test_state();
2598 let stdio = dispatch_stdio_request(&state, "healthz", json!({}))
2599 .await
2600 .expect("stdio healthz");
2601 assert_eq!(
2602 stdio.result["service"],
2603 legacy_deepseek_compat::SERVICE_NAME
2604 );
2605 }
2606
2607 #[test]
2608 fn non_loopback_bind_without_auth_fails_fast() {
2609 let options = AppServerOptions {
2610 listen: "0.0.0.0:8787".parse().expect("socket addr"),
2611 config_path: None,
2612 auth_token: None,
2613 insecure_no_auth: false,
2614 cors_origins: Vec::new(),
2615 };
2616
2617 let err =
2618 resolve_auth_token(&options).expect_err("non-loopback generated auth should fail");
2619 assert!(err.to_string().contains("without explicit auth token"));
2620 }
2621
2622 #[tokio::test]
2623 async fn stdio_transport_redacts_config_get_secrets() {
2624 let tmp = tempfile::tempdir().expect("tempdir");
2625 let config_path = tmp.path().join("config.toml");
2626 fs::write(&config_path, "").expect("write config");
2627 let state = build_state(Some(config_path), None).expect("state");
2628 {
2629 let mut cfg = state.config.write().await;
2630 cfg.api_key = Some("sk-deepseek-secret".to_string());
2631 }
2632
2633 let response = process_app_request(
2634 &state,
2635 AppRequest::ConfigGet {
2636 key: "api_key".to_string(),
2637 },
2638 AppTransport::Stdio,
2639 )
2640 .await;
2641
2642 assert_eq!(response.data["value"], "sk-d***cret");
2643 }
2644
2645 #[tokio::test]
2646 async fn stdio_thread_goal_methods_round_trip_persisted_goal() {
2647 let tmp = tempfile::tempdir().expect("tempdir");
2648 let config_path = tmp.path().join("config.toml");
2649 fs::write(&config_path, "").expect("write config");
2650 let state = build_state(Some(config_path), None).expect("state");
2651
2652 let capabilities = dispatch_stdio_request(&state, "thread/capabilities", json!({}))
2653 .await
2654 .expect("thread capabilities");
2655 assert!(
2656 capabilities.result["methods"]
2657 .as_array()
2658 .expect("methods")
2659 .iter()
2660 .any(|method| method == "thread/goal/set")
2661 );
2662
2663 let started = dispatch_stdio_request(&state, "thread/start", json!({}))
2664 .await
2665 .expect("start thread");
2666 let thread_id = started.result["thread_id"]
2667 .as_str()
2668 .expect("thread id")
2669 .to_string();
2670
2671 let set = dispatch_stdio_request(
2672 &state,
2673 "thread/goal/set",
2674 json!({
2675 "thread_id": thread_id,
2676 "objective": "Release 0.8.59",
2677 "token_budget": 59000
2678 }),
2679 )
2680 .await
2681 .expect("set goal");
2682 assert_eq!(set.result["status"], "ok");
2683 assert_eq!(set.result["goal"]["objective"], "Release 0.8.59");
2684 assert_eq!(set.result["goal"]["status"], "active");
2685
2686 let got = dispatch_stdio_request(
2687 &state,
2688 "thread/goal/get",
2689 json!({
2690 "thread_id": thread_id
2691 }),
2692 )
2693 .await
2694 .expect("get goal");
2695 assert_eq!(got.result["goal"]["token_budget"], 59000);
2696
2697 let cleared = dispatch_stdio_request(
2698 &state,
2699 "thread/goal/clear",
2700 json!({
2701 "thread_id": thread_id
2702 }),
2703 )
2704 .await
2705 .expect("clear goal");
2706 assert_eq!(cleared.result["status"], "cleared");
2707 assert_eq!(cleared.result["data"]["cleared"], true);
2708 }
2709
2710 #[tokio::test]
2711 async fn stdio_resume_of_missing_thread_fails_without_clobbering_the_hint() {
2712 let tmp = tempfile::tempdir().expect("tempdir");
2713 let config_path = tmp.path().join("config.toml");
2714 fs::write(&config_path, "").expect("write config");
2715 let state = build_state(Some(config_path), None).expect("state");
2716
2717 let workspace = tmp.path().join("ws");
2720 {
2721 let mut hints = state.stdio_thread_hints.lock().await;
2722 hints.insert(
2723 "ghost-thread".to_string(),
2724 RuntimeThreadHint {
2725 model: Some("deepseek-v4-pro".to_string()),
2726 workspace: Some(workspace.clone()),
2727 },
2728 );
2729 }
2730
2731 let err = dispatch_stdio_request(
2732 &state,
2733 "thread/resume",
2734 json!({ "thread_id": "ghost-thread" }),
2735 )
2736 .await
2737 .expect_err("resuming a missing thread must fail with a named not-found error");
2738 assert_eq!(err.code, -32004);
2739 assert!(err.message.contains("ghost-thread"), "{}", err.message);
2740
2741 let fork_err = dispatch_stdio_request(
2742 &state,
2743 "thread/fork",
2744 json!({ "thread_id": "ghost-thread" }),
2745 )
2746 .await
2747 .expect_err("forking a missing thread must fail with a named not-found error");
2748 assert_eq!(fork_err.code, -32004);
2749
2750 let hints = state.stdio_thread_hints.lock().await;
2751 let hint = hints.get("ghost-thread").expect("cached hint survives");
2752 assert_eq!(hint.model.as_deref(), Some("deepseek-v4-pro"));
2753 assert_eq!(hint.workspace.as_deref(), Some(workspace.as_path()));
2754 }
2755
2756 fn sse_frame(event: &str, payload: Value) -> String {
2757 format!("event: {event}\ndata: {payload}\n\n")
2758 }
2759 async fn spawn_uninterruptible_until_asked_runtime() -> (
2762 String,
2763 Arc<tokio::sync::Notify>,
2764 tokio::task::JoinHandle<()>,
2765 ) {
2766 use axum::body::Body;
2767 use axum::extract::Path as AxumPath;
2768
2769 let interrupted = Arc::new(tokio::sync::Notify::new());
2770
2771 async fn create_turn(AxumPath(_thread_id): AxumPath<String>) -> Json<Value> {
2772 Json(json!({ "turn": { "id": "turn_runaway" } }))
2773 }
2774 async fn create_thread() -> Json<Value> {
2775 Json(json!({ "id": "thr_runaway" }))
2776 }
2777 async fn interrupt(
2778 State(notify): State<Arc<tokio::sync::Notify>>,
2779 AxumPath((_thread_id, _turn_id)): AxumPath<(String, String)>,
2780 ) -> Json<Value> {
2781 notify.notify_waiters();
2782 Json(json!({ "ok": true }))
2783 }
2784 async fn thread_events(
2785 State(notify): State<Arc<tokio::sync::Notify>>,
2786 AxumPath(_thread_id): AxumPath<String>,
2787 ) -> ([(header::HeaderName, &'static str); 1], Body) {
2788 notify.notified().await;
2791 let body = [
2792 sse_frame(
2793 "item.delta",
2794 json!({
2795 "seq": 1,
2796 "turn_id": "turn_runaway",
2797 "payload": { "kind": "agent_message", "delta": "thinking" }
2798 }),
2799 ),
2800 sse_frame(
2801 "turn.completed",
2802 json!({
2803 "seq": 2,
2804 "turn_id": "turn_runaway",
2805 "payload": { "turn": { "status": "interrupted" } }
2806 }),
2807 ),
2808 ]
2809 .concat();
2810 (
2811 [(header::CONTENT_TYPE, "text/event-stream")],
2812 Body::from(body),
2813 )
2814 }
2815
2816 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2817 .await
2818 .expect("bind test listener");
2819 let addr = listener.local_addr().expect("listener addr");
2820 let app = Router::new()
2821 .route("/v1/threads", post(create_thread))
2822 .route("/v1/threads/{thread_id}/turns", post(create_turn))
2823 .route(
2824 "/v1/threads/{thread_id}/turns/{turn_id}/interrupt",
2825 post(interrupt),
2826 )
2827 .route("/v1/threads/{thread_id}/events", get(thread_events))
2828 .with_state(interrupted.clone());
2829 let server = tokio::spawn(async move {
2830 axum::serve(listener, app)
2831 .await
2832 .expect("serve test runtime");
2833 });
2834 (format!("http://{addr}"), interrupted, server)
2835 }
2836
2837 #[tokio::test]
2838 async fn interrupt_stops_a_turn_that_would_otherwise_stream_forever() {
2839 let (base_url, _notify, server) = spawn_uninterruptible_until_asked_runtime().await;
2840 let (state, _tmp) = capability_test_state();
2841 *state.stdio_bridge.lock().await = Some(Arc::new(Mutex::new(
2842 RuntimeBridge::from_base_url_for_test(base_url),
2843 )));
2844
2845 let (client, server_side) = tokio::io::duplex(16 * 1024);
2846 let (client_reader, mut client_writer) = tokio::io::split(client);
2847
2848 let loop_state = state.clone();
2849 let loop_handle = tokio::spawn(async move {
2850 let (rx, tx) = tokio::io::split(server_side);
2851 run_stdio_loop(&loop_state, BufReader::new(rx).lines(), tx).await
2852 });
2853
2854 client_writer
2856 .write_all(
2857 b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"thread/message\",\
2858 \"params\":{\"thread_id\":\"thr_a\",\"input\":\"go\"}}\n",
2859 )
2860 .await
2861 .expect("send thread/message");
2862
2863 tokio::time::timeout(Duration::from_secs(10), async {
2866 loop {
2867 if state.in_flight_turns.lock().await.contains_key("thr_a") {
2868 return;
2869 }
2870 tokio::time::sleep(Duration::from_millis(10)).await;
2871 }
2872 })
2873 .await
2874 .expect("turn should register itself as in flight");
2875
2876 client_writer
2878 .write_all(
2879 b"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"thread/interrupt\",\
2880 \"params\":{\"thread_id\":\"thr_a\"}}\n",
2881 )
2882 .await
2883 .expect("send thread/interrupt");
2884 client_writer
2885 .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"shutdown\"}\n")
2886 .await
2887 .expect("send shutdown");
2888
2889 let finished = tokio::time::timeout(Duration::from_secs(20), loop_handle)
2890 .await
2891 .expect("the loop must exit rather than hang on the runaway turn");
2892 finished.expect("join loop").expect("loop result");
2893
2894 let mut output = String::new();
2895 let mut lines = BufReader::new(client_reader);
2896 lines
2897 .read_to_string(&mut output)
2898 .await
2899 .expect("read stdio output");
2900
2901 let responses: Vec<Value> = output
2902 .lines()
2903 .filter_map(|line| serde_json::from_str::<Value>(line).ok())
2904 .collect();
2905 let by_id = |id: u64| {
2906 responses
2907 .iter()
2908 .find(|value| value["id"] == json!(id))
2909 .unwrap_or_else(|| panic!("no response for id {id} in {output}"))
2910 .clone()
2911 };
2912
2913 assert!(
2915 by_id(1)["error"].is_object(),
2916 "the interrupted turn should report an error, got: {}",
2917 by_id(1)
2918 );
2919 assert_eq!(by_id(2)["result"]["interrupted"], json!(true));
2920 assert_eq!(by_id(3)["result"]["status"], json!("stopped"));
2921
2922 server.abort();
2923 let _ = server.await;
2924 }
2925
2926 #[tokio::test]
2927 async fn interrupting_an_idle_thread_is_not_an_error() {
2928 let (state, _tmp) = capability_test_state();
2929 let response = dispatch_stdio_request(
2930 &state,
2931 "thread/interrupt",
2932 json!({ "thread_id": "thr_nothing_running" }),
2933 )
2934 .await
2935 .expect("interrupt dispatch");
2936 assert_eq!(response.result["interrupted"], json!(false));
2937 }
2938
2939 #[tokio::test]
2940 async fn stdio_runtime_bridge_streams_response_delta_events() {
2941 async fn create_turn(AxumPath(thread_id): AxumPath<String>) -> Json<Value> {
2942 Json(json!({
2943 "thread": { "id": thread_id },
2944 "turn": { "id": "turn_test" },
2945 }))
2946 }
2947
2948 async fn thread_events(
2949 AxumPath(thread_id): AxumPath<String>,
2950 Query(query): Query<HashMap<String, String>>,
2951 ) -> ([(header::HeaderName, &'static str); 1], String) {
2952 assert_eq!(thread_id, "thr_test");
2953 assert_eq!(query.get("since_seq").map(String::as_str), Some("0"));
2954
2955 let body = [
2956 sse_frame(
2957 "item.delta",
2958 json!({
2959 "seq": 1,
2960 "turn_id": "turn_test",
2961 "payload": {
2962 "kind": "agent_message",
2963 "delta": "hello"
2964 }
2965 }),
2966 ),
2967 sse_frame(
2968 "turn.completed",
2969 json!({
2970 "seq": 2,
2971 "turn_id": "turn_test",
2972 "payload": {
2973 "turn": {
2974 "status": "completed"
2975 }
2976 }
2977 }),
2978 ),
2979 ]
2980 .concat();
2981
2982 ([(header::CONTENT_TYPE, "text/event-stream")], body)
2983 }
2984
2985 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2986 .await
2987 .expect("bind test listener");
2988 let addr = listener.local_addr().expect("listener addr");
2989 let app = Router::new()
2990 .route("/v1/threads/{thread_id}/turns", post(create_turn))
2991 .route("/v1/threads/{thread_id}/events", get(thread_events));
2992
2993 let server = tokio::spawn(async move {
2994 axum::serve(listener, app)
2995 .await
2996 .expect("serve test runtime");
2997 });
2998
2999 let mut bridge = RuntimeBridge::from_base_url_for_test(format!("http://{addr}"));
3000 let (mut reader, mut writer) = tokio::io::duplex(4096);
3001
3002 let result = bridge
3003 .message_thread("thr_test", "hello", &mut writer, None)
3004 .await
3005 .expect("message_thread should succeed");
3006 drop(writer);
3007
3008 let mut stdout = Vec::new();
3009 reader
3010 .read_to_end(&mut stdout)
3011 .await
3012 .expect("read stdio output");
3013 server.abort();
3014 let _ = server.await;
3015
3016 let lines: Vec<Value> = String::from_utf8(stdout)
3017 .expect("utf8 output")
3018 .lines()
3019 .map(|line| serde_json::from_str(line).expect("json line"))
3020 .collect();
3021
3022 assert_eq!(
3023 result.get("status").and_then(Value::as_str),
3024 Some("accepted")
3025 );
3026 assert_eq!(
3027 result.pointer("/data/turn_id").and_then(Value::as_str),
3028 Some("turn_test")
3029 );
3030 assert_eq!(bridge.last_seq_by_thread.get("thr_test"), Some(&2));
3031
3032 let event_types: Vec<&str> = lines
3033 .iter()
3034 .map(|line| {
3035 line.get("type")
3036 .and_then(Value::as_str)
3037 .expect("event type")
3038 })
3039 .collect();
3040 assert_eq!(
3041 event_types,
3042 vec!["response_start", "response_delta", "response_end"]
3043 );
3044 assert_eq!(lines[1]["delta"], "hello");
3045 }
3046
3047 #[tokio::test]
3048 async fn stdio_runtime_bridge_applies_thread_start_hints() {
3049 async fn create_thread(Json(body): Json<Value>) -> Json<Value> {
3050 assert_eq!(body["model"], "deepseek-v4");
3051 assert_eq!(body["workspace"], "/tmp/codewhale-stdio");
3052 Json(json!({
3053 "id": "thr_runtime",
3054 "model": body["model"].clone(),
3055 "workspace": body["workspace"].clone(),
3056 }))
3057 }
3058
3059 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
3060 .await
3061 .expect("bind test listener");
3062 let addr = listener.local_addr().expect("listener addr");
3063 let app = Router::new().route("/v1/threads", post(create_thread));
3064
3065 let server = tokio::spawn(async move {
3066 axum::serve(listener, app)
3067 .await
3068 .expect("serve test runtime");
3069 });
3070
3071 let mut bridge = RuntimeBridge::from_base_url_for_test(format!("http://{addr}"));
3072 let runtime_id = bridge
3073 .ensure_runtime_thread(
3074 "legacy_thread",
3075 Some(RuntimeThreadHint {
3076 model: Some("deepseek-v4".to_string()),
3077 workspace: Some(PathBuf::from("/tmp/codewhale-stdio")),
3078 }),
3079 )
3080 .await
3081 .expect("runtime thread");
3082 server.abort();
3083 let _ = server.await;
3084
3085 assert_eq!(runtime_id, "thr_runtime");
3086 assert_eq!(
3087 bridge.thread_map.get("legacy_thread").map(String::as_str),
3088 Some("thr_runtime")
3089 );
3090 }
3091
3092 const EXPECTED_CAPABILITY_METHODS: &[&str] = &[
3101 "healthz",
3102 "thread/capabilities",
3103 "thread/request",
3104 "thread/create",
3105 "thread/start",
3106 "thread/resume",
3107 "thread/fork",
3108 "thread/list",
3109 "thread/read",
3110 "thread/set_name",
3111 "thread/goal/set",
3112 "thread/goal/get",
3113 "thread/goal/clear",
3114 "thread/archive",
3115 "thread/unarchive",
3116 "thread/message",
3117 "thread/interrupt",
3118 "app/capabilities",
3119 "app/request",
3120 "app/config/get",
3121 "app/config/set",
3122 "app/config/unset",
3123 "app/config/list",
3124 "app/config/reload",
3125 "app/models",
3126 "app/thread_loaded_list",
3127 "prompt/capabilities",
3128 "prompt/request",
3129 "prompt/run",
3130 "shutdown",
3131 ];
3132
3133 fn capability_test_state() -> (AppState, tempfile::TempDir) {
3134 let tmp = tempfile::tempdir().expect("tempdir");
3135 let config_path = tmp.path().join("config.toml");
3136 fs::write(&config_path, "").expect("write config");
3137 let state = build_state(Some(config_path), None).expect("state");
3138 (state, tmp)
3139 }
3140
3141 #[tokio::test]
3142 async fn capabilities_method_set_is_stable() {
3143 let (state, _tmp) = capability_test_state();
3144 let caps = dispatch_stdio_request(&state, "capabilities", json!({}))
3145 .await
3146 .expect("capabilities dispatch");
3147 let methods: Vec<String> = caps.result["methods"]
3148 .as_array()
3149 .expect("methods array")
3150 .iter()
3151 .map(|m| m.as_str().expect("method string").to_string())
3152 .collect();
3153 assert_eq!(
3154 methods, EXPECTED_CAPABILITY_METHODS,
3155 "app-server stdio capability set drifted; update the dispatcher, this \
3156 snapshot, and docs/RUNTIME_API.md together"
3157 );
3158 }
3159
3160 #[tokio::test]
3161 async fn every_advertised_capability_is_dispatchable() {
3162 let (state, _tmp) = capability_test_state();
3163 for method in EXPECTED_CAPABILITY_METHODS {
3167 if let Err(err) = dispatch_stdio_request(&state, method, json!({})).await {
3168 assert_ne!(
3169 err.code,
3170 JsonRpcError::method_not_found(method).code,
3171 "advertised capability `{method}` is not dispatchable"
3172 );
3173 }
3174 }
3175 }
3176
3177 #[test]
3180 fn auth_token_empty_string_fails() {
3181 let options = AppServerOptions {
3182 listen: "127.0.0.1:0".parse().expect("addr"),
3183 config_path: None,
3184 auth_token: Some(" ".to_string()),
3185 insecure_no_auth: false,
3186 cors_origins: Vec::new(),
3187 };
3188 let err = resolve_auth_token(&options).expect_err("empty token should fail");
3189 assert!(err.to_string().contains("cannot be empty"));
3190 }
3191
3192 #[test]
3193 fn auth_token_generated_when_none_provided() {
3194 let options = AppServerOptions {
3195 listen: "127.0.0.1:0".parse().expect("addr"),
3196 config_path: None,
3197 auth_token: None,
3198 insecure_no_auth: false,
3199 cors_origins: Vec::new(),
3200 };
3201 let token = resolve_auth_token(&options).unwrap();
3202 assert!(token.is_some());
3203 assert!(token.unwrap().starts_with("cwapp_"));
3204 }
3205
3206 #[test]
3207 fn runtime_bridge_command_keeps_auth_token_out_of_argv() {
3208 let token = "cwrt_unit_test_secret_token_not_for_argv";
3211 let cmd = RuntimeBridge::runtime_command(None, 18787, token).expect("command");
3212 let argv: Vec<String> = cmd
3213 .get_args()
3214 .map(|a| a.to_string_lossy().into_owned())
3215 .collect();
3216 assert!(
3217 !argv
3218 .iter()
3219 .any(|a| a.contains(token) || a == "--auth-token"),
3220 "auth token must not be present in child argv: {argv:?}"
3221 );
3222 let envs: Vec<(String, String)> = cmd
3223 .get_envs()
3224 .filter_map(|(k, v)| {
3225 Some((
3226 k.to_string_lossy().into_owned(),
3227 v?.to_string_lossy().into_owned(),
3228 ))
3229 })
3230 .collect();
3231 assert!(
3232 envs.iter()
3233 .any(|(k, v)| k == "CODEWHALE_RUNTIME_TOKEN" && v == token),
3234 "token must be carried via CODEWHALE_RUNTIME_TOKEN: {envs:?}"
3235 );
3236 assert!(
3237 envs.iter()
3238 .any(|(k, v)| k == "DEEPSEEK_RUNTIME_TOKEN" && v == token),
3239 "legacy alias DEEPSEEK_RUNTIME_TOKEN must also carry the token: {envs:?}"
3240 );
3241 }
3242
3243 #[test]
3244 fn generated_auth_status_does_not_render_token() {
3245 let rendered = app_server_auth_status_lines(false).join("\n");
3246
3247 assert!(!rendered.contains("Authorization: Bearer"));
3248 assert!(rendered.contains("not printed"));
3249 assert!(rendered.contains("CODEWHALE_APP_SERVER_TOKEN"));
3250 }
3251
3252 #[test]
3253 fn auth_token_explicit_is_preserved() {
3254 let options = AppServerOptions {
3255 listen: "127.0.0.1:0".parse().expect("addr"),
3256 config_path: None,
3257 auth_token: Some("my-secret".to_string()),
3258 insecure_no_auth: false,
3259 cors_origins: Vec::new(),
3260 };
3261 let token = resolve_auth_token(&options).unwrap();
3262 assert_eq!(token.as_deref(), Some("my-secret"));
3263 }
3264
3265 #[test]
3266 fn auth_token_explicit_allows_non_loopback_bind() {
3267 let options = AppServerOptions {
3268 listen: "0.0.0.0:8787".parse().expect("socket addr"),
3269 config_path: None,
3270 auth_token: Some("my-secret".to_string()),
3271 insecure_no_auth: false,
3272 cors_origins: Vec::new(),
3273 };
3274 let token = resolve_auth_token(&options).unwrap();
3275 assert_eq!(token.as_deref(), Some("my-secret"));
3276 }
3277
3278 #[test]
3279 fn insecure_no_auth_on_loopback_returns_none() {
3280 let options = AppServerOptions {
3281 listen: "127.0.0.1:0".parse().expect("addr"),
3282 config_path: None,
3283 auth_token: None,
3284 insecure_no_auth: true,
3285 cors_origins: Vec::new(),
3286 };
3287 let token = resolve_auth_token(&options).unwrap();
3288 assert!(token.is_none());
3289 }
3290
3291 #[test]
3292 fn insecure_no_auth_on_non_loopback_fails_fast() {
3293 let options = AppServerOptions {
3294 listen: "0.0.0.0:8787".parse().expect("socket addr"),
3295 config_path: None,
3296 auth_token: None,
3297 insecure_no_auth: true,
3298 cors_origins: Vec::new(),
3299 };
3300
3301 let err = resolve_auth_token(&options).expect_err("non-loopback unauth should fail");
3302 assert!(
3303 err.to_string()
3304 .contains("refusing unauthenticated app-server bind")
3305 );
3306 }
3307
3308 #[test]
3311 fn cors_layer_includes_default_origins() {
3312 let layer = cors_layer(&[]);
3313 let _ = layer;
3315 }
3316
3317 #[test]
3318 fn cors_layer_adds_extra_origins() {
3319 let extras = vec!["https://example.com".to_string()];
3320 let layer = cors_layer(&extras);
3321 let _ = layer;
3322 }
3323
3324 #[test]
3325 fn cors_layer_skips_empty_origins() {
3326 let extras = vec!["".to_string(), " ".to_string()];
3327 let layer = cors_layer(&extras);
3328 let _ = layer;
3329 }
3330
3331 #[test]
3334 fn params_or_object_returns_object_for_null() {
3335 let result = params_or_object(Value::Null);
3336 assert_eq!(result, json!({}));
3337 }
3338
3339 #[test]
3340 fn params_or_object_passthrough_for_non_null() {
3341 let input = json!({"key": "value"});
3342 let result = params_or_object(input.clone());
3343 assert_eq!(result, input);
3344 }
3345
3346 #[test]
3347 fn jsonrpc_result_format() {
3348 let result = jsonrpc_result(Some(json!(1)), json!({"ok": true}));
3349 assert_eq!(result["jsonrpc"], "2.0");
3350 assert_eq!(result["id"], 1);
3351 assert_eq!(result["result"]["ok"], true);
3352 }
3353
3354 #[test]
3355 fn jsonrpc_result_null_id() {
3356 let result = jsonrpc_result(None, json!(null));
3357 assert_eq!(result["id"], Value::Null);
3358 }
3359
3360 #[test]
3361 fn jsonrpc_error_format() {
3362 let err = jsonrpc_error(Some(json!(2)), JsonRpcError::internal("oops"));
3363 assert_eq!(err["jsonrpc"], "2.0");
3364 assert_eq!(err["id"], 2);
3365 assert_eq!(err["error"]["code"], -32603);
3366 assert_eq!(err["error"]["message"], "oops");
3367 }
3368
3369 #[test]
3370 fn jsonrpc_error_codes() {
3371 assert_eq!(JsonRpcError::parse_error("").code, -32700);
3372 assert_eq!(JsonRpcError::invalid_request("").code, -32600);
3373 assert_eq!(JsonRpcError::method_not_found("x").code, -32601);
3374 assert_eq!(JsonRpcError::invalid_params("").code, -32602);
3375 assert_eq!(JsonRpcError::internal("").code, -32603);
3376 }
3377
3378 #[test]
3381 fn app_server_options_debug_does_not_leak_token() {
3382 let options = AppServerOptions {
3383 listen: "127.0.0.1:8080".parse().expect("addr"),
3384 config_path: None,
3385 auth_token: Some("secret-token".to_string()),
3386 insecure_no_auth: false,
3387 cors_origins: vec!["https://example.com".to_string()],
3388 };
3389 let debug = format!("{options:?}");
3390 assert!(!debug.contains("secret-token"));
3391 assert!(debug.contains("<redacted>"));
3392 assert!(debug.contains("8080"));
3393 }
3394
3395 #[test]
3398 fn default_cors_origins_include_common_dev_ports() {
3399 assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:3000"));
3400 assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:5173"));
3401 assert!(DEFAULT_CORS_ORIGINS.contains(&"tauri://localhost"));
3402 }
3403}