Skip to main content

supercode_harness/
runtime.rs

1//! Primitive live-runtime contracts and the Codex app-server reference adapter.
2//!
3//! These APIs control harness-native sessions; they do not emulate terminal
4//! keystrokes and do not claim to attach to an arbitrary already-running TUI.
5
6use std::collections::{BTreeMap, HashMap};
7use std::path::{Path, PathBuf};
8use std::process::Stdio;
9use std::sync::Arc;
10use std::time::Duration;
11
12use async_trait::async_trait;
13use serde::{Deserialize, Serialize};
14use serde_json::{json, Value};
15use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
16use tokio::process::{Child, ChildStdin, Command};
17use tokio::sync::{mpsc, oneshot, Mutex};
18
19use crate::{Error, HarnessId, Result};
20
21mod adapters;
22mod hosted;
23#[cfg(feature = "adapter-api")]
24mod supercode_http;
25pub(crate) use adapters::generated_session_id;
26pub use adapters::{
27    AcpRuntimeBackend, ClaudeCodeRuntimeBackend, OpenCodeRuntimeBackend, PiRuntimeBackend,
28};
29pub use hosted::{HostedHarnessConnection, HostedHarnessRuntime};
30#[cfg(feature = "adapter-api")]
31pub use supercode_http::SupercodeHttpRuntimeBackend;
32
33/// Mechanical facts an adapter can guarantee.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct RuntimeCapabilities {
36    /// Can create a fresh harness-native session.
37    pub start_session: bool,
38    /// Can resume a harness-native persisted session by id.
39    pub resume_session: bool,
40    /// Can join an arbitrary already-running harness process.
41    pub attach_existing_process: bool,
42    /// Can send user input through a structured protocol.
43    pub send_input: bool,
44    /// Can receive structured live events.
45    pub stream_events: bool,
46    /// Can interrupt an in-flight turn.
47    pub interrupt: bool,
48    /// Can redirect an in-flight turn without interrupting it.
49    #[serde(default)]
50    pub steer: bool,
51    /// Can answer protocol requests such as approvals or elicitation.
52    pub respond_to_requests: bool,
53}
54
55/// Executable configuration used to launch one adapter endpoint.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct RuntimeLaunch {
58    /// Executable name or path.
59    pub program: String,
60    /// Arguments passed before adapter-generated protocol arguments.
61    pub arguments: Vec<String>,
62    /// Extra environment variables.
63    pub env: BTreeMap<String, String>,
64}
65
66/// Connect to an already-running harness endpoint instead of spawning one.
67///
68/// The registry stores where the endpoint and its credential live — the
69/// harness's own config file — never the values themselves. The service
70/// resolves them when it opens the connection, so a rotated token or a moved
71/// gateway is picked up on the next open without a registry change.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct RuntimeConnectLaunch {
74    /// Harness config file holding the endpoint; a leading `~/` expands to the
75    /// caller's home directory at resolve time.
76    pub config_path: String,
77    /// JSON pointer to the endpoint address inside the config file.
78    pub address_pointer: String,
79    /// Optional JSON pointer to a PORT number in the config file, consulted
80    /// when `address_pointer` names nothing: the address becomes that port on
81    /// loopback under `default_address`'s scheme. Harnesses like openclaw
82    /// configure a bare `gateway.port`, never a full URL.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub port_pointer: Option<String>,
85    /// Optional fallback endpoint when neither pointer resolves — the
86    /// harness's documented out-of-the-box endpoint. With this set, a missing
87    /// or pointer-less config is the harness "running on defaults", not an
88    /// error.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub default_address: Option<String>,
91    /// Optional JSON pointer to the bearer credential inside the config file.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub auth_pointer: Option<String>,
94    /// Protocol spoken at the endpoint.
95    pub protocol: String,
96}
97
98/// Bearer credential whose `Debug` output never contains the secret.
99#[derive(Clone, PartialEq, Eq)]
100pub struct BearerToken(String);
101
102impl BearerToken {
103    /// Wrap a resolved credential.
104    pub fn new(secret: impl Into<String>) -> Self {
105        Self(secret.into())
106    }
107
108    /// The secret itself, for constructing an Authorization header.
109    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/// Endpoint and credential resolved from a [`RuntimeConnectLaunch`].
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct ResolvedRuntimeConnection {
123    /// Concrete endpoint address.
124    pub address: String,
125    /// Bearer credential when the launch declares one.
126    pub auth: Option<BearerToken>,
127}
128
129impl RuntimeConnectLaunch {
130    /// Resolve the endpoint address and credential from the harness's config
131    /// file. Fails closed: a declared pointer that does not resolve to a
132    /// non-empty string is an error, and diagnostics name the path and the
133    /// pointer without echoing config contents.
134    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        // A missing config file is the harness on documented defaults when
140        // the descriptor declares them; otherwise it stays an error.
141        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        // Address chain: explicit URL pointer → configured port on loopback →
169        // the descriptor's documented default endpoint.
170        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        // Normalize a bare host:port to the endpoint's scheme — configs
204        // routinely omit it and a scheme-less URL makes gateway clients fall
205        // back to their compiled-in default endpoint instead.
206        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        // Auth is optional exactly when the endpoint can run without it: a
216        // declared pointer that resolves to nothing is only an error when no
217        // default endpoint is declared (the original fail-closed contract).
218        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/// One stdio MCP server the caller wants mounted into the session it is
233/// starting. Uniform shape; each backend translates it into whatever its own
234/// harness accepts (the ACP backend into `session/new`'s `mcpServers`).
235#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
236pub struct McpServerLaunch {
237    /// Server name the harness registers the tools under.
238    pub name: String,
239    /// Executable to spawn.
240    pub command: String,
241    /// Arguments passed to it.
242    #[serde(default)]
243    pub arguments: Vec<String>,
244    /// Extra environment for the spawned server.
245    #[serde(default)]
246    pub env: BTreeMap<String, String>,
247}
248
249/// Request to create a fresh runtime session.
250#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
251pub struct RuntimeStartRequest {
252    /// Project working directory.
253    pub cwd: PathBuf,
254    /// Optional executable override, primarily for alternate installs/tests.
255    pub launch: Option<RuntimeLaunch>,
256    /// MCP servers to mount into the new session, where the harness's own
257    /// start door carries them. Backends that have no such door ignore it —
258    /// their caller mounts through a config file instead.
259    #[serde(default)]
260    pub mcp_servers: Vec<McpServerLaunch>,
261}
262
263/// Request to resume or attach through a new adapter connection.
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265pub struct RuntimeAttachRequest {
266    /// Harness-native session/thread id.
267    pub runtime_id: String,
268    /// Optional cwd override accepted by the harness protocol.
269    pub cwd: Option<PathBuf>,
270    /// Optional executable override.
271    pub launch: Option<RuntimeLaunch>,
272}
273
274/// Observable endpoint backing a runtime connection.
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(tag = "kind", rename_all = "snake_case")]
277pub enum RuntimeEndpoint {
278    /// Child process owned by this connection.
279    LocalProcess {
280        /// Process id when available.
281        pid: Option<u32>,
282        /// Executable plus arguments.
283        command: Vec<String>,
284        /// Native protocol spoken over stdio.
285        protocol: String,
286    },
287    /// Existing HTTP service.
288    Http {
289        /// Service base URL.
290        base_url: String,
291        /// Native protocol name.
292        protocol: String,
293    },
294}
295
296/// Identity returned after a live session is started or resumed.
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub struct RuntimeHandle {
299    /// Runtime adapter/harness.
300    pub harness: HarnessId,
301    /// Harness-native live session identity.
302    pub runtime_id: String,
303    /// Concrete endpoint used by this connection.
304    pub endpoint: RuntimeEndpoint,
305}
306
307/// User input accepted by a live runtime.
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309pub struct RuntimeInput {
310    /// Plain text prompt or steering instruction.
311    pub text: String,
312    /// Runtime-resolved image URLs or `data:image/...;base64,...` payloads.
313    ///
314    /// Adapters must either preserve these as native multimodal input or
315    /// reject the turn explicitly; they must never flatten image bytes into
316    /// the text prompt.
317    #[serde(default, skip_serializing_if = "Vec::is_empty")]
318    pub image_urls: Vec<String>,
319}
320
321/// Protocol-neutral envelope around a native live event.
322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
323pub struct HarnessEvent {
324    /// Canonical SDK sequence when the event originated from an SDK runtime.
325    /// Native harness adapters leave this absent and the service sequences
326    /// their transport stream locally.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub sequence: Option<u64>,
329    /// Native method/type name, or `request` for a server-initiated request.
330    pub kind: String,
331    /// Lossless native event/request value.
332    pub payload: Value,
333}
334
335/// One connected harness-native runtime session.
336#[async_trait]
337pub trait RuntimeConnection: Send {
338    /// Identity and endpoint of this connection.
339    fn handle(&self) -> &RuntimeHandle;
340    /// Submit structured user input and return the harness-native turn id when
341    /// one is allocated.
342    async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>>;
343    /// Wait for the next native live event.
344    async fn next_event(&mut self) -> Result<Option<HarnessEvent>>;
345    /// Interrupt the current turn, when supported.
346    async fn interrupt(&mut self) -> Result<()>;
347    /// Redirect the current turn, when supported.
348    async fn steer(&mut self, _text: String) -> Result<()> {
349        Err(Error::Other(
350            "this runtime cannot steer an active turn".into(),
351        ))
352    }
353    /// Answer a server-initiated protocol request by its native JSON id.
354    async fn respond(&mut self, request_id: Value, response: Value) -> Result<()>;
355    /// Close the adapter-owned transport/process.
356    async fn close(&mut self) -> Result<()>;
357}
358
359/// Factory for starting, resuming, and (where the native protocol permits it)
360/// joining one harness's already-running runtime endpoint.
361#[async_trait]
362pub trait RuntimeBackend: Send + Sync {
363    /// Harness implemented by this backend.
364    fn harness(&self) -> HarnessId;
365    /// Honest mechanical capability report.
366    fn capabilities(&self) -> RuntimeCapabilities;
367    /// Create a fresh harness-native session.
368    async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>>;
369    /// Resume a persisted harness-native session through a new protocol
370    /// connection. This does not imply joining the process that originally
371    /// wrote the session.
372    async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>>;
373    /// Join an already-running harness process or server. Most stock harnesses
374    /// cannot do this; adapters must opt in rather than silently treating a
375    /// persisted resume as a live attach.
376    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/// Codex live-runtime backend using the official `codex app-server` JSONL
388/// protocol (`initialize`, `thread/start|resume`, `turn/start|interrupt`).
389#[derive(Debug, Clone)]
390pub struct CodexRuntimeBackend {
391    launch: RuntimeLaunch,
392}
393
394const CODEX_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
395
396/// A stock Codex app-server eagerly indexes everything below `CODEX_HOME`
397/// before answering `initialize`. That turns a runtime open into an unbounded
398/// corpus scan for long-time Codex users. Give each connection a private state
399/// database and project only the one native rollout it needs into that home.
400/// The rollout itself is hard-linked, so Codex continues the original inode
401/// rather than a copy that would need lossy reconciliation later.
402#[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
549/// Keeps a runtime's closing diagnostics short enough to read in an error.
550const STDERR_TAIL_LINES: usize = 20;
551const STDERR_TAIL_CHARACTERS: usize = 2_000;
552
553/// Reports a closed protocol together with whatever the runtime last said.
554fn 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    /// Use `codex app-server` from `PATH`.
675    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    /// Use an explicit command prefix.
686    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            // A new app-server can resume the same stored thread, but stock
777            // Codex does not let it join an arbitrary already-running TUI's
778            // transport/event fanout.
779            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 JsonLineClient {
926    stdin: Mutex<ChildStdin>,
927    child: Mutex<Child>,
928    pending: PendingResponses,
929    next_id: Mutex<u64>,
930    include_jsonrpc: bool,
931    events: mpsc::UnboundedSender<Value>,
932    process_group: Option<u32>,
933}
934
935impl JsonLineClient {
936    pub(super) async fn spawn(
937        launch: &RuntimeLaunch,
938        cwd: Option<&std::path::Path>,
939        include_jsonrpc: bool,
940        protocol: &str,
941    ) -> Result<(Arc<Self>, mpsc::UnboundedReceiver<Value>, RuntimeEndpoint)> {
942        let mut command = Command::new(&launch.program);
943        command
944            .args(&launch.arguments)
945            .envs(&launch.env)
946            .stdin(Stdio::piped())
947            .stdout(Stdio::piped())
948            .stderr(Stdio::piped())
949            .kill_on_drop(true);
950        // Package-manager shims commonly spawn a native worker. Isolate the
951        // complete adapter tree so close can reap it instead of orphaning the
952        // worker with inherited protocol handles.
953        #[cfg(unix)]
954        command.process_group(0);
955        if let Some(cwd) = cwd {
956            command.current_dir(cwd);
957        }
958        let mut child = command.spawn().map_err(|error| {
959            Error::Other(format!("could not launch {}: {error}", launch.program))
960        })?;
961        let pid = child.id();
962        let stdin = child
963            .stdin
964            .take()
965            .ok_or_else(|| Error::Other("runtime child has no stdin".into()))?;
966        let stdout = child
967            .stdout
968            .take()
969            .ok_or_else(|| Error::Other("runtime child has no stdout".into()))?;
970        let stderr = child
971            .stderr
972            .take()
973            .ok_or_else(|| Error::Other("runtime child has no stderr".into()))?;
974        let pending: PendingResponses = Arc::new(Mutex::new(HashMap::new()));
975        let (events_tx, events_rx) = mpsc::unbounded_channel();
976        let reader_events = events_tx.clone();
977        let reader_pending = pending.clone();
978        tokio::spawn(async move {
979            let mut stdout_lines = BufReader::new(stdout).lines();
980            let mut stderr_lines = BufReader::new(stderr).lines();
981            let mut stdout_open = true;
982            let mut stderr_open = true;
983            // A runtime that dies mid-handshake explains itself on stderr and
984            // nowhere else. Events reach only an already-started runtime, so
985            // without this the caller is told the protocol closed and never
986            // told why.
987            let mut recent_stderr: std::collections::VecDeque<String> =
988                std::collections::VecDeque::new();
989            while stdout_open || stderr_open {
990                tokio::select! {
991                    line = stdout_lines.next_line(), if stdout_open => match line {
992                        Ok(Some(line)) => {
993                            let Ok(value) = serde_json::from_str::<Value>(&line) else {
994                                let _ = reader_events.send(json!({"type": "malformed_output", "line": line}));
995                                continue;
996                            };
997                            let response_id = value.get("id").and_then(Value::as_u64);
998                            let is_response = value.get("result").is_some() || value.get("error").is_some();
999                            if let Some(id) = response_id.filter(|_| is_response) {
1000                                if let Some(sender) = reader_pending.lock().await.remove(&id) {
1001                                    let result = if let Some(error) = value.get("error") {
1002                                        Err(error.to_string())
1003                                    } else {
1004                                        Ok(value.get("result").cloned().unwrap_or(Value::Null))
1005                                    };
1006                                    let _ = sender.send(result);
1007                                    continue;
1008                                }
1009                            }
1010                            let _ = reader_events.send(value);
1011                        }
1012                        Ok(None) => stdout_open = false,
1013                        Err(error) => {
1014                            let _ = reader_events.send(json!({"type": "transport_error", "message": error.to_string()}));
1015                            stdout_open = false;
1016                        }
1017                    },
1018                    line = stderr_lines.next_line(), if stderr_open => match line {
1019                        Ok(Some(line)) => {
1020                            if !line.trim().is_empty() {
1021                                if recent_stderr.len() == STDERR_TAIL_LINES {
1022                                    recent_stderr.pop_front();
1023                                }
1024                                recent_stderr.push_back(line.clone());
1025                            }
1026                            let _ = reader_events.send(json!({"type": "transport_stderr", "line": line}));
1027                        }
1028                        Ok(None) => stderr_open = false,
1029                        Err(error) => {
1030                            let _ = reader_events.send(json!({"type": "transport_error", "message": error.to_string()}));
1031                            stderr_open = false;
1032                        }
1033                    }
1034                }
1035            }
1036            let _ = reader_events.send(json!({"type": "transport_closed"}));
1037            let reason = closed_reason(&recent_stderr);
1038            let mut pending = reader_pending.lock().await;
1039            for (_, sender) in pending.drain() {
1040                let _ = sender.send(Err(reason.clone()));
1041            }
1042        });
1043        let endpoint = RuntimeEndpoint::LocalProcess {
1044            pid,
1045            command: std::iter::once(launch.program.clone())
1046                .chain(launch.arguments.iter().cloned())
1047                .collect(),
1048            protocol: protocol.into(),
1049        };
1050        Ok((
1051            Arc::new(Self {
1052                stdin: Mutex::new(stdin),
1053                child: Mutex::new(child),
1054                pending,
1055                next_id: Mutex::new(1),
1056                include_jsonrpc,
1057                events: events_tx,
1058                process_group: pid,
1059            }),
1060            events_rx,
1061            endpoint,
1062        ))
1063    }
1064
1065    pub(super) async fn request(&self, method: &str, params: Value) -> Result<Value> {
1066        let (_id, rx) = self.begin_request(method, params).await?;
1067        rx.await
1068            .map_err(|_| Error::Other("runtime response channel closed".into()))?
1069            .map_err(|message| {
1070                Error::Other(format!("runtime request `{method}` failed: {message}"))
1071            })
1072    }
1073
1074    pub(super) async fn begin_request(
1075        &self,
1076        method: &str,
1077        params: Value,
1078    ) -> Result<(u64, oneshot::Receiver<std::result::Result<Value, String>>)> {
1079        let id = {
1080            let mut next = self.next_id.lock().await;
1081            let id = *next;
1082            *next += 1;
1083            id
1084        };
1085        let (tx, rx) = oneshot::channel();
1086        self.pending.lock().await.insert(id, tx);
1087        let mut request = json!({"id": id, "method": method, "params": params});
1088        if self.include_jsonrpc {
1089            request["jsonrpc"] = json!("2.0");
1090        }
1091        if let Err(error) = self.write(&request).await {
1092            self.pending.lock().await.remove(&id);
1093            return Err(error);
1094        }
1095        Ok((id, rx))
1096    }
1097
1098    pub(super) async fn notify(&self, method: &str, params: Value) -> Result<()> {
1099        let mut notification = json!({"method": method, "params": params});
1100        if self.include_jsonrpc {
1101            notification["jsonrpc"] = json!("2.0");
1102        }
1103        self.write(&notification).await
1104    }
1105
1106    pub(super) async fn respond(&self, id: Value, result: Value) -> Result<()> {
1107        let mut response = json!({"id": id, "result": result});
1108        if self.include_jsonrpc {
1109            response["jsonrpc"] = json!("2.0");
1110        }
1111        self.write(&response).await
1112    }
1113
1114    async fn write(&self, value: &Value) -> Result<()> {
1115        let mut stdin = self.stdin.lock().await;
1116        stdin.write_all(value.to_string().as_bytes()).await?;
1117        stdin.write_all(b"\n").await?;
1118        stdin.flush().await?;
1119        Ok(())
1120    }
1121
1122    pub(super) fn emit(&self, value: Value) {
1123        let _ = self.events.send(value);
1124    }
1125
1126    pub(super) async fn close(&self) -> Result<()> {
1127        let mut child = self.child.lock().await;
1128        #[cfg(unix)]
1129        if let Some(pid) = self.process_group {
1130            crate::lsp::kill_process_group(pid);
1131            tokio::time::timeout(Duration::from_secs(3), child.wait())
1132                .await
1133                .map_err(|_| Error::Other("timed out reaping runtime process group".into()))??;
1134            return Ok(());
1135        }
1136        #[cfg(not(unix))]
1137        if child.try_wait()?.is_none() {
1138            child.kill().await?;
1139        }
1140        Ok(())
1141    }
1142}
1143
1144#[cfg(test)]
1145mod tests {
1146    use super::*;
1147
1148    #[test]
1149    fn closed_reason_reports_the_runtime_last_words() {
1150        let mut stderr = std::collections::VecDeque::new();
1151        stderr.push_back("grok: unsupported syscall SYS_execve".to_string());
1152        assert_eq!(
1153            closed_reason(&stderr),
1154            "runtime protocol closed: grok: unsupported syscall SYS_execve",
1155        );
1156    }
1157
1158    #[test]
1159    fn closed_reason_stays_bare_without_stderr() {
1160        assert_eq!(
1161            closed_reason(&std::collections::VecDeque::new()),
1162            "runtime protocol closed",
1163        );
1164    }
1165
1166    #[test]
1167    fn closed_reason_truncates_a_long_tail() {
1168        let mut stderr = std::collections::VecDeque::new();
1169        stderr.push_back("x".repeat(STDERR_TAIL_CHARACTERS + 500));
1170        let reason = closed_reason(&stderr);
1171        assert!(reason.ends_with('…'), "{reason}");
1172        assert_eq!(
1173            reason.chars().count(),
1174            "runtime protocol closed: ".chars().count() + STDERR_TAIL_CHARACTERS + 1,
1175        );
1176    }
1177
1178    fn scratch_home(tag: &str) -> PathBuf {
1179        let dir = std::env::temp_dir().join(format!(
1180            "supercode-connect-launch-{tag}-{}-{}",
1181            std::process::id(),
1182            std::time::SystemTime::now()
1183                .duration_since(std::time::UNIX_EPOCH)
1184                .unwrap()
1185                .as_nanos()
1186        ));
1187        std::fs::create_dir_all(&dir).unwrap();
1188        dir
1189    }
1190
1191    #[test]
1192    fn connect_launch_resolves_address_and_auth_from_the_harness_config() {
1193        let home = scratch_home("resolve");
1194        std::fs::create_dir_all(home.join(".gateway")).unwrap();
1195        std::fs::write(
1196            home.join(".gateway/config.json"),
1197            r#"{"gateway": {"url": "ws://127.0.0.1:18789/", "auth": {"token": "secret-credential"}}}"#,
1198        )
1199        .unwrap();
1200        let launch = RuntimeConnectLaunch {
1201            config_path: "~/.gateway/config.json".into(),
1202            address_pointer: "/gateway/url".into(),
1203            port_pointer: None,
1204            default_address: None,
1205            auth_pointer: Some("/gateway/auth/token".into()),
1206            protocol: "acp-v1-jsonrpc".into(),
1207        };
1208        let resolved = launch.resolve(&home).unwrap();
1209        assert_eq!(resolved.address, "ws://127.0.0.1:18789");
1210        assert_eq!(
1211            resolved.auth.as_ref().unwrap().secret(),
1212            "secret-credential"
1213        );
1214        let debugged = format!("{resolved:?}");
1215        assert!(!debugged.contains("secret-credential"));
1216        assert!(debugged.contains("<redacted>"));
1217    }
1218
1219    #[test]
1220    fn connect_launch_resolution_fails_closed_without_echoing_config_contents() {
1221        let home = scratch_home("fail-closed");
1222        let launch = RuntimeConnectLaunch {
1223            config_path: "~/missing.json".into(),
1224            address_pointer: "/url".into(),
1225            port_pointer: None,
1226            default_address: None,
1227            auth_pointer: None,
1228            protocol: "acp-v1-jsonrpc".into(),
1229        };
1230        assert!(launch.resolve(&home).is_err());
1231
1232        std::fs::write(
1233            home.join("present.json"),
1234            r#"{"url": "", "auth": {"token": "secret-credential"}}"#,
1235        )
1236        .unwrap();
1237        let empty_address = RuntimeConnectLaunch {
1238            config_path: "~/present.json".into(),
1239            address_pointer: "/url".into(),
1240            port_pointer: None,
1241            default_address: None,
1242            auth_pointer: None,
1243            protocol: "acp-v1-jsonrpc".into(),
1244        };
1245        let error = empty_address.resolve(&home).unwrap_err();
1246        assert!(error.to_string().contains("/url"));
1247        assert!(!error.to_string().contains("secret-credential"));
1248
1249        let missing_auth = RuntimeConnectLaunch {
1250            config_path: "~/present.json".into(),
1251            address_pointer: "/auth/token".into(),
1252            port_pointer: None,
1253            default_address: None,
1254            auth_pointer: Some("/absent".into()),
1255            protocol: "acp-v1-jsonrpc".into(),
1256        };
1257        let error = missing_auth.resolve(&home).unwrap_err();
1258        assert!(error.to_string().contains("/absent"));
1259        assert!(!error.to_string().contains("secret-credential"));
1260    }
1261
1262    #[test]
1263    fn connect_launch_round_trips_through_json() {
1264        let launch = RuntimeConnectLaunch {
1265            config_path: "~/.openclaw/openclaw.json".into(),
1266            address_pointer: "/gateway/url".into(),
1267            port_pointer: None,
1268            default_address: None,
1269            auth_pointer: Some("/gateway/token".into()),
1270            protocol: "acp-v1-jsonrpc".into(),
1271        };
1272        let encoded = serde_json::to_value(&launch).unwrap();
1273        let decoded: RuntimeConnectLaunch = serde_json::from_value(encoded).unwrap();
1274        assert_eq!(decoded, launch);
1275        let minimal: RuntimeConnectLaunch = serde_json::from_value(json!({
1276            "config_path": "~/.gateway.json",
1277            "address_pointer": "/url",
1278            "protocol": "http",
1279        }))
1280        .unwrap();
1281        assert_eq!(minimal.auth_pointer, None);
1282    }
1283
1284    #[test]
1285    fn codex_capabilities_do_not_claim_arbitrary_process_attach() {
1286        let capabilities = CodexRuntimeBackend::new().capabilities();
1287        assert!(capabilities.start_session);
1288        assert!(capabilities.resume_session);
1289        assert!(!capabilities.attach_existing_process);
1290        assert!(capabilities.send_input);
1291        assert!(capabilities.stream_events);
1292        assert!(capabilities.interrupt);
1293        assert!(capabilities.steer);
1294    }
1295
1296    #[test]
1297    fn runtime_handle_is_language_neutral_json() {
1298        let handle = RuntimeHandle {
1299            harness: HarnessId::from(HarnessId::CODEX),
1300            runtime_id: "thread-1".into(),
1301            endpoint: RuntimeEndpoint::LocalProcess {
1302                pid: Some(42),
1303                command: vec!["codex".into(), "app-server".into()],
1304                protocol: "codex-app-server-jsonl".into(),
1305            },
1306        };
1307        let encoded = serde_json::to_string(&handle).unwrap();
1308        assert_eq!(
1309            serde_json::from_str::<RuntimeHandle>(&encoded).unwrap(),
1310            handle
1311        );
1312    }
1313
1314    #[cfg(unix)]
1315    #[tokio::test]
1316    async fn codex_adapter_performs_handshake_start_and_turn() {
1317        let script = r#"
1318            i=0
1319            while IFS= read -r line; do
1320              i=$((i + 1))
1321              case "$i" in
1322                1) printf '%s\n' '{"id":1,"result":{"userAgent":"mock"}}' ;;
1323                2) ;;
1324                3) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thr_mock"}}}' ;;
1325                4)
1326                  printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn_mock"}}}'
1327                  printf '%s\n' '{"method":"turn/started","params":{"turn":{"id":"turn_mock"}}}'
1328                  ;;
1329                5) printf '%s\n' '{"id":4,"result":{"turnId":"turn_mock"}}' ;;
1330              esac
1331            done
1332        "#;
1333        let backend = CodexRuntimeBackend::with_launch(RuntimeLaunch {
1334            program: "/bin/sh".into(),
1335            arguments: vec!["-c".into(), script.into()],
1336            env: BTreeMap::new(),
1337        });
1338        let mut connection = backend
1339            .start(RuntimeStartRequest {
1340                cwd: std::env::current_dir().unwrap(),
1341                launch: None,
1342                mcp_servers: Vec::new(),
1343            })
1344            .await
1345            .unwrap();
1346        assert_eq!(connection.handle().runtime_id, "thr_mock");
1347        assert_eq!(
1348            connection
1349                .send_input(RuntimeInput {
1350                    text: "hi".into(),
1351                    image_urls: Vec::new(),
1352                })
1353                .await
1354                .unwrap()
1355                .as_deref(),
1356            Some("turn_mock")
1357        );
1358        connection.steer("focus on tests".into()).await.unwrap();
1359        assert_eq!(
1360            connection.next_event().await.unwrap().unwrap().kind,
1361            "turn/started"
1362        );
1363        connection.close().await.unwrap();
1364    }
1365}