Skip to main content

supercode_harness/
runtime.rs

1//! Primitive live-runtime contracts and the Codex app-server reference adapter.
2//!
3//! These APIs control harness-native sessions; they do not emulate terminal
4//! keystrokes and do not claim to attach to an arbitrary already-running TUI.
5
6use 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/// Mechanical facts an adapter can guarantee.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct RuntimeCapabilities {
36    /// Can create a fresh harness-native session.
37    pub start_session: bool,
38    /// Can resume a harness-native persisted session by id.
39    pub resume_session: bool,
40    /// Can join an arbitrary already-running harness process.
41    pub attach_existing_process: bool,
42    /// Can send user input through a structured protocol.
43    pub send_input: bool,
44    /// Can receive structured live events.
45    pub stream_events: bool,
46    /// Can interrupt an in-flight turn.
47    pub interrupt: bool,
48    /// Can answer protocol requests such as approvals or elicitation.
49    pub respond_to_requests: bool,
50}
51
52/// Executable configuration used to launch one adapter endpoint.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct RuntimeLaunch {
55    /// Executable name or path.
56    pub program: String,
57    /// Arguments passed before adapter-generated protocol arguments.
58    pub arguments: Vec<String>,
59    /// Extra environment variables.
60    pub env: BTreeMap<String, String>,
61}
62
63/// Request to create a fresh runtime session.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct RuntimeStartRequest {
66    /// Project working directory.
67    pub cwd: PathBuf,
68    /// Optional executable override, primarily for alternate installs/tests.
69    pub launch: Option<RuntimeLaunch>,
70}
71
72/// Request to resume or attach through a new adapter connection.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct RuntimeAttachRequest {
75    /// Harness-native session/thread id.
76    pub runtime_id: String,
77    /// Optional cwd override accepted by the harness protocol.
78    pub cwd: Option<PathBuf>,
79    /// Optional executable override.
80    pub launch: Option<RuntimeLaunch>,
81}
82
83/// Observable endpoint backing a runtime connection.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(tag = "kind", rename_all = "snake_case")]
86pub enum RuntimeEndpoint {
87    /// Child process owned by this connection.
88    LocalProcess {
89        /// Process id when available.
90        pid: Option<u32>,
91        /// Executable plus arguments.
92        command: Vec<String>,
93        /// Native protocol spoken over stdio.
94        protocol: String,
95    },
96    /// Existing HTTP service.
97    Http {
98        /// Service base URL.
99        base_url: String,
100        /// Native protocol name.
101        protocol: String,
102    },
103}
104
105/// Identity returned after a live session is started or resumed.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct RuntimeHandle {
108    /// Runtime adapter/harness.
109    pub harness: HarnessId,
110    /// Harness-native live session identity.
111    pub runtime_id: String,
112    /// Concrete endpoint used by this connection.
113    pub endpoint: RuntimeEndpoint,
114}
115
116/// User input accepted by a live runtime.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct RuntimeInput {
119    /// Plain text prompt or steering instruction.
120    pub text: String,
121    /// Runtime-resolved image URLs or `data:image/...;base64,...` payloads.
122    ///
123    /// Adapters must either preserve these as native multimodal input or
124    /// reject the turn explicitly; they must never flatten image bytes into
125    /// the text prompt.
126    #[serde(default, skip_serializing_if = "Vec::is_empty")]
127    pub image_urls: Vec<String>,
128}
129
130/// Protocol-neutral envelope around a native live event.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct HarnessEvent {
133    /// Canonical SDK sequence when the event originated from an SDK runtime.
134    /// Native harness adapters leave this absent and the service sequences
135    /// their transport stream locally.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub sequence: Option<u64>,
138    /// Native method/type name, or `request` for a server-initiated request.
139    pub kind: String,
140    /// Lossless native event/request value.
141    pub payload: Value,
142}
143
144/// One connected harness-native runtime session.
145#[async_trait]
146pub trait RuntimeConnection: Send {
147    /// Identity and endpoint of this connection.
148    fn handle(&self) -> &RuntimeHandle;
149    /// Submit structured user input and return the harness-native turn id when
150    /// one is allocated.
151    async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>>;
152    /// Wait for the next native live event.
153    async fn next_event(&mut self) -> Result<Option<HarnessEvent>>;
154    /// Interrupt the current turn, when supported.
155    async fn interrupt(&mut self) -> Result<()>;
156    /// Answer a server-initiated protocol request by its native JSON id.
157    async fn respond(&mut self, request_id: Value, response: Value) -> Result<()>;
158    /// Close the adapter-owned transport/process.
159    async fn close(&mut self) -> Result<()>;
160}
161
162/// Factory for starting, resuming, and (where the native protocol permits it)
163/// joining one harness's already-running runtime endpoint.
164#[async_trait]
165pub trait RuntimeBackend: Send + Sync {
166    /// Harness implemented by this backend.
167    fn harness(&self) -> HarnessId;
168    /// Honest mechanical capability report.
169    fn capabilities(&self) -> RuntimeCapabilities;
170    /// Create a fresh harness-native session.
171    async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>>;
172    /// Resume a persisted harness-native session through a new protocol
173    /// connection. This does not imply joining the process that originally
174    /// wrote the session.
175    async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>>;
176    /// Join an already-running harness process or server. Most stock harnesses
177    /// cannot do this; adapters must opt in rather than silently treating a
178    /// persisted resume as a live attach.
179    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/// Codex live-runtime backend using the official `codex app-server` JSONL
191/// protocol (`initialize`, `thread/start|resume`, `turn/start|interrupt`).
192#[derive(Debug, Clone)]
193pub struct CodexRuntimeBackend {
194    launch: RuntimeLaunch,
195}
196
197const CODEX_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
198
199/// A stock Codex app-server eagerly indexes everything below `CODEX_HOME`
200/// before answering `initialize`. That turns a runtime open into an unbounded
201/// corpus scan for long-time Codex users. Give each connection a private state
202/// database and project only the one native rollout it needs into that home.
203/// The rollout itself is hard-linked, so Codex continues the original inode
204/// rather than a copy that would need lossy reconciliation later.
205#[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    /// Use `codex app-server` from `PATH`.
454    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    /// Use an explicit command prefix.
465    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            // A new app-server can resume the same stored thread, but stock
556            // Codex does not let it join an arbitrary already-running TUI's
557            // transport/event fanout.
558            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        // Package-manager shims commonly spawn a native worker. Isolate the
712        // complete adapter tree so close can reap it instead of orphaning the
713        // worker with inherited protocol handles.
714        #[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(&notification).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}