From b23cdfb2964372fad4906834c61f847ac4fdd33c Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Wed, 1 Jul 2026 13:21:48 -0700 Subject: [PATCH] feat(shell): --actor mode + PTY line-discipline & vim test suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shell CLI: - --actor flag runs the identical shell surface through the RivetKit agentOS actor (engine + envoy + dylib plugin + sidecar) instead of the in-process core client. Bootstrap mirrors the actor e2e harness (r6 runtime server child, runner-config upsert, envoy wait); output/exit stream back as shellData/shellStderr/shellExit events. - withLocalCommandFallback accepts the { packageDir } registry export shape and skips packages missing agentos-package.json instead of aborting VM creation. Actor plugin + TS mirror: - New shell actions (openShell/writeShell/resizeShell/closeShell/ waitShell) with shellData/shellStderr/shellExit broadcasts; spawn gains options + processOutput/processExit streaming. - waitShell/waitProcess reply from spawned tasks so long waits do not occupy the serial action worker (a held worker starved the stdin writes the wait depended on). - Config parser ported to the packages/packagesMountAt buildConfigJson shape; env-gated tracing subscriber (AGENTOS_PLUGIN_LOG). Rust client: - Fix the shell spawn-readiness gate: watch::Sender::send refuses to store while no receiver exists, leaving the gate permanently false — every write/resize issued after open hung in wait_for_spawn. Use send_replace. - wait_shell (exit-code retention for late waiters), real resize_shell (ResizePtyRequest), COLUMNS/LINES winsize env, write_shell_awaited, { packageDir } boot packages forwarded on ConfigureVm; adapt to the dir-only wire PackageDescriptor + ResourceSnapshotResponse. Core tests (from the earlier revision): pty-line-discipline matrix, vim suites, waitShell exit-code retention, pty-protocol snapshots. --- Cargo.lock | 1 + crates/agentos-actor-plugin/Cargo.toml | 1 + .../agentos-actor-plugin/src/actions/mod.rs | 103 +- .../src/actions/process.rs | 44 +- .../agentos-actor-plugin/src/actions/shell.rs | 233 +++ crates/agentos-actor-plugin/src/config.rs | 31 + crates/agentos-actor-plugin/src/lib.rs | 12 + crates/client/src/agent_os.rs | 37 +- crates/client/src/config.rs | 16 + crates/client/src/lib.rs | 3 + crates/client/src/shell.rs | 133 +- crates/client/tests/shell_pty_packages_e2e.rs | 114 ++ packages/agentos/src/actor-actions.ts | 40 +- packages/agentos/src/types.ts | 9 + packages/core/src/agent-os.ts | 46 +- .../pty-line-discipline.test.ts.snap | 1783 +++++++++++++++++ .../__snapshots__/pty-protocol.test.ts.snap | 54 +- packages/core/tests/fixtures/pty/pty_probe.c | 521 +++++ .../core/tests/fixtures/pty/pty_probe.mjs | 431 ++++ .../core/tests/pty-line-discipline.test.ts | 893 +++++++++ packages/core/tests/vim-interactive.test.ts | 254 +++ packages/core/tests/vim-provides.test.ts | 363 ++++ packages/core/vim-snap.mjs | 130 ++ packages/shell/package.json | 98 +- packages/shell/src/actor-server.ts | 55 + packages/shell/src/actor-vm.ts | 457 +++++ packages/shell/src/main.ts | 315 ++- 27 files changed, 5983 insertions(+), 194 deletions(-) create mode 100644 crates/agentos-actor-plugin/src/actions/shell.rs create mode 100644 crates/client/tests/shell_pty_packages_e2e.rs create mode 100644 packages/core/tests/__snapshots__/pty-line-discipline.test.ts.snap create mode 100644 packages/core/tests/fixtures/pty/pty_probe.c create mode 100644 packages/core/tests/fixtures/pty/pty_probe.mjs create mode 100644 packages/core/tests/pty-line-discipline.test.ts create mode 100644 packages/core/tests/vim-interactive.test.ts create mode 100644 packages/core/tests/vim-provides.test.ts create mode 100644 packages/core/vim-snap.mjs create mode 100644 packages/shell/src/actor-server.ts create mode 100644 packages/shell/src/actor-vm.ts diff --git a/Cargo.lock b/Cargo.lock index a24d77438b..c959c08a4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,6 +68,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "tracing-subscriber", "uuid", ] diff --git a/crates/agentos-actor-plugin/Cargo.toml b/crates/agentos-actor-plugin/Cargo.toml index 789a972135..a732d78adb 100644 --- a/crates/agentos-actor-plugin/Cargo.toml +++ b/crates/agentos-actor-plugin/Cargo.toml @@ -30,6 +30,7 @@ bytes = "1" http = "1" uuid = { version = "1", features = ["v4"] } tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } futures = "0.3" [dev-dependencies] diff --git a/crates/agentos-actor-plugin/src/actions/mod.rs b/crates/agentos-actor-plugin/src/actions/mod.rs index 1585c39c2c..9c9ce750df 100644 --- a/crates/agentos-actor-plugin/src/actions/mod.rs +++ b/crates/agentos-actor-plugin/src/actions/mod.rs @@ -16,6 +16,7 @@ pub mod network; pub mod preview; pub mod process; pub mod session; +pub mod shell; use std::collections::HashMap; @@ -41,6 +42,11 @@ pub struct Vars { pub capture_tasks: HashMap>, /// `live_session_id -> permission-request pump task`. pub permission_tasks: HashMap>, + /// Shell data/stderr/exit broadcast pump tasks (one triple per `openShell`). + /// The pumps end on their own when the shell exits (stream close); this + /// list exists so VM teardown aborts any still-live pumps. Bounded by the + /// client's shell registries, not here. + pub shell_tasks: Vec>, } impl Vars { @@ -62,6 +68,9 @@ impl Vars { for (_, task) in self.permission_tasks.drain() { task.abort(); } + for task in self.shell_tasks.drain(..) { + task.abort(); + } self.live_sessions.clear(); } } @@ -207,18 +216,45 @@ pub(crate) async fn dispatch( }, Err(error) => reply_err(host, token, error), }, - "spawn" => match decode_as::<(String, Vec)>(args) { - Ok((command, spawn_args)) => match process::spawn(vm, &command, spawn_args) { - Ok(handle) => reply_ok(host, token, &handle), + "spawn" => { + // The trailing options object is optional on the TS side, so a + // 2-arg call decodes via the fallback. + let decoded = + decode_as::<(String, Vec, Option)>(args) + .or_else(|_| { + decode_as::<(String, Vec)>(args) + .map(|(command, spawn_args)| (command, spawn_args, None)) + }); + match decoded { + Ok((command, spawn_args, options)) => match process::spawn( + host, + vm, + vars, + &command, + spawn_args, + options.unwrap_or_default(), + ) { + Ok(handle) => reply_ok(host, token, &handle), + Err(error) => reply_err(host, token, error), + }, Err(error) => reply_err(host, token, error), - }, - Err(error) => reply_err(host, token, error), - }, + } + } + // Long-running wait: replies from a spawned task so it does not occupy + // the serial action worker (a waitProcess held for the process lifetime + // would starve every later action, including the stdin writes the + // process needs to make progress). "waitProcess" => match decode_as::<(u32,)>(args) { - Ok((pid,)) => match process::wait_process(vm, pid).await { - Ok(code) => reply_ok(host, token, &code), - Err(error) => reply_err(host, token, error), - }, + Ok((pid,)) => { + let host = host.clone(); + let vm = vm.clone(); + vars.shell_tasks.push(tokio::spawn(async move { + match process::wait_process(&vm, pid).await { + Ok(code) => reply_ok(&host, token, &code), + Err(error) => reply_err(&host, token, error), + } + })); + } Err(error) => reply_err(host, token, error), }, "killProcess" => match decode_as::<(u32,)>(args) { @@ -373,6 +409,53 @@ pub(crate) async fn dispatch( }, Err(error) => reply_err(host, token, error), }, + "openShell" => match decode_as::<(Option,)>(args) { + Ok((options,)) => { + match shell::open_shell(host, vm, vars, options.unwrap_or_default()) { + Ok(dto) => reply_ok(host, token, &dto), + Err(error) => reply_err(host, token, error), + } + } + Err(error) => reply_err(host, token, error), + }, + "writeShell" => match decode_as::<(String, WriteFileContent)>(args) { + Ok((shell_id, data)) => match shell::write_shell(vm, &shell_id, data).await { + Ok(()) => reply_ok(host, token, &()), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "resizeShell" => match decode_as::<(String, u16, u16)>(args) { + Ok((shell_id, cols, rows)) => match shell::resize_shell(vm, &shell_id, cols, rows) { + Ok(()) => reply_ok(host, token, &()), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "closeShell" => match decode_as::<(String,)>(args) { + Ok((shell_id,)) => match shell::close_shell(vm, &shell_id) { + Ok(()) => reply_ok(host, token, &()), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + // Long-running wait: replies from a spawned task so it does not occupy + // the serial action worker (the shell CLI calls waitShell up front and + // streams writeShell input afterwards; holding the worker here would + // deadlock the shell — input can never arrive to end the wait). + "waitShell" => match decode_as::<(String,)>(args) { + Ok((shell_id,)) => { + let host = host.clone(); + let vm = vm.clone(); + vars.shell_tasks.push(tokio::spawn(async move { + match shell::wait_shell(&vm, &shell_id).await { + Ok(exit_code) => reply_ok(&host, token, &exit_code), + Err(error) => reply_err(&host, token, error), + } + })); + } + Err(error) => reply_err(host, token, error), + }, other => { host.reply_err( token, diff --git a/crates/agentos-actor-plugin/src/actions/process.rs b/crates/agentos-actor-plugin/src/actions/process.rs index b154d0be0b..91bf8bb8c3 100644 --- a/crates/agentos-actor-plugin/src/actions/process.rs +++ b/crates/agentos-actor-plugin/src/actions/process.rs @@ -18,11 +18,45 @@ pub async fn exec(vm: &AgentOs, command: &str) -> Result { .map(ExecResultDto::from) } -/// `spawn(command, args)` — port of [`AgentOs::spawn`]. Returns the -/// [`SpawnHandle`] `{ pid }` directly; the underlying type already -/// derives `Serialize`. -pub fn spawn(vm: &AgentOs, command: &str, args: Vec) -> Result { - vm.spawn(command, args, SpawnOptions::default()) +/// JSON options for the `spawn` action — the serializable subset of the TS +/// `SpawnOptions` (output callbacks are replaced by the `processOutput` / +/// `processExit` broadcasts). +#[derive(Debug, Default, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpawnActionOptions { + #[serde(default)] + pub env: std::collections::BTreeMap, + pub cwd: Option, + pub stream_stdin: Option, +} + +/// `spawn(command, args, options?)` — port of [`AgentOs::spawn`]. Returns the +/// [`SpawnHandle`] `{ pid }`; stdout/stderr chunks stream to connected clients +/// as `processOutput` events and the exit code broadcasts as `processExit`. +pub fn spawn( + host: &crate::host_ctx::HostCtx, + vm: &AgentOs, + vars: &mut super::Vars, + command: &str, + args: Vec, + options: SpawnActionOptions, +) -> Result { + let mut base = ExecOptions::default(); + base.env = options.env; + if options.cwd.is_some() { + base.cwd = options.cwd; + } + let handle = vm.spawn( + command, + args, + SpawnOptions { + base, + stream_stdin: options.stream_stdin, + ..SpawnOptions::default() + }, + )?; + super::shell::spawn_process_output_pumps(host, vm, vars, handle.pid); + Ok(handle) } /// `waitProcess(pid)` — port of [`AgentOs::wait_process`]. Returns the diff --git a/crates/agentos-actor-plugin/src/actions/shell.rs b/crates/agentos-actor-plugin/src/actions/shell.rs new file mode 100644 index 0000000000..4d3087f694 --- /dev/null +++ b/crates/agentos-actor-plugin/src/actions/shell.rs @@ -0,0 +1,233 @@ +//! Shell actions — the actor-side port of the `AgentOs` PTY shell surface +//! (`openShell` / `writeShell` / `resizeShell` / `closeShell` / `waitShell`). +//! +//! `open_shell` subscribes the shell's stdout/stderr streams and pumps each +//! chunk to connected clients as `shellData` / `shellStderr` broadcasts (the +//! event-driven mirror of the TS `onShellData` subscription); a third task +//! broadcasts `shellExit` when the shell process exits. Pump task handles are +//! tracked in [`super::Vars::shell_tasks`] so VM teardown aborts them. + +use agentos_client::{AgentOs, OpenShellOptions, StdinInput}; +use anyhow::Result; +use futures::StreamExt; +use serde::{Deserialize, Serialize}; + +use crate::host_ctx::HostCtx; + +use super::Vars; + +/// JSON options for the `openShell` action — the serializable subset of the TS +/// `OpenShellOptions` (callbacks are replaced by the broadcast events). +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenShellActionOptions { + pub command: Option, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub env: std::collections::BTreeMap, + pub cwd: Option, + pub cols: Option, + pub rows: Option, +} + +/// Reply DTO for `openShell` (`{ shellId }`). +#[derive(Serialize)] +pub struct OpenShellDto { + #[serde(rename = "shellId")] + pub shell_id: String, +} + +/// Broadcast one event whose single handler argument is `payload`. The wire +/// body is the CBOR array of handler args with the JSON-compat byte wrapping +/// (`["$Uint8Array", base64]`), byte-exact with the action-reply encoding, so +/// `data: ByteBuf` fields arrive client-side as real `Uint8Array`s. +fn broadcast_event(host: &HostCtx, name: &[u8], payload: &T) { + match rivet_actor_plugin_abi::codec::encode_json_compat_to_vec(&(payload,)) { + Ok(bytes) => { + let _ = host.broadcast(name.to_vec(), bytes); + } + Err(error) => { + tracing::warn!(?error, "failed to encode shell event broadcast"); + } + } +} + +#[derive(Serialize)] +struct ShellDataEvent<'a> { + #[serde(rename = "shellId")] + shell_id: &'a str, + data: serde_bytes::ByteBuf, +} + +#[derive(Serialize)] +struct ShellExitEvent<'a> { + #[serde(rename = "shellId")] + shell_id: &'a str, + #[serde(rename = "exitCode")] + exit_code: i32, +} + +/// `openShell(options)` — port of [`AgentOs::open_shell`]. Subscribes the +/// data/stderr streams and the exit code, forwarding them as `shellData` / +/// `shellStderr` / `shellExit` broadcasts. +pub fn open_shell( + host: &HostCtx, + vm: &AgentOs, + vars: &mut Vars, + options: OpenShellActionOptions, +) -> Result { + let handle = vm.open_shell(OpenShellOptions { + command: options.command, + args: options.args, + env: options.env, + cwd: options.cwd, + cols: options.cols, + rows: options.rows, + on_stderr: None, + })?; + let shell_id = handle.shell_id; + + let mut data_stream = vm.on_shell_data(&shell_id)?; + let mut stderr_stream = vm.on_shell_stderr(&shell_id)?; + + let data_host = host.clone(); + let data_shell_id = shell_id.clone(); + vars.shell_tasks.push(tokio::spawn(async move { + while let Some(chunk) = data_stream.next().await { + broadcast_event( + &data_host, + b"shellData", + &ShellDataEvent { + shell_id: &data_shell_id, + data: serde_bytes::ByteBuf::from(chunk), + }, + ); + } + })); + + let stderr_host = host.clone(); + let stderr_shell_id = shell_id.clone(); + vars.shell_tasks.push(tokio::spawn(async move { + while let Some(chunk) = stderr_stream.next().await { + broadcast_event( + &stderr_host, + b"shellStderr", + &ShellDataEvent { + shell_id: &stderr_shell_id, + data: serde_bytes::ByteBuf::from(chunk), + }, + ); + } + })); + + let exit_host = host.clone(); + let exit_vm = vm.clone(); + let exit_shell_id = shell_id.clone(); + vars.shell_tasks.push(tokio::spawn(async move { + if let Ok(exit_code) = exit_vm.wait_shell(&exit_shell_id).await { + broadcast_event( + &exit_host, + b"shellExit", + &ShellExitEvent { + shell_id: &exit_shell_id, + exit_code, + }, + ); + } + })); + + Ok(OpenShellDto { shell_id }) +} + +#[derive(Serialize)] +struct ProcessOutputEvent<'a> { + pid: u32, + stream: &'a str, + data: serde_bytes::ByteBuf, +} + +#[derive(Serialize)] +struct ProcessExitEvent { + pid: u32, + #[serde(rename = "exitCode")] + exit_code: i32, +} + +/// Subscribe a spawned process's stdout/stderr/exit and forward them to +/// connected clients as `processOutput` / `processExit` broadcasts. Used by +/// the `spawn` action so actor clients get the streaming the TS `SpawnOptions` +/// callbacks provide in-process. +pub fn spawn_process_output_pumps(host: &HostCtx, vm: &AgentOs, vars: &mut Vars, pid: u32) { + let stdout = vm.on_process_stdout(pid); + let stderr = vm.on_process_stderr(pid); + + if let Ok(mut stream) = stdout { + let host = host.clone(); + vars.shell_tasks.push(tokio::spawn(async move { + while let Some(chunk) = stream.next().await { + broadcast_event( + &host, + b"processOutput", + &ProcessOutputEvent { + pid, + stream: "stdout", + data: serde_bytes::ByteBuf::from(chunk), + }, + ); + } + })); + } + if let Ok(mut stream) = stderr { + let host = host.clone(); + vars.shell_tasks.push(tokio::spawn(async move { + while let Some(chunk) = stream.next().await { + broadcast_event( + &host, + b"processOutput", + &ProcessOutputEvent { + pid, + stream: "stderr", + data: serde_bytes::ByteBuf::from(chunk), + }, + ); + } + })); + } + let host = host.clone(); + let vm = vm.clone(); + vars.shell_tasks.push(tokio::spawn(async move { + if let Ok(exit_code) = vm.wait_process(pid).await { + broadcast_event(&host, b"processExit", &ProcessExitEvent { pid, exit_code }); + } + })); +} + +/// `writeShell(shellId, data)` — port of [`AgentOs::write_shell`], but awaited +/// so a failed wire write rejects the action instead of vanishing into the +/// fire-and-forget warn. +pub async fn write_shell( + vm: &AgentOs, + shell_id: &str, + data: super::filesystem::WriteFileContent, +) -> Result<()> { + vm.write_shell_awaited(shell_id, StdinInput::Bytes(data.into_bytes())) + .await + .map_err(anyhow::Error::from) +} + +/// `resizeShell(shellId, cols, rows)` — port of [`AgentOs::resize_shell`]. +pub fn resize_shell(vm: &AgentOs, shell_id: &str, cols: u16, rows: u16) -> Result<()> { + vm.resize_shell(shell_id, cols, rows) + .map_err(anyhow::Error::from) +} + +/// `closeShell(shellId)` — port of [`AgentOs::close_shell`]. +pub fn close_shell(vm: &AgentOs, shell_id: &str) -> Result<()> { + vm.close_shell(shell_id).map_err(anyhow::Error::from) +} + +/// `waitShell(shellId)` — port of [`AgentOs::wait_shell`]. Returns the exit code. +pub async fn wait_shell(vm: &AgentOs, shell_id: &str) -> Result { + vm.wait_shell(shell_id).await.map_err(anyhow::Error::from) +} diff --git a/crates/agentos-actor-plugin/src/config.rs b/crates/agentos-actor-plugin/src/config.rs index 78ac30e88e..2757c20a12 100644 --- a/crates/agentos-actor-plugin/src/config.rs +++ b/crates/agentos-actor-plugin/src/config.rs @@ -27,6 +27,18 @@ use anyhow::{Context, Result}; pub(crate) struct AgentOsConfigJson { #[serde(default)] software: Vec, + /// `{ packageDir }` boot packages (the current TS `buildConfigJson` shape). + #[serde(default)] + packages: Vec, + /// Guest mount root for the package projection (`/opt/agentos`). + #[serde(default)] + packages_mount_at: Option, + /// Agent ACP adapter configs derived from package manifests. Accepted for + /// schema parity with the TS `buildConfigJson`; adapter registration is not + /// yet ported to the plugin, so a non-empty list logs a warning at + /// bring-up. + #[serde(default)] + agent_configs: Vec, #[serde(default)] additional_instructions: Option, #[serde(default)] @@ -47,6 +59,12 @@ pub(crate) struct AgentOsConfigJson { sidecar: Option, } +#[derive(serde::Deserialize, Clone)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct PackageDirJson { + dir: String, +} + #[derive(serde::Deserialize, Clone)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct NativeMountJson { @@ -91,8 +109,21 @@ impl AgentOsConfigJson { pool: Some(fallback_pool.to_owned()), }, }; + if !self.agent_configs.is_empty() { + tracing::warn!( + count = self.agent_configs.len(), + "agentConfigs are not yet applied by the actor plugin; package \ + agents will not be registered for sessions" + ); + } AgentOsConfig { software: self.software.clone(), + packages: self + .packages + .iter() + .map(|package| package.dir.clone()) + .collect(), + packages_mount_at: self.packages_mount_at.clone(), loopback_exempt_ports: self.loopback_exempt_ports.clone(), allowed_node_builtins: self.allowed_node_builtins.clone(), module_access_cwd: self.module_access_cwd.clone(), diff --git a/crates/agentos-actor-plugin/src/lib.rs b/crates/agentos-actor-plugin/src/lib.rs index e4b6b4d0cd..c321e37346 100644 --- a/crates/agentos-actor-plugin/src/lib.rs +++ b/crates/agentos-actor-plugin/src/lib.rs @@ -60,6 +60,16 @@ pub extern "C" fn rivet_actor_abi_version() -> u64 { #[no_mangle] pub extern "C" fn rivet_actor_plugin_init(out_err: *mut abi::OwnedBuf) -> *mut c_void { + // Debug-only tracing to stderr, gated on AGENTOS_PLUGIN_LOG (RUST_LOG + // syntax). Without a subscriber every tracing::warn! from the embedded + // agentos-client (e.g. a failed fire-and-forget shell write) is silently + // dropped, which makes plugin-side failures undiagnosable. + if let Ok(filter) = std::env::var("AGENTOS_PLUGIN_LOG") { + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::new(filter)) + .with_writer(std::io::stderr) + .try_init(); + } let built = std::panic::catch_unwind(|| { tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -350,8 +360,10 @@ async fn actor_worker( }; match abi::decode_action_payload(&payload) { Ok((name, action_args)) => { + tracing::debug!(action = %name, "agent-os action start"); actions::dispatch(&host, vm_ref, &mut vars, &name, &action_args, token) .await; + tracing::debug!(action = %name, "agent-os action done"); } Err(_) => { let _ = host.reply_err(token, "malformed action event payload"); diff --git a/crates/client/src/agent_os.rs b/crates/client/src/agent_os.rs index 3445df694d..11217d9a70 100644 --- a/crates/client/src/agent_os.rs +++ b/crates/client/src/agent_os.rs @@ -85,6 +85,9 @@ pub(crate) struct ShellEntry { /// Rust wire spawn is async, so `write_shell`/`close_shell` await this gate before issuing their /// wire request to preserve the deterministic ordering and avoid dropping early input. pub spawned_tx: watch::Sender, + /// Exit-code channel backing `wait_shell` (TS `ShellHandle.wait`). Seeded `None`; the background + /// event loop publishes `Some(exit_code)` when the shell process exits. + pub exit_tx: watch::Sender>, } /// A connected ACP terminal process and its output fan-out task. @@ -195,6 +198,10 @@ pub(crate) struct AgentOsInner { pub(crate) shells: SccHashMap, pub(crate) shell_counter: AtomicU64, pub(crate) pending_shell_exits: SccHashMap>, + /// Bounded ordered map (cap [`crate::CLOSED_SHELL_EXIT_CODE_RETENTION_LIMIT`]) of exited shells' + /// exit codes, so `wait_shell` issued after the shell already exited (entry dropped from + /// `shells`) still resolves with the recorded code — mirrors the TS `_closedShellIds` retention. + pub(crate) closed_shell_exit_codes: parking_lot::Mutex>, pub(crate) acp_terminals: SccHashMap, pub(crate) acp_terminal_count: AtomicUsize, pub(crate) acp_terminal_lifecycle_lock: tokio::sync::Mutex<()>, @@ -283,6 +290,7 @@ impl AgentOs { | wire::ResponsePayload::StdinClosedResponse(_) | wire::ResponsePayload::ProcessKilledResponse(_) | wire::ResponsePayload::ProcessSnapshotResponse(_) + | wire::ResponsePayload::ResourceSnapshotResponse(_) | wire::ResponsePayload::ListenerSnapshotResponse(_) | wire::ResponsePayload::BoundUdpSnapshotResponse(_) | wire::ResponsePayload::SignalStateResponse(_) @@ -350,6 +358,7 @@ impl AgentOs { | wire::ResponsePayload::StdinClosedResponse(_) | wire::ResponsePayload::ProcessKilledResponse(_) | wire::ResponsePayload::ProcessSnapshotResponse(_) + | wire::ResponsePayload::ResourceSnapshotResponse(_) | wire::ResponsePayload::ListenerSnapshotResponse(_) | wire::ResponsePayload::BoundUdpSnapshotResponse(_) | wire::ResponsePayload::SignalStateResponse(_) @@ -390,9 +399,18 @@ impl AgentOs { // 6. Configure the VM (vm scope). The sidecar owns the `/opt/agentos` package // projection: it builds the staging dir + registers the read-only host_dir - // mount itself from the forwarded `packages`. The Rust client projects no boot - // packages, so an empty `packages` list still yields a live `/opt/agentos` - // mount that runtime `link_software` can append to. + // mount itself from the forwarded `packages` (`{ packageDir }` dirs from + // `config.packages`); an empty `packages` list still yields a live + // `/opt/agentos` mount that runtime `link_software` can append to. + let packages: Vec = config + .packages + .iter() + .map(|dir| wire::PackageDescriptor { dir: dir.clone() }) + .collect(); + let packages_mount_at = config + .packages_mount_at + .clone() + .unwrap_or_else(|| String::from("/opt/agentos")); match transport .request_wire( wire_vm_ownership(&connection_id, &session_id, &vm_id), @@ -405,8 +423,8 @@ impl AgentOs { projected_modules: Vec::new(), command_permissions: HashMap::new(), loopback_exempt_ports: config.loopback_exempt_ports.clone(), - packages: Vec::new(), - packages_mount_at: String::new(), + packages, + packages_mount_at, }), ) .await? @@ -434,6 +452,7 @@ impl AgentOs { | wire::ResponsePayload::StdinClosedResponse(_) | wire::ResponsePayload::ProcessKilledResponse(_) | wire::ResponsePayload::ProcessSnapshotResponse(_) + | wire::ResponsePayload::ResourceSnapshotResponse(_) | wire::ResponsePayload::ListenerSnapshotResponse(_) | wire::ResponsePayload::BoundUdpSnapshotResponse(_) | wire::ResponsePayload::SignalStateResponse(_) @@ -508,10 +527,11 @@ impl AgentOs { | wire::ResponsePayload::RootFilesystemSnapshotResponse(_) | wire::ResponsePayload::ProcessStartedResponse(_) | wire::ResponsePayload::StdinWrittenResponse(_) - | wire::ResponsePayload::PtyResizedResponse(_) + | wire::ResponsePayload::PtyResizedResponse(_) | wire::ResponsePayload::StdinClosedResponse(_) | wire::ResponsePayload::ProcessKilledResponse(_) | wire::ResponsePayload::ProcessSnapshotResponse(_) + | wire::ResponsePayload::ResourceSnapshotResponse(_) | wire::ResponsePayload::ListenerSnapshotResponse(_) | wire::ResponsePayload::BoundUdpSnapshotResponse(_) | wire::ResponsePayload::SignalStateResponse(_) @@ -570,6 +590,7 @@ impl AgentOs { shells: SccHashMap::new(), shell_counter: AtomicU64::new(0), pending_shell_exits: SccHashMap::new(), + closed_shell_exit_codes: parking_lot::Mutex::new(VecDeque::new()), acp_terminals: SccHashMap::new(), acp_terminal_count: AtomicUsize::new(0), acp_terminal_lifecycle_lock: tokio::sync::Mutex::new(()), @@ -627,10 +648,10 @@ impl AgentOs { .request_wire( wire_vm_ownership(&inner.connection_id, &inner.session_id, &inner.vm_id), wire::RequestPayload::LinkPackageRequest(wire::LinkPackageRequest { + // The wire descriptor is dir-only: the sidecar derives the package + // name/commands/manifest from the package directory itself. package: wire::PackageDescriptor { - name: descriptor.name, dir: descriptor.dir, - acp_entrypoint: descriptor.acp_entrypoint, }, }), ) diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs index 7ef2859970..07d9b02c2b 100644 --- a/crates/client/src/config.rs +++ b/crates/client/src/config.rs @@ -22,6 +22,12 @@ use crate::fs::VirtualFileSystem; pub struct AgentOsConfig { /// Software packages to install (flattened). Default `[]`. pub software: Vec, + /// `{ packageDir }` software package directories, forwarded to the sidecar + /// as boot `packages` (the sidecar owns the `/opt/agentos` projection). + /// Mirrors the TS `buildConfigJson` `packages` list. Default `[]`. + pub packages: Vec, + /// Guest mount root for the package projection. Default `/opt/agentos`. + pub packages_mount_at: Option, /// Loopback ports exempt from the default outbound-to-host block. pub loopback_exempt_ports: Vec, /// Allowed Node.js builtins. Default: the hardened native-bridge set. @@ -69,6 +75,16 @@ impl AgentOsConfigBuilder { self } + pub fn packages(mut self, packages: Vec) -> Self { + self.config.packages = packages; + self + } + + pub fn packages_mount_at(mut self, mount_at: impl Into) -> Self { + self.config.packages_mount_at = Some(mount_at.into()); + self + } + pub fn loopback_exempt_ports(mut self, ports: Vec) -> Self { self.config.loopback_exempt_ports = ports; self diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 1854f7068a..9b3cc0fcff 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -42,6 +42,9 @@ pub const PERMISSION_TIMEOUT_MS: u64 = 120_000; /// Bounded closed-session-id set capacity (for `close_session` idempotence). pub const CLOSED_SESSION_ID_RETENTION_LIMIT: usize = 2048; +/// Bounded exited-shell exit-code retention (for `wait_shell` after exit). +pub const CLOSED_SHELL_EXIT_CODE_RETENTION_LIMIT: usize = 2048; + /// Two-phase shell-drain timeout during dispose (milliseconds). pub const SHELL_DISPOSE_TIMEOUT_MS: u64 = 5_000; diff --git a/crates/client/src/shell.rs b/crates/client/src/shell.rs index af628fe037..7129ac1b98 100644 --- a/crates/client/src/shell.rs +++ b/crates/client/src/shell.rs @@ -244,6 +244,8 @@ impl AgentOs { let (stderr_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY); // Spawn-readiness gate: write/close await this before issuing their wire request. let (spawned_tx, _) = tokio::sync::watch::channel(false); + // Exit-code channel backing `wait_shell`. + let (exit_tx, _) = tokio::sync::watch::channel(None::); // Seed any caller-provided initial stderr callback into the stderr fan-out, matching the TS // initial-handler-set behavior (`stderrHandlers.add(options.onStderr)`). @@ -259,6 +261,7 @@ impl AgentOs { stderr_tx: stderr_tx.clone(), process_id: process_id.clone(), spawned_tx: spawned_tx.clone(), + exit_tx: exit_tx.clone(), }; // `insert` fails only if the key already exists; the monotonic counter guarantees it cannot. let _ = inner.shells.insert(shell_id.clone(), entry); @@ -270,6 +273,15 @@ impl AgentOs { options .env .insert(String::from("AGENTOS_EXEC_TTY"), String::from("1")); + // Seed the PTY winsize env exactly like the TS openShell (COLUMNS/LINES). + if let Some(cols) = options.cols { + options + .env + .insert(String::from("COLUMNS"), cols.to_string()); + } + if let Some(rows) = options.rows { + options.env.insert(String::from("LINES"), rows.to_string()); + } let execute = wire::ExecuteRequest { process_id: process_id.clone(), command: Some(command), @@ -322,7 +334,12 @@ impl AgentOs { .shells .update(&exit_shell_id, |_, existing| existing.pid = pid); } - let _ = spawned_tx.send(true); + // send_replace, not send: `watch::Sender::send` REFUSES to store the + // value while no receiver exists (and the initial receiver is dropped + // at channel creation), which left the spawn gate permanently false + // for any write/resize issued after this point — they hung forever in + // wait_for_spawn. send_replace stores unconditionally. + let _ = spawned_tx.send_replace(true); loop { let (_scope, payload) = match events.recv().await { @@ -347,6 +364,18 @@ impl AgentOs { } EventPayload::ProcessExitedEvent(exited) => { if exited.process_id == route_process_id { + // Record the exit code for `wait_shell`: live waiters observe the watch + // update; late waiters (after the entry is dropped below) find it in the + // bounded retention map, mirroring the TS closed-shell retention. + { + let mut retained = agent.inner().closed_shell_exit_codes.lock(); + retained.push_back((exit_shell_id.clone(), exited.exit_code)); + while retained.len() > crate::CLOSED_SHELL_EXIT_CODE_RETENTION_LIMIT + { + retained.pop_front(); + } + } + let _ = exit_tx.send(Some(exited.exit_code)); break; } } @@ -397,6 +426,8 @@ impl AgentOs { stderr_tx: stderr_tx.clone(), process_id: process_id.clone(), spawned_tx: spawned_tx.clone(), + // The caller-supplied exit channel doubles as the entry's `wait_shell` source. + exit_tx: exit_tx.clone(), }; let _ = inner.shells.insert(shell_id.clone(), entry); @@ -452,7 +483,12 @@ impl AgentOs { .shells .update(&exit_shell_id, |_, existing| existing.pid = pid); } - let _ = spawned_tx.send(true); + // send_replace, not send: `watch::Sender::send` REFUSES to store the + // value while no receiver exists (and the initial receiver is dropped + // at channel creation), which left the spawn gate permanently false + // for any write/resize issued after this point — they hung forever in + // wait_for_spawn. send_replace stores unconditionally. + let _ = spawned_tx.send_replace(true); let mut exit_code: i32 = 0; loop { @@ -690,6 +726,32 @@ impl AgentOs { Ok(()) } + /// Write to a shell and AWAIT the wire write. Same routing as [`Self::write_shell`], but the + /// caller observes wire failures instead of a fire-and-forget warn — used by the actor plugin's + /// `writeShell` action so a failed write rejects the action. + pub async fn write_shell_awaited( + &self, + shell_id: &str, + data: StdinInput, + ) -> std::result::Result<(), ClientError> { + let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?; + let chunk = stdin_chunk(data); + tracing::debug!(shell_id, "write_shell_awaited: waiting for spawn gate"); + wait_for_spawn(spawned_rx).await; + tracing::debug!(shell_id, "write_shell_awaited: issuing wire write"); + let payload = + wire::RequestPayload::WriteStdinRequest(wire::WriteStdinRequest { process_id, chunk }); + let response = self + .transport() + .request_wire(self.vm_ownership(), payload) + .await?; + tracing::debug!(shell_id, "write_shell_awaited: wire write acked"); + match response { + wire::ResponsePayload::RejectedResponse(rejected) => Err(rejected_to_error(rejected)), + _ => Ok(()), + } + } + /// Subscribe to a shell's stdout data. SYNC register; multi-handler; dropping the returned stream /// is the unsubscribe. Carries stdout ONLY (stderr is on `on_shell_stderr`). Errors with /// [`ClientError::ShellNotFound`]. @@ -712,11 +774,9 @@ impl AgentOs { .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string())) } - /// Resize a shell's PTY winsize. SYNC. Errors with [`ClientError::ShellNotFound`]. - /// - /// Validates shell existence (the load-bearing parity behavior). The native wire protocol has no - /// winsize request, so the resize itself is currently a best-effort no-op (the synthetic TS - /// kernel path is likewise a no-op). + /// Resize a shell's PTY winsize. SYNC fire-and-forget, mirroring the TS `ShellHandle.resize` + /// (which dispatches `resizePty` in the background after the spawn lands). Errors with + /// [`ClientError::ShellNotFound`]. pub fn resize_shell( &self, shell_id: &str, @@ -724,16 +784,61 @@ impl AgentOs { rows: u16, ) -> std::result::Result<(), ClientError> { // Existence check matches the TS `if (!entry) throw Shell not found`. - let _ = self.shell_wire_handle(shell_id)?; - tracing::warn!( - shell_id = %shell_id, - cols, - rows, - "resize_shell has no native winsize wire op; resize is a no-op" - ); + let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?; + + let agent = self.clone(); + let ownership = self.vm_ownership(); + tokio::spawn(async move { + wait_for_spawn(spawned_rx).await; + let payload = wire::RequestPayload::ResizePtyRequest(wire::ResizePtyRequest { + process_id, + cols, + rows, + }); + if let Err(error) = agent.transport().request_wire(ownership, payload).await { + tracing::warn!(?error, "resize_shell failed"); + } + }); + Ok(()) } + /// Wait for a shell to exit and return its process exit code (TS `waitShell`). Resolves + /// immediately for a shell that already exited within the bounded retention window. Errors with + /// [`ClientError::ShellNotFound`] for an unknown id. + pub async fn wait_shell(&self, shell_id: &str) -> std::result::Result { + let exit_rx = self + .inner() + .shells + .read(shell_id, |_, entry| entry.exit_tx.subscribe()); + let Some(mut exit_rx) = exit_rx else { + // Entry already dropped: fall back to the recorded exit code (TS retention behavior). + let retained = self.inner().closed_shell_exit_codes.lock(); + return retained + .iter() + .rev() + .find(|(id, _)| id == shell_id) + .map(|(_, code)| *code) + .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string())); + }; + loop { + if let Some(code) = *exit_rx.borrow_and_update() { + return Ok(code); + } + if exit_rx.changed().await.is_err() { + // Sender dropped without publishing a code (spawn failure / teardown): check the + // retention map once more before reporting the shell unknown. + let retained = self.inner().closed_shell_exit_codes.lock(); + return retained + .iter() + .rev() + .find(|(id, _)| id == shell_id) + .map(|(_, code)| *code) + .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string())); + } + } + } + /// Close a shell. SYNC. `kill()` + immediate map delete; the exit task is still drained by /// `dispose`. Errors with [`ClientError::ShellNotFound`]. pub fn close_shell(&self, shell_id: &str) -> std::result::Result<(), ClientError> { diff --git a/crates/client/tests/shell_pty_packages_e2e.rs b/crates/client/tests/shell_pty_packages_e2e.rs new file mode 100644 index 0000000000..c8b1896834 --- /dev/null +++ b/crates/client/tests/shell_pty_packages_e2e.rs @@ -0,0 +1,114 @@ +//! PTY shell round-trip against a `{ packageDir }` boot package — the exact +//! surface the actor plugin drives for the shell CLI's `--actor` mode: +//! `packages` config → sidecar `/opt/agentos` projection → `open_shell` (TTY) +//! → `write_shell` input (cooked echo) → `on_shell_data` output → `wait_shell` +//! exit code. Skips cleanly when the coreutils package dir is absent. + +mod common; + +use std::path::PathBuf; + +use agentos_client::{AgentOs, AgentOsConfig, OpenShellOptions, StdinInput}; +use futures::StreamExt; + +fn coreutils_package_dir() -> Option { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../node_modules/@agentos-software/coreutils/dist/package"); + if dir.join("agentos-package.json").is_file() { + std::fs::canonicalize(dir).ok() + } else { + None + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn pty_shell_round_trip_via_boot_packages() { + if !common::require_sidecar("pty_shell_round_trip_via_boot_packages") { + return; + } + let Some(package_dir) = coreutils_package_dir() else { + eprintln!("skipping: coreutils package dir not materialized"); + return; + }; + + common::ensure_sidecar_env(); + let os = AgentOs::create(AgentOsConfig { + packages: vec![package_dir.to_string_lossy().into_owned()], + ..Default::default() + }) + .await + .expect("create VM with boot packages"); + + let shell = os + .open_shell(OpenShellOptions { + command: Some(String::from("sh")), + args: vec![ + String::from("-c"), + String::from("echo before-read; read line; echo got:$line"), + ], + cwd: Some(String::from("/")), + cols: Some(80), + rows: Some(24), + ..Default::default() + }) + .expect("open_shell"); + + let mut data = os + .on_shell_data(&shell.shell_id) + .expect("on_shell_data subscription"); + + // Wait for the guest to reach its read() before writing. + let saw_before = tokio::time::timeout(std::time::Duration::from_secs(20), async { + let mut acc = Vec::::new(); + while let Some(chunk) = data.next().await { + acc.extend_from_slice(&chunk); + if String::from_utf8_lossy(&acc).contains("before-read") { + return true; + } + } + false + }) + .await + .unwrap_or(false); + assert!(saw_before, "guest banner must arrive on the data stream"); + + // Give the guest time to enter its blocking stdin read before writing, so + // this test also covers the delayed-write path (a host write must not be + // starved by the guest's in-flight blocking read). + tokio::time::sleep(std::time::Duration::from_secs(4)).await; + os.write_shell( + &shell.shell_id, + StdinInput::Text(String::from("marker-input\n")), + ) + .expect("write_shell"); + + // The cooked PTY must deliver the line to the guest's read() and surface + // `got:marker-input` (plus the input echo) on the data stream. + let saw_roundtrip = tokio::time::timeout(std::time::Duration::from_secs(20), async { + let mut acc = Vec::::new(); + while let Some(chunk) = data.next().await { + acc.extend_from_slice(&chunk); + if String::from_utf8_lossy(&acc).contains("got:marker-input") { + return true; + } + } + false + }) + .await + .unwrap_or(false); + assert!( + saw_roundtrip, + "write_shell input must reach the guest read() and its output must surface" + ); + + let exit_code = tokio::time::timeout( + std::time::Duration::from_secs(20), + os.wait_shell(&shell.shell_id), + ) + .await + .expect("wait_shell must resolve after the guest exits") + .expect("wait_shell result"); + assert_eq!(exit_code, 0, "clean guest exit"); + + os.shutdown().await.expect("shutdown"); +} diff --git a/packages/agentos/src/actor-actions.ts b/packages/agentos/src/actor-actions.ts index 8f3412a7e9..ce8d438b59 100644 --- a/packages/agentos/src/actor-actions.ts +++ b/packages/agentos/src/actor-actions.ts @@ -44,6 +44,13 @@ export interface SpawnedProcess { pid: number; } +/** Options accepted by `spawn` (mirrors `SpawnActionOptions`). */ +export interface SpawnActionOptions { + env?: Record; + cwd?: string; + streamStdin?: boolean; +} + /** Handle returned by `scheduleCron` (mirrors `ScheduledCronDto`). */ export interface ScheduledCronJob { id: string; @@ -79,6 +86,21 @@ export interface SignedPreviewUrl { expiresAt: number; } +/** Options accepted by `openShell` (mirrors `OpenShellActionOptions`). */ +export interface OpenShellActionOptions { + command?: string; + args?: string[]; + env?: Record; + cwd?: string; + cols?: number; + rows?: number; +} + +/** Handle returned by `openShell` (mirrors `OpenShellDto`). */ +export interface OpenShellResult { + shellId: string; +} + /** Per-entry result of the batch `writeFiles` / `readFiles` actions. */ export interface WriteFileResult { path: string; @@ -116,7 +138,14 @@ export type AgentOsActions = { // ── Processes ───────────────────────────────────────────────────── exec: (c: Ctx, command: string) => Promise; - spawn: (c: Ctx, command: string, args: string[]) => Promise; + // Output streams to connected clients as `processOutput` events; the exit + // code also broadcasts as `processExit`. + spawn: ( + c: Ctx, + command: string, + args: string[], + options?: SpawnActionOptions, + ) => Promise; waitProcess: (c: Ctx, pid: number) => Promise; killProcess: (c: Ctx, pid: number) => Promise; stopProcess: (c: Ctx, pid: number) => Promise; @@ -127,6 +156,15 @@ export type AgentOsActions = { writeProcessStdin: (c: Ctx, pid: number, data: string | Uint8Array) => Promise; closeProcessStdin: (c: Ctx, pid: number) => Promise; + // ── Shells (PTY) ────────────────────────────────────────────────── + // Output streams to connected clients as `shellData` / `shellStderr` + // events; the exit code also broadcasts as `shellExit`. + openShell: (c: Ctx, options?: OpenShellActionOptions) => Promise; + writeShell: (c: Ctx, shellId: string, data: string | Uint8Array) => Promise; + resizeShell: (c: Ctx, shellId: string, cols: number, rows: number) => Promise; + closeShell: (c: Ctx, shellId: string) => Promise; + waitShell: (c: Ctx, shellId: string) => Promise; + // ── Network ─────────────────────────────────────────────────────── vmFetch: ( c: Ctx, diff --git a/packages/agentos/src/types.ts b/packages/agentos/src/types.ts index 7b6f8e51d9..9a36b745d8 100644 --- a/packages/agentos/src/types.ts +++ b/packages/agentos/src/types.ts @@ -59,6 +59,11 @@ export interface ShellDataPayload { data: Uint8Array; } +export interface ShellExitPayload { + shellId: string; + exitCode: number; +} + export interface CronEventPayload { event: CronEvent; } @@ -73,6 +78,10 @@ export interface AgentOsEvents { processOutput: ProcessOutputPayload; processExit: ProcessExitPayload; shellData: ShellDataPayload; + /** Shell stderr (the dedicated channel backing the TS `onStderr` option). */ + shellStderr: ShellDataPayload; + /** Shell process exit (mirrors `waitShell` resolution). */ + shellExit: ShellExitPayload; cronEvent: CronEventPayload; } diff --git a/packages/core/src/agent-os.ts b/packages/core/src/agent-os.ts index f64644046a..aa2885b086 100644 --- a/packages/core/src/agent-os.ts +++ b/packages/core/src/agent-os.ts @@ -903,9 +903,9 @@ function isLocalCancelledPromptResponse( const CLOSED_SESSION_ID_RETENTION_LIMIT = 2048; const CLOSED_SHELL_ID_RETENTION_LIMIT = 2048; -class BoundedSet { +class BoundedSet { readonly limit: number; - #entries = new Map(); + #entries = new Map(); constructor(limit: number) { if (!Number.isInteger(limit) || limit <= 0) { @@ -914,11 +914,11 @@ class BoundedSet { this.limit = limit; } - add(value: T): void { + add(value: T, associated?: V): void { if (this.#entries.has(value)) { this.#entries.delete(value); } - this.#entries.set(value, undefined); + this.#entries.set(value, associated); if (this.#entries.size <= this.limit) { return; } @@ -932,6 +932,10 @@ class BoundedSet { return this.#entries.has(value); } + get(value: T): V | undefined { + return this.#entries.get(value); + } + delete(value: T): boolean { return this.#entries.delete(value); } @@ -2602,7 +2606,9 @@ export class AgentOs { } >(); private _shells = new Map(); - private _closedShellIds = new BoundedSet( + // Value is the recorded exit code (undefined until/unless the exit + // resolves) so waitShell can still report it after the entry is dropped. + private _closedShellIds = new BoundedSet( CLOSED_SHELL_ID_RETENTION_LIMIT, ); private _pendingShellExitPromises = new Set>(); @@ -3464,13 +3470,25 @@ export class AgentOs { exitPromise: Promise.resolve(0), }; const exitPromise = handle.wait(); - entry.exitPromise = exitPromise.finally(() => { + const finalize = (exitCode?: number) => { this._pendingShellExitPromises.delete(entry.exitPromise); if (this._shells.get(shellId) === entry) { this._shells.delete(shellId); - this._closedShellIds.add(shellId); } - }); + // Record the exit code even when closeShell already dropped the + // entry, so a waitShell issued after exit still resolves with it. + this._closedShellIds.add(shellId, exitCode); + }; + entry.exitPromise = exitPromise.then( + (exitCode) => { + finalize(exitCode); + return exitCode; + }, + (error) => { + finalize(); + throw error; + }, + ); this._pendingShellExitPromises.add(entry.exitPromise); this._shells.set(shellId, entry); return { shellId }; @@ -3507,10 +3525,18 @@ export class AgentOs { entry.handle.resize(cols, rows); } - /** Wait for a shell to exit and return its process exit code. */ + /** + * Wait for a shell to exit and return its process exit code. Resolves + * immediately for a shell that has already exited (within the closed-shell + * retention window). + */ waitShell(shellId: string): Promise { const entry = this._shells.get(shellId); - if (!entry) throw new Error(`Shell not found: ${shellId}`); + if (!entry) { + const exitCode = this._closedShellIds.get(shellId); + if (exitCode !== undefined) return Promise.resolve(exitCode); + throw new Error(`Shell not found: ${shellId}`); + } return entry.exitPromise; } diff --git a/packages/core/tests/__snapshots__/pty-line-discipline.test.ts.snap b/packages/core/tests/__snapshots__/pty-line-discipline.test.ts.snap new file mode 100644 index 0000000000..e2d2e9e3f2 --- /dev/null +++ b/packages/core/tests/__snapshots__/pty-line-discipline.test.ts.snap @@ -0,0 +1,1783 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`PTY line discipline matrix > js-node > backspace > js-node/backspace/echo 1`] = ` +"# js-node/backspace/echo +cols=80 rows=24 cursor=1,3 +01|#START id=backspace +02|#MODE want=cooked rc=0 +03|#READY tag=erase +04|a +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > backspace > js-node/backspace/report 1`] = ` +"# js-node/backspace/report +cols=80 rows=24 cursor=0,6 +01|#START id=backspace +02|#MODE want=cooked rc=0 +03|#READY tag=erase +04|a +05|#BYTES tag=erase n=2 hex=61 0A text=a\\n +06|#DONE id=backspace +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > control-char-echo > js-node/control-char-echo/echo 1`] = ` +"# js-node/control-char-echo/echo +cols=80 rows=24 cursor=2,3 +01|#START id=control-char-echo +02|#MODE want=cooked rc=0 +03|#READY tag=ctl +04|^A +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > control-char-echo > js-node/control-char-echo/report 1`] = ` +"# js-node/control-char-echo/report +cols=80 rows=24 cursor=0,6 +01|#START id=control-char-echo +02|#MODE want=cooked rc=0 +03|#READY tag=ctl +04|^A +05|#BYTES tag=ctl n=2 hex=01 0A text=\\x01\\n +06|#DONE id=control-char-echo +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > cooked-echo > js-node/cooked-echo/after-newline 1`] = ` +"# js-node/cooked-echo/after-newline +cols=80 rows=24 cursor=0,6 +01|#START id=cooked-echo +02|#MODE want=cooked rc=0 +03|#READY tag=echo +04|abc +05|#BYTES tag=echo n=4 hex=61 62 63 0A text=abc\\n +06|#DONE id=cooked-echo +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > cooked-echo > js-node/cooked-echo/echo-while-blocked 1`] = ` +"# js-node/cooked-echo/echo-while-blocked +cols=80 rows=24 cursor=3,3 +01|#START id=cooked-echo +02|#MODE want=cooked rc=0 +03|#READY tag=echo +04|abc +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > cpr > js-node/cpr/cpr-reply 1`] = ` +"# js-node/cpr/cpr-reply +cols=80 rows=24 cursor=0,5 +01|#START id=cpr +02|#MODE want=raw rc=0 +03|#CPR sent=1 +04|#CPRREPLY n=6 hex=1B 5B 33 3B 31 52 text=\\e[3;1R +05|#DONE id=cpr +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > eof > js-node/eof/done 1`] = ` +"# js-node/eof/done +cols=80 rows=24 cursor=0,5 +01|#START id=eof +02|#MODE want=cooked rc=0 +03|#READY tag=eof +04|#EOF tag=eof n=0 +05|#DONE id=eof +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > eof > js-node/eof/ready 1`] = ` +"# js-node/eof/ready +cols=80 rows=24 cursor=0,3 +01|#START id=eof +02|#MODE want=cooked rc=0 +03|#READY tag=eof +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > erase-ctrl-h > js-node/erase-ctrl-h/echo 1`] = ` +"# js-node/erase-ctrl-h/echo +cols=80 rows=24 cursor=1,3 +01|#START id=erase-ctrl-h +02|#MODE want=cooked rc=0 +03|#READY tag=eraseh +04|a +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > erase-ctrl-h > js-node/erase-ctrl-h/report 1`] = ` +"# js-node/erase-ctrl-h/report +cols=80 rows=24 cursor=0,6 +01|#START id=erase-ctrl-h +02|#MODE want=cooked rc=0 +03|#READY tag=eraseh +04|a +05|#BYTES tag=eraseh n=2 hex=61 0A text=a\\n +06|#DONE id=erase-ctrl-h +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > icrnl > js-node/icrnl/echo 1`] = ` +"# js-node/icrnl/echo +cols=80 rows=24 cursor=1,3 +01|#START id=icrnl +02|#MODE want=cooked rc=0 +03|#READY tag=icrnl +04|x +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > icrnl > js-node/icrnl/final 1`] = ` +"# js-node/icrnl/final +cols=80 rows=24 cursor=0,6 +01|#START id=icrnl +02|#MODE want=cooked rc=0 +03|#READY tag=icrnl +04|x +05|#BYTES tag=icrnl n=2 hex=78 0A text=x\\n +06|#DONE id=icrnl +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > isatty > js-node/isatty/tty 1`] = ` +"# js-node/isatty/tty +cols=80 rows=24 cursor=0,3 +01|#START id=isatty +02|#TTY in=1 out=1 err=1 +03|#DONE id=isatty +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > kill-line > js-node/kill-line/after-kill 1`] = ` +"# js-node/kill-line/after-kill +cols=80 rows=24 cursor=0,3 +01|#START id=kill-line +02|#MODE want=cooked rc=0 +03|#READY tag=kill +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > kill-line > js-node/kill-line/report 1`] = ` +"# js-node/kill-line/report +cols=80 rows=24 cursor=0,6 +01|#START id=kill-line +02|#MODE want=cooked rc=0 +03|#READY tag=kill +04| +05|#BYTES tag=kill n=1 hex=0A text=\\n +06|#DONE id=kill-line +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > line-buffering > js-node/line-buffering/delivered 1`] = ` +"# js-node/line-buffering/delivered +cols=80 rows=24 cursor=0,6 +01|#START id=line-buffering +02|#MODE want=cooked rc=0 +03|#READY tag=canon +04|hello +05|#BYTES tag=canon n=6 hex=68 65 6C 6C 6F 0A text=hello\\n +06|#DONE id=line-buffering +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > line-buffering > js-node/line-buffering/held 1`] = ` +"# js-node/line-buffering/held +cols=80 rows=24 cursor=5,3 +01|#START id=line-buffering +02|#MODE want=cooked rc=0 +03|#READY tag=canon +04|hello +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > raw-ctrlc-byte > js-node/raw-ctrlc-byte/after 1`] = ` +"# js-node/raw-ctrlc-byte/after +cols=80 rows=24 cursor=0,5 +01|#START id=raw-ctrlc-byte +02|#MODE want=raw rc=0 +03|#READY tag=rawc +04|#BYTES tag=rawc n=1 hex=03 text=\\x03 +05|#DONE id=raw-ctrlc-byte +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > raw-ctrlc-byte > js-node/raw-ctrlc-byte/before-input 1`] = ` +"# js-node/raw-ctrlc-byte/before-input +cols=80 rows=24 cursor=0,3 +01|#START id=raw-ctrlc-byte +02|#MODE want=raw rc=0 +03|#READY tag=rawc +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > raw-no-echo > js-node/raw-no-echo/blocked 1`] = ` +"# js-node/raw-no-echo/blocked +cols=80 rows=24 cursor=0,3 +01|#START id=raw-no-echo +02|#MODE want=raw rc=0 +03|#READY tag=raw +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > raw-no-echo > js-node/raw-no-echo/done 1`] = ` +"# js-node/raw-no-echo/done +cols=80 rows=24 cursor=0,5 +01|#START id=raw-no-echo +02|#MODE want=raw rc=0 +03|#READY tag=raw +04|#BYTES tag=raw n=4 hex=61 62 63 21 text=abc! +05|#DONE id=raw-no-echo +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > sigint > js-node/sigint/after 1`] = ` +"# js-node/sigint/after +cols=80 rows=24 cursor=0,3 +01|#START id=sigint +02|#MODE want=cooked rc=0 +03|#READY tag=sigint +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > sigquit > js-node/sigquit/after 1`] = ` +"# js-node/sigquit/after +cols=80 rows=24 cursor=0,3 +01|#START id=sigquit +02|#MODE want=cooked rc=0 +03|#READY tag=sigquit +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > vintr-buffer > js-node/vintr-buffer/after 1`] = ` +"# js-node/vintr-buffer/after +cols=80 rows=24 cursor=3,3 +01|#START id=vintr-buffer +02|#MODE want=cooked rc=0 +03|#READY tag=vintrbuf +04|abc +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > vsusp > js-node/vsusp/after 1`] = ` +"# js-node/vsusp/after +cols=80 rows=24 cursor=0,3 +01|#START id=vsusp +02|#MODE want=cooked rc=0 +03|#READY tag=susp +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > winsize > js-node/winsize/winsize 1`] = ` +"# js-node/winsize/winsize +cols=100 rows=37 cursor=0,3 +01|#START id=winsize +02|#SIZE tag=open rc=0 cols=100 rows=37 +03|#DONE id=winsize +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24| +25| +26| +27| +28| +29| +30| +31| +32| +33| +34| +35| +36| +37|" +`; + +exports[`PTY line discipline matrix > js-node > word-erase > js-node/word-erase/echo 1`] = ` +"# js-node/word-erase/echo +cols=80 rows=24 cursor=4,3 +01|#START id=word-erase +02|#MODE want=cooked rc=0 +03|#READY tag=werase +04|foo +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > js-node > word-erase > js-node/word-erase/report 1`] = ` +"# js-node/word-erase/report +cols=80 rows=24 cursor=0,6 +01|#START id=word-erase +02|#MODE want=cooked rc=0 +03|#READY tag=werase +04|foo +05|#BYTES tag=werase n=5 hex=66 6F 6F 20 0A text=foo \\n +06|#DONE id=word-erase +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > backspace > wasm-c/backspace/echo 1`] = ` +"# wasm-c/backspace/echo +cols=80 rows=24 cursor=1,3 +01|#START id=backspace +02|#MODE want=cooked rc=0 +03|#READY tag=erase +04|a +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > backspace > wasm-c/backspace/report 1`] = ` +"# wasm-c/backspace/report +cols=80 rows=24 cursor=0,6 +01|#START id=backspace +02|#MODE want=cooked rc=0 +03|#READY tag=erase +04|a +05|#BYTES tag=erase n=2 hex=61 0A text=a\\n +06|#DONE id=backspace +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > control-char-echo > wasm-c/control-char-echo/echo 1`] = ` +"# wasm-c/control-char-echo/echo +cols=80 rows=24 cursor=2,3 +01|#START id=control-char-echo +02|#MODE want=cooked rc=0 +03|#READY tag=ctl +04|^A +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > control-char-echo > wasm-c/control-char-echo/report 1`] = ` +"# wasm-c/control-char-echo/report +cols=80 rows=24 cursor=0,6 +01|#START id=control-char-echo +02|#MODE want=cooked rc=0 +03|#READY tag=ctl +04|^A +05|#BYTES tag=ctl n=2 hex=01 0A text=\\x01\\n +06|#DONE id=control-char-echo +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > cooked-echo > wasm-c/cooked-echo/after-newline 1`] = ` +"# wasm-c/cooked-echo/after-newline +cols=80 rows=24 cursor=0,6 +01|#START id=cooked-echo +02|#MODE want=cooked rc=0 +03|#READY tag=echo +04|abc +05|#BYTES tag=echo n=4 hex=61 62 63 0A text=abc\\n +06|#DONE id=cooked-echo +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > cooked-echo > wasm-c/cooked-echo/echo-while-blocked 1`] = ` +"# wasm-c/cooked-echo/echo-while-blocked +cols=80 rows=24 cursor=3,3 +01|#START id=cooked-echo +02|#MODE want=cooked rc=0 +03|#READY tag=echo +04|abc +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > cpr > wasm-c/cpr/cpr-reply 1`] = ` +"# wasm-c/cpr/cpr-reply +cols=80 rows=24 cursor=0,5 +01|#START id=cpr +02|#MODE want=raw rc=0 +03|#CPR sent=1 +04|#CPRREPLY n=6 hex=1B 5B 33 3B 31 52 text=\\e[3;1R +05|#DONE id=cpr +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > eof > wasm-c/eof/done 1`] = ` +"# wasm-c/eof/done +cols=80 rows=24 cursor=0,5 +01|#START id=eof +02|#MODE want=cooked rc=0 +03|#READY tag=eof +04|#EOF tag=eof n=0 +05|#DONE id=eof +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > eof > wasm-c/eof/ready 1`] = ` +"# wasm-c/eof/ready +cols=80 rows=24 cursor=0,3 +01|#START id=eof +02|#MODE want=cooked rc=0 +03|#READY tag=eof +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > erase-ctrl-h > wasm-c/erase-ctrl-h/echo 1`] = ` +"# wasm-c/erase-ctrl-h/echo +cols=80 rows=24 cursor=1,3 +01|#START id=erase-ctrl-h +02|#MODE want=cooked rc=0 +03|#READY tag=eraseh +04|a +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > erase-ctrl-h > wasm-c/erase-ctrl-h/report 1`] = ` +"# wasm-c/erase-ctrl-h/report +cols=80 rows=24 cursor=0,6 +01|#START id=erase-ctrl-h +02|#MODE want=cooked rc=0 +03|#READY tag=eraseh +04|a +05|#BYTES tag=eraseh n=2 hex=61 0A text=a\\n +06|#DONE id=erase-ctrl-h +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > icrnl > wasm-c/icrnl/echo 1`] = ` +"# wasm-c/icrnl/echo +cols=80 rows=24 cursor=1,3 +01|#START id=icrnl +02|#MODE want=cooked rc=0 +03|#READY tag=icrnl +04|x +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > icrnl > wasm-c/icrnl/final 1`] = ` +"# wasm-c/icrnl/final +cols=80 rows=24 cursor=0,6 +01|#START id=icrnl +02|#MODE want=cooked rc=0 +03|#READY tag=icrnl +04|x +05|#BYTES tag=icrnl n=2 hex=78 0A text=x\\n +06|#DONE id=icrnl +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > isatty > wasm-c/isatty/tty 1`] = ` +"# wasm-c/isatty/tty +cols=80 rows=24 cursor=0,3 +01|#START id=isatty +02|#TTY in=1 out=1 err=1 +03|#DONE id=isatty +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > kill-line > wasm-c/kill-line/after-kill 1`] = ` +"# wasm-c/kill-line/after-kill +cols=80 rows=24 cursor=0,3 +01|#START id=kill-line +02|#MODE want=cooked rc=0 +03|#READY tag=kill +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > kill-line > wasm-c/kill-line/report 1`] = ` +"# wasm-c/kill-line/report +cols=80 rows=24 cursor=0,6 +01|#START id=kill-line +02|#MODE want=cooked rc=0 +03|#READY tag=kill +04| +05|#BYTES tag=kill n=1 hex=0A text=\\n +06|#DONE id=kill-line +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > line-buffering > wasm-c/line-buffering/delivered 1`] = ` +"# wasm-c/line-buffering/delivered +cols=80 rows=24 cursor=0,6 +01|#START id=line-buffering +02|#MODE want=cooked rc=0 +03|#READY tag=canon +04|hello +05|#BYTES tag=canon n=6 hex=68 65 6C 6C 6F 0A text=hello\\n +06|#DONE id=line-buffering +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > line-buffering > wasm-c/line-buffering/held 1`] = ` +"# wasm-c/line-buffering/held +cols=80 rows=24 cursor=5,3 +01|#START id=line-buffering +02|#MODE want=cooked rc=0 +03|#READY tag=canon +04|hello +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > onlcr > wasm-c/onlcr/onlcr 1`] = ` +"# wasm-c/onlcr/onlcr +cols=80 rows=24 cursor=0,3 +01|#START id=onlcr +02|a +03|b#DONE id=onlcr +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > raw-ctrlc-byte > wasm-c/raw-ctrlc-byte/after 1`] = ` +"# wasm-c/raw-ctrlc-byte/after +cols=80 rows=24 cursor=0,5 +01|#START id=raw-ctrlc-byte +02|#MODE want=raw rc=0 +03|#READY tag=rawc +04|#BYTES tag=rawc n=1 hex=03 text=\\x03 +05|#DONE id=raw-ctrlc-byte +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > raw-ctrlc-byte > wasm-c/raw-ctrlc-byte/before-input 1`] = ` +"# wasm-c/raw-ctrlc-byte/before-input +cols=80 rows=24 cursor=0,3 +01|#START id=raw-ctrlc-byte +02|#MODE want=raw rc=0 +03|#READY tag=rawc +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > raw-no-echo > wasm-c/raw-no-echo/blocked 1`] = ` +"# wasm-c/raw-no-echo/blocked +cols=80 rows=24 cursor=0,3 +01|#START id=raw-no-echo +02|#MODE want=raw rc=0 +03|#READY tag=raw +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > raw-no-echo > wasm-c/raw-no-echo/done 1`] = ` +"# wasm-c/raw-no-echo/done +cols=80 rows=24 cursor=0,5 +01|#START id=raw-no-echo +02|#MODE want=raw rc=0 +03|#READY tag=raw +04|#BYTES tag=raw n=4 hex=61 62 63 21 text=abc! +05|#DONE id=raw-no-echo +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > resize-sigwinch > wasm-c/resize-sigwinch/resize 1`] = ` +"# wasm-c/resize-sigwinch/resize +cols=120 rows=40 cursor=0,6 +01|#START id=resize-sigwinch +02|#SIZE tag=before rc=0 cols=80 rows=24 +03|#READY tag=resize +04|! +05|#SIZE tag=after rc=0 cols=120 rows=40 +06|#DONE id=resize-sigwinch +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24| +25| +26| +27| +28| +29| +30| +31| +32| +33| +34| +35| +36| +37| +38| +39| +40|" +`; + +exports[`PTY line discipline matrix > wasm-c > sigint > wasm-c/sigint/after 1`] = ` +"# wasm-c/sigint/after +cols=80 rows=24 cursor=0,3 +01|#START id=sigint +02|#MODE want=cooked rc=0 +03|#READY tag=sigint +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > sigquit > wasm-c/sigquit/after 1`] = ` +"# wasm-c/sigquit/after +cols=80 rows=24 cursor=0,3 +01|#START id=sigquit +02|#MODE want=cooked rc=0 +03|#READY tag=sigquit +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > vintr-buffer > wasm-c/vintr-buffer/after 1`] = ` +"# wasm-c/vintr-buffer/after +cols=80 rows=24 cursor=3,3 +01|#START id=vintr-buffer +02|#MODE want=cooked rc=0 +03|#READY tag=vintrbuf +04|abc +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > vsusp > wasm-c/vsusp/after 1`] = ` +"# wasm-c/vsusp/after +cols=80 rows=24 cursor=0,3 +01|#START id=vsusp +02|#MODE want=cooked rc=0 +03|#READY tag=susp +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > winsize > wasm-c/winsize/winsize 1`] = ` +"# wasm-c/winsize/winsize +cols=100 rows=37 cursor=0,3 +01|#START id=winsize +02|#SIZE tag=open rc=0 cols=100 rows=37 +03|#DONE id=winsize +04| +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24| +25| +26| +27| +28| +29| +30| +31| +32| +33| +34| +35| +36| +37|" +`; + +exports[`PTY line discipline matrix > wasm-c > word-erase > wasm-c/word-erase/echo 1`] = ` +"# wasm-c/word-erase/echo +cols=80 rows=24 cursor=4,3 +01|#START id=word-erase +02|#MODE want=cooked rc=0 +03|#READY tag=werase +04|foo +05| +06| +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; + +exports[`PTY line discipline matrix > wasm-c > word-erase > wasm-c/word-erase/report 1`] = ` +"# wasm-c/word-erase/report +cols=80 rows=24 cursor=0,6 +01|#START id=word-erase +02|#MODE want=cooked rc=0 +03|#READY tag=werase +04|foo +05|#BYTES tag=werase n=5 hex=66 6F 6F 20 0A text=foo \\n +06|#DONE id=word-erase +07| +08| +09| +10| +11| +12| +13| +14| +15| +16| +17| +18| +19| +20| +21| +22| +23| +24|" +`; diff --git a/packages/core/tests/__snapshots__/pty-protocol.test.ts.snap b/packages/core/tests/__snapshots__/pty-protocol.test.ts.snap index d117e2af7b..05d02ad45d 100644 --- a/packages/core/tests/__snapshots__/pty-protocol.test.ts.snap +++ b/packages/core/tests/__snapshots__/pty-protocol.test.ts.snap @@ -5,7 +5,7 @@ exports[`PTY protocol snapshots > C WASM probe snapshots raw, cooked, CPR, resiz cols=80 rows=18 cursor=11,8 01|PTY_PROBE start 02|TTY_HOST stdin=1 stdout=1 stderr=1 -03|TTY_LIBC stdin=0 stdout=0 stderr=0 +03|TTY_LIBC stdin=1 stdout=1 stderr=1 04|SIZE_START env_cols=80 env_rows=18 host_rc=0 host_cols=80 host_rows=18 ioctl_rc= 05|-1 ioctl_errno=28 ioctl_cols=0 ioctl_rows=0 06|RAW_ON rc=0 @@ -28,7 +28,7 @@ exports[`PTY protocol snapshots > C WASM probe snapshots raw, cooked, CPR, resiz cols=80 rows=18 cursor=14,11 01|PTY_PROBE start 02|TTY_HOST stdin=1 stdout=1 stderr=1 -03|TTY_LIBC stdin=0 stdout=0 stderr=0 +03|TTY_LIBC stdin=1 stdout=1 stderr=1 04|SIZE_START env_cols=80 env_rows=18 host_rc=0 host_cols=80 host_rows=18 ioctl_rc= 05|-1 ioctl_errno=28 ioctl_cols=0 ioctl_rows=0 06|RAW_ON rc=0 @@ -48,10 +48,10 @@ cols=80 rows=18 cursor=14,11 exports[`PTY protocol snapshots > C WASM probe snapshots raw, cooked, CPR, resize, and EOF terminal protocol 3`] = ` "# after cooked enter -cols=80 rows=18 cursor=14,13 +cols=80 rows=18 cursor=14,14 01|PTY_PROBE start 02|TTY_HOST stdin=1 stdout=1 stderr=1 -03|TTY_LIBC stdin=0 stdout=0 stderr=0 +03|TTY_LIBC stdin=1 stdout=1 stderr=1 04|SIZE_START env_cols=80 env_rows=18 host_rc=0 host_cols=80 host_rows=18 ioctl_rc= 05|-1 ioctl_errno=28 ioctl_cols=0 ioctl_rows=0 06|RAW_ON rc=0 @@ -60,10 +60,10 @@ cols=80 rows=18 cursor=14,13 09|RAW_INPUT> 10|RAW_BYTES bytes=7 hex=41 0A 1B 5B 41 17 21 text=A\\n\\e[A\\x17! 11|RAW_OFF rc=0 -12|COOKED_INPUT> COOKED_BYTES bytes=13 hex=68 65 6C 6C 6F 20 63 6F 6F 6B 65 64 0A t -13|ext=hello cooked\\n -14|RESIZE_READY> -15| +12|COOKED_INPUT> hello cooked +13|COOKED_BYTES bytes=13 hex=68 65 6C 6C 6F 20 63 6F 6F 6B 65 64 0A text=hello cook +14|ed\\n +15|RESIZE_READY> 16| 17| 18|" @@ -71,10 +71,10 @@ cols=80 rows=18 cursor=14,13 exports[`PTY protocol snapshots > C WASM probe snapshots raw, cooked, CPR, resize, and EOF terminal protocol 4`] = ` "# after resize trigger -cols=100 rows=20 cursor=11,15 +cols=100 rows=20 cursor=11,17 01|PTY_PROBE start 02|TTY_HOST stdin=1 stdout=1 stderr=1 -03|TTY_LIBC stdin=0 stdout=0 stderr=0 +03|TTY_LIBC stdin=1 stdout=1 stderr=1 04|SIZE_START env_cols=80 env_rows=18 host_rc=0 host_cols=80 host_rows=18 ioctl_rc=-1 ioctl_errno=28 io 05|ctl_cols=0 ioctl_rows=0 06|RAW_ON rc=0 @@ -83,23 +83,23 @@ cols=100 rows=20 cursor=11,15 09|RAW_INPUT> 10|RAW_BYTES bytes=7 hex=41 0A 1B 5B 41 17 21 text=A\\n\\e[A\\x17! 11|RAW_OFF rc=0 -12|COOKED_INPUT> COOKED_BYTES bytes=13 hex=68 65 6C 6C 6F 20 63 6F 6F 6B 65 64 0A text=hello cooked\\n -13|RESIZE_READY> RESIZE_TRIGGER bytes=11 hex=72 65 73 69 7A 65 2D 6E 6F 77 0A text=resize-now\\n -14|SIZE_AFTER_RESIZE env_cols=80 env_rows=18 host_rc=0 host_cols=100 host_rows=20 ioctl_rc=-1 ioctl_err -15|no=28 ioctl_cols=0 ioctl_rows=0 -16|EOF_READY> -17| -18| +12|COOKED_INPUT> hello cooked +13|COOKED_BYTES bytes=13 hex=68 65 6C 6C 6F 20 63 6F 6F 6B 65 64 0A text=hello cooked\\n +14|RESIZE_READY> resize-now +15|RESIZE_TRIGGER bytes=11 hex=72 65 73 69 7A 65 2D 6E 6F 77 0A text=resize-now\\n +16|SIZE_AFTER_RESIZE env_cols=80 env_rows=18 host_rc=0 host_cols=100 host_rows=20 ioctl_rc=-1 ioctl_err +17|no=28 ioctl_cols=0 ioctl_rows=0 +18|EOF_READY> 19| 20|" `; exports[`PTY protocol snapshots > C WASM probe snapshots raw, cooked, CPR, resize, and EOF terminal protocol 5`] = ` "# after eof -cols=100 rows=20 cursor=0,17 +cols=100 rows=20 cursor=0,19 01|PTY_PROBE start 02|TTY_HOST stdin=1 stdout=1 stderr=1 -03|TTY_LIBC stdin=0 stdout=0 stderr=0 +03|TTY_LIBC stdin=1 stdout=1 stderr=1 04|SIZE_START env_cols=80 env_rows=18 host_rc=0 host_cols=80 host_rows=18 ioctl_rc=-1 ioctl_errno=28 io 05|ctl_cols=0 ioctl_rows=0 06|RAW_ON rc=0 @@ -108,13 +108,13 @@ cols=100 rows=20 cursor=0,17 09|RAW_INPUT> 10|RAW_BYTES bytes=7 hex=41 0A 1B 5B 41 17 21 text=A\\n\\e[A\\x17! 11|RAW_OFF rc=0 -12|COOKED_INPUT> COOKED_BYTES bytes=13 hex=68 65 6C 6C 6F 20 63 6F 6F 6B 65 64 0A text=hello cooked\\n -13|RESIZE_READY> RESIZE_TRIGGER bytes=11 hex=72 65 73 69 7A 65 2D 6E 6F 77 0A text=resize-now\\n -14|SIZE_AFTER_RESIZE env_cols=80 env_rows=18 host_rc=0 host_cols=100 host_rows=20 ioctl_rc=-1 ioctl_err -15|no=28 ioctl_cols=0 ioctl_rows=0 -16|EOF_READY> EOF_READ n=0 -17|PTY_PROBE done -18| -19| +12|COOKED_INPUT> hello cooked +13|COOKED_BYTES bytes=13 hex=68 65 6C 6C 6F 20 63 6F 6F 6B 65 64 0A text=hello cooked\\n +14|RESIZE_READY> resize-now +15|RESIZE_TRIGGER bytes=11 hex=72 65 73 69 7A 65 2D 6E 6F 77 0A text=resize-now\\n +16|SIZE_AFTER_RESIZE env_cols=80 env_rows=18 host_rc=0 host_cols=100 host_rows=20 ioctl_rc=-1 ioctl_err +17|no=28 ioctl_cols=0 ioctl_rows=0 +18|EOF_READY> EOF_READ n=0 +19|PTY_PROBE done 20|" `; diff --git a/packages/core/tests/fixtures/pty/pty_probe.c b/packages/core/tests/fixtures/pty/pty_probe.c new file mode 100644 index 0000000000..5405945fbf --- /dev/null +++ b/packages/core/tests/fixtures/pty/pty_probe.c @@ -0,0 +1,521 @@ +// pty_probe.c — argv-dispatched PTY/line-discipline probe for the agent-os +// terminal test matrix (packages/core/tests/pty-line-discipline.test.ts). +// +// Unlike secure-exec's single-sequence pty_probe.c, this probe runs exactly ONE +// case, selected by argv[1] (the caseId). The host harness drives the SAME case +// set through this WASM probe and a guest-Node twin (pty_probe.mjs) and asserts +// the SAME observable marker protocol against both runtimes. +// +// Build (vanilla wasi-sysroot — NO termios.h / sys/ioctl.h on the wasm path): +// /bin/clang --target=wasm32-wasip1 \ +// --sysroot=/share/wasi-sysroot -O2 -o /pty_probe pty_probe.c +// +// The `#else` real-termios branch keeps the file natively debuggable: +// clang -o /tmp/pty_probe pty_probe.c +// +// Marker protocol (every key=val lowercase; every line terminated with a LITERAL +// "\r\n" so @xterm/headless returns the cursor to column 0 even on the raw echo +// path that may not inject CR): see the test header for the full vocabulary. + +#include +#include +#include +#include +#include + +#if defined(__wasm__) +__attribute__((import_module("host_tty"), import_name("isatty"))) extern unsigned int +host_tty_isatty(unsigned int fd); +__attribute__((import_module("host_tty"), import_name("get_size"))) extern unsigned int +host_tty_get_size(unsigned int fd, unsigned short *cols, unsigned short *rows); +__attribute__((import_module("host_tty"), import_name("set_raw_mode"))) extern unsigned int +host_tty_set_raw_mode(unsigned int enabled); +#else +#include +#include +static struct termios saved_termios; +static int saved_termios_valid = 0; + +static unsigned int host_tty_isatty(unsigned int fd) { + return isatty((int)fd) ? 1u : 0u; +} + +static unsigned int host_tty_get_size(unsigned int fd, unsigned short *cols, unsigned short *rows) { + struct winsize ws; + memset(&ws, 0, sizeof(ws)); + if (ioctl((int)fd, TIOCGWINSZ, &ws) != 0) { + return (unsigned int)errno; + } + *cols = ws.ws_col; + *rows = ws.ws_row; + return 0; +} + +static unsigned int host_tty_set_raw_mode(unsigned int enabled) { + if (enabled) { + struct termios raw; + if (tcgetattr(STDIN_FILENO, &saved_termios) != 0) { + return (unsigned int)errno; + } + saved_termios_valid = 1; + raw = saved_termios; + cfmakeraw(&raw); + if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) != 0) { + return (unsigned int)errno; + } + return 0; + } + if (saved_termios_valid && tcsetattr(STDIN_FILENO, TCSANOW, &saved_termios) != 0) { + return (unsigned int)errno; + } + return 0; +} +#endif + +// ---- shared encoders (byte-identical to secure-exec's pty_probe.c) ---------- + +static void print_hex(const unsigned char *bytes, int len) { + for (int i = 0; i < len; i++) { + if (i > 0) { + fputc(' ', stdout); + } + printf("%02X", bytes[i]); + } +} + +static void print_text(const unsigned char *bytes, int len) { + for (int i = 0; i < len; i++) { + unsigned char c = bytes[i]; + if (c == '\r') { + fputs("\\r", stdout); + } else if (c == '\n') { + fputs("\\n", stdout); + } else if (c == '\t') { + fputs("\\t", stdout); + } else if (c == 0x1b) { + fputs("\\e", stdout); + } else if (c < 0x20 || c == 0x7f) { + printf("\\x%02X", c); + } else { + fputc((int)c, stdout); + } + } +} + +// Reads stdin one byte at a time, retrying on EINTR, until the terminator byte +// is seen / the buffer fills / EOF. Keeps the probe BLOCKED IN READ so the host +// can prove kernel echo while the program has not yet read the typed bytes. +static int read_until(unsigned char *buf, int cap, unsigned char terminator) { + int len = 0; + while (len < cap) { + unsigned char c = 0; + ssize_t n = read(STDIN_FILENO, &c, 1); + if (n == 0) { + return len; // EOF + } + if (n < 0) { + if (errno == EINTR) { + continue; + } + printf("READ_ERROR errno=%d\r\n", errno); + return -1; + } + buf[len++] = c; + if (c == terminator) { + return len; + } + } + return len; +} + +static void emit_bytes(const char *tag, const unsigned char *buf, int n) { + printf("#BYTES tag=%s n=%d hex=", tag, n); + if (n > 0) { + print_hex(buf, n); + } + printf(" text="); + if (n > 0) { + print_text(buf, n); + } + printf("\r\n"); +} + +// ---- cases ------------------------------------------------------------------ + +// cooked-echo: cooked-mode (ICANON+ECHO+ISIG) terminal echo. Block in read right +// after #READY so the host proves kernel echo independent of any readback. +static void case_cooked_echo(void) { + unsigned char buf[128]; + unsigned int rc = host_tty_set_raw_mode(0); + printf("#MODE want=cooked rc=%u\r\n", rc); + printf("#READY tag=echo\r\n"); + int n = read_until(buf, (int)sizeof(buf), '\n'); + if (n < 0) { + return; + } + if (n == 0) { + printf("#EOF tag=echo n=0\r\n"); + return; + } + emit_bytes("echo", buf, n); +} + +// control-char-echo: ECHOCTL — a non-signal control byte (^A 0x01) should echo +// as caret notation "^A"; the kernel echoes the raw byte instead (known broken). +static void case_control_char_echo(void) { + unsigned char buf[128]; + unsigned int rc = host_tty_set_raw_mode(0); + printf("#MODE want=cooked rc=%u\r\n", rc); + printf("#READY tag=ctl\r\n"); + int n = read_until(buf, (int)sizeof(buf), '\n'); + if (n < 0) { + return; + } + if (n == 0) { + printf("#EOF tag=ctl n=0\r\n"); + return; + } + emit_bytes("ctl", buf, n); +} + +// raw-no-echo: RAW mode (ICANON+ECHO+ISIG off) must NOT echo input. +static void case_raw_no_echo(void) { + unsigned char buf[128]; + unsigned int rc = host_tty_set_raw_mode(1); + printf("#MODE want=raw rc=%u\r\n", rc); + printf("#READY tag=raw\r\n"); + int n = read_until(buf, (int)sizeof(buf), '!'); + if (n < 0) { + return; + } + emit_bytes("raw", buf, n); +} + +// backspace: cooked-mode VERASE (DEL 0x7f) erases one char via 08 20 08 echo. +static void case_backspace(void) { + unsigned char buf[256]; + unsigned int rc = host_tty_set_raw_mode(0); + printf("#MODE want=cooked rc=%u\r\n", rc); + printf("#READY tag=erase\r\n"); + int n = read_until(buf, (int)sizeof(buf), '\n'); + if (n < 0) { + return; + } + emit_bytes("erase", buf, n); +} + +// kill-line: cooked-mode VKILL (^U 0x15) should discard the whole line; the +// kernel does neither (unimplemented) — captured honestly as broken. +static void case_kill_line(void) { + unsigned char buf[256]; + unsigned int rc = host_tty_set_raw_mode(0); + printf("#MODE want=cooked rc=%u\r\n", rc); + printf("#READY tag=kill\r\n"); + int n = read_until(buf, (int)sizeof(buf), '\n'); + if (n < 0) { + return; + } + emit_bytes("kill", buf, n); +} + +// word-erase: cooked-mode VWERASE (^W 0x17) should erase the last word; the +// kernel echoes it raw and keeps it in the buffer (unimplemented — broken). +static void case_word_erase(void) { + unsigned char buf[256]; + unsigned int rc = host_tty_set_raw_mode(0); + printf("#MODE want=cooked rc=%u\r\n", rc); + printf("#READY tag=werase\r\n"); + int n = read_until(buf, (int)sizeof(buf), '\n'); + if (n < 0) { + return; + } + emit_bytes("werase", buf, n); +} + +// line-buffering: ICANON holds the line until '\n', then delivers it whole. +static void case_line_buffering(void) { + unsigned char buf[256]; + unsigned int rc = host_tty_set_raw_mode(0); + printf("#MODE want=cooked rc=%u\r\n", rc); + printf("#READY tag=canon\r\n"); + int n = read_until(buf, (int)sizeof(buf), '\n'); + if (n < 0) { + return; + } + if (n == 0) { + printf("#EOF tag=canon n=0\r\n"); + return; + } + emit_bytes("canon", buf, n); +} + +// sigint: cooked ISIG — VINTR (^C 0x03) raises SIGINT to the foreground pgid. +// WASM cannot catch POSIX signals, so a correct kernel kills this process while +// it is blocked here (host observes via waitShell). The body runs only if the +// byte (wrongly) leaked through. +static void case_sigint(void) { + unsigned char buf[128]; + unsigned int rc = host_tty_set_raw_mode(0); + printf("#MODE want=cooked rc=%u\r\n", rc); + printf("#READY tag=sigint\r\n"); + int n = read_until(buf, (int)sizeof(buf), '!'); + if (n <= 0) { + printf("#EOF tag=sigint n=0\r\n"); + return; + } + emit_bytes("sigint", buf, n); +} + +// sigquit: cooked ISIG — VQUIT (^\ 0x1C) raises SIGQUIT; correct behavior is +// process death observed by the host. cap=1 so a leaked byte resolves at once. +static void case_sigquit(void) { + unsigned char buf[8]; + unsigned int rc = host_tty_set_raw_mode(0); + printf("#MODE want=cooked rc=%u\r\n", rc); + printf("#READY tag=sigquit\r\n"); + int n = read_until(buf, 1, '!'); + if (n <= 0) { + printf("#EOF tag=sigquit n=0\r\n"); + return; + } + emit_bytes("sigquit", buf, n); +} + +// vsusp: cooked ISIG — VSUSP (^Z 0x1a) raises SIGTSTP. The byte is consumed as a +// signal: neither delivered (no #BYTES) nor echoed. The foreground process is +// suspended (a STOP, not a kill), so a correct kernel never lets it reach #DONE. +static void case_vsusp(void) { + unsigned char buf[8]; + unsigned int rc = host_tty_set_raw_mode(0); + printf("#MODE want=cooked rc=%u\r\n", rc); + printf("#READY tag=susp\r\n"); + int n = read_until(buf, 1, '!'); + if (n <= 0) { + printf("#EOF tag=susp n=0\r\n"); + return; + } + emit_bytes("susp", buf, n); +} + +// erase-ctrl-h: cooked-mode VERASE alias ^H (0x08) erases one char exactly like +// DEL (0x7f); the kernel maps both bytes to the erase op. Type "ab" + ^H + '\n' +// -> the delivered line drops the last char -> "a\n". +static void case_erase_ctrl_h(void) { + unsigned char buf[256]; + unsigned int rc = host_tty_set_raw_mode(0); + printf("#MODE want=cooked rc=%u\r\n", rc); + printf("#READY tag=eraseh\r\n"); + int n = read_until(buf, (int)sizeof(buf), '\n'); + if (n < 0) { + return; + } + emit_bytes("eraseh", buf, n); +} + +// vintr-buffer: VINTR (^C) mid-line BOTH flushes the canonical input buffer AND +// raises SIGINT. A runtime that survives the signal proves the FLUSH: after +// "abc" + ^C + "de\n" the delivered line is "de\n" (the buffered "abc" was +// discarded), NOT "abcde\n". WASM cannot catch SIGINT, so this process is killed +// mid-read and only ever proves the kill; the buffer flush is proven by the +// surviving twin (guest-node). +static void case_vintr_buffer(void) { + unsigned char buf[256]; + unsigned int rc = host_tty_set_raw_mode(0); + printf("#MODE want=cooked rc=%u\r\n", rc); + printf("#READY tag=vintrbuf\r\n"); + int n = read_until(buf, (int)sizeof(buf), '\n'); + if (n < 0) { + return; + } + if (n == 0) { + printf("#EOF tag=vintrbuf n=0\r\n"); + return; + } + emit_bytes("vintrbuf", buf, n); +} + +// raw-ctrlc-byte: RAW mode (ISIG off) — VINTR (0x03) is ordinary input, not a +// signal. Read exactly one byte and report it; reaching #DONE proves no kill. +static void case_raw_ctrlc_byte(void) { + unsigned int rc = host_tty_set_raw_mode(1); + printf("#MODE want=raw rc=%u\r\n", rc); + printf("#READY tag=rawc\r\n"); + + unsigned char buf[1]; + int len = 0; + while (len < 1) { + unsigned char c = 0; + ssize_t n = read(STDIN_FILENO, &c, 1); + if (n == 0) { + printf("#EOF tag=rawc n=0\r\n"); + return; + } + if (n < 0) { + if (errno == EINTR) { + continue; + } + printf("#ERR read errno=%d\r\n", errno); + return; + } + buf[len++] = c; + } + emit_bytes("rawc", buf, len); +} + +// onlcr: OPOST+ONLCR output expansion. Raw-write `a\nb`; the master must read +// back 0x61 0x0D 0x0A 0x62 (CR injected before the lone LF). No input consumed. +static void case_onlcr(void) { + write(STDOUT_FILENO, "a\nb", 3); +} + +// icrnl: cooked-mode ICRNL maps a typed CR (0x0D) to NL (0x0A), which both +// terminates the canonical line and is the byte delivered to the program. +static void case_icrnl(void) { + unsigned char buf[128]; + unsigned int rc = host_tty_set_raw_mode(0); + printf("#MODE want=cooked rc=%u\r\n", rc); + printf("#READY tag=icrnl\r\n"); + int n = read_until(buf, (int)sizeof(buf), '\n'); + if (n < 0) { + return; + } + emit_bytes("icrnl", buf, n); +} + +// eof: cooked-mode VEOF (^D 0x04) on an empty line makes read() return 0 (EOF) +// and is NOT echoed. +static void case_eof(void) { + unsigned int rc = host_tty_set_raw_mode(0); + printf("#MODE want=cooked rc=%u\r\n", rc); + printf("#READY tag=eof\r\n"); + + unsigned char byte = 0; + ssize_t n; + do { + n = read(STDIN_FILENO, &byte, 1); + } while (n < 0 && errno == EINTR); + + if (n <= 0) { + printf("#EOF tag=eof n=0\r\n"); + } else { + emit_bytes("eof", &byte, 1); + } +} + +// resize-sigwinch: report size, block in a cooked read, then re-query the LIVE +// size after the host resizes + unblocks. WASM cannot catch SIGWINCH, so the +// new size from host_tty_get_size is the proof. +static void case_resize_sigwinch(void) { + unsigned short cols0 = 0, rows0 = 0; + unsigned int rc0 = host_tty_get_size(0, &cols0, &rows0); + printf("#SIZE tag=before rc=%u cols=%u rows=%u\r\n", + rc0, (unsigned int)cols0, (unsigned int)rows0); + + printf("#READY tag=resize\r\n"); + unsigned char buf[16]; + read_until(buf, (int)sizeof(buf), '!'); + + unsigned short cols1 = 0, rows1 = 0; + unsigned int rc1 = host_tty_get_size(0, &cols1, &rows1); + printf("#SIZE tag=after rc=%u cols=%u rows=%u\r\n", + rc1, (unsigned int)cols1, (unsigned int)rows1); +} + +// cpr: DSR 6 -> CPR. Raw mode so the reply arrives verbatim; write ESC[6n and +// read_until 'R'. @xterm answers (the host wires term.onData -> writeShell). +static void case_cpr(void) { + unsigned char buf[128]; + unsigned int rc = host_tty_set_raw_mode(1); + printf("#MODE want=raw rc=%u\r\n", rc); + write(STDOUT_FILENO, "\x1b[6n", 4); + printf("#CPR sent=1\r\n"); + int n = read_until(buf, (int)sizeof(buf), 'R'); + if (n <= 0) { + printf("#EOF tag=cpr n=0\r\n"); + return; + } + printf("#CPRREPLY n=%d hex=", n); + print_hex(buf, n); + printf(" text="); + print_text(buf, n); + printf("\r\n"); +} + +// isatty: report host_tty_isatty for fd 0/1/2. +static void case_isatty(void) { + printf("#TTY in=%u out=%u err=%u\r\n", + host_tty_isatty(0), + host_tty_isatty(1), + host_tty_isatty(2)); +} + +// winsize: report the kernel slave window size (host_tty_get_size on fd 0). A +// correct kernel returns the exact openShell({cols,rows}). No input read. +static void case_winsize(void) { + unsigned short cols = 0, rows = 0; + unsigned int rc = host_tty_get_size(STDIN_FILENO, &cols, &rows); + printf("#SIZE tag=open rc=%u cols=%u rows=%u\r\n", + rc, (unsigned int)cols, (unsigned int)rows); +} + +int main(int argc, char **argv) { + setvbuf(stdout, NULL, _IONBF, 0); + setvbuf(stderr, NULL, _IONBF, 0); + + const char *id = (argc > 1) ? argv[1] : ""; + + if (id[0] == '\0') { + printf("#ERR unknown-case id=\r\n"); + return 2; + } + + printf("#START id=%s\r\n", id); + + if (strcmp(id, "cooked-echo") == 0) { + case_cooked_echo(); + } else if (strcmp(id, "control-char-echo") == 0) { + case_control_char_echo(); + } else if (strcmp(id, "raw-no-echo") == 0) { + case_raw_no_echo(); + } else if (strcmp(id, "backspace") == 0) { + case_backspace(); + } else if (strcmp(id, "kill-line") == 0) { + case_kill_line(); + } else if (strcmp(id, "word-erase") == 0) { + case_word_erase(); + } else if (strcmp(id, "line-buffering") == 0) { + case_line_buffering(); + } else if (strcmp(id, "sigint") == 0) { + case_sigint(); + } else if (strcmp(id, "sigquit") == 0) { + case_sigquit(); + } else if (strcmp(id, "vsusp") == 0) { + case_vsusp(); + } else if (strcmp(id, "erase-ctrl-h") == 0) { + case_erase_ctrl_h(); + } else if (strcmp(id, "vintr-buffer") == 0) { + case_vintr_buffer(); + } else if (strcmp(id, "raw-ctrlc-byte") == 0) { + case_raw_ctrlc_byte(); + } else if (strcmp(id, "onlcr") == 0) { + case_onlcr(); + } else if (strcmp(id, "icrnl") == 0) { + case_icrnl(); + } else if (strcmp(id, "eof") == 0) { + case_eof(); + } else if (strcmp(id, "resize-sigwinch") == 0) { + case_resize_sigwinch(); + } else if (strcmp(id, "cpr") == 0) { + case_cpr(); + } else if (strcmp(id, "isatty") == 0) { + case_isatty(); + } else if (strcmp(id, "winsize") == 0) { + case_winsize(); + } else { + printf("#ERR unknown-case id=%s\r\n", id); + return 2; + } + + printf("#DONE id=%s\r\n", id); + return 0; +} diff --git a/packages/core/tests/fixtures/pty/pty_probe.mjs b/packages/core/tests/fixtures/pty/pty_probe.mjs new file mode 100644 index 0000000000..7fa76eae4f --- /dev/null +++ b/packages/core/tests/fixtures/pty/pty_probe.mjs @@ -0,0 +1,431 @@ +// pty_probe.mjs — guest-Node twin of pty_probe.c. +// +// Implements the IDENTICAL argv-dispatched case set and the IDENTICAL `#`-prefixed +// marker protocol (byte-matching hex/text encoding) so the host harness can assert +// the same strings against both the WASM-C probe and this Node probe. +// +// Launched by the host as a BUILT-IN VM command: +// vm.openShell({ command: "node", args: ["/pty_probe.mjs", caseId], cols, rows }) +// openShell auto-injects AGENTOS_EXEC_TTY=1 + COLUMNS/LINES; the sidecar opens a +// PTY and dup2()s the slave onto fd 0/1/2. +// +// GUEST-NODE TTY STATUS (the runtime now routes guest-node stdin through the +// kernel PTY and populates the TTY config from the kernel, so most cells match +// the wasm-c probe). Remaining honest gaps (asserted-as-broken by the host, NOT +// faked here): +// - SIGINT/SIGQUIT are consumed by the kernel line discipline (no data byte), +// but the signal is not yet delivered into the V8 isolate / does not kill it, +// so the probe is not torn down the way the wasm-c process is. +// - live PTY resize is not delivered as SIGWINCH, so a re-query after resize +// still reports the launch size. +// - a raw lone-LF stdout write does not flow through OPOST/ONLCR (guest-node +// stdout payload-write quirk), so `a\nb` is not rewritten to `a\r\nb`. + +// ---- output discipline: synchronous writes to fd 1, explicit \r\n ------------ + +function out(s) { + process.stdout.write(s); +} + +// ---- encoders (byte-identical to the C print_hex / print_text) -------------- + +function printHex(bytes) { + let o = ""; + for (let i = 0; i < bytes.length; i++) { + if (i > 0) o += " "; + o += bytes[i].toString(16).toUpperCase().padStart(2, "0"); + } + return o; +} + +function printText(bytes) { + let o = ""; + for (let i = 0; i < bytes.length; i++) { + const c = bytes[i]; + if (c === 0x0d) o += "\\r"; + else if (c === 0x0a) o += "\\n"; + else if (c === 0x09) o += "\\t"; + else if (c === 0x1b) o += "\\e"; + else if (c < 0x20 || c === 0x7f) + o += "\\x" + c.toString(16).toUpperCase().padStart(2, "0"); + else o += String.fromCharCode(c); + } + return o; +} + +function emitBytes(tag, buf) { + out(`#BYTES tag=${tag} n=${buf.length} hex=${printHex(buf)} text=${printText(buf)}\r\n`); +} + +// ---- blocking stdin reader (keeps the probe "blocked in read") -------------- +// +// Accumulates process.stdin 'data' bytes one at a time until the terminator byte +// is seen / `cap` bytes collected / EOF, then resolves a Buffer. Mirrors the C +// read_until loop: the host can type bytes with NO terminator and the promise +// stays pending, so any glyph on screen is kernel echo, not this readback. + +function readUntil(term, cap = 1 << 20) { + return new Promise((resolve) => { + const acc = []; + let done = false; + const finish = () => { + if (done) return; + done = true; + process.stdin.off("data", onData); + process.stdin.off("end", onEnd); + process.stdin.pause(); + resolve(Buffer.from(acc)); + }; + const onData = (chunk) => { + for (let i = 0; i < chunk.length; i++) { + acc.push(chunk[i]); + if (chunk[i] === term || acc.length >= cap) { + finish(); + return; + } + } + }; + const onEnd = () => finish(); + process.stdin.on("data", onData); + process.stdin.on("end", onEnd); + process.stdin.resume(); + }); +} + +// Reads exactly one byte (or resolves an empty Buffer on EOF). +function readByte() { + return new Promise((resolve) => { + let done = false; + const finish = (buf) => { + if (done) return; + done = true; + process.stdin.off("data", onData); + process.stdin.off("end", onEnd); + process.stdin.pause(); + resolve(buf); + }; + const onData = (chunk) => finish(Buffer.from(chunk.subarray(0, 1))); + const onEnd = () => finish(Buffer.alloc(0)); + process.stdin.on("data", onData); + process.stdin.on("end", onEnd); + process.stdin.resume(); + }); +} + +// Attempts a raw-mode toggle, emitting the honest #MODE marker. On guest-node +// setRawMode throws (isTTY=false) -> `#MODE want= rc=err err=`. +function setMode(want) { + const enable = want === "raw"; + try { + process.stdin.setRawMode(enable); + out(`#MODE want=${want} rc=0\r\n`); + } catch (err) { + out(`#MODE want=${want} rc=err err=${err && err.message ? err.message : String(err)}\r\n`); + } +} + +// ---- cases ------------------------------------------------------------------ + +async function caseCookedEcho() { + // Cooked is the kernel default; setRawMode(false) would throw on guest-node, + // and cooked is what we want, so report a no-op success to mirror the C output. + out("#MODE want=cooked rc=0\r\n"); + out("#READY tag=echo\r\n"); + const buf = await readUntil(0x0a); + if (buf.length === 0) { + out("#EOF tag=echo n=0\r\n"); + return; + } + emitBytes("echo", buf); +} + +async function caseControlCharEcho() { + setMode("cooked"); + out("#READY tag=ctl\r\n"); + const buf = await readUntil(0x0a); + if (buf.length === 0) { + out("#EOF tag=ctl n=0\r\n"); + return; + } + emitBytes("ctl", buf); +} + +async function caseRawNoEcho() { + setMode("raw"); + out("#READY tag=raw\r\n"); + const buf = await readUntil(0x21); // '!' + emitBytes("raw", buf); +} + +async function caseBackspace() { + // Cooked default; mirror C #MODE want=cooked rc=0 (no setRawMode call). + out("#MODE want=cooked rc=0\r\n"); + out("#READY tag=erase\r\n"); + const buf = await readUntil(0x0a); + emitBytes("erase", buf); +} + +async function caseKillLine() { + setMode("cooked"); + out("#READY tag=kill\r\n"); + const buf = await readUntil(0x0a); + emitBytes("kill", buf); +} + +async function caseWordErase() { + out("#MODE want=cooked rc=0\r\n"); + out("#READY tag=werase\r\n"); + const buf = await readUntil(0x0a); + emitBytes("werase", buf); +} + +async function caseLineBuffering() { + out("#MODE want=cooked rc=0\r\n"); + out("#READY tag=canon\r\n"); + const buf = await readUntil(0x0a); + if (buf.length === 0) { + out("#EOF tag=canon n=0\r\n"); + return; + } + emitBytes("canon", buf); +} + +async function caseSigint() { + out("#MODE want=cooked rc=0\r\n"); + // Register the handler that SHOULD fire on ^C. On guest-node it never does + // (no SIGINT delivery) — kept so a fixed runtime passes the same probe and so + // the absence of #SIG is observable, not faked. + process.on("SIGINT", () => { + out("#SIG name=SIGINT\r\n"); + finish("sigint"); + }); + out("#READY tag=sigint\r\n"); + // On guest-node ^C arrives as a raw 0x03 DATA byte (no signal). Report the + // first chunk we receive (mirrors the contract's first-data semantics) so the + // broken delivery is observable; a working runtime fires the handler instead. + const buf = await readByte(); + if (buf.length === 0) { + out("#EOF tag=sigint n=0\r\n"); + } else { + emitBytes("sigint", buf); + } +} + +async function caseSigquit() { + out("#MODE want=cooked rc=0\r\n"); + process.on("SIGQUIT", () => { + out("#SIG name=SIGQUIT\r\n"); + finish("sigquit"); + }); + out("#READY tag=sigquit\r\n"); + // The lone 0x1C arrives as a data byte on guest-node (no signal); report it. + const buf = await readByte(); + if (buf.length === 0) { + out("#EOF tag=sigquit n=0\r\n"); + } else { + emitBytes("sigquit", buf); + } +} + +async function caseVsusp() { + out("#MODE want=cooked rc=0\r\n"); + // SHOULD fire on ^Z; on guest-node it never does (no SIGTSTP delivery) — kept + // so a fixed runtime passes the same probe and the absence of #SIG is honest. + process.on("SIGTSTP", () => { + out("#SIG name=SIGTSTP\r\n"); + finish("vsusp"); + }); + out("#READY tag=susp\r\n"); + // The kernel line discipline consumes ^Z as a signal (no data byte), so this + // read stays pending; a leaked data byte resolves it and is reported. + const buf = await readByte(); + if (buf.length === 0) { + out("#EOF tag=susp n=0\r\n"); + } else { + emitBytes("susp", buf); + } +} + +async function caseEraseCtrlH() { + out("#MODE want=cooked rc=0\r\n"); + out("#READY tag=eraseh\r\n"); + const buf = await readUntil(0x0a); + emitBytes("eraseh", buf); +} + +async function caseVintrBuffer() { + out("#MODE want=cooked rc=0\r\n"); + out("#READY tag=vintrbuf\r\n"); + // The kernel flushes the canonical buffer on ^C, so the buffered "abc" is + // discarded; if this runtime survives the SIGINT it then reads "de\n" and the + // delivered line proves the flush ("de\n", not "abcde\n"). + const buf = await readUntil(0x0a); + if (buf.length === 0) { + out("#EOF tag=vintrbuf n=0\r\n"); + } else { + emitBytes("vintrbuf", buf); + } +} + +async function caseRawCtrlcByte() { + setMode("raw"); + out("#READY tag=rawc\r\n"); + const buf = await readByte(); + if (buf.length === 0) { + out("#EOF tag=rawc n=0\r\n"); + return; + } + emitBytes("rawc", buf); +} + +function caseOnlcr() { + // Raw-write `a\nb`; the kernel pty's OPOST+ONLCR must inject CR before the LF. + process.stdout.write(Buffer.from([0x61, 0x0a, 0x62])); +} + +async function caseIcrnl() { + out("#MODE want=cooked rc=0\r\n"); + out("#READY tag=icrnl\r\n"); + const buf = await readUntil(0x0a); + emitBytes("icrnl", buf); +} + +async function caseEof() { + out("#MODE want=cooked rc=0\r\n"); + out("#READY tag=eof\r\n"); + const buf = await readByte(); + if (buf.length === 0) { + out("#EOF tag=eof n=0\r\n"); + } else { + // Honest-broken guest-node path: ^D delivered as a data byte, not EOF. + emitBytes("eof", buf); + } +} + +async function caseResizeSigwinch() { + out(`#SIZE tag=before rc=0 cols=${process.stdout.columns} rows=${process.stdout.rows}\r\n`); + // A real TTY delivers SIGWINCH on resize; guest-node never receives it, so + // the #SIG / #SIZE tag=after lines simply never print (honest broken). + process.on("SIGWINCH", () => { + out("#SIG name=SIGWINCH\r\n"); + out(`#SIZE tag=after rc=0 cols=${process.stdout.columns} rows=${process.stdout.rows}\r\n`); + }); + out("#READY tag=resize\r\n"); + await readUntil(0x21); // '!' +} + +async function caseCpr() { + setMode("raw"); + process.stdout.write("\x1b[6n"); + out("#CPR sent=1\r\n"); + const buf = await readUntil(0x52); // 'R' + if (buf.length === 0) { + out("#EOF tag=cpr n=0\r\n"); + return; + } + out(`#CPRREPLY n=${buf.length} hex=${printHex(buf)} text=${printText(buf)}\r\n`); +} + +function caseIsatty() { + const in_ = process.stdin.isTTY ? 1 : 0; + const o = process.stdout.isTTY ? 1 : 0; + const e = process.stderr.isTTY ? 1 : 0; // PTY slave is dup2'd onto fd 2 too + out(`#TTY in=${in_} out=${o} err=${e}\r\n`); +} + +function caseWinsize() { + const cols = process.stdout.columns; + const rows = process.stdout.rows; + out(`#SIZE tag=open rc=0 cols=${cols} rows=${rows}\r\n`); +} + +// ---- dispatch --------------------------------------------------------------- + +const CASE_ID = process.argv[2] ?? ""; + +function finish(id) { + out(`#DONE id=${id}\r\n`); + process.exit(0); +} + +async function main() { + if (CASE_ID === "") { + out("#ERR unknown-case id=\r\n"); + process.exit(2); + return; + } + + out(`#START id=${CASE_ID}\r\n`); + + switch (CASE_ID) { + case "cooked-echo": + await caseCookedEcho(); + break; + case "control-char-echo": + await caseControlCharEcho(); + break; + case "raw-no-echo": + await caseRawNoEcho(); + break; + case "backspace": + await caseBackspace(); + break; + case "kill-line": + await caseKillLine(); + break; + case "word-erase": + await caseWordErase(); + break; + case "line-buffering": + await caseLineBuffering(); + break; + case "sigint": + await caseSigint(); + break; + case "sigquit": + await caseSigquit(); + break; + case "vsusp": + await caseVsusp(); + break; + case "erase-ctrl-h": + await caseEraseCtrlH(); + break; + case "vintr-buffer": + await caseVintrBuffer(); + break; + case "raw-ctrlc-byte": + await caseRawCtrlcByte(); + break; + case "onlcr": + caseOnlcr(); + break; + case "icrnl": + await caseIcrnl(); + break; + case "eof": + await caseEof(); + break; + case "resize-sigwinch": + await caseResizeSigwinch(); + break; + case "cpr": + await caseCpr(); + break; + case "isatty": + caseIsatty(); + break; + case "winsize": + caseWinsize(); + break; + default: + out(`#ERR unknown-case id=${CASE_ID}\r\n`); + process.exit(2); + return; + } + + out(`#DONE id=${CASE_ID}\r\n`); + process.exit(0); +} + +main(); diff --git a/packages/core/tests/pty-line-discipline.test.ts b/packages/core/tests/pty-line-discipline.test.ts new file mode 100644 index 0000000000..869af8c9dd --- /dev/null +++ b/packages/core/tests/pty-line-discipline.test.ts @@ -0,0 +1,893 @@ +// pty-line-discipline.test.ts — terminal (PTY) line-discipline matrix. +// +// ONE shared CASES table is exercised IDENTICALLY through TWO guest runtimes: +// - wasm-c : a C probe (tests/fixtures/pty/pty_probe.c) compiled to WASM and +// registered as a VM command. +// - js-node: a guest-Node probe (tests/fixtures/pty/pty_probe.mjs) launched via +// the built-in `node` command. +// Both probes implement the same argv-dispatched case set and emit the same +// `#`-prefixed marker protocol, so the host asserts the SAME strings for both. +// +// Each case's run(ctx) asserts the CORRECT (kernel-conformant) behavior. A cell +// is run under `test.fails` when it is effectively known-broken: +// effectiveKnownBroken = case.knownBroken || (runtime === "js-node" && case.ttyDependent) +// so the correct-behavior assertion still RUNS and still throws honestly today — +// `test.fails` records that expected failure and will turn RED the moment the +// kernel / V8 TTY bridge is fixed (alerting that the flag is stale). Nothing is +// skipped. +// +// IMPORTANT (regression-guard integrity): for an it.fails cell the ONLY thing that +// may throw is the load-bearing behavior assertion — `ctx.snapshot()` does NOT +// assert for broken cells (see the snapshot() impl). If it did, a future FIX would +// change the screen, the stored snapshot would mismatch and throw, and it.fails +// would stay green — silently masking the fix. Snapshots are therefore recorded and +// asserted only for the PASSING (real `it`) cells; a regression there turns the cell +// RED, and a fix to a broken cell turns its it.fails RED. +// +// Signal-death cells (sigint/sigquit/vintr-buffer) POSITIVELY assert death: they +// `await waitShell` and require it to RESOLVE (status is never the 15s "timeout"), +// proving the signal actually terminated the process rather than the read merely +// hanging — the wasm PTY-signal kill surfaces a racy/zero exit code, so death is +// proven by "terminated + never reached #DONE", not by a 128+sig exit code. +// +// Run: +// cd +// PATH="/home/nathan/.nvm/versions/node/v24.13.0/bin:/tmp/pnpm:/usr/bin:/bin" \ +// pnpm --dir packages/core exec vitest run tests/pty-line-discipline.test.ts + +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Terminal } from "@xterm/headless"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import type { AgentOs } from "../src/index.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, "../../.."); +const FIXTURE_DIR = resolve(__dirname, "fixtures/pty"); +const C_PROBE_SOURCE = join(FIXTURE_DIR, "pty_probe.c"); +const NODE_PROBE_SOURCE = join(FIXTURE_DIR, "pty_probe.mjs"); +const NODE_PROBE_GUEST_PATH = "/pty_probe.mjs"; + +const WASI_SDK = resolve( + REPO_ROOT, + "../secure-exec/registry/native/c/vendor/wasi-sdk", +); +const SIDECAR_BINARY = resolve(REPO_ROOT, "target/debug/agentos-sidecar"); + +const SETTLE_MS = 80; +const WAIT_TIMEOUT_MS = 15_000; +const TEST_TIMEOUT_MS = 60_000; + +// --------------------------------------------------------------------------- +// build / sidecar prerequisites +// --------------------------------------------------------------------------- + +// Compile the C probe to WASM directly into a package `bin/` dir. The basename +// MUST be `pty_probe` (no extension): the sidecar registers the command from the +// package `bin/` layout and `openShell({ command: "pty_probe" })` resolves +// it. +function buildCProbe(binDir: string): void { + const clang = join(WASI_SDK, "bin/clang"); + if (!existsSync(clang)) { + throw new Error(`wasi-sdk clang not found at ${clang}`); + } + const out = join(binDir, "pty_probe"); // basename MUST be `pty_probe`, no ext + const result = spawnSync( + clang, + [ + "--target=wasm32-wasip1", + `--sysroot=${join(WASI_SDK, "share/wasi-sysroot")}`, + "-O2", + "-o", + out, + C_PROBE_SOURCE, + ], + { encoding: "utf8" }, + ); + if (result.status !== 0) { + throw new Error( + ["failed to build pty_probe.c", result.stdout, result.stderr] + .filter(Boolean) + .join("\n"), + ); + } + // Validate wasm magic so a bad build fails loudly here, not in the resolver. + const magic = readFileSync(out).subarray(0, 4); + if (!(magic[0] === 0x00 && magic[1] === 0x61 && magic[2] === 0x73 && magic[3] === 0x6d)) { + throw new Error(`pty_probe build is not a wasm module (magic=${magic.toString("hex")})`); + } +} + +// Materialize a self-contained agentOS package directory holding the freshly +// built `pty_probe` WASM (`bin/pty_probe` + `package.json` + `agentos-package.json`). +// The sidecar projects `dir` into `/opt/agentos/bin/pty_probe` and registers it as +// a guest command. Returns the package dir to pass as `software: []`. +function materializePtyProbePackage(): string { + const pkgDir = mkdtempSync(join(tmpdir(), "pty-probe-pkg-")); + const binDir = join(pkgDir, "bin"); + mkdirSync(binDir); + buildCProbe(binDir); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ name: "pty-line-discipline-fixture", version: "0.0.0" }), + ); + writeFileSync( + join(pkgDir, "agentos-package.json"), + JSON.stringify({ name: "pty-line-discipline-fixture" }), + ); + return pkgDir; +} + +function ensureSidecarBuilt(): void { + if (!existsSync(SIDECAR_BINARY)) { + // Per repo CLAUDE.md: prepare-build MUST precede every cargo invocation. + const prep = spawnSync( + "node", + ["scripts/secure-exec-dep.mjs", "prepare-build"], + { cwd: REPO_ROOT, encoding: "utf8" }, + ); + if (prep.status !== 0) { + throw new Error( + ["failed prepare-build", prep.stdout, prep.stderr].filter(Boolean).join("\n"), + ); + } + const build = spawnSync("cargo", ["build", "-q", "-p", "agentos-sidecar"], { + cwd: REPO_ROOT, + encoding: "utf8", + }); + if (build.status !== 0) { + throw new Error( + ["failed to build agentos-sidecar", build.stdout, build.stderr] + .filter(Boolean) + .join("\n"), + ); + } + } + process.env.AGENTOS_SIDECAR_BIN = SIDECAR_BINARY; +} + +// --------------------------------------------------------------------------- +// terminal helpers +// --------------------------------------------------------------------------- + +function snapshotLines(term: Terminal): string[] { + const buffer = term.buffer.active; + const lines: string[] = []; + for (let row = 0; row < term.rows; row++) { + const line = buffer.getLine(buffer.viewportY + row); + lines.push(line ? line.translateToString(true) : ""); + } + return lines; +} + +function terminalSnapshot(label: string, term: Terminal): string { + const buffer = term.buffer.active; + const lines = snapshotLines(term).map( + (line, i) => `${String(i + 1).padStart(2, "0")}|${line}`, + ); + return [ + `# ${label}`, + `cols=${term.cols} rows=${term.rows} cursor=${buffer.cursorX},${buffer.cursorY}`, + ...lines, + ].join("\n"); +} + +function screenText(term: Terminal): string { + return snapshotLines(term).join("\n"); +} + +async function settle(): Promise { + await new Promise((r) => setTimeout(r, SETTLE_MS)); +} + +// --------------------------------------------------------------------------- +// case context +// --------------------------------------------------------------------------- + +interface Runtime { + name: "wasm-c" | "js-node"; +} + +interface Ctx { + runtime: Runtime; + broken: boolean; + vm: AgentOs; + shellId: string; + term: Terminal; + expect: typeof expect; + writeShell(data: string | Uint8Array): Promise; + resizeShell(cols: number, rows: number): void; + waitForScreen(text: string, timeoutMs?: number): Promise; + waitShellStatus(timeoutMs?: number): Promise; + settle(): Promise; + screen(): string; + currentLine(): string; + markerLine(prefix: string): string | undefined; + rawHex(): string; + snapshot(suffix: string): string; +} + +interface Case { + id: string; + knownBroken: boolean; + ttyDependent: boolean; + cols?: number; + rows?: number; + run(ctx: Ctx): Promise; +} + +// --------------------------------------------------------------------------- +// CASES — one shared table, looped over both runtimes +// --------------------------------------------------------------------------- + +const CASES: Case[] = [ + { + // Cooked-mode terminal echo: typing "abc" (no newline) must paint "abc" on + // screen via the kernel ECHO flag while the program is still blocked in + // read(). Correct on both runtimes: wasm-c surfaces the kernel echo via the + // PTY master, and guest-node now routes stdin through the same kernel PTY. + id: "cooked-echo", + knownBroken: false, + ttyDependent: false, + async run(ctx) { + await ctx.waitForScreen("#READY tag=echo"); + await ctx.writeShell("abc"); + await ctx.settle(); + ctx.snapshot("echo-while-blocked"); + // Load-bearing honest echo assertion (kernel echo, NOT readback). + ctx.expect(ctx.screen()).toContain("abc"); + await ctx.writeShell("\n"); + await ctx.waitForScreen("#BYTES tag=echo"); + ctx.snapshot("after-newline"); + ctx.expect(ctx.screen()).toContain( + "#BYTES tag=echo n=4 hex=61 62 63 0A text=abc\\n", + ); + await ctx.waitForScreen("#DONE id=cooked-echo"); + }, + }, + { + // ECHOCTL: a non-signal control byte (^A 0x01) should echo as caret "^A". + // The kernel caret-echoes control chars; correct on both runtimes now that + // guest-node stdin flows through the kernel PTY. + id: "control-char-echo", + knownBroken: false, + ttyDependent: false, + async run(ctx) { + await ctx.waitForScreen("#READY tag=ctl"); + await ctx.writeShell("\x01"); + await ctx.settle(); + ctx.snapshot("echo"); + ctx.expect(ctx.screen()).toContain("^A"); + await ctx.writeShell("\n"); + await ctx.waitForScreen("#BYTES tag=ctl"); + ctx.snapshot("report"); + ctx.expect(ctx.screen()).toContain( + "#BYTES tag=ctl n=2 hex=01 0A text=\\x01\\n", + ); + }, + }, + { + // RAW mode must not echo. Correct on both runtimes: guest-node's stdin is a + // real PTY now, so setRawMode() succeeds (rc=0) and disables echo just like + // the wasm-c probe. + id: "raw-no-echo", + knownBroken: false, + ttyDependent: false, + async run(ctx) { + await ctx.waitForScreen("#READY tag=raw"); + await ctx.writeShell("abc"); + await ctx.settle(); + const blocked = ctx.snapshot("blocked"); + ctx.expect(blocked).not.toContain("abc"); + ctx.expect(ctx.screen()).toContain("#MODE want=raw rc=0"); + await ctx.writeShell("!"); + await ctx.waitForScreen("#BYTES tag=raw"); + ctx.snapshot("done"); + ctx.expect(ctx.screen()).toContain( + "#BYTES tag=raw n=4 hex=61 62 63 21 text=abc!", + ); + await ctx.waitForScreen("#DONE id=raw-no-echo"); + }, + }, + { + // VERASE (DEL 0x7f) in cooked mode erases one char (08 20 08 echo) and the + // delivered line drops it. Correct on both runtimes (guest-node stdin is + // cooked by the kernel PTY). + id: "backspace", + knownBroken: false, + ttyDependent: false, + async run(ctx) { + await ctx.waitForScreen("#READY tag=erase"); + await ctx.writeShell("ab"); + await ctx.writeShell(Uint8Array.of(0x7f)); + await ctx.settle(); + ctx.snapshot("echo"); + await ctx.writeShell("\n"); + await ctx.waitForScreen("#BYTES tag=erase"); + ctx.snapshot("report"); + // Load-bearing: VERASE drops the last buffered char, so the delivered + // line is "a\n" (n=2), independent of the broken cooked screen echo. + ctx.expect(ctx.markerLine("#BYTES tag=erase")).toBe( + "#BYTES tag=erase n=2 hex=61 0A text=a\\n", + ); + }, + }, + { + // VKILL (^U 0x15) should clear the whole line. Implemented in the kernel + // (clears the canonical line buffer + echoes the erase). Correct on both + // runtimes now that guest-node stdin flows through the kernel PTY. + id: "kill-line", + knownBroken: false, + ttyDependent: false, + async run(ctx) { + await ctx.waitForScreen("#READY tag=kill"); + await ctx.writeShell("abc"); + await ctx.writeShell("\x15"); + await ctx.settle(); + ctx.snapshot("after-kill"); + ctx.expect(ctx.screen()).not.toContain("abc"); + await ctx.writeShell("\n"); + await ctx.waitForScreen("#BYTES tag=kill"); + ctx.snapshot("report"); + ctx.expect(ctx.screen()).toContain("#BYTES tag=kill n=1 hex=0A text=\\n"); + }, + }, + { + // VWERASE (^W 0x17) should erase the last word. Implemented in the kernel + // (erases the trailing non-blank run, keeping the preceding space). Correct + // on both runtimes now that guest-node stdin flows through the kernel PTY. + id: "word-erase", + knownBroken: false, + ttyDependent: false, + async run(ctx) { + await ctx.waitForScreen("#READY tag=werase"); + await ctx.writeShell("foo bar\x17"); + await ctx.settle(); + ctx.snapshot("echo"); + ctx.expect(ctx.screen()).not.toContain("foo bar"); + await ctx.writeShell("\n"); + await ctx.waitForScreen("#BYTES tag=werase"); + ctx.snapshot("report"); + ctx.expect(ctx.screen()).toContain( + "#BYTES tag=werase n=5 hex=66 6F 6F 20 0A text=foo \\n", + ); + }, + }, + { + // ICANON line buffering: read stays blocked (no #BYTES) until '\n', then + // the whole line is delivered. EMPIRICALLY this delivery path works for + // BOTH runtimes (the kernel buffers + delivers the cooked line to guest-node + // stdin too), so we assert only buffering+delivery, not the broken echo. + id: "line-buffering", + knownBroken: false, + ttyDependent: false, + async run(ctx) { + await ctx.waitForScreen("#READY tag=canon"); + await ctx.writeShell("hello"); + await ctx.settle(); + const held = ctx.snapshot("held"); + // Load-bearing: kernel is HOLDING the line (no read returned yet). + ctx.expect(held).not.toContain("#BYTES tag=canon"); + await ctx.writeShell("\n"); + await ctx.waitForScreen("#BYTES tag=canon"); + ctx.snapshot("delivered"); + ctx.expect(ctx.screen()).toContain( + "#BYTES tag=canon n=6 hex=68 65 6C 6C 6F 0A text=hello\\n", + ); + }, + }, + { + // ISIG VINTR (^C 0x03) raises SIGINT to the foreground pgid; the byte is + // neither delivered nor echoed. Both runtimes prove this via process + // death: guest-node stdin is now a real kernel PTY (slave on fd 0), so + // the discipline consumes the byte as a signal and SIGINT terminates the + // shell process exactly like wasm-c. + id: "sigint", + knownBroken: false, + ttyDependent: false, + async run(ctx) { + await ctx.waitForScreen("#READY tag=sigint"); + await ctx.writeShell("\x03"); + const status = await ctx.waitShellStatus(); + ctx.snapshot("after"); + // Correct: VINTR is consumed as a signal -> the byte is neither echoed + // nor delivered (no #BYTES) and the probe is killed mid-read so it never + // reaches #DONE. + ctx.expect(ctx.screen()).not.toContain("#BYTES tag=sigint"); + ctx.expect(ctx.screen()).not.toContain("#DONE id=sigint"); + ctx.expect(ctx.screen()).not.toContain("^C"); + // POSITIVE proof of death (fixes the prior weakness where a merely-HUNG + // process — signal silently dropped — also satisfied the negatives above): + // waitShell RESOLVED, so SIGINT actually TERMINATED the process instead of + // the read blocking the full 15s — `status` is a real exit status, never + // "timeout"/"error". Combined with the missing #DONE that means it died + // mid-read. (The wasm PTY-signal kill surfaces a racy/zero exit code, so + // death is proven by termination + no #DONE, not a 128+sig code.) guest-node + // survives the signal and reaches #DONE via EOF, so `not #DONE` throws and + // the cell stays correctly RED under it.fails until signal->isolate lands. + ctx.expect(status).not.toBe("timeout"); + ctx.expect(status).not.toBe("error"); + }, + }, + { + // ISIG VQUIT (^\ 0x1C) raises SIGQUIT. Same shape as sigint: the byte is + // consumed as a signal (no #BYTES, no echo) and the signal terminates the + // shell process on both runtimes. + id: "sigquit", + knownBroken: false, + ttyDependent: false, + async run(ctx) { + await ctx.waitForScreen("#READY tag=sigquit"); + await ctx.writeShell(Uint8Array.of(0x1c)); + const status = await ctx.waitShellStatus(); + ctx.snapshot("after"); + // Correct: VQUIT is consumed as a signal (no #BYTES, no #DONE). + ctx.expect(ctx.screen()).not.toContain("#BYTES tag=sigquit"); + ctx.expect(ctx.screen()).not.toContain("#DONE id=sigquit"); + // POSITIVE proof of death: waitShell RESOLVED, so SIGQUIT terminated the + // process (status is never a 15s "timeout"); with the missing #DONE that + // means it died mid-read. (js-node survives -> reaches #DONE -> the `not + // #DONE` check throws -> stays correctly RED under it.fails.) + ctx.expect(status).not.toBe("timeout"); + ctx.expect(status).not.toBe("error"); + }, + }, + { + // RAW mode (ISIG off): VINTR (0x03) is ordinary data, not a signal. The + // observable (#BYTES n=1 hex=03) is identical on both runtimes -> green. + id: "raw-ctrlc-byte", + knownBroken: false, + ttyDependent: false, + async run(ctx) { + await ctx.waitForScreen("#READY tag=rawc"); + ctx.snapshot("before-input"); + await ctx.writeShell("\x03"); + await ctx.waitForScreen("#BYTES tag=rawc"); + ctx.snapshot("after"); + ctx.expect(ctx.screen()).toContain("#BYTES tag=rawc n=1 hex=03 text=\\x03"); + ctx.expect(ctx.screen()).not.toContain("^C"); + await ctx.waitForScreen("#DONE id=raw-ctrlc-byte"); + }, + }, + { + // ISIG VSUSP (^Z 0x1a) raises SIGTSTP. The kernel line discipline consumes + // the byte as a signal on BOTH runtimes (pure pty.rs behavior): it is never + // echoed (no caret) and never delivered (no #BYTES), and the cooked read + // never completes into a clean #DONE. (We assert only the line-discipline + // contract here, not the host-side suspension: SIGTSTP marks the process + // Stopped rather than killing it, so unlike sigint there is no exit code to + // assert — proving the byte is swallowed as a signal is the conformant check + // and it holds identically for wasm-c and guest-node.) + id: "vsusp", + knownBroken: false, + ttyDependent: false, + async run(ctx) { + await ctx.waitForScreen("#READY tag=susp"); + await ctx.writeShell(Uint8Array.of(0x1a)); + await ctx.settle(); + ctx.snapshot("after"); + ctx.expect(ctx.screen()).not.toContain("#BYTES tag=susp"); + ctx.expect(ctx.screen()).not.toContain("#DONE id=vsusp"); + // Not echoed: neither the raw control byte nor an ECHOCTL caret ("^Z"). + ctx.expect(ctx.screen()).not.toContain("^Z"); + ctx.expect(ctx.screen()).not.toContain("\\x1A"); + }, + }, + { + // VERASE alias ^H (0x08): the kernel maps both DEL (0x7f) and ^H (0x08) to + // the erase op, so typing "ab" + ^H + '\n' drops the last buffered char and + // delivers "a\n". Pure line discipline -> correct on both runtimes. + id: "erase-ctrl-h", + knownBroken: false, + ttyDependent: false, + async run(ctx) { + await ctx.waitForScreen("#READY tag=eraseh"); + await ctx.writeShell("ab"); + await ctx.writeShell(Uint8Array.of(0x08)); + await ctx.settle(); + ctx.snapshot("echo"); + await ctx.writeShell("\n"); + await ctx.waitForScreen("#BYTES tag=eraseh"); + ctx.snapshot("report"); + // Load-bearing: ^H erased the last buffered byte exactly like DEL, so the + // delivered line is "a\n" (n=2), independent of the screen echo. + ctx.expect(ctx.markerLine("#BYTES tag=eraseh")).toBe( + "#BYTES tag=eraseh n=2 hex=61 0A text=a\\n", + ); + }, + }, + { + // VINTR mid-line BOTH flushes the canonical input buffer AND raises SIGINT. + // Neither runtime can catch SIGINT (both are killed mid-read), so this + // cell observes the kill half on both: "abc" is buffered but never + // delivered (no #BYTES), the probe never completes (no #DONE), and the + // shell terminates instead of blocking the full 15s. The buffer-flush + // half is covered by the kernel pty unit tests (canonical buffer cleared + // on VINTR), which don't need a signal-surviving guest to observe it. + id: "vintr-buffer", + knownBroken: false, + ttyDependent: false, + async run(ctx) { + await ctx.waitForScreen("#READY tag=vintrbuf"); + await ctx.writeShell("abc"); + await ctx.settle(); + await ctx.writeShell("\x03"); + await ctx.writeShell("de\n"); + const status = await ctx.waitShellStatus(); + ctx.snapshot("after"); + // Killed by SIGINT mid-read: "abc" is never delivered (no #BYTES) and + // the probe never completes (no #DONE). waitShell RESOLVED (not a 15s + // "timeout"), proving the signal actually terminated it. + ctx.expect(ctx.screen()).not.toContain("#BYTES tag=vintrbuf"); + ctx.expect(ctx.screen()).not.toContain("#DONE id=vintr-buffer"); + ctx.expect(status).not.toBe("timeout"); + ctx.expect(status).not.toBe("error"); + }, + }, + { + // OPOST+ONLCR: a raw-written lone LF must become CRLF on the master read + // path. Fixed on wasm-c: the host now surfaces the PTY master output + // (ONLCR-processed) instead of the raw guest chunk, so the master reads + // 61 0D 0A 62. js-node's raw stdout payload write does not flow through the + // ONLCR path (root cause 3 js stdout quirk) -> still broken -> tty-dependent. + id: "onlcr", + knownBroken: false, + ttyDependent: true, + async run(ctx) { + await ctx.waitForScreen("#DONE id=onlcr"); + ctx.snapshot("onlcr"); + ctx.expect(ctx.rawHex()).toContain("61 0D 0A 62"); + ctx.expect(ctx.rawHex()).not.toContain("61 0A 62"); + }, + }, + { + // ICRNL maps a typed CR (0x0D) to NL (0x0A) in cooked mode: it both + // terminates the line and is the byte delivered. Correct on both runtimes + // (guest-node stdin is cooked by the kernel PTY). + id: "icrnl", + knownBroken: false, + ttyDependent: false, + async run(ctx) { + await ctx.waitForScreen("#READY tag=icrnl"); + await ctx.writeShell("x"); + await ctx.settle(); + const echoSnap = ctx.snapshot("echo"); + // Read still blocked (no terminator yet): the line is held by canonical + // mode, so the delivery marker must not be present. + ctx.expect(echoSnap).not.toContain("#BYTES tag=icrnl"); + await ctx.writeShell("\r"); + await ctx.waitForScreen("#BYTES tag=icrnl"); + ctx.snapshot("final"); + // Load-bearing: the typed CR (0x0D) was mapped by ICRNL to NL (0x0A), + // terminating the line AND being the byte delivered -> "x\n" (78 0A). + ctx.expect(ctx.screen()).toContain( + "#BYTES tag=icrnl n=2 hex=78 0A text=x\\n", + ); + }, + }, + { + // VEOF (^D 0x04) on an empty line in cooked mode -> read() returns 0 (EOF), + // not echoed. Correct on both runtimes: guest-node reads the kernel PTY, + // so ^D surfaces as a 0-length read (EOF), not a data byte. + id: "eof", + knownBroken: false, + ttyDependent: false, + async run(ctx) { + await ctx.waitForScreen("#READY tag=eof"); + ctx.snapshot("ready"); + await ctx.writeShell(Uint8Array.of(0x04)); + await ctx.waitForScreen("#DONE id=eof"); + ctx.snapshot("done"); + // ^D never echoed on either runtime. + ctx.expect(ctx.screen()).not.toContain("^D"); + ctx.expect(ctx.screen()).not.toContain("\\x04"); + // Correct: clean EOF, no data byte delivered. + ctx.expect(ctx.screen()).toContain("#EOF tag=eof n=0"); + ctx.expect(ctx.screen()).not.toContain("#BYTES tag=eof"); + }, + }, + { + // SIGWINCH / live window size: after the host resizes the PTY, the probe + // re-queries and must see the NEW size. js-node freezes at the launch + // size and never sees the resize (broken). + id: "resize-sigwinch", + knownBroken: false, + ttyDependent: true, + cols: 80, + rows: 24, + async run(ctx) { + await ctx.waitForScreen("#SIZE tag=before"); + await ctx.waitForScreen("#READY tag=resize"); + ctx.resizeShell(120, 40); + // Sentinel '!' + CR (ICRNL -> NL flushes the cooked line). + await ctx.writeShell("!\r"); + await ctx.waitForScreen("#SIZE tag=after rc=0 cols=120 rows=40"); + ctx.snapshot("resize"); + ctx.expect(ctx.screen()).toContain( + "#SIZE tag=before rc=0 cols=80 rows=24", + ); + }, + }, + { + // Cursor Position Report round-trip: probe writes ESC[6n and reads the + // emulator's ESC[;R reply. Works on BOTH runtimes (guest-node stdin + // is a non-canonical data stream, so the newline-less reply still flows); + // only the #MODE marker diverges, which we do not assert. + id: "cpr", + knownBroken: false, + ttyDependent: false, + async run(ctx) { + await ctx.waitForScreen("#CPR sent=1"); + await ctx.waitForScreen("#CPRREPLY"); + ctx.snapshot("cpr-reply"); + const m = ctx + .screen() + .match(/#CPRREPLY n=\d+ hex=[0-9A-F ]+ text=(\S+)/); + ctx.expect(m, "expected a #CPRREPLY marker line").toBeTruthy(); + ctx.expect(m?.[1] ?? "").toMatch(/^\\e\[\d+;\d+R$/); + }, + }, + { + // isatty(0/1/2): a PTY slave dup2'd onto the fds reports TTY for all three. + // Correct on both runtimes: guest-node's TTY config is now derived from the + // kernel isatty bridge, so process.stdin/stdout/stderr.isTTY are all true. + id: "isatty", + knownBroken: false, + ttyDependent: false, + cols: 80, + rows: 24, + async run(ctx) { + await ctx.waitForScreen("#TTY "); + ctx.snapshot("tty"); + ctx.expect(ctx.markerLine("#TTY ")).toBe("#TTY in=1 out=1 err=1"); + await ctx.waitForScreen("#DONE id=isatty"); + }, + }, + { + // Window size: the kernel slave winsize must match openShell({cols,rows}). + // Correct on both runtimes: guest-node's process.stdout.columns/rows are now + // read live from the kernel PTY winsize. Non-default dims so a stale 80x24 + // fallback can't pass by accident. + id: "winsize", + knownBroken: false, + ttyDependent: false, + cols: 100, + rows: 37, + async run(ctx) { + await ctx.waitForScreen("#SIZE tag=open"); + await ctx.waitForScreen("#DONE id=winsize"); + ctx.snapshot("winsize"); + ctx.expect(ctx.screen()).toContain( + "#SIZE tag=open rc=0 cols=100 rows=37", + ); + }, + }, +]; + +const RUNTIMES: Runtime[] = [{ name: "wasm-c" }, { name: "js-node" }]; + +// --------------------------------------------------------------------------- +// suite +// --------------------------------------------------------------------------- + +describe("PTY line discipline matrix", () => { + let vm: AgentOs | undefined; + + beforeAll(async () => { + ensureSidecarBuilt(); + const packageDir = materializePtyProbePackage(); + + const { AgentOs } = await import("../src/index.js"); + vm = await AgentOs.create({ + software: [{ packageDir }], + }); + await vm.writeFile(NODE_PROBE_GUEST_PATH, readFileSync(NODE_PROBE_SOURCE)); + }, 180_000); + + afterAll(async () => { + if (vm) { + await vm.dispose(); + vm = undefined; + } + }); + + for (const rt of RUNTIMES) { + describe(rt.name, () => { + let term: Terminal | undefined; + let shellId: string | undefined; + let unsubscribe: (() => void) | undefined; + let disposeOnData: { dispose(): void } | undefined; + let rawBytes: number[] = []; + + afterEach(async () => { + unsubscribe?.(); + unsubscribe = undefined; + disposeOnData?.dispose(); + disposeOnData = undefined; + if (vm && shellId) { + try { + vm.closeShell(shellId); + } catch { + // probe may already have exited + } + } + term?.dispose(); + term = undefined; + shellId = undefined; + rawBytes = []; + }); + + for (const c of CASES) { + const effectiveKnownBroken = + c.knownBroken || (rt.name === "js-node" && c.ttyDependent); + registerCase(rt, c, effectiveKnownBroken, { + getVm: () => vm, + setState: (t, sid, un, dod) => { + term = t; + shellId = sid; + unsubscribe = un; + disposeOnData = dod; + }, + getRawBytes: () => rawBytes, + }); + } + }); + } +}); + +// --------------------------------------------------------------------------- +// per-cell registration (kept out of the loop body for clarity) +// --------------------------------------------------------------------------- + +interface CellHooks { + getVm(): AgentOs | undefined; + setState( + term: Terminal, + shellId: string, + unsubscribe: () => void, + disposeOnData: { dispose(): void }, + ): void; + getRawBytes(): number[]; +} + +function registerCase( + rt: Runtime, + c: Case, + effectiveKnownBroken: boolean, + hooks: CellHooks, +): void { + const runner = effectiveKnownBroken ? it.fails : it; + const title = effectiveKnownBroken ? `${c.id} [known-broken]` : c.id; + + runner( + title, + async () => { + const vm = hooks.getVm(); + if (!vm) throw new Error("vm not initialized"); + + const cols = c.cols ?? 80; + const rows = c.rows ?? 24; + const term = new Terminal({ cols, rows, allowProposedApi: true }); + const rawBytes = hooks.getRawBytes(); + + const command = + rt.name === "wasm-c" ? "pty_probe" : "node"; + const args = + rt.name === "wasm-c" + ? [c.id] + : [NODE_PROBE_GUEST_PATH, c.id]; + + const { shellId } = vm.openShell({ + command, + args, + cols, + rows, + env: { + TERM: "xterm-256color", + COLUMNS: String(cols), + LINES: String(rows), + }, + }); + + const unsubscribe = vm.onShellData(shellId, (data) => { + for (let i = 0; i < data.length; i++) rawBytes.push(data[i]); + term.write(data); + }); + const disposeOnData = term.onData((data) => { + vm.writeShell(shellId, data); + }); + hooks.setState(term, shellId, unsubscribe, disposeOnData); + + const ctx: Ctx = { + runtime: rt, + broken: effectiveKnownBroken, + vm, + shellId, + term, + expect, + async writeShell(data) { + await vm.writeShell(shellId, data); + }, + resizeShell(c2, r2) { + term.resize(c2, r2); + vm.resizeShell(shellId, c2, r2); + }, + async waitForScreen(text, timeoutMs = WAIT_TIMEOUT_MS) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (screenText(term).includes(text)) { + await settle(); + return; + } + await new Promise((r) => setTimeout(r, 25)); + } + throw new Error( + `timed out waiting for ${JSON.stringify(text)}\n${terminalSnapshot( + "timeout", + term, + )}`, + ); + }, + async waitShellStatus(timeoutMs = WAIT_TIMEOUT_MS) { + return await Promise.race([ + vm.waitShell(shellId).then( + (s) => s, + () => "error" as const, + ), + new Promise<"timeout">((r) => + setTimeout(() => r("timeout"), timeoutMs), + ), + ]); + }, + settle, + screen() { + return screenText(term); + }, + currentLine() { + const buffer = term.buffer.active; + const line = buffer.getLine(buffer.cursorY + buffer.viewportY); + return (line ? line.translateToString(true) : "").replace(/\s+$/, ""); + }, + markerLine(prefix) { + for (const line of snapshotLines(term)) { + const idx = line.indexOf(prefix); + if (idx !== -1) return line.slice(idx).replace(/\s+$/, ""); + } + return undefined; + }, + rawHex() { + return rawBytes + .map((b) => b.toString(16).toUpperCase().padStart(2, "0")) + .join(" "); + }, + snapshot(suffix) { + const label = `${rt.name}/${c.id}/${suffix}`; + const snap = terminalSnapshot(label, term); + // CRITICAL: a known-broken (it.fails) cell must throw ONLY from its + // load-bearing behavior assertion, never from a stale snapshot. If we + // asserted the snapshot here, a future FIX would change the screen, the + // snapshot would mismatch and throw, and it.fails would stay green — + // masking the very fix it should flag. So for it.fails cells the + // snapshot is captured for review but NOT asserted; the behavior + // assertion is the sole arbiter, so a fix turns the cell RED. + if (!effectiveKnownBroken) { + expect(snap).toMatchSnapshot(label); + } + return snap; + }, + }; + + await c.run(ctx); + }, + TEST_TIMEOUT_MS, + ); +} diff --git a/packages/core/tests/vim-interactive.test.ts b/packages/core/tests/vim-interactive.test.ts new file mode 100644 index 0000000000..bd0e9706d2 --- /dev/null +++ b/packages/core/tests/vim-interactive.test.ts @@ -0,0 +1,254 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import pkg from "@xterm/headless"; +import { afterEach, describe, expect, it } from "vitest"; +import { __disposeAllSharedSidecarsForTesting } from "../src/agent-os.js"; +import type { AgentOs } from "../src/index.js"; +import { allowAll } from "../src/runtime-compat.js"; + +const { Terminal } = pkg; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, "../../.."); +const VIM_COMMAND_DIR = resolve(REPO_ROOT, ".local-cmds"); +const VIM_BINARY = resolve(VIM_COMMAND_DIR, "vim"); +const SNAP_DIR = + "/home/nathan/progress/agent-os/2026-06-28-just-shell-fix/vim-snapshots"; + +const VIM_ARGS = [ + "-N", + "-u", + "NONE", + "-i", + "NONE", + "-n", + "--cmd", + "set noesckeys", +]; +const ESC = Uint8Array.of(0x1b); +const TEST_TIMEOUT_MS = 180_000; + +function assertVimAvailable() { + if (!existsSync(VIM_BINARY)) { + throw new Error(`vim wasm fixture not found at ${VIM_BINARY}`); + } + const magic = readFileSync(VIM_BINARY).subarray(0, 4); + expect([...magic]).toEqual([0x00, 0x61, 0x73, 0x6d]); +} + +// Materialize the raw `.local-cmds/vim` wasm into a self-contained agentOS +// package directory (`bin/vim` + `package.json` + `agentos-package.json`). The +// sidecar projects it into `/opt/agentos/bin/vim` and registers the command, so +// `openShell({ command: "vim" })` resolves it. Mirrors packages/shell/src/main.ts +// and tests/pty-protocol.test.ts's package materialization. +function materializeVimPackage(): { packageDir: string } { + const packageDir = mkdtempSync(join(tmpdir(), "agentos-vim-pkg-")); + const binDir = join(packageDir, "bin"); + mkdirSync(binDir); + copyFileSync(VIM_BINARY, join(binDir, "vim")); + writeFileSync( + join(packageDir, "package.json"), + JSON.stringify({ name: "vim", version: "0.0.0" }), + ); + writeFileSync( + join(packageDir, "agentos-package.json"), + JSON.stringify({ name: "vim" }), + ); + return { packageDir }; +} + +function screen(term: InstanceType): string { + const buffer = term.buffer.active; + const lines: string[] = []; + for (let y = 0; y < term.rows; y++) { + const line = buffer.getLine(y); + lines.push((line ? line.translateToString(true) : "").replace(/\s+$/, "")); + } + return `${lines.join("\n").replace(/\n+$/, "")}\n`; +} + +async function sleep(ms: number) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Requires the vim wasm binary staged locally at `.local-cmds/vim`. CI does not +// build or stage wasm editors, so skip when the fixture is absent rather than +// failing the suite (same policy as brush-interactive). +describe.skipIf(!existsSync(VIM_BINARY))("interactive vim over VM PTY", () => { + let vm: AgentOs | undefined; + + afterEach(async () => { + await vm?.dispose().catch(() => {}); + vm = undefined; + }, 120_000); + + it( + "renders vim, edits a file, writes it, and records per-keystroke snapshots", + async () => { + assertVimAvailable(); + mkdirSync(SNAP_DIR, { recursive: true }); + + const { AgentOs } = await import("../src/index.js"); + vm = await AgentOs.create({ + permissions: allowAll, + software: [materializeVimPackage()], + }); + await vm.mkdir("/work", { recursive: true }); + + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }); + let writes = Promise.resolve(); + let snapshotIndex = 0; + + const { shellId } = vm.openShell({ + command: "vim", + args: VIM_ARGS, + cols: 80, + rows: 24, + cwd: "/work", + env: { TERM: "xterm" }, + }); + const offData = vm.onShellData(shellId, (data) => { + const bytes = Buffer.from(data); + writes = writes.then( + () => new Promise((resolve) => term.write(bytes, resolve)), + ); + }); + + const settle = async (ms = 700) => { + await sleep(ms); + await writes; + await sleep(20); + await writes; + }; + const waitForScreen = async ( + predicate: (current: string) => boolean, + label: string, + timeoutMs = 20_000, + ) => { + const deadline = Date.now() + timeoutMs; + let current = screen(term); + while (Date.now() < deadline) { + await settle(250); + current = screen(term); + if (predicate(current)) { + return current; + } + } + throw new Error(`timed out waiting for ${label}\n\n${current}`); + }; + const snap = async (label: string, ms = 700) => { + await settle(ms); + const nn = String(snapshotIndex).padStart(2, "0"); + writeFileSync( + resolve(SNAP_DIR, `${nn}.txt`), + `## ${nn} - ${label}\n## (vim args: ${JSON.stringify(VIM_ARGS)})\n----- screen 80x24 -----\n${screen(term)}`, + ); + snapshotIndex++; + return screen(term); + }; + + await waitForScreen( + (current) => + current.includes("VIM - Vi IMproved") && + !current.includes("Press ENTER"), + "vim startup splash", + 20_000, + ); + const startup = await snap("startup (vim launched, no file)", 300); + expect(startup).toContain("VIM - Vi IMproved"); + expect(startup).not.toContain("Press ENTER"); + + const seq: Array<[string | Uint8Array, string, number?]> = [ + [":", "type : (enter command-line)"], + ["e", "e"], + [" ", "space"], + ["h", "h"], + ["e", "e"], + ["l", "l"], + ["l", "l"], + ["o", "o"], + [".", "."], + ["t", "t"], + ["x", "x"], + ["t", "t"], + ["\r", "Enter -> run :e hello.txt (open new file)"], + ["i", "i (enter INSERT mode)"], + ["h", "h"], + ["e", "e"], + ["l", "l"], + ["l", "l"], + ["o", "o"], + [" ", "space"], + ["w", "w"], + ["o", "o"], + ["r", "r"], + ["l", "l"], + ["d", "d"], + [ESC, "ESC (back to NORMAL)", 900], + [":", "type : (command-line)"], + ["w", "w"], + ["\r", "Enter -> run :w (write file)", 1200], + ]; + + const snapshots: string[] = []; + for (const [key, label, delayMs] of seq) { + await vm.writeShell(shellId, key); + snapshots.push(await snap(label, delayMs ?? 650)); + } + + const opened = snapshots[12] ?? ""; + expect(opened).toContain('"hello.txt" [New]'); + expect(opened).not.toContain("[No Name]"); + expect(opened).not.toContain("E32"); + expect(opened).not.toContain("VIM - Vi IMprovedversion"); + + const insert = snapshots[13] ?? ""; + expect(insert).toContain("-- INSERT --"); + + const typed = snapshots[24] ?? ""; + expect(typed).toContain("hello world"); + expect(typed).not.toContain("h1,2"); + expect(typed).not.toContain("e3"); + + const normal = snapshots[25] ?? ""; + expect(normal).toContain("hello world"); + expect(normal).not.toContain("-- INSERT --"); + expect(normal).not.toContain("^["); + expect(normal).not.toContain("E32"); + + const written = snapshots.at(-1) ?? ""; + expect(written).toContain('"hello.txt" [New] 1L, 12B written'); + expect(written).not.toContain("Press ENTER"); + expect(written).not.toContain("E32"); + expect(written).not.toContain("No file name"); + expect(written).not.toContain("E212"); + expect(written).not.toContain("^[:"); + + await vm.writeShell(shellId, ":q\r"); + await settle(1500); + + const fileContent = Buffer.from(await vm.readFile("/work/hello.txt")).toString( + "utf8", + ); + writeFileSync( + resolve(SNAP_DIR, "FILE.txt"), + `# /work/hello.txt after :w\n${JSON.stringify(fileContent)}\n\n---raw---\n${fileContent}`, + ); + expect(fileContent).toBe("hello world\n"); + + offData(); + void __disposeAllSharedSidecarsForTesting().catch(() => {}); + vm = undefined; + }, + TEST_TIMEOUT_MS, + ); +}); diff --git a/packages/core/tests/vim-provides.test.ts b/packages/core/tests/vim-provides.test.ts new file mode 100644 index 0000000000..dd87a3cafa --- /dev/null +++ b/packages/core/tests/vim-provides.test.ts @@ -0,0 +1,363 @@ +import { + copyFileSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import pkg from "@xterm/headless"; +import { afterEach, describe, expect, it } from "vitest"; +import { __disposeAllSharedSidecarsForTesting } from "../src/agent-os.js"; +import type { AgentOs } from "../src/index.js"; +import { allowAll } from "../src/runtime-compat.js"; + +const { Terminal } = pkg; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, "../../.."); +const VIM_COMMAND_DIR = resolve(REPO_ROOT, ".local-cmds"); +const VIM_BINARY = resolve(VIM_COMMAND_DIR, "vim"); +const SNAP_DIR = + "/home/nathan/progress/agent-os/2026-06-30-package-provisioned-files-env/vim-provides-snapshots"; + +// Mirror packages/shell/src/main.ts: VIMRUNTIME pointed straight at a runtime +// dir bypasses vim's version-name search, so a host 9.0/9.1 runtime sources +// cleanly under the 9.2 binary. +const VIM_RUNTIME_GUEST_DIR = "/usr/local/share/vim/vim92"; +const VIM_RUNTIME_CANDIDATES = [ + resolve(VIM_COMMAND_DIR, "vim-runtime"), + "/usr/share/vim/vim92", + "/usr/share/vim/vim91", + "/usr/share/vim/vim90", + "/usr/local/share/vim/vim92", +]; + +// Crucially NO `-u NONE`: vim sources `$VIMRUNTIME/defaults.vim` at startup, +// which is the exact path `provides` must satisfy. The other flags isolate the +// runtime as the only variable under test (they do NOT suppress runtime +// loading): `-n` disables the swap file (the VM cannot create vim swap files — +// see ~/.agents/todo, tracked separately), `-i NONE` disables viminfo, and +// `set noesckeys` is the headless-harness ESC-timing aid used by the sibling +// vim-interactive test. So any startup failure here is attributable to the +// runtime/provides, not to swap or ESC timing. +const BARE_VIM_ARGS = ["-n", "-i", "NONE", "--cmd", "set noesckeys"]; +const ESC = Uint8Array.of(0x1b); +const TEST_TIMEOUT_MS = 180_000; + +function resolveVimRuntimeHostDir(): string { + const found = VIM_RUNTIME_CANDIDATES.find((dir) => + existsSync(resolve(dir, "defaults.vim")), + ); + if (!found) { + throw new Error( + `no vim runtime found (need a dir with defaults.vim among ${VIM_RUNTIME_CANDIDATES.join(", ")})`, + ); + } + return found; +} + +function assertVimAvailable() { + if (!existsSync(VIM_BINARY)) { + throw new Error(`vim wasm fixture not found at ${VIM_BINARY}`); + } + const magic = readFileSync(VIM_BINARY).subarray(0, 4); + expect([...magic]).toEqual([0x00, 0x61, 0x73, 0x6d]); +} + +// Materialize the `local-editors` package directory used by the sibling shell +// integration. In the package model the runtime `provides` (VIMRUNTIME env + the +// runtime file overlay) lives in the package's `agentos-package.json`, NOT inline +// on the software descriptor. With `withProvides`, copy the host runtime tree +// into the package under `runtime/` and point `provides.files.source` at that +// package-relative path; without it, ship only the `bin/vim` command so bare vim +// cannot source defaults.vim (the control case). +function materializeLocalEditorsPackage(withProvides: boolean): { + packageDir: string; +} { + const packageDir = mkdtempSync(join(tmpdir(), "agentos-local-editors-")); + const binDir = join(packageDir, "bin"); + mkdirSync(binDir); + copyFileSync(VIM_BINARY, join(binDir, "vim")); + writeFileSync( + join(packageDir, "package.json"), + JSON.stringify({ name: "local-editors", version: "0.0.0" }), + ); + + let provides: + | { + env: Record; + files: Array<{ source: string; target: string }>; + } + | undefined; + if (withProvides) { + cpSync(resolveVimRuntimeHostDir(), join(packageDir, "runtime"), { + recursive: true, + }); + provides = { + env: { + VIMRUNTIME: VIM_RUNTIME_GUEST_DIR, + VIM: "/usr/local/share/vim", + }, + files: [{ source: "runtime", target: VIM_RUNTIME_GUEST_DIR }], + }; + } + writeFileSync( + join(packageDir, "agentos-package.json"), + JSON.stringify({ + name: "local-editors", + ...(provides ? { provides } : {}), + }), + ); + return { packageDir }; +} + +function screen(term: InstanceType): string { + const buffer = term.buffer.active; + const lines: string[] = []; + for (let y = 0; y < term.rows; y++) { + const line = buffer.getLine(y); + lines.push((line ? line.translateToString(true) : "").replace(/\s+$/, "")); + } + return `${lines.join("\n").replace(/\n+$/, "")}\n`; +} + +async function sleep(ms: number) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Requires the vim wasm binary staged locally at `.local-cmds/vim`. CI does not +// build or stage wasm editors, so skip when the fixture is absent rather than +// failing the suite (same policy as brush-interactive). +describe.skipIf(!existsSync(VIM_BINARY))("bare vim runtime via package provides", () => { + let vm: AgentOs | undefined; + + afterEach(async () => { + await vm?.dispose().catch(() => {}); + vm = undefined; + }, 120_000); + + it( + "provisions the vim runtime + VIMRUNTIME so bare vim starts clean and writes a file", + async () => { + assertVimAvailable(); + mkdirSync(SNAP_DIR, { recursive: true }); + + const { AgentOs } = await import("../src/index.js"); + vm = await AgentOs.create({ + permissions: allowAll, + // Note: VIMRUNTIME is intentionally NOT in the shell env below — it + // must reach vim via `provides.env` -> VM base env. The runtime tree + // reaches the guest via `provides.files` (overlay lower). + software: [materializeLocalEditorsPackage(true)], + }); + await vm.mkdir("/work", { recursive: true }); + + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }); + let writes = Promise.resolve(); + let snapshotIndex = 0; + + const { shellId } = vm.openShell({ + command: "vim", + args: BARE_VIM_ARGS, + cols: 80, + rows: 24, + cwd: "/work", + env: { TERM: "xterm" }, + }); + const offData = vm.onShellData(shellId, (data) => { + const bytes = Buffer.from(data); + writes = writes.then( + () => new Promise((resolve) => term.write(bytes, resolve)), + ); + }); + + const settle = async (ms = 700) => { + await sleep(ms); + await writes; + await sleep(20); + await writes; + }; + const waitForScreen = async ( + predicate: (current: string) => boolean, + label: string, + timeoutMs = 20_000, + ) => { + const deadline = Date.now() + timeoutMs; + let current = screen(term); + while (Date.now() < deadline) { + await settle(250); + current = screen(term); + if (predicate(current)) { + return current; + } + } + throw new Error(`timed out waiting for ${label}\n\n${current}`); + }; + const snap = async (label: string, ms = 700) => { + await settle(ms); + const nn = String(snapshotIndex).padStart(2, "0"); + writeFileSync( + resolve(SNAP_DIR, `${nn}.txt`), + `## ${nn} - ${label}\n## (bare vim args: ${JSON.stringify(BARE_VIM_ARGS)})\n----- screen 80x24 -----\n${screen(term)}`, + ); + snapshotIndex++; + return screen(term); + }; + + // The crux: bare vim found $VIMRUNTIME and sourced defaults.vim cleanly. + const startup = await waitForScreen( + (current) => + current.includes("VIM - Vi IMproved") && + !current.includes("Press ENTER"), + "vim startup splash (clean, runtime loaded)", + 20_000, + ); + await snap("startup (bare vim, runtime via provides)", 300); + expect(startup).toContain("VIM - Vi IMproved"); + expect(startup).not.toContain("Press ENTER"); + expect(startup).not.toContain("E1187"); + expect(startup).not.toContain("defaults.vim"); + + const seq: Array<[string | Uint8Array, string, number?]> = [ + [":", "type : (enter command-line)"], + ["e", "e"], + [" ", "space"], + ["p", "p"], + [".", "."], + ["t", "t"], + ["x", "x"], + ["t", "t"], + ["\r", "Enter -> run :e p.txt (open new file)"], + ["i", "i (enter INSERT mode)"], + ["p", "p"], + ["r", "r"], + ["o", "o"], + ["v", "v"], + ["i", "i"], + ["d", "d"], + ["e", "e"], + ["s", "s"], + [" ", "space"], + ["w", "w"], + ["o", "o"], + ["r", "r"], + ["k", "k"], + ["s", "s"], + [ESC, "ESC (back to NORMAL)", 900], + [":", "type : (command-line)"], + ["w", "w"], + ["q", "q"], + ["\r", "Enter -> run :wq (write + quit)", 1200], + ]; + + const snapshots: string[] = []; + for (const [key, label, delayMs] of seq) { + await vm.writeShell(shellId, key); + snapshots.push(await snap(label, delayMs ?? 650)); + } + + const opened = snapshots[8] ?? ""; + expect(opened).toContain('"p.txt" [New]'); + expect(opened).not.toContain("E1187"); + + const insert = snapshots[9] ?? ""; + expect(insert).toContain("-- INSERT --"); + + const typed = snapshots[23] ?? ""; + expect(typed).toContain("provides works"); + + const written = snapshots.at(-1) ?? ""; + expect(written).toContain('"p.txt"'); + expect(written).toContain("written"); + expect(written).not.toContain("Press ENTER"); + expect(written).not.toContain("E1187"); + + await settle(1200); + const fileContent = Buffer.from(await vm.readFile("/work/p.txt")).toString( + "utf8", + ); + writeFileSync( + resolve(SNAP_DIR, "FILE.txt"), + `# /work/p.txt after :wq\n${JSON.stringify(fileContent)}\n\n---raw---\n${fileContent}`, + ); + expect(fileContent).toBe("provides works\n"); + + offData(); + void __disposeAllSharedSidecarsForTesting().catch(() => {}); + vm = undefined; + }, + TEST_TIMEOUT_MS, + ); + + it( + "control: WITHOUT provides, bare vim fails to source defaults.vim (E1187 / Press ENTER)", + async () => { + assertVimAvailable(); + + const { AgentOs } = await import("../src/index.js"); + vm = await AgentOs.create({ + permissions: allowAll, + software: [materializeLocalEditorsPackage(false)], + }); + await vm.mkdir("/work", { recursive: true }); + + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }); + let writes = Promise.resolve(); + const { shellId } = vm.openShell({ + command: "vim", + args: BARE_VIM_ARGS, + cols: 80, + rows: 24, + cwd: "/work", + env: { TERM: "xterm" }, + }); + const offData = vm.onShellData(shellId, (data) => { + const bytes = Buffer.from(data); + writes = writes.then( + () => new Promise((resolve) => term.write(bytes, resolve)), + ); + }); + const settle = async (ms = 700) => { + await sleep(ms); + await writes; + await sleep(20); + await writes; + }; + + // No runtime provisioned -> vim cannot source defaults.vim. Prove the + // failure surface so the positive test above can't pass for unrelated + // reasons (provides is load-bearing, not incidental). + const deadline = Date.now() + 25_000; + let current = screen(term); + let failed = false; + while (Date.now() < deadline) { + await settle(300); + current = screen(term); + if (current.includes("E1187") || current.includes("Press ENTER")) { + failed = true; + break; + } + } + writeFileSync( + resolve(SNAP_DIR, "CONTROL-no-provides.txt"), + `## control: bare vim WITHOUT provides (expect E1187 / Press ENTER)\n----- screen 80x24 -----\n${current}`, + ); + expect(failed).toBe(true); + + // Dismiss the prompt so teardown is clean. + await vm.writeShell(shellId, "\r"); + await vm.writeShell(shellId, ":q!\r"); + await settle(800); + + offData(); + void __disposeAllSharedSidecarsForTesting().catch(() => {}); + vm = undefined; + }, + TEST_TIMEOUT_MS, + ); +}); diff --git a/packages/core/vim-snap.mjs b/packages/core/vim-snap.mjs new file mode 100644 index 0000000000..b6d49114e9 --- /dev/null +++ b/packages/core/vim-snap.mjs @@ -0,0 +1,130 @@ +import { AgentOs } from "@rivet-dev/agentos-core"; +import { allowAll } from "@rivet-dev/agentos-core/internal/runtime-compat"; +import assert from "node:assert/strict"; +import { copyFileSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import pkg from "@xterm/headless"; +const { Terminal } = pkg; + +const SNAP_DIR = "/home/nathan/progress/agent-os/2026-06-28-just-shell-fix/vim-snapshots"; +const VIM_COMMAND_DIR = "/home/nathan/.herdr/workspaces/agent-os/workspace-brave-forest-1e98/.local-cmds"; +const VIM_ARGS = JSON.parse(process.env.VIM_ARGS || '["-N","-u","NONE","-i","NONE","-n","--cmd","set noesckeys"]'); +const VIM_ENV = JSON.parse(process.env.VIM_ENV || '{"TERM":"xterm"}'); +const ESC = Uint8Array.of(0x1b); + +// Materialize the raw `.local-cmds/vim` wasm into a self-contained agentOS +// package directory (`bin/vim` + package.json + agentos-package.json), matching +// the package model consumed by `AgentOs.create({ software: [] })`. +function materializeVimPackage() { + const packageDir = mkdtempSync(join(tmpdir(), "agentos-vim-pkg-")); + mkdirSync(join(packageDir, "bin")); + copyFileSync(resolve(VIM_COMMAND_DIR, "vim"), join(packageDir, "bin", "vim")); + writeFileSync(join(packageDir, "package.json"), JSON.stringify({ name: "vim", version: "0.0.0" })); + writeFileSync(join(packageDir, "agentos-package.json"), JSON.stringify({ name: "vim" })); + return packageDir; +} + +const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }); +let writes = Promise.resolve(); +const screen = () => { + const b = term.buffer.active; + const lines = []; + for (let y = 0; y < 24; y++) { + const ln = b.getLine(y); + lines.push((ln ? ln.translateToString(true) : "").replace(/\s+$/, "")); + } + return lines.join("\n").replace(/\n+$/, "\n"); +}; +let n = 0; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const settle = async (ms = 700) => { + await sleep(ms); + await writes; + await sleep(20); + await writes; +}; +const waitForScreen = async (predicate, label, timeoutMs = 20_000) => { + const deadline = Date.now() + timeoutMs; + let latest = screen(); + while (Date.now() < deadline) { + await settle(250); + latest = screen(); + if (predicate(latest)) return latest; + } + throw new Error(`timed out waiting for ${label}\n\n${latest}`); +}; +const snap = async (label, ms = 700) => { + await settle(ms); + const nn = String(n).padStart(2, "0"); + const current = screen(); + writeFileSync(`${SNAP_DIR}/${nn}.txt`, `## ${nn} — ${label}\n## (vim args: ${JSON.stringify(VIM_ARGS)})\n----- screen 80x24 -----\n${current}\n`); + console.log(`snap ${nn}: ${label}`); + n++; + return current; +}; + +mkdirSync(SNAP_DIR, { recursive: true }); + +const vm = await AgentOs.create({ + permissions: allowAll, + software: [materializeVimPackage()], +}); +await vm.mkdir("/work", { recursive: true }); +const { shellId } = vm.openShell({ command: "vim", args: VIM_ARGS, cols: 80, rows: 24, cwd: "/work", env: VIM_ENV }); +vm.onShellData(shellId, (d) => { + const bytes = Buffer.from(d); + writes = writes.then(() => new Promise((resolve) => term.write(bytes, resolve))); +}); + +await waitForScreen((s) => s.includes("VIM - Vi IMproved") && !s.includes("Press ENTER"), "vim startup"); +const startup = await snap("startup (vim launched, no file)", 300); +assert.match(startup, /VIM - Vi IMproved/); +assert.doesNotMatch(startup, /Press ENTER/); + +const SEQ = [ + [":", "type : (enter command-line)"], ["e", "e"], [" ", "space"], + ["h", "h"], ["e", "e"], ["l", "l"], ["l", "l"], ["o", "o"], [".", "."], ["t", "t"], ["x", "x"], ["t", "t"], + ["\r", "Enter -> run :e hello.txt (open new file)"], + ["i", "i (enter INSERT mode)"], + ["h", "h"], ["e", "e"], ["l", "l"], ["l", "l"], ["o", "o"], [" ", "space"], ["w", "w"], ["o", "o"], ["r", "r"], ["l", "l"], ["d", "d"], + [ESC, "ESC (back to NORMAL)", 900], + [":", "type : (command-line)"], ["w", "w"], ["\r", "Enter -> run :w (write file)"], +]; +const snapshots = []; +for (const [key, label, delayMs] of SEQ) { + await vm.writeShell(shellId, key); + snapshots.push(await snap(label, delayMs ?? 650)); +} + +const opened = snapshots[12] ?? ""; +assert.match(opened, /"hello\.txt" \[New\]/); +assert.doesNotMatch(opened, /No Name/); + +const insert = snapshots[13] ?? ""; +assert.match(insert, /-- INSERT --/); + +const typed = snapshots[24] ?? ""; +assert.match(typed, /hello world/); + +const normal = snapshots[25] ?? ""; +assert.doesNotMatch(normal, /-- INSERT --/); +assert.match(normal, /hello world/); + +const written = snapshots.at(-1) ?? ""; +assert.match(written, /"hello\.txt" \[New\] 1L, 12B written/); +assert.doesNotMatch(written, /E32/); +assert.doesNotMatch(written, /Press ENTER/); +assert.doesNotMatch(written, /\^\[/); + +await vm.writeShell(shellId, ":q\r"); +await settle(1500); + +// read the file back from the VM +let fileContent = ""; +try { fileContent = Buffer.from(await vm.readFile("/work/hello.txt")).toString("utf8"); } catch (e) { fileContent = "ERR: " + (e?.message || e); } +writeFileSync(`${SNAP_DIR}/FILE.txt`, `# /work/hello.txt after :w\n${JSON.stringify(fileContent)}\n\n---raw---\n${fileContent}`); +assert.equal(fileContent, "hello world\n"); +console.log("FILE /work/hello.txt =", JSON.stringify(fileContent)); +await vm.dispose().catch(() => {}); +process.exit(0); diff --git a/packages/shell/package.json b/packages/shell/package.json index ff8f12d5d5..71c4f834a2 100644 --- a/packages/shell/package.json +++ b/packages/shell/package.json @@ -1,50 +1,52 @@ { - "name": "@rivet-dev/agentos-shell", - "version": "0.2.0-rc.3", - "private": false, - "type": "module", - "bin": { - "agentos-shell": "./dist/main.js", - "agent-os-shell": "./dist/main.js" - }, - "scripts": { - "build": "tsc", - "check-types": "tsc --noEmit", - "shell": "tsx src/main.ts", - "test": "pnpm build && vitest run --fileParallelism=false" - }, - "dependencies": { - "@rivet-dev/agentos-core": "workspace:*", - "@agentos-software/codex-cli": "link:../../../secure-exec-convwasi/registry/software/codex", - "@agentos-software/coreutils": "link:../../../secure-exec-convwasi/registry/software/coreutils", - "@agentos-software/curl": "link:../../../secure-exec-convwasi/registry/software/curl", - "@agentos-software/diffutils": "link:../../../secure-exec-convwasi/registry/software/diffutils", - "@agentos-software/duckdb": "link:../../../secure-exec-convwasi/registry/software/duckdb", - "@agentos-software/fd": "link:../../../secure-exec-convwasi/registry/software/fd", - "@agentos-software/file": "link:../../../secure-exec-convwasi/registry/software/file", - "@agentos-software/findutils": "link:../../../secure-exec-convwasi/registry/software/findutils", - "@agentos-software/gawk": "link:../../../secure-exec-convwasi/registry/software/gawk", - "@agentos-software/git": "link:../../../secure-exec-convwasi/registry/software/git", - "@agentos-software/grep": "link:../../../secure-exec-convwasi/registry/software/grep", - "@agentos-software/gzip": "link:../../../secure-exec-convwasi/registry/software/gzip", - "@agentos-software/http-get": "link:../../../secure-exec-convwasi/registry/software/http-get", - "@agentos-software/jq": "link:../../../secure-exec-convwasi/registry/software/jq", - "@agentos-software/make": "link:../../../secure-exec-convwasi/registry/software/make", - "@agentos-software/ripgrep": "link:../../../secure-exec-convwasi/registry/software/ripgrep", - "@agentos-software/sed": "link:../../../secure-exec-convwasi/registry/software/sed", - "@agentos-software/sqlite3": "link:../../../secure-exec-convwasi/registry/software/sqlite3", - "@agentos-software/tar": "link:../../../secure-exec-convwasi/registry/software/tar", - "@agentos-software/tree": "link:../../../secure-exec-convwasi/registry/software/tree", - "@agentos-software/unzip": "link:../../../secure-exec-convwasi/registry/software/unzip", - "@agentos-software/wget": "link:../../../secure-exec-convwasi/registry/software/wget", - "@agentos-software/yq": "link:../../../secure-exec-convwasi/registry/software/yq", - "@agentos-software/zip": "link:../../../secure-exec-convwasi/registry/software/zip", - "commander": "^14.0.2" - }, - "devDependencies": { - "@types/node": "^22.19.3", - "tsx": "^4.19.2", - "typescript": "^5.7.2", - "vitest": "^2.1.8" - } + "name": "@rivet-dev/agentos-shell", + "version": "0.2.0-rc.3", + "private": false, + "type": "module", + "bin": { + "agentos-shell": "./dist/main.js", + "agent-os-shell": "./dist/main.js" + }, + "scripts": { + "build": "tsc", + "check-types": "tsc --noEmit", + "shell": "tsx src/main.ts", + "test": "pnpm build && vitest run --fileParallelism=false" + }, + "dependencies": { + "@agentos-software/codex-cli": "link:../../../secure-exec-convwasi/registry/software/codex", + "@agentos-software/coreutils": "link:../../../secure-exec-convwasi/registry/software/coreutils", + "@agentos-software/curl": "link:../../../secure-exec-convwasi/registry/software/curl", + "@agentos-software/diffutils": "link:../../../secure-exec-convwasi/registry/software/diffutils", + "@agentos-software/duckdb": "link:../../../secure-exec-convwasi/registry/software/duckdb", + "@agentos-software/fd": "link:../../../secure-exec-convwasi/registry/software/fd", + "@agentos-software/file": "link:../../../secure-exec-convwasi/registry/software/file", + "@agentos-software/findutils": "link:../../../secure-exec-convwasi/registry/software/findutils", + "@agentos-software/gawk": "link:../../../secure-exec-convwasi/registry/software/gawk", + "@agentos-software/git": "link:../../../secure-exec-convwasi/registry/software/git", + "@agentos-software/grep": "link:../../../secure-exec-convwasi/registry/software/grep", + "@agentos-software/gzip": "link:../../../secure-exec-convwasi/registry/software/gzip", + "@agentos-software/http-get": "link:../../../secure-exec-convwasi/registry/software/http-get", + "@agentos-software/jq": "link:../../../secure-exec-convwasi/registry/software/jq", + "@agentos-software/make": "link:../../../secure-exec-convwasi/registry/software/make", + "@agentos-software/ripgrep": "link:../../../secure-exec-convwasi/registry/software/ripgrep", + "@agentos-software/sed": "link:../../../secure-exec-convwasi/registry/software/sed", + "@agentos-software/sqlite3": "link:../../../secure-exec-convwasi/registry/software/sqlite3", + "@agentos-software/tar": "link:../../../secure-exec-convwasi/registry/software/tar", + "@agentos-software/tree": "link:../../../secure-exec-convwasi/registry/software/tree", + "@agentos-software/unzip": "link:../../../secure-exec-convwasi/registry/software/unzip", + "@agentos-software/wget": "link:../../../secure-exec-convwasi/registry/software/wget", + "@agentos-software/yq": "link:../../../secure-exec-convwasi/registry/software/yq", + "@agentos-software/zip": "link:../../../secure-exec-convwasi/registry/software/zip", + "@rivet-dev/agentos": "workspace:*", + "@rivet-dev/agentos-core": "workspace:*", + "commander": "^14.0.2", + "rivetkit": "0.0.0-feat-dylib-actor-plugin.c44621f" + }, + "devDependencies": { + "@types/node": "^22.19.3", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } } diff --git a/packages/shell/src/actor-server.ts b/packages/shell/src/actor-server.ts new file mode 100644 index 0000000000..d253bf0f71 --- /dev/null +++ b/packages/shell/src/actor-server.ts @@ -0,0 +1,55 @@ +// Runtime server for the shell's `--actor` mode — the shell-CLI equivalent of +// `packages/agentos/tests/fixtures/agentos-runtime-server.ts`. Boots the +// agentOS actor registry on the native runtime and serves it against a local +// rivet engine (spawned by the native registry via RIVET_RUN_ENGINE_PORT / +// RIVET_ENGINE_BINARY). Spawned as a child by `actor-vm.ts` with the shell's +// VM options passed through AGENTOS_SHELL_ACTOR_OPTIONS. + +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { agentOS } from "@rivet-dev/agentos"; +import { allowAll } from "@rivet-dev/agentos-core/internal/runtime-compat"; +import { setup } from "rivetkit"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const workspaceRoot = resolve(__dirname, "../../.."); +const r6Root = + process.env.AGENTOS_R6_ROOT ?? resolve(workspaceRoot, "..", "r6"); + +const options = JSON.parse(process.env.AGENTOS_SHELL_ACTOR_OPTIONS ?? "{}"); + +const vm = agentOS({ permissions: allowAll, ...options }); +const registry = setup({ + use: { vm }, + endpoint: process.env.AGENTOS_SHELL_ENDPOINT, + namespace: process.env.RIVET_NAMESPACE ?? "default", + token: process.env.RIVET_TOKEN ?? "dev", + envoy: { poolName: process.env.AGENTOS_SHELL_POOL_NAME ?? "agentos-shell" }, + runtime: "native", + shutdown: { disableSignalHandlers: true }, +} as never); + +// The native registry builder lives in the r6 rivetkit-typescript source tree +// (same import the actor e2e fixture uses); resolved dynamically so the shell +// package itself carries no static dependency on the sibling checkout. +const nativeModuleUrl = pathToFileURL( + join( + r6Root, + "rivetkit-typescript", + "packages", + "rivetkit", + "src", + "registry", + "native.ts", + ), +).href; +const { buildNativeRegistry } = await import(nativeModuleUrl); + +const { registry: nativeRegistry, serveConfig } = await buildNativeRegistry( + registry.parseConfig(), +); +if (process.env.RIVET_ENGINE_BINARY) { + serveConfig.engineBinaryPath = process.env.RIVET_ENGINE_BINARY; +} + +await nativeRegistry.serve(serveConfig); diff --git a/packages/shell/src/actor-vm.ts b/packages/shell/src/actor-vm.ts new file mode 100644 index 0000000000..ec1b638a50 --- /dev/null +++ b/packages/shell/src/actor-vm.ts @@ -0,0 +1,457 @@ +// Actor-backed shell VM (the `--actor` flag): drives the exact same shell +// surface as the in-process `AgentOs` core client, but through the RivetKit +// agentOS actor (rivet engine + envoy + dylib actor plugin + sidecar). +// +// Bootstrap mirrors `packages/agentos/tests/actor.test.ts`: spawn a runtime +// server child (engine auto-spawned by the native registry), upsert a "normal" +// runner config for the envoy pool, wait for envoy registration, then talk to +// the actor through `createClient`. Requires the `r6` sibling checkout (the +// native registry builder is imported from its rivetkit-typescript source), +// exactly like the actor e2e test. + +import { type ChildProcess, spawn } from "node:child_process"; +import { existsSync, mkdtempSync } from "node:fs"; +import { createRequire } from "node:module"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createClient } from "@rivet-dev/agentos/client"; +import type { MountConfig, SoftwareInput } from "@rivet-dev/agentos-core"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const workspaceRoot = resolve(__dirname, "../../.."); +const require_ = createRequire(import.meta.url); + +const NAMESPACE = "default"; +const TOKEN = "dev"; +const POOL_NAME = "agentos-shell"; + +export interface ActorShellVmOptions { + software: SoftwareInput[]; + mounts: MountConfig[]; + limits?: unknown; +} + +/** + * The subset of the `AgentOs` surface the shell CLI drives. The in-process + * core client satisfies this directly (its `spawn`/`openShell` return + * synchronously — hence the sync|Promise unions); the actor backend returns + * promises for everything. + */ +export interface ShellVmHandle { + spawn( + command: string, + args: string[], + options: { + cwd?: string; + env?: Record; + streamStdin?: boolean; + onStdout?: (data: Uint8Array) => void; + onStderr?: (data: Uint8Array) => void; + }, + ): { pid: number } | Promise<{ pid: number }>; + writeProcessStdin(pid: number, data: Uint8Array | string): Promise; + closeProcessStdin(pid: number): Promise; + waitProcess(pid: number): Promise; + openShell(options: { + command?: string; + args?: string[]; + cwd?: string; + env?: Record; + cols?: number; + rows?: number; + onStderr?: (data: Uint8Array) => void; + }): { shellId: string } | Promise<{ shellId: string }>; + writeShell(shellId: string, data: Uint8Array | string): Promise; + resizeShell(shellId: string, cols: number, rows: number): void; + onShellData(shellId: string, handler: (data: Uint8Array) => void): () => void; + waitShell(shellId: string): Promise; + dispose(): Promise; +} + +function r6Root(): string { + return process.env.AGENTOS_R6_ROOT ?? resolve(workspaceRoot, "..", "r6"); +} + +function resolveEngineBinary(): string | undefined { + if (process.env.RIVET_ENGINE_BINARY) return process.env.RIVET_ENGINE_BINARY; + const r6Engine = join(r6Root(), "target", "debug", "rivet-engine"); + if (existsSync(r6Engine)) return r6Engine; + try { + const pkgJson = require_.resolve( + "@rivetkit/engine-cli-linux-x64-musl/package.json", + ); + const candidate = join(dirname(pkgJson), "rivet-engine"); + if (existsSync(candidate)) return candidate; + } catch { + // platform package not installed; serve() reports binary_unavailable. + } + return undefined; +} + +async function getFreePort(): Promise { + return await new Promise((resolvePort, reject) => { + const server = createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close(() => { + if (!address || typeof address === "string") { + reject(new Error("failed to allocate a TCP port")); + return; + } + resolvePort(address.port); + }); + }); + }); +} + +async function waitForHealth( + endpoint: string, + child: ChildProcess, + logs: () => string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error( + `actor runtime exited before the engine became healthy:\n${logs()}`, + ); + } + try { + const response = await fetch(`${endpoint}/health`); + if (response.ok) return; + } catch {} + await new Promise((r) => setTimeout(r, 300)); + } + throw new Error( + `timed out waiting for engine health at ${endpoint}\n${logs()}`, + ); +} + +async function upsertRunnerConfig(endpoint: string): Promise { + const auth = { Authorization: `Bearer ${TOKEN}` }; + const dcResponse = await fetch( + `${endpoint}/datacenters?namespace=${NAMESPACE}`, + { headers: auth }, + ); + if (!dcResponse.ok) { + throw new Error(`failed to list datacenters: ${dcResponse.status}`); + } + const body = (await dcResponse.json()) as { + datacenters: Array<{ name: string }>; + }; + const datacenter = body.datacenters[0]?.name; + if (!datacenter) throw new Error("engine returned no datacenters"); + const response = await fetch( + `${endpoint}/runner-configs/${POOL_NAME}?namespace=${NAMESPACE}`, + { + method: "PUT", + headers: { ...auth, "Content-Type": "application/json" }, + body: JSON.stringify({ datacenters: { [datacenter]: { normal: {} } } }), + }, + ); + if (!response.ok) { + throw new Error(`failed to upsert runner config: ${response.status}`); + } +} + +async function waitForEnvoy( + endpoint: string, + child: ChildProcess, + logs: () => string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + const auth = { Authorization: `Bearer ${TOKEN}` }; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error( + `actor runtime exited before envoy registration:\n${logs()}`, + ); + } + const response = await fetch( + `${endpoint}/envoys?namespace=${NAMESPACE}&name=${POOL_NAME}`, + { headers: auth }, + ); + if (response.ok) { + const body = (await response.json()) as { envoys: unknown[] }; + if (body.envoys.length > 0) return; + } + await new Promise((r) => setTimeout(r, 300)); + } + throw new Error(`timed out waiting for envoy registration\n${logs()}`); +} + +/** Decode an event `data` field: real Uint8Array, `["$Uint8Array", b64]`, or utf8 string. */ +function toBytes(data: unknown): Uint8Array { + if (data instanceof Uint8Array) return data; + if ( + Array.isArray(data) && + data.length === 2 && + data[0] === "$Uint8Array" && + typeof data[1] === "string" + ) { + return Buffer.from(data[1], "base64"); + } + return Buffer.from(String(data ?? ""), "utf8"); +} + +export async function createActorShellVm( + options: ActorShellVmOptions, +): Promise { + const r6 = r6Root(); + const r6RivetkitPackageRoot = join( + r6, + "rivetkit-typescript", + "packages", + "rivetkit", + ); + const tsxLoaderPath = join( + r6RivetkitPackageRoot, + "node_modules", + "tsx", + "dist", + "loader.mjs", + ); + if (!existsSync(tsxLoaderPath)) { + throw new Error( + `--actor requires the r6 sibling checkout with rivetkit-typescript deps installed (missing ${tsxLoaderPath}); set AGENTOS_R6_ROOT to override`, + ); + } + + // Dev convenience: prefer the workspace debug sidecar/plugin builds when the + // platform npm packages are not installed. + if (!process.env.AGENTOS_SIDECAR_BIN) { + const debugSidecar = join( + workspaceRoot, + "target", + "debug", + "agentos-sidecar", + ); + if (existsSync(debugSidecar)) { + process.env.AGENTOS_SIDECAR_BIN = debugSidecar; + } + } + + const enginePort = await getFreePort(); + const endpoint = `http://127.0.0.1:${enginePort}`; + const engineBinary = resolveEngineBinary(); + const serverPath = join(__dirname, "actor-server.ts"); + const logs = { stdout: "", stderr: "" }; + const child = spawn( + process.execPath, + ["--import", tsxLoaderPath, serverPath], + { + cwd: r6RivetkitPackageRoot, + env: { + ...process.env, + RIVET_TOKEN: TOKEN, + RIVET_NAMESPACE: NAMESPACE, + AGENTOS_SHELL_ENDPOINT: endpoint, + AGENTOS_SHELL_POOL_NAME: POOL_NAME, + AGENTOS_SHELL_ACTOR_OPTIONS: JSON.stringify({ + software: options.software, + mounts: options.mounts, + ...(options.limits ? { limits: options.limits } : {}), + }), + AGENTOS_R6_ROOT: r6, + ...(engineBinary ? { RIVET_ENGINE_BINARY: engineBinary } : {}), + RIVET_RUN_ENGINE_HOST: "127.0.0.1", + RIVET_RUN_ENGINE_PORT: String(enginePort), + ESBK_TSCONFIG_PATH: join(r6RivetkitPackageRoot, "tsconfig.json"), + TSX_TSCONFIG_PATH: join(r6RivetkitPackageRoot, "tsconfig.json"), + RIVETKIT_STORAGE_PATH: mkdtempSync( + join(tmpdir(), "agentos-shell-actor-"), + ), + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const debugLogs = process.env.AGENTOS_SHELL_ACTOR_DEBUG === "1"; + child.stdout?.on("data", (chunk) => { + logs.stdout += chunk.toString(); + if (debugLogs) process.stderr.write(`[actor-server] ${chunk}`); + }); + child.stderr?.on("data", (chunk) => { + logs.stderr += chunk.toString(); + if (debugLogs) process.stderr.write(`[actor-server] ${chunk}`); + }); + const childLogs = () => [logs.stdout, logs.stderr].filter(Boolean).join("\n"); + + await waitForHealth(endpoint, child, childLogs, 60_000); + await upsertRunnerConfig(endpoint); + await waitForEnvoy(endpoint, child, childLogs, 30_000); + + const client = createClient({ + endpoint, + token: TOKEN, + namespace: NAMESPACE, + poolName: POOL_NAME, + disableMetadataLookup: true, + } as never); + // biome-ignore lint/suspicious/noExplicitAny: untyped registry handle; the action surface mirrors AgentOsActions. + const handle = (client as any).vm.getOrCreate(`shell-${process.pid}`); + const conn = handle.connect(); + + const shellDataHandlers = new Map void>>(); + const shellStderrHandlers = new Map< + string, + Set<(data: Uint8Array) => void> + >(); + const processStdoutHandlers = new Map void>(); + const processStderrHandlers = new Map void>(); + // Output can arrive between the openShell reply and the caller's + // onShellData subscription; buffer it (bounded) and flush on subscribe. + const PENDING_SHELL_DATA_LIMIT = 256; + const pendingShellData = new Map(); + + conn.on("shellData", (payload: { shellId: string; data: unknown }) => { + const bytes = toBytes(payload.data); + const handlers = shellDataHandlers.get(payload.shellId); + if (!handlers || handlers.size === 0) { + let pending = pendingShellData.get(payload.shellId); + if (!pending) { + pending = []; + pendingShellData.set(payload.shellId, pending); + } + if (pending.length < PENDING_SHELL_DATA_LIMIT) pending.push(bytes); + return; + } + for (const handler of handlers) handler(bytes); + }); + conn.on("shellStderr", (payload: { shellId: string; data: unknown }) => { + const handlers = shellStderrHandlers.get(payload.shellId); + if (!handlers) return; + const bytes = toBytes(payload.data); + for (const handler of handlers) handler(bytes); + }); + conn.on( + "processOutput", + (payload: { pid: number; stream: string; data: unknown }) => { + const handler = + payload.stream === "stderr" + ? processStderrHandlers.get(payload.pid) + : processStdoutHandlers.get(payload.pid); + if (handler) handler(toBytes(payload.data)); + }, + ); + + // The first action triggers actor creation + VM bring-up; retry the + // scheduling races the same way the actor e2e test does. + async function withReadyRetry(run: () => Promise): Promise { + const deadline = Date.now() + 60_000; + let lastError: unknown; + while (Date.now() < deadline) { + try { + return await run(); + } catch (error) { + lastError = error; + const code = + typeof error === "object" && error !== null && "code" in error + ? String((error as { code: unknown }).code) + : ""; + if ( + !/^(no_envoys|actor_ready_timeout|actor_wake_retries_exceeded|service_unavailable)$/.test( + code, + ) + ) { + throw error; + } + await new Promise((r) => setTimeout(r, 1000)); + } + } + throw lastError; + } + + return { + async spawn(command, args, spawnOptions) { + const result = (await withReadyRetry(() => + handle.spawn(command, args, { + env: spawnOptions.env, + cwd: spawnOptions.cwd, + streamStdin: spawnOptions.streamStdin, + }), + )) as { pid: number }; + if (spawnOptions.onStdout) { + processStdoutHandlers.set(result.pid, spawnOptions.onStdout); + } + if (spawnOptions.onStderr) { + processStderrHandlers.set(result.pid, spawnOptions.onStderr); + } + return result; + }, + async writeProcessStdin(pid, data) { + await handle.writeProcessStdin(pid, data); + }, + async closeProcessStdin(pid) { + await handle.closeProcessStdin(pid); + }, + async waitProcess(pid) { + return (await handle.waitProcess(pid)) as number; + }, + async openShell(shellOptions) { + const { onStderr, ...rest } = shellOptions; + const result = (await withReadyRetry(() => handle.openShell(rest))) as { + shellId: string; + }; + if (onStderr) { + let handlers = shellStderrHandlers.get(result.shellId); + if (!handlers) { + handlers = new Set(); + shellStderrHandlers.set(result.shellId, handlers); + } + handlers.add(onStderr); + } + return result; + }, + async writeShell(shellId, data) { + await handle.writeShell(shellId, data); + }, + resizeShell(shellId, cols, rows) { + void handle.resizeShell(shellId, cols, rows).catch((error: unknown) => { + process.stderr.write(`resizeShell failed: ${String(error)}\n`); + }); + }, + onShellData(shellId, handler) { + let handlers = shellDataHandlers.get(shellId); + if (!handlers) { + handlers = new Set(); + shellDataHandlers.set(shellId, handlers); + } + handlers.add(handler); + const pending = pendingShellData.get(shellId); + if (pending) { + pendingShellData.delete(shellId); + for (const chunk of pending) handler(chunk); + } + return () => { + handlers?.delete(handler); + }; + }, + async waitShell(shellId) { + return (await handle.waitShell(shellId)) as number; + }, + async dispose() { + try { + conn.dispose?.(); + } catch {} + if (child.exitCode === null) { + child.kill("SIGINT"); + await new Promise((resolveExit) => { + const timeout = setTimeout(() => { + if (child.exitCode === null) child.kill("SIGKILL"); + resolveExit(); + }, 5_000); + child.once("exit", () => { + clearTimeout(timeout); + resolveExit(); + }); + }); + } + }, + }; +} diff --git a/packages/shell/src/main.ts b/packages/shell/src/main.ts index bc600a1ec5..572782426b 100644 --- a/packages/shell/src/main.ts +++ b/packages/shell/src/main.ts @@ -12,8 +12,16 @@ * commands use process spawn with Docker-like stdin attachment rules. */ -import { existsSync, readFileSync } from "node:fs"; -import { basename, dirname, resolve } from "node:path"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import codex from "@agentos-software/codex-cli"; import coreutils from "@agentos-software/coreutils"; @@ -36,10 +44,11 @@ import tree from "@agentos-software/tree"; import unzip from "@agentos-software/unzip"; import yq from "@agentos-software/yq"; import zip from "@agentos-software/zip"; -import { AgentOs } from "@rivet-dev/agentos-core"; import type { MountConfig, SoftwareInput } from "@rivet-dev/agentos-core"; +import { AgentOs } from "@rivet-dev/agentos-core"; import { allowAll } from "@rivet-dev/agentos-core/internal/runtime-compat"; import { Command, Option } from "commander"; +import { createActorShellVm, type ShellVmHandle } from "./actor-vm.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const workspaceRoot = resolve(__dirname, "../../.."); @@ -68,6 +77,7 @@ const SHELL_OPTIONS_WITH_VALUES = new Set([ interface CliOptions { interactive: boolean; tty: boolean; + actor: boolean; workdir: string; env: string[]; envFile: string[]; @@ -79,36 +89,44 @@ interface CliOptions { args: string[]; } -// Published packages ship package-local wasm/ dirs. Workspace packages use the -// native build output when those package-local dirs have not been materialized. -function withLocalCommandFallback(software: SoftwareInput): SoftwareInput { - if (Array.isArray(software)) { - return software.map(withLocalCommandFallback) as SoftwareInput; - } +// The `@agentos-software/*` packages default-export a package descriptor +// pointing at their self-contained package directory (a `bin/` of wasm files + +// an `agentos-package.json`). Current packages export `{ packageDir }`; older +// builds exported `{ name, dir }` — accept both. +interface RegistryPackage { + name?: string; + dir?: string; + packageDir?: string; +} - if ( - "commandDir" in software && - typeof software.commandDir === "string" && - !existsSync(software.commandDir) - ) { - const fallbackCommandDir = fallbackCommandDirs.find((dir) => - existsSync(dir), - ); - if (!fallbackCommandDir) { - return software; - } - return { - ...software, - get commandDir() { - return fallbackCommandDir; - }, - }; - } +// The sidecar requires an agentos-package.json manifest in every package dir. +function isUsablePackageDir(dir: string | undefined): dir is string { + return dir !== undefined && existsSync(resolve(dir, "agentos-package.json")); +} - return software; +// Published packages ship their package dir materialized. Workspace packages +// may not have run the native build yet, so their dir can be missing — fall +// back to the native command-build output when it is. A package whose dir +// exists but predates the agentos-package.json manifest requirement is skipped +// (with a warning) rather than aborting VM creation for the whole shell. +function withLocalCommandFallback(pkg: RegistryPackage): SoftwareInput | null { + const packageDir = pkg.packageDir ?? pkg.dir; + if (isUsablePackageDir(packageDir)) { + return { packageDir }; + } + const fallbackCommandDir = fallbackCommandDirs.find(isUsablePackageDir); + if (fallbackCommandDir !== undefined) { + return { packageDir: fallbackCommandDir }; + } + console.warn( + `agentos-shell: skipping software package without agentos-package.json: ${ + pkg.name ?? packageDir ?? "" + }`, + ); + return null; } -const software = [ +const software: SoftwareInput[] = [ coreutils, sed, grep, @@ -130,9 +148,81 @@ const software = [ git, httpGet, sqlite3, -].map(withLocalCommandFallback); +] + .map(withLocalCommandFallback) + .filter((input): input is SoftwareInput => input !== null); + +// Local-only: minimal vi-like editors (vix, vim) built to wasm32-wasip1, used to +// prove raw-mode PTY round-trips (insert mode, :wq, file written). The raw +// binaries live in a workspace-local command dir; the sidecar wants a +// self-contained package directory, so materialize one with a `bin/` layout +// and an `agentos-package.json`, then reference it via `{ packageDir }`. +const VIX_COMMAND_DIR = resolve(workspaceRoot, ".local-cmds"); +const localEditors = (["vix", "vim"] as const).filter((name) => + existsSync(resolve(VIX_COMMAND_DIR, name)), +); + +// Bare `vim` sources `$VIMRUNTIME/defaults.vim` on startup; without a runtime +// tree in the VM it fails with `E1187: Failed to source defaults.vim` and drops +// to the "Press ENTER" prompt. Provide a host vim runtime read-only through the +// package `provides` field and point `VIMRUNTIME` straight at it (which bypasses +// vim's version-dir search, so a 9.0/9.1 runtime sources cleanly under our 9.2 +// binary). +const VIM_RUNTIME_GUEST_DIR = "/usr/local/share/vim/vim92"; +const vimRuntimeHostDir = localEditors.includes("vim") + ? [ + resolve(VIX_COMMAND_DIR, "vim-runtime"), + "/usr/share/vim/vim92", + "/usr/share/vim/vim91", + "/usr/share/vim/vim90", + "/usr/local/share/vim/vim92", + ].find((dir) => existsSync(resolve(dir, "defaults.vim"))) + : undefined; + +if (localEditors.length > 0) { + // Assemble the package directory: bin/ for each editor, a package.json, + // and an agentos-package.json carrying the runtime `provides` (env + files). + const packageDir = mkdtempSync(join(tmpdir(), "agentos-local-editors-")); + const binDir = join(packageDir, "bin"); + mkdirSync(binDir, { recursive: true }); + for (const name of localEditors) { + cpSync(resolve(VIX_COMMAND_DIR, name), join(binDir, name)); + } + writeFileSync( + join(packageDir, "package.json"), + JSON.stringify({ name: "local-editors", version: "0.0.0" }), + ); + // The runtime tree is overlaid read-only into the package under `runtime/`, + // so `provides.files.source` is a package-relative path the sidecar can read. + let provides: + | { + env: Record; + files: Array<{ source: string; target: string }>; + } + | undefined; + if (vimRuntimeHostDir) { + cpSync(vimRuntimeHostDir, join(packageDir, "runtime"), { recursive: true }); + provides = { + env: { + VIMRUNTIME: VIM_RUNTIME_GUEST_DIR, + VIM: "/usr/local/share/vim", + }, + files: [{ source: "runtime", target: VIM_RUNTIME_GUEST_DIR }], + }; + } + writeFileSync( + join(packageDir, "agentos-package.json"), + JSON.stringify({ + name: "local-editors", + ...(provides ? { provides } : {}), + }), + ); + software.push({ packageDir }); +} -function createShellDiagnosticStripper(): (data: Uint8Array) => Uint8Array | null { +function createShellDiagnosticStripper(): ( + data: Uint8Array, +) => Uint8Array | null { let suppressUntilNewline = false; return (data: Uint8Array) => { let text = Buffer.from(data).toString("utf8"); @@ -186,10 +276,15 @@ function parseCli(argv: string[]): CliOptions { .argument("[command]", "guest command to run", "bash") .argument("[args...]", "guest command arguments") .addOption( - new Option("-i, --interactive", "keep stdin attached") - .default(false), + new Option("-i, --interactive", "keep stdin attached").default(false), ) .addOption(new Option("-t, --tty", "connect a terminal").default(false)) + .addOption( + new Option( + "--actor", + "run through the RivetKit agentOS actor (engine + dylib plugin) instead of the in-process core client", + ).default(false), + ) .option( "-e, --env ", "set environment variable (KEY=VALUE or KEY to copy from host)", @@ -215,7 +310,10 @@ function parseCli(argv: string[]): CliOptions { [], ) .option("-w, --workdir ", "working directory inside the VM", "/") - .option("--name ", "container-style name label (accepted for Docker CLI parity)") + .option( + "--name ", + "container-style name label (accepted for Docker CLI parity)", + ) .option("--rm", "remove VM after exit (always true for this CLI)", false); try { @@ -235,6 +333,7 @@ function parseCli(argv: string[]): CliOptions { const opts = program.opts<{ interactive: boolean; tty: boolean; + actor: boolean; workdir: string; env: string[]; envFile: string[]; @@ -248,6 +347,7 @@ function parseCli(argv: string[]): CliOptions { return { interactive: opts.interactive, tty: opts.tty, + actor: opts.actor, workdir: opts.workdir, env: opts.env, envFile: opts.envFile, @@ -314,7 +414,9 @@ function hostDirMount( function parseVolumeSpec(spec: string): MountConfig { const [hostPath, guestPath, mode] = spec.split(":"); if (!hostPath || !guestPath) { - throw new Error(`Invalid volume spec "${spec}"; expected host:guest[:ro|rw]`); + throw new Error( + `Invalid volume spec "${spec}"; expected host:guest[:ro|rw]`, + ); } if (mode && mode !== "ro" && mode !== "rw") { throw new Error(`Invalid volume mode "${mode}" in "${spec}"`); @@ -341,7 +443,8 @@ function parseMountSpec(spec: string): MountConfig { throw new Error(`Only bind mounts are supported: --mount ${spec}`); } const source = fields.get("source") ?? fields.get("src"); - const target = fields.get("target") ?? fields.get("dst") ?? fields.get("destination"); + const target = + fields.get("target") ?? fields.get("dst") ?? fields.get("destination"); if (typeof source !== "string" || typeof target !== "string") { throw new Error( `Invalid mount spec "${spec}"; expected type=bind,source=...,target=...`, @@ -399,34 +502,48 @@ function shellArgsRequestCommandOrScript(args: string[]): boolean { return false; } -function buildTerminalCommand(options: CliOptions): { +// The error brush prints when the requested input backend is not compiled +// into the wasm build (e.g. reedline missing from the shipped package). Used +// to auto-fall back to the always-available `minimal` backend. +const BRUSH_BACKEND_UNSUPPORTED_MARKER = + "requested input backend type not supported"; + +/** + * Terminal command candidates, tried in order. Prefer `reedline` (history, + * arrows, reverse search — requires a brush wasm build with the reedline + * feature) and fall back to `minimal` when the shipped build rejects it. An + * explicit user-provided backend is used verbatim with no fallback. + */ +function buildTerminalCommandAttempts(options: CliOptions): { command: string; args: string[]; -} { - const args = [...options.args]; - if (isBrushShellCommand(options.command)) { - if (!hasBrushInputBackend(args)) { - args.unshift("--input-backend", "reedline"); - } - if ( - !hasInteractiveShellFlag(args) && - !shellArgsRequestCommandOrScript(args) - ) { - args.push("-i"); - } +}[] { + const baseArgs = [...options.args]; + if (!isBrushShellCommand(options.command)) { + return [{ command: options.command, args: baseArgs }]; } - return { + if ( + !hasInteractiveShellFlag(baseArgs) && + !shellArgsRequestCommandOrScript(baseArgs) + ) { + baseArgs.push("-i"); + } + if (hasBrushInputBackend(baseArgs)) { + // Explicit backend: use it verbatim; the brush error surfaces as-is. + return [{ command: options.command, args: baseArgs }]; + } + return ["reedline", "minimal"].map((backend) => ({ command: options.command, - args, - }; + args: ["--input-backend", backend, ...baseArgs], + })); } async function runSpawnedCommand( - vm: AgentOs, + vm: ShellVmHandle, options: CliOptions, env: Record, ): Promise { - const child = vm.spawn(options.command, options.args, { + const child = await vm.spawn(options.command, options.args, { cwd: options.workdir, env, streamStdin: options.interactive, @@ -478,24 +595,64 @@ async function runSpawnedCommand( } async function runTerminalCommand( - vm: AgentOs, + vm: ShellVmHandle, options: CliOptions, env: Record, ): Promise { + const attempts = buildTerminalCommandAttempts(options); + for (let index = 0; index < attempts.length; index++) { + const canFallback = index + 1 < attempts.length; + const result = await runTerminalAttempt( + vm, + options, + env, + attempts[index], + canFallback, + ); + if (result.backendUnsupported && canFallback) { + process.stderr.write( + "agentos-shell: shell build does not support the requested input backend; retrying with --input-backend minimal\n", + ); + continue; + } + return result.exitCode; + } + return 1; +} + +async function runTerminalAttempt( + vm: ShellVmHandle, + options: CliOptions, + env: Record, + terminalCommand: { command: string; args: string[] }, + canFallback: boolean, +): Promise<{ exitCode: number; backendUnsupported: boolean }> { const stripDiagnostics = createShellDiagnosticStripper(); + let backendUnsupported = false; + const decoder = new TextDecoder(); + const detectBackendError = (data: Uint8Array) => { + if (decoder.decode(data).includes(BRUSH_BACKEND_UNSUPPORTED_MARKER)) { + backendUnsupported = true; + } + }; + // Suppress the backend error output only when a fallback attempt will run; + // without one the error must reach the user. + const suppress = () => backendUnsupported && canFallback; const shellOptions = { cwd: options.workdir, env, cols: process.stdout.columns, rows: process.stdout.rows, onStderr: (data: Uint8Array) => { + detectBackendError(data); + if (suppress()) return; const sanitized = stripDiagnostics(data); if (sanitized) process.stderr.write(sanitized); }, }; - const { shellId } = vm.openShell({ + const { shellId } = await vm.openShell({ ...shellOptions, - ...buildTerminalCommand(options), + ...terminalCommand, }); let stdinQueue = Promise.resolve(); const queueShellInput = (data: Uint8Array | string) => { @@ -515,6 +672,8 @@ async function runTerminalCommand( vm.resizeShell(shellId, process.stdout.columns, process.stdout.rows); }; const unsubscribeOutput = vm.onShellData(shellId, (data) => { + detectBackendError(data); + if (suppress()) return; const sanitized = stripDiagnostics(data); if (sanitized) process.stdout.write(sanitized); }); @@ -540,7 +699,12 @@ async function runTerminalCommand( onResize(); } - return await vm.waitShell(shellId); + const exitCode = await vm.waitShell(shellId); + // Give trailing output events a moment to flush before unsubscribing: + // a fast-failing shell otherwise exits with its error output silently + // dropped, leaving nothing but a bare exit code to debug from. + await new Promise((r) => setTimeout(r, 250)); + return { exitCode, backendUnsupported }; } finally { unsubscribeOutput(); process.stdin.removeListener("data", onStdinData); @@ -558,20 +722,29 @@ async function runTerminalCommand( const cli = parseCli(process.argv.slice(2)); const env = buildEnv(cli); +if (cli.tty && env.AGENTOS_V8_CPU_TIME_LIMIT_MS === undefined) { + // Interactive sessions idle for hours; the wasm runner's input polling + // accrues active-CPU against the default 30s watchdog and kills the guest + // (vim/reedline die mid-session). Match the 24h interactive fuel budget. + env.AGENTOS_V8_CPU_TIME_LIMIT_MS = String(INTERACTIVE_SHELL_WASM_FUEL_MS); +} const mounts = buildMounts(cli); - -const vm = await AgentOs.create({ - mounts, - permissions: allowAll, - software, - limits: cli.tty - ? { - resources: { - maxWasmFuel: INTERACTIVE_SHELL_WASM_FUEL_MS, - }, - } - : undefined, -}); +const limits = cli.tty + ? { + resources: { + maxWasmFuel: INTERACTIVE_SHELL_WASM_FUEL_MS, + }, + } + : undefined; + +const vm: ShellVmHandle = cli.actor + ? await createActorShellVm({ software, mounts, limits }) + : await AgentOs.create({ + mounts, + permissions: allowAll, + software, + limits, + }); let exitCode = 1; try {