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(config_path, None)?;
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 let has_explicit_config_path = config_path.is_some();
619 let store = ConfigStore::load(config_path)?;
620 let config_path = has_explicit_config_path.then(|| store.path().to_path_buf());
621 let config = store.config.clone();
622 let exec_policy = store.exec_policy_engine();
623 let registry = ModelRegistry::default();
624
625 let state_db_path = config_path
626 .as_ref()
627 .and_then(|p| p.parent().map(|parent| parent.join("state.db")));
628 let state_store = StateStore::open(state_db_path)?;
629
630 let mut hooks = HookDispatcher::default();
631 hooks.add_sink(Arc::new(StdoutHookSink));
632 let hook_log_path = config_path
633 .as_ref()
634 .and_then(|p| p.parent().map(|parent| parent.join("events.jsonl")))
635 .unwrap_or_else(legacy_deepseek_compat::default_events_log_path);
636 hooks.add_sink(Arc::new(JsonlHookSink::new(hook_log_path)));
637
638 if let Some(socket_path) = config
639 .hook_sinks
640 .as_ref()
641 .and_then(|sinks| sinks.unix_socket_path.as_ref())
642 .filter(|path| !path.as_os_str().is_empty())
643 {
644 hooks.add_sink(Arc::new(UnixSocketHookSink::new(socket_path.clone())));
645 }
646
647 let runtime = Runtime::new(
648 config.clone(),
649 registry.clone(),
650 state_store,
651 Arc::new(ToolRegistry::default()),
652 Arc::new(McpManager::default()),
653 exec_policy,
654 hooks,
655 );
656
657 Ok(AppState {
658 config_path,
659 config: Arc::new(RwLock::new(config)),
660 runtime: Arc::new(RwLock::new(runtime)),
661 registry,
662 auth_token,
663 stdio_bridge: Arc::new(Mutex::new(None)),
664 stdio_thread_hints: Arc::new(Mutex::new(HashMap::new())),
665 pending_user_input: Arc::new(Mutex::new(std::collections::HashMap::new())),
666 in_flight_turns: Arc::new(Mutex::new(HashMap::new())),
667 })
668}
669
670fn resolve_auth_token(options: &AppServerOptions) -> Result<Option<String>> {
671 let configured = options.auth_token.as_ref().map(|token| token.trim());
672 if let Some(token) = configured
673 && token.is_empty()
674 {
675 bail!("app-server auth token cannot be empty");
676 }
677 let has_explicit_token = configured.is_some();
678
679 if options.insecure_no_auth {
680 if !options.listen.ip().is_loopback() {
681 bail!("refusing unauthenticated app-server bind on non-loopback address");
682 }
683 eprintln!("warning: app-server HTTP auth disabled by --insecure-no-auth");
684 return Ok(None);
685 }
686
687 if !has_explicit_token && !options.listen.ip().is_loopback() {
688 bail!(
689 "refusing non-loopback app-server bind without explicit auth token; pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN"
690 );
691 }
692
693 let token = configured
694 .map(str::to_string)
695 .unwrap_or_else(|| format!("cwapp_{}", Uuid::new_v4().simple()));
696 for line in app_server_auth_status_lines(has_explicit_token) {
697 eprintln!("{line}");
698 }
699 Ok(Some(token))
700}
701
702fn app_server_auth_status_lines(has_explicit_token: bool) -> Vec<&'static str> {
703 if has_explicit_token {
704 return vec!["app-server auth: bearer token required for HTTP routes."];
705 }
706 vec![
707 "app-server auth: generated bearer token for this process (not printed).",
708 " Pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN when another client needs to connect.",
709 ]
710}
711
712fn cors_layer(extra_origins: &[String]) -> CorsLayer {
713 let mut origins: Vec<HeaderValue> = DEFAULT_CORS_ORIGINS
714 .iter()
715 .filter_map(|origin| HeaderValue::from_str(origin).ok())
716 .collect();
717 for raw in extra_origins {
718 let trimmed = raw.trim();
719 if trimmed.is_empty() {
720 continue;
721 }
722 match HeaderValue::from_str(trimmed) {
723 Ok(value) if !origins.contains(&value) => origins.push(value),
724 Ok(_) => {}
725 Err(err) => {
726 eprintln!("warning: ignoring invalid app-server CORS origin `{trimmed}`: {err}")
727 }
728 }
729 }
730
731 CorsLayer::new()
732 .allow_origin(origins)
733 .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
734 .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
735}
736
737async fn require_app_server_token(
738 State(state): State<AppState>,
739 req: Request,
740 next: Next,
741) -> Response {
742 let Some(expected) = state.auth_token.as_deref() else {
743 return next.run(req).await;
744 };
745 let authorized = req
746 .headers()
747 .get(header::AUTHORIZATION)
748 .and_then(|value| value.to_str().ok())
749 .and_then(|raw| raw.strip_prefix("Bearer "))
750 .is_some_and(|token| constant_time_eq(token.as_bytes(), expected.as_bytes()));
751
752 if authorized {
753 next.run(req).await
754 } else {
755 (
756 StatusCode::UNAUTHORIZED,
757 Json(json!({
758 "error": {
759 "message": "app-server bearer token required",
760 "status": StatusCode::UNAUTHORIZED.as_u16(),
761 }
762 })),
763 )
764 .into_response()
765 }
766}
767
768fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
771 let mut diff = a.len() ^ b.len();
772 for i in 0..a.len().max(b.len()) {
773 let x = a.get(i).copied().unwrap_or(0);
774 let y = b.get(i).copied().unwrap_or(0);
775 diff |= usize::from(x ^ y);
776 }
777 diff == 0
778}
779
780fn params_or_object(params: Value) -> Value {
781 if params.is_null() { json!({}) } else { params }
782}
783
784fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
785 serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
786}
787
788fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
789 json!({
790 "jsonrpc": "2.0",
791 "id": id.unwrap_or(Value::Null),
792 "result": result
793 })
794}
795
796fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
797 json!({
798 "jsonrpc": "2.0",
799 "id": id.unwrap_or(Value::Null),
800 "error": {
801 "code": err.code,
802 "message": err.message,
803 "data": err.data
804 }
805 })
806}
807
808impl JsonRpcError {
809 fn parse_error(message: impl Into<String>) -> Self {
810 Self {
811 code: -32700,
812 message: message.into(),
813 data: None,
814 }
815 }
816
817 fn invalid_request(message: impl Into<String>) -> Self {
818 Self {
819 code: -32600,
820 message: message.into(),
821 data: None,
822 }
823 }
824
825 fn method_not_found(method: &str) -> Self {
826 Self {
827 code: -32601,
828 message: format!("unsupported method: {method}"),
829 data: None,
830 }
831 }
832
833 fn invalid_params(message: impl Into<String>) -> Self {
834 Self {
835 code: -32602,
836 message: message.into(),
837 data: None,
838 }
839 }
840
841 fn internal(message: impl Into<String>) -> Self {
842 Self {
843 code: -32603,
844 message: message.into(),
845 data: None,
846 }
847 }
848}
849
850async fn handle_thread_request(
851 state: &AppState,
852 req: ThreadRequest,
853) -> std::result::Result<ThreadResponse, JsonRpcError> {
854 let mut runtime = state.runtime.write().await;
855 runtime
856 .handle_thread(req)
857 .await
858 .map_err(|err| JsonRpcError::internal(err.to_string()))
859}
860
861async fn handle_prompt_request(
862 state: &AppState,
863 req: PromptRequest,
864) -> std::result::Result<PromptResponse, JsonRpcError> {
865 let mut runtime = state.runtime.write().await;
866 runtime
867 .handle_prompt(req, &CliRuntimeOverrides::default())
868 .await
869 .map_err(|err| JsonRpcError::internal(err.to_string()))
870}
871
872async fn handle_stdio_thread_message<W: AsyncWrite + Unpin>(
873 state: &AppState,
874 writer: &mut W,
875 parsed: ThreadMessageParams,
876) -> std::result::Result<Value, JsonRpcError> {
877 let hint = {
878 let hints = state.stdio_thread_hints.lock().await;
879 hints.get(&parsed.thread_id).cloned()
880 };
881 let bridge = acquire_stdio_bridge(state).await?;
882 let mut bridge = bridge.lock().await;
887 let runtime_thread_id = bridge
888 .ensure_runtime_thread(&parsed.thread_id, hint)
889 .await
890 .map_err(|err| JsonRpcError::internal(err.to_string()))?;
891 let mut result = bridge
892 .message_thread(
893 &runtime_thread_id,
894 &parsed.input,
895 writer,
896 Some((state.in_flight_turns.clone(), parsed.thread_id.clone())),
897 )
898 .await
899 .map_err(|err| JsonRpcError::internal(err.to_string()))?;
900 if let Some(object) = result.as_object_mut() {
901 object.insert("thread_id".to_string(), Value::String(parsed.thread_id));
902 }
903 Ok(result)
904}
905
906async fn record_stdio_thread_hint(state: &AppState, response: &ThreadResponse) {
907 let mut hints = state.stdio_thread_hints.lock().await;
908 hints.insert(
909 response.thread_id.clone(),
910 RuntimeThreadHint {
911 model: response.model.clone(),
912 workspace: response.cwd.clone(),
913 },
914 );
915}
916
917async fn acquire_stdio_bridge(
923 state: &AppState,
924) -> std::result::Result<SharedRuntimeBridge, JsonRpcError> {
925 if let Some(bridge) = state.stdio_bridge.lock().await.as_ref() {
926 return Ok(bridge.clone());
927 }
928 let bridge = Arc::new(Mutex::new(
929 RuntimeBridge::start(state.config_path.as_deref())
930 .await
931 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
932 ));
933 let mut slot = state.stdio_bridge.lock().await;
934 Ok(slot.get_or_insert_with(|| bridge.clone()).clone())
937}
938
939async fn interrupt_stdio_turn(
945 state: &AppState,
946 thread_id: &str,
947) -> std::result::Result<bool, JsonRpcError> {
948 let Some(turn) = state.in_flight_turns.lock().await.get(thread_id).cloned() else {
949 return Ok(false);
950 };
951 let mut request = codewhale_release::platform_http_client_builder()
952 .timeout(Duration::from_secs(10))
953 .build()
954 .map_err(|err| JsonRpcError::internal(err.to_string()))?
955 .post(format!(
956 "{}/v1/threads/{}/turns/{}/interrupt",
957 turn.base_url, turn.runtime_thread_id, turn.turn_id
958 ));
959 if let Some(token) = turn.auth_token.as_deref() {
960 request = request.bearer_auth(token);
961 }
962 request
963 .send()
964 .await
965 .and_then(reqwest::Response::error_for_status)
966 .map_err(|err| JsonRpcError::internal(format!("interrupt failed: {err}")))?;
967 Ok(true)
968}
969
970async fn invalidate_stdio_bridge(state: &AppState) {
975 let mut bridge = state.stdio_bridge.lock().await;
976 *bridge = None;
977}
978
979impl RuntimeBridge {
980 async fn start(config_path: Option<&Path>) -> Result<Self> {
981 install_rustls_crypto_provider();
982 let port = reserve_runtime_port()?;
983 let auth_token = format!("cwrt_{}", Uuid::new_v4().simple());
984 let child = Self::runtime_command(config_path, port, &auth_token)?
985 .spawn()
986 .context("failed to start runtime API bridge")?;
987 let mut bridge = Self {
988 base_url: format!("http://127.0.0.1:{port}"),
989 client: codewhale_release::platform_http_client_builder()
990 .build()
991 .context("failed to build runtime API client")?,
992 auth_token: Some(auth_token),
993 child: Some(child),
994 thread_map: HashMap::new(),
995 last_seq_by_thread: HashMap::new(),
996 };
997 bridge.wait_until_ready().await?;
998 Ok(bridge)
999 }
1000
1001 fn runtime_command(config_path: Option<&Path>, port: u16, auth_token: &str) -> Result<Command> {
1002 let current_exe = std::env::current_exe().ok();
1003 let mut command = if let Some(path) = current_exe {
1004 Command::new(path)
1005 } else {
1006 Command::new("codewhale")
1007 };
1008 command
1013 .arg("app-server")
1014 .arg("--http")
1015 .arg("--host")
1016 .arg("127.0.0.1")
1017 .arg("--port")
1018 .arg(port.to_string())
1019 .env("CODEWHALE_RUNTIME_TOKEN", auth_token)
1020 .env("DEEPSEEK_RUNTIME_TOKEN", auth_token)
1021 .stdin(Stdio::null())
1022 .stdout(Stdio::null())
1023 .stderr(Stdio::null());
1024 if let Some(config_path) = config_path {
1025 command.arg("--config").arg(config_path);
1026 }
1027 Ok(command)
1028 }
1029
1030 async fn wait_until_ready(&mut self) -> Result<()> {
1031 let deadline = Instant::now() + Duration::from_secs(15);
1032 loop {
1033 if let Some(child) = self.child.as_mut()
1034 && let Some(status) = child.try_wait()?
1035 {
1036 return Err(anyhow!(
1037 "runtime API bridge exited before becoming ready (status {status})"
1038 ));
1039 }
1040
1041 match self
1042 .client
1043 .get(format!("{}/health", self.base_url))
1044 .send()
1045 .await
1046 {
1047 Ok(response) if response.status().is_success() => return Ok(()),
1048 _ if Instant::now() >= deadline => {
1049 bail!(
1050 "timed out waiting for runtime API bridge at {}/health",
1051 self.base_url
1052 )
1053 }
1054 _ => tokio::time::sleep(Duration::from_millis(50)).await,
1055 }
1056 }
1057 }
1058
1059 fn authed(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
1060 match self.auth_token.as_deref() {
1061 Some(token) => builder.bearer_auth(token),
1062 None => builder,
1063 }
1064 }
1065
1066 async fn request_json(&self, builder: reqwest::RequestBuilder) -> Result<Value> {
1067 let response = builder.send().await?;
1068 let status = response.status();
1069 let body = response.text().await?;
1070 if !status.is_success() {
1071 let detail = body.trim();
1072 if detail.is_empty() {
1073 bail!("runtime API returned {status}");
1074 }
1075 bail!("runtime API returned {status}: {detail}");
1076 }
1077 serde_json::from_str(&body).with_context(|| format!("invalid runtime API json: {body}"))
1078 }
1079
1080 async fn ensure_runtime_thread(
1081 &mut self,
1082 stdio_thread_id: &str,
1083 hint: Option<RuntimeThreadHint>,
1084 ) -> Result<String> {
1085 if let Some(runtime_thread_id) = self.thread_map.get(stdio_thread_id) {
1086 return Ok(runtime_thread_id.clone());
1087 }
1088 let hint = hint.unwrap_or_default();
1089 let runtime_thread_id = self
1090 .create_runtime_thread(hint.model, hint.workspace)
1091 .await?;
1092 self.thread_map
1093 .insert(stdio_thread_id.to_string(), runtime_thread_id.clone());
1094 Ok(runtime_thread_id)
1095 }
1096
1097 async fn create_runtime_thread(
1098 &mut self,
1099 model: Option<String>,
1100 workspace: Option<PathBuf>,
1101 ) -> Result<String> {
1102 let record = self
1103 .request_json(
1104 self.authed(self.client.post(format!("{}/v1/threads", self.base_url)))
1105 .json(&json!({
1106 "model": model,
1107 "workspace": workspace,
1108 "mode": "agent",
1109 "archived": false,
1110 })),
1111 )
1112 .await?;
1113 let thread_id = extract_runtime_thread_id(&record)?.to_string();
1114 self.last_seq_by_thread
1115 .entry(thread_id.clone())
1116 .or_insert(0);
1117 Ok(thread_id)
1118 }
1119
1120 async fn message_thread<W: AsyncWrite + Unpin>(
1126 &mut self,
1127 thread_id: &str,
1128 input: &str,
1129 writer: &mut W,
1130 registration: Option<(TurnRegistry, String)>,
1131 ) -> Result<Value> {
1132 let turn = self
1133 .request_json(
1134 self.authed(
1135 self.client
1136 .post(format!("{}/v1/threads/{thread_id}/turns", self.base_url)),
1137 )
1138 .json(&json!({ "prompt": input })),
1139 )
1140 .await?;
1141 let turn_id = turn
1142 .pointer("/turn/id")
1143 .and_then(Value::as_str)
1144 .ok_or_else(|| anyhow!("runtime API turn response missing turn.id"))?
1145 .to_string();
1146 let response_id = format!("{thread_id}:{turn_id}");
1147
1148 emit_stdio_event(
1149 writer,
1150 json!({
1151 "type": "response_start",
1152 "response_id": response_id,
1153 }),
1154 )
1155 .await?;
1156
1157 if let Some((registry, key)) = registration.as_ref() {
1161 registry.lock().await.insert(
1162 key.clone(),
1163 InFlightTurn {
1164 base_url: self.base_url.clone(),
1165 auth_token: self.auth_token.clone(),
1166 runtime_thread_id: thread_id.to_string(),
1167 turn_id: turn_id.clone(),
1168 },
1169 );
1170 }
1171
1172 let since_seq = self.last_seq_by_thread.get(thread_id).copied().unwrap_or(0);
1173 let stream_result = self
1174 .stream_turn_events(thread_id, &turn_id, &response_id, writer, since_seq)
1175 .await;
1176
1177 if let Some((registry, key)) = registration.as_ref() {
1178 registry.lock().await.remove(key);
1179 }
1180
1181 let _ = emit_stdio_event(
1182 writer,
1183 json!({
1184 "type": "response_end",
1185 "response_id": response_id,
1186 }),
1187 )
1188 .await;
1189
1190 let (last_seq, status, error) = stream_result?;
1191 self.last_seq_by_thread
1192 .insert(thread_id.to_string(), last_seq);
1193
1194 match status {
1195 TurnTerminalStatus::Completed => Ok(json!({
1196 "thread_id": thread_id,
1197 "status": "accepted",
1198 "thread": Value::Null,
1199 "threads": [],
1200 "model": Value::Null,
1201 "model_provider": Value::Null,
1202 "cwd": Value::Null,
1203 "approval_policy": Value::Null,
1204 "sandbox": Value::Null,
1205 "events": [],
1206 "data": { "turn_id": turn_id },
1207 })),
1208 TurnTerminalStatus::Failed => Err(anyhow!(
1209 "{}",
1210 error.unwrap_or_else(|| "turn failed".to_string())
1211 )),
1212 TurnTerminalStatus::Interrupted => Err(anyhow!(
1213 "{}",
1214 error.unwrap_or_else(|| "turn interrupted".to_string())
1215 )),
1216 TurnTerminalStatus::Canceled => Err(anyhow!(
1217 "{}",
1218 error.unwrap_or_else(|| "turn canceled".to_string())
1219 )),
1220 }
1221 }
1222
1223 async fn stream_turn_events<W: AsyncWrite + Unpin>(
1224 &self,
1225 thread_id: &str,
1226 turn_id: &str,
1227 response_id: &str,
1228 writer: &mut W,
1229 since_seq: u64,
1230 ) -> Result<(u64, TurnTerminalStatus, Option<String>)> {
1231 let mut response = self
1232 .authed(self.client.get(format!(
1233 "{}/v1/threads/{thread_id}/events?since_seq={since_seq}",
1234 self.base_url
1235 )))
1236 .send()
1237 .await?
1238 .error_for_status()?;
1239
1240 let mut buffer = Vec::new();
1241 let mut last_seq = since_seq;
1242
1243 while let Some(chunk) = response.chunk().await? {
1244 buffer.extend_from_slice(&chunk);
1245 if buffer.len() > MAX_SSE_FRAME_BYTES {
1246 bail!(
1247 "runtime SSE frame exceeded {MAX_SSE_FRAME_BYTES} bytes without a frame delimiter"
1248 );
1249 }
1250 while let Some(frame_bytes) = take_sse_frame(&mut buffer) {
1251 let Some((event_name, frame_data)) = parse_sse_frame(&frame_bytes) else {
1252 continue;
1253 };
1254 let envelope: Value = serde_json::from_str(&frame_data)
1255 .with_context(|| format!("invalid SSE json for {event_name}: {frame_data}"))?;
1256 if let Some(seq) = envelope.get("seq").and_then(Value::as_u64) {
1257 last_seq = last_seq.max(seq);
1258 }
1259 if envelope.get("turn_id").and_then(Value::as_str) != Some(turn_id) {
1260 continue;
1261 }
1262 let payload = envelope.get("payload").cloned().unwrap_or(Value::Null);
1263 match event_name.as_str() {
1264 "item.delta" => {
1265 let kind = payload
1266 .get("kind")
1267 .and_then(Value::as_str)
1268 .unwrap_or_default();
1269 if kind == "agent_message"
1270 && let Some(delta) = payload.get("delta").and_then(Value::as_str)
1271 && !delta.is_empty()
1272 {
1273 emit_stdio_event(
1274 writer,
1275 json!({
1276 "type": "response_delta",
1277 "response_id": response_id,
1278 "delta": delta,
1279 }),
1280 )
1281 .await?;
1282 }
1283 }
1284 "turn.completed" => {
1285 let status = turn_terminal_status(&payload);
1286 let error = payload
1287 .pointer("/turn/error")
1288 .and_then(Value::as_str)
1289 .map(str::to_string);
1290 return Ok((last_seq, status, error));
1291 }
1292 _ => {}
1293 }
1294 }
1295 }
1296
1297 bail!("runtime event stream ended before turn.completed")
1298 }
1299
1300 #[cfg(test)]
1301 fn from_base_url_for_test(base_url: String) -> Self {
1302 install_rustls_crypto_provider();
1303 Self {
1304 base_url,
1305 client: codewhale_release::platform_http_client_builder()
1306 .timeout(Duration::from_secs(5))
1307 .build()
1308 .expect("build reqwest test client"),
1309 auth_token: None,
1310 child: None,
1311 thread_map: HashMap::new(),
1312 last_seq_by_thread: HashMap::new(),
1313 }
1314 }
1315}
1316
1317impl RuntimeBridge {
1318 fn shutdown_child(&mut self) {
1321 if let Some(mut child) = self.child.take() {
1322 let _ = child.kill();
1323 std::thread::spawn(move || {
1324 let _ = child.wait();
1325 });
1326 }
1327 }
1328}
1329
1330impl Drop for RuntimeBridge {
1331 fn drop(&mut self) {
1332 self.shutdown_child();
1333 }
1334}
1335
1336fn reserve_runtime_port() -> Result<u16> {
1337 let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
1338 Ok(listener.local_addr()?.port())
1339}
1340
1341fn install_rustls_crypto_provider() {
1342 let _ = rustls::crypto::ring::default_provider().install_default();
1343}
1344
1345fn extract_runtime_thread_id(record: &Value) -> Result<&str> {
1346 record
1347 .get("id")
1348 .and_then(Value::as_str)
1349 .ok_or_else(|| anyhow!("runtime API thread response missing id"))
1350}
1351
1352fn turn_terminal_status(payload: &Value) -> TurnTerminalStatus {
1353 match payload
1354 .pointer("/turn/status")
1355 .and_then(Value::as_str)
1356 .unwrap_or("completed")
1357 .to_ascii_lowercase()
1358 .as_str()
1359 {
1360 "failed" => TurnTerminalStatus::Failed,
1361 "interrupted" => TurnTerminalStatus::Interrupted,
1362 "canceled" | "cancelled" => TurnTerminalStatus::Canceled,
1363 _ => TurnTerminalStatus::Completed,
1364 }
1365}
1366
1367async fn emit_stdio_event<W: AsyncWrite + Unpin>(writer: &mut W, event: Value) -> Result<()> {
1368 writer.write_all(&serde_json::to_vec(&event)?).await?;
1369 writer.write_all(b"\n").await?;
1370 writer.flush().await?;
1371 Ok(())
1372}
1373
1374fn take_sse_frame(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
1375 if let Some(pos) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
1376 return Some(buffer.drain(..pos + 4).collect());
1377 }
1378 buffer
1379 .windows(2)
1380 .position(|window| window == b"\n\n")
1381 .map(|pos| buffer.drain(..pos + 2).collect())
1382}
1383
1384fn parse_sse_frame(frame_bytes: &[u8]) -> Option<(String, String)> {
1385 let text = String::from_utf8(frame_bytes.to_vec()).ok()?;
1386 let mut event_name = None;
1387 let mut data_lines = Vec::new();
1388 for raw_line in text.lines() {
1389 let line = raw_line.trim_end_matches('\r');
1390 if let Some(value) = line.strip_prefix("event:") {
1391 event_name = Some(value.trim().to_string());
1392 } else if let Some(value) = line.strip_prefix("data:") {
1393 data_lines.push(value.trim_start().to_string());
1394 }
1395 }
1396 match (event_name, data_lines.is_empty()) {
1397 (Some(event), false) => Some((event, data_lines.join("\n"))),
1398 _ => None,
1399 }
1400}
1401
1402#[cfg(test)]
1403async fn dispatch_stdio_request(
1404 state: &AppState,
1405 method: &str,
1406 params: Value,
1407) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
1408 let mut sink = tokio::io::sink();
1409 dispatch_stdio_request_with_writer(state, &mut sink, method, params).await
1410}
1411
1412async fn dispatch_stdio_app_request(
1413 state: &AppState,
1414 request: AppRequest,
1415) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
1416 let response = Box::pin(process_app_request(state, request, AppTransport::Stdio)).await;
1417 Ok(StdioDispatchResult {
1418 result: serde_json::to_value(response)
1419 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1420 should_exit: false,
1421 })
1422}
1423
1424async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
1425 state: &AppState,
1426 writer: &mut W,
1427 method: &str,
1428 params: Value,
1429) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
1430 let outcome = match method {
1431 "healthz" | "app/healthz" => StdioDispatchResult {
1432 result: json!({
1433 "status": "ok",
1434 "service": legacy_deepseek_compat::SERVICE_NAME,
1435 "transport": "stdio"
1436 }),
1437 should_exit: false,
1438 },
1439 "capabilities" => StdioDispatchResult {
1440 result: json!({
1441 "transport": "stdio",
1442 "families": ["thread/*", "app/*", "prompt/*"],
1443 "methods": [
1444 "healthz",
1445 "thread/capabilities",
1446 "thread/request",
1447 "thread/create",
1448 "thread/start",
1449 "thread/resume",
1450 "thread/fork",
1451 "thread/list",
1452 "thread/read",
1453 "thread/set_name",
1454 "thread/goal/set",
1455 "thread/goal/get",
1456 "thread/goal/clear",
1457 "thread/archive",
1458 "thread/unarchive",
1459 "thread/message",
1460 "thread/interrupt",
1461 "app/capabilities",
1462 "app/request",
1463 "app/config/get",
1464 "app/config/set",
1465 "app/config/unset",
1466 "app/config/list",
1467 "app/config/reload",
1468 "app/models",
1469 "app/thread_loaded_list",
1470 "prompt/capabilities",
1471 "prompt/request",
1472 "prompt/run",
1473 "shutdown"
1474 ]
1475 }),
1476 should_exit: false,
1477 },
1478 "thread/capabilities" => StdioDispatchResult {
1479 result: json!({
1480 "methods": [
1481 "thread/request",
1482 "thread/create",
1483 "thread/start",
1484 "thread/resume",
1485 "thread/fork",
1486 "thread/list",
1487 "thread/read",
1488 "thread/set_name",
1489 "thread/goal/set",
1490 "thread/goal/get",
1491 "thread/goal/clear",
1492 "thread/archive",
1493 "thread/unarchive",
1494 "thread/message",
1495 "thread/interrupt"
1496 ]
1497 }),
1498 should_exit: false,
1499 },
1500 "thread/request" => {
1501 let request: ThreadRequest = parse_params(params)?;
1502 if let ThreadRequest::Message { thread_id, input } = request {
1503 let response = handle_stdio_thread_message(
1504 state,
1505 writer,
1506 ThreadMessageParams { thread_id, input },
1507 )
1508 .await?;
1509 return Ok(StdioDispatchResult {
1510 result: response,
1511 should_exit: false,
1512 });
1513 }
1514 let should_record_hint = matches!(
1515 &request,
1516 ThreadRequest::Create { .. }
1517 | ThreadRequest::Start(_)
1518 | ThreadRequest::Resume(_)
1519 | ThreadRequest::Fork(_)
1520 );
1521 let response = handle_thread_request(state, request).await?;
1522 if should_record_hint {
1523 record_stdio_thread_hint(state, &response).await;
1524 }
1525 StdioDispatchResult {
1526 result: serde_json::to_value(response)
1527 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1528 should_exit: false,
1529 }
1530 }
1531 "thread/create" => {
1532 #[derive(Debug, Deserialize)]
1533 struct CreateParams {
1534 #[serde(default)]
1535 metadata: Value,
1536 }
1537 let parsed: CreateParams = parse_params(params_or_object(params))?;
1538 let response = handle_thread_request(
1539 state,
1540 ThreadRequest::Create {
1541 metadata: parsed.metadata,
1542 },
1543 )
1544 .await?;
1545 record_stdio_thread_hint(state, &response).await;
1546 StdioDispatchResult {
1547 result: serde_json::to_value(response)
1548 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1549 should_exit: false,
1550 }
1551 }
1552 "thread/start" => {
1553 let request = ThreadRequest::Start(parse_params(params_or_object(params))?);
1554 let response = handle_thread_request(state, request).await?;
1555 record_stdio_thread_hint(state, &response).await;
1556 StdioDispatchResult {
1557 result: serde_json::to_value(response)
1558 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1559 should_exit: false,
1560 }
1561 }
1562 "thread/resume" => {
1563 let request = ThreadRequest::Resume(parse_params(params_or_object(params))?);
1564 let response = handle_thread_request(state, request).await?;
1565 record_stdio_thread_hint(state, &response).await;
1566 StdioDispatchResult {
1567 result: serde_json::to_value(response)
1568 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1569 should_exit: false,
1570 }
1571 }
1572 "thread/fork" => {
1573 let request = ThreadRequest::Fork(parse_params(params_or_object(params))?);
1574 let response = handle_thread_request(state, request).await?;
1575 record_stdio_thread_hint(state, &response).await;
1576 StdioDispatchResult {
1577 result: serde_json::to_value(response)
1578 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1579 should_exit: false,
1580 }
1581 }
1582 "thread/list" => {
1583 let request = ThreadRequest::List(parse_params(params_or_object(params))?);
1584 let response = handle_thread_request(state, request).await?;
1585 StdioDispatchResult {
1586 result: serde_json::to_value(response)
1587 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1588 should_exit: false,
1589 }
1590 }
1591 "thread/read" => {
1592 let request = ThreadRequest::Read(parse_params(params_or_object(params))?);
1593 let response = handle_thread_request(state, request).await?;
1594 StdioDispatchResult {
1595 result: serde_json::to_value(response)
1596 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1597 should_exit: false,
1598 }
1599 }
1600 "thread/set_name" | "thread/set-name" => {
1601 let request = ThreadRequest::SetName(parse_params(params_or_object(params))?);
1602 let response = handle_thread_request(state, request).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/goal/set" | "thread/goal_set" | "thread/goal-set" => {
1610 let request = ThreadRequest::GoalSet(parse_params::<ThreadGoalSetParams>(
1611 params_or_object(params),
1612 )?);
1613 let response = handle_thread_request(state, request).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/goal/get" | "thread/goal_get" | "thread/goal-get" => {
1621 let request = ThreadRequest::GoalGet(parse_params::<ThreadGoalGetParams>(
1622 params_or_object(params),
1623 )?);
1624 let response = handle_thread_request(state, request).await?;
1625 StdioDispatchResult {
1626 result: serde_json::to_value(response)
1627 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1628 should_exit: false,
1629 }
1630 }
1631 "thread/goal/clear" | "thread/goal_clear" | "thread/goal-clear" => {
1632 let request = ThreadRequest::GoalClear(parse_params::<ThreadGoalClearParams>(
1633 params_or_object(params),
1634 )?);
1635 let response = handle_thread_request(state, request).await?;
1636 StdioDispatchResult {
1637 result: serde_json::to_value(response)
1638 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1639 should_exit: false,
1640 }
1641 }
1642 "thread/archive" => {
1643 let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
1644 let response = handle_thread_request(
1645 state,
1646 ThreadRequest::Archive {
1647 thread_id: parsed.thread_id,
1648 },
1649 )
1650 .await?;
1651 StdioDispatchResult {
1652 result: serde_json::to_value(response)
1653 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1654 should_exit: false,
1655 }
1656 }
1657 "thread/unarchive" => {
1658 let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
1659 let response = handle_thread_request(
1660 state,
1661 ThreadRequest::Unarchive {
1662 thread_id: parsed.thread_id,
1663 },
1664 )
1665 .await?;
1666 StdioDispatchResult {
1667 result: serde_json::to_value(response)
1668 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1669 should_exit: false,
1670 }
1671 }
1672 "thread/message" => {
1673 let parsed: ThreadMessageParams = parse_params(params_or_object(params))?;
1674 let response = handle_stdio_thread_message(state, writer, parsed).await?;
1675 StdioDispatchResult {
1676 result: response,
1677 should_exit: false,
1678 }
1679 }
1680 "app/capabilities" => dispatch_stdio_app_request(state, AppRequest::Capabilities).await?,
1681 "app/request" => {
1682 let request: AppRequest = parse_params(params)?;
1683 dispatch_stdio_app_request(state, request).await?
1684 }
1685 "app/config/get" => {
1686 let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
1687 dispatch_stdio_app_request(state, AppRequest::ConfigGet { key: parsed.key }).await?
1688 }
1689 "app/config/set" => {
1690 let parsed: ConfigSetParams = parse_params(params_or_object(params))?;
1691 dispatch_stdio_app_request(
1692 state,
1693 AppRequest::ConfigSet {
1694 key: parsed.key,
1695 value: parsed.value,
1696 },
1697 )
1698 .await?
1699 }
1700 "app/config/unset" => {
1701 let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
1702 dispatch_stdio_app_request(state, AppRequest::ConfigUnset { key: parsed.key }).await?
1703 }
1704 "app/config/list" => dispatch_stdio_app_request(state, AppRequest::ConfigList).await?,
1705 "app/config/reload" => dispatch_stdio_app_request(state, AppRequest::ConfigReload).await?,
1706 "app/models" => dispatch_stdio_app_request(state, AppRequest::Models).await?,
1707 "app/thread_loaded_list" | "app/thread-loaded-list" => {
1708 dispatch_stdio_app_request(state, AppRequest::ThreadLoadedList).await?
1709 }
1710 "prompt/capabilities" => StdioDispatchResult {
1711 result: json!({
1712 "methods": ["prompt/request", "prompt/run"]
1713 }),
1714 should_exit: false,
1715 },
1716 "prompt/request" | "prompt/run" => {
1717 let request: PromptRequest = parse_params(params)?;
1718 let response = handle_prompt_request(state, request).await?;
1719 StdioDispatchResult {
1720 result: serde_json::to_value(response)
1721 .map_err(|err| JsonRpcError::internal(err.to_string()))?,
1722 should_exit: false,
1723 }
1724 }
1725 "thread/interrupt" => {
1726 let parsed: ThreadInterruptParams = parse_params(params_or_object(params))?;
1727 let interrupted = interrupt_stdio_turn(state, &parsed.thread_id).await?;
1728 StdioDispatchResult {
1729 result: json!({
1730 "thread_id": parsed.thread_id,
1731 "interrupted": interrupted,
1732 }),
1733 should_exit: false,
1734 }
1735 }
1736 "shutdown" => {
1737 let live: Vec<String> = state.in_flight_turns.lock().await.keys().cloned().collect();
1742 for thread_id in live {
1743 let _ = interrupt_stdio_turn(state, &thread_id).await;
1744 }
1745 if let Some(bridge) = state.stdio_bridge.lock().await.take() {
1746 bridge.lock().await.shutdown_child();
1747 }
1748 StdioDispatchResult {
1749 result: json!({"ok": true, "status": "stopped"}),
1750 should_exit: true,
1751 }
1752 }
1753 _ => return Err(JsonRpcError::method_not_found(method)),
1754 };
1755 Ok(outcome)
1756}
1757
1758async fn process_app_request(
1759 state: &AppState,
1760 req: AppRequest,
1761 _transport: AppTransport,
1762) -> AppResponse {
1763 match req {
1764 AppRequest::Capabilities => AppResponse {
1765 ok: true,
1766 data: json!({
1767 "routes": ["/thread", "/app", "/prompt", "/tool", "/jobs", "/mcp/startup"],
1768 "config": ["get", "set", "unset", "list", "reload"],
1769 "events": ["response_start", "response_delta", "response_end", "tool_call_start", "tool_call_result", "mcp_startup_update", "mcp_startup_complete"],
1770 "transport": "stdio+http",
1771 "config_path": state.config_path.as_ref().map(|p| p.display().to_string()),
1772 }),
1773 events: Vec::new(),
1774 },
1775 AppRequest::ConfigGet { key } => {
1776 let cfg = state.config.read().await;
1777 let value = cfg.get_display_value(&key);
1778 AppResponse {
1779 ok: true,
1780 data: json!({ "key": key, "value": value }),
1781 events: Vec::new(),
1782 }
1783 }
1784 AppRequest::ConfigSet { key, value } => {
1785 let (result, snapshot) = {
1786 let mut cfg = state.config.write().await;
1787 let result = cfg.set_value(&key, &value);
1788 (result, cfg.clone())
1789 };
1790 let ok = result.is_ok();
1791 let message = result.err().map(|e| e.to_string());
1792 if ok {
1800 apply_config_update(state, snapshot, None, true).await;
1801 }
1802 AppResponse {
1803 ok,
1804 data: json!({ "key": key, "value": value, "error": message }),
1805 events: Vec::new(),
1806 }
1807 }
1808 AppRequest::ConfigUnset { key } => {
1809 let (result, snapshot) = {
1810 let mut cfg = state.config.write().await;
1811 let result = cfg.unset_value(&key);
1812 (result, cfg.clone())
1813 };
1814 let ok = result.is_ok();
1815 let message = result.err().map(|e| e.to_string());
1816 if ok {
1819 apply_config_update(state, snapshot, None, true).await;
1820 }
1821 AppResponse {
1822 ok,
1823 data: json!({ "key": key, "error": message }),
1824 events: Vec::new(),
1825 }
1826 }
1827 AppRequest::ConfigList => {
1828 let cfg = state.config.read().await;
1829 AppResponse {
1830 ok: true,
1831 data: json!({ "values": cfg.list_values() }),
1832 events: Vec::new(),
1833 }
1834 }
1835 AppRequest::ConfigReload => {
1836 let store = match ConfigStore::load(state.config_path.clone()) {
1846 Ok(store) => store,
1847 Err(e) => {
1848 return AppResponse {
1849 ok: false,
1850 data: json!({ "error": format!("failed to load config: {e}") }),
1851 events: Vec::new(),
1852 };
1853 }
1854 };
1855 let new_config = store.config.clone();
1856 let new_exec_policy = store.exec_policy_engine();
1857
1858 apply_config_update(state, new_config, Some(new_exec_policy), false).await;
1862
1863 AppResponse {
1864 ok: true,
1865 data: json!({ "reloaded": true }),
1866 events: Vec::new(),
1867 }
1868 }
1869 AppRequest::Models => AppResponse {
1870 ok: true,
1871 data: json!({ "models": state.registry.list() }),
1872 events: Vec::new(),
1873 },
1874 AppRequest::ThreadLoadedList => {
1875 let mut runtime = state.runtime.write().await;
1876 let response = runtime
1877 .handle_thread(codewhale_protocol::ThreadRequest::List(
1878 codewhale_protocol::ThreadListParams {
1879 include_archived: false,
1880 limit: Some(50),
1881 },
1882 ))
1883 .await;
1884 match response {
1885 Ok(thread_resp) => AppResponse {
1886 ok: true,
1887 data: json!({ "threads": thread_resp.threads }),
1888 events: thread_resp.events,
1889 },
1890 Err(err) => AppResponse {
1891 ok: false,
1892 data: json!({ "error": err.to_string() }),
1893 events: Vec::new(),
1894 },
1895 }
1896 }
1897 AppRequest::SubmitUserInput {
1898 request_id,
1899 answers,
1900 } => {
1901 let mut pending = state.pending_user_input.lock().await;
1907 if pending.contains_key(&request_id) {
1908 return AppResponse {
1909 ok: false,
1910 data: json!({
1911 "error": "request_id already resolved",
1912 "request_id": request_id,
1913 }),
1914 events: Vec::new(),
1915 };
1916 }
1917 pending.insert(request_id.clone(), answers);
1918 AppResponse {
1919 ok: true,
1920 data: json!({ "request_id": request_id, "resolved": true }),
1921 events: Vec::new(),
1922 }
1923 }
1924 }
1925}
1926
1927async fn apply_config_update(
1939 state: &AppState,
1940 snapshot: codewhale_config::ConfigToml,
1941 exec_policy: Option<codewhale_execpolicy::ExecPolicyEngine>,
1942 persist: bool,
1943) {
1944 if persist && let Err(e) = persist_config(state, snapshot.clone()).await {
1945 tracing::error!("Failed to persist config update: {e}");
1946 }
1947 {
1948 let mut cfg = state.config.write().await;
1949 *cfg = snapshot.clone();
1950 }
1951 {
1956 let mut runtime = state.runtime.write().await;
1957 match exec_policy {
1958 Some(policy) => runtime.reload_config_and_policy(snapshot, policy),
1959 None => runtime.update_config(snapshot),
1960 }
1961 }
1962 invalidate_stdio_bridge(state).await;
1963}
1964
1965async fn persist_config(state: &AppState, config: codewhale_config::ConfigToml) -> Result<()> {
1966 if state.config_path.is_none() {
1967 return Ok(());
1968 }
1969 let mut store = ConfigStore::load(state.config_path.clone())?;
1970 store.config = config;
1971 store.save()
1972}
1973
1974#[cfg(test)]
1975mod tests {
1976 use super::*;
1977 use axum::body::{Body, to_bytes};
1978 use axum::extract::{Path as AxumPath, Query};
1979 use axum::http::header;
1980 use codewhale_protocol::AppRequest;
1981 use std::collections::HashMap;
1982 use std::fs;
1983 use tokio::io::AsyncReadExt;
1984 use tower::ServiceExt;
1985
1986 fn app_with_config(auth_token: Option<&str>) -> (Router, tempfile::TempDir) {
1987 let tmp = tempfile::tempdir().expect("tempdir");
1988 let config_path = tmp.path().join("config.toml");
1989 fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
1990 let state = build_state(
1991 Some(config_path),
1992 auth_token.map(std::string::ToString::to_string),
1993 )
1994 .expect("state");
1995 (app_router(state, &[]), tmp)
1996 }
1997
1998 #[test]
1999 fn build_state_keeps_resolved_explicit_config_path() {
2000 let tmp = tempfile::tempdir().expect("tempdir");
2001 let config_dir = tmp.path().join("config-dir");
2002 fs::create_dir_all(&config_dir).expect("config dir");
2003 let config_path = config_dir.join("config.toml");
2004 fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
2005
2006 let state = build_state(Some(config_path.clone()), None).expect("state");
2007
2008 assert_eq!(
2009 state.config_path.as_deref(),
2010 Some(
2011 config_path
2012 .canonicalize()
2013 .expect("canonical config")
2014 .as_path()
2015 )
2016 );
2017 }
2018
2019 async fn response_body_json(response: Response) -> Value {
2020 let bytes = to_bytes(response.into_body(), usize::MAX)
2021 .await
2022 .expect("body bytes");
2023 serde_json::from_slice(&bytes).expect("json response")
2024 }
2025
2026 #[tokio::test]
2027 async fn http_app_routes_require_bearer_token_when_auth_enabled() {
2028 let (app, _tmp) = app_with_config(Some("test-token"));
2029 let response = app
2030 .oneshot(
2031 Request::builder()
2032 .method(Method::POST)
2033 .uri("/app")
2034 .header(header::CONTENT_TYPE, "application/json")
2035 .body(Body::from(
2036 serde_json::to_vec(&AppRequest::ConfigGet {
2037 key: "api_key".to_string(),
2038 })
2039 .expect("request json"),
2040 ))
2041 .expect("request"),
2042 )
2043 .await
2044 .expect("response");
2045
2046 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
2047 }
2048
2049 #[tokio::test]
2050 async fn http_config_get_redacts_sensitive_values_after_auth() {
2051 let (app, _tmp) = app_with_config(Some("test-token"));
2052 let response = app
2053 .oneshot(
2054 Request::builder()
2055 .method(Method::POST)
2056 .uri("/app")
2057 .header(header::AUTHORIZATION, "Bearer test-token")
2058 .header(header::CONTENT_TYPE, "application/json")
2059 .body(Body::from(
2060 serde_json::to_vec(&AppRequest::ConfigGet {
2061 key: "api_key".to_string(),
2062 })
2063 .expect("request json"),
2064 ))
2065 .expect("request"),
2066 )
2067 .await
2068 .expect("response");
2069
2070 assert_eq!(response.status(), StatusCode::OK);
2071 let body = response_body_json(response).await;
2072 assert_eq!(body["data"]["value"], "sk-d***cret");
2073 }
2074
2075 #[tokio::test]
2076 async fn cors_does_not_allow_arbitrary_origins() {
2077 let (app, _tmp) = app_with_config(Some("test-token"));
2078 let response = app
2079 .oneshot(
2080 Request::builder()
2081 .method(Method::GET)
2082 .uri("/healthz")
2083 .header(header::ORIGIN, "https://attacker.example")
2084 .body(Body::empty())
2085 .expect("request"),
2086 )
2087 .await
2088 .expect("response");
2089
2090 assert_eq!(response.status(), StatusCode::OK);
2091 assert!(
2092 response
2093 .headers()
2094 .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
2095 .is_none()
2096 );
2097 }
2098
2099 #[tokio::test]
2100 async fn build_state_loads_permissions_into_runtime_policy() {
2101 let tmp = tempfile::tempdir().expect("tempdir");
2102 let config_path = tmp.path().join("config.toml");
2103 fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
2104 fs::write(
2105 tmp.path().join("permissions.toml"),
2106 r#"
2107 [[rules]]
2108 tool = "exec_shell"
2109 command = "cargo test"
2110 "#,
2111 )
2112 .expect("write permissions");
2113
2114 let state = build_state(Some(config_path), None).expect("state");
2115 let runtime = state.runtime.read().await;
2116 let decision = runtime
2117 .exec_policy
2118 .check(codewhale_execpolicy::ExecPolicyContext {
2119 command: "cargo test --workspace",
2120 cwd: "/workspace",
2121 tool: Some("exec_shell"),
2122 path: None,
2123 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
2124 sandbox_mode: Some("workspace-write"),
2125 })
2126 .expect("policy check");
2127
2128 assert!(decision.allow);
2129 assert!(decision.requires_approval);
2130 assert_eq!(
2131 decision.matched_rule.as_deref(),
2132 Some("tool=exec_shell command=cargo test")
2133 );
2134 }
2135
2136 #[tokio::test]
2137 async fn config_reload_refreshes_runtime_config_and_exec_policy_from_disk() {
2138 let tmp = tempfile::tempdir().expect("tempdir");
2139 let config_path = tmp.path().join("config.toml");
2140 fs::write(
2141 &config_path,
2142 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
2143 )
2144 .expect("write config");
2145 let state = build_state(Some(config_path.clone()), None).expect("state");
2147
2148 {
2150 let runtime = state.runtime.read().await;
2151 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat"));
2152 let decision = runtime
2153 .exec_policy
2154 .check(codewhale_execpolicy::ExecPolicyContext {
2155 command: "cargo test",
2156 cwd: "/workspace",
2157 tool: Some("exec_shell"),
2158 path: None,
2159 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
2160 sandbox_mode: Some("workspace-write"),
2161 })
2162 .expect("policy check");
2163 assert!(decision.matched_rule.is_none());
2164 }
2165
2166 fs::write(
2168 &config_path,
2169 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-reasoner\"\n",
2170 )
2171 .expect("rewrite config");
2172 fs::write(
2173 tmp.path().join("permissions.toml"),
2174 r#"
2175 [[rules]]
2176 tool = "exec_shell"
2177 command = "cargo test"
2178 "#,
2179 )
2180 .expect("write permissions");
2181
2182 let response =
2185 process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await;
2186 assert!(response.ok, "reload should succeed");
2187 assert_eq!(response.data["reloaded"], true);
2188
2189 {
2191 let cfg = state.config.read().await;
2192 assert_eq!(cfg.model.as_deref(), Some("deepseek-reasoner"));
2193 }
2194 {
2196 let runtime = state.runtime.read().await;
2197 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-reasoner"));
2198 let decision = runtime
2199 .exec_policy
2200 .check(codewhale_execpolicy::ExecPolicyContext {
2201 command: "cargo test --workspace",
2202 cwd: "/workspace",
2203 tool: Some("exec_shell"),
2204 path: None,
2205 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
2206 sandbox_mode: Some("workspace-write"),
2207 })
2208 .expect("policy check");
2209 assert!(decision.allow);
2210 assert!(decision.requires_approval);
2211 assert_eq!(
2212 decision.matched_rule.as_deref(),
2213 Some("tool=exec_shell command=cargo test")
2214 );
2215 }
2216 }
2217
2218 #[tokio::test]
2219 async fn config_set_propagates_to_runtime_config_without_touching_exec_policy() {
2220 let tmp = tempfile::tempdir().expect("tempdir");
2221 let config_path = tmp.path().join("config.toml");
2222 fs::write(
2223 &config_path,
2224 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
2225 )
2226 .expect("write config");
2227 let state = build_state(Some(config_path.clone()), None).expect("state");
2228
2229 let response = process_app_request(
2232 &state,
2233 AppRequest::ConfigSet {
2234 key: "model".to_string(),
2235 value: "deepseek-reasoner".to_string(),
2236 },
2237 AppTransport::Stdio,
2238 )
2239 .await;
2240 assert!(response.ok, "set should succeed");
2241
2242 {
2244 let runtime = state.runtime.read().await;
2245 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-reasoner"));
2246 let decision = runtime
2248 .exec_policy
2249 .check(codewhale_execpolicy::ExecPolicyContext {
2250 command: "cargo test",
2251 cwd: "/workspace",
2252 tool: Some("exec_shell"),
2253 path: None,
2254 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
2255 sandbox_mode: Some("workspace-write"),
2256 })
2257 .expect("policy check");
2258 assert!(decision.matched_rule.is_none());
2259 }
2260 let persisted = fs::read_to_string(&config_path).expect("read config");
2262 assert!(persisted.contains("deepseek-reasoner"));
2263 }
2264
2265 fn sentinel_bridge() -> SharedRuntimeBridge {
2268 Arc::new(Mutex::new(RuntimeBridge {
2269 base_url: "http://127.0.0.1:0".to_string(),
2270 client: reqwest::Client::new(),
2271 auth_token: None,
2272 child: None,
2273 thread_map: HashMap::from([("stdio-1".to_string(), "runtime-1".to_string())]),
2274 last_seq_by_thread: HashMap::new(),
2275 }))
2276 }
2277
2278 #[tokio::test]
2279 async fn failed_config_set_keeps_the_stdio_bridge() {
2280 let tmp = tempfile::tempdir().expect("tempdir");
2290 let config_path = tmp.path().join("config.toml");
2291 fs::write(&config_path, "model = \"deepseek-chat\"\n").expect("write config");
2292 let state = build_state(Some(config_path.clone()), None).expect("state");
2293 *state.stdio_bridge.lock().await = Some(sentinel_bridge());
2294
2295 let response = process_app_request(
2296 &state,
2297 AppRequest::ConfigSet {
2298 key: "telemetry".to_string(),
2299 value: "not-a-bool".to_string(),
2300 },
2301 AppTransport::Stdio,
2302 )
2303 .await;
2304 assert!(!response.ok, "invalid value must fail: {response:?}");
2305
2306 let slot = state.stdio_bridge.lock().await;
2307 let kept = slot
2308 .as_ref()
2309 .expect("bridge must survive a failed config/set");
2310 assert_eq!(
2311 kept.lock()
2312 .await
2313 .thread_map
2314 .get("stdio-1")
2315 .map(String::as_str),
2316 Some("runtime-1"),
2317 "the live thread map must be intact",
2318 );
2319 }
2320
2321 #[tokio::test]
2322 async fn successful_config_set_still_invalidates_the_stdio_bridge() {
2323 let tmp = tempfile::tempdir().expect("tempdir");
2326 let config_path = tmp.path().join("config.toml");
2327 fs::write(&config_path, "model = \"deepseek-chat\"\n").expect("write config");
2328 let state = build_state(Some(config_path.clone()), None).expect("state");
2329 *state.stdio_bridge.lock().await = Some(sentinel_bridge());
2330
2331 let response = process_app_request(
2332 &state,
2333 AppRequest::ConfigSet {
2334 key: "model".to_string(),
2335 value: "deepseek-reasoner".to_string(),
2336 },
2337 AppTransport::Stdio,
2338 )
2339 .await;
2340 assert!(response.ok, "valid set should succeed: {response:?}");
2341 assert!(
2342 state.stdio_bridge.lock().await.is_none(),
2343 "a successful config change must invalidate the cached bridge",
2344 );
2345 }
2346
2347 #[tokio::test]
2348 async fn config_unset_propagates_to_runtime_config() {
2349 let tmp = tempfile::tempdir().expect("tempdir");
2350 let config_path = tmp.path().join("config.toml");
2351 fs::write(
2352 &config_path,
2353 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
2354 )
2355 .expect("write config");
2356 let state = build_state(Some(config_path.clone()), None).expect("state");
2357
2358 {
2360 let runtime = state.runtime.read().await;
2361 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat"));
2362 }
2363
2364 let response = process_app_request(
2368 &state,
2369 AppRequest::ConfigUnset {
2370 key: "model".to_string(),
2371 },
2372 AppTransport::Stdio,
2373 )
2374 .await;
2375 assert!(response.ok, "unset should succeed");
2376
2377 {
2379 let runtime = state.runtime.read().await;
2380 assert!(runtime.config.model.is_none());
2381 }
2382 {
2384 let cfg = state.config.read().await;
2385 assert!(cfg.model.is_none());
2386 }
2387 let persisted = fs::read_to_string(&config_path).expect("read config");
2389 assert!(!persisted.contains("deepseek-chat"));
2390 }
2391
2392 #[tokio::test]
2393 async fn config_reload_returns_error_when_disk_config_is_invalid() {
2394 let tmp = tempfile::tempdir().expect("tempdir");
2395 let config_path = tmp.path().join("config.toml");
2396 fs::write(
2397 &config_path,
2398 "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n",
2399 )
2400 .expect("write config");
2401 let state = build_state(Some(config_path.clone()), None).expect("state");
2402
2403 fs::write(&config_path, "api_key = \"unterminated\n").expect("corrupt config");
2405
2406 let response =
2407 process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await;
2408 assert!(!response.ok, "reload of corrupt config must fail");
2409 let err = response.data["error"]
2410 .as_str()
2411 .expect("error message present")
2412 .to_string();
2413 assert!(
2414 err.contains("failed to load config"),
2415 "error should mention load failure, got: {err}"
2416 );
2417
2418 {
2421 let runtime = state.runtime.read().await;
2422 assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat"));
2423 }
2424 {
2425 let cfg = state.config.read().await;
2426 assert_eq!(cfg.model.as_deref(), Some("deepseek-chat"));
2427 }
2428 }
2429
2430 async fn seed_test_bridge(state: &AppState) -> SharedRuntimeBridge {
2431 let bridge = Arc::new(Mutex::new(RuntimeBridge::from_base_url_for_test(
2432 "http://127.0.0.1:9".to_string(),
2433 )));
2434 *state.stdio_bridge.lock().await = Some(bridge.clone());
2435 bridge
2436 }
2437
2438 #[tokio::test]
2439 async fn config_set_invalidates_cached_stdio_bridge() {
2440 let tmp = tempfile::tempdir().expect("tempdir");
2441 let config_path = tmp.path().join("config.toml");
2442 fs::write(&config_path, "model = \"deepseek-chat\"\n").expect("write config");
2443 let state = build_state(Some(config_path), None).expect("state");
2444 seed_test_bridge(&state).await;
2445
2446 let response = process_app_request(
2447 &state,
2448 AppRequest::ConfigSet {
2449 key: "model".to_string(),
2450 value: "deepseek-reasoner".to_string(),
2451 },
2452 AppTransport::Stdio,
2453 )
2454 .await;
2455 assert!(response.ok, "set should succeed");
2456
2457 assert!(state.stdio_bridge.lock().await.is_none());
2460 }
2461
2462 #[tokio::test]
2463 async fn config_reload_invalidates_cached_stdio_bridge() {
2464 let tmp = tempfile::tempdir().expect("tempdir");
2465 let config_path = tmp.path().join("config.toml");
2466 fs::write(&config_path, "model = \"deepseek-chat\"\n").expect("write config");
2467 let state = build_state(Some(config_path), None).expect("state");
2468 seed_test_bridge(&state).await;
2469
2470 let response =
2471 process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await;
2472 assert!(response.ok, "reload should succeed");
2473
2474 assert!(state.stdio_bridge.lock().await.is_none());
2475 }
2476
2477 #[tokio::test]
2478 async fn stdio_bridge_invalidation_not_blocked_by_in_flight_turn() {
2479 let (state, _tmp) = capability_test_state();
2480 let bridge = seed_test_bridge(&state).await;
2481
2482 let _in_flight = bridge.lock().await;
2484
2485 tokio::time::timeout(Duration::from_secs(1), invalidate_stdio_bridge(&state))
2488 .await
2489 .expect("invalidation must not wait on bridge traffic");
2490 assert!(state.stdio_bridge.lock().await.is_none());
2491 }
2492
2493 #[tokio::test]
2494 async fn runtime_read_paths_run_concurrently() {
2495 let (state, _tmp) = capability_test_state();
2499 let first = state.runtime.read().await;
2500 let second = state.runtime.read().await;
2501 assert!(first.app_status().ok);
2502 assert!(second.app_status().ok);
2503 }
2504
2505 #[tokio::test]
2506 async fn health_probes_advertise_legacy_deepseek_service_name() {
2507 let (app, _tmp) = app_with_config(None);
2510 let response = app
2511 .oneshot(
2512 Request::builder()
2513 .method(Method::GET)
2514 .uri("/healthz")
2515 .body(Body::empty())
2516 .expect("request"),
2517 )
2518 .await
2519 .expect("response");
2520 let body = response_body_json(response).await;
2521 assert_eq!(body["service"], legacy_deepseek_compat::SERVICE_NAME);
2522 assert_eq!(body["service"], "deepseek-app-server");
2523
2524 let (state, _tmp) = capability_test_state();
2525 let stdio = dispatch_stdio_request(&state, "healthz", json!({}))
2526 .await
2527 .expect("stdio healthz");
2528 assert_eq!(
2529 stdio.result["service"],
2530 legacy_deepseek_compat::SERVICE_NAME
2531 );
2532 }
2533
2534 #[test]
2535 fn non_loopback_bind_without_auth_fails_fast() {
2536 let options = AppServerOptions {
2537 listen: "0.0.0.0:8787".parse().expect("socket addr"),
2538 config_path: None,
2539 auth_token: None,
2540 insecure_no_auth: false,
2541 cors_origins: Vec::new(),
2542 };
2543
2544 let err =
2545 resolve_auth_token(&options).expect_err("non-loopback generated auth should fail");
2546 assert!(err.to_string().contains("without explicit auth token"));
2547 }
2548
2549 #[tokio::test]
2550 async fn stdio_transport_redacts_config_get_secrets() {
2551 let tmp = tempfile::tempdir().expect("tempdir");
2552 let config_path = tmp.path().join("config.toml");
2553 fs::write(&config_path, "").expect("write config");
2554 let state = build_state(Some(config_path), None).expect("state");
2555 {
2556 let mut cfg = state.config.write().await;
2557 cfg.api_key = Some("sk-deepseek-secret".to_string());
2558 }
2559
2560 let response = process_app_request(
2561 &state,
2562 AppRequest::ConfigGet {
2563 key: "api_key".to_string(),
2564 },
2565 AppTransport::Stdio,
2566 )
2567 .await;
2568
2569 assert_eq!(response.data["value"], "sk-d***cret");
2570 }
2571
2572 #[tokio::test]
2573 async fn stdio_thread_goal_methods_round_trip_persisted_goal() {
2574 let tmp = tempfile::tempdir().expect("tempdir");
2575 let config_path = tmp.path().join("config.toml");
2576 fs::write(&config_path, "").expect("write config");
2577 let state = build_state(Some(config_path), None).expect("state");
2578
2579 let capabilities = dispatch_stdio_request(&state, "thread/capabilities", json!({}))
2580 .await
2581 .expect("thread capabilities");
2582 assert!(
2583 capabilities.result["methods"]
2584 .as_array()
2585 .expect("methods")
2586 .iter()
2587 .any(|method| method == "thread/goal/set")
2588 );
2589
2590 let started = dispatch_stdio_request(&state, "thread/start", json!({}))
2591 .await
2592 .expect("start thread");
2593 let thread_id = started.result["thread_id"]
2594 .as_str()
2595 .expect("thread id")
2596 .to_string();
2597
2598 let set = dispatch_stdio_request(
2599 &state,
2600 "thread/goal/set",
2601 json!({
2602 "thread_id": thread_id,
2603 "objective": "Release 0.8.59",
2604 "token_budget": 59000
2605 }),
2606 )
2607 .await
2608 .expect("set goal");
2609 assert_eq!(set.result["status"], "ok");
2610 assert_eq!(set.result["goal"]["objective"], "Release 0.8.59");
2611 assert_eq!(set.result["goal"]["status"], "active");
2612
2613 let got = dispatch_stdio_request(
2614 &state,
2615 "thread/goal/get",
2616 json!({
2617 "thread_id": thread_id
2618 }),
2619 )
2620 .await
2621 .expect("get goal");
2622 assert_eq!(got.result["goal"]["token_budget"], 59000);
2623
2624 let cleared = dispatch_stdio_request(
2625 &state,
2626 "thread/goal/clear",
2627 json!({
2628 "thread_id": thread_id
2629 }),
2630 )
2631 .await
2632 .expect("clear goal");
2633 assert_eq!(cleared.result["status"], "cleared");
2634 assert_eq!(cleared.result["data"]["cleared"], true);
2635 }
2636
2637 fn sse_frame(event: &str, payload: Value) -> String {
2638 format!("event: {event}\ndata: {payload}\n\n")
2639 }
2640
2641 async fn spawn_uninterruptible_until_asked_runtime() -> (
2644 String,
2645 Arc<tokio::sync::Notify>,
2646 tokio::task::JoinHandle<()>,
2647 ) {
2648 use axum::body::Body;
2649 use axum::extract::Path as AxumPath;
2650
2651 let interrupted = Arc::new(tokio::sync::Notify::new());
2652
2653 async fn create_turn(AxumPath(_thread_id): AxumPath<String>) -> Json<Value> {
2654 Json(json!({ "turn": { "id": "turn_runaway" } }))
2655 }
2656 async fn create_thread() -> Json<Value> {
2657 Json(json!({ "id": "thr_runaway" }))
2658 }
2659 async fn interrupt(
2660 State(notify): State<Arc<tokio::sync::Notify>>,
2661 AxumPath((_thread_id, _turn_id)): AxumPath<(String, String)>,
2662 ) -> Json<Value> {
2663 notify.notify_waiters();
2664 Json(json!({ "ok": true }))
2665 }
2666 async fn thread_events(
2667 State(notify): State<Arc<tokio::sync::Notify>>,
2668 AxumPath(_thread_id): AxumPath<String>,
2669 ) -> ([(header::HeaderName, &'static str); 1], Body) {
2670 notify.notified().await;
2673 let body = [
2674 sse_frame(
2675 "item.delta",
2676 json!({
2677 "seq": 1,
2678 "turn_id": "turn_runaway",
2679 "payload": { "kind": "agent_message", "delta": "thinking" }
2680 }),
2681 ),
2682 sse_frame(
2683 "turn.completed",
2684 json!({
2685 "seq": 2,
2686 "turn_id": "turn_runaway",
2687 "payload": { "turn": { "status": "interrupted" } }
2688 }),
2689 ),
2690 ]
2691 .concat();
2692 (
2693 [(header::CONTENT_TYPE, "text/event-stream")],
2694 Body::from(body),
2695 )
2696 }
2697
2698 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2699 .await
2700 .expect("bind test listener");
2701 let addr = listener.local_addr().expect("listener addr");
2702 let app = Router::new()
2703 .route("/v1/threads", post(create_thread))
2704 .route("/v1/threads/{thread_id}/turns", post(create_turn))
2705 .route(
2706 "/v1/threads/{thread_id}/turns/{turn_id}/interrupt",
2707 post(interrupt),
2708 )
2709 .route("/v1/threads/{thread_id}/events", get(thread_events))
2710 .with_state(interrupted.clone());
2711 let server = tokio::spawn(async move {
2712 axum::serve(listener, app)
2713 .await
2714 .expect("serve test runtime");
2715 });
2716 (format!("http://{addr}"), interrupted, server)
2717 }
2718
2719 #[tokio::test]
2720 async fn interrupt_stops_a_turn_that_would_otherwise_stream_forever() {
2721 let (base_url, _notify, server) = spawn_uninterruptible_until_asked_runtime().await;
2722 let (state, _tmp) = capability_test_state();
2723 *state.stdio_bridge.lock().await = Some(Arc::new(Mutex::new(
2724 RuntimeBridge::from_base_url_for_test(base_url),
2725 )));
2726
2727 let (client, server_side) = tokio::io::duplex(16 * 1024);
2728 let (client_reader, mut client_writer) = tokio::io::split(client);
2729
2730 let loop_state = state.clone();
2731 let loop_handle = tokio::spawn(async move {
2732 let (rx, tx) = tokio::io::split(server_side);
2733 run_stdio_loop(&loop_state, BufReader::new(rx).lines(), tx).await
2734 });
2735
2736 client_writer
2738 .write_all(
2739 b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"thread/message\",\
2740 \"params\":{\"thread_id\":\"thr_a\",\"input\":\"go\"}}\n",
2741 )
2742 .await
2743 .expect("send thread/message");
2744
2745 tokio::time::timeout(Duration::from_secs(10), async {
2748 loop {
2749 if state.in_flight_turns.lock().await.contains_key("thr_a") {
2750 return;
2751 }
2752 tokio::time::sleep(Duration::from_millis(10)).await;
2753 }
2754 })
2755 .await
2756 .expect("turn should register itself as in flight");
2757
2758 client_writer
2760 .write_all(
2761 b"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"thread/interrupt\",\
2762 \"params\":{\"thread_id\":\"thr_a\"}}\n",
2763 )
2764 .await
2765 .expect("send thread/interrupt");
2766 client_writer
2767 .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"shutdown\"}\n")
2768 .await
2769 .expect("send shutdown");
2770
2771 let finished = tokio::time::timeout(Duration::from_secs(20), loop_handle)
2772 .await
2773 .expect("the loop must exit rather than hang on the runaway turn");
2774 finished.expect("join loop").expect("loop result");
2775
2776 let mut output = String::new();
2777 let mut lines = BufReader::new(client_reader);
2778 lines
2779 .read_to_string(&mut output)
2780 .await
2781 .expect("read stdio output");
2782
2783 let responses: Vec<Value> = output
2784 .lines()
2785 .filter_map(|line| serde_json::from_str::<Value>(line).ok())
2786 .collect();
2787 let by_id = |id: u64| {
2788 responses
2789 .iter()
2790 .find(|value| value["id"] == json!(id))
2791 .unwrap_or_else(|| panic!("no response for id {id} in {output}"))
2792 .clone()
2793 };
2794
2795 assert!(
2797 by_id(1)["error"].is_object(),
2798 "the interrupted turn should report an error, got: {}",
2799 by_id(1)
2800 );
2801 assert_eq!(by_id(2)["result"]["interrupted"], json!(true));
2802 assert_eq!(by_id(3)["result"]["status"], json!("stopped"));
2803
2804 server.abort();
2805 let _ = server.await;
2806 }
2807
2808 #[tokio::test]
2809 async fn interrupting_an_idle_thread_is_not_an_error() {
2810 let (state, _tmp) = capability_test_state();
2811 let response = dispatch_stdio_request(
2812 &state,
2813 "thread/interrupt",
2814 json!({ "thread_id": "thr_nothing_running" }),
2815 )
2816 .await
2817 .expect("interrupt dispatch");
2818 assert_eq!(response.result["interrupted"], json!(false));
2819 }
2820
2821 #[tokio::test]
2822 async fn stdio_runtime_bridge_streams_response_delta_events() {
2823 async fn create_turn(AxumPath(thread_id): AxumPath<String>) -> Json<Value> {
2824 Json(json!({
2825 "thread": { "id": thread_id },
2826 "turn": { "id": "turn_test" },
2827 }))
2828 }
2829
2830 async fn thread_events(
2831 AxumPath(thread_id): AxumPath<String>,
2832 Query(query): Query<HashMap<String, String>>,
2833 ) -> ([(header::HeaderName, &'static str); 1], String) {
2834 assert_eq!(thread_id, "thr_test");
2835 assert_eq!(query.get("since_seq").map(String::as_str), Some("0"));
2836
2837 let body = [
2838 sse_frame(
2839 "item.delta",
2840 json!({
2841 "seq": 1,
2842 "turn_id": "turn_test",
2843 "payload": {
2844 "kind": "agent_message",
2845 "delta": "hello"
2846 }
2847 }),
2848 ),
2849 sse_frame(
2850 "turn.completed",
2851 json!({
2852 "seq": 2,
2853 "turn_id": "turn_test",
2854 "payload": {
2855 "turn": {
2856 "status": "completed"
2857 }
2858 }
2859 }),
2860 ),
2861 ]
2862 .concat();
2863
2864 ([(header::CONTENT_TYPE, "text/event-stream")], body)
2865 }
2866
2867 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2868 .await
2869 .expect("bind test listener");
2870 let addr = listener.local_addr().expect("listener addr");
2871 let app = Router::new()
2872 .route("/v1/threads/{thread_id}/turns", post(create_turn))
2873 .route("/v1/threads/{thread_id}/events", get(thread_events));
2874
2875 let server = tokio::spawn(async move {
2876 axum::serve(listener, app)
2877 .await
2878 .expect("serve test runtime");
2879 });
2880
2881 let mut bridge = RuntimeBridge::from_base_url_for_test(format!("http://{addr}"));
2882 let (mut reader, mut writer) = tokio::io::duplex(4096);
2883
2884 let result = bridge
2885 .message_thread("thr_test", "hello", &mut writer, None)
2886 .await
2887 .expect("message_thread should succeed");
2888 drop(writer);
2889
2890 let mut stdout = Vec::new();
2891 reader
2892 .read_to_end(&mut stdout)
2893 .await
2894 .expect("read stdio output");
2895 server.abort();
2896 let _ = server.await;
2897
2898 let lines: Vec<Value> = String::from_utf8(stdout)
2899 .expect("utf8 output")
2900 .lines()
2901 .map(|line| serde_json::from_str(line).expect("json line"))
2902 .collect();
2903
2904 assert_eq!(
2905 result.get("status").and_then(Value::as_str),
2906 Some("accepted")
2907 );
2908 assert_eq!(
2909 result.pointer("/data/turn_id").and_then(Value::as_str),
2910 Some("turn_test")
2911 );
2912 assert_eq!(bridge.last_seq_by_thread.get("thr_test"), Some(&2));
2913
2914 let event_types: Vec<&str> = lines
2915 .iter()
2916 .map(|line| {
2917 line.get("type")
2918 .and_then(Value::as_str)
2919 .expect("event type")
2920 })
2921 .collect();
2922 assert_eq!(
2923 event_types,
2924 vec!["response_start", "response_delta", "response_end"]
2925 );
2926 assert_eq!(lines[1]["delta"], "hello");
2927 }
2928
2929 #[tokio::test]
2930 async fn stdio_runtime_bridge_applies_thread_start_hints() {
2931 async fn create_thread(Json(body): Json<Value>) -> Json<Value> {
2932 assert_eq!(body["model"], "deepseek-v4");
2933 assert_eq!(body["workspace"], "/tmp/codewhale-stdio");
2934 Json(json!({
2935 "id": "thr_runtime",
2936 "model": body["model"].clone(),
2937 "workspace": body["workspace"].clone(),
2938 }))
2939 }
2940
2941 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2942 .await
2943 .expect("bind test listener");
2944 let addr = listener.local_addr().expect("listener addr");
2945 let app = Router::new().route("/v1/threads", post(create_thread));
2946
2947 let server = tokio::spawn(async move {
2948 axum::serve(listener, app)
2949 .await
2950 .expect("serve test runtime");
2951 });
2952
2953 let mut bridge = RuntimeBridge::from_base_url_for_test(format!("http://{addr}"));
2954 let runtime_id = bridge
2955 .ensure_runtime_thread(
2956 "legacy_thread",
2957 Some(RuntimeThreadHint {
2958 model: Some("deepseek-v4".to_string()),
2959 workspace: Some(PathBuf::from("/tmp/codewhale-stdio")),
2960 }),
2961 )
2962 .await
2963 .expect("runtime thread");
2964 server.abort();
2965 let _ = server.await;
2966
2967 assert_eq!(runtime_id, "thr_runtime");
2968 assert_eq!(
2969 bridge.thread_map.get("legacy_thread").map(String::as_str),
2970 Some("thr_runtime")
2971 );
2972 }
2973
2974 const EXPECTED_CAPABILITY_METHODS: &[&str] = &[
2983 "healthz",
2984 "thread/capabilities",
2985 "thread/request",
2986 "thread/create",
2987 "thread/start",
2988 "thread/resume",
2989 "thread/fork",
2990 "thread/list",
2991 "thread/read",
2992 "thread/set_name",
2993 "thread/goal/set",
2994 "thread/goal/get",
2995 "thread/goal/clear",
2996 "thread/archive",
2997 "thread/unarchive",
2998 "thread/message",
2999 "thread/interrupt",
3000 "app/capabilities",
3001 "app/request",
3002 "app/config/get",
3003 "app/config/set",
3004 "app/config/unset",
3005 "app/config/list",
3006 "app/config/reload",
3007 "app/models",
3008 "app/thread_loaded_list",
3009 "prompt/capabilities",
3010 "prompt/request",
3011 "prompt/run",
3012 "shutdown",
3013 ];
3014
3015 fn capability_test_state() -> (AppState, tempfile::TempDir) {
3016 let tmp = tempfile::tempdir().expect("tempdir");
3017 let config_path = tmp.path().join("config.toml");
3018 fs::write(&config_path, "").expect("write config");
3019 let state = build_state(Some(config_path), None).expect("state");
3020 (state, tmp)
3021 }
3022
3023 #[tokio::test]
3024 async fn capabilities_method_set_is_stable() {
3025 let (state, _tmp) = capability_test_state();
3026 let caps = dispatch_stdio_request(&state, "capabilities", json!({}))
3027 .await
3028 .expect("capabilities dispatch");
3029 let methods: Vec<String> = caps.result["methods"]
3030 .as_array()
3031 .expect("methods array")
3032 .iter()
3033 .map(|m| m.as_str().expect("method string").to_string())
3034 .collect();
3035 assert_eq!(
3036 methods, EXPECTED_CAPABILITY_METHODS,
3037 "app-server stdio capability set drifted; update the dispatcher, this \
3038 snapshot, and docs/RUNTIME_API.md together"
3039 );
3040 }
3041
3042 #[tokio::test]
3043 async fn every_advertised_capability_is_dispatchable() {
3044 let (state, _tmp) = capability_test_state();
3045 for method in EXPECTED_CAPABILITY_METHODS {
3049 if let Err(err) = dispatch_stdio_request(&state, method, json!({})).await {
3050 assert_ne!(
3051 err.code,
3052 JsonRpcError::method_not_found(method).code,
3053 "advertised capability `{method}` is not dispatchable"
3054 );
3055 }
3056 }
3057 }
3058
3059 #[test]
3062 fn auth_token_empty_string_fails() {
3063 let options = AppServerOptions {
3064 listen: "127.0.0.1:0".parse().expect("addr"),
3065 config_path: None,
3066 auth_token: Some(" ".to_string()),
3067 insecure_no_auth: false,
3068 cors_origins: Vec::new(),
3069 };
3070 let err = resolve_auth_token(&options).expect_err("empty token should fail");
3071 assert!(err.to_string().contains("cannot be empty"));
3072 }
3073
3074 #[test]
3075 fn auth_token_generated_when_none_provided() {
3076 let options = AppServerOptions {
3077 listen: "127.0.0.1:0".parse().expect("addr"),
3078 config_path: None,
3079 auth_token: None,
3080 insecure_no_auth: false,
3081 cors_origins: Vec::new(),
3082 };
3083 let token = resolve_auth_token(&options).unwrap();
3084 assert!(token.is_some());
3085 assert!(token.unwrap().starts_with("cwapp_"));
3086 }
3087
3088 #[test]
3089 fn runtime_bridge_command_keeps_auth_token_out_of_argv() {
3090 let token = "cwrt_unit_test_secret_token_not_for_argv";
3093 let cmd = RuntimeBridge::runtime_command(None, 18787, token).expect("command");
3094 let argv: Vec<String> = cmd
3095 .get_args()
3096 .map(|a| a.to_string_lossy().into_owned())
3097 .collect();
3098 assert!(
3099 !argv
3100 .iter()
3101 .any(|a| a.contains(token) || a == "--auth-token"),
3102 "auth token must not be present in child argv: {argv:?}"
3103 );
3104 let envs: Vec<(String, String)> = cmd
3105 .get_envs()
3106 .filter_map(|(k, v)| {
3107 Some((
3108 k.to_string_lossy().into_owned(),
3109 v?.to_string_lossy().into_owned(),
3110 ))
3111 })
3112 .collect();
3113 assert!(
3114 envs.iter()
3115 .any(|(k, v)| k == "CODEWHALE_RUNTIME_TOKEN" && v == token),
3116 "token must be carried via CODEWHALE_RUNTIME_TOKEN: {envs:?}"
3117 );
3118 assert!(
3119 envs.iter()
3120 .any(|(k, v)| k == "DEEPSEEK_RUNTIME_TOKEN" && v == token),
3121 "legacy alias DEEPSEEK_RUNTIME_TOKEN must also carry the token: {envs:?}"
3122 );
3123 }
3124
3125 #[test]
3126 fn generated_auth_status_does_not_render_token() {
3127 let rendered = app_server_auth_status_lines(false).join("\n");
3128
3129 assert!(!rendered.contains("Authorization: Bearer"));
3130 assert!(rendered.contains("not printed"));
3131 assert!(rendered.contains("CODEWHALE_APP_SERVER_TOKEN"));
3132 }
3133
3134 #[test]
3135 fn auth_token_explicit_is_preserved() {
3136 let options = AppServerOptions {
3137 listen: "127.0.0.1:0".parse().expect("addr"),
3138 config_path: None,
3139 auth_token: Some("my-secret".to_string()),
3140 insecure_no_auth: false,
3141 cors_origins: Vec::new(),
3142 };
3143 let token = resolve_auth_token(&options).unwrap();
3144 assert_eq!(token.as_deref(), Some("my-secret"));
3145 }
3146
3147 #[test]
3148 fn auth_token_explicit_allows_non_loopback_bind() {
3149 let options = AppServerOptions {
3150 listen: "0.0.0.0:8787".parse().expect("socket addr"),
3151 config_path: None,
3152 auth_token: Some("my-secret".to_string()),
3153 insecure_no_auth: false,
3154 cors_origins: Vec::new(),
3155 };
3156 let token = resolve_auth_token(&options).unwrap();
3157 assert_eq!(token.as_deref(), Some("my-secret"));
3158 }
3159
3160 #[test]
3161 fn insecure_no_auth_on_loopback_returns_none() {
3162 let options = AppServerOptions {
3163 listen: "127.0.0.1:0".parse().expect("addr"),
3164 config_path: None,
3165 auth_token: None,
3166 insecure_no_auth: true,
3167 cors_origins: Vec::new(),
3168 };
3169 let token = resolve_auth_token(&options).unwrap();
3170 assert!(token.is_none());
3171 }
3172
3173 #[test]
3174 fn insecure_no_auth_on_non_loopback_fails_fast() {
3175 let options = AppServerOptions {
3176 listen: "0.0.0.0:8787".parse().expect("socket addr"),
3177 config_path: None,
3178 auth_token: None,
3179 insecure_no_auth: true,
3180 cors_origins: Vec::new(),
3181 };
3182
3183 let err = resolve_auth_token(&options).expect_err("non-loopback unauth should fail");
3184 assert!(
3185 err.to_string()
3186 .contains("refusing unauthenticated app-server bind")
3187 );
3188 }
3189
3190 #[test]
3193 fn cors_layer_includes_default_origins() {
3194 let layer = cors_layer(&[]);
3195 let _ = layer;
3197 }
3198
3199 #[test]
3200 fn cors_layer_adds_extra_origins() {
3201 let extras = vec!["https://example.com".to_string()];
3202 let layer = cors_layer(&extras);
3203 let _ = layer;
3204 }
3205
3206 #[test]
3207 fn cors_layer_skips_empty_origins() {
3208 let extras = vec!["".to_string(), " ".to_string()];
3209 let layer = cors_layer(&extras);
3210 let _ = layer;
3211 }
3212
3213 #[test]
3216 fn params_or_object_returns_object_for_null() {
3217 let result = params_or_object(Value::Null);
3218 assert_eq!(result, json!({}));
3219 }
3220
3221 #[test]
3222 fn params_or_object_passthrough_for_non_null() {
3223 let input = json!({"key": "value"});
3224 let result = params_or_object(input.clone());
3225 assert_eq!(result, input);
3226 }
3227
3228 #[test]
3229 fn jsonrpc_result_format() {
3230 let result = jsonrpc_result(Some(json!(1)), json!({"ok": true}));
3231 assert_eq!(result["jsonrpc"], "2.0");
3232 assert_eq!(result["id"], 1);
3233 assert_eq!(result["result"]["ok"], true);
3234 }
3235
3236 #[test]
3237 fn jsonrpc_result_null_id() {
3238 let result = jsonrpc_result(None, json!(null));
3239 assert_eq!(result["id"], Value::Null);
3240 }
3241
3242 #[test]
3243 fn jsonrpc_error_format() {
3244 let err = jsonrpc_error(Some(json!(2)), JsonRpcError::internal("oops"));
3245 assert_eq!(err["jsonrpc"], "2.0");
3246 assert_eq!(err["id"], 2);
3247 assert_eq!(err["error"]["code"], -32603);
3248 assert_eq!(err["error"]["message"], "oops");
3249 }
3250
3251 #[test]
3252 fn jsonrpc_error_codes() {
3253 assert_eq!(JsonRpcError::parse_error("").code, -32700);
3254 assert_eq!(JsonRpcError::invalid_request("").code, -32600);
3255 assert_eq!(JsonRpcError::method_not_found("x").code, -32601);
3256 assert_eq!(JsonRpcError::invalid_params("").code, -32602);
3257 assert_eq!(JsonRpcError::internal("").code, -32603);
3258 }
3259
3260 #[test]
3263 fn app_server_options_debug_does_not_leak_token() {
3264 let options = AppServerOptions {
3265 listen: "127.0.0.1:8080".parse().expect("addr"),
3266 config_path: None,
3267 auth_token: Some("secret-token".to_string()),
3268 insecure_no_auth: false,
3269 cors_origins: vec!["https://example.com".to_string()],
3270 };
3271 let debug = format!("{options:?}");
3272 assert!(!debug.contains("secret-token"));
3273 assert!(debug.contains("<redacted>"));
3274 assert!(debug.contains("8080"));
3275 }
3276
3277 #[test]
3280 fn default_cors_origins_include_common_dev_ports() {
3281 assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:3000"));
3282 assert!(DEFAULT_CORS_ORIGINS.contains(&"http://localhost:5173"));
3283 assert!(DEFAULT_CORS_ORIGINS.contains(&"tauri://localhost"));
3284 }
3285}