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