1use std::collections::{BTreeMap, HashMap};
7use std::path::{Path, PathBuf};
8use std::process::Stdio;
9use std::sync::Arc;
10use std::time::Duration;
11
12use async_trait::async_trait;
13use serde::{Deserialize, Serialize};
14use serde_json::{json, Value};
15use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
16use tokio::process::{Child, ChildStdin, Command};
17use tokio::sync::{mpsc, oneshot, Mutex};
18
19use crate::{Error, HarnessId, Result};
20
21mod adapters;
22mod hosted;
23#[cfg(feature = "adapter-api")]
24mod supercode_http;
25pub(crate) use adapters::generated_session_id;
26pub use adapters::{
27 AcpRuntimeBackend, ClaudeCodeRuntimeBackend, OpenCodeRuntimeBackend, PiRuntimeBackend,
28};
29pub use hosted::{HostedHarnessConnection, HostedHarnessRuntime};
30#[cfg(feature = "adapter-api")]
31pub use supercode_http::SupercodeHttpRuntimeBackend;
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct RuntimeCapabilities {
36 pub start_session: bool,
38 pub resume_session: bool,
40 pub attach_existing_process: bool,
42 pub send_input: bool,
44 pub stream_events: bool,
46 pub interrupt: bool,
48 pub respond_to_requests: bool,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct RuntimeLaunch {
55 pub program: String,
57 pub arguments: Vec<String>,
59 pub env: BTreeMap<String, String>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct RuntimeStartRequest {
66 pub cwd: PathBuf,
68 pub launch: Option<RuntimeLaunch>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct RuntimeAttachRequest {
75 pub runtime_id: String,
77 pub cwd: Option<PathBuf>,
79 pub launch: Option<RuntimeLaunch>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(tag = "kind", rename_all = "snake_case")]
86pub enum RuntimeEndpoint {
87 LocalProcess {
89 pid: Option<u32>,
91 command: Vec<String>,
93 protocol: String,
95 },
96 Http {
98 base_url: String,
100 protocol: String,
102 },
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct RuntimeHandle {
108 pub harness: HarnessId,
110 pub runtime_id: String,
112 pub endpoint: RuntimeEndpoint,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct RuntimeInput {
119 pub text: String,
121}
122
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub struct HarnessEvent {
126 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub sequence: Option<u64>,
131 pub kind: String,
133 pub payload: Value,
135}
136
137#[async_trait]
139pub trait RuntimeConnection: Send {
140 fn handle(&self) -> &RuntimeHandle;
142 async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>>;
145 async fn next_event(&mut self) -> Result<Option<HarnessEvent>>;
147 async fn interrupt(&mut self) -> Result<()>;
149 async fn respond(&mut self, request_id: Value, response: Value) -> Result<()>;
151 async fn close(&mut self) -> Result<()>;
153}
154
155#[async_trait]
158pub trait RuntimeBackend: Send + Sync {
159 fn harness(&self) -> HarnessId;
161 fn capabilities(&self) -> RuntimeCapabilities;
163 async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>>;
165 async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>>;
169 async fn attach_existing(
173 &self,
174 _request: RuntimeAttachRequest,
175 ) -> Result<Box<dyn RuntimeConnection>> {
176 Err(Error::Other(format!(
177 "{} cannot attach to an already-running process",
178 self.harness().as_str()
179 )))
180 }
181}
182
183#[derive(Debug, Clone)]
186pub struct CodexRuntimeBackend {
187 launch: RuntimeLaunch,
188}
189
190const CODEX_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
191
192#[derive(Debug)]
199struct CodexRuntimeHome {
200 root: PathBuf,
201 native_home: PathBuf,
202}
203
204impl CodexRuntimeHome {
205 fn prepare(launch: &mut RuntimeLaunch, runtime_id: Option<&str>) -> Result<Self> {
206 let native_home = codex_native_home(launch)?;
207 let root = supercode_runtime_root()
208 .join("codex")
209 .join(generated_session_id());
210 std::fs::create_dir_all(&root).map_err(|error| {
211 Error::Other(format!(
212 "could not create isolated Codex runtime home {}: {error}",
213 root.display()
214 ))
215 })?;
216 set_private_directory(&root)?;
217 let root = std::fs::canonicalize(&root)?;
218
219 for entry in [
220 "auth.json",
221 "config.toml",
222 "hooks.json",
223 "models_cache.json",
224 "installation_id",
225 ".personality_migration",
226 ".sandbox_migration",
227 "cache",
228 "generated_images",
229 "mcp-oauth-locks",
230 "memories",
231 "plugins",
232 "rules",
233 "shell_snapshots",
234 "skills",
235 "thread-writer-locks",
236 ] {
237 link_runtime_resource(&native_home.join(entry), &root.join(entry))?;
238 }
239
240 if let Some(runtime_id) = runtime_id {
241 let source = find_codex_rollout(&native_home.join("sessions"), runtime_id)?
242 .ok_or_else(|| {
243 Error::Other(format!(
244 "could not find Codex rollout `{runtime_id}` below {}",
245 native_home.join("sessions").display()
246 ))
247 })?;
248 let relative = source.strip_prefix(&native_home).map_err(|_| {
249 Error::Other(format!(
250 "Codex rollout {} is outside native home {}",
251 source.display(),
252 native_home.display()
253 ))
254 })?;
255 let projected = root.join(relative);
256 if let Some(parent) = projected.parent() {
257 std::fs::create_dir_all(parent)?;
258 }
259 std::fs::hard_link(&source, &projected).map_err(|error| {
260 Error::Other(format!(
261 "could not project Codex rollout {} into isolated runtime home: {error}",
262 source.display()
263 ))
264 })?;
265 }
266
267 launch
268 .env
269 .insert("CODEX_HOME".into(), root.to_string_lossy().into_owned());
270 Ok(Self { root, native_home })
271 }
272
273 fn started_rollout_path(&self, response: &Value) -> Result<PathBuf> {
274 let path = response
275 .pointer("/thread/path")
276 .and_then(Value::as_str)
277 .map(PathBuf::from)
278 .ok_or_else(|| {
279 Error::Other("Codex thread/start response omitted thread.path".into())
280 })?;
281 let relative = path.strip_prefix(&self.root).map_err(|_| {
282 Error::Other(format!(
283 "Codex created rollout {} outside isolated runtime home {}",
284 path.display(),
285 self.root.display()
286 ))
287 })?;
288 if !relative.starts_with("sessions") {
289 return Err(Error::Other(format!(
290 "Codex created non-session rollout {}",
291 path.display()
292 )));
293 }
294 Ok(path)
295 }
296
297 async fn publish_rollout(&self, path: &Path) -> Result<()> {
298 let relative = path.strip_prefix(&self.root).map_err(|_| {
299 Error::Other(format!(
300 "Codex created rollout {} outside isolated runtime home {}",
301 path.display(),
302 self.root.display()
303 ))
304 })?;
305 let publish_deadline = tokio::time::Instant::now() + Duration::from_secs(2);
306 while !path.is_file() {
307 if tokio::time::Instant::now() >= publish_deadline {
308 return Err(Error::Other(format!(
309 "Codex did not create promised rollout {} within 2s",
310 path.display()
311 )));
312 }
313 tokio::time::sleep(Duration::from_millis(10)).await;
314 }
315 let native = self.native_home.join(relative);
316 if let Some(parent) = native.parent() {
317 std::fs::create_dir_all(parent)?;
318 }
319 std::fs::hard_link(path, &native).map_err(|error| {
320 Error::Other(format!(
321 "could not publish Codex rollout {} to native home: {error}",
322 path.display()
323 ))
324 })
325 }
326
327 fn cleanup(&self) -> Result<()> {
328 match std::fs::remove_dir_all(&self.root) {
329 Ok(()) => Ok(()),
330 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
331 Err(error) => Err(Error::Other(format!(
332 "could not clean isolated Codex runtime home {}: {error}",
333 self.root.display()
334 ))),
335 }
336 }
337}
338
339impl Drop for CodexRuntimeHome {
340 fn drop(&mut self) {
341 let _ = self.cleanup();
342 }
343}
344
345fn is_stock_codex_launch(launch: &RuntimeLaunch) -> bool {
346 launch
347 .arguments
348 .iter()
349 .any(|argument| argument == "app-server")
350 && Path::new(&launch.program)
351 .file_name()
352 .and_then(|name| name.to_str())
353 .is_some_and(|name| name == "codex" || name == "codex.exe")
354}
355
356fn codex_native_home(launch: &RuntimeLaunch) -> Result<PathBuf> {
357 launch
358 .env
359 .get("CODEX_HOME")
360 .map(PathBuf::from)
361 .or_else(|| std::env::var_os("CODEX_HOME").map(PathBuf::from))
362 .or_else(|| {
363 std::env::var_os("HOME")
364 .map(PathBuf::from)
365 .map(|home| home.join(".codex"))
366 })
367 .ok_or_else(|| Error::Other("Codex runtime requires CODEX_HOME or HOME".into()))
368}
369
370fn supercode_runtime_root() -> PathBuf {
371 std::env::var_os("SUPERCODE_HOME")
372 .map(PathBuf::from)
373 .or_else(|| {
374 std::env::var_os("HOME")
375 .map(PathBuf::from)
376 .map(|home| home.join(".supercode"))
377 })
378 .unwrap_or_else(|| std::env::temp_dir().join("supercode"))
379 .join("runtime-homes")
380}
381
382fn find_codex_rollout(root: &Path, runtime_id: &str) -> Result<Option<PathBuf>> {
383 let entries = match std::fs::read_dir(root) {
384 Ok(entries) => entries,
385 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
386 Err(error) => return Err(error.into()),
387 };
388 let expected_suffix = format!("-{runtime_id}.jsonl");
389 for entry in entries {
390 let entry = entry?;
391 let kind = entry.file_type()?;
392 if kind.is_dir() {
393 if let Some(path) = find_codex_rollout(&entry.path(), runtime_id)? {
394 return Ok(Some(path));
395 }
396 } else if kind.is_file()
397 && entry
398 .file_name()
399 .to_str()
400 .is_some_and(|name| name.ends_with(&expected_suffix))
401 {
402 return Ok(Some(entry.path()));
403 }
404 }
405 Ok(None)
406}
407
408#[cfg(unix)]
409fn link_runtime_resource(source: &Path, target: &Path) -> Result<()> {
410 use std::os::unix::fs::symlink;
411
412 if source.exists() {
413 symlink(source, target)?;
414 }
415 Ok(())
416}
417
418#[cfg(not(unix))]
419fn link_runtime_resource(source: &Path, target: &Path) -> Result<()> {
420 if source.is_file() {
421 std::fs::copy(source, target)?;
422 }
423 Ok(())
424}
425
426#[cfg(unix)]
427fn set_private_directory(path: &Path) -> Result<()> {
428 use std::os::unix::fs::PermissionsExt;
429
430 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
431 Ok(())
432}
433
434#[cfg(not(unix))]
435fn set_private_directory(_path: &Path) -> Result<()> {
436 Ok(())
437}
438
439impl Default for CodexRuntimeBackend {
440 fn default() -> Self {
441 Self::new()
442 }
443}
444
445impl CodexRuntimeBackend {
446 pub fn new() -> Self {
448 Self {
449 launch: RuntimeLaunch {
450 program: "codex".into(),
451 arguments: vec!["app-server".into()],
452 env: BTreeMap::new(),
453 },
454 }
455 }
456
457 pub fn with_launch(launch: RuntimeLaunch) -> Self {
459 Self { launch }
460 }
461
462 async fn connect(
463 &self,
464 launch: Option<RuntimeLaunch>,
465 runtime_id: Option<&str>,
466 ) -> Result<(
467 Arc<JsonLineClient>,
468 mpsc::UnboundedReceiver<Value>,
469 RuntimeEndpoint,
470 Option<CodexRuntimeHome>,
471 )> {
472 let mut launch = launch.unwrap_or_else(|| self.launch.clone());
473 let runtime_home = if is_stock_codex_launch(&launch) {
474 Some(CodexRuntimeHome::prepare(&mut launch, runtime_id)?)
475 } else {
476 None
477 };
478 let (client, receiver, endpoint) =
479 JsonLineClient::spawn(&launch, None, false, "codex-app-server-jsonl").await?;
480 tokio::time::timeout(
481 CODEX_STARTUP_TIMEOUT,
482 client.request(
483 "initialize",
484 json!({
485 "clientInfo": {
486 "name": "supercode",
487 "title": "Supercode",
488 "version": env!("CARGO_PKG_VERSION"),
489 }
490 }),
491 ),
492 )
493 .await
494 .map_err(|_| Error::Other("Codex app-server initialize timed out after 10s".into()))??;
495 client.notify("initialized", json!({})).await?;
496 Ok((client, receiver, endpoint, runtime_home))
497 }
498
499 async fn open_thread(
500 &self,
501 method: &str,
502 params: Value,
503 launch: Option<RuntimeLaunch>,
504 runtime_id: Option<&str>,
505 ) -> Result<Box<dyn RuntimeConnection>> {
506 let (client, receiver, endpoint, runtime_home) = self.connect(launch, runtime_id).await?;
507 let response = tokio::time::timeout(CODEX_STARTUP_TIMEOUT, client.request(method, params))
508 .await
509 .map_err(|_| Error::Other(format!("Codex {method} timed out after 10s")))??;
510 let thread_id = response
511 .pointer("/thread/id")
512 .and_then(Value::as_str)
513 .ok_or_else(|| Error::Other(format!("Codex {method} response omitted thread.id")))?
514 .to_string();
515 let unpublished_rollout = if method == "thread/start" {
516 runtime_home
517 .as_ref()
518 .map(|home| home.started_rollout_path(&response))
519 .transpose()?
520 } else {
521 None
522 };
523 Ok(Box::new(CodexRuntimeConnection {
524 handle: RuntimeHandle {
525 harness: HarnessId::from(HarnessId::CODEX),
526 runtime_id: thread_id,
527 endpoint,
528 },
529 client,
530 receiver,
531 active_turn: None,
532 runtime_home,
533 unpublished_rollout,
534 }))
535 }
536}
537
538#[async_trait]
539impl RuntimeBackend for CodexRuntimeBackend {
540 fn harness(&self) -> HarnessId {
541 HarnessId::from(HarnessId::CODEX)
542 }
543
544 fn capabilities(&self) -> RuntimeCapabilities {
545 RuntimeCapabilities {
546 start_session: true,
547 resume_session: true,
548 attach_existing_process: false,
552 send_input: true,
553 stream_events: true,
554 interrupt: true,
555 respond_to_requests: true,
556 }
557 }
558
559 async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
560 self.open_thread(
561 "thread/start",
562 json!({"cwd": request.cwd}),
563 request.launch,
564 None,
565 )
566 .await
567 }
568
569 async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
570 let mut params = json!({"threadId": request.runtime_id});
571 if let Some(cwd) = request.cwd {
572 params["cwd"] = json!(cwd);
573 }
574 let runtime_id = request.runtime_id.clone();
575 self.open_thread("thread/resume", params, request.launch, Some(&runtime_id))
576 .await
577 }
578}
579
580struct CodexRuntimeConnection {
581 handle: RuntimeHandle,
582 client: Arc<JsonLineClient>,
583 receiver: mpsc::UnboundedReceiver<Value>,
584 active_turn: Option<String>,
585 runtime_home: Option<CodexRuntimeHome>,
586 unpublished_rollout: Option<PathBuf>,
587}
588
589#[async_trait]
590impl RuntimeConnection for CodexRuntimeConnection {
591 fn handle(&self) -> &RuntimeHandle {
592 &self.handle
593 }
594
595 async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
596 let response = self
597 .client
598 .request(
599 "turn/start",
600 json!({
601 "threadId": self.handle.runtime_id,
602 "input": [{"type": "text", "text": input.text}],
603 }),
604 )
605 .await?;
606 let turn_id = response
607 .pointer("/turn/id")
608 .and_then(Value::as_str)
609 .map(str::to_owned);
610 if let (Some(home), Some(path)) = (
611 self.runtime_home.as_ref(),
612 self.unpublished_rollout.as_ref(),
613 ) {
614 home.publish_rollout(path).await?;
615 self.unpublished_rollout = None;
616 }
617 self.active_turn = turn_id.clone();
618 Ok(turn_id)
619 }
620
621 async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
622 let Some(payload) = self.receiver.recv().await else {
623 return Ok(None);
624 };
625 let kind = payload
626 .get("method")
627 .and_then(Value::as_str)
628 .map(str::to_owned)
629 .unwrap_or_else(|| "protocol".into());
630 if kind == "turn/completed" {
631 self.active_turn = None;
632 }
633 Ok(Some(HarnessEvent {
634 sequence: None,
635 kind,
636 payload,
637 }))
638 }
639
640 async fn interrupt(&mut self) -> Result<()> {
641 let Some(turn_id) = self.active_turn.as_ref() else {
642 return Err(Error::Other("Codex has no active turn to interrupt".into()));
643 };
644 self.client
645 .request(
646 "turn/interrupt",
647 json!({"threadId": self.handle.runtime_id, "turnId": turn_id}),
648 )
649 .await?;
650 Ok(())
651 }
652
653 async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
654 self.client.respond(request_id, response).await
655 }
656
657 async fn close(&mut self) -> Result<()> {
658 self.client.close().await?;
659 if let Some(home) = self.runtime_home.take() {
660 home.cleanup()?;
661 }
662 Ok(())
663 }
664}
665
666type PendingResponse = oneshot::Sender<std::result::Result<Value, String>>;
667type PendingResponses = Arc<Mutex<HashMap<u64, PendingResponse>>>;
668
669pub(super) struct JsonLineClient {
670 stdin: Mutex<ChildStdin>,
671 child: Mutex<Child>,
672 pending: PendingResponses,
673 next_id: Mutex<u64>,
674 include_jsonrpc: bool,
675 events: mpsc::UnboundedSender<Value>,
676 process_group: Option<u32>,
677}
678
679impl JsonLineClient {
680 pub(super) async fn spawn(
681 launch: &RuntimeLaunch,
682 cwd: Option<&std::path::Path>,
683 include_jsonrpc: bool,
684 protocol: &str,
685 ) -> Result<(Arc<Self>, mpsc::UnboundedReceiver<Value>, RuntimeEndpoint)> {
686 let mut command = Command::new(&launch.program);
687 command
688 .args(&launch.arguments)
689 .envs(&launch.env)
690 .stdin(Stdio::piped())
691 .stdout(Stdio::piped())
692 .stderr(Stdio::piped())
693 .kill_on_drop(true);
694 #[cfg(unix)]
698 command.process_group(0);
699 if let Some(cwd) = cwd {
700 command.current_dir(cwd);
701 }
702 let mut child = command.spawn().map_err(|error| {
703 Error::Other(format!("could not launch {}: {error}", launch.program))
704 })?;
705 let pid = child.id();
706 let stdin = child
707 .stdin
708 .take()
709 .ok_or_else(|| Error::Other("runtime child has no stdin".into()))?;
710 let stdout = child
711 .stdout
712 .take()
713 .ok_or_else(|| Error::Other("runtime child has no stdout".into()))?;
714 let stderr = child
715 .stderr
716 .take()
717 .ok_or_else(|| Error::Other("runtime child has no stderr".into()))?;
718 let pending: PendingResponses = Arc::new(Mutex::new(HashMap::new()));
719 let (events_tx, events_rx) = mpsc::unbounded_channel();
720 let reader_events = events_tx.clone();
721 let reader_pending = pending.clone();
722 tokio::spawn(async move {
723 let mut stdout_lines = BufReader::new(stdout).lines();
724 let mut stderr_lines = BufReader::new(stderr).lines();
725 let mut stdout_open = true;
726 let mut stderr_open = true;
727 while stdout_open || stderr_open {
728 tokio::select! {
729 line = stdout_lines.next_line(), if stdout_open => match line {
730 Ok(Some(line)) => {
731 let Ok(value) = serde_json::from_str::<Value>(&line) else {
732 let _ = reader_events.send(json!({"type": "malformed_output", "line": line}));
733 continue;
734 };
735 let response_id = value.get("id").and_then(Value::as_u64);
736 let is_response = value.get("result").is_some() || value.get("error").is_some();
737 if let Some(id) = response_id.filter(|_| is_response) {
738 if let Some(sender) = reader_pending.lock().await.remove(&id) {
739 let result = if let Some(error) = value.get("error") {
740 Err(error.to_string())
741 } else {
742 Ok(value.get("result").cloned().unwrap_or(Value::Null))
743 };
744 let _ = sender.send(result);
745 continue;
746 }
747 }
748 let _ = reader_events.send(value);
749 }
750 Ok(None) => stdout_open = false,
751 Err(error) => {
752 let _ = reader_events.send(json!({"type": "transport_error", "message": error.to_string()}));
753 stdout_open = false;
754 }
755 },
756 line = stderr_lines.next_line(), if stderr_open => match line {
757 Ok(Some(line)) => {
758 let _ = reader_events.send(json!({"type": "transport_stderr", "line": line}));
759 }
760 Ok(None) => stderr_open = false,
761 Err(error) => {
762 let _ = reader_events.send(json!({"type": "transport_error", "message": error.to_string()}));
763 stderr_open = false;
764 }
765 }
766 }
767 }
768 let _ = reader_events.send(json!({"type": "transport_closed"}));
769 let mut pending = reader_pending.lock().await;
770 for (_, sender) in pending.drain() {
771 let _ = sender.send(Err("runtime protocol closed".into()));
772 }
773 });
774 let endpoint = RuntimeEndpoint::LocalProcess {
775 pid,
776 command: std::iter::once(launch.program.clone())
777 .chain(launch.arguments.iter().cloned())
778 .collect(),
779 protocol: protocol.into(),
780 };
781 Ok((
782 Arc::new(Self {
783 stdin: Mutex::new(stdin),
784 child: Mutex::new(child),
785 pending,
786 next_id: Mutex::new(1),
787 include_jsonrpc,
788 events: events_tx,
789 process_group: pid,
790 }),
791 events_rx,
792 endpoint,
793 ))
794 }
795
796 pub(super) async fn request(&self, method: &str, params: Value) -> Result<Value> {
797 let (_id, rx) = self.begin_request(method, params).await?;
798 rx.await
799 .map_err(|_| Error::Other("runtime response channel closed".into()))?
800 .map_err(|message| {
801 Error::Other(format!("runtime request `{method}` failed: {message}"))
802 })
803 }
804
805 pub(super) async fn begin_request(
806 &self,
807 method: &str,
808 params: Value,
809 ) -> Result<(u64, oneshot::Receiver<std::result::Result<Value, String>>)> {
810 let id = {
811 let mut next = self.next_id.lock().await;
812 let id = *next;
813 *next += 1;
814 id
815 };
816 let (tx, rx) = oneshot::channel();
817 self.pending.lock().await.insert(id, tx);
818 let mut request = json!({"id": id, "method": method, "params": params});
819 if self.include_jsonrpc {
820 request["jsonrpc"] = json!("2.0");
821 }
822 if let Err(error) = self.write(&request).await {
823 self.pending.lock().await.remove(&id);
824 return Err(error);
825 }
826 Ok((id, rx))
827 }
828
829 pub(super) async fn notify(&self, method: &str, params: Value) -> Result<()> {
830 let mut notification = json!({"method": method, "params": params});
831 if self.include_jsonrpc {
832 notification["jsonrpc"] = json!("2.0");
833 }
834 self.write(¬ification).await
835 }
836
837 pub(super) async fn respond(&self, id: Value, result: Value) -> Result<()> {
838 let mut response = json!({"id": id, "result": result});
839 if self.include_jsonrpc {
840 response["jsonrpc"] = json!("2.0");
841 }
842 self.write(&response).await
843 }
844
845 async fn write(&self, value: &Value) -> Result<()> {
846 let mut stdin = self.stdin.lock().await;
847 stdin.write_all(value.to_string().as_bytes()).await?;
848 stdin.write_all(b"\n").await?;
849 stdin.flush().await?;
850 Ok(())
851 }
852
853 pub(super) fn emit(&self, value: Value) {
854 let _ = self.events.send(value);
855 }
856
857 pub(super) async fn close(&self) -> Result<()> {
858 let mut child = self.child.lock().await;
859 #[cfg(unix)]
860 if let Some(pid) = self.process_group {
861 crate::lsp::kill_process_group(pid);
862 tokio::time::timeout(Duration::from_secs(3), child.wait())
863 .await
864 .map_err(|_| Error::Other("timed out reaping runtime process group".into()))??;
865 return Ok(());
866 }
867 #[cfg(not(unix))]
868 if child.try_wait()?.is_none() {
869 child.kill().await?;
870 }
871 Ok(())
872 }
873}
874
875#[cfg(test)]
876mod tests {
877 use super::*;
878
879 #[test]
880 fn codex_capabilities_do_not_claim_arbitrary_process_attach() {
881 let capabilities = CodexRuntimeBackend::new().capabilities();
882 assert!(capabilities.start_session);
883 assert!(capabilities.resume_session);
884 assert!(!capabilities.attach_existing_process);
885 assert!(capabilities.send_input);
886 assert!(capabilities.stream_events);
887 assert!(capabilities.interrupt);
888 }
889
890 #[test]
891 fn runtime_handle_is_language_neutral_json() {
892 let handle = RuntimeHandle {
893 harness: HarnessId::from(HarnessId::CODEX),
894 runtime_id: "thread-1".into(),
895 endpoint: RuntimeEndpoint::LocalProcess {
896 pid: Some(42),
897 command: vec!["codex".into(), "app-server".into()],
898 protocol: "codex-app-server-jsonl".into(),
899 },
900 };
901 let encoded = serde_json::to_string(&handle).unwrap();
902 assert_eq!(
903 serde_json::from_str::<RuntimeHandle>(&encoded).unwrap(),
904 handle
905 );
906 }
907
908 #[cfg(unix)]
909 #[tokio::test]
910 async fn codex_adapter_performs_handshake_start_and_turn() {
911 let script = r#"
912 i=0
913 while IFS= read -r line; do
914 i=$((i + 1))
915 case "$i" in
916 1) printf '%s\n' '{"id":1,"result":{"userAgent":"mock"}}' ;;
917 2) ;;
918 3) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thr_mock"}}}' ;;
919 4)
920 printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn_mock"}}}'
921 printf '%s\n' '{"method":"turn/started","params":{"turn":{"id":"turn_mock"}}}'
922 ;;
923 esac
924 done
925 "#;
926 let backend = CodexRuntimeBackend::with_launch(RuntimeLaunch {
927 program: "/bin/sh".into(),
928 arguments: vec!["-c".into(), script.into()],
929 env: BTreeMap::new(),
930 });
931 let mut connection = backend
932 .start(RuntimeStartRequest {
933 cwd: std::env::current_dir().unwrap(),
934 launch: None,
935 })
936 .await
937 .unwrap();
938 assert_eq!(connection.handle().runtime_id, "thr_mock");
939 assert_eq!(
940 connection
941 .send_input(RuntimeInput { text: "hi".into() })
942 .await
943 .unwrap()
944 .as_deref(),
945 Some("turn_mock")
946 );
947 assert_eq!(
948 connection.next_event().await.unwrap().unwrap().kind,
949 "turn/started"
950 );
951 connection.close().await.unwrap();
952 }
953}