diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a731c55d3a..69e0136cb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,7 +104,9 @@ jobs: - uses: actions/download-artifact@v4 with: name: wasm-commands - path: packages/runtime-core/commands + # Restore the canonical build output so registry packages can stage + # from the same path used by a local `just registry-native` build. + path: registry/native/target/wasm32-wasip1/release/commands - run: node packages/runtime-core/scripts/copy-wasm-commands.mjs --require - run: | if grep -qE '^[[:space:]]*-[[:space:]]*website[[:space:]]*$' pnpm-workspace.yaml; then @@ -112,6 +114,8 @@ jobs: else npx turbo build fi + - name: Verify complete coreutils runtime package + run: pnpm --filter @agentos-software/coreutils build:runtime - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test -p agentos-protocol -- --test-threads=1 - run: cargo test -p agentos-sidecar -- --test-threads=1 diff --git a/.gitignore b/.gitignore index 985e5c514c..ddccd47e77 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ registry/native/c/vendor/ registry/native/c/libs/ registry/native/c/.cache/ registry/native/c/sysroot/ +registry/software/coreutils/bin/ registry/software/*/wasm/ registry/software/*/agentos-package.meta.json registry/.last-publish-hash diff --git a/CLAUDE.md b/CLAUDE.md index c0b7f45a3e..b9172199b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,26 @@ the guest — over inventing a softer fallback that hides the failure. only when the caller explicitly supplied them. - Agent adapters must use real upstream SDKs. Do not replace SDK adapters with direct API-call stubs. +- Native registry command binaries are generated artifacts. Do not commit + `registry/software/coreutils/bin/`; a fresh checkout intentionally has no + staged coreutils commands. Any VM, browser demo, or test that needs `sh` or + coreutils must build and stage the complete package from the repository root: + + ```bash + pnpm install --frozen-lockfile + just registry-native + pnpm --filter @agentos-software/coreutils build:runtime + ``` + + `just registry-native` compiles the patched Rust and C WASI sources into + `registry/native/target/wasm32-wasip1/release/commands/`; the pnpm command + then stages `bin/` and assembles `dist/package/`. `just registry-native-cmd + sh` builds only one development artifact and is not sufficient to assemble + the complete coreutils package. The ordinary `build` script may create an + empty placeholder for source-only repository checks; that placeholder is not + runnable and must never be treated as proof that coreutils was built. + Publishing coreutils must run the strict `build:runtime` lifecycle and fail + when the generated command set is absent or incomplete. ## Publishing diff --git a/Cargo.lock b/Cargo.lock index 0d848d8848..6c46128bd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,6 +197,7 @@ dependencies = [ "serde_json", "sha1 0.10.6", "sha2 0.10.9", + "shlex 1.3.0", "socket2 0.6.4", "tar", "tokio", diff --git a/crates/agentos-actor-plugin/src/actions/shell.rs b/crates/agentos-actor-plugin/src/actions/shell.rs index 3c6824c609..f6af3e01c7 100644 --- a/crates/agentos-actor-plugin/src/actions/shell.rs +++ b/crates/agentos-actor-plugin/src/actions/shell.rs @@ -1,9 +1,9 @@ //! 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 +//! `open_shell` subscribes the shell's ordered terminal-data stream and optional +//! diagnostic stderr tap. It pumps terminal bytes to clients as `shellData` and +//! preserves channel-specific diagnostics as `shellStderr`; 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. @@ -106,13 +106,13 @@ pub fn open_shell( })?; let shell_id = handle.shell_id; - let mut data_stream = vm.on_shell_data(&shell_id)?; + let mut terminal_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 { + while let Some(chunk) = terminal_stream.next().await { broadcast_event( &data_host, b"shellData", diff --git a/crates/client/src/agent_os.rs b/crates/client/src/agent_os.rs index 7bfc806fb4..0be8383221 100644 --- a/crates/client/src/agent_os.rs +++ b/crates/client/src/agent_os.rs @@ -73,9 +73,9 @@ pub(crate) struct ProcessEntry { /// A PTY-backed shell (TS `_shells` value). Keyed by synthetic `shell-N` id. /// -/// `data_tx` carries stdout only, matching TS where the kernel handle's `onData` is fed exclusively -/// by `stdoutHandlers`. `stderr_tx` is the dedicated stderr channel that backs the `on_stderr` option -/// and `on_shell_stderr`, matching TS where stderr reaches the host only through `stderrHandlers`. +/// `data_tx` carries stdout and stderr in their original wire order for terminal renderers. +/// `stderr_tx` is an optional channel-specific diagnostic tap backing the `on_stderr` option and +/// `on_shell_stderr`; terminal consumers must not render both streams or stderr would be duplicated. pub(crate) struct ShellEntry { pub pid: u32, pub data_tx: broadcast::Sender>, diff --git a/crates/client/src/shell.rs b/crates/client/src/shell.rs index fe52813d08..b504966db3 100644 --- a/crates/client/src/shell.rs +++ b/crates/client/src/shell.rs @@ -10,13 +10,10 @@ //! spawned via [`ExecuteRequest`]: its `process_id` is what `write_shell`/`close_shell` address on //! the wire, while the public boundary keeps the synthetic `shell-N` id. //! -//! Stream routing mirrors the TS real-process spawn path exactly: the public `data` stream -//! (`on_shell_data`) carries stdout ONLY, because TS wires only the kernel handle's `onData` (fed -//! exclusively by `stdoutHandlers`) into the data handlers. stderr is delivered on a SEPARATE channel -//! (`on_shell_stderr` + the [`OpenShellOptions::on_stderr`] callback), matching TS where stderr -//! reaches the host only through `stderrHandlers` / the `onStderr` option. Fanning stderr into the -//! data stream is only correct for the synthetic-prompt PTY path, which this native real-process path -//! does not implement. +//! Stream routing mirrors the TS PTY path: the public `data` stream (`on_shell_data`) carries stdout +//! and stderr in the order received from the sidecar. stderr is also delivered on an optional +//! channel-specific diagnostic tap (`on_shell_stderr` + [`OpenShellOptions::on_stderr`]); terminal +//! renderers consume only `data` so prompts and control sequences are neither reordered nor doubled. use std::collections::BTreeMap; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -31,7 +28,7 @@ use crate::error::ClientError; use crate::process::{install_output_callback, OutputCallback, ProcessStatus, StdinInput}; use crate::stream::ByteStream; -/// Channel capacity for a shell's data / stderr broadcasts. +/// Channel capacity for a shell's ordered terminal-data and diagnostic-stderr broadcasts. const SHELL_DATA_CHANNEL_CAPACITY: usize = 1024; /// Maximum active or spawning terminals created by `connect_terminal` per VM. @@ -47,9 +44,9 @@ const DEFAULT_SHELL_COMMAND: &str = "sh"; /// Options for `open_shell`. /// -/// `on_stderr` mirrors the TS `OpenShellOptions.onStderr` raw-byte callback: it is the dedicated -/// path stderr reaches the caller (stderr is never fanned into the data stream). It is seeded into -/// the stderr fan-out at open time, matching the TS `stderrHandlers.add(options.onStderr)` behavior. +/// `on_stderr` mirrors the TS `OpenShellOptions.onStderr` raw-byte callback. It is an optional +/// stderr-only diagnostic tap; the same bytes are already present once in ordered shell data, so a +/// terminal renderer must not consume both surfaces. #[derive(Default)] pub struct OpenShellOptions { pub command: Option, @@ -230,9 +227,9 @@ impl AgentOs { /// on a background task because the wire spawn is async. The exit task is tracked in the /// pending-shell-exit set so `dispose` can drain it (two-phase teardown). /// - /// Stdout is fanned into the shell's `data` broadcast (`on_shell_data`); stderr is fanned into a - /// SEPARATE `stderr` broadcast (`on_shell_stderr` + the [`OpenShellOptions::on_stderr`] callback), - /// matching the TS real-process routing where stderr never reaches the data stream. + /// Stdout and stderr are fanned into the shell's ordered `data` broadcast (`on_shell_data`). + /// Stderr is also fanned into a dedicated diagnostic broadcast (`on_shell_stderr` and the + /// [`OpenShellOptions::on_stderr`] callback); terminal renderers should consume only `data`. pub fn open_shell(&self, mut options: OpenShellOptions) -> Result { let inner = self.inner(); let counter = inner.shell_counter.fetch_add(1, Ordering::SeqCst) + 1; @@ -352,14 +349,12 @@ impl AgentOs { if output.process_id != route_process_id { continue; } - // stdout -> data stream; stderr -> separate stderr stream (TS routing). - match output.channel { - StreamChannel::Stdout => { - let _ = data_tx.send(output.chunk); - } - StreamChannel::Stderr => { - let _ = stderr_tx.send(output.chunk); - } + // Publish every PTY chunk from this single wire-event consumer so terminal + // control sequences retain their original stdout/stderr order. + let _ = data_tx.send(output.chunk.clone()); + if output.channel == StreamChannel::Stderr { + // Channel identity remains available as an optional diagnostic tap. + let _ = stderr_tx.send(output.chunk); } } EventPayload::ProcessExitedEvent(exited) => { @@ -502,8 +497,11 @@ impl AgentOs { if output.process_id != route_process_id { continue; } - // Both stdout and stderr are appended to the same terminal output buffer - // (the agent reads a single combined stream), matching the TS handle. + let _ = data_tx.send(output.chunk.clone()); + if output.channel == StreamChannel::Stderr { + let _ = stderr_tx.send(output.chunk.clone()); + } + // Both channels are appended exactly once to the terminal output buffer. on_output(&output.chunk); } EventPayload::ProcessExitedEvent(exited) => { @@ -559,8 +557,8 @@ impl AgentOs { /// be addressed by other shell methods. Killed during dispose via the ACP-terminal registry. /// /// Mirrors the TS `connectTerminal`, which routes its `onData`/`onStderr` callbacks through - /// `openShell`. The Rust port opens a shell, wires the caller's `on_data` to the shell's data - /// stream and `on_stderr` to the shell's stderr stream, then returns the shell's pid. Host + /// `openShell`. The Rust port opens a shell, wires the caller's `on_data` to ordered terminal data + /// and `on_stderr` to the optional diagnostic tap, then returns the shell's pid. Host /// stdin binding, terminal raw-mode, and SIGWINCH/resize forwarding are host-process concerns /// that have no native wire op and are intentionally not bound here. pub async fn connect_terminal(&self, options: ConnectTerminalOptions) -> Result { @@ -575,9 +573,8 @@ impl AgentOs { let (stderr_tx, _) = tokio::sync::broadcast::channel::>(SHELL_DATA_CHANNEL_CAPACITY); - // Wire the caller's onData/onStderr to the terminal's streams (TS routes both through the - // shell handle's onData/onStderr). onData defaults to host stdout in TS; the Rust port has no - // host process stdout to bind to, so it only fans out when a sink is supplied. + // onData defaults to host stdout in TS; the Rust port has no host process stdout to bind to, + // so it only fans out when a sink is supplied. onStderr is diagnostic and independent. if let Some(cb) = on_data { install_output_callback(data_tx.clone(), cb); } @@ -631,13 +628,9 @@ impl AgentOs { if output.process_id != route_process_id { continue; } - match output.channel { - StreamChannel::Stdout => { - let _ = data_tx.send(output.chunk); - } - StreamChannel::Stderr => { - let _ = stderr_tx.send(output.chunk); - } + let _ = data_tx.send(output.chunk.clone()); + if output.channel == StreamChannel::Stderr { + let _ = stderr_tx.send(output.chunk); } } EventPayload::ProcessExitedEvent(exited) => { @@ -752,9 +745,10 @@ impl AgentOs { } } - /// 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`]. + /// Subscribe to a shell's ordered terminal data. SYNC register; multi-handler; dropping the + /// returned stream is the unsubscribe. Carries stdout and stderr exactly once in wire order. + /// Use [`Self::on_shell_stderr`] only as a channel-specific diagnostic tap, not as a second + /// terminal-rendering stream. Errors with [`ClientError::ShellNotFound`]. pub fn on_shell_data(&self, shell_id: &str) -> std::result::Result { self.inner() .shells @@ -764,8 +758,9 @@ impl AgentOs { } /// Subscribe to a shell's stderr. SYNC register; multi-handler; dropping the returned stream is - /// the unsubscribe. This is the dedicated stderr channel backing the TS `onStderr` option; stderr - /// is never fanned into `on_shell_data`. Errors with [`ClientError::ShellNotFound`]. + /// the unsubscribe. This is the optional diagnostic channel backing the TS `onStderr` option; + /// stderr is also present once in ordered `on_shell_data`. Errors with + /// [`ClientError::ShellNotFound`]. pub fn on_shell_stderr(&self, shell_id: &str) -> std::result::Result { self.inner() .shells diff --git a/crates/client/tests/shell_e2e.rs b/crates/client/tests/shell_e2e.rs index 9d8f0cb04f..a722cf32d1 100644 --- a/crates/client/tests/shell_e2e.rs +++ b/crates/client/tests/shell_e2e.rs @@ -1,11 +1,11 @@ //! Shell / PTY e2e against a real `agentos-sidecar`. //! -//! `open_shell` spawns a PTY-backed `sh` (a WASM command). This suite fails fast by default when -//! that command is unavailable; set `AGENT_OS_CLIENT_ALLOW_E2E_SKIPS=1` only for local skip-only -//! runs. +//! `open_shell` spawns a PTY-backed process through the real sidecar. The ordered-stream assertion +//! uses guest Node so stdout and stderr remain distinct wire channels; separate package coverage +//! exercises the real WASM `sh` command. //! //! When the shell IS available the suite asserts the real TS contract: open returns a synthetic -//! `shell-N` id (NOT a pid), `on_shell_data` carries stdout, `write_shell` reaches the shell, +//! `shell-N` id (NOT a pid), `on_shell_data` carries ordered PTY output, `write_shell` reaches the shell, //! `resize_shell` validates existence, and `close_shell` plus the ShellNotFound error contract hold. mod common; @@ -51,15 +51,22 @@ async fn shell_surface_open_write_data_resize_close() { ), "on_shell_data(unknown) must return ShellNotFound" ); - - if !common::require_wasm_commands(&os, "shell_surface_open_write_data_resize_close").await { - os.shutdown().await.expect("shutdown after local skip"); - return; - } - // --- open_shell: synthetic id, NOT a pid ------------------------------------------------------ let shell = os .open_shell(OpenShellOptions { + command: Some("node".to_string()), + args: vec![ + "-e".to_string(), + [ + "process.stdin.setEncoding('utf8');", + "process.stdin.once('data', (chunk) => {", + " process.stdout.write(`OUT:${chunk}`);", + " process.stderr.write(`ERR:${chunk}`);", + "});", + "setInterval(() => {}, 1000);", + ] + .join("\n"), + ], cols: Some(80), rows: Some(24), ..Default::default() @@ -71,30 +78,55 @@ async fn shell_surface_open_write_data_resize_close() { shell.shell_id ); - // --- on_shell_data: subscribe to stdout (stderr is on a separate channel) --------------------- + // --- on_shell_data: subscribe to the ordered PTY stream --------------------------------------- let mut data = os .on_shell_data(&shell.shell_id) .expect("on_shell_data for live shell"); - // A separate stderr channel must also be subscribable. - let _stderr = os + // Stderr remains available as a channel-specific diagnostic tap. + let mut stderr = os .on_shell_stderr(&shell.shell_id) .expect("on_shell_stderr for live shell"); - // --- write_shell: drive the shell, expect the echoed command/output on the data stream -------- - // A PTY shell echoes typed input and runs the command. `echo shell-marker` produces the literal - // marker on stdout. We scan the data stream for the marker rather than asserting an exact frame, - // because PTY line-discipline echo + prompts interleave. + // --- write_shell: prove execution and stdout/stderr ordering on the PTY stream ---------------- + // Neither output marker occurs in the input, so PTY input echo alone cannot satisfy this + // assertion. The process writes stdout before stderr, proving the combined stream retains wire + // order while the stderr bytes remain available through the diagnostic tap. os.write_shell( &shell.shell_id, - StdinInput::Text("echo shell-marker\n".to_string()), + StdinInput::Text("hello-shell\n".to_string()), ) .expect("write_shell"); - let saw_marker = tokio::time::timeout(std::time::Duration::from_secs(10), async { + let ordered_output = tokio::time::timeout(std::time::Duration::from_secs(10), 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("shell-marker") { + let text = String::from_utf8_lossy(&acc); + if text.contains("OUT:hello-shell") && text.contains("ERR:hello-shell") { + return acc; + } + } + acc + }) + .await + .expect("timed out waiting for ordered shell output"); + let ordered_output = String::from_utf8_lossy(&ordered_output); + let stdout_index = ordered_output.find("OUT:hello-shell").unwrap_or_else(|| { + panic!("combined PTY stream should contain executed stdout: {ordered_output:?}") + }); + let stderr_index = ordered_output.find("ERR:hello-shell").unwrap_or_else(|| { + panic!("combined PTY stream should contain executed stderr: {ordered_output:?}") + }); + assert!( + stdout_index < stderr_index, + "combined PTY stream reordered stdout/stderr: {ordered_output:?}" + ); + + let diagnostic_saw_stderr = tokio::time::timeout(std::time::Duration::from_secs(10), async { + let mut acc = Vec::::new(); + while let Some(chunk) = stderr.next().await { + acc.extend_from_slice(&chunk); + if String::from_utf8_lossy(&acc).contains("ERR:hello-shell") { return true; } } @@ -103,11 +135,11 @@ async fn shell_surface_open_write_data_resize_close() { .await .unwrap_or(false); assert!( - saw_marker, - "the shell's data stream should surface the echoed `shell-marker` output" + diagnostic_saw_stderr, + "stderr diagnostic stream should retain the executed stderr output" ); - // --- resize_shell: validates existence (no native winsize op, so it is a best-effort no-op) ---- + // --- resize_shell: validates existence and forwards the native PTY resize ---------------------- os.resize_shell(&shell.shell_id, 120, 40) .expect("resize_shell on a live shell must succeed"); diff --git a/crates/kernel/src/kernel.rs b/crates/kernel/src/kernel.rs index abb5ab2481..19e3d160f3 100644 --- a/crates/kernel/src/kernel.rs +++ b/crates/kernel/src/kernel.rs @@ -2803,6 +2803,45 @@ impl KernelVm { Ok(()) } + /// Toggle PTY raw mode and, when the caller belongs to the terminal's + /// foreground process group, create a generation-scoped recovery lease. + /// The lease can be released during process cleanup without overwriting a + /// newer terminal mutation from another process. + pub fn pty_set_raw_mode( + &self, + requester_driver: &str, + pid: u32, + fd: u32, + enabled: bool, + ) -> KernelResult> { + let description = self.description_for_fd(requester_driver, pid, fd)?; + let foreground_pgid = self.ptys.get_foreground_pgid(description.id())?; + let process_pgid = self.processes.getpgid(pid)?; + let lease_owner = + (!enabled || foreground_pgid == 0 || foreground_pgid == process_pgid).then_some(pid); + Ok(self + .ptys + .set_raw_mode(description.id(), lease_owner, enabled)?) + } + + /// Release a raw-mode recovery lease through any descriptor for the same + /// PTY. `fd` normally belongs to the terminal owner because the exiting + /// child may already have closed its own descriptor zero. + pub fn pty_release_raw_mode( + &self, + requester_driver: &str, + descriptor_owner_pid: u32, + fd: u32, + raw_mode_owner_pid: u32, + generation: u64, + ) -> KernelResult { + self.assert_driver_owns(requester_driver, raw_mode_owner_pid)?; + let description = self.description_for_fd(requester_driver, descriptor_owner_pid, fd)?; + Ok(self + .ptys + .release_raw_mode(description.id(), raw_mode_owner_pid, generation)?) + } + pub fn pty_set_foreground_pgid( &self, requester_driver: &str, diff --git a/crates/kernel/src/pty.rs b/crates/kernel/src/pty.rs index 7d90712076..c11f790e12 100644 --- a/crates/kernel/src/pty.rs +++ b/crates/kernel/src/pty.rs @@ -64,6 +64,7 @@ impl Error for PtyError {} #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct LineDisciplineConfig { + pub icrnl: Option, pub canonical: Option, pub echo: Option, pub isig: Option, @@ -235,6 +236,14 @@ struct PendingRead { result: Option>>, } +#[derive(Debug, Clone)] +struct RawModeLease { + owner_pid: u32, + generation: u64, + applied_termios_generation: u64, + restore_termios: Termios, +} + #[derive(Debug, Clone, Default)] struct PtyState { path: String, @@ -246,6 +255,9 @@ struct PtyState { waiting_input_reads: VecDeque, waiting_output_reads: VecDeque, termios: Termios, + termios_generation: u64, + next_raw_mode_generation: u64, + raw_mode_leases: Vec, line_buffer: Vec, foreground_pgid: u32, window_size: PtyWindowSize, @@ -754,9 +766,16 @@ impl PtyManager { .ptys .get_mut(&pty_ref.pty_id) .ok_or_else(|| PtyError::bad_file_descriptor("PTY not found"))?; + pty.termios_generation = pty + .termios_generation + .checked_add(1) + .ok_or_else(|| PtyError::io("PTY terminal-attribute generation counter exhausted"))?; if let Some(canonical) = config.canonical { pty.termios.icanon = canonical; } + if let Some(icrnl) = config.icrnl { + pty.termios.icrnl = icrnl; + } if let Some(echo) = config.echo { pty.termios.echo = echo; } @@ -772,6 +791,108 @@ impl PtyManager { Ok(()) } + /// Apply or release raw mode for a process. A foreground owner receives a + /// generation-scoped lease so teardown can recover the exact attributes it + /// inherited without letting an unrelated child restore a stale snapshot. + /// + /// `lease_owner_pid = None` applies the requested mode but deliberately + /// does not register teardown recovery (used for a background process). + pub fn set_raw_mode( + &self, + description_id: u64, + lease_owner_pid: Option, + enabled: bool, + ) -> PtyResult> { + let mut state = lock_or_recover(&self.inner.state); + let pty_ref = state + .desc_to_pty + .get(&description_id) + .copied() + .ok_or_else(|| PtyError::bad_file_descriptor("not a PTY end"))?; + let pty = state + .ptys + .get_mut(&pty_ref.pty_id) + .ok_or_else(|| PtyError::bad_file_descriptor("PTY not found"))?; + + if !enabled { + if let Some(owner_pid) = lease_owner_pid { + if release_raw_mode_lease(pty, owner_pid, None)? { + return Ok(None); + } + } + advance_termios_generation(pty)?; + apply_raw_mode(&mut pty.termios, false); + return Ok(None); + } + + let Some(owner_pid) = lease_owner_pid else { + advance_termios_generation(pty)?; + apply_raw_mode(&mut pty.termios, true); + return Ok(None); + }; + + // Repeated setRawMode(true) by one process keeps its original restore + // point. If another owner acquired raw mode in between, remove this + // owner's older frame first and re-acquire at the top of the stack. + if let Some(index) = pty + .raw_mode_leases + .iter() + .position(|lease| lease.owner_pid == owner_pid) + { + if index + 1 == pty.raw_mode_leases.len() { + advance_termios_generation(pty)?; + apply_raw_mode(&mut pty.termios, true); + let lease = pty + .raw_mode_leases + .last_mut() + .expect("raw-mode lease index was just validated"); + lease.applied_termios_generation = pty.termios_generation; + return Ok(Some(lease.generation)); + } + let generation = pty.raw_mode_leases[index].generation; + release_raw_mode_lease(pty, owner_pid, Some(generation))?; + } + + let generation = pty + .next_raw_mode_generation + .checked_add(1) + .ok_or_else(|| PtyError::io("PTY raw-mode generation counter exhausted"))?; + pty.next_raw_mode_generation = generation; + let restore_termios = pty.termios.clone(); + advance_termios_generation(pty)?; + apply_raw_mode(&mut pty.termios, true); + pty.raw_mode_leases.push(RawModeLease { + owner_pid, + generation, + applied_termios_generation: pty.termios_generation, + restore_termios, + }); + Ok(Some(generation)) + } + + /// Release a particular foreground raw-mode lease during process cleanup. + /// Returns whether the lease still existed. A stale/non-top release never + /// changes live terminal attributes; its restore point is transferred to + /// the next owner so out-of-order child exits still unwind correctly. + pub fn release_raw_mode( + &self, + description_id: u64, + owner_pid: u32, + generation: u64, + ) -> PtyResult { + let mut state = lock_or_recover(&self.inner.state); + let pty_ref = state + .desc_to_pty + .get(&description_id) + .copied() + .ok_or_else(|| PtyError::bad_file_descriptor("not a PTY end"))?; + let pty = state + .ptys + .get_mut(&pty_ref.pty_id) + .ok_or_else(|| PtyError::bad_file_descriptor("PTY not found"))?; + release_raw_mode_lease(pty, owner_pid, Some(generation)) + } + pub fn get_termios(&self, description_id: u64) -> PtyResult { let state = lock_or_recover(&self.inner.state); let pty_ref = state @@ -798,6 +919,7 @@ impl PtyManager { .ptys .get_mut(&pty_ref.pty_id) .ok_or_else(|| PtyError::bad_file_descriptor("PTY not found"))?; + advance_termios_generation(pty)?; pty.termios.merge(termios); Ok(()) } @@ -1173,6 +1295,62 @@ fn deliver_output( Ok(()) } +fn advance_termios_generation(pty: &mut PtyState) -> PtyResult<()> { + pty.termios_generation = pty + .termios_generation + .checked_add(1) + .ok_or_else(|| PtyError::io("PTY terminal-attribute generation counter exhausted"))?; + Ok(()) +} + +fn apply_raw_mode(termios: &mut Termios, enabled: bool) { + termios.icrnl = !enabled; + termios.icanon = !enabled; + termios.echo = !enabled; + termios.isig = !enabled; + termios.opost = !enabled; + termios.onlcr = !enabled; +} + +fn release_raw_mode_lease( + pty: &mut PtyState, + owner_pid: u32, + expected_generation: Option, +) -> PtyResult { + let Some(index) = pty.raw_mode_leases.iter().position(|lease| { + lease.owner_pid == owner_pid + && expected_generation.is_none_or(|generation| lease.generation == generation) + }) else { + return Ok(false); + }; + + let was_top = index + 1 == pty.raw_mode_leases.len(); + let lease = pty.raw_mode_leases.remove(index); + if !was_top { + // The newer owner inherited this owner's effective state. If the older + // owner exits first, splice its restore point into that newer frame so + // the eventual top-level release still reaches the pre-stack state. + pty.raw_mode_leases[index].restore_termios = lease.restore_termios; + return Ok(true); + } + + // A direct tcsetattr/set-discipline after this lease was applied supersedes + // it. Do not clobber that newer terminal state during delayed process reap. + if pty.termios_generation != lease.applied_termios_generation { + return Ok(true); + } + + advance_termios_generation(pty)?; + pty.termios = lease.restore_termios; + let restored_generation = pty.termios_generation; + if let Some(previous) = pty.raw_mode_leases.last_mut() { + // The previous owner is active again after unwinding the top frame. + // Point its compare-and-restore token at the state just restored. + previous.applied_termios_generation = restored_generation; + } + Ok(true) +} + fn signal_for_byte(termios: &Termios, byte: u8) -> Option { if byte == termios.cc.vintr { return Some(SIGINT); diff --git a/crates/kernel/tests/kernel_integration.rs b/crates/kernel/tests/kernel_integration.rs index 4c0909142c..59d155c7bb 100644 --- a/crates/kernel/tests/kernel_integration.rs +++ b/crates/kernel/tests/kernel_integration.rs @@ -65,6 +65,74 @@ fn minimal_vm_lifecycle_transitions_between_ready_busy_and_terminated() { assert_eq!(kernel.state(), LifecycleState::Terminated); } +#[test] +fn raw_mode_recovery_lease_is_limited_to_foreground_process_group() { + let mut config = KernelVmConfig::new("vm-pty-raw-owner"); + config.permissions = Permissions::allow_all(); + let mut kernel = KernelVm::new(MemoryFileSystem::new(), config); + kernel + .register_driver(CommandDriver::new("shell", ["sh"])) + .expect("register shell"); + + let shell = kernel + .spawn_process( + "sh", + Vec::new(), + SpawnOptions { + requester_driver: Some(String::from("shell")), + ..SpawnOptions::default() + }, + ) + .expect("spawn shell"); + let (master_fd, slave_fd, _) = kernel + .open_pty("shell", shell.pid()) + .expect("open controlling pty"); + kernel + .fd_dup2("shell", shell.pid(), slave_fd, 0) + .expect("install shell stdin"); + kernel + .pty_set_foreground_pgid("shell", shell.pid(), master_fd, shell.pid()) + .expect("make shell group foreground"); + + let foreground_child = kernel + .spawn_process( + "sh", + Vec::new(), + SpawnOptions { + requester_driver: Some(String::from("shell")), + parent_pid: Some(shell.pid()), + ..SpawnOptions::default() + }, + ) + .expect("spawn foreground child"); + assert!(kernel + .pty_set_raw_mode("shell", foreground_child.pid(), 0, true) + .expect("foreground raw mode") + .is_some()); + + let background_child = kernel + .spawn_process( + "sh", + Vec::new(), + SpawnOptions { + requester_driver: Some(String::from("shell")), + parent_pid: Some(shell.pid()), + ..SpawnOptions::default() + }, + ) + .expect("spawn background child"); + kernel + .setpgid("shell", background_child.pid(), background_child.pid()) + .expect("move child into background process group"); + assert_eq!( + kernel + .pty_set_raw_mode("shell", background_child.pid(), 0, true) + .expect("background raw mode"), + None, + "background process must not own foreground recovery" + ); +} + #[test] fn dispose_kills_running_processes_and_cleans_special_resources() { let mut config = KernelVmConfig::new("vm-dispose"); diff --git a/crates/kernel/tests/pty.rs b/crates/kernel/tests/pty.rs index d47b152753..7998f38a27 100644 --- a/crates/kernel/tests/pty.rs +++ b/crates/kernel/tests/pty.rs @@ -17,6 +17,93 @@ fn wait_for(predicate: impl Fn() -> bool, timeout: Duration) { assert!(predicate(), "condition should become true before timeout"); } +#[test] +fn raw_mode_leases_unwind_nested_owners_after_out_of_order_exit() { + let manager = PtyManager::new(); + let pty = manager.create_pty(); + let description_id = pty.slave.description.id(); + + let first = manager + .set_raw_mode(description_id, Some(101), true) + .expect("first foreground owner enters raw mode") + .expect("first foreground owner receives a lease"); + let second = manager + .set_raw_mode(description_id, Some(102), true) + .expect("nested foreground owner enters raw mode") + .expect("nested foreground owner receives a lease"); + + assert!(manager + .release_raw_mode(description_id, 101, first) + .expect("older owner exits first")); + let still_raw = manager + .get_termios(description_id) + .expect("read nested raw state"); + assert!(!still_raw.icanon); + assert!(!still_raw.echo); + + assert!(manager + .release_raw_mode(description_id, 102, second) + .expect("newer owner exits last")); + let restored = manager + .get_termios(description_id) + .expect("read restored state"); + assert!(restored.icanon); + assert!(restored.echo); + assert!(restored.icrnl); + assert!(restored.opost); +} + +#[test] +fn stale_raw_mode_lease_does_not_overwrite_newer_termios_change() { + let manager = PtyManager::new(); + let pty = manager.create_pty(); + let description_id = pty.slave.description.id(); + let generation = manager + .set_raw_mode(description_id, Some(201), true) + .expect("foreground owner enters raw mode") + .expect("foreground owner receives a lease"); + + manager + .set_discipline( + description_id, + LineDisciplineConfig { + echo: Some(true), + ..Default::default() + }, + ) + .expect("newer process changes terminal state"); + assert!(manager + .release_raw_mode(description_id, 201, generation) + .expect("release stale owner")); + + let current = manager + .get_termios(description_id) + .expect("read current termios"); + assert!(current.echo, "newer echo change must survive stale cleanup"); + assert!( + !current.icanon, + "stale cleanup must not restore the older canonical snapshot" + ); +} + +#[test] +fn background_raw_mode_change_does_not_create_recovery_lease() { + let manager = PtyManager::new(); + let pty = manager.create_pty(); + let description_id = pty.slave.description.id(); + + let generation = manager + .set_raw_mode(description_id, None, true) + .expect("background raw-mode request"); + assert_eq!(generation, None); + assert!( + !manager + .get_termios(description_id) + .expect("read background mutation") + .icanon + ); +} + #[test] fn raw_mode_delivers_bytes_and_applies_icrnl_translation() { let manager = PtyManager::new(); diff --git a/crates/native-sidecar/Cargo.toml b/crates/native-sidecar/Cargo.toml index 6178fdce70..c2f7826540 100644 --- a/crates/native-sidecar/Cargo.toml +++ b/crates/native-sidecar/Cargo.toml @@ -60,6 +60,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha1 = "0.10" sha2 = "0.10" +shlex = "1.3" socket2 = "0.6" tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "rt", "rt-multi-thread", "sync", "time"] } tracing = "0.1" diff --git a/crates/native-sidecar/src/execution.rs b/crates/native-sidecar/src/execution.rs index ef336720d7..049d7565ed 100644 --- a/crates/native-sidecar/src/execution.rs +++ b/crates/native-sidecar/src/execution.rs @@ -108,7 +108,7 @@ use agentos_kernel::network_policy::{ use agentos_kernel::permissions::NetworkOperation; use agentos_kernel::poll::{PollEvents, PollFd, PollTargetEntry, POLLERR, POLLHUP, POLLIN}; use agentos_kernel::process_table::{ProcessStatus, WaitPidFlags, SIGKILL, SIGTERM}; -use agentos_kernel::pty::{LineDisciplineConfig, MAX_PTY_BUFFER_BYTES}; +use agentos_kernel::pty::MAX_PTY_BUFFER_BYTES; use agentos_kernel::resource_accounting::ResourceLimits; use agentos_kernel::root_fs::RootFilesystemMode; use agentos_kernel::socket_table::{ @@ -511,6 +511,7 @@ impl ActiveProcess { sqlite_statements: BTreeMap::new(), next_sqlite_statement_id: 0, tty_master_owner: None, + tty_raw_mode_generation: None, deferred_kernel_wait_rpc: None, module_resolution_cache: agentos_execution::LocalModuleResolutionCache::default(), } @@ -4084,7 +4085,7 @@ where .ok_or_else(|| missing_vm_error(&vm_id))?; let process = vm .active_processes - .get_mut(&payload.process_id) + .get(&payload.process_id) .ok_or_else(|| { SidecarError::InvalidState(format!( "VM {vm_id} has no active process {}", @@ -4106,6 +4107,24 @@ where payload.rows, ) .map_err(kernel_error)?; + // The kernel records SIGWINCH for every member of the terminal's + // foreground process group. Embedded V8 executions cannot observe that + // process-table state directly, so mirror the same group delivery into + // every tracked runtime session, including nested Vim/readline children. + let foreground_pgid = vm + .kernel + .tcgetpgrp(EXECUTION_DRIVER_NAME, process.kernel_pid, writer_fd) + .map_err(kernel_error)?; + let foreground_pids = vm + .kernel + .list_processes() + .into_values() + .filter(|info| info.pgid == foreground_pgid && info.status != ProcessStatus::Exited) + .map(|info| info.pid) + .collect::>(); + for active in vm.active_processes.values() { + dispatch_v8_signal_to_tracked_processes(active, &foreground_pids, libc::SIGWINCH)?; + } Ok(DispatchResult { response: self.respond( @@ -5499,6 +5518,7 @@ where "process_exit_cleanup_sync_host_writes", phase_start.elapsed(), ); + release_inherited_child_raw_mode(&mut vm.kernel, &process)?; let phase_start = Instant::now(); let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); terminate_child_process_tree(&mut vm.kernel, &mut process, &kernel_readiness); @@ -6577,28 +6597,86 @@ where }) } + fn resolve_javascript_child_process_with_shebang( + &mut self, + vm_id: &str, + parent_env: &BTreeMap, + parent_guest_cwd: &str, + parent_host_cwd: &Path, + request: &mut JavascriptChildProcessSpawnRequest, + ) -> Result { + const MAX_SHEBANG_REDIRECTS: usize = 4; + + let mut resolved = { + let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + self.resolve_javascript_child_process_execution( + vm, + parent_env, + parent_guest_cwd, + parent_host_cwd, + request, + )? + }; + + for redirects in 0..=MAX_SHEBANG_REDIRECTS { + let redirected = { + let vm = self + .vms + .get_mut(vm_id) + .ok_or_else(|| missing_vm_error(vm_id))?; + rewrite_javascript_shebang_request(vm, &resolved, request)? + }; + if !redirected { + return Ok(resolved); + } + if redirects == MAX_SHEBANG_REDIRECTS { + return Err(SidecarError::Execution(format!( + "ELOOP: exceeded {MAX_SHEBANG_REDIRECTS} shebang redirects" + ))); + } + resolved = { + let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + self.resolve_javascript_child_process_execution( + vm, + parent_env, + parent_guest_cwd, + parent_host_cwd, + request, + )? + }; + } + + Ok(resolved) + } + pub(crate) fn spawn_javascript_child_process( &mut self, vm_id: &str, process_id: &str, request: JavascriptChildProcessSpawnRequest, ) -> Result { + let mut request = request; let total_start = Instant::now(); let phase_start = Instant::now(); - let mut resolved = { + let (parent_env, parent_guest_cwd, parent_host_cwd) = { let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; let parent = vm .active_processes .get(process_id) .ok_or_else(|| missing_process_error(vm_id, process_id))?; - self.resolve_javascript_child_process_execution( - vm, - &parent.env, - &parent.guest_cwd, - &parent.host_cwd, - &request, - )? + ( + parent.env.clone(), + parent.guest_cwd.clone(), + parent.host_cwd.clone(), + ) }; + let mut resolved = self.resolve_javascript_child_process_with_shebang( + vm_id, + &parent_env, + &parent_guest_cwd, + &parent_host_cwd, + &mut request, + )?; { let vm = self .vms @@ -6900,7 +6978,7 @@ where .unwrap_or(false); let process = vm .active_processes - .get_mut(process_id) + .get(process_id) .ok_or_else(|| missing_process_error(vm_id, process_id))?; let inherited_tty_master_owner = if child_fd1_is_tty { process @@ -6910,6 +6988,10 @@ where } else { None }; + let process = vm + .active_processes + .get_mut(process_id) + .ok_or_else(|| missing_process_error(vm_id, process_id))?; process.child_processes.insert( child_process_id.clone(), ActiveProcess::new(kernel_pid, kernel_handle, resolved.runtime, execution) @@ -7075,11 +7157,12 @@ where current_process_path: &[&str], request: JavascriptChildProcessSpawnRequest, ) -> Result { + let mut request = request; let total_start = Instant::now(); let current_process_label = Self::child_process_path_label(process_id, current_process_path); let phase_start = Instant::now(); - let (mut resolved, parent_kernel_pid) = { + let (parent_env, parent_guest_cwd, parent_host_cwd, parent_kernel_pid) = { let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; let root = vm .active_processes @@ -7092,16 +7175,19 @@ where )) })?; ( - self.resolve_javascript_child_process_execution( - vm, - &parent.env, - &parent.guest_cwd, - &parent.host_cwd, - &request, - )?, + parent.env.clone(), + parent.guest_cwd.clone(), + parent.host_cwd.clone(), parent.kernel_pid, ) }; + let mut resolved = self.resolve_javascript_child_process_with_shebang( + vm_id, + &parent_env, + &parent_guest_cwd, + &parent_host_cwd, + &mut request, + )?; { let vm = self .vms @@ -7403,14 +7489,13 @@ where .unwrap_or(false); let root = vm .active_processes - .get_mut(process_id) + .get(process_id) .ok_or_else(|| missing_process_error(vm_id, process_id))?; - let parent = - Self::active_process_by_path_mut(root, current_process_path).ok_or_else(|| { - SidecarError::InvalidState(format!( - "unknown child process path {current_process_label} during nested spawn" - )) - })?; + let parent = Self::active_process_by_path(root, current_process_path).ok_or_else(|| { + SidecarError::InvalidState(format!( + "unknown child process path {current_process_label} during nested spawn" + )) + })?; let inherited_tty_master_owner = if child_fd1_is_tty { parent .tty_master_fd @@ -7419,6 +7504,16 @@ where } else { None }; + let root = vm + .active_processes + .get_mut(process_id) + .ok_or_else(|| missing_process_error(vm_id, process_id))?; + let parent = + Self::active_process_by_path_mut(root, current_process_path).ok_or_else(|| { + SidecarError::InvalidState(format!( + "unknown child process path {current_process_label} during nested spawn" + )) + })?; parent.child_processes.insert( child_process_id.clone(), ActiveProcess::new(kernel_pid, kernel_handle, resolved.runtime, execution) @@ -7793,12 +7888,11 @@ where Ok(true) } - /// Service `__kernel_stdio_write` for a process writing to the SHARED - /// terminal (`tty_master_owner` set): write through the process's own PTY - /// slave (line discipline applies), then drain the master and surface the - /// drained bytes as the OWNER's ordered output stream — the single - /// host-facing path. No child stdout event is queued, so nothing gets - /// relayed (and re-rendered) by the parent shell. + /// Service `__kernel_stdio_write` for a process writing to the shared PTY. + /// Apply the slave line discipline once, then enqueue the drained bytes on + /// the owning process before acknowledging the write. The owner's local + /// queue is drained before its next runtime event, keeping child output + /// ahead of the next shell prompt. pub(crate) fn service_shared_tty_stdio_write( &mut self, vm_id: &str, @@ -7826,7 +7920,7 @@ where .map_err(kernel_error)? }; let (owner_pid, master_fd) = owner; - let mut drained: Vec = Vec::new(); + let mut drained = Vec::new(); loop { match vm.kernel.fd_read_with_timeout_result( EXECUTION_DRIVER_NAME, @@ -7842,14 +7936,16 @@ where } } if !drained.is_empty() { - if let Some(owner_process) = vm + let Some(owner_process) = vm .active_processes .values_mut() .find(|process| process.kernel_pid == owner_pid) - { - owner_process - .queue_pending_execution_event(ActiveExecutionEvent::Stdout(drained))?; - } + else { + return Err(SidecarError::InvalidState(format!( + "shared PTY owner pid {owner_pid} is not active" + ))); + }; + owner_process.queue_pending_execution_event(ActiveExecutionEvent::Stdout(drained))?; } Ok(json!(written)) } @@ -8096,6 +8192,7 @@ where let detached_children = Self::adopt_detached_child_processes(&child_process_label, &mut child); sync_process_host_writes_to_kernel(vm, &child)?; + release_inherited_child_raw_mode(&mut vm.kernel, &child)?; let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); terminate_child_process_tree(&mut vm.kernel, &mut child, &kernel_readiness); child.kernel_handle.finish(exit_code); @@ -10539,6 +10636,229 @@ fn build_host_node_cli_eval(cli: &ResolvedHostNodeCliEntrypoint) -> String { } } +fn rewrite_javascript_shebang_request( + vm: &mut VmState, + resolved: &ResolvedChildProcessExecution, + request: &mut JavascriptChildProcessSpawnRequest, +) -> Result { + const MAX_SHEBANG_LINE_BYTES: usize = 256; + + if !matches!(resolved.runtime, GuestRuntimeKind::WebAssembly) { + return Ok(false); + } + let Some(script_path) = resolved + .env + .get("AGENTOS_GUEST_ENTRYPOINT") + .filter(|path| path.starts_with('/')) + .map(|path| normalize_path(path)) + else { + return Ok(false); + }; + let is_registered_command = vm + .command_guest_paths + .values() + .any(|path| normalize_path(path) == script_path); + if !is_registered_command { + let stat = vm.kernel.stat(&script_path).map_err(kernel_error)?; + if stat.is_directory || stat.mode & 0o111 == 0 { + return Err(SidecarError::Execution(format!( + "EACCES: permission denied, execute '{script_path}'" + ))); + } + } + let header = vm + .kernel + .pread_file(&script_path, 0, MAX_SHEBANG_LINE_BYTES + 1) + .map_err(kernel_error)?; + let Some((command, args)) = + parse_javascript_shebang(&script_path, &header, &resolved.execution_args)? + else { + return Ok(false); + }; + request.command = command; + request.args = args; + request.options.shell = false; + Ok(true) +} + +fn parse_javascript_shebang( + script_path: &str, + header: &[u8], + execution_args: &[String], +) -> Result)>, SidecarError> { + const MAX_SHEBANG_LINE_BYTES: usize = 256; + + if !header.starts_with(b"#!") { + return Ok(None); + } + + let line_end = match header.iter().position(|byte| *byte == b'\n') { + Some(index) if index > MAX_SHEBANG_LINE_BYTES => { + return Err(SidecarError::Execution(format!( + "ENOEXEC: shebang line exceeds {MAX_SHEBANG_LINE_BYTES} bytes: {script_path}" + ))); + } + Some(index) => index, + None if header.len() > MAX_SHEBANG_LINE_BYTES => { + return Err(SidecarError::Execution(format!( + "ENOEXEC: shebang line exceeds {MAX_SHEBANG_LINE_BYTES} bytes: {script_path}" + ))); + } + None => header.len(), + }; + let line = header[2..line_end] + .strip_suffix(b"\r") + .unwrap_or(&header[2..line_end]); + let text = std::str::from_utf8(line).map_err(|_| { + SidecarError::Execution(format!("ENOEXEC: invalid shebang line: {script_path}")) + })?; + let text = text.trim_start_matches(|ch: char| ch.is_ascii_whitespace()); + let (interpreter, optional_arg) = text + .find(|ch: char| ch.is_ascii_whitespace()) + .map(|index| { + ( + &text[..index], + text[index..].trim_matches(|ch: char| ch.is_ascii_whitespace()), + ) + }) + .map(|(interpreter, optional_arg)| { + ( + interpreter, + (!optional_arg.is_empty()).then_some(optional_arg), + ) + }) + .unwrap_or((text, None)); + if interpreter.is_empty() { + return Err(SidecarError::Execution(format!( + "ENOEXEC: invalid shebang line: {script_path}" + ))); + } + let (command, mut interpreter_args) = if matches!(interpreter, "/usr/bin/env" | "/bin/env") { + let optional_arg = optional_arg.ok_or_else(|| { + SidecarError::Execution(format!( + "ENOENT: missing interpreter after {interpreter} in shebang: {script_path}" + )) + })?; + if let Some(split_string) = optional_arg + .strip_prefix("-S") + .filter(|rest| rest.starts_with(|ch: char| ch.is_ascii_whitespace())) + { + let mut words = shlex::split(split_string.trim()).ok_or_else(|| { + SidecarError::Execution(format!( + "ENOEXEC: invalid /usr/bin/env -S quoting in shebang: {script_path}" + )) + })?; + if words.is_empty() { + return Err(SidecarError::Execution(format!( + "ENOENT: missing interpreter after /usr/bin/env -S in shebang: {script_path}" + ))); + } + let command = words.remove(0); + (command, words) + } else { + if optional_arg.starts_with('-') + || optional_arg.chars().any(|ch| ch.is_ascii_whitespace()) + { + return Err(SidecarError::Execution(format!( + "ENOEXEC: /usr/bin/env shebang arguments require -S: {script_path}" + ))); + } + (optional_arg.to_owned(), Vec::new()) + } + } else { + ( + interpreter.to_owned(), + optional_arg + .map(|arg| vec![arg.to_owned()]) + .unwrap_or_default(), + ) + }; + interpreter_args.push(script_path.to_owned()); + interpreter_args.extend(execution_args.iter().cloned()); + Ok(Some((command, interpreter_args))) +} + +#[cfg(test)] +mod javascript_shebang_tests { + use super::parse_javascript_shebang; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + } + + #[test] + fn preserves_linux_optional_argument_and_crlf() { + let parsed = parse_javascript_shebang( + "/workspace/test.sh", + b"#!/bin/sh -e -x\r\necho ignored", + &strings(&["one", "two"]), + ) + .expect("parse direct shebang") + .expect("shebang should be detected"); + + assert_eq!(parsed.0, "/bin/sh"); + assert_eq!( + parsed.1, + strings(&["-e -x", "/workspace/test.sh", "one", "two"]) + ); + } + + #[test] + fn parses_env_and_quoted_env_split_strings() { + let env = parse_javascript_shebang("/workspace/env.sh", b"#!/usr/bin/env sh\n", &[]) + .expect("parse env shebang") + .expect("env shebang should be detected"); + assert_eq!(env, (String::from("sh"), strings(&["/workspace/env.sh"]))); + + let env_split = parse_javascript_shebang( + "/workspace/env-s.sh", + b"#! /usr/bin/env -S sh -c 'printf \"%s\" \"$1\"' shell\n", + &strings(&["tail"]), + ) + .expect("parse env -S shebang") + .expect("env -S shebang should be detected"); + assert_eq!( + env_split, + ( + String::from("sh"), + strings(&[ + "-c", + "printf \"%s\" \"$1\"", + "shell", + "/workspace/env-s.sh", + "tail" + ]) + ) + ); + } + + #[test] + fn rejects_invalid_or_unbounded_shebangs() { + assert!( + parse_javascript_shebang("/workspace/plain", b"plain text", &[]) + .expect("parse plain file") + .is_none() + ); + + let missing = parse_javascript_shebang("/workspace/missing", b"#!/usr/bin/env\n", &[]) + .expect_err("env without interpreter must fail"); + assert!(missing.to_string().contains("ENOENT")); + + let malformed = parse_javascript_shebang( + "/workspace/malformed", + b"#!/usr/bin/env -S sh 'unterminated\n", + &[], + ) + .expect_err("unterminated env -S quote must fail"); + assert!(malformed.to_string().contains("ENOEXEC")); + + let overlong = format!("#!/{}\n", "x".repeat(257)); + let too_long = parse_javascript_shebang("/workspace/long", overlong.as_bytes(), &[]) + .expect_err("overlong shebang must fail"); + assert!(too_long.to_string().contains("ENOEXEC")); + } +} + fn resolve_guest_command_entrypoint( vm: &VmState, guest_cwd: &str, @@ -19080,25 +19400,30 @@ fn service_javascript_pty_set_raw_mode_sync_rpc( request: &JavascriptSyncRpcRequest, ) -> Result { let enabled = javascript_sync_rpc_arg_bool(&request.args, 0, "__pty_set_raw_mode enabled")?; + process.tty_raw_mode_generation = kernel + .pty_set_raw_mode(EXECUTION_DRIVER_NAME, process.kernel_pid, 0, enabled) + .map_err(kernel_error)?; + Ok(Value::Null) +} + +fn release_inherited_child_raw_mode( + kernel: &mut SidecarKernel, + child: &ActiveProcess, +) -> Result<(), SidecarError> { + let Some(generation) = child.tty_raw_mode_generation else { + return Ok(()); + }; + let (descriptor_owner_pid, fd) = child.tty_master_owner.unwrap_or((child.kernel_pid, 0)); kernel - .pty_set_discipline( + .pty_release_raw_mode( EXECUTION_DRIVER_NAME, - process.kernel_pid, - 0, - // Match cfmakeraw: raw mode also disables output post-processing - // (OPOST/ONLCR) so a full-screen app (vim) that drives its own - // cursor/CRLF is not mangled by the line discipline; cooked mode - // re-enables it so the line shell gets LF->CRLF. - LineDisciplineConfig { - canonical: Some(!enabled), - echo: Some(!enabled), - isig: Some(!enabled), - opost: Some(!enabled), - onlcr: Some(!enabled), - }, + descriptor_owner_pid, + fd, + child.kernel_pid, + generation, ) - .map_err(kernel_error)?; - Ok(Value::Null) + .map(|_| ()) + .map_err(kernel_error) } fn service_javascript_kernel_isatty_sync_rpc( @@ -24104,6 +24429,27 @@ fn dispatch_v8_process_signal(process: &ActiveProcess, signal: i32) -> Result, + signal: i32, +) -> Result { + let mut delivered = 0; + if target_pids.contains(&process.kernel_pid) + && matches!( + &process.execution, + ActiveExecution::Javascript(_) | ActiveExecution::Wasm(_) + ) + && dispatch_v8_process_signal(process, signal)? + { + delivered += 1; + } + for child in process.child_processes.values() { + delivered += dispatch_v8_signal_to_tracked_processes(child, target_pids, signal)?; + } + Ok(delivered) +} + fn dispatch_v8_session_signal_async(session: V8SessionHandle, signal: i32) { let Some(signal_name) = signal_name_for_stream_event(signal).map(str::to_owned) else { return; diff --git a/crates/native-sidecar/src/state.rs b/crates/native-sidecar/src/state.rs index bb290b277c..e0dd8be9db 100644 --- a/crates/native-sidecar/src/state.rs +++ b/crates/native-sidecar/src/state.rs @@ -508,6 +508,10 @@ pub(crate) struct ActiveProcess { /// terminal reading the PTY master (a shell never relays its child's tty /// output). pub(crate) tty_master_owner: Option<(u32, u32)>, + /// Generation of the foreground PTY raw-mode lease owned by this process. + /// Cleanup releases only this generation, so an unrelated/background child + /// or a newer terminal mutation cannot restore a stale termios snapshot. + pub(crate) tty_raw_mode_generation: Option, /// A parked `__kernel_stdin_read` / `__kernel_poll` sync RPC awaiting /// kernel readiness (reply-by-token deferral so servicing never blocks the /// dispatch loop). At most one per process: the guest thread is blocked in diff --git a/crates/native-sidecar/tests/posix_path_repro.rs b/crates/native-sidecar/tests/posix_path_repro.rs index 94163bc9e1..3bec55fdc8 100644 --- a/crates/native-sidecar/tests/posix_path_repro.rs +++ b/crates/native-sidecar/tests/posix_path_repro.rs @@ -5,6 +5,7 @@ use agentos_native_sidecar::wire::{ }; use serde_json::{json, Value}; use std::collections::HashMap; +use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; @@ -29,33 +30,19 @@ const ALLOWED_NODE_BUILTINS: &[&str] = &[ "util", ]; -fn strip_benign_child_pid_warnings(stderr: &str) -> String { - stderr - .lines() - .filter(|line| !line.contains("WARN") || !line.contains("could not retrieve pid")) - .collect::>() - .join("\n") -} - fn registry_command_root() -> PathBuf { let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../..") .canonicalize() .expect("canonicalize repo root"); - let copied = repo_root.join("registry/software/coreutils/wasm"); - if copied.exists() { - return copied; - } - - let fallback = repo_root.join("registry/native/target/wasm32-wasip1/release/commands"); - if fallback.exists() { - return fallback; + let commands = repo_root.join("registry/native/target/wasm32-wasip1/release/commands"); + if commands.exists() { + return commands; } panic!( - "registry WASM commands are required for posix path repro tests: expected {} or {}", - copied.display(), - fallback.display() + "registry WASM commands are required for posix path repro tests: run `just registry-native`; expected {}", + commands.display() ); } @@ -356,11 +343,9 @@ console.log(JSON.stringify({ "guest shell should resolve relative paths inside the cwd: {guest}" ); assert_eq!( - strip_benign_child_pid_warnings( - guest["stderr"] - .as_str() - .expect("child stderr should be encoded as a string") - ), + guest["stderr"] + .as_str() + .expect("child stderr should be encoded as a string"), "", "guest shell should not emit unexpected stderr: {guest}" ); @@ -480,11 +465,9 @@ console.log(JSON.stringify({ "guest shell should still succeed with absolute paths: {guest}" ); assert_eq!( - strip_benign_child_pid_warnings( - guest["stderr"] - .as_str() - .expect("child stderr should be encoded as a string") - ), + guest["stderr"] + .as_str() + .expect("child stderr should be encoded as a string"), "", "guest shell should not emit unexpected stderr: {guest}" ); @@ -502,6 +485,7 @@ fn node_path_posix_edge_cases_match_host_node() { import path from "node:path"; console.log(JSON.stringify({ + posixIdentity: path.posix === path, resolve: path.resolve("/workspace/project/", "./src", "../tests", "spec.ts"), join: path.join("/workspace", "project", "..", "project", "note.txt"), normalize: path.normalize("/workspace//project/tests/../nested//file.txt"), @@ -514,6 +498,113 @@ console.log(JSON.stringify({ ); } +fn node_console_formatting_matches_host_node() { + assert_guest_matches_host( + "console-formatting", + r#" +const writes = []; +const originalWrite = process.stdout.write; +process.stdout.write = (chunk) => { + writes.push(String(chunk)); + return true; +}; +console.log("value:%s count:%d object:%o", "ok", 3, { nested: true }); +process.stdout.write = originalWrite; +originalWrite.call(process.stdout, JSON.stringify({ writes })); +"#, + ); +} + +fn javascript_child_process_executes_guest_shebang_scripts() { + assert_node_available(); + + let (cwd, entrypoint) = write_probe( + "child-shebang", + r#" +import childProcess from "node:child_process"; + +const result = childProcess.spawnSync( + "/workspace/hello.sh", + ["native"], + { encoding: "utf8" }, +); +const denied = childProcess.spawnSync( + "/workspace/not-executable.sh", + [], + { encoding: "utf8" }, +); +console.log(JSON.stringify({ + status: result.status, + signal: result.signal, + stdout: result.stdout, + stderr: result.stderr, + denied: { + status: denied.status, + signal: denied.signal, + errorCode: denied.error?.code ?? null, + stderr: denied.stderr, + }, +})); +"#, + ); + let script = cwd.join("hello.sh"); + write_fixture( + &script, + "#!/usr/bin/env -S sh\nprintf 'shebang:%s\\n' \"$1\"\n", + ); + let mut permissions = fs::metadata(&script) + .expect("read shebang fixture metadata") + .permissions(); + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o755); + fs::set_permissions(&script, permissions).expect("make shebang fixture executable"); + write_fixture( + &cwd.join("not-executable.sh"), + "#!/bin/sh\nprintf 'must-not-run\\n'\n", + ); + + let guest = run_guest_probe( + "child-shebang", + &cwd, + &entrypoint, + true, + HashMap::new(), + vec![MountDescriptor { + guest_path: String::from("/workspace"), + read_only: false, + plugin: MountPluginDescriptor { + id: String::from("host_dir"), + config: serde_json::to_string(&json!({ + "hostPath": cwd, + "readOnly": false, + })) + .expect("serialize shebang workspace mount config"), + }, + }], + ); + + assert_eq!(guest["status"], json!(0), "shebang child failed: {guest}"); + assert_eq!( + guest["signal"], + Value::Null, + "shebang child signaled: {guest}" + ); + assert_eq!(guest["stdout"], "shebang:native\n"); + assert_eq!(guest["stderr"], ""); + assert_ne!( + guest["denied"]["status"], + json!(0), + "non-executable script unexpectedly ran: {guest}" + ); + assert!( + guest["denied"]["errorCode"] == "EACCES" + || guest["denied"]["stderr"] + .as_str() + .is_some_and(|stderr| stderr.contains("EACCES")), + "non-executable script did not surface EACCES: {guest}" + ); +} + fn filesystem_path_edge_cases_match_host_node() { assert_guest_matches_host( "filesystem-paths", @@ -556,5 +647,7 @@ fn posix_path_repro_suite() { filesystem_path_edge_cases_match_host_node(); guest_shell_absolute_paths_still_work_after_cd(); guest_shell_relative_paths_follow_cwd_after_cd(); + javascript_child_process_executes_guest_shebang_scripts(); + node_console_formatting_matches_host_node(); node_path_posix_edge_cases_match_host_node(); } diff --git a/crates/native-sidecar/tests/service.rs b/crates/native-sidecar/tests/service.rs index a1e4c98697..3387887e69 100644 --- a/crates/native-sidecar/tests/service.rs +++ b/crates/native-sidecar/tests/service.rs @@ -5661,6 +5661,12 @@ ykAheWCsAteSEWVc0w==\n\ { let vm = sidecar.vms.get(&vm_id).expect("javascript vm"); + assert!( + vm.active_processes["proc-js-pty"] + .tty_raw_mode_generation + .is_some(), + "foreground raw mode should retain a cleanup lease" + ); let kernel_pid = vm.active_processes["proc-js-pty"].kernel_pid; let termios = vm .kernel @@ -5669,6 +5675,7 @@ ykAheWCsAteSEWVc0w==\n\ assert!(!termios.icanon); assert!(!termios.echo); assert!(!termios.isig); + assert!(!termios.icrnl); } let cooked = call_javascript_sync_rpc( @@ -5687,6 +5694,10 @@ ykAheWCsAteSEWVc0w==\n\ { let vm = sidecar.vms.get(&vm_id).expect("javascript vm"); + assert_eq!( + vm.active_processes["proc-js-pty"].tty_raw_mode_generation, None, + "explicit cooked mode should release the cleanup lease" + ); let kernel_pid = vm.active_processes["proc-js-pty"].kernel_pid; let termios = vm .kernel @@ -5695,6 +5706,7 @@ ykAheWCsAteSEWVc0w==\n\ assert!(termios.icanon); assert!(termios.echo); assert!(termios.isig); + assert!(termios.icrnl); } sidecar @@ -21283,6 +21295,11 @@ console.log(JSON.stringify({ run_isolated_service_test("dgram-loopback-events"); } + #[test] + fn javascript_sync_rpc_pty_raw_mode_toggles_tty_discipline_regression() { + run_isolated_service_test("javascript-pty-raw-mode"); + } + #[test] fn javascript_http_external_get_reaches_host_listener_regression() { javascript_http_external_get_reaches_host_listener(); @@ -21479,6 +21496,9 @@ console.log(JSON.stringify({ "javascript-fs-promises-hot-metadata" => { javascript_fs_promises_hot_metadata_ops_use_sync_semantics(); } + "javascript-pty-raw-mode" => { + javascript_sync_rpc_pty_set_raw_mode_toggles_kernel_tty_discipline(); + } "wasm-shell-external-stdout-redirect" => { wasm_shell_external_stdout_redirect_writes_file(); } diff --git a/crates/native-sidecar/tests/signal.rs b/crates/native-sidecar/tests/signal.rs index b01bc27a8d..6f2d890880 100644 --- a/crates/native-sidecar/tests/signal.rs +++ b/crates/native-sidecar/tests/signal.rs @@ -2,15 +2,16 @@ mod support; use agentos_native_sidecar::wire::{ EventPayload, GetSignalStateRequest, GuestRuntimeKind, KillProcessRequest, - ProcessSnapshotStatus, RequestPayload, ResponsePayload, SignalDispositionAction, - SignalHandlerRegistration, StreamChannel, + ProcessSnapshotStatus, RequestPayload, ResizePtyRequest, ResponsePayload, + SignalDispositionAction, SignalHandlerRegistration, StreamChannel, }; use nix::libc; use std::collections::HashMap; use std::time::{Duration, Instant}; use support::{ - assert_node_available, authenticate_wire, create_vm_wire_with_metadata, execute_wire, - new_sidecar, open_session_wire, temp_dir, wire_request, wire_vm, write_fixture, + assert_node_available, authenticate_wire, collect_process_output_wire_with_timeout, + create_vm_wire_with_metadata, execute_wire, new_sidecar, open_session_wire, temp_dir, + wire_request, wire_vm, write_fixture, }; fn wait_for_process_output( @@ -715,6 +716,146 @@ fn embedded_runtime_process_group_kill_terminates_detached_tree() { ); } +fn pty_resize_delivers_sigwinch_to_nested_foreground_runtime() { + assert_node_available(); + + let mut sidecar = new_sidecar("pty-resize-nested-sigwinch"); + let cwd = temp_dir("pty-resize-nested-sigwinch-cwd"); + let parent_entry = cwd.join("parent.mjs"); + let child_entry = cwd.join("child.mjs"); + write_fixture( + &child_entry, + [ + "process.on('SIGWINCH', () => {", + " console.log('child-winch');", + " process.exit(0);", + "});", + "console.log('child-winch-ready');", + "setInterval(() => {}, 25);", + ] + .join("\n"), + ); + write_fixture( + &parent_entry, + [ + "import { spawn } from 'node:child_process';", + "let parentWinch = false;", + "process.on('SIGWINCH', () => {", + " parentWinch = true;", + " console.log('parent-winch');", + "});", + "const child = spawn('node', ['./child.mjs'], { stdio: 'inherit' });", + "await new Promise((resolve, reject) => {", + " child.on('error', reject);", + " child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`child exit ${code}`)));", + "});", + "const deadline = Date.now() + 2000;", + "while (!parentWinch && Date.now() < deadline) {", + " await new Promise((resolve) => setTimeout(resolve, 10));", + "}", + "if (!parentWinch) throw new Error('parent did not receive SIGWINCH');", + "console.log('nested-winch-complete');", + ] + .join("\n"), + ); + + let connection_id = authenticate_wire(&mut sidecar, "conn-pty-resize-nested-sigwinch"); + let session_id = open_session_wire(&mut sidecar, 2, &connection_id); + let allowed_builtins = serde_json::to_string(&[ + "assert", + "buffer", + "child_process", + "console", + "crypto", + "events", + "fs", + "path", + "querystring", + "stream", + "string_decoder", + "timers", + "url", + "util", + "zlib", + ]) + .expect("serialize builtins"); + let (vm_id, _) = create_vm_wire_with_metadata( + &mut sidecar, + 3, + &connection_id, + &session_id, + GuestRuntimeKind::JavaScript, + &cwd, + HashMap::from([( + String::from("env.AGENTOS_ALLOWED_NODE_BUILTINS"), + allowed_builtins, + )]), + ); + let ownership = wire_vm(&connection_id, &session_id, &vm_id); + let started = sidecar + .dispatch_wire_blocking(wire_request( + 4, + ownership.clone(), + RequestPayload::ExecuteRequest(agentos_native_sidecar::wire::ExecuteRequest { + process_id: String::from("pty-winch-parent"), + command: None, + runtime: Some(GuestRuntimeKind::JavaScript), + entrypoint: Some(parent_entry.to_string_lossy().into_owned()), + args: Vec::new(), + env: HashMap::from([(String::from("AGENTOS_EXEC_TTY"), String::from("1"))]), + cwd: None, + wasm_permission_tier: None, + }), + )) + .expect("start PTY parent"); + assert!(matches!( + started.response.payload, + ResponsePayload::ProcessStartedResponse(_) + )); + + wait_for_process_output( + &mut sidecar, + &connection_id, + &session_id, + &vm_id, + "pty-winch-parent", + "child-winch-ready", + ); + sidecar + .dispatch_wire_blocking(wire_request( + 5, + ownership, + RequestPayload::ResizePtyRequest(ResizePtyRequest { + process_id: String::from("pty-winch-parent"), + cols: 132, + rows: 48, + }), + )) + .expect("resize parent PTY"); + + let (stdout, stderr, exit_code) = collect_process_output_wire_with_timeout( + &mut sidecar, + &connection_id, + &session_id, + &vm_id, + "pty-winch-parent", + Duration::from_secs(10), + ); + assert_eq!(exit_code, 0, "PTY parent failed: {stderr}"); + assert!( + stdout.contains("parent-winch"), + "root runtime missed SIGWINCH: {stdout}" + ); + assert!( + stdout.contains("child-winch"), + "nested foreground runtime missed SIGWINCH: {stdout}" + ); + assert!( + stdout.contains("nested-winch-complete"), + "parent did not observe nested signal completion: {stdout}" + ); +} + fn embedded_runtime_signal_delivers_sigchld_on_child_exit() { assert_node_available(); @@ -887,5 +1028,6 @@ fn embedded_runtime_signal_suite() { embedded_runtime_kill_process_rejects_invalid_signal_without_killing_process(); embedded_runtime_process_kill_signal_zero_checks_child_liveness(); embedded_runtime_process_group_kill_terminates_detached_tree(); + pty_resize_delivers_sigwinch_to_nested_foreground_runtime(); embedded_runtime_signal_delivers_sigchld_on_child_exit(); } diff --git a/docs-internal/native-runtime-fixes.md b/docs-internal/native-runtime-fixes.md new file mode 100644 index 0000000000..fe3c47d0ab --- /dev/null +++ b/docs-internal/native-runtime-fixes.md @@ -0,0 +1,254 @@ +# AgentOS native runtime fixes extracted from browser shell work + +This document records the native AgentOS fixes extracted from `bb-demo` +revision `f48396c6` (PR #1745) onto `main` revision `f63c6ef2`. The extraction +contains native kernel, sidecar, Rust client, actor transport, V8 bridge, +software source/build, and agent packaging changes. It excludes +Browserbase, `runtime-browser`, browser terminal UI, browser test bundles, and +browser-only Pi module injection. + +## Ordered PTY output + +**Problem:** the Rust client exposed stdout and stderr through independently +scheduled streams. Terminal renderers could therefore display prompts and +control sequences out of sidecar wire order. + +**Code:** the existing shell-data stream is the terminal rendering surface in +both the Rust and TypeScript APIs. Both shell execution paths publish every +wire chunk to it before optionally routing stderr to the diagnostic tap: + +```rust +let _ = data_tx.send(output.chunk.clone()); +if output.channel == StreamChannel::Stderr { + let _ = stderr_tx.send(output.chunk); +} +``` + +Rust `AgentOs::on_shell_data` and TypeScript `AgentOs.onShellData` expose the +same ordered stream. The actor broadcasts it as `shellData` while retaining +`shellStderr` as the optional channel-specific diagnostic tap. A terminal +client renders only `shellData`; rendering `shellStderr` too would duplicate +the same stderr bytes. + +## Native PTY state and signal behavior + +**Problems:** raw mode did not disable carriage-return translation, embedded V8 +processes did not observe terminal resize, and a full-screen child could leave +its parent terminal in raw mode. + +**Code:** + +- `crates/kernel/src/pty.rs` adds `icrnl` to `LineDisciplineConfig` and merges it + into the live PTY state. +- `service_javascript_pty_set_raw_mode_sync_rpc` in + `crates/native-sidecar/src/execution.rs` acquires/releases the kernel PTY's + raw-mode lease, which changes ICRNL, canonical mode, echo, signal processing, + and output post-processing together. +- `resize_pty` reads the PTY foreground process group after resizing and mirrors + `SIGWINCH` into every tracked JavaScript/WASM execution in that group, + including nested full-screen and readline children: + + ```rust + vm.kernel.pty_resize( + EXECUTION_DRIVER_NAME, + process.kernel_pid, + writer_fd, + payload.cols, + payload.rows, + )?; + let foreground_pgid = vm.kernel.tcgetpgrp( + EXECUTION_DRIVER_NAME, + process.kernel_pid, + writer_fd, + )?; + for active in vm.active_processes.values() { + dispatch_v8_signal_to_tracked_processes(active, &foreground_pids, libc::SIGWINCH)?; + } + ``` + +- `PtyState` owns a bounded-by-process-count raw-mode lease stack. Only members + of the current foreground process group receive recovery leases. Each lease + has a monotonic generation and the exact prior `Termios`; nested leases unwind + correctly even when owners exit out of order, while a newer `tcsetattr` or + line-discipline mutation invalidates stale recovery. `ActiveProcess` retains + only its lease generation, and child cleanup releases that specific lease. + An ordinary or background child that never acquired raw mode cannot alter the + terminal during reap. + +`SIGWINCH` forwarding is limited to embedded JavaScript and WASM V8 executions; +Python and tool executions retain the kernel resize without receiving an invalid +JavaScript stream event. + +The kernel PTY tests cover nested/out-of-order owners, stale-generation +protection, and background processes without recovery ownership. The isolated +native-sidecar service test asserts that raw mode disables `icrnl` and cooked +mode restores it; the signal suite verifies both root and nested foreground V8 +executions observe a live resize. + +## Native child execution + +### Executable shebang scripts + +**Problem:** JavaScript `child_process` resolution could identify an executable +guest file as WASM and fail before honoring its shebang. This prevented normal +executable shell scripts and `/usr/bin/env` entrypoints from working through the +native sidecar. + +**Code:** `resolve_javascript_child_process_with_shebang` resolves the initial +entrypoint, verifies execute permission, reads a bounded shebang from the guest +VFS, rewrites the command and arguments, and resolves again. It preserves +Linux's single optional interpreter string; `/usr/bin/env -S ...` is parsed as +shell words with quoted and backslash-escaped arguments before the real +interpreter is resolved. Plain `/usr/bin/env command` is also supported, while +multiple env arguments without `-S` fail explicitly. The resolver caps the +line at 256 bytes and allows at most four redirects. Guest VFS and metadata +failures propagate rather than being silently treated as non-shebang files. +Invalid input returns explicit `EACCES`, `ENOEXEC`, `ENOENT`, or `ELOOP` errors. + +```rust +interpreter_args.push(script_path); +interpreter_args.extend(resolved.execution_args.iter().cloned()); +request.command = command; +request.args = interpreter_args; +request.options.shell = false; +``` + +### Shared-PTY write ordering + +**Problem:** a child sharing its shell's PTY could have its write acknowledged +before the owner queued the drained terminal bytes. The parent could then render +its next prompt before the child output. + +**Code:** `service_shared_tty_stdio_write` now drains the PTY master and queues +the owner's `ActiveExecutionEvent::Stdout` before acknowledging the write. A +missing owner is an explicit `InvalidState` error rather than silently dropping +output. + +## Native V8 bridge compatibility + +These files are the source for the native bridge generated by +`packages/build-tools/scripts/build-v8-bridge.mjs`; they are not browser-runtime +copies. + +- `builtin-modules.ts` makes `path.posix` reference the selected AgentOS Linux + path module rather than a stale uncloned namespace. +- `console.ts` installs `util.formatWithOptions` before formatting console + arguments, preserving Node-style placeholders and inspection behavior. +- `tty-config.ts` no longer permanently caches the 80x24 non-TTY bootstrap + fallback before the kernel synchronous bridge has attached. It caches stable + TTY identity but reads the current kernel window size for every dimensions + query, so `process.stdout.columns` and `.rows` change after `SIGWINCH`. The + added `tty-config.test.mjs` verifies both bootstrap recovery and live resize. + +The browser-only `__agentOSBuiltinWasiModule` injection from the source PR is +intentionally not included. + +The browser-driven `wasi-module.js` timestamp hunk is also excluded. Native +command execution already supplies `path_filestat_set_times` in +`wasm-runner.mjs`; porting the alternate Node `wasi` implementation without a +separate native contract test would broaden this fix PR rather than repair the +observed native path. Direct `process.uid`/`gid` properties are excluded because +host Node exposes identity through getter methods instead. + +## Native software staging and builds + +### On-demand coreutils artifacts + +**Problem:** `registry/software/coreutils/bin/` contained 113 committed WASM +artifacts even though their patched native sources are authoritative. A fresh +source build was not sufficient to replace them: the bulk Rust build excluded +the `_stubs` command required by the package manifest, the C dependency graph +referenced archive members before their fetch targets existed, and the normal +source-only package build could not distinguish itself from a runtime build. + +**Code:** the committed artifacts are removed and the coreutils `bin/` directory +is gitignored. The native Makefile maps the `_stubs` binary to its actual +`cmd-stubs` Cargo package and includes it in the default build. The C Makefile +declares zlib and minizip archive members as outputs of their representative +fetch targets, making a clean dependency graph buildable. Coreutils adds a +strict `build:runtime` script that fails unless all 113 manifest commands, +aliases, and stubs were built; the ordinary `build` script retains placeholder +behavior for repository-wide source checks. `CLAUDE.md` and +`registry/README.md` document the complete runtime sequence: + +```bash +pnpm install --frozen-lockfile +just registry-native +pnpm --filter @agentos-software/coreutils build:runtime +``` + +### Brush child PID + +**Problem:** Brush's WASI process shim returned `None` from `Child::id`, producing +`WARN could not retrieve pid for child process` for successfully spawned +commands. + +**Code:** `registry/native/patches/crates/brush-core/0004-wasi-child-pid.patch` +returns the PID supplied by the patched WASI process implementation: + +```rust +pub fn id(&self) -> Option { + Some(self.inner.id()) +} +``` + +Coreutils command artifacts are not committed. `just registry-native` applies +this patch while compiling the complete command set, and +`pnpm --filter @agentos-software/coreutils build:runtime` stages that output. +The runtime build is strict so an absent or incomplete native build cannot +produce a misleading placeholder package; see `registry/README.md`. + +### Vim WASI cross-build + +**Problem:** Vim configuration could detect host Wayland headers while targeting +WASI and then fail or produce a host-contaminated build. + +**Code:** the Vim configure invocation in `registry/native/c/Makefile` includes +`--without-wayland` alongside the existing GUI and X exclusions. + +### Pi package dependency + +**Problem:** native Pi snapshot and fallback code import +`@mariozechner/pi-agent-core` directly, but the package relied on it arriving +transitively through `pi-coding-agent`. + +**Code:** `registry/agent/pi/package.json` now declares the matching `0.60.0` +dependency directly, with the corresponding importer entry in `pnpm-lock.yaml`. +The browser-only `__piSdkModules` adapter fallback remains excluded. + +## Extraction boundary + +No files under `packages/runtime-browser`, `packages/browser`, +`examples/browser-terminal`, or `examples/experiments/browser-base-shell` are +part of this port. The browser WASI polyfill mirrors the native runner but is +left in the browser PR. Pi's `__piSdkModules` fallback is also excluded because +native Pi resolves its projected package graph normally. + +## Validation + +The extraction was validated from a fresh JJ workspace based on `main`: + +- `cargo fmt --check` +- targeted `cargo check` for kernel, client, execution, native sidecar, and + actor plugin crates +- kernel PTY suite: 24 passed +- native raw-mode service regression: passed +- native path, identity, console, and executable-shebang integration suite: + passed +- Rust client shell E2E: passed +- actor plugin unit tests: 5 passed; targeted event contract test: passed +- build-tools TTY regression: passed +- clean native registry build: all 113 coreutils manifest entries present +- strict coreutils staging and `.aospkg` assembly: passed +- native Pi package build: passed +- Brush interactive PTY suite: 3 passed +- V8 PTY line-discipline matrix: 20 passed, including live `SIGWINCH` resize +- repository-wide `pnpm check-types`: 147 tasks passed +- fixed-version verification: passed + +The complete actor plugin test command also reaches an unrelated existing +failure: `ts_dto_field_names_match_rust_contract_fixture` expects a +`VirtualStat` interface shape that already differs in unchanged `main` files. +Neither the fixture nor `packages/core/src/runtime.ts` is touched by this port; +the actor unit and event-contract tests that cover the changed shell transport +pass. diff --git a/examples/browser-terminal/README.md b/examples/browser-terminal/README.md index eef3e036e0..e778c01d41 100644 --- a/examples/browser-terminal/README.md +++ b/examples/browser-terminal/README.md @@ -24,17 +24,18 @@ Browser (React + xterm.js) Node (server.ts) ├─ useActor({ name:"shellVm", key }) ├─ agentOS({ software:[…] }) ├─ openShell / writeShell / resize ──────▶│ setup({ use:{ shellVm } }) ├─ closeShell │ registry.start() - └─ conn.on("shellData"|"shellStderr"| │ - "shellExit") ◀─────────────────────┘ openShell ─▶ broadcast shellData/… + └─ conn.on("shellData"|"shellExit") ◀────┘ openShell ─▶ broadcast events ``` The browser opens a shell with `openShell`, sends keystrokes with `writeShell`, -and renders output delivered as `shellData` / `shellStderr` broadcast **events** -(routed to the right tab by `shellId`, with a small buffer for output that arrives -before a tab subscribes). This mirrors the actor terminal in -`packages/shell/src/actor-vm.ts`. The VM and its shells live inside the actor's -Rust plugin, so there is no server-side terminal code here — `registry.start()` -hosts the actor and the browser talks to it directly. +and renders the ordered stdout/stderr bytes delivered by the `shellData` broadcast +**event** (routed to the right tab by `shellId`, with a small buffer for output +that arrives before a tab subscribes). `shellStderr` remains available as an +optional diagnostic tap and must not be rendered alongside `shellData`. This +mirrors the actor terminal in `packages/shell/src/actor-vm.ts`. The VM and its +shells live inside the actor's Rust plugin, so there is no server-side terminal +code here — `registry.start()` hosts the actor and the browser talks to it +directly. ## Run diff --git a/examples/browser-terminal/src/ActorView.tsx b/examples/browser-terminal/src/ActorView.tsx index 155a39c637..44c9228849 100644 --- a/examples/browser-terminal/src/ActorView.tsx +++ b/examples/browser-terminal/src/ActorView.tsx @@ -95,15 +95,11 @@ export function ActorView({ actorId }: { actorId: string }) { const offData = events.on("shellData", (p: ShellDataPayload) => dispatchData(p.shellId, toBytes(p.data)), ); - const offStderr = events.on("shellStderr", (p: ShellDataPayload) => - dispatchData(p.shellId, toBytes(p.data)), - ); const offExit = events.on("shellExit", (p: ShellExitPayload) => dropTab(p.shellId), ); return () => { offData(); - offStderr(); offExit(); }; }, [conn, dispatchData, dropTab]); diff --git a/packages/agentos/src/types.ts b/packages/agentos/src/types.ts index eb5c033e2a..2183b9f5d5 100644 --- a/packages/agentos/src/types.ts +++ b/packages/agentos/src/types.ts @@ -88,8 +88,9 @@ export interface AgentOsEvents { vmShutdown: VmShutdownPayload; processOutput: ProcessOutputPayload; processExit: ProcessExitPayload; + /** Ordered PTY output containing stdout and stderr exactly once. */ shellData: ShellDataPayload; - /** Shell stderr (the dedicated channel backing the TS `onStderr` option). */ + /** Optional stderr-only diagnostic tap; do not render it with `shellData`. */ shellStderr: ShellDataPayload; /** Shell process exit (mirrors `waitShell` resolution). */ shellExit: ShellExitPayload; diff --git a/packages/browser/scripts/build-wasm-test-assets.mjs b/packages/browser/scripts/build-wasm-test-assets.mjs index 203e385a5e..429b34d591 100644 --- a/packages/browser/scripts/build-wasm-test-assets.mjs +++ b/packages/browser/scripts/build-wasm-test-assets.mjs @@ -205,8 +205,9 @@ if (existsSync(piAdapterDist) && existsSync(piAgentCore)) { "--external:node:*", `--outfile=${path.join(browserTestsDir, "pi-adapter.bundle.js")}`, ]); - // (b) The M4b full-SDK bundle: the browser entry (adapter + SDK submodules statically - // inlined via the __piSdkModules override) as ONE self-contained .cjs. The `.cjs` + // (b) The M4b full-SDK bundle: the browser entry (adapter + SDK submodules + // statically published through the native __PI_SDK_RUNTIME__ contract) as ONE + // self-contained .cjs. The `.cjs` // extension tells the executor it is CommonJS so it does NOT apply the ESM import // transform (which trips on the bundle's dynamic import()). The SDK still needs a few // more node-builtin shims before it reaches session/prompt; tracked as M4b/pi-4. diff --git a/packages/browser/tests/browser-wasm/real-terminal.entry.ts b/packages/browser/tests/browser-wasm/real-terminal.entry.ts index 50f36edd53..3ae07f0694 100644 --- a/packages/browser/tests/browser-wasm/real-terminal.entry.ts +++ b/packages/browser/tests/browser-wasm/real-terminal.entry.ts @@ -32,7 +32,7 @@ const SHELL_COMMANDS = [ "readlink", "realpath", "basename", "dirname", "du", "df", "date", "seq", "sleep", "true", "false", "yes", "test", "expr", "tee", "xargs", "which", "whoami", "id", "uname", "hostname", "tree", "diff", "od", "base64", "md5sum", - "sha256sum", "cksum", "rg", "fd", "jq", "yq", "git", "tar", "gzip", "gunzip", + "sha256sum", "cksum", "rg", "fd", "jq", "yq", "tar", "gzip", "gunzip", ]; // proc_spawn resolves a command by basename (/usr/bin/ls -> the "ls" module), but diff --git a/packages/build-tools/bridge-src/builtins/builtin-modules.ts b/packages/build-tools/bridge-src/builtins/builtin-modules.ts index 54413f8402..fd1abbc98e 100644 --- a/packages/build-tools/bridge-src/builtins/builtin-modules.ts +++ b/packages/build-tools/bridge-src/builtins/builtin-modules.ts @@ -154,11 +154,10 @@ function ensureBuiltinEventsStdlibModule() { return builtinEventsStdlibModule; } var builtinPathStdlibModule = cloneStdlibModule(pathStdlibModuleNs); -if (!builtinPathStdlibModule?.posix) { - builtinPathStdlibModule.posix = cloneStdlibModule( - pathStdlibModuleNs?.posix ?? pathStdlibModuleNs?.default?.posix - ) ?? builtinPathStdlibModule; -} +// AgentOS targets Linux. Node exposes the selected platform implementation as +// both `path` and `path.posix`; cloning the stdlib namespace would otherwise +// leave `posix` pointing at the uncloned source object. +builtinPathStdlibModule.posix = builtinPathStdlibModule; if (!builtinPathStdlibModule?.win32) { builtinPathStdlibModule.win32 = cloneStdlibModule( pathStdlibModuleNs?.win32 ?? pathStdlibModuleNs?.default?.win32 diff --git a/packages/build-tools/bridge-src/builtins/console.ts b/packages/build-tools/bridge-src/builtins/console.ts index 67eb805b6f..155119e8c8 100644 --- a/packages/build-tools/bridge-src/builtins/console.ts +++ b/packages/build-tools/bridge-src/builtins/console.ts @@ -161,6 +161,9 @@ function formatConsoleValue(value) { } function formatConsoleArgs(args) { + const builtinUtilModule = installBuiltinUtilFormatWithOptions( + globalThis.__secureExecBuiltinUtilModule + ); if (typeof builtinUtilModule !== "undefined" && typeof builtinUtilModule?.formatWithOptions === "function") { return builtinUtilModule.formatWithOptions({ colors: false }, ...args); } diff --git a/packages/build-tools/bridge-src/builtins/tty-config.ts b/packages/build-tools/bridge-src/builtins/tty-config.ts index 856af2171b..0832e0c0aa 100644 --- a/packages/build-tools/bridge-src/builtins/tty-config.ts +++ b/packages/build-tools/bridge-src/builtins/tty-config.ts @@ -6,7 +6,7 @@ const DEFAULT_RUNTIME_TTY_CONFIG = { rows: 24 }; -var _cachedRuntimeTtyConfig; +var _cachedRuntimeTtyConfig = null; function _kernelIsatty(fd) { if (typeof _kernelIsattyRaw === "undefined") { @@ -27,34 +27,39 @@ function _kernelTtySize(fd) { } function _resolveRuntimeTtyConfig() { - if (_cachedRuntimeTtyConfig) { - return _cachedRuntimeTtyConfig; - } if (typeof __runtimeTtyConfig !== "undefined") { _cachedRuntimeTtyConfig = __runtimeTtyConfig; return _cachedRuntimeTtyConfig; } - try { - _cachedRuntimeTtyConfig = { - stdinIsTTY: _kernelIsatty(0), - stdoutIsTTY: _kernelIsatty(1), - stderrIsTTY: _kernelIsatty(2), - cols: 80, - rows: 24 - }; - } catch { - _cachedRuntimeTtyConfig = DEFAULT_RUNTIME_TTY_CONFIG; - return _cachedRuntimeTtyConfig; - } - for (const fd of [1, 0]) { + if (!_cachedRuntimeTtyConfig) { try { - const size = _kernelTtySize(fd); - _cachedRuntimeTtyConfig.cols = size.cols; - _cachedRuntimeTtyConfig.rows = size.rows; - break; + _cachedRuntimeTtyConfig = { + stdinIsTTY: _kernelIsatty(0), + stdoutIsTTY: _kernelIsatty(1), + stderrIsTTY: _kernelIsatty(2), + cols: 80, + rows: 24 + }; } catch { + // Snapshot/bootstrap evaluation can touch process stdio before the kernel + // sync bridge is attached. Return the safe default for that early read, but + // do not cache it: the execution must retry once the bridge is available. + return DEFAULT_RUNTIME_TTY_CONFIG; } } + + // TTY identity is stable for an execution, but its window size is not. Query + // the live kernel dimensions so SIGWINCH observers see the resized PTY. + const sizeFd = _cachedRuntimeTtyConfig.stdoutIsTTY + ? 1 + : _cachedRuntimeTtyConfig.stdinIsTTY + ? 0 + : undefined; + if (sizeFd !== undefined) { + const size = _kernelTtySize(sizeFd); + _cachedRuntimeTtyConfig.cols = size.cols; + _cachedRuntimeTtyConfig.rows = size.rows; + } return _cachedRuntimeTtyConfig; } diff --git a/packages/build-tools/package.json b/packages/build-tools/package.json index f5eefdb7d1..4a3ed00ca9 100644 --- a/packages/build-tools/package.json +++ b/packages/build-tools/package.json @@ -19,7 +19,7 @@ "check:generated": "node ../../scripts/check-generated-artifacts.mjs", "check-types": "node --check ./scripts/build-base-filesystem.mjs && node --check ./scripts/build-browser-buffer-polyfill.mjs && node --check ./scripts/build-browser-path-polyfill.mjs && node --check ./scripts/build-browser-util-polyfill.mjs && node --check ./scripts/build-v8-bridge.mjs && node --check ./scripts/compile-sidecar-protocol.mjs", "build": "pnpm run check-types", - "test": "pnpm run check-types", + "test": "pnpm run check-types && node --test ./scripts/tty-config.test.mjs", "build:package-format": "node ./scripts/compile-package-format.mjs" }, "dependencies": { diff --git a/packages/build-tools/scripts/tty-config.test.mjs b/packages/build-tools/scripts/tty-config.test.mjs new file mode 100644 index 0000000000..6fa3ca2411 --- /dev/null +++ b/packages/build-tools/scripts/tty-config.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import vm from "node:vm"; +import { build } from "esbuild"; + +test("TTY detection retries after bootstrap and refreshes the window size", async () => { + const result = await build({ + bundle: true, + format: "iife", + stdin: { + contents: ` + import { _resolveRuntimeTtyConfig } from "./bridge-src/builtins/tty-config.ts"; + globalThis.resolveRuntimeTtyConfig = _resolveRuntimeTtyConfig; + `, + resolveDir: new URL("../", import.meta.url).pathname, + }, + write: false, + }); + const context = vm.createContext({}); + vm.runInContext(result.outputFiles[0].text, context); + const resolve = () => + JSON.parse( + vm.runInContext("JSON.stringify(resolveRuntimeTtyConfig())", context), + ); + + assert.deepEqual( + resolve(), + { + stdinIsTTY: false, + stdoutIsTTY: false, + stderrIsTTY: false, + cols: 80, + rows: 24, + }, + ); + + context._kernelIsattyRaw = { + applySync(_receiver, [fd]) { + return fd === 0 || fd === 1 || fd === 2; + }, + }; + let cols = 100; + let rows = 32; + context._kernelTtySizeRaw = { + applySync() { + return { cols, rows }; + }, + }; + + assert.deepEqual( + resolve(), + { + stdinIsTTY: true, + stdoutIsTTY: true, + stderrIsTTY: true, + cols: 100, + rows: 32, + }, + ); + + cols = 120; + rows = 40; + assert.deepEqual( + resolve(), + { + stdinIsTTY: true, + stdoutIsTTY: true, + stderrIsTTY: true, + cols: 120, + rows: 40, + }, + ); +}); diff --git a/packages/core/README.md b/packages/core/README.md index fe058db5a4..8500338ddb 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -104,7 +104,7 @@ await vm.dispose(); | `connectTerminal` | `connectTerminal(options?: ConnectTerminalOptions): Promise` | Attach a shell directly to the host terminal and wait for exit | | `openShell` | `openShell(options?: OpenShellOptions): { shellId: string }` | Open an interactive shell with PTY support | | `writeShell` | `writeShell(shellId: string, data: string \| Uint8Array): void` | Write data to a shell's PTY input | -| `onShellData` | `onShellData(shellId: string, handler: (data: Uint8Array) => void): () => void` | Subscribe to shell output data | +| `onShellData` | `onShellData(shellId: string, handler: (data: Uint8Array) => void): () => void` | Subscribe to ordered PTY output (stdout and stderr exactly once) | | `resizeShell` | `resizeShell(shellId: string, cols: number, rows: number): void` | Notify terminal resize | | `closeShell` | `closeShell(shellId: string): void` | Kill the shell process | diff --git a/packages/core/src/agent-os.ts b/packages/core/src/agent-os.ts index 7c8807fe14..49895aa30a 100644 --- a/packages/core/src/agent-os.ts +++ b/packages/core/src/agent-os.ts @@ -3358,7 +3358,11 @@ export class AgentOs { return entry.handle.write(data); } - /** Subscribe to data output from a shell. Returns an unsubscribe function. */ + /** + * Subscribe to ordered PTY output (stdout and stderr). Returns an unsubscribe + * function. `OpenShellOptions.onStderr` is a diagnostic tap for callers that + * need channel identity; do not render both surfaces. + */ onShellData( shellId: string, handler: (data: Uint8Array) => void, @@ -4920,9 +4924,6 @@ export class AgentOs { ...(cwd ? { cwd } : {}), ...(cols !== undefined ? { cols: Math.trunc(cols) } : {}), ...(rows !== undefined ? { rows: Math.trunc(rows) } : {}), - onStderr: (data) => { - this._appendAcpTerminalOutput(terminal, data); - }, }), output: "", truncated: false, diff --git a/packages/core/src/runtime-compat.ts b/packages/core/src/runtime-compat.ts index 7dd2dd45f1..dc446a1196 100644 --- a/packages/core/src/runtime-compat.ts +++ b/packages/core/src/runtime-compat.ts @@ -219,6 +219,7 @@ export interface ManagedProcess { export interface ShellHandle { pid: number; write(data: Uint8Array | string): Promise; + /** Ordered PTY output containing stdout and stderr exactly once. */ onData: ((data: Uint8Array) => void) | null; resize(cols: number, rows: number): void; kill(signal?: number): void; @@ -232,6 +233,7 @@ export interface OpenShellOptions { cwd?: string; cols?: number; rows?: number; + /** Optional stderr-only diagnostic tap; do not render it alongside `onData`. */ onStderr?: (data: Uint8Array) => void; } diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 8f3f065488..94237fe896 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -124,6 +124,7 @@ export interface ManagedProcess { export interface ShellHandle { pid: number; write(data: Uint8Array | string): Promise; + /** Ordered PTY output containing stdout and stderr exactly once. */ onData: ((data: Uint8Array) => void) | null; resize(cols: number, rows: number): void; kill(signal?: number): void; @@ -137,6 +138,7 @@ export interface OpenShellOptions { cwd?: string; cols?: number; rows?: number; + /** Optional stderr-only diagnostic tap; do not render it alongside `onData`. */ onStderr?: (data: Uint8Array) => void; } diff --git a/packages/core/src/sidecar/rpc-client.ts b/packages/core/src/sidecar/rpc-client.ts index 2ed242f959..843f4eb304 100644 --- a/packages/core/src/sidecar/rpc-client.ts +++ b/packages/core/src/sidecar/rpc-client.ts @@ -851,7 +851,7 @@ export class NativeSidecarKernelProxy { } openShell(options?: OpenShellOptions): ShellHandle { - const stdoutHandlers = new Set<(data: Uint8Array) => void>(); + const terminalHandlers = new Set<(data: Uint8Array) => void>(); const stderrHandlers = new Set<(data: Uint8Array) => void>(); const command = options?.command ?? "sh"; const args = @@ -912,7 +912,7 @@ export class NativeSidecarKernelProxy { const normalized = normalizeSyntheticTerminalText(text); updateSyntheticCursor(normalized); const chunk = textEncoder.encode(normalized); - for (const handler of stdoutHandlers) { + for (const handler of terminalHandlers) { handler(chunk); } }; @@ -923,7 +923,7 @@ export class NativeSidecarKernelProxy { const normalized = normalizeSyntheticTerminalText(text); updateSyntheticCursor(normalized); const chunk = textEncoder.encode(normalized); - for (const handler of stdoutHandlers) { + for (const handler of terminalHandlers) { handler(chunk); } }; @@ -968,7 +968,7 @@ export class NativeSidecarKernelProxy { commandInFlight = false; const promptPrefix = syntheticCursorAtLineStart ? "" : "\r\n"; const promptChunk = textEncoder.encode(`${promptPrefix}${promptText}`); - for (const handler of stdoutHandlers) { + for (const handler of terminalHandlers) { handler(promptChunk); } syntheticCursorAtLineStart = false; @@ -1031,7 +1031,7 @@ export class NativeSidecarKernelProxy { }; let onData: ((data: Uint8Array) => void) | null = null; - stdoutHandlers.add((data) => onData?.(data)); + terminalHandlers.add((data) => onData?.(data)); if (options?.onStderr) { stderrHandlers.add(options.onStderr); } @@ -1227,7 +1227,7 @@ export class NativeSidecarKernelProxy { if (!sanitized) { return; } - for (const handler of stdoutHandlers) { + for (const handler of terminalHandlers) { handler(sanitized); } if (commandInFlight) { @@ -1239,6 +1239,11 @@ export class NativeSidecarKernelProxy { if (!sanitized) { return; } + // `onData` is the ordered PTY rendering stream. `onStderr` remains an + // optional channel-specific diagnostic tap and must not also be rendered. + for (const handler of terminalHandlers) { + handler(sanitized); + } for (const handler of stderrHandlers) { handler(sanitized); } @@ -1304,14 +1309,7 @@ export class NativeSidecarKernelProxy { const stdin = process.stdin; const stdout = process.stdout; const { onData, ...shellOptions } = options ?? {}; - const shell = this.openShell({ - ...shellOptions, - onStderr: - shellOptions.onStderr ?? - ((data) => { - process.stderr.write(data); - }), - }); + const shell = this.openShell(shellOptions); const outputHandler = onData ?? ((data: Uint8Array) => { diff --git a/packages/core/src/test/terminal-harness.ts b/packages/core/src/test/terminal-harness.ts index 1e8515b5a4..14bad2a1d8 100644 --- a/packages/core/src/test/terminal-harness.ts +++ b/packages/core/src/test/terminal-harness.ts @@ -36,9 +36,6 @@ export class TerminalHarness { rows, env: options?.env, cwd: options?.cwd, - onStderr: (data: Uint8Array) => { - this.term.write(data); - }, }); this.shell.onData = (data: Uint8Array) => { this.term.write(data); diff --git a/packages/core/tests/__snapshots__/pty-line-discipline.test.ts.snap b/packages/core/tests/__snapshots__/pty-line-discipline.test.ts.snap index 04aa382937..24326918d6 100644 --- a/packages/core/tests/__snapshots__/pty-line-discipline.test.ts.snap +++ b/packages/core/tests/__snapshots__/pty-line-discipline.test.ts.snap @@ -638,6 +638,51 @@ cols=80 rows=24 cursor=0,5 24|" `; +exports[`PTY line discipline matrix > js-node > resize-sigwinch > js-node/resize-sigwinch/resize 1`] = ` +"# js-node/resize-sigwinch/resize +cols=120 rows=40 cursor=0,7 +01|#START id=resize-sigwinch +02|#SIZE tag=before rc=0 cols=80 rows=24 +03|#READY tag=resize +04|! +05|#SIG name=SIGWINCH +06|#SIZE tag=after rc=0 cols=120 rows=40 +07|#DONE id=resize-sigwinch +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 > js-node > sigint > js-node/sigint/after 1`] = ` "# js-node/sigint/after cols=80 rows=24 cursor=0,3 diff --git a/packages/core/tests/brush-interactive.test.ts b/packages/core/tests/brush-interactive.test.ts index 07317e374c..4ca64ef5bd 100644 --- a/packages/core/tests/brush-interactive.test.ts +++ b/packages/core/tests/brush-interactive.test.ts @@ -37,7 +37,6 @@ const SIDECAR_BINARY = process.env.AGENTOS_SIDECAR_BIN ?? resolve(REPO_ROOT, "target/debug/agentos-sidecar"); const REGISTRY_SH_CANDIDATES = [ - "packages/runtime-core/commands/sh", "registry/native/target/wasm32-wasip1/release/commands/sh", ].map((candidate) => resolve(REPO_ROOT, candidate)); const REGISTRY_SH = REGISTRY_SH_CANDIDATES.find((candidate) => @@ -59,10 +58,18 @@ function snapshot(label: string, term: Terminal): string { `${String(row + 1).padStart(2, "0")}|${line ? line.translateToString(true).replace(/\s+$/, "") : ""}`, ); } - return [`# ${label}`, `cursor=${buffer.cursorX},${buffer.cursorY}`, ...lines].join("\n"); + return [ + `# ${label}`, + `cursor=${buffer.cursorX},${buffer.cursorY}`, + ...lines, + ].join("\n"); } -async function waitFor(term: Terminal, text: string, timeoutMs = 20000): Promise { +async function waitFor( + term: Terminal, + text: string, + timeoutMs = 20000, +): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (snapshot("w", term).includes(text)) { @@ -71,143 +78,240 @@ async function waitFor(term: Terminal, text: string, timeoutMs = 20000): Promise } await new Promise((r) => setTimeout(r, 25)); } - throw new Error(`timeout waiting for ${JSON.stringify(text)}\n${snapshot("timeout", term)}`); + throw new Error( + `timeout waiting for ${JSON.stringify(text)}\n${snapshot("timeout", term)}`, + ); } -// Requires the vendored or locally-built `sh` wasm command. Skip when the -// artifact is absent rather than failing suites that do not build WASM commands. -describe.skipIf(REGISTRY_SH === undefined)("brush interactive PTY repaint", () => { - let vm: AgentOs | undefined; - let term: Terminal | undefined; - let shellId: string | undefined; - - beforeAll(() => { - // Materialize a self-contained `{ packageDir }` fixture: bin/ plus - // the agentos-package.json manifest the sidecar projection requires. - fixtureDir = mkdtempSync(join(tmpdir(), "brush-fixture-")); - const binDir = join(fixtureDir, "bin"); - mkdirSync(binDir, { recursive: true }); - copyFileSync(REGISTRY_SH as string, join(binDir, FIXTURE_COMMAND)); - // A real external command (spawned as a CHILD of the shell) for the - // child-output regression below; a unique name avoids /bin/cat. - if (REGISTRY_CAT !== undefined) { - copyFileSync(REGISTRY_CAT, join(binDir, "childcat")); - } - writeFileSync( - join(fixtureDir, "package.json"), - JSON.stringify({ name: "brush-fixture", version: "0.0.0" }), - ); - writeFileSync( - join(fixtureDir, "agentos-package.json"), - JSON.stringify({ name: "brush-fixture", version: "1.0.0" }), - ); - process.env.AGENTOS_SIDECAR_BIN = SIDECAR_BINARY; - }); - - afterEach(async () => { - if (vm && shellId) { - try { - vm.closeShell(shellId); - } catch { - // already exited +// Requires a freshly built registry `sh` command (`just registry-native-cmd sh`). +// Skip when the artifact is absent rather than testing a stale copied binary. +describe.skipIf(REGISTRY_SH === undefined)( + "brush interactive PTY repaint", + () => { + let vm: AgentOs | undefined; + let term: Terminal | undefined; + let shellId: string | undefined; + + beforeAll(() => { + // Materialize a self-contained `{ packageDir }` fixture: bin/ plus + // the agentos-package.json manifest the sidecar projection requires. + fixtureDir = mkdtempSync(join(tmpdir(), "brush-fixture-")); + const binDir = join(fixtureDir, "bin"); + mkdirSync(binDir, { recursive: true }); + copyFileSync(REGISTRY_SH as string, join(binDir, FIXTURE_COMMAND)); + // A real external command (spawned as a CHILD of the shell) for the + // child-output regression below; a unique name avoids /bin/cat. + if (REGISTRY_CAT !== undefined) { + copyFileSync(REGISTRY_CAT, join(binDir, "childcat")); } - } - term?.dispose(); - if (vm) await vm.dispose(); - vm = term = shellId = undefined; - }); - - test("Enter preserves scrollback; history and word-edit work", async () => { - const { AgentOs } = await import("../src/index.js"); - term = new Terminal({ cols: 80, rows: 14, allowProposedApi: true }); - vm = await AgentOs.create({ - software: [{ packagePath: fixtureDir }], + writeFileSync( + join(fixtureDir, "package.json"), + JSON.stringify({ name: "brush-fixture", version: "0.0.0" }), + ); + writeFileSync( + join(fixtureDir, "agentos-package.json"), + JSON.stringify({ name: "brush-fixture", version: "1.0.0" }), + ); + process.env.AGENTOS_SIDECAR_BIN = SIDECAR_BINARY; }); - ({ shellId } = vm.openShell({ - command: FIXTURE_COMMAND, - args: ["--input-backend", "reedline", "-i"], - cols: term.cols, - rows: term.rows, - env: { - TERM: "xterm-256color", - PS1: "AOS$ ", - COLUMNS: "80", - LINES: "14", - }, - // A real PTY merges stdout+stderr; brush paints its prompt on stderr. - onStderr: (d: Uint8Array) => term?.write(d), - })); - vm.onShellData(shellId, (d) => term?.write(d)); - const t = term; - const s = shellId; - const v = vm; - // Forwarding xterm's responses back makes it answer DSR (`ESC[6n`) queries. - t.onData((d) => v.writeShell(s, d)); - - await waitFor(t, "AOS$"); - expect(snapshot("startup prompt", t)).toMatchSnapshot(); - - // Run three commands. Each output must remain on screen after Enter. - for (const word of ["alpha", "bravo", "charlie"]) { - v.writeShell(s, `echo ${word}\r`); - await waitFor(t, word); - } - expect(snapshot("after three commands (scrollback intact)", t)).toMatchSnapshot(); - - // Up-arrow recalls the last command ("echo charlie"). - v.writeShell(s, "\x1b[A"); - await new Promise((r) => setTimeout(r, 300)); - expect(snapshot("after up-arrow recall", t)).toMatchSnapshot(); - - // Ctrl-W deletes the recalled word ("charlie"), then type a new one and run it. - v.writeShell(s, "\x17delta\r"); - // Wait for the new command's output line, then settle. - await waitFor(t, "echo delta"); - await new Promise((r) => setTimeout(r, 400)); - expect(snapshot("after ctrl-w edit + enter", t)).toMatchSnapshot(); - }, 60000); - - // Regression: output from an EXTERNAL command (a child process sharing the - // shell's terminal) must reach the host exactly once. It used to arrive - // twice — once relayed by the shell's runner from the child's stdout - // events, and once via the PTY master drain of the same bytes — doubling - // every child's output (`cat` lines printed twice, vim keystroke echo - // corrupting the screen). - test.skipIf(REGISTRY_CAT === undefined)("external child command output renders exactly once", async () => { - const { AgentOs } = await import("../src/index.js"); - term = new Terminal({ cols: 80, rows: 14, allowProposedApi: true }); - vm = await AgentOs.create({ - software: [{ packagePath: fixtureDir }], + afterEach(async () => { + if (vm && shellId) { + try { + vm.closeShell(shellId); + } catch { + // already exited + } + } + term?.dispose(); + if (vm) await vm.dispose(); + vm = term = shellId = undefined; }); - await vm.writeFile("/tmp/marker.txt", "child-once-marker\n"); - - ({ shellId } = vm.openShell({ - command: FIXTURE_COMMAND, - args: ["--input-backend", "reedline", "-i"], - cols: term.cols, - rows: term.rows, - env: { - TERM: "xterm-256color", - PS1: "AOS$ ", - COLUMNS: "80", - LINES: "14", + + test("Enter preserves scrollback; history and word-edit work", async () => { + const { AgentOs } = await import("../src/index.js"); + term = new Terminal({ cols: 80, rows: 14, allowProposedApi: true }); + vm = await AgentOs.create({ + software: [{ packagePath: fixtureDir }], + }); + + ({ shellId } = vm.openShell({ + command: FIXTURE_COMMAND, + args: ["--input-backend", "reedline", "-i"], + cols: term.cols, + rows: term.rows, + env: { + TERM: "xterm-256color", + PS1: "AOS$ ", + COLUMNS: "80", + LINES: "14", + }, + })); + vm.onShellData(shellId, (d) => term?.write(d)); + const t = term; + const s = shellId; + const v = vm; + // Forwarding xterm's responses back makes it answer DSR (`ESC[6n`) queries. + t.onData((d) => v.writeShell(s, d)); + + await waitFor(t, "AOS$"); + expect(snapshot("startup prompt", t)).toMatchSnapshot(); + + // Run three commands. Each output must remain on screen after Enter. + for (const word of ["alpha", "bravo", "charlie"]) { + v.writeShell(s, `echo ${word}\r`); + await waitFor(t, word); + } + expect( + snapshot("after three commands (scrollback intact)", t), + ).toMatchSnapshot(); + + // Up-arrow recalls the last command ("echo charlie"). + v.writeShell(s, "\x1b[A"); + await new Promise((r) => setTimeout(r, 300)); + expect(snapshot("after up-arrow recall", t)).toMatchSnapshot(); + + // Ctrl-W deletes the recalled word ("charlie"), then type a new one and run it. + v.writeShell(s, "\x17delta\r"); + // Wait for the new command's output line, then settle. + await waitFor(t, "echo delta"); + await new Promise((r) => setTimeout(r, 400)); + expect(snapshot("after ctrl-w edit + enter", t)).toMatchSnapshot(); + }, 60000); + + test("restores cooked terminal state after a raw child exits", async () => { + const { AgentOs } = await import("../src/index.js"); + term = new Terminal({ cols: 80, rows: 14, allowProposedApi: true }); + vm = await AgentOs.create({ + software: [{ packagePath: fixtureDir }], + }); + await vm.writeFile( + "/tmp/raw-child.mjs", + "process.stdin.setRawMode(true); process.stdout.write('raw-child-exited\\n');\n", + ); + await vm.writeFile( + "/tmp/redirected-stdin-parent.mjs", + [ + 'import { spawnSync } from "node:child_process";', + "const ignored = spawnSync('node', ['-e', `process.stdout.write('ignored-stdin-child\\\\n')`], { stdio: ['ignore', 'inherit', 'inherit'] });", + "process.stdout.write('ignored-stdin-status:' + ignored.status + ' error:' + (ignored.error?.code ?? 'none') + '\\n');", + "const piped = spawnSync('node', ['-e', `process.stdout.write('piped-stdin-child\\\\n')`], { input: '', stdio: ['pipe', 'inherit', 'inherit'] });", + "process.stdout.write('piped-stdin-status:' + piped.status + ' error:' + (piped.error?.code ?? 'none') + '\\n');", + "process.exit(ignored.status || piped.status || 0);", + ].join("\n"), + ); + await vm.writeFile( + "/tmp/cooked-check.mjs", + "process.stdout.write('cooked-output-after-raw-child\\n');\n", + ); + + ({ shellId } = vm.openShell({ + command: FIXTURE_COMMAND, + args: ["--input-backend", "minimal", "-i"], + cols: term.cols, + rows: term.rows, + env: { + TERM: "xterm-256color", + PS1: "AOS$ ", + }, + })); + vm.onShellData(shellId, (d) => term?.write(d)); + const t = term; + const s = shellId; + const v = vm; + + await waitFor(t, "AOS$"); + const promptsBeforeRedirectedStdin = + snapshot("before redirected stdin", t).split("AOS$").length - 1; + v.writeShell(s, "node /tmp/redirected-stdin-parent.mjs\r"); + await waitFor(t, "ignored-stdin-status:0 error:none"); + await waitFor(t, "piped-stdin-status:0 error:none"); + const redirectedPromptDeadline = Date.now() + 20_000; + while ( + Date.now() < redirectedPromptDeadline && + snapshot("wait redirected", t).split("AOS$").length - 1 <= + promptsBeforeRedirectedStdin + ) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + const promptsBeforeRaw = + snapshot("before raw child", t).split("AOS$").length - 1; + expect(promptsBeforeRaw).toBeGreaterThan(promptsBeforeRedirectedStdin); + v.writeShell(s, "node /tmp/raw-child.mjs\r"); + await waitFor(t, "raw-child-exited"); + + const promptDeadline = Date.now() + 20_000; + while ( + Date.now() < promptDeadline && + snapshot("wait raw", t).split("AOS$").length - 1 <= promptsBeforeRaw + ) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + const afterRawChild = snapshot("after raw child", t); + expect(afterRawChild.split("AOS$").length - 1).toBeGreaterThan( + promptsBeforeRaw, + ); + expect(afterRawChild).not.toContain( + "could not retrieve pid for child process", + ); + + // A raw child disables ICRNL. If the sidecar does not restore the + // parent's cooked termios, this carriage return never submits the line. + v.writeShell(s, "node /tmp/cooked-check.mjs\r"); + await waitFor(t, "cooked-output-after-raw-child"); + }, 60000); + + // Regression: output from an EXTERNAL command (a child process sharing the + // shell's terminal) must reach the host exactly once. It used to arrive + // twice — once relayed by the shell's runner from the child's stdout + // events, and once via the PTY master drain of the same bytes — doubling + // every child's output (`cat` lines printed twice, vim keystroke echo + // corrupting the screen). + test.skipIf(REGISTRY_CAT === undefined)( + "external child command output renders exactly once", + async () => { + const { AgentOs } = await import("../src/index.js"); + term = new Terminal({ cols: 80, rows: 14, allowProposedApi: true }); + vm = await AgentOs.create({ + software: [{ packagePath: fixtureDir }], + }); + await vm.writeFile("/tmp/marker.txt", "child-once-marker\n"); + + ({ shellId } = vm.openShell({ + command: FIXTURE_COMMAND, + args: ["--input-backend", "reedline", "-i"], + cols: term.cols, + rows: term.rows, + env: { + TERM: "xterm-256color", + PS1: "AOS$ ", + COLUMNS: "80", + LINES: "14", + }, + })); + vm.onShellData(shellId, (d) => term?.write(d)); + const t = term; + const s = shellId; + const v = vm; + t.onData((d) => v.writeShell(s, d)); + + await waitFor(t, "AOS$"); + v.writeShell(s, "childcat /tmp/marker.txt\r"); + await waitFor(t, "child-once-marker"); + await new Promise((r) => setTimeout(r, 500)); + + const rendered = snapshot("child output", t); + const occurrences = rendered.split("child-once-marker").length - 1; + expect(occurrences).toBe(1); + expect(rendered).not.toContain( + "could not retrieve pid for child process", + ); + expect(rendered.lastIndexOf("child-once-marker")).toBeLessThan( + rendered.lastIndexOf("AOS$"), + ); }, - onStderr: (d: Uint8Array) => term?.write(d), - })); - vm.onShellData(shellId, (d) => term?.write(d)); - const t = term; - const s = shellId; - const v = vm; - t.onData((d) => v.writeShell(s, d)); - - await waitFor(t, "AOS$"); - v.writeShell(s, "childcat /tmp/marker.txt\r"); - await waitFor(t, "child-once-marker"); - await new Promise((r) => setTimeout(r, 500)); - - const rendered = snapshot("child output", t); - const occurrences = rendered.split("child-once-marker").length - 1; - expect(occurrences).toBe(1); - }, 60000); -}); + 60000, + ); + }, +); diff --git a/packages/core/tests/native-sidecar-process.test.ts b/packages/core/tests/native-sidecar-process.test.ts index 6639008925..80da7a21d5 100644 --- a/packages/core/tests/native-sidecar-process.test.ts +++ b/packages/core/tests/native-sidecar-process.test.ts @@ -1874,7 +1874,7 @@ describe("native sidecar process client", () => { } }, 60_000); - test("openShell keeps stdout and stderr separate on the native sidecar path", async () => { + test("openShell preserves terminal order and exposes diagnostic stderr", async () => { const kernel = createKernel({ filesystem: createInMemoryFileSystem(), permissions: ALLOW_ALL_VM_PERMISSIONS, @@ -1883,7 +1883,7 @@ describe("native sidecar process client", () => { try { await kernel.mount(createNodeRuntime()); - let stdout = ""; + let terminal = ""; let stderr = ""; const decoder = new TextDecoder(); const shell = kernel.openShell({ @@ -1905,20 +1905,26 @@ describe("native sidecar process client", () => { }); shell.onData = (chunk) => { - stdout += decoder.decode(chunk); + terminal += decoder.decode(chunk); }; - shell.write("hello-shell\n"); + await shell.write("hello-shell\n"); - await waitFor(() => stdout, { - isReady: (value) => value.includes("OUT:hello-shell"), + await waitFor(() => terminal, { + isReady: (value) => + value.includes("OUT:hello-shell") && + value.includes("ERR:hello-shell"), }); await waitFor(() => stderr, { isReady: (value) => value.includes("ERR:hello-shell"), }); - expect(stdout).toContain("OUT:hello-shell"); - expect(stdout).not.toContain("ERR:hello-shell"); + expect(terminal).toContain("OUT:hello-shell"); + expect(terminal).toContain("ERR:hello-shell"); + expect(terminal.indexOf("OUT:hello-shell")).toBeLessThan( + terminal.indexOf("ERR:hello-shell"), + ); + expect(terminal.match(/ERR:hello-shell/g)).toHaveLength(1); expect(stderr).toContain("ERR:hello-shell"); expect(stderr).not.toContain("OUT:hello-shell"); expect(await shell.wait()).toBe(0); diff --git a/packages/core/tests/pty-line-discipline.test.ts b/packages/core/tests/pty-line-discipline.test.ts index 3f481fcdd0..037703e382 100644 --- a/packages/core/tests/pty-line-discipline.test.ts +++ b/packages/core/tests/pty-line-discipline.test.ts @@ -601,11 +601,11 @@ const CASES: Case[] = [ }, { // 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). + // re-queries and must see the NEW size. The native sidecar forwards the + // kernel resize as SIGWINCH to embedded V8 so js-node re-queries it. id: "resize-sigwinch", knownBroken: false, - ttyDependent: true, + ttyDependent: false, cols: 80, rows: 24, async run(ctx) { diff --git a/packages/runtime-core/src/kernel-proxy.ts b/packages/runtime-core/src/kernel-proxy.ts index 2a0c5e30a3..4c4f964bde 100644 --- a/packages/runtime-core/src/kernel-proxy.ts +++ b/packages/runtime-core/src/kernel-proxy.ts @@ -711,7 +711,7 @@ export class NativeSidecarKernelProxy { } openShell(options?: OpenShellOptions): ShellHandle { - const stdoutHandlers = new Set<(data: Uint8Array) => void>(); + const terminalHandlers = new Set<(data: Uint8Array) => void>(); const stderrHandlers = new Set<(data: Uint8Array) => void>(); const command = options?.command ?? "sh"; const args = @@ -774,7 +774,7 @@ export class NativeSidecarKernelProxy { const normalized = normalizeSyntheticTerminalText(text); updateSyntheticCursor(normalized); const chunk = textEncoder.encode(normalized); - for (const handler of stdoutHandlers) { + for (const handler of terminalHandlers) { handler(chunk); } }; @@ -785,7 +785,7 @@ export class NativeSidecarKernelProxy { const normalized = normalizeSyntheticTerminalText(text); updateSyntheticCursor(normalized); const chunk = textEncoder.encode(normalized); - for (const handler of stdoutHandlers) { + for (const handler of terminalHandlers) { handler(chunk); } }; @@ -830,7 +830,7 @@ export class NativeSidecarKernelProxy { commandInFlight = false; const promptPrefix = syntheticCursorAtLineStart ? "" : "\r\n"; const promptChunk = textEncoder.encode(`${promptPrefix}${promptText}`); - for (const handler of stdoutHandlers) { + for (const handler of terminalHandlers) { handler(promptChunk); } syntheticCursorAtLineStart = false; @@ -873,7 +873,7 @@ export class NativeSidecarKernelProxy { }; let onData: ((data: Uint8Array) => void) | null = null; - stdoutHandlers.add((data) => onData?.(data)); + terminalHandlers.add((data) => onData?.(data)); if (options?.onStderr) { stderrHandlers.add(options.onStderr); } @@ -1052,7 +1052,7 @@ export class NativeSidecarKernelProxy { cwd: options?.cwd, streamStdin: true, onStdout: (chunk) => { - for (const handler of stdoutHandlers) { + for (const handler of terminalHandlers) { handler(chunk); } if (commandInFlight) { @@ -1060,6 +1060,11 @@ export class NativeSidecarKernelProxy { } }, onStderr: (chunk) => { + // `onData` is the ordered PTY rendering stream. `onStderr` remains an + // optional channel-specific diagnostic tap and must not also be rendered. + for (const handler of terminalHandlers) { + handler(chunk); + } for (const handler of stderrHandlers) { handler(chunk); } @@ -1117,14 +1122,7 @@ export class NativeSidecarKernelProxy { const stdin = process.stdin; const stdout = process.stdout; const { onData, ...shellOptions } = options ?? {}; - const shell = this.openShell({ - ...shellOptions, - onStderr: - shellOptions.onStderr ?? - ((data) => { - process.stderr.write(data); - }), - }); + const shell = this.openShell(shellOptions); const outputHandler = onData ?? ((data: Uint8Array) => { diff --git a/packages/runtime-core/src/test-runtime.ts b/packages/runtime-core/src/test-runtime.ts index aeb8c13260..776bd8bb25 100644 --- a/packages/runtime-core/src/test-runtime.ts +++ b/packages/runtime-core/src/test-runtime.ts @@ -227,6 +227,7 @@ export interface ManagedProcess { export interface ShellHandle { pid: number; write(data: Uint8Array | string): void; + /** Ordered PTY output containing stdout and stderr exactly once. */ onData: ((data: Uint8Array) => void) | null; resize(cols: number, rows: number): void; kill(signal?: number): void; @@ -240,6 +241,7 @@ export interface OpenShellOptions { cwd?: string; cols?: number; rows?: number; + /** Optional stderr-only diagnostic tap; do not render it alongside `onData`. */ onStderr?: (data: Uint8Array) => void; } diff --git a/packages/shell/src/actor-vm.ts b/packages/shell/src/actor-vm.ts index 02274061b2..30a70f388b 100644 --- a/packages/shell/src/actor-vm.ts +++ b/packages/shell/src/actor-vm.ts @@ -62,10 +62,12 @@ export interface ShellVmHandle { env?: Record; cols?: number; rows?: number; + /** Optional stderr-only diagnostic tap; do not render it with `onShellData`. */ onStderr?: (data: Uint8Array) => void; }): { shellId: string } | Promise<{ shellId: string }>; writeShell(shellId: string, data: Uint8Array | string): Promise; resizeShell(shellId: string, cols: number, rows: number): void; + /** Ordered PTY output containing stdout and stderr exactly once. */ onShellData(shellId: string, handler: (data: Uint8Array) => void): () => void; waitShell(shellId: string): Promise; dispose(): Promise; diff --git a/packages/shell/src/main.ts b/packages/shell/src/main.ts index 40940929ab..1fb50aea0c 100644 --- a/packages/shell/src/main.ts +++ b/packages/shell/src/main.ts @@ -609,12 +609,6 @@ async function runTerminalAttempt( 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 } = await vm.openShell({ ...shellOptions, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f159cc531e..ab1a636334 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2988,6 +2988,9 @@ importers: '@agentclientprotocol/sdk': specifier: ^0.16.1 version: 0.16.1(zod@4.3.6) + '@mariozechner/pi-agent-core': + specifier: 0.60.0 + version: 0.60.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(bufferutil@4.1.0)(ws@8.21.0(bufferutil@4.1.0))(zod@4.3.6) '@mariozechner/pi-ai': specifier: 0.60.0 version: 0.60.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(bufferutil@4.1.0)(ws@8.21.0(bufferutil@4.1.0))(zod@4.3.6) diff --git a/registry/CONTRIBUTING.md b/registry/CONTRIBUTING.md index 22123b3d2b..1caef14b93 100644 --- a/registry/CONTRIBUTING.md +++ b/registry/CONTRIBUTING.md @@ -37,7 +37,8 @@ From the repo root: ```bash just registry-native # compile the fast native wasm command gate just registry-native-cmd # build one command (required for git, duckdb, vim, wget, codex) -just registry-build # stage bin/ + assemble dist/package/ +pnpm --filter @agentos-software/ build # stage bin/ + assemble one package +just registry-build # stage + assemble every software package just registry-status # per-package state just registry-test # registry integration tests ``` @@ -56,7 +57,8 @@ for toolchain details (Rust vs C builds, the patched WASI sysroot). that block the package is not listed on the website registry page. 4. Register the directory in `pnpm-workspace.yaml` (it is covered by the `registry/software/*` glob) and run `pnpm install`. -5. `just registry-build ` and check `just registry-status`. +5. Run `pnpm --filter @agentos-software/ build` and check + `just registry-status`. See [Software Definition](https://agentos-sdk.dev/docs/custom-software/definition) for every manifest field. @@ -94,4 +96,4 @@ default) — see [Publishing Packages](https://agentos-sdk.dev/docs/custom-softw independently); never touch other packages' versions or the `latest` dist-tag. - Cheap gates before pushing: `cargo check --workspace`, `pnpm build`, - `pnpm check-types`, and `just registry-build `. + `pnpm check-types`, and `pnpm --filter @agentos-software/ build`. diff --git a/registry/README.md b/registry/README.md index 553c520110..a4b904a248 100644 --- a/registry/README.md +++ b/registry/README.md @@ -32,7 +32,7 @@ registry/software// ├── agentos-package.json manifest: runtime fields (name/agent/provides) + │ staging fields (commands/aliases/stubs) ├── src/index.ts descriptor: packageDir -> ./package/ (dist/package) -├── bin/ staged command binaries (gitignored, built) +├── bin/ staged command binaries (generated build output) └── dist/package/ the assembled runtime dir (shipped in the npm tarball): ├── package.json { name, version, bin: { : "bin/" } } ├── agentos-package.json @@ -59,11 +59,44 @@ All recipes run from the repo root (see `justfile`): just registry-native # compile the fast native wasm command gate just registry-native-cmd # build ONE command binary, whatever its toolchain just registry-build # stage + assemble every registry package -just registry-build coreutils # ... or just one +pnpm --filter @agentos-software/coreutils build:runtime # assemble runnable coreutils just registry-status # per-package state; --remote adds npm dist-tags just registry-test # registry integration tests (registry/tests) ``` +### Building coreutils from a clean checkout + +`registry/software/coreutils/bin/` is deliberately gitignored and must never be +committed. A fresh checkout has no runnable coreutils package: VMs, browser +demos, and tests that use `sh`, `cat`, `chmod`, or the other bundled commands +will not work until the native WASM commands are compiled and staged. + +Run the complete build from the repository root: + +```bash +pnpm install --frozen-lockfile +just registry-native +pnpm --filter @agentos-software/coreutils build:runtime +``` + +The native build compiles the patched Rust and C WASI sources into +`registry/native/target/wasm32-wasip1/release/commands/`. `build:runtime` +strictly stages every command, alias, and stub into +`registry/software/coreutils/bin/`, then assembles `dist/package/`. It fails if +the native command set is absent or incomplete; an empty placeholder is not a +usable coreutils package. + +The ordinary `build` script retains `--if-missing skip` so repository-wide +TypeScript builds can run without first compiling the WASM toolchain. On a +source-only checkout that script assembles an empty placeholder. It does not +make `sh` or any coreutils command available and is not a runtime build. +Publishing coreutils runs `build:runtime` through its `prepublishOnly` lifecycle +and fails rather than publishing an empty or incomplete command package. + +`just registry-native-cmd sh` is useful when iterating on only the shell, but it +does not build the other commands and therefore is not enough to assemble +coreutils. Use the complete sequence above for demos, packaging, and E2E tests. + `registry-native-cmd` (= `make -C registry/native cmd/`) is the uniform per-binary entry point; it dispatches to whichever toolchain owns the command: @@ -82,9 +115,10 @@ The default native build (`registry/native`) compiles the fast command gate to intentionally excludes slow/heavy or non-default commands: `git`, `duckdb`, `vim`, `wget`, and the external `codex`/`codex-exec` fork build. Build those explicitly with `just registry-native-cmd ` when working on them. Package builds then run -`agentos-toolchain stage` (with `--if-missing skip`, so a checkout without the -native build still assembles valid empty placeholders) followed by `tsc` and -`agentos-toolchain build`. +`agentos-toolchain stage`, followed by `tsc` and `agentos-toolchain build`. +Use coreutils `build:runtime` for strict staging as described above; software +packages may use skip mode for source-only checks or commands outside the +default native gate. Within this repo, everything consumes the LOCAL builds by default: the registry packages are pnpm workspace members, so tests and examples resolve them via diff --git a/registry/agent/pi/adapter-browser-entry.mjs b/registry/agent/pi/adapter-browser-entry.mjs index 2bb013fee2..2e621a03b7 100644 --- a/registry/agent/pi/adapter-browser-entry.mjs +++ b/registry/agent/pi/adapter-browser-entry.mjs @@ -1,9 +1,9 @@ // Browser-bundle entry for the pi ACP adapter. The adapter normally loads its SDK via // computed dynamic import() from a VM-mounted node_modules; in the browser converged // executor there is no such mount, so we statically import the SDK submodules (which -// esbuild bundles into a single self-contained file) and hand them to the adapter via -// the `__piSdkModules` override (see loadPiSdkRuntime). The adapter is otherwise -// unchanged — same ACP behavior, just bundled instead of VFS-resolved. +// esbuild bundles into a single self-contained file) and publish the same +// `__PI_SDK_RUNTIME__` object used by the native V8 startup snapshot. The adapter is +// otherwise unchanged — same ACP behavior, just bundled instead of VFS-resolved. // // Imports use relative node_modules file paths (not bare package subpaths) so esbuild // resolves the dist files directly, bypassing the packages' restrictive `exports`. @@ -19,20 +19,23 @@ import * as sessionManager from "./node_modules/@mariozechner/pi-coding-agent/di import * as settingsManager from "./node_modules/@mariozechner/pi-coding-agent/dist/core/settings-manager.js"; import * as tools from "./node_modules/@mariozechner/pi-coding-agent/dist/core/tools/index.js"; -// loadPiSdkRuntime reads this lazily (at session/new), so setting it after the adapter -// import (ESM-hoisted) is fine. -globalThis.__piSdkModules = { - agentCore, - authStorage, - config, - defaults, - messages, - modelRegistry, - resourceLoader, - sdk, - sessionManager, - settingsManager, - tools, +// Keep this shape identical to `runtime` in src/snapshot-entry.ts. The adapter reads +// it lazily at session/new, so setting it after the adapter import (ESM-hoisted) is +// safe. +globalThis.__PI_SDK_RUNTIME__ = { + Agent: agentCore.Agent, + AuthStorage: authStorage.AuthStorage, + DefaultResourceLoader: resourceLoader.DefaultResourceLoader, + DEFAULT_THINKING_LEVEL: defaults.DEFAULT_THINKING_LEVEL, + ModelRegistry: modelRegistry.ModelRegistry, + SettingsManager: settingsManager.SettingsManager, + SessionManager: sessionManager.SessionManager, + convertToLlm: messages.convertToLlm, + getAgentDir: config.getAgentDir, + getDocsPath: config.getDocsPath, + createAgentSession: sdk.createAgentSession, + createCodingTools: sdk.createCodingTools, + createAllTools: tools.createAllTools, }; import "./dist/adapter.js"; diff --git a/registry/agent/pi/package.json b/registry/agent/pi/package.json index 40d41edbb5..96ae3ce7fa 100644 --- a/registry/agent/pi/package.json +++ b/registry/agent/pi/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "^0.16.1", + "@mariozechner/pi-agent-core": "0.60.0", "@mariozechner/pi-coding-agent": "0.60.0", "@mariozechner/pi-ai": "0.60.0" }, diff --git a/registry/native/Makefile b/registry/native/Makefile index adb69498b3..c8e814ffc4 100644 --- a/registry/native/Makefile +++ b/registry/native/Makefile @@ -15,9 +15,12 @@ WASI_SYSROOT_SELF_CONTAINED := $(shell rustc --print sysroot)/lib/rustlib/$(WASM # Discover command binary names from crates/commands/. Keep known slow/heavy # commands out of the default registry gate; they can still be built explicitly # with `make cmd/`. -SKIPPED_BULK_COMMANDS := _stubs git codex codex-exec +SKIPPED_BULK_COMMANDS := git codex codex-exec COMMAND_NAMES := $(filter-out $(SKIPPED_BULK_COMMANDS),$(notdir $(wildcard crates/commands/*))) -COMMAND_PACKAGES := $(addprefix -p cmd-,$(COMMAND_NAMES)) +# `_stubs` is the binary name but its Cargo package is `cmd-stubs`; every +# other command follows the `cmd-` convention. +COMMAND_PACKAGE_NAMES := $(patsubst _stubs,stubs,$(COMMAND_NAMES)) +COMMAND_PACKAGES := $(addprefix -p cmd-,$(COMMAND_PACKAGE_NAMES)) # Alias symlinks: link_name:target_binary ALIAS_SYMLINKS := \ @@ -47,7 +50,7 @@ all: wasm # Build the fast command set that lands in COMMANDS_DIR: Rust commands except # known heavy/external ones, then the opted-in fast C commands. Heavy commands -# (git, duckdb, vim), generated helpers (_stubs), and the external codex build +# (git, duckdb, vim) and the external codex build # are intentionally excluded from the default registry-build gate. commands: wasm $(MAKE) -C c programs install diff --git a/registry/native/c/Makefile b/registry/native/c/Makefile index f297344082..29209a762f 100644 --- a/registry/native/c/Makefile +++ b/registry/native/c/Makefile @@ -169,6 +169,17 @@ libs/zlib/zutil.c: @cd $(LIBS_CACHE) && unzip -qo zlib.zip @cp $(LIBS_CACHE)/zlib-*/*.c $(LIBS_CACHE)/zlib-*/*.h $(LIBS_DIR)/zlib/ +# The fetch recipe above materializes the complete archive. Declare the other +# files as outputs that depend on its representative target so a clean `make +# programs` can construct its dependency graph before the archive exists. +ZLIB_FETCHED_SRCS := \ + libs/zlib/adler32.c libs/zlib/compress.c libs/zlib/crc32.c \ + libs/zlib/deflate.c libs/zlib/infback.c libs/zlib/inffast.c \ + libs/zlib/inflate.c libs/zlib/inftrees.c libs/zlib/trees.c \ + libs/zlib/uncompr.c +$(ZLIB_FETCHED_SRCS): libs/zlib/zutil.c + @test -f "$@" || { echo "Error: zlib fetch did not produce $@"; exit 1; } + libs/minizip/ioapi.c: @echo "Fetching minizip (from zlib contrib)..." @mkdir -p $(LIBS_CACHE) $(LIBS_DIR)/minizip @@ -182,6 +193,10 @@ libs/minizip/ioapi.c: $(LIBS_CACHE)/zlib-*/contrib/minizip/crypt.h \ $(LIBS_DIR)/minizip/ +MINIZIP_FETCHED_SRCS := libs/minizip/zip.c libs/minizip/unzip.c +$(MINIZIP_FETCHED_SRCS): libs/minizip/ioapi.c + @test -f "$@" || { echo "Error: minizip fetch did not produce $@"; exit 1; } + libs/cjson/cJSON.c: @echo "Fetching cJSON..." @mkdir -p $(LIBS_CACHE) $(LIBS_DIR)/cjson @@ -689,7 +704,7 @@ $(BUILD_DIR)/vim: fetch-vim $(VIM_BRIDGE_BUILD)/libtermcap.a $(VIM_WCC) wasm-opt vim_cv_tty_mode=0620 \ ./configure --host=wasm32-wasip1 --build=$$(cc -dumpmachine 2>/dev/null || echo x86_64-pc-linux-gnu) \ --with-features=normal --with-tlib=termcap \ - --disable-gui --without-x --disable-nls --disable-channel \ + --disable-gui --without-x --without-wayland --disable-nls --disable-channel \ --disable-netbeans --disable-selinux --disable-gpm --disable-sysmouse \ --disable-icon-cache-update --disable-desktop-database-update $(MAKE) -C $(VIM_SRC)/src vim diff --git a/registry/native/patches/crates/brush-core/0004-wasi-child-pid.patch b/registry/native/patches/crates/brush-core/0004-wasi-child-pid.patch new file mode 100644 index 0000000000..2d04b03930 --- /dev/null +++ b/registry/native/patches/crates/brush-core/0004-wasi-child-pid.patch @@ -0,0 +1,10 @@ +--- a/src/sys/stubs/process.rs ++++ b/src/sys/stubs/process.rs +@@ -13,7 +13,7 @@ impl Child { + /// Returns the process ID of the child process, if available. + pub fn id(&self) -> Option { +- None ++ Some(self.inner.id()) + } + + /// Asynchronously waits for the child process to exit. diff --git a/registry/software/coreutils/bin/[ b/registry/software/coreutils/bin/[ deleted file mode 100644 index 321231e0c4..0000000000 Binary files a/registry/software/coreutils/bin/[ and /dev/null differ diff --git a/registry/software/coreutils/bin/arch b/registry/software/coreutils/bin/arch deleted file mode 100644 index 414eecc3c7..0000000000 Binary files a/registry/software/coreutils/bin/arch and /dev/null differ diff --git a/registry/software/coreutils/bin/b2sum b/registry/software/coreutils/bin/b2sum deleted file mode 100644 index cb078b1959..0000000000 Binary files a/registry/software/coreutils/bin/b2sum and /dev/null differ diff --git a/registry/software/coreutils/bin/base32 b/registry/software/coreutils/bin/base32 deleted file mode 100644 index 662f4584d8..0000000000 Binary files a/registry/software/coreutils/bin/base32 and /dev/null differ diff --git a/registry/software/coreutils/bin/base64 b/registry/software/coreutils/bin/base64 deleted file mode 100644 index e315357a92..0000000000 Binary files a/registry/software/coreutils/bin/base64 and /dev/null differ diff --git a/registry/software/coreutils/bin/basename b/registry/software/coreutils/bin/basename deleted file mode 100644 index 5a5547db31..0000000000 Binary files a/registry/software/coreutils/bin/basename and /dev/null differ diff --git a/registry/software/coreutils/bin/basenc b/registry/software/coreutils/bin/basenc deleted file mode 100644 index faed8dc645..0000000000 Binary files a/registry/software/coreutils/bin/basenc and /dev/null differ diff --git a/registry/software/coreutils/bin/bash b/registry/software/coreutils/bin/bash deleted file mode 100644 index 9e80dda943..0000000000 Binary files a/registry/software/coreutils/bin/bash and /dev/null differ diff --git a/registry/software/coreutils/bin/cat b/registry/software/coreutils/bin/cat deleted file mode 100644 index d48dcaa8cd..0000000000 Binary files a/registry/software/coreutils/bin/cat and /dev/null differ diff --git a/registry/software/coreutils/bin/chcon b/registry/software/coreutils/bin/chcon deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/chcon and /dev/null differ diff --git a/registry/software/coreutils/bin/chgrp b/registry/software/coreutils/bin/chgrp deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/chgrp and /dev/null differ diff --git a/registry/software/coreutils/bin/chmod b/registry/software/coreutils/bin/chmod deleted file mode 100644 index 05e1293098..0000000000 Binary files a/registry/software/coreutils/bin/chmod and /dev/null differ diff --git a/registry/software/coreutils/bin/chown b/registry/software/coreutils/bin/chown deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/chown and /dev/null differ diff --git a/registry/software/coreutils/bin/chroot b/registry/software/coreutils/bin/chroot deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/chroot and /dev/null differ diff --git a/registry/software/coreutils/bin/cksum b/registry/software/coreutils/bin/cksum deleted file mode 100644 index 45bb83c1f8..0000000000 Binary files a/registry/software/coreutils/bin/cksum and /dev/null differ diff --git a/registry/software/coreutils/bin/column b/registry/software/coreutils/bin/column deleted file mode 100644 index 1580c3b660..0000000000 Binary files a/registry/software/coreutils/bin/column and /dev/null differ diff --git a/registry/software/coreutils/bin/comm b/registry/software/coreutils/bin/comm deleted file mode 100644 index 63c7779a2d..0000000000 Binary files a/registry/software/coreutils/bin/comm and /dev/null differ diff --git a/registry/software/coreutils/bin/cp b/registry/software/coreutils/bin/cp deleted file mode 100644 index d2af9d05b3..0000000000 Binary files a/registry/software/coreutils/bin/cp and /dev/null differ diff --git a/registry/software/coreutils/bin/cut b/registry/software/coreutils/bin/cut deleted file mode 100644 index 2c58944d96..0000000000 Binary files a/registry/software/coreutils/bin/cut and /dev/null differ diff --git a/registry/software/coreutils/bin/date b/registry/software/coreutils/bin/date deleted file mode 100644 index 16c8308fc5..0000000000 Binary files a/registry/software/coreutils/bin/date and /dev/null differ diff --git a/registry/software/coreutils/bin/dd b/registry/software/coreutils/bin/dd deleted file mode 100644 index e255315326..0000000000 Binary files a/registry/software/coreutils/bin/dd and /dev/null differ diff --git a/registry/software/coreutils/bin/df b/registry/software/coreutils/bin/df deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/df and /dev/null differ diff --git a/registry/software/coreutils/bin/dir b/registry/software/coreutils/bin/dir deleted file mode 100644 index 3dc3e5295e..0000000000 Binary files a/registry/software/coreutils/bin/dir and /dev/null differ diff --git a/registry/software/coreutils/bin/dircolors b/registry/software/coreutils/bin/dircolors deleted file mode 100644 index f230724e29..0000000000 Binary files a/registry/software/coreutils/bin/dircolors and /dev/null differ diff --git a/registry/software/coreutils/bin/dirname b/registry/software/coreutils/bin/dirname deleted file mode 100644 index 6f21608159..0000000000 Binary files a/registry/software/coreutils/bin/dirname and /dev/null differ diff --git a/registry/software/coreutils/bin/du b/registry/software/coreutils/bin/du deleted file mode 100644 index bd087ccddf..0000000000 Binary files a/registry/software/coreutils/bin/du and /dev/null differ diff --git a/registry/software/coreutils/bin/echo b/registry/software/coreutils/bin/echo deleted file mode 100644 index dbe1cb5b22..0000000000 Binary files a/registry/software/coreutils/bin/echo and /dev/null differ diff --git a/registry/software/coreutils/bin/env b/registry/software/coreutils/bin/env deleted file mode 100644 index d9860269d1..0000000000 Binary files a/registry/software/coreutils/bin/env and /dev/null differ diff --git a/registry/software/coreutils/bin/expand b/registry/software/coreutils/bin/expand deleted file mode 100644 index acf0b7e03d..0000000000 Binary files a/registry/software/coreutils/bin/expand and /dev/null differ diff --git a/registry/software/coreutils/bin/expr b/registry/software/coreutils/bin/expr deleted file mode 100644 index b2825b7557..0000000000 Binary files a/registry/software/coreutils/bin/expr and /dev/null differ diff --git a/registry/software/coreutils/bin/factor b/registry/software/coreutils/bin/factor deleted file mode 100644 index 4c1d451774..0000000000 Binary files a/registry/software/coreutils/bin/factor and /dev/null differ diff --git a/registry/software/coreutils/bin/false b/registry/software/coreutils/bin/false deleted file mode 100644 index 0913e06b95..0000000000 Binary files a/registry/software/coreutils/bin/false and /dev/null differ diff --git a/registry/software/coreutils/bin/fmt b/registry/software/coreutils/bin/fmt deleted file mode 100644 index 8f7f5b020a..0000000000 Binary files a/registry/software/coreutils/bin/fmt and /dev/null differ diff --git a/registry/software/coreutils/bin/fold b/registry/software/coreutils/bin/fold deleted file mode 100644 index 66893046f6..0000000000 Binary files a/registry/software/coreutils/bin/fold and /dev/null differ diff --git a/registry/software/coreutils/bin/groups b/registry/software/coreutils/bin/groups deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/groups and /dev/null differ diff --git a/registry/software/coreutils/bin/head b/registry/software/coreutils/bin/head deleted file mode 100644 index 28a97356d7..0000000000 Binary files a/registry/software/coreutils/bin/head and /dev/null differ diff --git a/registry/software/coreutils/bin/hostid b/registry/software/coreutils/bin/hostid deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/hostid and /dev/null differ diff --git a/registry/software/coreutils/bin/hostname b/registry/software/coreutils/bin/hostname deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/hostname and /dev/null differ diff --git a/registry/software/coreutils/bin/id b/registry/software/coreutils/bin/id deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/id and /dev/null differ diff --git a/registry/software/coreutils/bin/install b/registry/software/coreutils/bin/install deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/install and /dev/null differ diff --git a/registry/software/coreutils/bin/join b/registry/software/coreutils/bin/join deleted file mode 100644 index 60b7cad297..0000000000 Binary files a/registry/software/coreutils/bin/join and /dev/null differ diff --git a/registry/software/coreutils/bin/kill b/registry/software/coreutils/bin/kill deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/kill and /dev/null differ diff --git a/registry/software/coreutils/bin/link b/registry/software/coreutils/bin/link deleted file mode 100644 index 609677d3fe..0000000000 Binary files a/registry/software/coreutils/bin/link and /dev/null differ diff --git a/registry/software/coreutils/bin/ln b/registry/software/coreutils/bin/ln deleted file mode 100644 index 7930e6370e..0000000000 Binary files a/registry/software/coreutils/bin/ln and /dev/null differ diff --git a/registry/software/coreutils/bin/logname b/registry/software/coreutils/bin/logname deleted file mode 100644 index f0fad8d365..0000000000 Binary files a/registry/software/coreutils/bin/logname and /dev/null differ diff --git a/registry/software/coreutils/bin/ls b/registry/software/coreutils/bin/ls deleted file mode 100644 index 3dc3e5295e..0000000000 Binary files a/registry/software/coreutils/bin/ls and /dev/null differ diff --git a/registry/software/coreutils/bin/md5sum b/registry/software/coreutils/bin/md5sum deleted file mode 100644 index 38d5041ca5..0000000000 Binary files a/registry/software/coreutils/bin/md5sum and /dev/null differ diff --git a/registry/software/coreutils/bin/mkdir b/registry/software/coreutils/bin/mkdir deleted file mode 100644 index ff1921b459..0000000000 Binary files a/registry/software/coreutils/bin/mkdir and /dev/null differ diff --git a/registry/software/coreutils/bin/mkfifo b/registry/software/coreutils/bin/mkfifo deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/mkfifo and /dev/null differ diff --git a/registry/software/coreutils/bin/mknod b/registry/software/coreutils/bin/mknod deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/mknod and /dev/null differ diff --git a/registry/software/coreutils/bin/mktemp b/registry/software/coreutils/bin/mktemp deleted file mode 100644 index a67bb8b610..0000000000 Binary files a/registry/software/coreutils/bin/mktemp and /dev/null differ diff --git a/registry/software/coreutils/bin/more b/registry/software/coreutils/bin/more deleted file mode 100644 index d48dcaa8cd..0000000000 Binary files a/registry/software/coreutils/bin/more and /dev/null differ diff --git a/registry/software/coreutils/bin/mv b/registry/software/coreutils/bin/mv deleted file mode 100644 index db5e849abc..0000000000 Binary files a/registry/software/coreutils/bin/mv and /dev/null differ diff --git a/registry/software/coreutils/bin/nice b/registry/software/coreutils/bin/nice deleted file mode 100644 index f0d9cb44fb..0000000000 Binary files a/registry/software/coreutils/bin/nice and /dev/null differ diff --git a/registry/software/coreutils/bin/nl b/registry/software/coreutils/bin/nl deleted file mode 100644 index 53abc2d686..0000000000 Binary files a/registry/software/coreutils/bin/nl and /dev/null differ diff --git a/registry/software/coreutils/bin/nohup b/registry/software/coreutils/bin/nohup deleted file mode 100644 index 5b91d7ca8a..0000000000 Binary files a/registry/software/coreutils/bin/nohup and /dev/null differ diff --git a/registry/software/coreutils/bin/nproc b/registry/software/coreutils/bin/nproc deleted file mode 100644 index 30671ed9d6..0000000000 Binary files a/registry/software/coreutils/bin/nproc and /dev/null differ diff --git a/registry/software/coreutils/bin/numfmt b/registry/software/coreutils/bin/numfmt deleted file mode 100644 index ed496d7db7..0000000000 Binary files a/registry/software/coreutils/bin/numfmt and /dev/null differ diff --git a/registry/software/coreutils/bin/od b/registry/software/coreutils/bin/od deleted file mode 100644 index 29edaafc0c..0000000000 Binary files a/registry/software/coreutils/bin/od and /dev/null differ diff --git a/registry/software/coreutils/bin/paste b/registry/software/coreutils/bin/paste deleted file mode 100644 index 2ffd02940f..0000000000 Binary files a/registry/software/coreutils/bin/paste and /dev/null differ diff --git a/registry/software/coreutils/bin/pathchk b/registry/software/coreutils/bin/pathchk deleted file mode 100644 index 785f1f7af3..0000000000 Binary files a/registry/software/coreutils/bin/pathchk and /dev/null differ diff --git a/registry/software/coreutils/bin/pinky b/registry/software/coreutils/bin/pinky deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/pinky and /dev/null differ diff --git a/registry/software/coreutils/bin/printenv b/registry/software/coreutils/bin/printenv deleted file mode 100644 index cbb8bc32cd..0000000000 Binary files a/registry/software/coreutils/bin/printenv and /dev/null differ diff --git a/registry/software/coreutils/bin/printf b/registry/software/coreutils/bin/printf deleted file mode 100644 index 244d2d9ae2..0000000000 Binary files a/registry/software/coreutils/bin/printf and /dev/null differ diff --git a/registry/software/coreutils/bin/ptx b/registry/software/coreutils/bin/ptx deleted file mode 100644 index 881c9e8850..0000000000 Binary files a/registry/software/coreutils/bin/ptx and /dev/null differ diff --git a/registry/software/coreutils/bin/pwd b/registry/software/coreutils/bin/pwd deleted file mode 100644 index e173b2b9b2..0000000000 Binary files a/registry/software/coreutils/bin/pwd and /dev/null differ diff --git a/registry/software/coreutils/bin/readlink b/registry/software/coreutils/bin/readlink deleted file mode 100644 index 5c4dcb2009..0000000000 Binary files a/registry/software/coreutils/bin/readlink and /dev/null differ diff --git a/registry/software/coreutils/bin/realpath b/registry/software/coreutils/bin/realpath deleted file mode 100644 index 6df9bfa73f..0000000000 Binary files a/registry/software/coreutils/bin/realpath and /dev/null differ diff --git a/registry/software/coreutils/bin/rev b/registry/software/coreutils/bin/rev deleted file mode 100644 index 864b62afd1..0000000000 Binary files a/registry/software/coreutils/bin/rev and /dev/null differ diff --git a/registry/software/coreutils/bin/rm b/registry/software/coreutils/bin/rm deleted file mode 100644 index e00af18f8e..0000000000 Binary files a/registry/software/coreutils/bin/rm and /dev/null differ diff --git a/registry/software/coreutils/bin/rmdir b/registry/software/coreutils/bin/rmdir deleted file mode 100644 index 3ec6f0f036..0000000000 Binary files a/registry/software/coreutils/bin/rmdir and /dev/null differ diff --git a/registry/software/coreutils/bin/runcon b/registry/software/coreutils/bin/runcon deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/runcon and /dev/null differ diff --git a/registry/software/coreutils/bin/seq b/registry/software/coreutils/bin/seq deleted file mode 100644 index 45a46fd826..0000000000 Binary files a/registry/software/coreutils/bin/seq and /dev/null differ diff --git a/registry/software/coreutils/bin/sh b/registry/software/coreutils/bin/sh deleted file mode 100644 index 9e80dda943..0000000000 Binary files a/registry/software/coreutils/bin/sh and /dev/null differ diff --git a/registry/software/coreutils/bin/sha1sum b/registry/software/coreutils/bin/sha1sum deleted file mode 100644 index a45e8a03a1..0000000000 Binary files a/registry/software/coreutils/bin/sha1sum and /dev/null differ diff --git a/registry/software/coreutils/bin/sha224sum b/registry/software/coreutils/bin/sha224sum deleted file mode 100644 index f4d681dbc0..0000000000 Binary files a/registry/software/coreutils/bin/sha224sum and /dev/null differ diff --git a/registry/software/coreutils/bin/sha256sum b/registry/software/coreutils/bin/sha256sum deleted file mode 100644 index 9a1d01135d..0000000000 Binary files a/registry/software/coreutils/bin/sha256sum and /dev/null differ diff --git a/registry/software/coreutils/bin/sha384sum b/registry/software/coreutils/bin/sha384sum deleted file mode 100644 index a5f63b13dd..0000000000 Binary files a/registry/software/coreutils/bin/sha384sum and /dev/null differ diff --git a/registry/software/coreutils/bin/sha512sum b/registry/software/coreutils/bin/sha512sum deleted file mode 100644 index cda448b94f..0000000000 Binary files a/registry/software/coreutils/bin/sha512sum and /dev/null differ diff --git a/registry/software/coreutils/bin/shred b/registry/software/coreutils/bin/shred deleted file mode 100644 index a0d21cd818..0000000000 Binary files a/registry/software/coreutils/bin/shred and /dev/null differ diff --git a/registry/software/coreutils/bin/shuf b/registry/software/coreutils/bin/shuf deleted file mode 100644 index 974b618c1d..0000000000 Binary files a/registry/software/coreutils/bin/shuf and /dev/null differ diff --git a/registry/software/coreutils/bin/sleep b/registry/software/coreutils/bin/sleep deleted file mode 100644 index af6efb965e..0000000000 Binary files a/registry/software/coreutils/bin/sleep and /dev/null differ diff --git a/registry/software/coreutils/bin/sort b/registry/software/coreutils/bin/sort deleted file mode 100644 index 1d6dc77ac3..0000000000 Binary files a/registry/software/coreutils/bin/sort and /dev/null differ diff --git a/registry/software/coreutils/bin/split b/registry/software/coreutils/bin/split deleted file mode 100644 index a5821574ca..0000000000 Binary files a/registry/software/coreutils/bin/split and /dev/null differ diff --git a/registry/software/coreutils/bin/stat b/registry/software/coreutils/bin/stat deleted file mode 100644 index af4a973161..0000000000 Binary files a/registry/software/coreutils/bin/stat and /dev/null differ diff --git a/registry/software/coreutils/bin/stdbuf b/registry/software/coreutils/bin/stdbuf deleted file mode 100644 index d5884b404a..0000000000 Binary files a/registry/software/coreutils/bin/stdbuf and /dev/null differ diff --git a/registry/software/coreutils/bin/strings b/registry/software/coreutils/bin/strings deleted file mode 100644 index 9b501f8ec3..0000000000 Binary files a/registry/software/coreutils/bin/strings and /dev/null differ diff --git a/registry/software/coreutils/bin/stty b/registry/software/coreutils/bin/stty deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/stty and /dev/null differ diff --git a/registry/software/coreutils/bin/sum b/registry/software/coreutils/bin/sum deleted file mode 100644 index 5dd3eaf7a5..0000000000 Binary files a/registry/software/coreutils/bin/sum and /dev/null differ diff --git a/registry/software/coreutils/bin/sync b/registry/software/coreutils/bin/sync deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/sync and /dev/null differ diff --git a/registry/software/coreutils/bin/tac b/registry/software/coreutils/bin/tac deleted file mode 100644 index 35179d8163..0000000000 Binary files a/registry/software/coreutils/bin/tac and /dev/null differ diff --git a/registry/software/coreutils/bin/tail b/registry/software/coreutils/bin/tail deleted file mode 100644 index 0838f8e1ca..0000000000 Binary files a/registry/software/coreutils/bin/tail and /dev/null differ diff --git a/registry/software/coreutils/bin/tee b/registry/software/coreutils/bin/tee deleted file mode 100644 index b23a82ccbd..0000000000 Binary files a/registry/software/coreutils/bin/tee and /dev/null differ diff --git a/registry/software/coreutils/bin/test b/registry/software/coreutils/bin/test deleted file mode 100644 index 321231e0c4..0000000000 Binary files a/registry/software/coreutils/bin/test and /dev/null differ diff --git a/registry/software/coreutils/bin/timeout b/registry/software/coreutils/bin/timeout deleted file mode 100644 index 2638c09dc5..0000000000 Binary files a/registry/software/coreutils/bin/timeout and /dev/null differ diff --git a/registry/software/coreutils/bin/touch b/registry/software/coreutils/bin/touch deleted file mode 100644 index fd1ed0710f..0000000000 Binary files a/registry/software/coreutils/bin/touch and /dev/null differ diff --git a/registry/software/coreutils/bin/tr b/registry/software/coreutils/bin/tr deleted file mode 100644 index 536e993b9a..0000000000 Binary files a/registry/software/coreutils/bin/tr and /dev/null differ diff --git a/registry/software/coreutils/bin/true b/registry/software/coreutils/bin/true deleted file mode 100644 index e08e0be528..0000000000 Binary files a/registry/software/coreutils/bin/true and /dev/null differ diff --git a/registry/software/coreutils/bin/truncate b/registry/software/coreutils/bin/truncate deleted file mode 100644 index c693df7acb..0000000000 Binary files a/registry/software/coreutils/bin/truncate and /dev/null differ diff --git a/registry/software/coreutils/bin/tsort b/registry/software/coreutils/bin/tsort deleted file mode 100644 index 15609b02d8..0000000000 Binary files a/registry/software/coreutils/bin/tsort and /dev/null differ diff --git a/registry/software/coreutils/bin/tty b/registry/software/coreutils/bin/tty deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/tty and /dev/null differ diff --git a/registry/software/coreutils/bin/uname b/registry/software/coreutils/bin/uname deleted file mode 100644 index 7c9268ff8c..0000000000 Binary files a/registry/software/coreutils/bin/uname and /dev/null differ diff --git a/registry/software/coreutils/bin/unexpand b/registry/software/coreutils/bin/unexpand deleted file mode 100644 index 50728dccb0..0000000000 Binary files a/registry/software/coreutils/bin/unexpand and /dev/null differ diff --git a/registry/software/coreutils/bin/uniq b/registry/software/coreutils/bin/uniq deleted file mode 100644 index e243c67dd6..0000000000 Binary files a/registry/software/coreutils/bin/uniq and /dev/null differ diff --git a/registry/software/coreutils/bin/unlink b/registry/software/coreutils/bin/unlink deleted file mode 100644 index de09163081..0000000000 Binary files a/registry/software/coreutils/bin/unlink and /dev/null differ diff --git a/registry/software/coreutils/bin/uptime b/registry/software/coreutils/bin/uptime deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/uptime and /dev/null differ diff --git a/registry/software/coreutils/bin/users b/registry/software/coreutils/bin/users deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/users and /dev/null differ diff --git a/registry/software/coreutils/bin/vdir b/registry/software/coreutils/bin/vdir deleted file mode 100644 index 3dc3e5295e..0000000000 Binary files a/registry/software/coreutils/bin/vdir and /dev/null differ diff --git a/registry/software/coreutils/bin/wc b/registry/software/coreutils/bin/wc deleted file mode 100644 index 3b7e2fdf39..0000000000 Binary files a/registry/software/coreutils/bin/wc and /dev/null differ diff --git a/registry/software/coreutils/bin/which b/registry/software/coreutils/bin/which deleted file mode 100644 index a27bc06037..0000000000 Binary files a/registry/software/coreutils/bin/which and /dev/null differ diff --git a/registry/software/coreutils/bin/who b/registry/software/coreutils/bin/who deleted file mode 100644 index abc6255662..0000000000 Binary files a/registry/software/coreutils/bin/who and /dev/null differ diff --git a/registry/software/coreutils/bin/whoami b/registry/software/coreutils/bin/whoami deleted file mode 100644 index c4b00993ed..0000000000 Binary files a/registry/software/coreutils/bin/whoami and /dev/null differ diff --git a/registry/software/coreutils/bin/yes b/registry/software/coreutils/bin/yes deleted file mode 100644 index 3a0ebb9b7a..0000000000 Binary files a/registry/software/coreutils/bin/yes and /dev/null differ diff --git a/registry/software/coreutils/package.json b/registry/software/coreutils/package.json index 02002509ee..1a6941da37 100644 --- a/registry/software/coreutils/package.json +++ b/registry/software/coreutils/package.json @@ -19,7 +19,9 @@ }, "scripts": { "build": "agentos-toolchain stage --commands-dir ../../native/target/wasm32-wasip1/release/commands --if-missing skip && tsc && agentos-toolchain build", - "check-types": "tsc --noEmit" + "build:runtime": "agentos-toolchain stage --commands-dir ../../native/target/wasm32-wasip1/release/commands && tsc && agentos-toolchain build", + "check-types": "tsc --noEmit", + "prepublishOnly": "pnpm run build:runtime" }, "devDependencies": { "@agentos-software/manifest": "workspace:*", diff --git a/website/docs.config.mjs b/website/docs.config.mjs index d88545e48c..38fb5a5328 100644 --- a/website/docs.config.mjs +++ b/website/docs.config.mjs @@ -33,7 +33,6 @@ import { faTowerBroadcast, faArrowsLeftRight, faDiagramNext, - faBug, faWindowMaximize, } from "@rivet-gg/icons"; @@ -179,7 +178,7 @@ export const siteConfig = { collapsible: true, pages: [ { title: "Core SDK", href: "/docs/core" }, - { title: "Debugging", href: "/docs/debugging", icon: faBug }, + { title: "Debugging", href: "/docs/debugging" }, { title: "Benchmarks", href: "/docs/benchmarks" }, { title: "Cost Evaluation", href: "/docs/cost-evaluation" }, ], diff --git a/website/public/docs/docs/architecture/agent-sdk-snapshots.md b/website/public/docs/docs/architecture/agent-sdk-snapshots.md index cbd57f6811..60a61752dd 100644 --- a/website/public/docs/docs/architecture/agent-sdk-snapshots.md +++ b/website/public/docs/docs/architecture/agent-sdk-snapshots.md @@ -2,6 +2,8 @@ How an agent's SDK is evaluated once per sidecar into a V8 heap snapshot and reused across sessions instead of re-imported on every createSession: the bundle, the userland snapshot, the process-wide cache, pre-warm, per-session restore, isolation, and the snapshot-safety rules an SDK must follow. +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + This page is an internals deep-dive on **agent SDK snapshotting** — an optional optimization that loads an agent's SDK *once per sidecar* and reuses it for every session, instead of re-evaluating the whole SDK module graph on each `createSession`. For the agent-author view (how to opt in and the rules your SDK must follow), see [Software Definition → SDK snapshotting & snapshot-safety](/docs/custom-software/definition). For how sessions work in general, see [Agent Sessions](/docs/architecture/agent-sessions). ## The problem: per-session SDK re-evaluation diff --git a/website/public/docs/docs/architecture/agent-sessions.md b/website/public/docs/docs/architecture/agent-sessions.md index 215647ed18..f9dad316a3 100644 --- a/website/public/docs/docs/architecture/agent-sessions.md +++ b/website/public/docs/docs/architecture/agent-sessions.md @@ -2,6 +2,8 @@ Internals of agent sessions: how a session is created and bound to a VM, how prompts and events flow from client to sidecar to agent adapter and back, the session lifecycle, and where session state lives. +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + This page is an internals deep-dive on how agent sessions work under the hood. For the usage API (creating sessions, sending prompts, streaming responses, replaying events), see [Sessions](/docs/sessions). A session is a long-lived conversation with an agent (such as [Pi](https://github.com/mariozechner/pi-coding-agent)) running inside a VM. Where a bare `exec()` / `run()` starts a fresh guest process and returns when it exits, a session keeps an agent process alive across many prompts, streams its output back as events, and persists a transcript that survives sleep/wake cycles. Everything below describes the machinery that makes that possible while keeping the agent inside the same isolation boundary as any other guest. diff --git a/website/public/docs/docs/architecture/compiler-toolchain.md b/website/public/docs/docs/architecture/compiler-toolchain.md index cbac551f09..cefb2222a9 100644 --- a/website/public/docs/docs/architecture/compiler-toolchain.md +++ b/website/public/docs/docs/architecture/compiler-toolchain.md @@ -2,6 +2,8 @@ How agentOS compiles its command suite to WebAssembly: Rust coreutils via cargo and C programs via wasi-sdk, linked against a patched wasi-libc plus the wasi-ext bindings, and how the resulting .wasm files become the guest's commands. +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + The commands a guest runs through [process execution](/docs/processes), the shell (`sh`) and the coreutils behind it, are not native host binaries. They are WebAssembly modules compiled ahead of time and mounted into the VM. This page diff --git a/website/public/docs/docs/architecture/filesystem.md b/website/public/docs/docs/architecture/filesystem.md index 7c71279714..c40b7a0f78 100644 --- a/website/public/docs/docs/architecture/filesystem.md +++ b/website/public/docs/docs/architecture/filesystem.md @@ -2,6 +2,8 @@ Internals of the kernel VFS: the overlay/mount/root engines, how guest fs syscalls are routed and confined, WASM preopens, and mount confinement against symlink and .. escapes. +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + This page is an internals deep-dive on the **kernel virtual filesystem (VFS)**: how it is layered, how a guest `fs` syscall is routed through it, and how guest I/O is confined to the VM. For the user-facing API (reading, writing, mounting, persistence), see [Filesystem](/docs/filesystem). The invariant this whole subsystem exists to uphold: **every guest filesystem operation is serviced by the kernel-owned VFS, never by a real host capability.** There is no host disk reachable from the guest. The VFS presents normal Linux semantics to tools while keeping every byte inside the kernel. diff --git a/website/public/docs/docs/architecture/limits-and-observability.md b/website/public/docs/docs/architecture/limits-and-observability.md index 8aed3417c0..08d586fc26 100644 --- a/website/public/docs/docs/architecture/limits-and-observability.md +++ b/website/public/docs/docs/architecture/limits-and-observability.md @@ -2,6 +2,8 @@ How agentOS bounds resources, applies backpressure, warns before a limit is hit, and surfaces it all to the host. +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + agentOS runs untrusted, AI-generated code inside disposable VMs. Every resource that code can consume is **bounded by default**, and every bound is designed to **warn before it is hit**, **fail with a clear error when it is**, and stay diff --git a/website/public/docs/docs/architecture/networking.md b/website/public/docs/docs/architecture/networking.md index 13f461335d..9262b5c769 100644 --- a/website/public/docs/docs/architecture/networking.md +++ b/website/public/docs/docs/architecture/networking.md @@ -2,6 +2,8 @@ How the kernel socket table works: a single VM-local transport that carries host, JavaScript, and WASM traffic, where fetch / net / dns route through it, how egress policy and loopback confinement are enforced, and how preview URLs are served. +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + This is the internals view of agentOS networking: the kernel socket table, the layers a request crosses, and where policy is enforced. For the user-facing API (`vmFetch`, preview URLs, the confinement model from a caller's perspective), see [Networking & Previews](/docs/networking). For the trust boundary this all sits inside, see [Architecture](/docs/architecture). The governing rule is that there is exactly **one authoritative transport for everything VM-local**: the kernel socket table. No part of guest networking opens a real host socket on its own. Guest `fetch()`, `node:http`, `node:net`, WASM TCP clients and servers, and host-into-guest requests (`vmFetch` / `rt.fetch`) all target the same listener table. diff --git a/website/public/docs/docs/architecture/packages-and-command-resolution.md b/website/public/docs/docs/architecture/packages-and-command-resolution.md index 9d495c430c..6cae8b4c6a 100644 --- a/website/public/docs/docs/architecture/packages-and-command-resolution.md +++ b/website/public/docs/docs/architecture/packages-and-command-resolution.md @@ -2,6 +2,8 @@ How software is packaged, linked, resolved, and executed in an agentOS VM: a package is a directory, resolution is a $PATH walk, and a file's header picks its runtime. +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + How a command name becomes a running program, and how the software that provides it is packaged and linked. Everything is real files under [`/opt/agentos`](/docs/architecture/filesystem) — there is no command registry; the diff --git a/website/public/docs/docs/architecture/posix-syscalls.md b/website/public/docs/docs/architecture/posix-syscalls.md index 9e9fd29c37..6009c01f67 100644 --- a/website/public/docs/docs/architecture/posix-syscalls.md +++ b/website/public/docs/docs/architecture/posix-syscalls.md @@ -2,6 +2,8 @@ How agentOS extends WASI in two layers so WebAssembly guests behave like normal POSIX programs on top of the kernel. +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + Not everything inside an agentOS VM is JavaScript. The shell (`sh`) and the coreutils behind [process execution](/docs/processes) ship as WebAssembly binaries, and you can run your own WASM programs too. To make those programs diff --git a/website/public/docs/docs/architecture/processes.md b/website/public/docs/docs/architecture/processes.md index 68358bfa48..97a84b916c 100644 --- a/website/public/docs/docs/architecture/processes.md +++ b/website/public/docs/docs/architecture/processes.md @@ -2,6 +2,8 @@ Internals of the kernel process model: the virtual process table, how spawns are serviced, stdio bridging, PTYs, and how WASM sh and coreutils map onto it. +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + This page is an internals deep-dive on the kernel's **process model**: the data structures and syscall paths behind every guest process. For the client-facing API (`exec`, `spawn`, `openShell`, lifecycle, the process tree), see diff --git a/website/public/docs/docs/architecture/sessions-persistence.md b/website/public/docs/docs/architecture/sessions-persistence.md index bbc812b4f4..5e4a1df1c5 100644 --- a/website/public/docs/docs/architecture/sessions-persistence.md +++ b/website/public/docs/docs/architecture/sessions-persistence.md @@ -2,6 +2,8 @@ How agentOS, ACP, RivetKit actors, and durable session persistence fit together. +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + agentOS runs coding agents inside VMs and talks to them through the Agent Communication Protocol (ACP). RivetKit wraps those VMs in durable actors, so a session can survive actor sleep/wake even though the live VM and agent process diff --git a/website/public/docs/docs/persistence.md b/website/public/docs/docs/persistence.md index 5f339e0ddc..a39028a283 100644 --- a/website/public/docs/docs/persistence.md +++ b/website/public/docs/docs/persistence.md @@ -2,6 +2,8 @@ How agentOS persists data and manages sleep/wake cycles. +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + agentOS automatically persists the `/home/agentos` filesystem and session transcripts (with sequence numbers for replay) across sleep/wake, sleeping after a configurable grace period (15 minutes by default) and waking automatically when a client connects or a cron job triggers. ## What persists across sleep diff --git a/website/public/docs/docs/system-prompt.md b/website/public/docs/docs/system-prompt.md index 1a06098e7f..bf8c5c0eaf 100644 --- a/website/public/docs/docs/system-prompt.md +++ b/website/public/docs/docs/system-prompt.md @@ -2,6 +2,8 @@ How agentOS injects context into agent sessions. +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + agentOS automatically injects a system prompt into every agent session that describes the VM environment and available commands and bindings. The prompt is additive and never replaces the agent's own instructions (CLAUDE.md, AGENTS.md, etc.). The base prompt is embedded in the sidecar (not written to a file inside the VM). At session start the sidecar assembles the base prompt with your additional instructions and generated binding docs, then injects the result into the agent adapter's launch arguments (for example, `--append-system-prompt` for Pi). diff --git a/website/src/content/docs/docs/architecture/agent-sdk-snapshots.mdx b/website/src/content/docs/docs/architecture/agent-sdk-snapshots.mdx index ac89048499..bb300ef058 100644 --- a/website/src/content/docs/docs/architecture/agent-sdk-snapshots.mdx +++ b/website/src/content/docs/docs/architecture/agent-sdk-snapshots.mdx @@ -4,6 +4,8 @@ description: "How an agent's SDK is evaluated once per sidecar into a V8 heap sn skill: true --- +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + This page is an internals deep-dive on **agent SDK snapshotting** — an optional optimization that loads an agent's SDK *once per sidecar* and reuses it for every session, instead of re-evaluating the whole SDK module graph on each `createSession`. For the agent-author view (how to opt in and the rules your SDK must follow), see [Software Definition → SDK snapshotting & snapshot-safety](/docs/custom-software/definition). For how sessions work in general, see [Agent Sessions](/docs/architecture/agent-sessions). ## The problem: per-session SDK re-evaluation diff --git a/website/src/content/docs/docs/architecture/agent-sessions.mdx b/website/src/content/docs/docs/architecture/agent-sessions.mdx index bf65b82a68..8223772209 100644 --- a/website/src/content/docs/docs/architecture/agent-sessions.mdx +++ b/website/src/content/docs/docs/architecture/agent-sessions.mdx @@ -4,6 +4,8 @@ description: "Internals of agent sessions: how a session is created and bound to skill: true --- +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + This page is an internals deep-dive on how agent sessions work under the hood. For the usage API (creating sessions, sending prompts, streaming responses, replaying events), see [Sessions](/docs/sessions). A session is a long-lived conversation with an agent (such as [Pi](https://github.com/mariozechner/pi-coding-agent)) running inside a VM. Where a bare `exec()` / `run()` starts a fresh guest process and returns when it exits, a session keeps an agent process alive across many prompts, streams its output back as events, and persists a transcript that survives sleep/wake cycles. Everything below describes the machinery that makes that possible while keeping the agent inside the same isolation boundary as any other guest. diff --git a/website/src/content/docs/docs/architecture/compiler-toolchain.mdx b/website/src/content/docs/docs/architecture/compiler-toolchain.mdx index df8a059685..0187b61362 100644 --- a/website/src/content/docs/docs/architecture/compiler-toolchain.mdx +++ b/website/src/content/docs/docs/architecture/compiler-toolchain.mdx @@ -4,6 +4,8 @@ description: "How agentOS compiles its command suite to WebAssembly: Rust coreut skill: true --- +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + The commands a guest runs through [process execution](/docs/processes), the shell (`sh`) and the coreutils behind it, are not native host binaries. They are WebAssembly modules compiled ahead of time and mounted into the VM. This page diff --git a/website/src/content/docs/docs/architecture/filesystem.mdx b/website/src/content/docs/docs/architecture/filesystem.mdx index c3c873efc6..b7308e9556 100644 --- a/website/src/content/docs/docs/architecture/filesystem.mdx +++ b/website/src/content/docs/docs/architecture/filesystem.mdx @@ -4,6 +4,8 @@ description: "Internals of the kernel VFS: the overlay/mount/root engines, how g skill: true --- +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + This page is an internals deep-dive on the **kernel virtual filesystem (VFS)**: how it is layered, how a guest `fs` syscall is routed through it, and how guest I/O is confined to the VM. For the user-facing API (reading, writing, mounting, persistence), see [Filesystem](/docs/filesystem). The invariant this whole subsystem exists to uphold: **every guest filesystem operation is serviced by the kernel-owned VFS, never by a real host capability.** There is no host disk reachable from the guest. The VFS presents normal Linux semantics to tools while keeping every byte inside the kernel. diff --git a/website/src/content/docs/docs/architecture/limits-and-observability.mdx b/website/src/content/docs/docs/architecture/limits-and-observability.mdx index 65d56f8e79..7ab691bd2d 100644 --- a/website/src/content/docs/docs/architecture/limits-and-observability.mdx +++ b/website/src/content/docs/docs/architecture/limits-and-observability.mdx @@ -4,6 +4,8 @@ description: "How agentOS bounds resources, applies backpressure, warns before a skill: true --- +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + agentOS runs untrusted, AI-generated code inside disposable VMs. Every resource that code can consume is **bounded by default**, and every bound is designed to **warn before it is hit**, **fail with a clear error when it is**, and stay diff --git a/website/src/content/docs/docs/architecture/networking.mdx b/website/src/content/docs/docs/architecture/networking.mdx index 1d07c1ca62..d4dc2860cf 100644 --- a/website/src/content/docs/docs/architecture/networking.mdx +++ b/website/src/content/docs/docs/architecture/networking.mdx @@ -4,6 +4,8 @@ description: "How the kernel socket table works: a single VM-local transport tha skill: true --- +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + This is the internals view of agentOS networking: the kernel socket table, the layers a request crosses, and where policy is enforced. For the user-facing API (`vmFetch`, preview URLs, the confinement model from a caller's perspective), see [Networking & Previews](/docs/networking). For the trust boundary this all sits inside, see [Architecture](/docs/architecture). The governing rule is that there is exactly **one authoritative transport for everything VM-local**: the kernel socket table. No part of guest networking opens a real host socket on its own. Guest `fetch()`, `node:http`, `node:net`, WASM TCP clients and servers, and host-into-guest requests (`vmFetch` / `rt.fetch`) all target the same listener table. diff --git a/website/src/content/docs/docs/architecture/packages-and-command-resolution.mdx b/website/src/content/docs/docs/architecture/packages-and-command-resolution.mdx index 66ea3978bd..f1b19b841d 100644 --- a/website/src/content/docs/docs/architecture/packages-and-command-resolution.mdx +++ b/website/src/content/docs/docs/architecture/packages-and-command-resolution.mdx @@ -4,6 +4,8 @@ description: "How software is packaged, linked, resolved, and executed in an age skill: true --- +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + How a command name becomes a running program, and how the software that provides it is packaged and linked. Everything is real files under [`/opt/agentos`](/docs/architecture/filesystem) — there is no command registry; the diff --git a/website/src/content/docs/docs/architecture/posix-syscalls.mdx b/website/src/content/docs/docs/architecture/posix-syscalls.mdx index 8e698fdb34..7313311c38 100644 --- a/website/src/content/docs/docs/architecture/posix-syscalls.mdx +++ b/website/src/content/docs/docs/architecture/posix-syscalls.mdx @@ -4,6 +4,8 @@ description: "How agentOS extends WASI in two layers so WebAssembly guests behav skill: true --- +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + Not everything inside an agentOS VM is JavaScript. The shell (`sh`) and the coreutils behind [process execution](/docs/processes) ship as WebAssembly binaries, and you can run your own WASM programs too. To make those programs diff --git a/website/src/content/docs/docs/architecture/processes.mdx b/website/src/content/docs/docs/architecture/processes.mdx index f041cba4fe..219cca9bba 100644 --- a/website/src/content/docs/docs/architecture/processes.mdx +++ b/website/src/content/docs/docs/architecture/processes.mdx @@ -4,6 +4,8 @@ description: "Internals of the kernel process model: the virtual process table, skill: true --- +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + This page is an internals deep-dive on the kernel's **process model**: the data structures and syscall paths behind every guest process. For the client-facing API (`exec`, `spawn`, `openShell`, lifecycle, the process tree), see diff --git a/website/src/content/docs/docs/architecture/sessions-persistence.mdx b/website/src/content/docs/docs/architecture/sessions-persistence.mdx index 044506d617..ca8efa2e38 100644 --- a/website/src/content/docs/docs/architecture/sessions-persistence.mdx +++ b/website/src/content/docs/docs/architecture/sessions-persistence.mdx @@ -3,6 +3,8 @@ title: "Sessions & Persistence" description: "How agentOS, ACP, RivetKit actors, and durable session persistence fit together." --- +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + agentOS runs coding agents inside VMs and talks to them through the Agent Communication Protocol (ACP). RivetKit wraps those VMs in durable actors, so a session can survive actor sleep/wake even though the live VM and agent process diff --git a/website/src/content/docs/docs/persistence.mdx b/website/src/content/docs/docs/persistence.mdx index 158df487ec..aa3271d288 100644 --- a/website/src/content/docs/docs/persistence.mdx +++ b/website/src/content/docs/docs/persistence.mdx @@ -4,6 +4,8 @@ description: "How agentOS persists data and manages sleep/wake cycles." skill: true --- +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + agentOS automatically persists the `/home/agentos` filesystem and session transcripts (with sequence numbers for replay) across sleep/wake, sleeping after a configurable grace period (15 minutes by default) and waking automatically when a client connects or a cron job triggers. ## What persists across sleep diff --git a/website/src/content/docs/docs/system-prompt.mdx b/website/src/content/docs/docs/system-prompt.mdx index cc17c77b1c..da110372e8 100644 --- a/website/src/content/docs/docs/system-prompt.mdx +++ b/website/src/content/docs/docs/system-prompt.mdx @@ -4,6 +4,8 @@ description: "How agentOS injects context into agent sessions." skill: true --- +These internal architecture docs are mostly generated and maintained by LLMs, then reviewed by humans. They are intentionally verbose; use your preferred LLM to ask focused questions about the architecture as needed. + agentOS automatically injects a system prompt into every agent session that describes the VM environment and available commands and bindings. The prompt is additive and never replaces the agent's own instructions (CLAUDE.md, AGENTS.md, etc.). The base prompt is embedded in the sidecar (not written to a file inside the VM). At session start the sidecar assembles the base prompt with your additional instructions and generated binding docs, then injects the result into the agent adapter's launch arguments (for example, `--append-system-prompt` for Pi).