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