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)]
73pub struct RuntimeConnectLaunch {
74 pub config_path: String,
77 pub address_pointer: String,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub port_pointer: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub default_address: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub auth_pointer: Option<String>,
94 pub protocol: String,
96}
97
98#[derive(Clone, PartialEq, Eq)]
100pub struct BearerToken(String);
101
102impl BearerToken {
103 pub fn new(secret: impl Into<String>) -> Self {
105 Self(secret.into())
106 }
107
108 pub fn secret(&self) -> &str {
110 &self.0
111 }
112}
113
114impl std::fmt::Debug for BearerToken {
115 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 formatter.write_str("BearerToken(<redacted>)")
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct ResolvedRuntimeConnection {
123 pub address: String,
125 pub auth: Option<BearerToken>,
127}
128
129impl RuntimeConnectLaunch {
130 pub fn resolve(&self, home: &Path) -> Result<ResolvedRuntimeConnection> {
135 let path = match self.config_path.strip_prefix("~/") {
136 Some(rest) => home.join(rest),
137 None => PathBuf::from(&self.config_path),
138 };
139 let config: Value = match std::fs::read_to_string(&path) {
142 Ok(raw) => serde_json::from_str(&raw).map_err(|_| {
143 Error::Other(format!(
144 "connect-mode config {} is not valid JSON",
145 path.display()
146 ))
147 })?,
148 Err(error) => {
149 if self.default_address.is_some() {
150 Value::Object(Default::default())
151 } else {
152 return Err(Error::Other(format!(
153 "connect-mode config {} is unreadable: {error}",
154 path.display()
155 )));
156 }
157 }
158 };
159 let field = |pointer: &str, name: &str| -> Result<String> {
160 match config.pointer(pointer).and_then(Value::as_str) {
161 Some(value) if !value.trim().is_empty() => Ok(value.trim().to_string()),
162 _ => Err(Error::Other(format!(
163 "connect-mode {name} pointer `{pointer}` does not name a non-empty string in {}",
164 path.display()
165 ))),
166 }
167 };
168 let address = match config
171 .pointer(&self.address_pointer)
172 .and_then(Value::as_str)
173 {
174 Some(value) if !value.trim().is_empty() => value.trim().to_string(),
175 _ => {
176 let from_port = self
177 .port_pointer
178 .as_deref()
179 .and_then(|pointer| config.pointer(pointer))
180 .and_then(Value::as_u64)
181 .map(|port| {
182 let scheme = self
183 .default_address
184 .as_deref()
185 .and_then(|address| address.split_once("://"))
186 .map(|(scheme, _)| scheme)
187 .unwrap_or("ws");
188 format!("{scheme}://127.0.0.1:{port}")
189 });
190 match from_port.or_else(|| self.default_address.clone()) {
191 Some(address) => address,
192 None => {
193 return Err(Error::Other(format!(
194 "connect-mode address pointer `{}` does not name a non-empty string in {}",
195 self.address_pointer,
196 path.display()
197 )));
198 }
199 }
200 }
201 };
202 let mut address = address.trim_end_matches('/').to_string();
203 if !address.contains("://") {
207 let scheme = self
208 .default_address
209 .as_deref()
210 .and_then(|default| default.split_once("://"))
211 .map(|(scheme, _)| scheme)
212 .unwrap_or("ws");
213 address = format!("{scheme}://{address}");
214 }
215 let auth = match &self.auth_pointer {
219 Some(pointer) => match config.pointer(pointer).and_then(Value::as_str) {
220 Some(value) if !value.trim().is_empty() => {
221 Some(BearerToken::new(value.trim().to_string()))
222 }
223 _ if self.default_address.is_some() => None,
224 _ => Some(BearerToken::new(field(pointer, "auth")?)),
225 },
226 None => None,
227 };
228 Ok(ResolvedRuntimeConnection { address, auth })
229 }
230}
231
232#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
236pub struct McpServerLaunch {
237 pub name: String,
239 pub command: String,
241 #[serde(default)]
243 pub arguments: Vec<String>,
244 #[serde(default)]
246 pub env: BTreeMap<String, String>,
247}
248
249#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
251pub struct RuntimeStartRequest {
252 pub cwd: PathBuf,
254 pub launch: Option<RuntimeLaunch>,
256 #[serde(default)]
260 pub mcp_servers: Vec<McpServerLaunch>,
261}
262
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265pub struct RuntimeAttachRequest {
266 pub runtime_id: String,
268 pub cwd: Option<PathBuf>,
270 pub launch: Option<RuntimeLaunch>,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(tag = "kind", rename_all = "snake_case")]
277pub enum RuntimeEndpoint {
278 LocalProcess {
280 pid: Option<u32>,
282 command: Vec<String>,
284 protocol: String,
286 },
287 Http {
289 base_url: String,
291 protocol: String,
293 },
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub struct RuntimeHandle {
299 pub harness: HarnessId,
301 pub runtime_id: String,
303 pub endpoint: RuntimeEndpoint,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309pub struct RuntimeInput {
310 pub text: String,
312 #[serde(default, skip_serializing_if = "Vec::is_empty")]
318 pub image_urls: Vec<String>,
319}
320
321#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
323pub struct HarnessEvent {
324 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub sequence: Option<u64>,
329 pub kind: String,
331 pub payload: Value,
333}
334
335#[async_trait]
337pub trait RuntimeConnection: Send {
338 fn handle(&self) -> &RuntimeHandle;
340 async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>>;
343 async fn next_event(&mut self) -> Result<Option<HarnessEvent>>;
345 async fn interrupt(&mut self) -> Result<()>;
347 async fn steer(&mut self, _text: String) -> Result<()> {
349 Err(Error::Other(
350 "this runtime cannot steer an active turn".into(),
351 ))
352 }
353 async fn respond(&mut self, request_id: Value, response: Value) -> Result<()>;
355 async fn close(&mut self) -> Result<()>;
357}
358
359#[async_trait]
362pub trait RuntimeBackend: Send + Sync {
363 fn harness(&self) -> HarnessId;
365 fn capabilities(&self) -> RuntimeCapabilities;
367 async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>>;
369 async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>>;
373 async fn attach_existing(
377 &self,
378 _request: RuntimeAttachRequest,
379 ) -> Result<Box<dyn RuntimeConnection>> {
380 Err(Error::Other(format!(
381 "{} cannot attach to an already-running process",
382 self.harness().as_str()
383 )))
384 }
385}
386
387#[derive(Debug, Clone)]
390pub struct CodexRuntimeBackend {
391 launch: RuntimeLaunch,
392}
393
394const CODEX_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
395
396#[derive(Debug)]
403struct CodexRuntimeHome {
404 root: PathBuf,
405 native_home: PathBuf,
406}
407
408impl CodexRuntimeHome {
409 fn prepare(launch: &mut RuntimeLaunch, runtime_id: Option<&str>) -> Result<Self> {
410 let native_home = codex_native_home(launch)?;
411 let root = supercode_runtime_root()
412 .join("codex")
413 .join(generated_session_id());
414 std::fs::create_dir_all(&root).map_err(|error| {
415 Error::Other(format!(
416 "could not create isolated Codex runtime home {}: {error}",
417 root.display()
418 ))
419 })?;
420 set_private_directory(&root)?;
421 let root = std::fs::canonicalize(&root)?;
422
423 for entry in [
424 "auth.json",
425 "config.toml",
426 "hooks.json",
427 "models_cache.json",
428 "installation_id",
429 ".personality_migration",
430 ".sandbox_migration",
431 "cache",
432 "generated_images",
433 "mcp-oauth-locks",
434 "memories",
435 "plugins",
436 "rules",
437 "shell_snapshots",
438 "skills",
439 "thread-writer-locks",
440 ] {
441 link_runtime_resource(&native_home.join(entry), &root.join(entry))?;
442 }
443
444 if let Some(runtime_id) = runtime_id {
445 let source = find_codex_rollout(&native_home.join("sessions"), runtime_id)?
446 .ok_or_else(|| {
447 Error::Other(format!(
448 "could not find Codex rollout `{runtime_id}` below {}",
449 native_home.join("sessions").display()
450 ))
451 })?;
452 let relative = source.strip_prefix(&native_home).map_err(|_| {
453 Error::Other(format!(
454 "Codex rollout {} is outside native home {}",
455 source.display(),
456 native_home.display()
457 ))
458 })?;
459 let projected = root.join(relative);
460 if let Some(parent) = projected.parent() {
461 std::fs::create_dir_all(parent)?;
462 }
463 std::fs::hard_link(&source, &projected).map_err(|error| {
464 Error::Other(format!(
465 "could not project Codex rollout {} into isolated runtime home: {error}",
466 source.display()
467 ))
468 })?;
469 }
470
471 launch
472 .env
473 .insert("CODEX_HOME".into(), root.to_string_lossy().into_owned());
474 Ok(Self { root, native_home })
475 }
476
477 fn started_rollout_path(&self, response: &Value) -> Result<PathBuf> {
478 let path = response
479 .pointer("/thread/path")
480 .and_then(Value::as_str)
481 .map(PathBuf::from)
482 .ok_or_else(|| {
483 Error::Other("Codex thread/start response omitted thread.path".into())
484 })?;
485 let relative = path.strip_prefix(&self.root).map_err(|_| {
486 Error::Other(format!(
487 "Codex created rollout {} outside isolated runtime home {}",
488 path.display(),
489 self.root.display()
490 ))
491 })?;
492 if !relative.starts_with("sessions") {
493 return Err(Error::Other(format!(
494 "Codex created non-session rollout {}",
495 path.display()
496 )));
497 }
498 Ok(path)
499 }
500
501 async fn publish_rollout(&self, path: &Path) -> Result<()> {
502 let relative = path.strip_prefix(&self.root).map_err(|_| {
503 Error::Other(format!(
504 "Codex created rollout {} outside isolated runtime home {}",
505 path.display(),
506 self.root.display()
507 ))
508 })?;
509 let publish_deadline = tokio::time::Instant::now() + Duration::from_secs(2);
510 while !path.is_file() {
511 if tokio::time::Instant::now() >= publish_deadline {
512 return Err(Error::Other(format!(
513 "Codex did not create promised rollout {} within 2s",
514 path.display()
515 )));
516 }
517 tokio::time::sleep(Duration::from_millis(10)).await;
518 }
519 let native = self.native_home.join(relative);
520 if let Some(parent) = native.parent() {
521 std::fs::create_dir_all(parent)?;
522 }
523 std::fs::hard_link(path, &native).map_err(|error| {
524 Error::Other(format!(
525 "could not publish Codex rollout {} to native home: {error}",
526 path.display()
527 ))
528 })
529 }
530
531 fn cleanup(&self) -> Result<()> {
532 match std::fs::remove_dir_all(&self.root) {
533 Ok(()) => Ok(()),
534 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
535 Err(error) => Err(Error::Other(format!(
536 "could not clean isolated Codex runtime home {}: {error}",
537 self.root.display()
538 ))),
539 }
540 }
541}
542
543impl Drop for CodexRuntimeHome {
544 fn drop(&mut self) {
545 let _ = self.cleanup();
546 }
547}
548
549const STDERR_TAIL_LINES: usize = 20;
551const STDERR_TAIL_CHARACTERS: usize = 2_000;
552
553fn closed_reason(recent_stderr: &std::collections::VecDeque<String>) -> String {
555 if recent_stderr.is_empty() {
556 return "runtime protocol closed".into();
557 }
558 let mut tail = recent_stderr
559 .iter()
560 .map(String::as_str)
561 .collect::<Vec<_>>()
562 .join(" | ");
563 if tail.chars().count() > STDERR_TAIL_CHARACTERS {
564 tail = tail
565 .chars()
566 .take(STDERR_TAIL_CHARACTERS)
567 .collect::<String>()
568 + "…";
569 }
570 format!("runtime protocol closed: {tail}")
571}
572
573fn is_stock_codex_launch(launch: &RuntimeLaunch) -> bool {
574 launch
575 .arguments
576 .iter()
577 .any(|argument| argument == "app-server")
578 && Path::new(&launch.program)
579 .file_name()
580 .and_then(|name| name.to_str())
581 .is_some_and(|name| name == "codex" || name == "codex.exe")
582}
583
584fn codex_native_home(launch: &RuntimeLaunch) -> Result<PathBuf> {
585 launch
586 .env
587 .get("CODEX_HOME")
588 .map(PathBuf::from)
589 .or_else(|| std::env::var_os("CODEX_HOME").map(PathBuf::from))
590 .or_else(|| {
591 std::env::var_os("HOME")
592 .map(PathBuf::from)
593 .map(|home| home.join(".codex"))
594 })
595 .ok_or_else(|| Error::Other("Codex runtime requires CODEX_HOME or HOME".into()))
596}
597
598fn supercode_runtime_root() -> PathBuf {
599 std::env::var_os("SUPERCODE_HOME")
600 .map(PathBuf::from)
601 .or_else(|| {
602 std::env::var_os("HOME")
603 .map(PathBuf::from)
604 .map(|home| home.join(".supercode"))
605 })
606 .unwrap_or_else(|| std::env::temp_dir().join("supercode"))
607 .join("runtime-homes")
608}
609
610fn find_codex_rollout(root: &Path, runtime_id: &str) -> Result<Option<PathBuf>> {
611 let entries = match std::fs::read_dir(root) {
612 Ok(entries) => entries,
613 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
614 Err(error) => return Err(error.into()),
615 };
616 let expected_suffix = format!("-{runtime_id}.jsonl");
617 for entry in entries {
618 let entry = entry?;
619 let kind = entry.file_type()?;
620 if kind.is_dir() {
621 if let Some(path) = find_codex_rollout(&entry.path(), runtime_id)? {
622 return Ok(Some(path));
623 }
624 } else if kind.is_file()
625 && entry
626 .file_name()
627 .to_str()
628 .is_some_and(|name| name.ends_with(&expected_suffix))
629 {
630 return Ok(Some(entry.path()));
631 }
632 }
633 Ok(None)
634}
635
636#[cfg(unix)]
637fn link_runtime_resource(source: &Path, target: &Path) -> Result<()> {
638 use std::os::unix::fs::symlink;
639
640 if source.exists() {
641 symlink(source, target)?;
642 }
643 Ok(())
644}
645
646#[cfg(not(unix))]
647fn link_runtime_resource(source: &Path, target: &Path) -> Result<()> {
648 if source.is_file() {
649 std::fs::copy(source, target)?;
650 }
651 Ok(())
652}
653
654#[cfg(unix)]
655fn set_private_directory(path: &Path) -> Result<()> {
656 use std::os::unix::fs::PermissionsExt;
657
658 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
659 Ok(())
660}
661
662#[cfg(not(unix))]
663fn set_private_directory(_path: &Path) -> Result<()> {
664 Ok(())
665}
666
667impl Default for CodexRuntimeBackend {
668 fn default() -> Self {
669 Self::new()
670 }
671}
672
673impl CodexRuntimeBackend {
674 pub fn new() -> Self {
676 Self {
677 launch: RuntimeLaunch {
678 program: "codex".into(),
679 arguments: vec!["app-server".into()],
680 env: BTreeMap::new(),
681 },
682 }
683 }
684
685 pub fn with_launch(launch: RuntimeLaunch) -> Self {
687 Self { launch }
688 }
689
690 async fn connect(
691 &self,
692 launch: Option<RuntimeLaunch>,
693 runtime_id: Option<&str>,
694 ) -> Result<(
695 Arc<JsonLineClient>,
696 mpsc::UnboundedReceiver<Value>,
697 RuntimeEndpoint,
698 Option<CodexRuntimeHome>,
699 )> {
700 let mut launch = launch.unwrap_or_else(|| self.launch.clone());
701 let runtime_home = if is_stock_codex_launch(&launch) {
702 Some(CodexRuntimeHome::prepare(&mut launch, runtime_id)?)
703 } else {
704 None
705 };
706 let (client, receiver, endpoint) =
707 JsonLineClient::spawn(&launch, None, false, "codex-app-server-jsonl").await?;
708 tokio::time::timeout(
709 CODEX_STARTUP_TIMEOUT,
710 client.request(
711 "initialize",
712 json!({
713 "clientInfo": {
714 "name": "supercode",
715 "title": "Supercode",
716 "version": env!("CARGO_PKG_VERSION"),
717 }
718 }),
719 ),
720 )
721 .await
722 .map_err(|_| Error::Other("Codex app-server initialize timed out after 10s".into()))??;
723 client.notify("initialized", json!({})).await?;
724 Ok((client, receiver, endpoint, runtime_home))
725 }
726
727 async fn open_thread(
728 &self,
729 method: &str,
730 params: Value,
731 launch: Option<RuntimeLaunch>,
732 runtime_id: Option<&str>,
733 ) -> Result<Box<dyn RuntimeConnection>> {
734 let (client, receiver, endpoint, runtime_home) = self.connect(launch, runtime_id).await?;
735 let response = tokio::time::timeout(CODEX_STARTUP_TIMEOUT, client.request(method, params))
736 .await
737 .map_err(|_| Error::Other(format!("Codex {method} timed out after 10s")))??;
738 let thread_id = response
739 .pointer("/thread/id")
740 .and_then(Value::as_str)
741 .ok_or_else(|| Error::Other(format!("Codex {method} response omitted thread.id")))?
742 .to_string();
743 let unpublished_rollout = if method == "thread/start" {
744 runtime_home
745 .as_ref()
746 .map(|home| home.started_rollout_path(&response))
747 .transpose()?
748 } else {
749 None
750 };
751 Ok(Box::new(CodexRuntimeConnection {
752 handle: RuntimeHandle {
753 harness: HarnessId::from(HarnessId::CODEX),
754 runtime_id: thread_id,
755 endpoint,
756 },
757 client,
758 receiver,
759 active_turn: None,
760 runtime_home,
761 unpublished_rollout,
762 }))
763 }
764}
765
766#[async_trait]
767impl RuntimeBackend for CodexRuntimeBackend {
768 fn harness(&self) -> HarnessId {
769 HarnessId::from(HarnessId::CODEX)
770 }
771
772 fn capabilities(&self) -> RuntimeCapabilities {
773 RuntimeCapabilities {
774 start_session: true,
775 resume_session: true,
776 attach_existing_process: false,
780 send_input: true,
781 stream_events: true,
782 interrupt: true,
783 steer: true,
784 respond_to_requests: true,
785 }
786 }
787
788 async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
789 self.open_thread(
790 "thread/start",
791 json!({"cwd": request.cwd}),
792 request.launch,
793 None,
794 )
795 .await
796 }
797
798 async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
799 let mut params = json!({"threadId": request.runtime_id});
800 if let Some(cwd) = request.cwd {
801 params["cwd"] = json!(cwd);
802 }
803 let runtime_id = request.runtime_id.clone();
804 self.open_thread("thread/resume", params, request.launch, Some(&runtime_id))
805 .await
806 }
807}
808
809struct CodexRuntimeConnection {
810 handle: RuntimeHandle,
811 client: Arc<JsonLineClient>,
812 receiver: mpsc::UnboundedReceiver<Value>,
813 active_turn: Option<String>,
814 runtime_home: Option<CodexRuntimeHome>,
815 unpublished_rollout: Option<PathBuf>,
816}
817
818#[async_trait]
819impl RuntimeConnection for CodexRuntimeConnection {
820 fn handle(&self) -> &RuntimeHandle {
821 &self.handle
822 }
823
824 async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
825 let mut parts = Vec::new();
826 if !input.text.is_empty() {
827 parts.push(json!({"type": "text", "text": input.text}));
828 }
829 parts.extend(
830 input
831 .image_urls
832 .into_iter()
833 .map(|url| json!({"type": "image", "url": url})),
834 );
835 let response = self
836 .client
837 .request(
838 "turn/start",
839 json!({
840 "threadId": self.handle.runtime_id,
841 "input": parts,
842 }),
843 )
844 .await?;
845 let turn_id = response
846 .pointer("/turn/id")
847 .and_then(Value::as_str)
848 .map(str::to_owned);
849 if let (Some(home), Some(path)) = (
850 self.runtime_home.as_ref(),
851 self.unpublished_rollout.as_ref(),
852 ) {
853 home.publish_rollout(path).await?;
854 self.unpublished_rollout = None;
855 }
856 self.active_turn = turn_id.clone();
857 Ok(turn_id)
858 }
859
860 async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
861 let Some(payload) = self.receiver.recv().await else {
862 return Ok(None);
863 };
864 let kind = payload
865 .get("method")
866 .and_then(Value::as_str)
867 .map(str::to_owned)
868 .unwrap_or_else(|| "protocol".into());
869 if kind == "turn/completed" {
870 self.active_turn = None;
871 }
872 Ok(Some(HarnessEvent {
873 sequence: None,
874 kind,
875 payload,
876 }))
877 }
878
879 async fn interrupt(&mut self) -> Result<()> {
880 let Some(turn_id) = self.active_turn.as_ref() else {
881 return Err(Error::Other("Codex has no active turn to interrupt".into()));
882 };
883 self.client
884 .request(
885 "turn/interrupt",
886 json!({"threadId": self.handle.runtime_id, "turnId": turn_id}),
887 )
888 .await?;
889 Ok(())
890 }
891
892 async fn steer(&mut self, text: String) -> Result<()> {
893 let Some(turn_id) = self.active_turn.as_ref() else {
894 return Err(Error::Other("Codex has no active turn to steer".into()));
895 };
896 self.client
897 .request(
898 "turn/steer",
899 json!({
900 "threadId": self.handle.runtime_id,
901 "expectedTurnId": turn_id,
902 "input": [{"type":"text", "text":text}],
903 }),
904 )
905 .await?;
906 Ok(())
907 }
908
909 async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
910 self.client.respond(request_id, response).await
911 }
912
913 async fn close(&mut self) -> Result<()> {
914 self.client.close().await?;
915 if let Some(home) = self.runtime_home.take() {
916 home.cleanup()?;
917 }
918 Ok(())
919 }
920}
921
922type PendingResponse = oneshot::Sender<std::result::Result<Value, String>>;
923type PendingResponses = Arc<Mutex<HashMap<u64, PendingResponse>>>;
924
925pub(super) struct GroupLeader(Child);
940
941impl std::ops::Deref for GroupLeader {
942 type Target = Child;
943
944 fn deref(&self) -> &Child {
945 &self.0
946 }
947}
948
949impl std::ops::DerefMut for GroupLeader {
950 fn deref_mut(&mut self) -> &mut Child {
951 &mut self.0
952 }
953}
954
955impl Drop for GroupLeader {
956 fn drop(&mut self) {
957 #[cfg(unix)]
958 if let Some(pid) = self.0.id() {
959 crate::lsp::kill_process_group(pid);
960 }
961 }
962}
963
964pub(super) struct JsonLineClient {
965 stdin: Mutex<ChildStdin>,
966 child: Mutex<GroupLeader>,
967 pending: PendingResponses,
968 next_id: Mutex<u64>,
969 include_jsonrpc: bool,
970 events: mpsc::UnboundedSender<Value>,
971 process_group: Option<u32>,
972}
973
974impl JsonLineClient {
975 pub(super) async fn spawn(
976 launch: &RuntimeLaunch,
977 cwd: Option<&std::path::Path>,
978 include_jsonrpc: bool,
979 protocol: &str,
980 ) -> Result<(Arc<Self>, mpsc::UnboundedReceiver<Value>, RuntimeEndpoint)> {
981 let mut command = Command::new(&launch.program);
982 command
983 .args(&launch.arguments)
984 .envs(&launch.env)
985 .stdin(Stdio::piped())
986 .stdout(Stdio::piped())
987 .stderr(Stdio::piped())
988 .kill_on_drop(true);
989 #[cfg(unix)]
993 command.process_group(0);
994 if let Some(cwd) = cwd {
995 command.current_dir(cwd);
996 }
997 let mut child = command.spawn().map_err(|error| {
998 Error::Other(format!("could not launch {}: {error}", launch.program))
999 })?;
1000 let pid = child.id();
1001 let stdin = child
1002 .stdin
1003 .take()
1004 .ok_or_else(|| Error::Other("runtime child has no stdin".into()))?;
1005 let stdout = child
1006 .stdout
1007 .take()
1008 .ok_or_else(|| Error::Other("runtime child has no stdout".into()))?;
1009 let stderr = child
1010 .stderr
1011 .take()
1012 .ok_or_else(|| Error::Other("runtime child has no stderr".into()))?;
1013 let pending: PendingResponses = Arc::new(Mutex::new(HashMap::new()));
1014 let (events_tx, events_rx) = mpsc::unbounded_channel();
1015 let reader_events = events_tx.clone();
1016 let reader_pending = pending.clone();
1017 tokio::spawn(async move {
1018 let mut stdout_lines = BufReader::new(stdout).lines();
1019 let mut stderr_lines = BufReader::new(stderr).lines();
1020 let mut stdout_open = true;
1021 let mut stderr_open = true;
1022 let mut recent_stderr: std::collections::VecDeque<String> =
1027 std::collections::VecDeque::new();
1028 while stdout_open || stderr_open {
1029 tokio::select! {
1030 line = stdout_lines.next_line(), if stdout_open => match line {
1031 Ok(Some(line)) => {
1032 let Ok(value) = serde_json::from_str::<Value>(&line) else {
1033 let _ = reader_events.send(json!({"type": "malformed_output", "line": line}));
1034 continue;
1035 };
1036 let response_id = value.get("id").and_then(Value::as_u64);
1037 let is_response = value.get("result").is_some() || value.get("error").is_some();
1038 if let Some(id) = response_id.filter(|_| is_response) {
1039 if let Some(sender) = reader_pending.lock().await.remove(&id) {
1040 let result = if let Some(error) = value.get("error") {
1041 Err(error.to_string())
1042 } else {
1043 Ok(value.get("result").cloned().unwrap_or(Value::Null))
1044 };
1045 let _ = sender.send(result);
1046 continue;
1047 }
1048 }
1049 let _ = reader_events.send(value);
1050 }
1051 Ok(None) => stdout_open = false,
1052 Err(error) => {
1053 let _ = reader_events.send(json!({"type": "transport_error", "message": error.to_string()}));
1054 stdout_open = false;
1055 }
1056 },
1057 line = stderr_lines.next_line(), if stderr_open => match line {
1058 Ok(Some(line)) => {
1059 if !line.trim().is_empty() {
1060 if recent_stderr.len() == STDERR_TAIL_LINES {
1061 recent_stderr.pop_front();
1062 }
1063 recent_stderr.push_back(line.clone());
1064 }
1065 let _ = reader_events.send(json!({"type": "transport_stderr", "line": line}));
1066 }
1067 Ok(None) => stderr_open = false,
1068 Err(error) => {
1069 let _ = reader_events.send(json!({"type": "transport_error", "message": error.to_string()}));
1070 stderr_open = false;
1071 }
1072 }
1073 }
1074 }
1075 let _ = reader_events.send(json!({"type": "transport_closed"}));
1076 let reason = closed_reason(&recent_stderr);
1077 let mut pending = reader_pending.lock().await;
1078 for (_, sender) in pending.drain() {
1079 let _ = sender.send(Err(reason.clone()));
1080 }
1081 });
1082 let endpoint = RuntimeEndpoint::LocalProcess {
1083 pid,
1084 command: std::iter::once(launch.program.clone())
1085 .chain(launch.arguments.iter().cloned())
1086 .collect(),
1087 protocol: protocol.into(),
1088 };
1089 Ok((
1090 Arc::new(Self {
1091 stdin: Mutex::new(stdin),
1092 child: Mutex::new(GroupLeader(child)),
1093 pending,
1094 next_id: Mutex::new(1),
1095 include_jsonrpc,
1096 events: events_tx,
1097 process_group: pid,
1098 }),
1099 events_rx,
1100 endpoint,
1101 ))
1102 }
1103
1104 pub(super) async fn request(&self, method: &str, params: Value) -> Result<Value> {
1105 let (_id, rx) = self.begin_request(method, params).await?;
1106 rx.await
1107 .map_err(|_| Error::Other("runtime response channel closed".into()))?
1108 .map_err(|message| {
1109 Error::Other(format!("runtime request `{method}` failed: {message}"))
1110 })
1111 }
1112
1113 pub(super) async fn begin_request(
1114 &self,
1115 method: &str,
1116 params: Value,
1117 ) -> Result<(u64, oneshot::Receiver<std::result::Result<Value, String>>)> {
1118 let id = {
1119 let mut next = self.next_id.lock().await;
1120 let id = *next;
1121 *next += 1;
1122 id
1123 };
1124 let (tx, rx) = oneshot::channel();
1125 self.pending.lock().await.insert(id, tx);
1126 let mut request = json!({"id": id, "method": method, "params": params});
1127 if self.include_jsonrpc {
1128 request["jsonrpc"] = json!("2.0");
1129 }
1130 if let Err(error) = self.write(&request).await {
1131 self.pending.lock().await.remove(&id);
1132 return Err(error);
1133 }
1134 Ok((id, rx))
1135 }
1136
1137 pub(super) async fn notify(&self, method: &str, params: Value) -> Result<()> {
1138 let mut notification = json!({"method": method, "params": params});
1139 if self.include_jsonrpc {
1140 notification["jsonrpc"] = json!("2.0");
1141 }
1142 self.write(¬ification).await
1143 }
1144
1145 pub(super) async fn respond(&self, id: Value, result: Value) -> Result<()> {
1146 let mut response = json!({"id": id, "result": result});
1147 if self.include_jsonrpc {
1148 response["jsonrpc"] = json!("2.0");
1149 }
1150 self.write(&response).await
1151 }
1152
1153 async fn write(&self, value: &Value) -> Result<()> {
1154 let mut stdin = self.stdin.lock().await;
1155 stdin.write_all(value.to_string().as_bytes()).await?;
1156 stdin.write_all(b"\n").await?;
1157 stdin.flush().await?;
1158 Ok(())
1159 }
1160
1161 pub(super) fn emit(&self, value: Value) {
1162 let _ = self.events.send(value);
1163 }
1164
1165 pub(super) async fn close(&self) -> Result<()> {
1166 let mut child = self.child.lock().await;
1167 if child.try_wait()?.is_some() {
1174 return Ok(());
1175 }
1176 #[cfg(unix)]
1177 {
1178 match self.process_group {
1179 Some(pid) => crate::lsp::kill_process_group(pid),
1180 None => child.kill().await?,
1181 }
1182 tokio::time::timeout(Duration::from_secs(3), child.wait())
1183 .await
1184 .map_err(|_| Error::Other("timed out reaping runtime process group".into()))??;
1185 }
1186 #[cfg(not(unix))]
1187 child.kill().await?;
1188 Ok(())
1189 }
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194 use super::*;
1195
1196 #[test]
1197 fn closed_reason_reports_the_runtime_last_words() {
1198 let mut stderr = std::collections::VecDeque::new();
1199 stderr.push_back("grok: unsupported syscall SYS_execve".to_string());
1200 assert_eq!(
1201 closed_reason(&stderr),
1202 "runtime protocol closed: grok: unsupported syscall SYS_execve",
1203 );
1204 }
1205
1206 #[test]
1207 fn closed_reason_stays_bare_without_stderr() {
1208 assert_eq!(
1209 closed_reason(&std::collections::VecDeque::new()),
1210 "runtime protocol closed",
1211 );
1212 }
1213
1214 #[test]
1215 fn closed_reason_truncates_a_long_tail() {
1216 let mut stderr = std::collections::VecDeque::new();
1217 stderr.push_back("x".repeat(STDERR_TAIL_CHARACTERS + 500));
1218 let reason = closed_reason(&stderr);
1219 assert!(reason.ends_with('…'), "{reason}");
1220 assert_eq!(
1221 reason.chars().count(),
1222 "runtime protocol closed: ".chars().count() + STDERR_TAIL_CHARACTERS + 1,
1223 );
1224 }
1225
1226 fn scratch_home(tag: &str) -> PathBuf {
1227 let dir = std::env::temp_dir().join(format!(
1228 "supercode-connect-launch-{tag}-{}-{}",
1229 std::process::id(),
1230 std::time::SystemTime::now()
1231 .duration_since(std::time::UNIX_EPOCH)
1232 .unwrap()
1233 .as_nanos()
1234 ));
1235 std::fs::create_dir_all(&dir).unwrap();
1236 dir
1237 }
1238
1239 #[test]
1240 fn connect_launch_resolves_address_and_auth_from_the_harness_config() {
1241 let home = scratch_home("resolve");
1242 std::fs::create_dir_all(home.join(".gateway")).unwrap();
1243 std::fs::write(
1244 home.join(".gateway/config.json"),
1245 r#"{"gateway": {"url": "ws://127.0.0.1:18789/", "auth": {"token": "secret-credential"}}}"#,
1246 )
1247 .unwrap();
1248 let launch = RuntimeConnectLaunch {
1249 config_path: "~/.gateway/config.json".into(),
1250 address_pointer: "/gateway/url".into(),
1251 port_pointer: None,
1252 default_address: None,
1253 auth_pointer: Some("/gateway/auth/token".into()),
1254 protocol: "acp-v1-jsonrpc".into(),
1255 };
1256 let resolved = launch.resolve(&home).unwrap();
1257 assert_eq!(resolved.address, "ws://127.0.0.1:18789");
1258 assert_eq!(
1259 resolved.auth.as_ref().unwrap().secret(),
1260 "secret-credential"
1261 );
1262 let debugged = format!("{resolved:?}");
1263 assert!(!debugged.contains("secret-credential"));
1264 assert!(debugged.contains("<redacted>"));
1265 }
1266
1267 #[test]
1268 fn connect_launch_resolution_fails_closed_without_echoing_config_contents() {
1269 let home = scratch_home("fail-closed");
1270 let launch = RuntimeConnectLaunch {
1271 config_path: "~/missing.json".into(),
1272 address_pointer: "/url".into(),
1273 port_pointer: None,
1274 default_address: None,
1275 auth_pointer: None,
1276 protocol: "acp-v1-jsonrpc".into(),
1277 };
1278 assert!(launch.resolve(&home).is_err());
1279
1280 std::fs::write(
1281 home.join("present.json"),
1282 r#"{"url": "", "auth": {"token": "secret-credential"}}"#,
1283 )
1284 .unwrap();
1285 let empty_address = RuntimeConnectLaunch {
1286 config_path: "~/present.json".into(),
1287 address_pointer: "/url".into(),
1288 port_pointer: None,
1289 default_address: None,
1290 auth_pointer: None,
1291 protocol: "acp-v1-jsonrpc".into(),
1292 };
1293 let error = empty_address.resolve(&home).unwrap_err();
1294 assert!(error.to_string().contains("/url"));
1295 assert!(!error.to_string().contains("secret-credential"));
1296
1297 let missing_auth = RuntimeConnectLaunch {
1298 config_path: "~/present.json".into(),
1299 address_pointer: "/auth/token".into(),
1300 port_pointer: None,
1301 default_address: None,
1302 auth_pointer: Some("/absent".into()),
1303 protocol: "acp-v1-jsonrpc".into(),
1304 };
1305 let error = missing_auth.resolve(&home).unwrap_err();
1306 assert!(error.to_string().contains("/absent"));
1307 assert!(!error.to_string().contains("secret-credential"));
1308 }
1309
1310 #[test]
1311 fn connect_launch_round_trips_through_json() {
1312 let launch = RuntimeConnectLaunch {
1313 config_path: "~/.openclaw/openclaw.json".into(),
1314 address_pointer: "/gateway/url".into(),
1315 port_pointer: None,
1316 default_address: None,
1317 auth_pointer: Some("/gateway/token".into()),
1318 protocol: "acp-v1-jsonrpc".into(),
1319 };
1320 let encoded = serde_json::to_value(&launch).unwrap();
1321 let decoded: RuntimeConnectLaunch = serde_json::from_value(encoded).unwrap();
1322 assert_eq!(decoded, launch);
1323 let minimal: RuntimeConnectLaunch = serde_json::from_value(json!({
1324 "config_path": "~/.gateway.json",
1325 "address_pointer": "/url",
1326 "protocol": "http",
1327 }))
1328 .unwrap();
1329 assert_eq!(minimal.auth_pointer, None);
1330 }
1331
1332 #[test]
1333 fn codex_capabilities_do_not_claim_arbitrary_process_attach() {
1334 let capabilities = CodexRuntimeBackend::new().capabilities();
1335 assert!(capabilities.start_session);
1336 assert!(capabilities.resume_session);
1337 assert!(!capabilities.attach_existing_process);
1338 assert!(capabilities.send_input);
1339 assert!(capabilities.stream_events);
1340 assert!(capabilities.interrupt);
1341 assert!(capabilities.steer);
1342 }
1343
1344 #[test]
1345 fn runtime_handle_is_language_neutral_json() {
1346 let handle = RuntimeHandle {
1347 harness: HarnessId::from(HarnessId::CODEX),
1348 runtime_id: "thread-1".into(),
1349 endpoint: RuntimeEndpoint::LocalProcess {
1350 pid: Some(42),
1351 command: vec!["codex".into(), "app-server".into()],
1352 protocol: "codex-app-server-jsonl".into(),
1353 },
1354 };
1355 let encoded = serde_json::to_string(&handle).unwrap();
1356 assert_eq!(
1357 serde_json::from_str::<RuntimeHandle>(&encoded).unwrap(),
1358 handle
1359 );
1360 }
1361
1362 #[cfg(unix)]
1363 #[tokio::test]
1364 async fn codex_adapter_performs_handshake_start_and_turn() {
1365 let script = r#"
1366 i=0
1367 while IFS= read -r line; do
1368 i=$((i + 1))
1369 case "$i" in
1370 1) printf '%s\n' '{"id":1,"result":{"userAgent":"mock"}}' ;;
1371 2) ;;
1372 3) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thr_mock"}}}' ;;
1373 4)
1374 printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn_mock"}}}'
1375 printf '%s\n' '{"method":"turn/started","params":{"turn":{"id":"turn_mock"}}}'
1376 ;;
1377 5) printf '%s\n' '{"id":4,"result":{"turnId":"turn_mock"}}' ;;
1378 esac
1379 done
1380 "#;
1381 let backend = CodexRuntimeBackend::with_launch(RuntimeLaunch {
1382 program: "/bin/sh".into(),
1383 arguments: vec!["-c".into(), script.into()],
1384 env: BTreeMap::new(),
1385 });
1386 let mut connection = backend
1387 .start(RuntimeStartRequest {
1388 cwd: std::env::current_dir().unwrap(),
1389 launch: None,
1390 mcp_servers: Vec::new(),
1391 })
1392 .await
1393 .unwrap();
1394 assert_eq!(connection.handle().runtime_id, "thr_mock");
1395 assert_eq!(
1396 connection
1397 .send_input(RuntimeInput {
1398 text: "hi".into(),
1399 image_urls: Vec::new(),
1400 })
1401 .await
1402 .unwrap()
1403 .as_deref(),
1404 Some("turn_mock")
1405 );
1406 connection.steer("focus on tests".into()).await.unwrap();
1407 assert_eq!(
1408 connection.next_event().await.unwrap().unwrap().kind,
1409 "turn/started"
1410 );
1411 connection.close().await.unwrap();
1412 }
1413}