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