diff --git a/.gitignore b/.gitignore index 985e5c514c..cad3d19560 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,10 @@ crates/execution/.agentos-pyodide-cache/ crates/execution/async-out.txt crates/sidecar/.tmp-sidecar-tests/ packages/browser/.cache/ +examples/browser-terminal/.cache/ +examples/browser-terminal/.rivetkit-data/ +examples/browser-terminal/test-results/ +examples/experiments/browser-base-shell/.cache/ # Vendored V8 bridge bundles staged at release time for crates.io publishing crates/execution/assets/generated/ diff --git a/crates/agentos-actor-plugin/src/actions/shell.rs b/crates/agentos-actor-plugin/src/actions/shell.rs index 3c6824c609..ebe3ea9fb0 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/stderr streams. It pumps +//! the combined terminal bytes to clients as `shellData` and preserves the +//! dedicated diagnostic channel 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_terminal_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..931e5f40c2 100644 --- a/crates/client/src/agent_os.rs +++ b/crates/client/src/agent_os.rs @@ -76,10 +76,14 @@ pub(crate) struct ProcessEntry { /// `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`. +/// `terminal_tx` carries both streams in their original wire order for terminal renderers. Keeping +/// that merge at the single wire-event consumer avoids reordering terminal control sequences and +/// prompts when stdout/stderr are consumed by independent async tasks. pub(crate) struct ShellEntry { pub pid: u32, pub data_tx: broadcast::Sender>, pub stderr_tx: broadcast::Sender>, + pub terminal_tx: broadcast::Sender>, /// The sidecar-side process id used on the wire. pub process_id: String, /// Spawn-readiness gate. Seeded `false`; flips to `true` once the background `Execute` request is diff --git a/crates/client/src/shell.rs b/crates/client/src/shell.rs index fe52813d08..3bcd4f7525 100644 --- a/crates/client/src/shell.rs +++ b/crates/client/src/shell.rs @@ -31,7 +31,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 data / stderr / ordered terminal broadcasts. const SHELL_DATA_CHANNEL_CAPACITY: usize = 1024; /// Maximum active or spawning terminals created by `connect_terminal` per VM. @@ -242,6 +242,7 @@ impl AgentOs { let (data_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY); let (stderr_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY); + let (terminal_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY); // Spawn-readiness gate: write/close await this before issuing their wire request. let (spawned_tx, _) = tokio::sync::watch::channel(false); // Exit-code channel backing `wait_shell`. @@ -259,6 +260,7 @@ impl AgentOs { pid: 0, data_tx: data_tx.clone(), stderr_tx: stderr_tx.clone(), + terminal_tx: terminal_tx.clone(), process_id: process_id.clone(), spawned_tx: spawned_tx.clone(), exit_tx: exit_tx.clone(), @@ -352,6 +354,9 @@ impl AgentOs { if output.process_id != route_process_id { continue; } + // Preserve the exact wire order for PTY terminal renderers before also + // exposing the existing stdout/stderr-specific subscriptions. + let _ = terminal_tx.send(output.chunk.clone()); // stdout -> data stream; stderr -> separate stderr stream (TS routing). match output.channel { StreamChannel::Stdout => { @@ -418,12 +423,14 @@ impl AgentOs { let (data_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY); let (stderr_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY); + let (terminal_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY); let (spawned_tx, _) = tokio::sync::watch::channel(false); let entry = ShellEntry { pid: 0, data_tx: data_tx.clone(), stderr_tx: stderr_tx.clone(), + terminal_tx: terminal_tx.clone(), process_id: process_id.clone(), spawned_tx: spawned_tx.clone(), // The caller-supplied exit channel doubles as the entry's `wait_shell` source. @@ -502,6 +509,7 @@ impl AgentOs { if output.process_id != route_process_id { continue; } + let _ = terminal_tx.send(output.chunk.clone()); // Both stdout and stderr are appended to the same terminal output buffer // (the agent reads a single combined stream), matching the TS handle. on_output(&output.chunk); @@ -774,6 +782,21 @@ impl AgentOs { .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string())) } + /// Subscribe to stdout and stderr in the exact order received from the sidecar. This is the + /// PTY-facing stream: terminal control sequences, command output, and prompts must not be split + /// across independently scheduled consumers. The stdout-only and stderr-only subscriptions + /// remain available for callers that need channel identity. + pub fn on_shell_terminal_data( + &self, + shell_id: &str, + ) -> std::result::Result { + self.inner() + .shells + .read(shell_id, |_, entry| entry.terminal_tx.subscribe()) + .map(ByteStream::new) + .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string())) + } + /// Resize a shell's PTY winsize. SYNC fire-and-forget, mirroring the TS `ShellHandle.resize` /// (which dispatches `resizePty` in the background after the spawn lands). Errors with /// [`ClientError::ShellNotFound`]. diff --git a/crates/execution/assets/runners/wasi-module.js b/crates/execution/assets/runners/wasi-module.js index 7dfe4d0384..d888ca4347 100644 --- a/crates/execution/assets/runners/wasi-module.js +++ b/crates/execution/assets/runners/wasi-module.js @@ -50,6 +50,10 @@ if (typeof globalThis !== "undefined" && typeof globalThis.__agentOSWasiModule = const __agentOSWasiFiletypeRegularFile = 4; const __agentOSWasiFiletypeSymbolicLink = 7; const __agentOSWasiLookupSymlinkFollow = 1; + const __agentOSWasiFstflagsAtim = 1; + const __agentOSWasiFstflagsAtimNow = 2; + const __agentOSWasiFstflagsMtim = 4; + const __agentOSWasiFstflagsMtimNow = 8; const __agentOSWasiOpenCreate = 1; const __agentOSWasiOpenDirectory = 2; const __agentOSWasiOpenExclusive = 4; @@ -147,6 +151,7 @@ if (typeof globalThis !== "undefined" && typeof globalThis.__agentOSWasiModule = fd_write: (...args) => this._fdWrite(...args), path_create_directory: (...args) => this._pathCreateDirectory(...args), path_filestat_get: (...args) => this._pathFilestatGet(...args), + path_filestat_set_times: (...args) => this._pathFilestatSetTimes(...args), path_link: (...args) => this._pathLink(...args), path_open: (...args) => this._pathOpen(...args), path_readlink: (...args) => this._pathReadlink(...args), @@ -1227,9 +1232,10 @@ if (typeof globalThis !== "undefined" && typeof globalThis.__agentOSWasiModule = typeof bridgeStat?.applySyncPromise === "function" && typeof resolved?.guestPath === "string" ) { - return this._measureWasiPhase(follow ? "bridgeStatSync" : "bridgeLstatSync", () => - bridgeStat.applySyncPromise(void 0, [resolved.guestPath]) - ); + return this._measureWasiPhase(follow ? "bridgeStatSync" : "bridgeLstatSync", () => { + const stats = bridgeStat.applySyncPromise(void 0, [resolved.guestPath]); + return typeof stats === "string" ? JSON.parse(stats) : stats; + }); } const fsPath = this._resolvedFsPath(resolved); return this._measureWasiPhase(follow ? "fsModuleStatSync" : "fsModuleLstatSync", () => @@ -2348,6 +2354,56 @@ if (typeof globalThis !== "undefined" && typeof globalThis.__agentOSWasiModule = } } + _pathFilestatSetTimes(fd, lookupFlags, pathPtr, pathLen, atim, mtim, fstFlags) { + try { + const resolved = this._resolveDescriptorPath(fd, pathPtr, pathLen); + if (resolved.error !== __agentOSWasiErrnoSuccess) { + return resolved.error; + } + if (resolved.readOnly) { + return __agentOSWasiErrnoRofs; + } + const flags = Number(fstFlags) >>> 0; + if ( + (flags & __agentOSWasiFstflagsAtim) !== 0 && + (flags & __agentOSWasiFstflagsAtimNow) !== 0 + ) { + return __agentOSWasiErrnoInval; + } + if ( + (flags & __agentOSWasiFstflagsMtim) !== 0 && + (flags & __agentOSWasiFstflagsMtimNow) !== 0 + ) { + return __agentOSWasiErrnoInval; + } + const fsPath = this._resolvedFsPath(resolved); + const follow = (Number(lookupFlags) & __agentOSWasiLookupSymlinkFollow) !== 0; + const stats = follow + ? __agentOSFs().statSync(fsPath) + : __agentOSFs().lstatSync(fsPath); + const nowSeconds = Date.now() / 1000; + const atimeSeconds = (flags & __agentOSWasiFstflagsAtimNow) !== 0 + ? nowSeconds + : (flags & __agentOSWasiFstflagsAtim) !== 0 + ? Number(atim) / 1e9 + : Number(stats.atimeMs || 0) / 1000; + const mtimeSeconds = (flags & __agentOSWasiFstflagsMtimNow) !== 0 + ? nowSeconds + : (flags & __agentOSWasiFstflagsMtim) !== 0 + ? Number(mtim) / 1e9 + : Number(stats.mtimeMs || 0) / 1000; + this._clearStatCache(); + if (!follow && typeof __agentOSFs().lutimesSync === "function") { + __agentOSFs().lutimesSync(fsPath, atimeSeconds, mtimeSeconds); + } else { + __agentOSFs().utimesSync(fsPath, atimeSeconds, mtimeSeconds); + } + return __agentOSWasiErrnoSuccess; + } catch (error) { + return this._mapFsError(error); + } + } + _pathReadlink(fd, pathPtr, pathLen, bufPtr, bufLen, bufUsedPtr) { try { const resolved = this._resolveDescriptorPath(fd, pathPtr, pathLen); diff --git a/crates/execution/assets/wasi-preview1-imports.json b/crates/execution/assets/wasi-preview1-imports.json index 1aaaa41311..b0e322e343 100644 --- a/crates/execution/assets/wasi-preview1-imports.json +++ b/crates/execution/assets/wasi-preview1-imports.json @@ -22,6 +22,7 @@ "fd_write", "path_create_directory", "path_filestat_get", + "path_filestat_set_times", "path_link", "path_open", "path_readlink", diff --git a/crates/kernel/src/pty.rs b/crates/kernel/src/pty.rs index 7d90712076..b86a5efe0d 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, @@ -757,6 +758,9 @@ impl PtyManager { 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; } diff --git a/crates/native-sidecar/src/execution.rs b/crates/native-sidecar/src/execution.rs index ef336720d7..560cdaaa95 100644 --- a/crates/native-sidecar/src/execution.rs +++ b/crates/native-sidecar/src/execution.rs @@ -108,7 +108,9 @@ 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::{ + LineDisciplineConfig, PartialTermios, PartialTermiosControlChars, Termios, MAX_PTY_BUFFER_BYTES, +}; use agentos_kernel::resource_accounting::ResourceLimits; use agentos_kernel::root_fs::RootFilesystemMode; use agentos_kernel::socket_table::{ @@ -511,6 +513,7 @@ impl ActiveProcess { sqlite_statements: BTreeMap::new(), next_sqlite_statement_id: 0, tty_master_owner: None, + tty_restore_termios: None, deferred_kernel_wait_rpc: None, module_resolution_cache: agentos_execution::LocalModuleResolutionCache::default(), } @@ -4106,6 +4109,12 @@ where payload.rows, ) .map_err(kernel_error)?; + // The kernel delivers SIGWINCH to its process table, but embedded V8 + // executions are not host processes and cannot observe that table on + // their own. Mirror native terminal behavior by forwarding the signal + // into the runtime so readline/full-screen applications invalidate + // cached dimensions and query the resized PTY. + dispatch_v8_process_signal(process, libc::SIGWINCH)?; Ok(DispatchResult { response: self.respond( @@ -6577,28 +6586,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 depth 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 depth + 1 == 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 @@ -6898,6 +6965,13 @@ where .kernel .isatty(EXECUTION_DRIVER_NAME, kernel_pid, 1) .unwrap_or(false); + let inherited_tty_restore_termios = child_fd1_is_tty + .then(|| { + vm.kernel + .tcgetattr(EXECUTION_DRIVER_NAME, kernel_pid, 0) + .map_err(kernel_error) + }) + .transpose()?; let process = vm .active_processes .get_mut(process_id) @@ -6928,6 +7002,7 @@ where )) })?; child.tty_master_owner = inherited_tty_master_owner; + child.tty_restore_termios = inherited_tty_restore_termios; if let Some(kernel_stdin_writer_fd) = kernel_stdin_writer_fd { child.kernel_stdin_writer_fd = Some(kernel_stdin_writer_fd); } @@ -7075,11 +7150,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 +7168,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 @@ -7401,6 +7480,13 @@ where .kernel .isatty(EXECUTION_DRIVER_NAME, kernel_pid, 1) .unwrap_or(false); + let inherited_tty_restore_termios = child_fd1_is_tty + .then(|| { + vm.kernel + .tcgetattr(EXECUTION_DRIVER_NAME, kernel_pid, 0) + .map_err(kernel_error) + }) + .transpose()?; let root = vm .active_processes .get_mut(process_id) @@ -7437,6 +7523,7 @@ where )) })?; child.tty_master_owner = inherited_tty_master_owner; + child.tty_restore_termios = inherited_tty_restore_termios; if let Some(kernel_stdin_writer_fd) = kernel_stdin_writer_fd { child.kernel_stdin_writer_fd = Some(kernel_stdin_writer_fd); } @@ -7793,12 +7880,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 +7912,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 +7928,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 +8184,7 @@ where let detached_children = Self::adopt_detached_child_processes(&child_process_label, &mut child); sync_process_host_writes_to_kernel(vm, &child)?; + restore_inherited_child_tty(&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 +10628,78 @@ 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 Ok(header) = vm + .kernel + .pread_file(&script_path, 0, MAX_SHEBANG_LINE_BYTES + 1) + else { + return Ok(false); + }; + if !header.starts_with(b"#!") { + return Ok(false); + } + + 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 mut parts = text.split_ascii_whitespace(); + let interpreter = parts.next().ok_or_else(|| { + SidecarError::Execution(format!("ENOEXEC: invalid shebang line: {script_path}")) + })?; + let mut interpreter_args = parts.map(ToOwned::to_owned).collect::>(); + let command = if interpreter == "/usr/bin/env" || interpreter == "/bin/env" { + if interpreter_args.is_empty() { + return Err(SidecarError::Execution(format!( + "ENOENT: missing interpreter after /usr/bin/env in shebang: {script_path}" + ))); + } + interpreter_args.remove(0) + } else { + interpreter.to_owned() + }; + + interpreter_args.push(script_path); + interpreter_args.extend(resolved.execution_args.iter().cloned()); + request.command = command; + request.args = interpreter_args; + request.options.shell = false; + Ok(true) +} + fn resolve_guest_command_entrypoint( vm: &VmState, guest_cwd: &str, @@ -19090,6 +19251,7 @@ fn service_javascript_pty_set_raw_mode_sync_rpc( // cursor/CRLF is not mangled by the line discipline; cooked mode // re-enables it so the line shell gets LF->CRLF. LineDisciplineConfig { + icrnl: Some(!enabled), canonical: Some(!enabled), echo: Some(!enabled), isig: Some(!enabled), @@ -19101,6 +19263,47 @@ fn service_javascript_pty_set_raw_mode_sync_rpc( Ok(Value::Null) } +fn restore_inherited_child_tty( + kernel: &mut SidecarKernel, + child: &ActiveProcess, +) -> Result<(), SidecarError> { + let Some(termios) = child.tty_restore_termios.as_ref() else { + return Ok(()); + }; + let restore_pid = child + .tty_master_owner + .map(|(owner_pid, _)| owner_pid) + .unwrap_or(child.kernel_pid); + kernel + .tcsetattr( + EXECUTION_DRIVER_NAME, + restore_pid, + 0, + partial_termios_from_snapshot(termios), + ) + .map_err(kernel_error) +} + +fn partial_termios_from_snapshot(termios: &Termios) -> PartialTermios { + PartialTermios { + icrnl: Some(termios.icrnl), + opost: Some(termios.opost), + onlcr: Some(termios.onlcr), + icanon: Some(termios.icanon), + echo: Some(termios.echo), + isig: Some(termios.isig), + cc: Some(PartialTermiosControlChars { + vintr: Some(termios.cc.vintr), + vquit: Some(termios.cc.vquit), + vsusp: Some(termios.cc.vsusp), + veof: Some(termios.cc.veof), + verase: Some(termios.cc.verase), + vkill: Some(termios.cc.vkill), + vwerase: Some(termios.cc.vwerase), + }), + } +} + fn service_javascript_kernel_isatty_sync_rpc( kernel: &mut SidecarKernel, process: &ActiveProcess, diff --git a/crates/native-sidecar/src/state.rs b/crates/native-sidecar/src/state.rs index bb290b277c..a995c8b1c5 100644 --- a/crates/native-sidecar/src/state.rs +++ b/crates/native-sidecar/src/state.rs @@ -16,6 +16,7 @@ use agentos_execution::{ }; use agentos_kernel::kernel::{KernelProcessHandle, KernelVm}; use agentos_kernel::mount_table::MountTable; +use agentos_kernel::pty::Termios; use agentos_kernel::root_fs::RootFilesystemMode; use agentos_kernel::socket_table::SocketId; use agentos_native_sidecar_core::VmLayerStore; @@ -508,6 +509,12 @@ 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)>, + /// Terminal attributes inherited at spawn by a child sharing an ancestor's + /// PTY. Restored when the child is reaped so a full-screen command cannot + /// strand its caller in raw mode. This is the exact inherited state rather + /// than an assumed cooked configuration, so nested raw-mode callers remain + /// raw after their child exits. + pub(crate) tty_restore_termios: 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/service.rs b/crates/native-sidecar/tests/service.rs index a1e4c98697..4b4fbd7f60 100644 --- a/crates/native-sidecar/tests/service.rs +++ b/crates/native-sidecar/tests/service.rs @@ -5669,6 +5669,7 @@ ykAheWCsAteSEWVc0w==\n\ assert!(!termios.icanon); assert!(!termios.echo); assert!(!termios.isig); + assert!(!termios.icrnl); } let cooked = call_javascript_sync_rpc( @@ -5695,6 +5696,7 @@ ykAheWCsAteSEWVc0w==\n\ assert!(termios.icanon); assert!(termios.echo); assert!(termios.isig); + assert!(termios.icrnl); } sidecar @@ -21283,6 +21285,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 +21486,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/examples/browser-terminal/README.md b/examples/browser-terminal/README.md index eef3e036e0..cd64833aec 100644 --- a/examples/browser-terminal/README.md +++ b/examples/browser-terminal/README.md @@ -1,13 +1,25 @@ --- title: "Browser Terminal" -description: "A full xterm.js terminal in the browser for Agent OS VMs, driving PTY shells over the shipped agentOS() RivetKit actor with a VM sidebar, tabs, and reconnect." +description: "Two full xterm.js Agent OS terminals: one running the VM locally in the browser, and one driving a durable VM through the agentOS() RivetKit actor API." category: "Processes & Shell" order: 2 --- -A full terminal for Agent OS VMs that runs in the browser, talking to the shipped -Agent OS actor (`agentOS()` from `@rivet-dev/agentos`) over its live -[RivetKit](https://rivetkit.org) connection — no bespoke WebSocket server. +This example contains two deliberately separate versions of the same xterm + PTY +demo: + +- **In-browser VM** (`/browser.html`) — the Agent OS WASM sidecar, kernel, VFS, + shell commands, Pi CLI, and PTYs all execute inside the browser tab. It does not + call the Actor API. Each terminal tab is an isolated in-memory browser VM. +- **Actor API** (`/actor.html`) — the React client talks to the shipped Agent OS + actor (`agentOS()` from `@rivet-dev/agentos`) over its live + [RivetKit](https://rivetkit.org) connection. The VM and PTYs execute behind the + actor and survive browser reconnects. + +The root page (`/`) is a mode selector and both terminal pages are visibly labeled +with their execution boundary. + +## Actor API version - **Left sidebar** — a list of VMs. Each is one Agent OS VM (one RivetKit actor instance). The VM ids are kept in `localStorage`, so reopening the page — or @@ -17,26 +29,47 @@ Agent OS actor (`agentOS()` from `@rivet-dev/agentos`) over its live reconnects re-adopts the running shells (by the ids it saved in `localStorage`) and resumes their live I/O. -## How it works +### How it works ``` 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 ordered PTY bytes ``` 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 +and renders stdout/stderr in their original PTY wire order from the combined +`shellData` broadcast event (routed by `shellId`, with a small pre-subscription +buffer). A separate `shellStderr` event remains available for diagnostics but is +not rendered a second time. 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. +Rust plugin, so there is no Node terminal proxy — `registry.start()` hosts the +actor and the browser talks to it directly. + +## In-browser VM version + +The browser-local terminal uses the production browser runtime driver and +converged Agent OS WASM sidecar. The shell is the real Brush WASM shell. Real +WASM executable bytes for Vim, Git, Bash, and the minimal core command set are +written under `/opt/agentos/pkgs/browser-terminal/0.0.1/bin` and linked through +`/opt/agentos/bin`, `/bin`, and `/usr/bin`; the process host reads the executable +selected by guest `PATH` lookup. There are no empty executable markers or +demo-specific basename dispatch. Pi is the real bundled Pi CLI attached to an +Agent OS browser PTY. A clearly labeled deterministic model adapter makes the Pi +prompt round trip reproducible without credentials or a network model. + +Both pages launch upstream Brush, compiled with the generic Agent OS WASI patch +set, using Brush's built-in `minimal` interactive input backend. Brush still runs +on the Agent OS PTY; this backend avoids cursor-position queries whose +action-by-action round trip can be visible over a browser transport. Vim retains +its normal raw/full-screen PTY behavior. + +No terminal bytes, filesystem operations, or process execution cross the Actor +API in this version. -## Run +## Run both versions From the repo root: @@ -51,8 +84,25 @@ or from this directory: pnpm dev # RivetKit server (:6420) + Vite (:5173) ``` -Open http://localhost:5173, click **+ New VM**, then **+** to open a terminal and -start typing (`ls`, `echo hi | tr a-z A-Z`, `cd /tmp`, …). +Open http://localhost:5173 and choose a mode. In the actor version, click **+ New +VM** before opening a terminal. In the in-browser version, open **+ shell** or +**+ pi** directly. + +Build a deployable static demo explicitly with `pnpm build:demo`. The ordinary +workspace `pnpm build` only runs the TypeScript gate because browser WASM asset +assembly requires `wasm-pack`; `pnpm dev`, `pnpm build:demo`, and `pnpm test:e2e` +prepare those runnable assets. + +The end-to-end gate builds the browser runtime assets plus the repository's +native Agent OS sidecar and actor plugin. In both modes it drives real Vim over +the PTY to write a shell script, marks and executes that script, creates a local +Git commit, checks the commit id, and verifies Brush never emits the former +missing-child-PID warning. It also proves a Pi TUI/model turn independently in +both modes: + +```bash +pnpm test:e2e +``` Run the pieces separately if you prefer: @@ -64,15 +114,26 @@ pnpm web # Vite dev server on :5173 Override the web→server endpoint with `VITE_AGENTOS_ENDPOINT` (default `http://localhost:6420`). +The in-browser runtime requires cross-origin isolation. This Vite configuration +sets `Cross-Origin-Opener-Policy: same-origin` and +`Cross-Origin-Embedder-Policy: require-corp` for both dev and preview; configure +the same headers if serving `dist/` from another static host. + +The demo defaults `RIVETKIT_STORAGE_PATH` to a local ignored +`.rivetkit-data/` directory so it can run alongside RivetKit servers from other +workspaces. Set the environment variable explicitly to use a different durable +location. + ## Notes -- Software: `@agentos-software/common` (provides `sh` + coreutils) plus `git`, - `curl`, `ripgrep`, `jq`, and `sqlite3`. Agent OS has no vim/editor package, so - there is no in-VM editor. +- Actor software: `@agentos-software/common` (core commands), `fd`, `ripgrep`, + `git`, `vim`, and `@agentos-software/pi` (the Pi CLI/TUI and ACP adapter). +- Pi boots without credentials and displays `no-model` until you use `/login` or + provide one of Pi's supported API-key environment variables. - The shipped actor has no `listShells` action and keeps no server-side scrollback, so reconnect re-adopts saved shell ids and resumes **live** output only (history from before the reload is not replayed). Stale ids (VM recreated) are dropped after a liveness probe. -- The VM shell is line-buffered (it only echoes a line on Enter), so the client - does **local echo + line editing** (printable chars, Backspace, Ctrl-C) and - suppresses the shell's own echo of the submitted line to avoid double display. +- xterm input is forwarded byte-for-byte to the VM PTY. Echo, line editing, + control keys, cursor movement, and full-screen rendering are owned by the + guest terminal stack, so interactive programs such as Pi work normally. diff --git a/examples/browser-terminal/actor.html b/examples/browser-terminal/actor.html new file mode 100644 index 0000000000..1525c1e012 --- /dev/null +++ b/examples/browser-terminal/actor.html @@ -0,0 +1,12 @@ + + + + + + Agent OS · Actor API Terminal + + +
+ + + diff --git a/examples/browser-terminal/browser.html b/examples/browser-terminal/browser.html new file mode 100644 index 0000000000..37788c609f --- /dev/null +++ b/examples/browser-terminal/browser.html @@ -0,0 +1,12 @@ + + + + + + Agent OS · In-browser VM Terminal + + +
+ + + diff --git a/examples/browser-terminal/index.html b/examples/browser-terminal/index.html index 280bbdc860..b9334c6a63 100644 --- a/examples/browser-terminal/index.html +++ b/examples/browser-terminal/index.html @@ -3,10 +3,35 @@ - Agent OS · Browser Terminal + Agent OS · Terminal Demos + -
- +
+

Agent OS terminal demos

+

The same xterm + PTY experience, with two different runtime boundaries.

+ +
diff --git a/examples/browser-terminal/local-pi.html b/examples/browser-terminal/local-pi.html new file mode 100644 index 0000000000..0b957ed0e6 --- /dev/null +++ b/examples/browser-terminal/local-pi.html @@ -0,0 +1,19 @@ + + + + + + Agent OS browser-local Pi + + + +
loading
+
+ + + diff --git a/examples/browser-terminal/local-shell.html b/examples/browser-terminal/local-shell.html new file mode 100644 index 0000000000..2a4cb9333d --- /dev/null +++ b/examples/browser-terminal/local-shell.html @@ -0,0 +1,19 @@ + + + + + + Agent OS browser-local shell + + + +
loading
+
+ + + diff --git a/examples/browser-terminal/package.json b/examples/browser-terminal/package.json index 954ea7c5be..6ca3791278 100644 --- a/examples/browser-terminal/package.json +++ b/examples/browser-terminal/package.json @@ -5,20 +5,29 @@ "type": "module", "description": "Browser terminal for Agent OS VMs — RivetKit actor + xterm.js.", "scripts": { + "prepare:commands": "node ./scripts/prepare-commands.mjs", + "prepare:browser-assets": "pnpm prepare:commands && pnpm --dir ../../packages/runtime-browser build && pnpm --dir ../../packages/browser build:wasm-test-assets", "server": "tsx server.ts", "web": "vite", - "dev": "concurrently -k -n server,web -c blue,magenta \"tsx server.ts\" \"vite\"", - "build": "vite build", + "dev:ready": "concurrently -k -n server,web -c blue,magenta \"tsx server.ts\" \"vite\"", + "dev": "pnpm prepare:browser-assets && pnpm dev:ready", + "build": "tsc --noEmit", + "build:demo": "pnpm prepare:browser-assets && vite build", "preview": "vite preview", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "test:e2e": "node ./scripts/run-e2e.mjs", + "test:e2e:connected": "playwright test" }, "dependencies": { "@agentos-software/common": "workspace:*", + "@agentos-software/fd": "workspace:*", + "@agentos-software/pi": "workspace:*", "@agentos-software/curl": "workspace:*", "@agentos-software/git": "workspace:*", "@agentos-software/jq": "workspace:*", "@agentos-software/ripgrep": "workspace:*", "@agentos-software/sqlite3": "workspace:*", + "@agentos-software/vim": "workspace:*", "@rivet-dev/agentos": "workspace:*", "@rivetkit/react": "catalog:rivetkit", "@xterm/addon-fit": "^0.10.0", @@ -28,6 +37,7 @@ "rivetkit": "catalog:rivetkit" }, "devDependencies": { + "@playwright/test": "^1.57.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", diff --git a/examples/browser-terminal/playwright.config.ts b/examples/browser-terminal/playwright.config.ts new file mode 100644 index 0000000000..fd4f59b5a9 --- /dev/null +++ b/examples/browser-terminal/playwright.config.ts @@ -0,0 +1,24 @@ +import { existsSync } from "node:fs"; +import { defineConfig, devices } from "@playwright/test"; + +const executablePath = + process.env.AGENTOS_CHROME_EXECUTABLE_PATH ?? + (existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : undefined); + +export default defineConfig({ + testDir: "./tests", + timeout: 180_000, + use: { + baseURL: process.env.BROWSER_TERMINAL_BASE_URL ?? "http://127.0.0.1:5173", + trace: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + ...(executablePath ? { launchOptions: { executablePath } } : {}), + }, + }, + ], +}); diff --git a/examples/browser-terminal/scripts/prepare-commands.mjs b/examples/browser-terminal/scripts/prepare-commands.mjs new file mode 100644 index 0000000000..fb19c45064 --- /dev/null +++ b/examples/browser-terminal/scripts/prepare-commands.mjs @@ -0,0 +1,74 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolve(packageRoot, "../.."); +const commandsDir = resolve( + repoRoot, + "registry/native/target/wasm32-wasip1/release/commands", +); + +function runMake(command) { + const result = spawnSync("make", ["-C", "registry/native", `cmd/${command}`], { + cwd: repoRoot, + stdio: "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} + +function runPnpm(packageDirectory, script) { + const result = spawnSync("pnpm", ["--dir", packageDirectory, script], { + cwd: repoRoot, + stdio: "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} + +// Brush's WASM child-PID fix is a vendored patch. Fingerprinting the tracked +// inputs prevents an old ignored target artifact from silently restoring the +// warning after a checkout or branch update. +const shInputs = [ + resolve(repoRoot, "registry/native/crates/commands/sh/Cargo.toml"), + resolve(repoRoot, "registry/native/crates/commands/sh/src/main.rs"), + resolve( + repoRoot, + "registry/native/patches/crates/brush-core/0004-wasi-child-pid.patch", + ), +]; +const shFingerprint = createHash("sha256") + .update(shInputs.map((path) => readFileSync(path)).join("\0")) + .digest("hex"); +const cacheDir = resolve(packageRoot, ".cache"); +const shStamp = resolve(cacheDir, "sh-command.sha256"); +const shCommand = resolve(commandsDir, "sh"); +const currentShFingerprint = existsSync(shStamp) + ? readFileSync(shStamp, "utf8").trim() + : ""; + +if (!existsSync(shCommand) || currentShFingerprint !== shFingerprint) { + runMake("sh"); + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(shStamp, `${shFingerprint}\n`); +} else { + console.log(`using current Brush command ${shCommand}`); +} + +const vimCommand = resolve(commandsDir, "vim"); +if (!existsSync(vimCommand)) runMake("vim"); +else console.log(`using existing Vim command ${vimCommand}`); + +// The Actor mode consumes packed software, not the browser's /commands URLs. +// Repack coreutils after the shell overlay so its package contains the complete +// checked-in command set plus the freshly patched Brush binary. +runPnpm(resolve(repoRoot, "packages/agentos-toolchain"), "build"); +runPnpm(resolve(repoRoot, "registry/software/coreutils"), "build"); diff --git a/examples/browser-terminal/scripts/run-e2e.mjs b/examples/browser-terminal/scripts/run-e2e.mjs new file mode 100644 index 0000000000..d276721f11 --- /dev/null +++ b/examples/browser-terminal/scripts/run-e2e.mjs @@ -0,0 +1,141 @@ +import { execFileSync, spawn } from "node:child_process"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const workspaceRoot = resolve(packageRoot, "../.."); +const storagePath = resolve( + tmpdir(), + `agentos-browser-terminal-e2e-${process.pid}`, +); +const pluginFilename = + process.platform === "darwin" + ? "libagentos_actor_plugin.dylib" + : process.platform === "win32" + ? "agentos_actor_plugin.dll" + : "libagentos_actor_plugin.so"; +const nativeEnv = { + AGENTOS_PLUGIN_BIN: resolve( + workspaceRoot, + "target", + "debug", + pluginFilename, + ), + AGENTOS_SIDECAR_BIN: resolve( + workspaceRoot, + "target", + "debug", + process.platform === "win32" ? "agentos-sidecar.exe" : "agentos-sidecar", + ), +}; + +async function waitFor(url, timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs; + let lastError; + while (Date.now() < deadline) { + try { + const response = await fetch(url); + if (response.ok) return; + lastError = new Error(`health check returned HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); + } + throw new Error(`timed out waiting for ${url}`, { cause: lastError }); +} + +function run(command, args, options = {}) { + return spawn(command, args, { + cwd: packageRoot, + env: process.env, + stdio: "inherit", + ...options, + }); +} + +function listenerPids(port) { + try { + return execFileSync( + "lsof", + ["-tiTCP:" + port, "-sTCP:LISTEN"], + { encoding: "utf8" }, + ) + .trim() + .split("\n") + .filter(Boolean) + .map(Number); + } catch (error) { + if (error?.status !== 1) { + console.error(`failed to inspect listener on port ${port}`, error); + } + return []; + } +} + +execFileSync("pnpm", ["prepare:browser-assets"], { + cwd: packageRoot, + stdio: "inherit", +}); +execFileSync( + "cargo", + ["build", "-p", "agentos-sidecar", "-p", "agentos-actor-plugin"], + { + cwd: workspaceRoot, + stdio: "inherit", + }, +); + +for (const port of [5173, 6420]) { + if (listenerPids(port).length > 0) { + throw new Error(`port ${port} is already in use; stop the demo before running E2E`); + } +} + +const dev = run("pnpm", ["dev:ready"], { + detached: true, + env: { + ...process.env, + ...nativeEnv, + RIVETKIT_STORAGE_PATH: storagePath, + }, +}); + +let exitCode = 1; +try { + await Promise.all([ + waitFor("http://127.0.0.1:5173"), + waitFor("http://127.0.0.1:6420/health"), + ]); + const test = run("pnpm", ["run", "test:e2e:connected"]); + exitCode = await new Promise((resolveExit) => { + test.once("exit", (code) => resolveExit(code ?? 1)); + }); +} finally { + try { + process.kill(-dev.pid, "SIGTERM"); + } catch (error) { + console.warn(`failed to terminate demo process group ${dev.pid}`, error); + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 250)); + try { + process.kill(-dev.pid, "SIGKILL"); + } catch (error) { + if (error?.code !== "ESRCH") { + console.warn(`failed to reap demo process group ${dev.pid}`, error); + } + } + // RivetKit intentionally detaches its local engine so it can outlive the + // registry process. These ports were verified free above, so any listener + // here belongs to this E2E invocation and must be torn down with it. + for (const pid of listenerPids(6420)) { + try { + process.kill(pid, "SIGTERM"); + } catch (error) { + console.warn(`failed to terminate detached engine ${pid}`, error); + } + } +} + +process.exitCode = exitCode; diff --git a/examples/browser-terminal/server.ts b/examples/browser-terminal/server.ts index 6aac03e596..fb8ee6d2e8 100644 --- a/examples/browser-terminal/server.ts +++ b/examples/browser-terminal/server.ts @@ -1,10 +1,40 @@ +import { resolve } from "node:path"; import common from "@agentos-software/common"; +import fd from "@agentos-software/fd"; +import git from "@agentos-software/git"; +import pi from "@agentos-software/pi"; +import ripgrep from "@agentos-software/ripgrep"; +import vim from "@agentos-software/vim"; import { agentOS, setup } from "@rivet-dev/agentos"; -// TEMP (local debug): only `common` (sha 42d8146) has valid projected packs; -// `sqlite3` (efc374f) is missing its `dist/package`, so it's dropped for now. +// Rivet engines intentionally outlive their host process. Keep this demo's +// RocksDB state workspace-local so another checkout's engine cannot hold its +// database lock and prevent this one from starting. +process.env.RIVETKIT_STORAGE_PATH ??= resolve( + import.meta.dirname, + ".rivetkit-data", +); + const shellVm = agentOS({ - software: [common], + software: [common, fd, ripgrep, git, vim, pi], + permissions: { + network: { + default: "deny", + rules: [ + { + mode: "allow", + operations: ["http"], + patterns: ["tcp://127.0.0.1:6431"], + }, + { + // Guest TCP clients bind a loopback ephemeral port before connect. + mode: "allow", + operations: ["listen"], + patterns: ["tcp://127.0.0.1:*"], + }, + ], + }, + }, }); export const registry = setup({ use: { shellVm } }); diff --git a/examples/browser-terminal/src/ActorView.tsx b/examples/browser-terminal/src/ActorView.tsx index 155a39c637..a11b5fbae0 100644 --- a/examples/browser-terminal/src/ActorView.tsx +++ b/examples/browser-terminal/src/ActorView.tsx @@ -15,6 +15,108 @@ interface ShellDataPayload { interface ShellExitPayload { shellId: string; } +interface ProcessOutputPayload { + pid: number; + stream: "stdout" | "stderr"; + data: unknown; +} +interface ActorProcessInfo { + pid: number; + command: string; + args: string[]; + running: boolean; +} + +const ACTOR_PI_ENV = { + HOME: "/home/agentos", + PI_CODING_AGENT_DIR: "/home/agentos/.pi/agent", +}; +const ACTOR_PI_AGENT_DIR = ACTOR_PI_ENV.PI_CODING_AGENT_DIR; +const ACTOR_PI_MODEL_SERVER = "/home/agentos/actor-demo-model.mjs"; +const ACTOR_PI_PROVIDER = "actor-demo"; +const ACTOR_PI_MODEL = "deterministic"; +const ACTOR_PI_MODEL_SERVER_SOURCE = ` +import { createServer } from "node:http"; +const MAX_REQUEST_BYTES = 1024 * 1024; +function event(name, data) { return "event: " + name + "\\ndata: " + JSON.stringify(data) + "\\n\\n"; } +function reply(prompt) { + const exact = prompt.match(/reply exactly\\s+([A-Z0-9_]+)/i)?.[1]; + return "[MOCK actor model — not real] " + (exact ?? ("Received: " + prompt.trim().slice(0, 240))); +} +function sse(text) { + return [ + event("message_start", { type: "message_start", message: { id: "msg_actor_demo", type: "message", role: "assistant", model: "actor-demo", content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } } }), + event("content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }), + event("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text } }), + event("content_block_stop", { type: "content_block_stop", index: 0 }), + event("message_delta", { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 1 } }), + event("message_stop", { type: "message_stop" }), + ].join(""); +} +const server = createServer((request, response) => { + if (request.method !== "POST" || request.url !== "/v1/messages") { + response.writeHead(404).end("not found"); + return; + } + let size = 0; + const chunks = []; + request.on("data", (chunk) => { + size += chunk.length; + if (size > MAX_REQUEST_BYTES) { + response.writeHead(413).end("request too large"); + request.destroy(); + return; + } + chunks.push(chunk); + }); + request.on("end", () => { + if (response.writableEnded) return; + try { + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + const message = [...(body.messages ?? [])].reverse().find((entry) => entry.role === "user"); + const prompt = typeof message?.content === "string" + ? message.content + : (message?.content ?? []).map((part) => part?.text ?? "").join(""); + response.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-store" }); + response.end(sse(reply(prompt))); + } catch (error) { + console.error("actor demo model rejected malformed input", error); + response.writeHead(400).end("malformed request"); + } + }); + request.on("error", (error) => console.error("actor demo model request failed", error)); +}); +await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(6431, "127.0.0.1", resolve); +}); +console.error("actor demo model ready on 6431"); +await new Promise(() => {}); +`; +const ACTOR_PI_MODELS = JSON.stringify({ + providers: { + [ACTOR_PI_PROVIDER]: { + baseUrl: "http://127.0.0.1:6431", + api: "anthropic-messages", + apiKey: "sk-agentos-actor-demo", + models: [ + { + id: ACTOR_PI_MODEL, + name: "Actor demo model (mock)", + reasoning: false, + input: ["text"], + contextWindow: 8192, + maxTokens: 1024, + }, + ], + }, + }, +}); +const ACTOR_PI_SETTINGS = JSON.stringify({ + defaultProvider: ACTOR_PI_PROVIDER, + defaultModel: ACTOR_PI_MODEL, + enabledModels: [`${ACTOR_PI_PROVIDER}/${ACTOR_PI_MODEL}`], +}); function toBytes(data: unknown): Uint8Array { if (data instanceof Uint8Array) return data; @@ -38,6 +140,7 @@ export function ActorView({ actorId }: { actorId: string }) { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const initedRef = useRef(false); + const modelServerPidRef = useRef(null); const writers = useRef void>>(new Map()); const pending = useRef>(new Map()); @@ -95,16 +198,27 @@ 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), ); + const offProcessOutput = events.on( + "processOutput", + (payload: ProcessOutputPayload) => { + if (payload.stream !== "stderr") return; + const message = new TextDecoder().decode(toBytes(payload.data)).trim(); + if (!message) return; + if (message.includes("actor demo model ready")) { + console.info(`actor process ${payload.pid}: ${message}`); + return; + } + console.error(`actor process ${payload.pid}: ${message}`); + setError(`Actor Pi demo model: ${message}`); + }, + ); return () => { offData(); - offStderr(); offExit(); + offProcessOutput(); }; }, [conn, dispatchData, dropTab]); @@ -140,18 +254,61 @@ export function ActorView({ actorId }: { actorId: string }) { setTabs([]); setActive(null); setError(null); + modelServerPidRef.current = null; }, [actorId]); - const openShell = useCallback(async () => { + const openShell = useCallback(async (command?: string) => { if (!conn) return; setBusy(true); setError(null); try { - const { shellId } = await conn.openShell({ cols: 80, rows: 24 }); + if (command === "pi") { + for (const directory of ["/home/agentos/.pi", ACTOR_PI_AGENT_DIR]) { + if (!(await conn.exists(directory))) await conn.mkdir(directory); + } + await conn.writeFiles([ + { path: `${ACTOR_PI_AGENT_DIR}/models.json`, content: ACTOR_PI_MODELS }, + { + path: `${ACTOR_PI_AGENT_DIR}/settings.json`, + content: ACTOR_PI_SETTINGS, + }, + { + path: ACTOR_PI_MODEL_SERVER, + content: ACTOR_PI_MODEL_SERVER_SOURCE, + }, + ]); + if (modelServerPidRef.current === null) { + const runningServer = (await conn.listProcesses()).find( + (process: ActorProcessInfo) => + process.running && + process.command === "node" && + process.args.includes(ACTOR_PI_MODEL_SERVER), + ); + if (runningServer) { + modelServerPidRef.current = runningServer.pid; + } else { + const { pid } = await conn.spawn("node", [ACTOR_PI_MODEL_SERVER]); + modelServerPidRef.current = pid; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + } + const { shellId } = await conn.openShell({ + ...(command ? { command } : {}), + ...(!command + ? { args: ["--input-backend", "minimal", "-i"] } + : {}), + ...(command === "pi" ? { env: ACTOR_PI_ENV } : {}), + cols: 80, + rows: 24, + }); setTabs((prev) => { const next = [ ...prev, - { shellId, title: `shell ${prev.length + 1}` }, + { + shellId, + title: command === "pi" ? "pi" : `shell ${prev.length + 1}`, + }, ]; saveShellIds( actorId, @@ -172,13 +329,22 @@ export function ActorView({ actorId }: { actorId: string }) { dropTab(shellId); try { await conn?.closeShell(shellId); - } catch {} + } catch (cause) { + setError(`Failed to close terminal: ${String(cause)}`); + } }, [conn, dropTab], ); return (
+
+ ACTOR API + + Runtime and PTYs execute behind the RivetKit actor. Pi uses the + labeled deterministic demo model. + +
{tabs.map((t) => (
void openShell()} - title="New terminal" + title="New shell terminal" + > + + shell + + {conn ? "connected" : "connecting…"} @@ -230,6 +405,7 @@ export function ActorView({ actorId }: { actorId: string }) { shellId={t.shellId} active={t.shellId === active} onInput={(text) => conn?.writeShell(t.shellId, text)} + onError={(cause) => setError(String(cause))} onResize={(cols, rows) => conn?.resizeShell(t.shellId, cols, rows)} subscribe={subscribe(t.shellId)} /> diff --git a/examples/browser-terminal/src/App.tsx b/examples/browser-terminal/src/App.tsx index bb5ab20b5a..86fac85845 100644 --- a/examples/browser-terminal/src/App.tsx +++ b/examples/browser-terminal/src/App.tsx @@ -33,7 +33,7 @@ export function App() {
+ Open in-browser version →
diff --git a/examples/browser-terminal/src/BrowserApp.tsx b/examples/browser-terminal/src/BrowserApp.tsx new file mode 100644 index 0000000000..f5604335b4 --- /dev/null +++ b/examples/browser-terminal/src/BrowserApp.tsx @@ -0,0 +1,134 @@ +import { useEffect, useRef, useState } from "react"; + +type TerminalKind = "shell" | "pi"; + +interface LocalTab { + id: string; + kind: TerminalKind; + title: string; +} + +type LocalFrameWindow = Window; + +declare global { + interface Window { + __agentOSBrowserTerminalDemo?: { + screens(): Record; + write(id: string, data: string): Promise; + askPi(id: string, prompt: string): Promise; + }; + } +} + +let nextTab = 1; + +export function BrowserApp() { + const [tabs, setTabs] = useState([]); + const [active, setActive] = useState(null); + const frames = useRef(new Map()); + + useEffect(() => { + window.__agentOSBrowserTerminalDemo = { + screens: () => + Object.fromEntries( + tabs.map((tab) => { + const frame = frames.current.get(tab.id)?.contentWindow as LocalFrameWindow | null; + return [ + tab.id, + tab.kind === "shell" + ? (frame?.__realTerminal?.screen() ?? "") + : (frame?.__piTui?.screen() ?? ""), + ]; + }), + ), + write: async (id, data) => { + const tab = tabs.find((candidate) => candidate.id === id); + const frame = frames.current.get(id)?.contentWindow as LocalFrameWindow | null; + if (!tab || !frame) throw new Error(`unknown browser-local terminal ${id}`); + if (tab.kind === "shell") await frame.__realTerminal?.write(data); + else await frame.__piTui?.write(data); + }, + askPi: async (id, prompt) => { + const frame = frames.current.get(id)?.contentWindow as LocalFrameWindow | null; + if (!frame?.__piTui) throw new Error(`Pi terminal ${id} is not ready`); + return frame.__piTui.ask(prompt); + }, + }; + return () => { + delete window.__agentOSBrowserTerminalDemo; + }; + }, [tabs]); + + const open = (kind: TerminalKind) => { + const sequence = nextTab++; + const tab: LocalTab = { + id: `browser-${kind}-${sequence}`, + kind, + title: kind === "pi" ? "pi" : `shell ${sequence}`, + }; + setTabs((current) => [...current, tab]); + setActive(tab.id); + }; + + const close = (id: string) => { + frames.current.delete(id); + setTabs((current) => { + const next = current.filter((tab) => tab.id !== id); + setActive((selected) => selected === id ? (next.at(-1)?.id ?? null) : selected); + return next; + }); + }; + + return ( +
+ + +
+
+
+ IN-BROWSER VM + No Actor API. Runtime and PTYs execute in this tab. +
+
+ {tabs.map((tab) => ( +
setActive(tab.id)}> + {tab.title} + +
+ ))} + + + local · ready +
+
+ {tabs.length === 0 &&
No terminals yet — open a browser-local shell or Pi PTY.
} + {tabs.map((tab) => ( +