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