diff --git a/crates/bridge/bridge-contract.json b/crates/bridge/bridge-contract.json index 13574c5db..c3351bdad 100644 --- a/crates/bridge/bridge-contract.json +++ b/crates/bridge/bridge-contract.json @@ -219,6 +219,8 @@ "_sqliteStatementFinalizeRaw", "_kernelStdioWriteRaw", "_kernelPollRaw", + "_kernelIsattyRaw", + "_kernelTtySizeRaw", "_ptySetRawMode" ] }, diff --git a/crates/execution/assets/v8-bridge.source.js b/crates/execution/assets/v8-bridge.source.js index 045ac73a5..acf8ad242 100644 --- a/crates/execution/assets/v8-bridge.source.js +++ b/crates/execution/assets/v8-bridge.source.js @@ -3943,6 +3943,16 @@ var __bridge = (() => { classification: "hardened", rationale: "Host kernel poll bridge reference for multi-fd readiness waits." }, + { + name: "_kernelIsattyRaw", + classification: "hardened", + rationale: "Host kernel TTY detection bridge reference for WASM terminal commands." + }, + { + name: "_kernelTtySizeRaw", + classification: "hardened", + rationale: "Host kernel TTY size bridge reference for WASM terminal commands." + }, { name: "_ptySetRawMode", classification: "hardened", @@ -6409,6 +6419,8 @@ var __bridge = (() => { var _processResourceUsage = createBridgeSyncFacade("process.resourceUsage"); var _processVersions = createBridgeSyncFacade("process.versions"); var _kernelPollRaw = createBridgeSyncFacade("_kernelPollRaw"); + var _kernelIsattyRaw = createBridgeSyncFacade("_kernelIsattyRaw"); + var _kernelTtySizeRaw = createBridgeSyncFacade("_kernelTtySizeRaw"); function decodeBridgeJson(value) { return typeof value === "string" ? JSON.parse(value) : value; } diff --git a/crates/execution/src/javascript.rs b/crates/execution/src/javascript.rs index d8dc13548..8c62e8482 100644 --- a/crates/execution/src/javascript.rs +++ b/crates/execution/src/javascript.rs @@ -81,6 +81,7 @@ const V8_WALL_CLOCK_LIMIT_MS_ENV: &str = "AGENTOS_V8_WALL_CLOCK_LIMIT_MS"; const NODE_SYNC_RPC_DEFAULT_DATA_BYTES: usize = 4 * 1024 * 1024; const NODE_SYNC_RPC_DEFAULT_WAIT_TIMEOUT_MS: u64 = 30_000; const NODE_SYNC_RPC_RESPONSE_QUEUE_CAPACITY: usize = 1; +const FORWARD_KERNEL_STDIN_RPC_ENV: &str = "AGENTOS_FORWARD_KERNEL_STDIN_RPC"; // Defense-in-depth headroom: a transient burst of guest events (e.g. a chatty // tool/skill turn) should be absorbed by the buffer, so the producer only ever // hits backpressure under a genuinely stuck consumer rather than on every spike. @@ -481,6 +482,7 @@ struct LocalBridgeState { next_timer_id: u64, timers: Arc>>, kernel_stdin: Arc, + forward_kernel_stdin_rpc: bool, v8_session: Option, /// Optional read-only reader over the mounted `node_modules` VFS, supplied by /// the sidecar. When present, the bridge thread resolves module-resolution @@ -1888,6 +1890,10 @@ impl JavascriptExecutionEngine { local_bridge.v8_session = Some(v8_session.clone()); local_bridge.module_reader = module_reader; local_bridge.module_resolution = GuestModuleResolution::from_env(&request.env); + local_bridge.forward_kernel_stdin_rpc = request + .env + .get(FORWARD_KERNEL_STDIN_RPC_ENV) + .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")); let events = spawn_v8_event_bridge( frame_receiver, pending_sync_rpc.clone(), @@ -3031,6 +3037,7 @@ impl LocalBridgeState { bytes[15], )))) } + "_kernelStdinRead" | "_kernelStdinReadRaw" if self.forward_kernel_stdin_rpc => None, "_kernelStdinRead" | "_kernelStdinReadRaw" => Some(LocalBridgeCallResult::Immediate( self.kernel_stdin.read(args), )), diff --git a/crates/execution/src/node_import_cache.rs b/crates/execution/src/node_import_cache.rs index 0399e7277..ed0d03e04 100644 --- a/crates/execution/src/node_import_cache.rs +++ b/crates/execution/src/node_import_cache.rs @@ -17,7 +17,7 @@ const NODE_IMPORT_CACHE_MATERIALIZE_TIMEOUT_MS_ENV: &str = "AGENTOS_NODE_IMPORT_CACHE_MATERIALIZE_TIMEOUT_MS"; const NODE_IMPORT_CACHE_SCHEMA_VERSION: &str = "1"; const NODE_IMPORT_CACHE_LOADER_VERSION: &str = "8"; -const NODE_IMPORT_CACHE_ASSET_VERSION: &str = "71"; +const NODE_IMPORT_CACHE_ASSET_VERSION: &str = "78"; const NODE_IMPORT_CACHE_DIR_PREFIX: &str = "agentos-node-import-cache"; const DEFAULT_NODE_IMPORT_CACHE_MATERIALIZE_TIMEOUT: Duration = Duration::from_secs(30); const PYODIDE_DIST_DIR: &str = "pyodide-dist"; @@ -9228,17 +9228,24 @@ function decodeBase64ToUint8Array(value) { return bytes; } -function readKernelStdinChunk(maxBytes) { +function readKernelStdinChunk(maxBytes, timeoutMs = 10) { const requestedLength = Math.max(1, Number(maxBytes) >>> 0); + const numericTimeoutMs = Number(timeoutMs); + const blocking = !Number.isFinite(numericTimeoutMs) || numericTimeoutMs >= 0xffffffff; + const deadline = blocking ? 0 : Date.now() + Math.max(0, numericTimeoutMs); while (true) { - const response = callSyncRpc('__kernel_stdin_read', [requestedLength, 10]); + const waitMs = blocking ? 10 : Math.max(0, Math.min(10, deadline - Date.now())); + const response = callSyncRpc('__kernel_stdin_read', [requestedLength, waitMs]); if (response && typeof response.dataBase64 === 'string') { return Buffer.from(response.dataBase64, 'base64'); } if (response && response.done === true) { return null; } - Atomics.wait(syntheticWaitArray, 0, 0, 10); + if (!blocking && Date.now() >= deadline) { + return Buffer.alloc(0); + } + Atomics.wait(syntheticWaitArray, 0, 0, blocking ? 10 : Math.max(0, Math.min(10, deadline - Date.now()))); } } @@ -12380,7 +12387,12 @@ const hostUserImport = { }, isatty(fd, retBoolPtr) { const descriptor = Number(fd) >>> 0; - const isTerminal = descriptor <= 2 ? 0 : 0; + let isTerminal = 0; + try { + isTerminal = callSyncRpc('__kernel_isatty', [descriptor]) === true ? 1 : 0; + } catch { + isTerminal = 0; + } return writeGuestUint32(retBoolPtr, isTerminal); }, getpwuid(uid, bufPtr, bufLen, retLenPtr) { @@ -12393,6 +12405,67 @@ const hostUserImport = { }, }; +const hostTtyImport = { + isatty(fd) { + try { + const result = callSyncRpc('__kernel_isatty', [Number(fd) >>> 0]); + return result === true || result === 1 ? 1 : 0; + } catch (error) { + process?.stderr?.write?.(`WARN host_tty.isatty failed: ${error?.stack || error}\n`); + return 0; + } + }, + get_size(fd, colsPtr, rowsPtr) { + try { + if (!(instanceMemory instanceof WebAssembly.Memory)) { + return WASI_ERRNO_FAULT; + } + const size = callSyncRpc('__kernel_tty_size', [Number(fd) >>> 0]); + if (!size || typeof size !== 'object') { + return WASI_ERRNO_FAULT; + } + const colsValue = Array.isArray(size) ? size[0] : size.cols; + const rowsValue = Array.isArray(size) ? size[1] : size.rows; + const cols = Math.max(0, Math.min(0xffff, Number(colsValue) >>> 0)); + const rows = Math.max(0, Math.min(0xffff, Number(rowsValue) >>> 0)); + const view = new DataView(instanceMemory.buffer); + view.setUint16(Number(colsPtr) >>> 0, cols, true); + view.setUint16(Number(rowsPtr) >>> 0, rows, true); + return WASI_ERRNO_SUCCESS; + } catch (error) { + process?.stderr?.write?.(`WARN host_tty.get_size failed: ${error?.stack || error}\n`); + return WASI_ERRNO_FAULT; + } + }, + set_raw_mode(enabled) { + try { + callSyncRpc('__pty_set_raw_mode', [Number(enabled) !== 0]); + return WASI_ERRNO_SUCCESS; + } catch (error) { + process?.stderr?.write?.(`WARN host_tty.set_raw_mode failed: ${error?.stack || error}\n`); + return WASI_ERRNO_FAULT; + } + }, + read(ptr, len, timeoutMs = 10) { + try { + if (!(instanceMemory instanceof WebAssembly.Memory)) { + return 0; + } + const requestedLength = Math.max(1, Number(len) >>> 0); + const chunk = readKernelStdinChunk(requestedLength, Number(timeoutMs) >>> 0); + if (!chunk || chunk.length === 0) { + return 0; + } + const written = Math.min(chunk.length, requestedLength); + new Uint8Array(instanceMemory.buffer).set(chunk.subarray(0, written), Number(ptr) >>> 0); + return written >>> 0; + } catch (error) { + process?.stderr?.write?.(`WARN host_tty.read failed: ${error?.stack || error}\n`); + return 0; + } + }, +}; + const HOST_FS_MODE_REGULAR = 0o100644; const HOST_FS_MODE_CHARACTER = 0o020666; const HOST_FS_MODE_FIFO = 0o010600; @@ -12874,7 +12947,7 @@ wasiImport.fd_read = (fd, iovs, iovsLen, nreadPtr) => { const sidecarManagedProcess = typeof process?.env?.AGENTOS_SANDBOX_ROOT === 'string' && process.env.AGENTOS_SANDBOX_ROOT.length > 0; - if (sidecarManagedProcess || KERNEL_STDIO_SYNC_RPC) { + if (typeof callSyncRpc === 'function') { try { const requestedLength = (() => { if (!(instanceMemory instanceof WebAssembly.Memory)) { @@ -12888,7 +12961,7 @@ wasiImport.fd_read = (fd, iovs, iovsLen, nreadPtr) => { } return total >>> 0; })(); - const chunk = readKernelStdinChunk(requestedLength); + const chunk = readKernelStdinChunk(requestedLength, 0xffffffff); if (!chunk || chunk.length === 0) { return writeGuestUint32(nreadPtr, 0); } @@ -13637,6 +13710,7 @@ const instance = new WebAssembly.Instance(module, { : limitedHostProcessImport, host_net: permissionTier === 'full' ? hostNetImport : undefined, host_user: hostUserImport, + host_tty: hostTtyImport, host_fs: hostFsImport, }); diff --git a/crates/execution/src/v8_runtime.rs b/crates/execution/src/v8_runtime.rs index 80657c3e8..6e1f98aac 100644 --- a/crates/execution/src/v8_runtime.rs +++ b/crates/execution/src/v8_runtime.rs @@ -287,6 +287,8 @@ pub fn map_bridge_method(method: &str) -> (&str, bool) { "_kernelStdinReadRaw" => ("__kernel_stdin_read", false), "_kernelStdioWriteRaw" => ("__kernel_stdio_write", false), "_kernelPollRaw" => ("__kernel_poll", false), + "_kernelIsattyRaw" => ("__kernel_isatty", false), + "_kernelTtySizeRaw" => ("__kernel_tty_size", false), // Network operations "_networkHttpServerListenRaw" => ("net.http_listen", false), @@ -577,6 +579,7 @@ mod tests { "_netSocketSetNoDelayRaw", "_kernelStdioWriteRaw", "_kernelPollRaw", + "_kernelTtySizeRaw", "_netSocketUpgradeTlsRaw", "_tlsGetCiphersRaw", "_dgramSocketAddressRaw", diff --git a/crates/execution/src/wasm.rs b/crates/execution/src/wasm.rs index bb640ec38..535077836 100644 --- a/crates/execution/src/wasm.rs +++ b/crates/execution/src/wasm.rs @@ -2157,11 +2157,17 @@ fn build_wasm_internal_env( .filter(|(key, _)| key.starts_with("AGENTOS_")) .map(|(key, value)| (key.clone(), value.clone())) .collect::>(); - + if let Some(value) = request.env.get("SECURE_EXEC_KEEP_STDIN_OPEN") { + internal_env.insert(String::from("SECURE_EXEC_KEEP_STDIN_OPEN"), value.clone()); + } internal_env.insert( WASM_MODULE_PATH_ENV.to_string(), resolved_module.specifier.clone(), ); + internal_env.insert( + String::from("AGENTOS_FORWARD_KERNEL_STDIN_RPC"), + String::from("1"), + ); if let Ok(module_bytes) = fs::read(&resolved_module.resolved_path) { internal_env.insert( String::from("AGENTOS_WASM_MODULE_BASE64"), @@ -2199,8 +2205,6 @@ fn build_wasm_internal_env( } else { internal_env.remove(WASM_PREWARM_ONLY_ENV); } - internal_env.remove("SECURE_EXEC_KEEP_STDIN_OPEN"); - internal_env } @@ -2282,7 +2286,7 @@ const __agentOSRequireBuiltin = (specifier) => {{ }} throw new Error(`secure-exec WASM bootstrap cannot load ${{specifier}}`); }}; -if (typeof globalThis !== "undefined" && typeof globalThis.__agentOSWasiModule === "undefined") {{ +if (typeof globalThis !== "undefined") {{ const __agentOSFs = () => __agentOSRequireBuiltin("node:fs"); const __agentOSPath = () => __agentOSRequireBuiltin("node:path"); const __agentOSCrypto = () => __agentOSRequireBuiltin("node:crypto"); @@ -3444,7 +3448,7 @@ if (typeof globalThis !== "undefined" && typeof globalThis.__agentOSWasiModule = const sidecarManagedProcess = typeof process?.env?.AGENTOS_SANDBOX_ROOT === "string" && process.env.AGENTOS_SANDBOX_ROOT.length > 0; - if (syncRpc && (sidecarManagedProcess || __agentOSKernelStdioSyncRpcEnabled())) {{ + if (syncRpc) {{ try {{ let chunk = null; while (true) {{ @@ -4367,6 +4371,21 @@ if (typeof globalThis !== "undefined" && typeof globalThis.__agentOSSyncRpc === throw new Error("secure-exec WASM kernel poll bridge is unavailable"); }} return _kernelPollRaw.applySync(void 0, args); + case "__kernel_isatty": + if (typeof _kernelIsattyRaw === "undefined") {{ + throw new Error("secure-exec WASM kernel isatty bridge is unavailable"); + }} + return _kernelIsattyRaw.applySync(void 0, args); + case "__kernel_tty_size": + if (typeof _kernelTtySizeRaw === "undefined") {{ + throw new Error("secure-exec WASM kernel tty size bridge is unavailable"); + }} + return _kernelTtySizeRaw.applySync(void 0, args); + case "__pty_set_raw_mode": + if (typeof _ptySetRawMode === "undefined") {{ + throw new Error("secure-exec WASM PTY raw-mode bridge is unavailable"); + }} + return _ptySetRawMode.applySync(void 0, args); case "child_process.spawn": {{ if (typeof _childProcessSpawnStart === "undefined") {{ throw new Error("secure-exec WASM child_process bridge is unavailable"); diff --git a/crates/kernel/src/kernel.rs b/crates/kernel/src/kernel.rs index a48f364e6..86383b105 100644 --- a/crates/kernel/src/kernel.rs +++ b/crates/kernel/src/kernel.rs @@ -27,7 +27,9 @@ use crate::process_table::{ ProcessTableError, ProcessWaitResult, SigmaskHow, SignalSet, DEFAULT_PROCESS_UMASK, SIGCONT, SIGPIPE, SIGSTOP, SIGTSTP, SIGWINCH, }; -use crate::pty::{LineDisciplineConfig, PartialTermios, PtyError, PtyManager, Termios}; +use crate::pty::{ + LineDisciplineConfig, PartialTermios, PtyError, PtyManager, PtyWindowSize, Termios, +}; use crate::resource_accounting::{ measure_filesystem_usage, FileSystemUsage, ResourceAccountant, ResourceError, ResourceLimits, ResourceSnapshot, DEFAULT_MAX_OPEN_FDS, @@ -2488,6 +2490,16 @@ impl KernelVm { Ok(self.ptys.is_slave(entry.description.id())) } + pub fn pty_window_size( + &self, + requester_driver: &str, + pid: u32, + fd: u32, + ) -> KernelResult { + let description = self.description_for_fd(requester_driver, pid, fd)?; + Ok(self.ptys.window_size(description.id())?) + } + pub fn pty_set_discipline( &self, requester_driver: &str, diff --git a/crates/kernel/src/pty.rs b/crates/kernel/src/pty.rs index 3ca2e40a6..7d873c030 100644 --- a/crates/kernel/src/pty.rs +++ b/crates/kernel/src/pty.rs @@ -225,6 +225,7 @@ struct PtyState { path: String, input_buffer: VecDeque>, output_buffer: VecDeque>, + input_eof_pending: bool, closed_master: bool, closed_slave: bool, waiting_input_reads: VecDeque, @@ -420,7 +421,9 @@ impl PtyManager { } } PtyEndKind::Slave => { - if requested.intersects(POLLIN) && !pty.input_buffer.is_empty() { + if requested.intersects(POLLIN) + && (pty.input_eof_pending || !pty.input_buffer.is_empty()) + { events |= POLLIN; } if pty.closed_master { @@ -576,6 +579,15 @@ impl PtyManager { return Ok(Some(result)); } + if pty.input_eof_pending { + pty.input_eof_pending = false; + if let Some(id) = waiter_id { + state.waiters.remove(&id); + } + self.notify_waiters_and_pollers(); + return Ok(None); + } + if pty.closed_master { if let Some(id) = waiter_id { state.waiters.remove(&id); @@ -790,6 +802,20 @@ impl PtyManager { .ok_or_else(|| PtyError::bad_file_descriptor("PTY not found")) } + pub fn window_size(&self, description_id: u64) -> PtyResult { + let 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"))?; + state + .ptys + .get(&pty_ref.pty_id) + .map(|pty| pty.window_size) + .ok_or_else(|| PtyError::bad_file_descriptor("PTY not found")) + } + pub fn resize(&self, description_id: u64, cols: u16, rows: u16) -> PtyResult> { let mut state = lock_or_recover(&self.inner.state); let pty_ref = state @@ -911,7 +937,7 @@ fn process_input( if pty.termios.icanon { if byte == pty.termios.cc.veof { if pty.line_buffer.is_empty() { - deliver_input(pty, waiters, &[])?; + deliver_input_eof(pty, waiters); } else { let line = pty.line_buffer.clone(); deliver_input(pty, waiters, &line)?; @@ -1004,6 +1030,17 @@ fn deliver_input( Ok(()) } +fn deliver_input_eof(pty: &mut PtyState, waiters: &mut BTreeMap) { + if let Some(waiter_id) = pty.waiting_input_reads.pop_front() { + if let Some(waiter) = waiters.get_mut(&waiter_id) { + waiter.result = Some(None); + return; + } + } + + pty.input_eof_pending = true; +} + fn deliver_output( pty: &mut PtyState, waiters: &mut BTreeMap, diff --git a/crates/kernel/tests/pty.rs b/crates/kernel/tests/pty.rs index a64066898..e9a3cfbcf 100644 --- a/crates/kernel/tests/pty.rs +++ b/crates/kernel/tests/pty.rs @@ -252,6 +252,46 @@ fn canonical_mode_buffers_until_newline_and_honors_backspace() { ); } +#[test] +fn canonical_mode_eof_on_empty_line_returns_hangup_once() { + let manager = PtyManager::new(); + let pty = manager.create_pty(); + + manager + .write(pty.master.description.id(), [0x04]) + .expect("write eof char"); + + let eof = manager + .read(pty.slave.description.id(), 64) + .expect("read eof marker"); + assert_eq!(eof, None); + + manager + .write(pty.master.description.id(), b"after\n") + .expect("write after eof marker"); + let line = manager + .read(pty.slave.description.id(), 64) + .expect("read line after eof") + .expect("line should be available"); + assert_eq!(line, b"after\n"); +} + +#[test] +fn canonical_mode_eof_after_pending_text_delivers_text_without_eof_byte() { + let manager = PtyManager::new(); + let pty = manager.create_pty(); + + manager + .write(pty.master.description.id(), b"partial\x04") + .expect("write partial line followed by eof"); + + let line = manager + .read(pty.slave.description.id(), 64) + .expect("read partial line") + .expect("partial line should be delivered"); + assert_eq!(line, b"partial"); +} + #[test] fn control_characters_signal_the_foreground_process_group() { let signals = Arc::new(Mutex::new(Vec::new())); @@ -277,6 +317,25 @@ fn control_characters_signal_the_foreground_process_group() { ); } +#[test] +fn window_size_reports_default_and_resize_updates() { + let manager = PtyManager::new(); + let pty = manager.create_pty(); + + let initial = manager + .window_size(pty.slave.description.id()) + .expect("read initial pty size"); + assert_eq!((initial.cols, initial.rows), (80, 24)); + + manager + .resize(pty.master.description.id(), 100, 20) + .expect("resize pty"); + let resized = manager + .window_size(pty.slave.description.id()) + .expect("read resized pty size"); + assert_eq!((resized.cols, resized.rows), (100, 20)); +} + #[test] fn peer_close_returns_hangup_instead_of_blocking() { let manager = PtyManager::new(); diff --git a/crates/sidecar/protocol/secure_exec_sidecar_v1.bare b/crates/sidecar/protocol/secure_exec_sidecar_v1.bare index 004c1b8e6..2cc288415 100644 --- a/crates/sidecar/protocol/secure_exec_sidecar_v1.bare +++ b/crates/sidecar/protocol/secure_exec_sidecar_v1.bare @@ -317,6 +317,12 @@ type WriteStdinRequest struct { chunk: data } +type ResizePtyRequest struct { + processId: str + cols: u16 + rows: u16 +} + type CloseStdinRequest struct { processId: str } @@ -395,6 +401,7 @@ type RequestPayload union { SnapshotRootFilesystemRequest | ExecuteRequest | WriteStdinRequest | + ResizePtyRequest | CloseStdinRequest | KillProcessRequest | GetProcessSnapshotRequest | @@ -513,6 +520,12 @@ type StdinWrittenResponse struct { acceptedBytes: u64 } +type PtyResizedResponse struct { + processId: str + cols: u16 + rows: u16 +} + type StdinClosedResponse struct { processId: str } @@ -629,6 +642,7 @@ type ResponsePayload union { RootFilesystemSnapshotResponse | ProcessStartedResponse | StdinWrittenResponse | + PtyResizedResponse | StdinClosedResponse | ProcessKilledResponse | ProcessSnapshotResponse | diff --git a/crates/sidecar/src/execution.rs b/crates/sidecar/src/execution.rs index 48cf267ba..d89140da0 100644 --- a/crates/sidecar/src/execution.rs +++ b/crates/sidecar/src/execution.rs @@ -16,7 +16,8 @@ use crate::protocol::{ JavascriptNetReserveTcpPortRequest, KillProcessRequest, ListenerSnapshotResponse, OwnershipScope, ProcessExitedEvent, ProcessKilledResponse, ProcessOutputEvent, ProcessSnapshotEntry, ProcessSnapshotResponse, ProcessSnapshotStatus, ProcessStartedResponse, - RequestFrame, ResponseFrame, ResponsePayload, SidecarRequestPayload, SignalDispositionAction, + PtyResizedResponse, RequestFrame, ResizePtyRequest, ResponseFrame, ResponsePayload, + SidecarRequestPayload, SignalDispositionAction, SignalHandlerRegistration, SignalStateResponse, SocketStateEntry, StdinClosedResponse, StdinWrittenResponse, StreamChannel, VmFetchRequest, VmFetchResponse, WasmPermissionTier, WriteStdinRequest, ZombieTimerCountResponse, @@ -206,6 +207,7 @@ const DEFAULT_ALLOWED_NODE_BUILTINS: &[&str] = &[ "util", "zlib", ]; +const EXECUTION_REQUEST_TTY_ENV: &str = "AGENTOS_EXEC_TTY"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum JavascriptCryptoDigestAlgorithm { @@ -3120,8 +3122,13 @@ where } } + let requested_tty = payload + .env + .get(EXECUTION_REQUEST_TTY_ENV) + .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")); let resolved = resolve_execute_request(vm, &payload)?; let mut env = resolved.env.clone(); + env.remove(EXECUTION_REQUEST_TTY_ENV); let sandbox_root = normalize_host_path(&vm.cwd); env.insert( String::from(EXECUTION_SANDBOX_ROOT_ENV), @@ -3151,6 +3158,32 @@ where ) .map_err(kernel_error)?; let kernel_pid = kernel_handle.pid(); + let tty_master_fd = if requested_tty { + let (master_fd, slave_fd, _) = vm + .kernel + .open_pty(EXECUTION_DRIVER_NAME, kernel_pid) + .map_err(kernel_error)?; + vm.kernel + .fd_dup2(EXECUTION_DRIVER_NAME, kernel_pid, slave_fd, 0) + .map_err(kernel_error)?; + vm.kernel + .fd_dup2(EXECUTION_DRIVER_NAME, kernel_pid, slave_fd, 1) + .map_err(kernel_error)?; + vm.kernel + .fd_dup2(EXECUTION_DRIVER_NAME, kernel_pid, slave_fd, 2) + .map_err(kernel_error)?; + vm.kernel + .pty_set_foreground_pgid(EXECUTION_DRIVER_NAME, kernel_pid, master_fd, kernel_pid) + .map_err(kernel_error)?; + if let Some((cols, rows)) = requested_pty_window_size(&env) { + vm.kernel + .pty_resize(EXECUTION_DRIVER_NAME, kernel_pid, master_fd, cols, rows) + .map_err(kernel_error)?; + } + Some(master_fd) + } else { + None + }; let (execution, process_env) = match resolved.runtime { GuestRuntimeKind::JavaScript => { @@ -3290,7 +3323,11 @@ where } }; let child_pid = execution.child_pid(); - let kernel_stdin_writer_fd = install_kernel_stdin_pipe(&mut vm.kernel, kernel_pid)?; + let kernel_stdin_writer_fd = if let Some(master_fd) = tty_master_fd { + master_fd + } else { + install_kernel_stdin_pipe(&mut vm.kernel, kernel_pid)? + }; vm.active_processes.insert( payload.process_id.clone(), ActiveProcess::new(kernel_pid, kernel_handle, resolved.runtime, execution) @@ -3317,6 +3354,56 @@ where }) } + pub(crate) async fn resize_pty( + &mut self, + request: &RequestFrame, + payload: ResizePtyRequest, + ) -> Result { + let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; + self.require_owned_vm(&connection_id, &session_id, &vm_id)?; + + let vm = self + .vms + .get_mut(&vm_id) + .ok_or_else(|| missing_vm_error(&vm_id))?; + let process = vm + .active_processes + .get_mut(&payload.process_id) + .ok_or_else(|| { + SidecarError::InvalidState(format!( + "VM {vm_id} has no active process {}", + payload.process_id + )) + })?; + let Some(writer_fd) = process.kernel_stdin_writer_fd else { + return Err(SidecarError::InvalidState(format!( + "process {} does not have a PTY", + payload.process_id + ))); + }; + vm.kernel + .pty_resize( + EXECUTION_DRIVER_NAME, + process.kernel_pid, + writer_fd, + payload.cols, + payload.rows, + ) + .map_err(kernel_error)?; + + Ok(DispatchResult { + response: self.respond( + request, + ResponsePayload::PtyResized(PtyResizedResponse { + process_id: payload.process_id, + cols: payload.cols, + rows: payload.rows, + }), + ), + events: Vec::new(), + }) + } + pub(crate) async fn write_stdin( &mut self, request: &RequestFrame, @@ -13798,6 +13885,10 @@ where "__kernel_stdio_write" => { service_javascript_kernel_stdio_write_sync_rpc(kernel, process, request) } + "__kernel_isatty" => service_javascript_kernel_isatty_sync_rpc(kernel, process, request), + "__kernel_tty_size" => { + service_javascript_kernel_tty_size_sync_rpc(kernel, process, request) + } "__kernel_poll" => service_javascript_kernel_poll_sync_rpc(kernel, process, request), "__pty_set_raw_mode" => { service_javascript_pty_set_raw_mode_sync_rpc(kernel, process, request) @@ -16282,6 +16373,33 @@ fn service_javascript_pty_set_raw_mode_sync_rpc( Ok(Value::Null) } +fn service_javascript_kernel_isatty_sync_rpc( + kernel: &mut SidecarKernel, + process: &ActiveProcess, + request: &JavascriptSyncRpcRequest, +) -> Result { + let fd = javascript_sync_rpc_arg_u32(&request.args, 0, "__kernel_isatty fd")?; + let is_tty = kernel + .isatty(EXECUTION_DRIVER_NAME, process.kernel_pid, fd) + .map_err(kernel_error)?; + Ok(json!(is_tty)) +} + +fn service_javascript_kernel_tty_size_sync_rpc( + kernel: &mut SidecarKernel, + process: &ActiveProcess, + request: &JavascriptSyncRpcRequest, +) -> Result { + let fd = javascript_sync_rpc_arg_u32(&request.args, 0, "__kernel_tty_size fd")?; + let size = kernel + .pty_window_size(EXECUTION_DRIVER_NAME, process.kernel_pid, fd) + .map_err(kernel_error)?; + Ok(json!({ + "cols": size.cols, + "rows": size.rows, + })) +} + fn service_javascript_kernel_stdio_write_sync_rpc( kernel: &mut SidecarKernel, process: &mut ActiveProcess, @@ -16354,7 +16472,6 @@ fn service_javascript_kernel_poll_sync_rpc( timeout_ms, ) .map_err(kernel_error)?; - Ok(json!({ "readyCount": result.ready_count, "fds": result @@ -16380,6 +16497,18 @@ fn install_kernel_stdin_pipe(kernel: &mut SidecarKernel, pid: u32) -> Result) -> Option<(u16, u16)> { + let cols = env + .get("COLUMNS") + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0)?; + let rows = env + .get("LINES") + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0)?; + Some((cols, rows)) } fn javascript_child_process_stdin_mode(request: &JavascriptChildProcessSpawnRequest) -> &str { diff --git a/crates/sidecar/src/protocol.rs b/crates/sidecar/src/protocol.rs index ad49b3bec..55f0d8425 100644 --- a/crates/sidecar/src/protocol.rs +++ b/crates/sidecar/src/protocol.rs @@ -429,6 +429,9 @@ fn to_generated_request_payload( RequestPayload::WriteStdin(inner) => { generated_protocol::RequestPayload::WriteStdinRequest(inner.clone()) } + RequestPayload::ResizePty(inner) => { + generated_protocol::RequestPayload::ResizePtyRequest(inner.clone()) + } RequestPayload::CloseStdin(inner) => { generated_protocol::RequestPayload::CloseStdinRequest(inner.clone()) } @@ -526,6 +529,9 @@ fn from_generated_request_payload( generated_protocol::RequestPayload::WriteStdinRequest(inner) => { RequestPayload::WriteStdin(inner) } + generated_protocol::RequestPayload::ResizePtyRequest(inner) => { + RequestPayload::ResizePty(inner) + } generated_protocol::RequestPayload::CloseStdinRequest(inner) => { RequestPayload::CloseStdin(inner) } @@ -642,6 +648,9 @@ fn to_generated_response_payload( ResponsePayload::StdinWritten(inner) => { generated_protocol::ResponsePayload::StdinWrittenResponse(inner.clone()) } + ResponsePayload::PtyResized(inner) => { + generated_protocol::ResponsePayload::PtyResizedResponse(inner.clone()) + } ResponsePayload::StdinClosed(inner) => { generated_protocol::ResponsePayload::StdinClosedResponse(inner.clone()) } @@ -774,6 +783,9 @@ fn from_generated_response_payload( generated_protocol::ResponsePayload::StdinWrittenResponse(inner) => { ResponsePayload::StdinWritten(inner) } + generated_protocol::ResponsePayload::PtyResizedResponse(inner) => { + ResponsePayload::PtyResized(inner) + } generated_protocol::ResponsePayload::StdinClosedResponse(inner) => { ResponsePayload::StdinClosed(inner) } @@ -1179,6 +1191,7 @@ pub enum RequestPayload { SnapshotRootFilesystem(SnapshotRootFilesystemRequest), Execute(ExecuteRequest), WriteStdin(WriteStdinRequest), + ResizePty(ResizePtyRequest), CloseStdin(CloseStdinRequest), KillProcess(KillProcessRequest), GetProcessSnapshot(GetProcessSnapshotRequest), @@ -1211,6 +1224,7 @@ pub enum ResponsePayload { RootFilesystemSnapshot(RootFilesystemSnapshotResponse), ProcessStarted(ProcessStartedResponse), StdinWritten(StdinWrittenResponse), + PtyResized(PtyResizedResponse), StdinClosed(StdinClosedResponse), ProcessKilled(ProcessKilledResponse), ProcessSnapshot(ProcessSnapshotResponse), @@ -1411,6 +1425,10 @@ pub type ProcessStartedResponse = crate::wire::ProcessStartedResponse; pub type StdinWrittenResponse = crate::wire::StdinWrittenResponse; +pub type ResizePtyRequest = crate::wire::ResizePtyRequest; + +pub type PtyResizedResponse = crate::wire::PtyResizedResponse; + pub type StdinClosedResponse = crate::wire::StdinClosedResponse; pub type ProcessKilledResponse = crate::wire::ProcessKilledResponse; @@ -1493,18 +1511,19 @@ impl_bare_newtype_union_enum!( SnapshotRootFilesystem(SnapshotRootFilesystemRequest) = 13, Execute(ExecuteRequest) = 14, WriteStdin(WriteStdinRequest) = 15, - CloseStdin(CloseStdinRequest) = 16, - KillProcess(KillProcessRequest) = 17, - GetProcessSnapshot(GetProcessSnapshotRequest) = 18, - FindListener(FindListenerRequest) = 19, - FindBoundUdp(FindBoundUdpRequest) = 20, - GetSignalState(GetSignalStateRequest) = 21, - GetZombieTimerCount(GetZombieTimerCountRequest) = 22, - HostFilesystemCall(HostFilesystemCallRequest) = 23, - PersistenceLoad(PersistenceLoadRequest) = 24, - PersistenceFlush(PersistenceFlushRequest) = 25, - VmFetch(VmFetchRequest) = 26, - Ext(ExtEnvelope) = 27, + ResizePty(ResizePtyRequest) = 16, + CloseStdin(CloseStdinRequest) = 17, + KillProcess(KillProcessRequest) = 18, + GetProcessSnapshot(GetProcessSnapshotRequest) = 19, + FindListener(FindListenerRequest) = 20, + FindBoundUdp(FindBoundUdpRequest) = 21, + GetSignalState(GetSignalStateRequest) = 22, + GetZombieTimerCount(GetZombieTimerCountRequest) = 23, + HostFilesystemCall(HostFilesystemCallRequest) = 24, + PersistenceLoad(PersistenceLoadRequest) = 25, + PersistenceFlush(PersistenceFlushRequest) = 26, + VmFetch(VmFetchRequest) = 27, + Ext(ExtEnvelope) = 28, } ); @@ -1529,20 +1548,21 @@ impl_bare_newtype_union_enum!( RootFilesystemSnapshot(RootFilesystemSnapshotResponse) = 13, ProcessStarted(ProcessStartedResponse) = 14, StdinWritten(StdinWrittenResponse) = 15, - StdinClosed(StdinClosedResponse) = 16, - ProcessKilled(ProcessKilledResponse) = 17, - ProcessSnapshot(ProcessSnapshotResponse) = 18, - ListenerSnapshot(ListenerSnapshotResponse) = 19, - BoundUdpSnapshot(BoundUdpSnapshotResponse) = 20, - SignalState(SignalStateResponse) = 21, - ZombieTimerCount(ZombieTimerCountResponse) = 22, - FilesystemResult(FilesystemResultResponse) = 23, - PermissionDecision(PermissionDecisionResponse) = 24, - PersistenceState(PersistenceStateResponse) = 25, - PersistenceFlushed(PersistenceFlushedResponse) = 26, - Rejected(RejectedResponse) = 27, - VmFetchResult(VmFetchResponse) = 28, - ExtResult(ExtEnvelope) = 29, + PtyResized(PtyResizedResponse) = 16, + StdinClosed(StdinClosedResponse) = 17, + ProcessKilled(ProcessKilledResponse) = 18, + ProcessSnapshot(ProcessSnapshotResponse) = 19, + ListenerSnapshot(ListenerSnapshotResponse) = 20, + BoundUdpSnapshot(BoundUdpSnapshotResponse) = 21, + SignalState(SignalStateResponse) = 22, + ZombieTimerCount(ZombieTimerCountResponse) = 23, + FilesystemResult(FilesystemResultResponse) = 24, + PermissionDecision(PermissionDecisionResponse) = 25, + PersistenceState(PersistenceStateResponse) = 26, + PersistenceFlushed(PersistenceFlushedResponse) = 27, + Rejected(RejectedResponse) = 28, + VmFetchResult(VmFetchResponse) = 29, + ExtResult(ExtEnvelope) = 30, } ); @@ -2103,6 +2123,7 @@ enum ExpectedResponseKind { RootFilesystemSnapshot, ProcessStarted, StdinWritten, + PtyResized, StdinClosed, ProcessKilled, ProcessSnapshot, @@ -2147,6 +2168,7 @@ impl ExpectedResponseKind { Self::RootFilesystemSnapshot => "root_filesystem_snapshot", Self::ProcessStarted => "process_started", Self::StdinWritten => "stdin_written", + Self::PtyResized => "pty_resized", Self::StdinClosed => "stdin_closed", Self::ProcessKilled => "process_killed", Self::ProcessSnapshot => "process_snapshot", @@ -2205,6 +2227,7 @@ impl RequestPayload { | Self::SnapshotRootFilesystem(_) | Self::Execute(_) | Self::WriteStdin(_) + | Self::ResizePty(_) | Self::CloseStdin(_) | Self::KillProcess(_) | Self::GetProcessSnapshot(_) @@ -2236,6 +2259,7 @@ impl RequestPayload { Self::SnapshotRootFilesystem(_) => ExpectedResponseKind::RootFilesystemSnapshot, Self::Execute(_) => ExpectedResponseKind::ProcessStarted, Self::WriteStdin(_) => ExpectedResponseKind::StdinWritten, + Self::ResizePty(_) => ExpectedResponseKind::PtyResized, Self::CloseStdin(_) => ExpectedResponseKind::StdinClosed, Self::KillProcess(_) => ExpectedResponseKind::ProcessKilled, Self::GetProcessSnapshot(_) => ExpectedResponseKind::ProcessSnapshot, @@ -2287,6 +2311,7 @@ impl ResponsePayload { | Self::RootFilesystemSnapshot(_) | Self::ProcessStarted(_) | Self::StdinWritten(_) + | Self::PtyResized(_) | Self::StdinClosed(_) | Self::ProcessKilled(_) | Self::ProcessSnapshot(_) @@ -2319,6 +2344,7 @@ impl ResponsePayload { Self::RootFilesystemSnapshot(_) => "root_filesystem_snapshot", Self::ProcessStarted(_) => "process_started", Self::StdinWritten(_) => "stdin_written", + Self::PtyResized(_) => "pty_resized", Self::StdinClosed(_) => "stdin_closed", Self::ProcessKilled(_) => "process_killed", Self::ProcessSnapshot(_) => "process_snapshot", diff --git a/crates/sidecar/src/service.rs b/crates/sidecar/src/service.rs index 614bba83d..aca756f83 100644 --- a/crates/sidecar/src/service.rs +++ b/crates/sidecar/src/service.rs @@ -1413,6 +1413,7 @@ where } RequestPayload::Execute(payload) => self.execute(&request, payload).await, RequestPayload::WriteStdin(payload) => self.write_stdin(&request, payload).await, + RequestPayload::ResizePty(payload) => self.resize_pty(&request, payload).await, RequestPayload::CloseStdin(payload) => self.close_stdin(&request, payload).await, RequestPayload::KillProcess(payload) => self.kill_process(&request, payload).await, RequestPayload::GetProcessSnapshot(payload) => { diff --git a/crates/sidecar/src/stdio.rs b/crates/sidecar/src/stdio.rs index 9e81ec077..bb3cfe49d 100644 --- a/crates/sidecar/src/stdio.rs +++ b/crates/sidecar/src/stdio.rs @@ -511,6 +511,7 @@ fn extension_interrupt_response( | RequestPayload::SnapshotRootFilesystemRequest | RequestPayload::ExecuteRequest(_) | RequestPayload::WriteStdinRequest(_) + | RequestPayload::ResizePtyRequest(_) | RequestPayload::CloseStdinRequest(_) | RequestPayload::GetProcessSnapshotRequest | RequestPayload::FindListenerRequest(_) @@ -586,6 +587,7 @@ fn interrupted_extension_dispatch( | RequestPayload::SnapshotRootFilesystemRequest | RequestPayload::ExecuteRequest(_) | RequestPayload::WriteStdinRequest(_) + | RequestPayload::ResizePtyRequest(_) | RequestPayload::CloseStdinRequest(_) | RequestPayload::KillProcessRequest(_) | RequestPayload::GetProcessSnapshotRequest diff --git a/crates/v8-runtime/src/session.rs b/crates/v8-runtime/src/session.rs index e412db66a..ede2ca9e5 100644 --- a/crates/v8-runtime/src/session.rs +++ b/crates/v8-runtime/src/session.rs @@ -1694,6 +1694,8 @@ pub(crate) const SYNC_BRIDGE_FNS: &[&str] = &[ "_kernelStdinReadRaw", "_kernelStdioWriteRaw", "_kernelPollRaw", + "_kernelIsattyRaw", + "_kernelTtySizeRaw", "_ptySetRawMode", ]; diff --git a/packages/core/src/generated-protocol.ts b/packages/core/src/generated-protocol.ts index cd0951e6c..b3c7540c6 100644 --- a/packages/core/src/generated-protocol.ts +++ b/packages/core/src/generated-protocol.ts @@ -1728,6 +1728,26 @@ export function writeWriteStdinRequest(bc: bare.ByteCursor, x: WriteStdinRequest bare.writeData(bc, x.chunk) } +export type ResizePtyRequest = { + readonly processId: string + readonly cols: u16 + readonly rows: u16 +} + +export function readResizePtyRequest(bc: bare.ByteCursor): ResizePtyRequest { + return { + processId: bare.readString(bc), + cols: bare.readU16(bc), + rows: bare.readU16(bc), + } +} + +export function writeResizePtyRequest(bc: bare.ByteCursor, x: ResizePtyRequest): void { + bare.writeString(bc, x.processId) + bare.writeU16(bc, x.cols) + bare.writeU16(bc, x.rows) +} + export type CloseStdinRequest = { readonly processId: string } @@ -1987,6 +2007,7 @@ export type RequestPayload = | { readonly tag: "SnapshotRootFilesystemRequest"; readonly val: SnapshotRootFilesystemRequest } | { readonly tag: "ExecuteRequest"; readonly val: ExecuteRequest } | { readonly tag: "WriteStdinRequest"; readonly val: WriteStdinRequest } + | { readonly tag: "ResizePtyRequest"; readonly val: ResizePtyRequest } | { readonly tag: "CloseStdinRequest"; readonly val: CloseStdinRequest } | { readonly tag: "KillProcessRequest"; readonly val: KillProcessRequest } | { readonly tag: "GetProcessSnapshotRequest"; readonly val: GetProcessSnapshotRequest } @@ -2037,28 +2058,30 @@ export function readRequestPayload(bc: bare.ByteCursor): RequestPayload { case 15: return { tag: "WriteStdinRequest", val: readWriteStdinRequest(bc) } case 16: - return { tag: "CloseStdinRequest", val: readCloseStdinRequest(bc) } + return { tag: "ResizePtyRequest", val: readResizePtyRequest(bc) } case 17: - return { tag: "KillProcessRequest", val: readKillProcessRequest(bc) } + return { tag: "CloseStdinRequest", val: readCloseStdinRequest(bc) } case 18: - return { tag: "GetProcessSnapshotRequest", val: null } + return { tag: "KillProcessRequest", val: readKillProcessRequest(bc) } case 19: - return { tag: "FindListenerRequest", val: readFindListenerRequest(bc) } + return { tag: "GetProcessSnapshotRequest", val: null } case 20: - return { tag: "FindBoundUdpRequest", val: readFindBoundUdpRequest(bc) } + return { tag: "FindListenerRequest", val: readFindListenerRequest(bc) } case 21: - return { tag: "GetSignalStateRequest", val: readGetSignalStateRequest(bc) } + return { tag: "FindBoundUdpRequest", val: readFindBoundUdpRequest(bc) } case 22: - return { tag: "GetZombieTimerCountRequest", val: null } + return { tag: "GetSignalStateRequest", val: readGetSignalStateRequest(bc) } case 23: - return { tag: "HostFilesystemCallRequest", val: readHostFilesystemCallRequest(bc) } + return { tag: "GetZombieTimerCountRequest", val: null } case 24: - return { tag: "PersistenceLoadRequest", val: readPersistenceLoadRequest(bc) } + return { tag: "HostFilesystemCallRequest", val: readHostFilesystemCallRequest(bc) } case 25: - return { tag: "PersistenceFlushRequest", val: readPersistenceFlushRequest(bc) } + return { tag: "PersistenceLoadRequest", val: readPersistenceLoadRequest(bc) } case 26: - return { tag: "VmFetchRequest", val: readVmFetchRequest(bc) } + return { tag: "PersistenceFlushRequest", val: readPersistenceFlushRequest(bc) } case 27: + return { tag: "VmFetchRequest", val: readVmFetchRequest(bc) } + case 28: return { tag: "ExtEnvelope", val: readExtEnvelope(bc) } default: { bc.offset = offset @@ -2147,61 +2170,66 @@ export function writeRequestPayload(bc: bare.ByteCursor, x: RequestPayload): voi writeWriteStdinRequest(bc, x.val) break } - case "CloseStdinRequest": { + case "ResizePtyRequest": { bare.writeU8(bc, 16) + writeResizePtyRequest(bc, x.val) + break + } + case "CloseStdinRequest": { + bare.writeU8(bc, 17) writeCloseStdinRequest(bc, x.val) break } case "KillProcessRequest": { - bare.writeU8(bc, 17) + bare.writeU8(bc, 18) writeKillProcessRequest(bc, x.val) break } case "GetProcessSnapshotRequest": { - bare.writeU8(bc, 18) + bare.writeU8(bc, 19) break } case "FindListenerRequest": { - bare.writeU8(bc, 19) + bare.writeU8(bc, 20) writeFindListenerRequest(bc, x.val) break } case "FindBoundUdpRequest": { - bare.writeU8(bc, 20) + bare.writeU8(bc, 21) writeFindBoundUdpRequest(bc, x.val) break } case "GetSignalStateRequest": { - bare.writeU8(bc, 21) + bare.writeU8(bc, 22) writeGetSignalStateRequest(bc, x.val) break } case "GetZombieTimerCountRequest": { - bare.writeU8(bc, 22) + bare.writeU8(bc, 23) break } case "HostFilesystemCallRequest": { - bare.writeU8(bc, 23) + bare.writeU8(bc, 24) writeHostFilesystemCallRequest(bc, x.val) break } case "PersistenceLoadRequest": { - bare.writeU8(bc, 24) + bare.writeU8(bc, 25) writePersistenceLoadRequest(bc, x.val) break } case "PersistenceFlushRequest": { - bare.writeU8(bc, 25) + bare.writeU8(bc, 26) writePersistenceFlushRequest(bc, x.val) break } case "VmFetchRequest": { - bare.writeU8(bc, 26) + bare.writeU8(bc, 27) writeVmFetchRequest(bc, x.val) break } case "ExtEnvelope": { - bare.writeU8(bc, 27) + bare.writeU8(bc, 28) writeExtEnvelope(bc, x.val) break } @@ -2589,6 +2617,26 @@ export function writeStdinWrittenResponse(bc: bare.ByteCursor, x: StdinWrittenRe bare.writeU64(bc, x.acceptedBytes) } +export type PtyResizedResponse = { + readonly processId: string + readonly cols: u16 + readonly rows: u16 +} + +export function readPtyResizedResponse(bc: bare.ByteCursor): PtyResizedResponse { + return { + processId: bare.readString(bc), + cols: bare.readU16(bc), + rows: bare.readU16(bc), + } +} + +export function writePtyResizedResponse(bc: bare.ByteCursor, x: PtyResizedResponse): void { + bare.writeString(bc, x.processId) + bare.writeU16(bc, x.cols) + bare.writeU16(bc, x.rows) +} + export type StdinClosedResponse = { readonly processId: string } @@ -3043,6 +3091,7 @@ export type ResponsePayload = | { readonly tag: "RootFilesystemSnapshotResponse"; readonly val: RootFilesystemSnapshotResponse } | { readonly tag: "ProcessStartedResponse"; readonly val: ProcessStartedResponse } | { readonly tag: "StdinWrittenResponse"; readonly val: StdinWrittenResponse } + | { readonly tag: "PtyResizedResponse"; readonly val: PtyResizedResponse } | { readonly tag: "StdinClosedResponse"; readonly val: StdinClosedResponse } | { readonly tag: "ProcessKilledResponse"; readonly val: ProcessKilledResponse } | { readonly tag: "ProcessSnapshotResponse"; readonly val: ProcessSnapshotResponse } @@ -3095,32 +3144,34 @@ export function readResponsePayload(bc: bare.ByteCursor): ResponsePayload { case 15: return { tag: "StdinWrittenResponse", val: readStdinWrittenResponse(bc) } case 16: - return { tag: "StdinClosedResponse", val: readStdinClosedResponse(bc) } + return { tag: "PtyResizedResponse", val: readPtyResizedResponse(bc) } case 17: - return { tag: "ProcessKilledResponse", val: readProcessKilledResponse(bc) } + return { tag: "StdinClosedResponse", val: readStdinClosedResponse(bc) } case 18: - return { tag: "ProcessSnapshotResponse", val: readProcessSnapshotResponse(bc) } + return { tag: "ProcessKilledResponse", val: readProcessKilledResponse(bc) } case 19: - return { tag: "ListenerSnapshotResponse", val: readListenerSnapshotResponse(bc) } + return { tag: "ProcessSnapshotResponse", val: readProcessSnapshotResponse(bc) } case 20: - return { tag: "BoundUdpSnapshotResponse", val: readBoundUdpSnapshotResponse(bc) } + return { tag: "ListenerSnapshotResponse", val: readListenerSnapshotResponse(bc) } case 21: - return { tag: "SignalStateResponse", val: readSignalStateResponse(bc) } + return { tag: "BoundUdpSnapshotResponse", val: readBoundUdpSnapshotResponse(bc) } case 22: - return { tag: "ZombieTimerCountResponse", val: readZombieTimerCountResponse(bc) } + return { tag: "SignalStateResponse", val: readSignalStateResponse(bc) } case 23: - return { tag: "FilesystemResultResponse", val: readFilesystemResultResponse(bc) } + return { tag: "ZombieTimerCountResponse", val: readZombieTimerCountResponse(bc) } case 24: - return { tag: "PermissionDecisionResponse", val: readPermissionDecisionResponse(bc) } + return { tag: "FilesystemResultResponse", val: readFilesystemResultResponse(bc) } case 25: - return { tag: "PersistenceStateResponse", val: readPersistenceStateResponse(bc) } + return { tag: "PermissionDecisionResponse", val: readPermissionDecisionResponse(bc) } case 26: - return { tag: "PersistenceFlushedResponse", val: readPersistenceFlushedResponse(bc) } + return { tag: "PersistenceStateResponse", val: readPersistenceStateResponse(bc) } case 27: - return { tag: "RejectedResponse", val: readRejectedResponse(bc) } + return { tag: "PersistenceFlushedResponse", val: readPersistenceFlushedResponse(bc) } case 28: - return { tag: "VmFetchResponse", val: readVmFetchResponse(bc) } + return { tag: "RejectedResponse", val: readRejectedResponse(bc) } case 29: + return { tag: "VmFetchResponse", val: readVmFetchResponse(bc) } + case 30: return { tag: "ExtEnvelope", val: readExtEnvelope(bc) } default: { bc.offset = offset @@ -3211,73 +3262,78 @@ export function writeResponsePayload(bc: bare.ByteCursor, x: ResponsePayload): v writeStdinWrittenResponse(bc, x.val) break } - case "StdinClosedResponse": { + case "PtyResizedResponse": { bare.writeU8(bc, 16) + writePtyResizedResponse(bc, x.val) + break + } + case "StdinClosedResponse": { + bare.writeU8(bc, 17) writeStdinClosedResponse(bc, x.val) break } case "ProcessKilledResponse": { - bare.writeU8(bc, 17) + bare.writeU8(bc, 18) writeProcessKilledResponse(bc, x.val) break } case "ProcessSnapshotResponse": { - bare.writeU8(bc, 18) + bare.writeU8(bc, 19) writeProcessSnapshotResponse(bc, x.val) break } case "ListenerSnapshotResponse": { - bare.writeU8(bc, 19) + bare.writeU8(bc, 20) writeListenerSnapshotResponse(bc, x.val) break } case "BoundUdpSnapshotResponse": { - bare.writeU8(bc, 20) + bare.writeU8(bc, 21) writeBoundUdpSnapshotResponse(bc, x.val) break } case "SignalStateResponse": { - bare.writeU8(bc, 21) + bare.writeU8(bc, 22) writeSignalStateResponse(bc, x.val) break } case "ZombieTimerCountResponse": { - bare.writeU8(bc, 22) + bare.writeU8(bc, 23) writeZombieTimerCountResponse(bc, x.val) break } case "FilesystemResultResponse": { - bare.writeU8(bc, 23) + bare.writeU8(bc, 24) writeFilesystemResultResponse(bc, x.val) break } case "PermissionDecisionResponse": { - bare.writeU8(bc, 24) + bare.writeU8(bc, 25) writePermissionDecisionResponse(bc, x.val) break } case "PersistenceStateResponse": { - bare.writeU8(bc, 25) + bare.writeU8(bc, 26) writePersistenceStateResponse(bc, x.val) break } case "PersistenceFlushedResponse": { - bare.writeU8(bc, 26) + bare.writeU8(bc, 27) writePersistenceFlushedResponse(bc, x.val) break } case "RejectedResponse": { - bare.writeU8(bc, 27) + bare.writeU8(bc, 28) writeRejectedResponse(bc, x.val) break } case "VmFetchResponse": { - bare.writeU8(bc, 28) + bare.writeU8(bc, 29) writeVmFetchResponse(bc, x.val) break } case "ExtEnvelope": { - bare.writeU8(bc, 29) + bare.writeU8(bc, 30) writeExtEnvelope(bc, x.val) break } diff --git a/packages/core/src/request-payloads.ts b/packages/core/src/request-payloads.ts index 69f6fa471..b3c7af339 100644 --- a/packages/core/src/request-payloads.ts +++ b/packages/core/src/request-payloads.ts @@ -152,6 +152,12 @@ export type LiveRequestPayload = process_id: string; chunk: Uint8Array; } + | { + type: "resize_pty"; + process_id: string; + cols: number; + rows: number; + } | { type: "close_stdin"; process_id: string; @@ -381,6 +387,15 @@ export function toGeneratedRequestPayload( chunk: toExactArrayBuffer(payload.chunk), }, }; + case "resize_pty": + return { + tag: "ResizePtyRequest", + val: { + processId: payload.process_id, + cols: payload.cols, + rows: payload.rows, + }, + }; case "close_stdin": return { tag: "CloseStdinRequest", diff --git a/packages/core/src/response-payloads.ts b/packages/core/src/response-payloads.ts index cbdfc679b..a145576b7 100644 --- a/packages/core/src/response-payloads.ts +++ b/packages/core/src/response-payloads.ts @@ -110,6 +110,12 @@ export type LiveResponsePayload = process_id: string; accepted_bytes: number; } + | { + type: "pty_resized"; + process_id: string; + cols: number; + rows: number; + } | { type: "stdin_closed"; process_id: string; @@ -266,6 +272,13 @@ export function fromGeneratedResponsePayload( "stdin_written.accepted_bytes", ), }; + case "PtyResizedResponse": + return { + type: "pty_resized", + process_id: payload.val.processId, + cols: payload.val.cols, + rows: payload.val.rows, + }; case "StdinClosedResponse": return { type: "stdin_closed", process_id: payload.val.processId }; case "ProcessKilledResponse": diff --git a/packages/core/src/sidecar-process.ts b/packages/core/src/sidecar-process.ts index a78efa900..aafd4b86a 100644 --- a/packages/core/src/sidecar-process.ts +++ b/packages/core/src/sidecar-process.ts @@ -1012,6 +1012,32 @@ export class SidecarProcess { } } + async resizePty( + session: AuthenticatedSession, + vm: CreatedVm, + processId: string, + cols: number, + rows: number, + ): Promise { + const response = await this.sendRequest({ + ownership: { + scope: "vm", + connection_id: session.connectionId, + session_id: session.sessionId, + vm_id: vm.vmId, + }, + payload: { + type: "resize_pty", + process_id: processId, + cols, + rows, + }, + }); + if (response.payload.type !== "pty_resized") { + throw new Error(`unexpected resize_pty response: ${response.payload.type}`); + } + } + async closeStdin( session: AuthenticatedSession, vm: CreatedVm, diff --git a/registry/agent/claude/package.json b/registry/agent/claude/package.json index 9178e193f..71cc71306 100644 --- a/registry/agent/claude/package.json +++ b/registry/agent/claude/package.json @@ -1,32 +1,32 @@ { - "name": "@agentos-software/claude-code", - "version": "0.2.0-rc.3", - "type": "module", - "license": "Apache-2.0", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "bin": { - "claude-sdk-acp": "./dist/adapter.js" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "scripts": { - "build": "tsc && node ./scripts/build-patched-cli.mjs", - "check-types": "tsc --noEmit", - "test": "pnpm build && node --test --test-force-exit tests/*.test.mjs" - }, - "dependencies": { - "@agentclientprotocol/sdk": "^0.16.1", - "@anthropic-ai/claude-agent-sdk": "0.2.87", - "zod": "^4.1.11" - }, - "devDependencies": { - "@types/node": "^22.10.2", - "typescript": "^5.7.2" - } + "name": "@agentos-software/claude-code", + "version": "0.2.1", + "type": "module", + "license": "Apache-2.0", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "claude-sdk-acp": "./dist/adapter.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc && node ./scripts/build-patched-cli.mjs", + "check-types": "tsc --noEmit", + "test": "pnpm build && node --test --test-force-exit tests/*.test.mjs" + }, + "dependencies": { + "@agentclientprotocol/sdk": "^0.16.1", + "@anthropic-ai/claude-agent-sdk": "0.2.87", + "zod": "^4.1.11" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + } } diff --git a/registry/agent/opencode/package.json b/registry/agent/opencode/package.json index 37a1d528c..7786d5a82 100644 --- a/registry/agent/opencode/package.json +++ b/registry/agent/opencode/package.json @@ -1,27 +1,27 @@ { - "name": "@agentos-software/opencode", - "version": "0.2.0-rc.3", - "type": "module", - "license": "Apache-2.0", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "bin": { - "agentos-opencode-acp": "./dist/adapter.js" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "scripts": { - "build": "node ./scripts/build-opencode-acp.mjs && tsc", - "check-types": "tsc --noEmit" - }, - "devDependencies": { - "bun": "1.3.11", - "@types/node": "^22.10.2", - "typescript": "^5.7.2" - } + "name": "@agentos-software/opencode", + "version": "0.2.1", + "type": "module", + "license": "Apache-2.0", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "agentos-opencode-acp": "./dist/adapter.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "node ./scripts/build-opencode-acp.mjs && tsc", + "check-types": "tsc --noEmit" + }, + "devDependencies": { + "bun": "1.3.11", + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + } } diff --git a/registry/agent/pi-cli/package.json b/registry/agent/pi-cli/package.json index c2b7d0f32..08e63081e 100644 --- a/registry/agent/pi-cli/package.json +++ b/registry/agent/pi-cli/package.json @@ -1,26 +1,26 @@ { - "name": "@agentos-software/pi-cli", - "version": "0.2.0-rc.3", - "type": "module", - "license": "Apache-2.0", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "scripts": { - "build": "tsc", - "check-types": "tsc --noEmit" - }, - "dependencies": { - "@mariozechner/pi-coding-agent": "^0.60.0", - "pi-acp": "^0.0.23" - }, - "devDependencies": { - "@types/node": "^22.10.2", - "typescript": "^5.7.2" - } + "name": "@agentos-software/pi-cli", + "version": "0.2.1", + "type": "module", + "license": "Apache-2.0", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@mariozechner/pi-coding-agent": "^0.60.0", + "pi-acp": "^0.0.23" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + } } diff --git a/registry/agent/pi/package.json b/registry/agent/pi/package.json index 7703ed7fa..472125065 100644 --- a/registry/agent/pi/package.json +++ b/registry/agent/pi/package.json @@ -1,33 +1,33 @@ { - "name": "@agentos-software/pi", - "version": "0.2.0-rc.3", - "type": "module", - "license": "Apache-2.0", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "bin": { - "pi-sdk-acp": "./dist/adapter.js" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "scripts": { - "build": "tsc && node scripts/build-snapshot-bundle.mjs", - "build:snapshot": "node scripts/build-snapshot-bundle.mjs", - "check-types": "tsc --noEmit", - "test": "pnpm build && node --test --test-force-exit tests/*.test.mjs" - }, - "dependencies": { - "@agentclientprotocol/sdk": "^0.16.1", - "@mariozechner/pi-coding-agent": "0.60.0", - "@mariozechner/pi-ai": "0.60.0" - }, - "devDependencies": { - "@types/node": "^22.10.2", - "typescript": "^5.7.2" - } + "name": "@agentos-software/pi", + "version": "0.2.1", + "type": "module", + "license": "Apache-2.0", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "pi-sdk-acp": "./dist/adapter.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc && node scripts/build-snapshot-bundle.mjs", + "build:snapshot": "node scripts/build-snapshot-bundle.mjs", + "check-types": "tsc --noEmit", + "test": "pnpm build && node --test --test-force-exit tests/*.test.mjs" + }, + "dependencies": { + "@agentclientprotocol/sdk": "^0.16.1", + "@mariozechner/pi-coding-agent": "0.60.0", + "@mariozechner/pi-ai": "0.60.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + } } diff --git a/registry/native/c/programs/pty_probe.c b/registry/native/c/programs/pty_probe.c new file mode 100644 index 000000000..62aa6b82d --- /dev/null +++ b/registry/native/c/programs/pty_probe.c @@ -0,0 +1,230 @@ +// pty_probe.c — deterministic PTY/stdio probe for AgentOS shell tests. +// +// The program intentionally speaks a tiny line-oriented protocol over stdio so +// integration tests can snapshot terminal state after each PTY operation: +// TTY detection, raw-mode toggling, cursor-position request/response, raw byte +// delivery, cooked Enter behavior, resize notification, and EOF. + +#include +#include +#include +#include +#include +#include + +#if defined(__wasm__) +__attribute__((import_module("host_tty"), import_name("isatty"))) extern unsigned int +host_tty_isatty(unsigned int fd); +__attribute__((import_module("host_tty"), import_name("get_size"))) extern unsigned int +host_tty_get_size(unsigned int fd, unsigned short *cols, unsigned short *rows); +__attribute__((import_module("host_tty"), import_name("set_raw_mode"))) extern unsigned int +host_tty_set_raw_mode(unsigned int enabled); +#else +#include +static struct termios saved_termios; +static int saved_termios_valid = 0; + +static unsigned int host_tty_isatty(unsigned int fd) { + return isatty((int)fd) ? 1u : 0u; +} + +static unsigned int host_tty_get_size(unsigned int fd, unsigned short *cols, unsigned short *rows) { + struct winsize ws; + memset(&ws, 0, sizeof(ws)); + if (ioctl((int)fd, TIOCGWINSZ, &ws) != 0) { + return (unsigned int)errno; + } + *cols = ws.ws_col; + *rows = ws.ws_row; + return 0; +} + +static unsigned int host_tty_set_raw_mode(unsigned int enabled) { + if (enabled) { + struct termios raw; + if (tcgetattr(STDIN_FILENO, &saved_termios) != 0) { + return (unsigned int)errno; + } + saved_termios_valid = 1; + raw = saved_termios; + cfmakeraw(&raw); + if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) != 0) { + return (unsigned int)errno; + } + return 0; + } + + if (saved_termios_valid && tcsetattr(STDIN_FILENO, TCSANOW, &saved_termios) != 0) { + return (unsigned int)errno; + } + return 0; +} +#endif + +static void print_hex(const unsigned char *bytes, int len) { + for (int i = 0; i < len; i++) { + if (i > 0) { + fputc(' ', stdout); + } + printf("%02X", bytes[i]); + } +} + +static void print_text(const unsigned char *bytes, int len) { + for (int i = 0; i < len; i++) { + unsigned char c = bytes[i]; + if (c == '\r') { + fputs("\\r", stdout); + } else if (c == '\n') { + fputs("\\n", stdout); + } else if (c == '\t') { + fputs("\\t", stdout); + } else if (c == 0x1b) { + fputs("\\e", stdout); + } else if (c < 0x20 || c == 0x7f) { + printf("\\x%02X", c); + } else { + fputc((int)c, stdout); + } + } +} + +static int read_until(unsigned char *buf, int cap, unsigned char terminator) { + int len = 0; + while (len < cap) { + unsigned char c = 0; + ssize_t n = read(STDIN_FILENO, &c, 1); + if (n == 0) { + return len == 0 ? 0 : len; + } + if (n < 0) { + if (errno == EINTR) { + continue; + } + printf("READ_ERROR errno=%d\r\n", errno); + fflush(stdout); + return -1; + } + buf[len++] = c; + if (c == terminator) { + return len; + } + } + return len; +} + +static void print_size(const char *label) { + const char *cols_env = getenv("COLUMNS"); + const char *rows_env = getenv("LINES"); + unsigned short host_cols = 0; + unsigned short host_rows = 0; + unsigned int host_rc = host_tty_get_size(STDOUT_FILENO, &host_cols, &host_rows); + struct winsize ws; + memset(&ws, 0, sizeof(ws)); + errno = 0; + int rc = ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws); + printf( + "%s env_cols=%s env_rows=%s host_rc=%u host_cols=%u host_rows=%u ioctl_rc=%d ioctl_errno=%d ioctl_cols=%u ioctl_rows=%u\r\n", + label, + cols_env ? cols_env : "", + rows_env ? rows_env : "", + host_rc, + (unsigned int)host_cols, + (unsigned int)host_rows, + rc, + errno, + (unsigned int)ws.ws_col, + (unsigned int)ws.ws_row); + fflush(stdout); +} + +int main(void) { + setvbuf(stdout, NULL, _IONBF, 0); + setvbuf(stderr, NULL, _IONBF, 0); + + printf("PTY_PROBE start\r\n"); + printf( + "TTY_HOST stdin=%u stdout=%u stderr=%u\r\n", + host_tty_isatty(STDIN_FILENO), + host_tty_isatty(STDOUT_FILENO), + host_tty_isatty(STDERR_FILENO)); + printf( + "TTY_LIBC stdin=%d stdout=%d stderr=%d\r\n", + isatty(STDIN_FILENO), + isatty(STDOUT_FILENO), + isatty(STDERR_FILENO)); + print_size("SIZE_START"); + + unsigned int raw_rc = host_tty_set_raw_mode(1); + printf("RAW_ON rc=%u\r\n", raw_rc); + + printf("CPR_REQUEST "); + fflush(stdout); + write(STDOUT_FILENO, "\x1b[6n", 4); + + unsigned char buf[128]; + int cpr_len = read_until(buf, (int)sizeof(buf), 'R'); + printf("\r\nCPR_REPLY bytes=%d hex=", cpr_len); + if (cpr_len > 0) { + print_hex(buf, cpr_len); + } + printf(" text="); + if (cpr_len > 0) { + print_text(buf, cpr_len); + } + printf("\r\n"); + + printf("RAW_INPUT> "); + fflush(stdout); + int raw_len = read_until(buf, (int)sizeof(buf), '!'); + printf("\r\nRAW_BYTES bytes=%d hex=", raw_len); + if (raw_len > 0) { + print_hex(buf, raw_len); + } + printf(" text="); + if (raw_len > 0) { + print_text(buf, raw_len); + } + printf("\r\n"); + + unsigned int cooked_rc = host_tty_set_raw_mode(0); + printf("RAW_OFF rc=%u\r\n", cooked_rc); + + printf("COOKED_INPUT> "); + fflush(stdout); + int cooked_len = read_until(buf, (int)sizeof(buf), '\n'); + printf("COOKED_BYTES bytes=%d hex=", cooked_len); + if (cooked_len > 0) { + print_hex(buf, cooked_len); + } + printf(" text="); + if (cooked_len > 0) { + print_text(buf, cooked_len); + } + printf("\r\n"); + + printf("RESIZE_READY> "); + fflush(stdout); + int resize_len = read_until(buf, (int)sizeof(buf), '\n'); + printf("RESIZE_TRIGGER bytes=%d hex=", resize_len); + if (resize_len > 0) { + print_hex(buf, resize_len); + } + printf(" text="); + if (resize_len > 0) { + print_text(buf, resize_len); + } + printf("\r\n"); + print_size("SIZE_AFTER_RESIZE"); + + printf("EOF_READY> "); + fflush(stdout); + unsigned char eof_byte = 0; + ssize_t eof_read = read(STDIN_FILENO, &eof_byte, 1); + printf("EOF_READ n=%zd", eof_read); + if (eof_read > 0) { + printf(" byte=%02X", eof_byte); + } + printf("\r\nPTY_PROBE done\r\n"); + return 0; +} diff --git a/registry/native/crates/commands/sh/Cargo.toml b/registry/native/crates/commands/sh/Cargo.toml index 80401dc39..f5bc1ba09 100644 --- a/registry/native/crates/commands/sh/Cargo.toml +++ b/registry/native/crates/commands/sh/Cargo.toml @@ -10,4 +10,4 @@ name = "sh" path = "src/main.rs" [dependencies] -brush-shell = { version = "0.3.0", default-features = false, features = ["minimal"] } +brush-shell = { version = "0.3.0", default-features = false, features = ["reedline", "minimal"] } diff --git a/registry/native/patches/crates/brush-interactive/0001-wasi-support.patch b/registry/native/patches/crates/brush-interactive/0001-wasi-support.patch new file mode 100644 index 000000000..779a68666 --- /dev/null +++ b/registry/native/patches/crates/brush-interactive/0001-wasi-support.patch @@ -0,0 +1,117 @@ +diff -ruN '--exclude=*.orig' a/src/basic/basic_shell.rs b/src/basic/basic_shell.rs +--- a/src/basic/basic_shell.rs 2026-06-27 14:11:52.527433629 -0700 ++++ b/src/basic/basic_shell.rs 2026-06-27 14:11:52.527433629 -0700 +@@ -104,6 +104,13 @@ + line: &str, + cursor: usize, + ) -> Result { ++ #[cfg(target_os = "wasi")] ++ { ++ let _ = (line, cursor); ++ return Ok(brush_core::completion::Completions::default()); ++ } ++ ++ #[cfg(not(target_os = "wasi"))] + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(self.generate_completions_async(line, cursor)) +diff -ruN '--exclude=*.orig' a/src/completion.rs b/src/completion.rs +--- a/src/completion.rs 2026-06-27 14:11:52.527433629 -0700 ++++ b/src/completion.rs 2026-06-27 14:11:52.527433629 -0700 +@@ -17,6 +17,10 @@ + tokio::pin!(completion_future); + + // Wait for the completions to come back or interruption, whichever happens first. ++ #[cfg(target_os = "wasi")] ++ let result = completion_future.await; ++ ++ #[cfg(not(target_os = "wasi"))] + let result = tokio::select! { + result = &mut completion_future => { + result +diff -ruN '--exclude=*.orig' a/src/reedline/completer.rs b/src/reedline/completer.rs +--- a/src/reedline/completer.rs 2026-06-27 14:11:52.527433629 -0700 ++++ b/src/reedline/completer.rs 2026-06-27 14:11:52.527433629 -0700 +@@ -10,6 +10,13 @@ + + impl reedline::Completer for ReedlineCompleter { + fn complete(&mut self, line: &str, pos: usize) -> Vec { ++ #[cfg(target_os = "wasi")] ++ { ++ let _ = (line, pos); ++ return Vec::new(); ++ } ++ ++ #[cfg(not(target_os = "wasi"))] + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.complete_async(line, pos)) + }) +diff -ruN '--exclude=*.orig' a/src/reedline/edit_mode.rs b/src/reedline/edit_mode.rs +--- a/src/reedline/edit_mode.rs 2026-06-27 14:11:52.527433629 -0700 ++++ b/src/reedline/edit_mode.rs 2026-06-27 14:11:52.527433629 -0700 +@@ -31,6 +31,10 @@ + + impl reedline::EditMode for MutableEditMode { + fn parse_event(&mut self, event: reedline::ReedlineRawEvent) -> reedline::ReedlineEvent { ++ #[cfg(target_os = "wasi")] ++ let mut inner = self.inner.try_lock().expect("edit mode lock is not reentrant"); ++ ++ #[cfg(not(target_os = "wasi"))] + let mut inner = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.inner.lock()) + }); +@@ -39,6 +43,10 @@ + } + + fn edit_mode(&self) -> reedline::PromptEditMode { ++ #[cfg(target_os = "wasi")] ++ let inner = self.inner.try_lock().expect("edit mode lock is not reentrant"); ++ ++ #[cfg(not(target_os = "wasi"))] + let inner = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.inner.lock()) + }); +diff -ruN '--exclude=*.orig' a/src/reedline/highlighter.rs b/src/reedline/highlighter.rs +--- a/src/reedline/highlighter.rs 2026-06-27 14:11:52.527433629 -0700 ++++ b/src/reedline/highlighter.rs 2026-06-27 14:11:52.527433629 -0700 +@@ -78,6 +78,10 @@ + impl reedline::Highlighter for ReedlineHighlighter { + #[expect(clippy::significant_drop_tightening)] + fn highlight(&self, line: &str, cursor: usize) -> reedline::StyledText { ++ #[cfg(target_os = "wasi")] ++ let shell = self.shell.try_lock().expect("shell lock is not reentrant"); ++ ++ #[cfg(not(target_os = "wasi"))] + let shell = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.shell.lock()) + }); +diff -ruN '--exclude=*.orig' a/src/reedline/history.rs b/src/reedline/history.rs +--- a/src/reedline/history.rs 2026-06-27 14:11:52.527433629 -0700 ++++ b/src/reedline/history.rs 2026-06-27 14:11:52.527433629 -0700 +@@ -6,6 +6,12 @@ + + impl ReedlineHistory { + fn lock_shell(&self) -> tokio::sync::MutexGuard<'_, brush_core::Shell> { ++ #[cfg(target_os = "wasi")] ++ { ++ return self.shell.try_lock().expect("shell lock is not reentrant"); ++ } ++ ++ #[cfg(not(target_os = "wasi"))] + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.shell.lock()) + }) +diff -ruN '--exclude=*.orig' a/src/reedline/validator.rs b/src/reedline/validator.rs +--- a/src/reedline/validator.rs 2026-06-27 14:11:52.527433629 -0700 ++++ b/src/reedline/validator.rs 2026-06-27 14:11:52.527433629 -0700 +@@ -6,6 +6,10 @@ + + impl reedline::Validator for ReedlineValidator { + fn validate(&self, line: &str) -> reedline::ValidationResult { ++ #[cfg(target_os = "wasi")] ++ let shell = self.shell.try_lock().expect("shell lock is not reentrant"); ++ ++ #[cfg(not(target_os = "wasi"))] + let shell = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.shell.lock()) + }); diff --git a/registry/native/patches/crates/brush-shell/0001-wasi-support.patch b/registry/native/patches/crates/brush-shell/0001-wasi-support.patch new file mode 100644 index 000000000..1fe40915f --- /dev/null +++ b/registry/native/patches/crates/brush-shell/0001-wasi-support.patch @@ -0,0 +1,48 @@ +diff -ruN '--exclude=*.orig' a/src/entry.rs b/src/entry.rs +--- a/src/entry.rs 2026-06-27 14:11:52.543433624 -0700 ++++ b/src/entry.rs 2026-06-27 14:11:52.551433620 -0700 +@@ -354,7 +354,15 @@ + InputBackend::Minimal + } + } +- #[cfg(not(any(unix, windows)))] ++ #[cfg(all(not(any(unix, windows)), target_os = "wasi"))] ++ { ++ if std::io::stdin().is_terminal() { ++ InputBackend::Reedline ++ } else { ++ InputBackend::Minimal ++ } ++ } ++ #[cfg(all(not(any(unix, windows)), not(target_os = "wasi")))] + { + InputBackend::Minimal + } +diff -ruN '--exclude=*.orig' a/src/shell_factory.rs b/src/shell_factory.rs +--- a/src/shell_factory.rs 2026-06-27 14:11:52.543433624 -0700 ++++ b/src/shell_factory.rs 2026-06-27 14:11:52.551433620 -0700 +@@ -50,20 +50,20 @@ + + #[allow(unused_variables, reason = "options are not used on all platforms")] + impl ShellFactory for ReedlineShellFactory { +- #[cfg(all(feature = "reedline", any(unix, windows)))] ++ #[cfg(all(feature = "reedline", any(unix, windows, target_os = "wasi")))] + type ShellType = brush_interactive::ReedlineShell; +- #[cfg(any(not(feature = "reedline"), not(any(unix, windows))))] ++ #[cfg(any(not(feature = "reedline"), not(any(unix, windows, target_os = "wasi"))))] + type ShellType = StubShell; + + async fn create( + &self, + options: brush_interactive::Options, + ) -> Result { +- #[cfg(all(feature = "reedline", any(unix, windows)))] ++ #[cfg(all(feature = "reedline", any(unix, windows, target_os = "wasi")))] + { + brush_interactive::ReedlineShell::new(options).await + } +- #[cfg(any(not(feature = "reedline"), not(any(unix, windows))))] ++ #[cfg(any(not(feature = "reedline"), not(any(unix, windows, target_os = "wasi"))))] + { + Err(brush_interactive::ShellError::InputBackendNotSupported) + } diff --git a/registry/native/patches/crates/crossterm-0.28.1/0001-wasi-support.patch b/registry/native/patches/crates/crossterm-0.28.1/0001-wasi-support.patch index 4401695d1..d8f0a4e6e 100644 --- a/registry/native/patches/crates/crossterm-0.28.1/0001-wasi-support.patch +++ b/registry/native/patches/crates/crossterm-0.28.1/0001-wasi-support.patch @@ -1,19 +1,72 @@ -diff -ruN '--exclude=*.orig' a/src/cursor/sys/wasi.rs src/cursor/sys/wasi.rs +diff -ruN '--exclude=*.orig' a/src/cursor/sys/wasi.rs b/src/cursor/sys/wasi.rs --- a/src/cursor/sys/wasi.rs 1969-12-31 16:00:00.000000000 -0800 -+++ b/src/cursor/sys/wasi.rs 2026-03-21 16:45:39.940014592 -0700 -@@ -0,0 +1,9 @@ ++++ b/src/cursor/sys/wasi.rs 2026-06-27 12:35:25.293090904 -0700 +@@ -0,0 +1,62 @@ +//! WASI-specific cursor functions. ++//! ++//! The WasmVM PTY answers a DSR (`ESC[6n`) request with a real CPR reply, so ++//! the cursor position is read exactly like the Unix backend instead of being ++//! stubbed to `(0, 0)`. A correct cursor row is required by line editors such ++//! as reedline: it anchors the prompt's `prompt_start_row`, and a wrong anchor ++//! makes every repaint `MoveTo(0, 0)` + `Clear(FromCursorDown)`, wiping the ++//! whole screen on each keystroke and after every accepted line. + -+use std::io; ++use std::io::{self, Error, ErrorKind, Write}; ++use std::time::Duration; ++ ++use crate::event::{filter::CursorPositionFilter, poll_internal, read_internal, InternalEvent}; ++use crate::terminal::sys::is_raw_mode_enabled; + +/// Returns the cursor position (column, row). -+/// On WASI, returns (0, 0) as a stub. ++/// ++/// The top left cell is represented as `(0, 0)`. +pub fn position() -> io::Result<(u16, u16)> { -+ Ok((0, 0)) ++ if is_raw_mode_enabled() { ++ read_position_raw() ++ } else { ++ read_position() ++ } ++} ++ ++fn read_position() -> io::Result<(u16, u16)> { ++ use crate::terminal::{disable_raw_mode, enable_raw_mode}; ++ ++ enable_raw_mode()?; ++ let pos = read_position_raw(); ++ disable_raw_mode()?; ++ pos +} -diff -ruN '--exclude=*.orig' a/src/cursor/sys.rs src/cursor/sys.rs ---- a/src/cursor/sys.rs 2026-03-21 16:44:07.904027465 -0700 -+++ b/src/cursor/sys.rs 2026-03-21 16:45:35.728014945 -0700 ++ ++fn read_position_raw() -> io::Result<(u16, u16)> { ++ // Request the cursor position with `ESC [ 6 n`. The reply (`ESC [ row ; col R`) ++ // arrives on stdin and is decoded by the shared event parser as ++ // `InternalEvent::CursorPosition`, exactly as on Unix. ++ let mut stdout = io::stdout(); ++ stdout.write_all(b"\x1b[6n")?; ++ stdout.flush()?; ++ ++ loop { ++ match poll_internal(Some(Duration::from_millis(2000)), &CursorPositionFilter) { ++ Ok(true) => { ++ if let Ok(InternalEvent::CursorPosition(x, y)) = ++ read_internal(&CursorPositionFilter) ++ { ++ return Ok((x, y)); ++ } ++ } ++ Ok(false) => { ++ return Err(Error::new( ++ ErrorKind::Other, ++ "The cursor position could not be read within a normal duration", ++ )); ++ } ++ Err(_) => {} ++ } ++ } ++} +diff -ruN '--exclude=*.orig' a/src/cursor/sys.rs b/src/cursor/sys.rs +--- a/src/cursor/sys.rs 2026-06-27 12:35:25.289090903 -0700 ++++ b/src/cursor/sys.rs 2026-06-27 12:35:25.293090904 -0700 @@ -6,6 +6,9 @@ #[cfg(windows)] #[cfg(feature = "events")] @@ -32,24 +85,38 @@ diff -ruN '--exclude=*.orig' a/src/cursor/sys.rs src/cursor/sys.rs +#[cfg(target_os = "wasi")] +#[cfg(feature = "events")] +pub(crate) mod wasi; -diff -ruN '--exclude=*.orig' a/src/event/filter.rs src/event/filter.rs ---- a/src/event/filter.rs 2026-03-21 16:44:07.904027465 -0700 -+++ b/src/event/filter.rs 2026-03-21 16:44:15.332026002 -0700 -@@ -55,6 +55,11 @@ +diff -ruN '--exclude=*.orig' a/src/event/filter.rs b/src/event/filter.rs +--- a/src/event/filter.rs 2026-06-27 12:35:25.289090903 -0700 ++++ b/src/event/filter.rs 2026-06-27 12:35:25.293090904 -0700 +@@ -6,11 +6,11 @@ + fn eval(&self, event: &InternalEvent) -> bool; + } + +-#[cfg(unix)] ++#[cfg(any(unix, target_os = "wasi"))] + #[derive(Debug, Clone)] + pub(crate) struct CursorPositionFilter; + +-#[cfg(unix)] ++#[cfg(any(unix, target_os = "wasi"))] + impl Filter for CursorPositionFilter { + fn eval(&self, event: &InternalEvent) -> bool { + matches!(*event, InternalEvent::CursorPosition(_, _)) +@@ -54,6 +54,11 @@ + fn eval(&self, event: &InternalEvent) -> bool { matches!(*event, InternalEvent::Event(_)) } - ++ + #[cfg(target_os = "wasi")] + fn eval(&self, event: &InternalEvent) -> bool { + matches!(*event, InternalEvent::Event(_)) + } -+ + #[cfg(windows)] fn eval(&self, _: &InternalEvent) -> bool { - true -diff -ruN '--exclude=*.orig' a/src/event/read.rs src/event/read.rs ---- a/src/event/read.rs 2026-03-21 16:44:07.904027465 -0700 -+++ b/src/event/read.rs 2026-03-21 16:45:02.056018546 -0700 +diff -ruN '--exclude=*.orig' a/src/event/read.rs b/src/event/read.rs +--- a/src/event/read.rs 2026-06-27 12:35:25.289090903 -0700 ++++ b/src/event/read.rs 2026-06-27 12:35:25.293090904 -0700 @@ -2,6 +2,8 @@ #[cfg(unix)] @@ -68,13 +135,14 @@ diff -ruN '--exclude=*.orig' a/src/event/read.rs src/event/read.rs let source = source.ok().map(|x| Box::new(x) as Box); -diff -ruN '--exclude=*.orig' a/src/event/source/wasi.rs src/event/source/wasi.rs +diff -ruN '--exclude=*.orig' a/src/event/source/wasi.rs b/src/event/source/wasi.rs --- a/src/event/source/wasi.rs 1969-12-31 16:00:00.000000000 -0800 -+++ b/src/event/source/wasi.rs 2026-03-21 16:44:47.396020569 -0700 -@@ -0,0 +1,51 @@ ++++ b/src/event/source/wasi.rs 2026-06-27 12:35:25.293090904 -0700 +@@ -0,0 +1,137 @@ +//! WASI event source — reads raw bytes from stdin and parses ANSI escape sequences. + -+use std::io::{self, Read}; ++use std::collections::VecDeque; ++use std::io; +use std::time::Duration; + +#[cfg(feature = "event-stream")] @@ -82,50 +150,135 @@ diff -ruN '--exclude=*.orig' a/src/event/source/wasi.rs src/event/source/wasi.rs +use super::{EventSource, InternalEvent}; +use crate::event::sys::unix::parse::parse_event; + ++#[link(wasm_import_module = "host_tty")] ++unsafe extern "C" { ++ fn read(ptr: *mut u8, len: usize, timeout_ms: usize) -> usize; ++} ++ ++const BLOCKING_TIMEOUT_MS: usize = usize::MAX; ++const CONTINUATION_TIMEOUT_MS: usize = 10; ++ ++fn timeout_to_ms(timeout: Option) -> usize { ++ match timeout { ++ None => BLOCKING_TIMEOUT_MS, ++ Some(timeout) => timeout.as_millis().min((BLOCKING_TIMEOUT_MS - 1) as u128) as usize, ++ } ++} ++ ++/// WASI event source that reads from stdin and parses terminal input. +pub(crate) struct WasiEventSource { -+ buffer: Vec, ++ parser: Parser, +} + +impl WasiEventSource { + pub fn new() -> io::Result { + Ok(Self { -+ buffer: Vec::with_capacity(256), ++ parser: Parser::default(), + }) + } +} + +impl EventSource for WasiEventSource { -+ fn try_read(&mut self, _timeout: Option) -> io::Result> { -+ let mut buf = [0u8; 64]; -+ let n = io::stdin().read(&mut buf)?; ++ fn try_read(&mut self, timeout: Option) -> io::Result> { ++ if let Some(event) = self.parser.next() { ++ return Ok(Some(event)); ++ } ++ ++ let mut buf = [0u8; 1]; ++ let n = unsafe { read(buf.as_mut_ptr(), buf.len(), timeout_to_ms(timeout)) }; + if n == 0 { + return Ok(None); + } + -+ self.buffer.extend_from_slice(&buf[..n]); ++ self.parser.advance(&buf[..n], true); ++ while self.parser.has_pending_prefix() { ++ if let Some(event) = self.parser.next() { ++ return Ok(Some(event)); ++ } + -+ let more_available = self.buffer.len() > 1; -+ match parse_event(&self.buffer, more_available) { ++ let n = unsafe { read(buf.as_mut_ptr(), buf.len(), CONTINUATION_TIMEOUT_MS) }; ++ if n == 0 { ++ self.parser.finish(); ++ break; ++ } ++ self.parser.advance(&buf[..n], true); ++ } ++ ++ Ok(self.parser.next()) ++ } ++ ++ #[cfg(feature = "event-stream")] ++ fn waker(&self) -> Waker { ++ unimplemented!("event-stream not supported on WASI") ++ } ++} ++ ++#[derive(Debug)] ++struct Parser { ++ buffer: Vec, ++ internal_events: VecDeque, ++} ++ ++impl Default for Parser { ++ fn default() -> Self { ++ Self { ++ buffer: Vec::with_capacity(256), ++ internal_events: VecDeque::with_capacity(128), ++ } ++ } ++} ++ ++impl Parser { ++ fn has_pending_prefix(&self) -> bool { ++ !self.buffer.is_empty() ++ } ++ ++ fn finish(&mut self) { ++ if self.buffer.is_empty() { ++ return; ++ } ++ ++ match parse_event(&self.buffer, false) { + Ok(Some(event)) => { ++ self.internal_events.push_back(event); + self.buffer.clear(); -+ Ok(Some(event)) + } -+ Ok(None) => Ok(None), -+ Err(_) => { ++ Ok(None) | Err(_) => { + self.buffer.clear(); -+ Ok(None) + } + } + } + -+ #[cfg(feature = "event-stream")] -+ fn waker(&self) -> Waker { -+ unimplemented!("event-stream not supported on WASI") ++ fn advance(&mut self, buffer: &[u8], more: bool) { ++ for (idx, byte) in buffer.iter().enumerate() { ++ let more = idx + 1 < buffer.len() || more; ++ ++ self.buffer.push(*byte); ++ ++ match parse_event(&self.buffer, more) { ++ Ok(Some(event)) => { ++ self.internal_events.push_back(event); ++ self.buffer.clear(); ++ } ++ Ok(None) => {} ++ Err(_) => { ++ self.buffer.clear(); ++ } ++ } ++ } ++ } ++} ++ ++impl Iterator for Parser { ++ type Item = InternalEvent; ++ ++ fn next(&mut self) -> Option { ++ self.internal_events.pop_front() + } +} -diff -ruN '--exclude=*.orig' a/src/event/source.rs src/event/source.rs ---- a/src/event/source.rs 2026-03-21 16:44:07.904027465 -0700 -+++ b/src/event/source.rs 2026-03-21 16:44:39.664021751 -0700 +diff -ruN '--exclude=*.orig' a/src/event/source.rs b/src/event/source.rs +--- a/src/event/source.rs 2026-06-27 12:35:25.289090903 -0700 ++++ b/src/event/source.rs 2026-06-27 12:35:25.293090904 -0700 @@ -6,6 +6,8 @@ #[cfg(unix)] @@ -135,9 +288,9 @@ diff -ruN '--exclude=*.orig' a/src/event/source.rs src/event/source.rs #[cfg(windows)] pub(crate) mod windows; -diff -ruN '--exclude=*.orig' a/src/event/sys.rs src/event/sys.rs ---- a/src/event/sys.rs 2026-03-21 16:44:07.904027465 -0700 -+++ b/src/event/sys.rs 2026-03-21 16:45:06.960017933 -0700 +diff -ruN '--exclude=*.orig' a/src/event/sys.rs b/src/event/sys.rs +--- a/src/event/sys.rs 2026-06-27 12:35:25.293090904 -0700 ++++ b/src/event/sys.rs 2026-06-27 12:35:25.293090904 -0700 @@ -3,7 +3,7 @@ #[cfg(all(windows, feature = "event-stream"))] pub(crate) use windows::waker::Waker; @@ -147,9 +300,9 @@ diff -ruN '--exclude=*.orig' a/src/event/sys.rs src/event/sys.rs pub(crate) mod unix; #[cfg(windows)] pub(crate) mod windows; -diff -ruN '--exclude=*.orig' a/src/event.rs src/event.rs ---- a/src/event.rs 2026-03-21 16:44:07.904027465 -0700 -+++ b/src/event.rs 2026-03-21 16:45:28.060015641 -0700 +diff -ruN '--exclude=*.orig' a/src/event.rs b/src/event.rs +--- a/src/event.rs 2026-06-27 12:35:25.293090904 -0700 ++++ b/src/event.rs 2026-06-27 12:35:25.293090904 -0700 @@ -247,7 +247,7 @@ pub fn read() -> std::io::Result { match read_internal(&EventFilter)? { @@ -185,10 +338,10 @@ diff -ruN '--exclude=*.orig' a/src/event.rs src/event.rs PrimaryDeviceAttributes, } -diff -ruN '--exclude=*.orig' a/src/terminal/sys/wasi.rs src/terminal/sys/wasi.rs +diff -ruN '--exclude=*.orig' a/src/terminal/sys/wasi.rs b/src/terminal/sys/wasi.rs --- a/src/terminal/sys/wasi.rs 1969-12-31 16:00:00.000000000 -0800 -+++ b/src/terminal/sys/wasi.rs 2026-03-21 16:44:33.460022756 -0700 -@@ -0,0 +1,49 @@ ++++ b/src/terminal/sys/wasi.rs 2026-06-27 12:35:25.293090904 -0700 +@@ -0,0 +1,62 @@ +//! WASI-specific terminal functions. + +use std::io; @@ -198,16 +351,29 @@ diff -ruN '--exclude=*.orig' a/src/terminal/sys/wasi.rs src/terminal/sys/wasi.rs + +static RAW_MODE_ENABLED: AtomicBool = AtomicBool::new(false); + ++#[link(wasm_import_module = "host_tty")] ++unsafe extern "C" { ++ fn set_raw_mode(enabled: u32) -> u32; ++} ++ +pub(crate) fn is_raw_mode_enabled() -> bool { + RAW_MODE_ENABLED.load(Ordering::SeqCst) +} + +pub(crate) fn enable_raw_mode() -> io::Result<()> { ++ let errno = unsafe { set_raw_mode(1) }; ++ if errno != 0 { ++ return Err(io::Error::from_raw_os_error(errno as i32)); ++ } + RAW_MODE_ENABLED.store(true, Ordering::SeqCst); + Ok(()) +} + +pub(crate) fn disable_raw_mode() -> io::Result<()> { ++ let errno = unsafe { set_raw_mode(0) }; ++ if errno != 0 { ++ return Err(io::Error::from_raw_os_error(errno as i32)); ++ } + RAW_MODE_ENABLED.store(false, Ordering::SeqCst); + Ok(()) +} @@ -235,12 +401,12 @@ diff -ruN '--exclude=*.orig' a/src/terminal/sys/wasi.rs src/terminal/sys/wasi.rs +} + +#[cfg(feature = "events")] -+pub fn supports_keyboard_enhancement() -> bool { -+ false ++pub fn supports_keyboard_enhancement() -> io::Result { ++ Ok(false) +} -diff -ruN '--exclude=*.orig' a/src/terminal/sys.rs src/terminal/sys.rs ---- a/src/terminal/sys.rs 2026-03-21 16:44:07.904027465 -0700 -+++ b/src/terminal/sys.rs 2026-03-21 16:44:26.788023898 -0700 +diff -ruN '--exclude=*.orig' a/src/terminal/sys.rs b/src/terminal/sys.rs +--- a/src/terminal/sys.rs 2026-06-27 12:35:25.293090904 -0700 ++++ b/src/terminal/sys.rs 2026-06-27 12:35:25.293090904 -0700 @@ -18,6 +18,14 @@ set_size, set_window_title, size, window_size, }; @@ -263,9 +429,9 @@ diff -ruN '--exclude=*.orig' a/src/terminal/sys.rs src/terminal/sys.rs + +#[cfg(target_os = "wasi")] +mod wasi; -diff -ruN '--exclude=*.orig' a/src/terminal.rs src/terminal.rs ---- a/src/terminal.rs 2026-03-21 16:44:07.904027465 -0700 -+++ b/src/terminal.rs 2026-03-21 16:44:20.548025021 -0700 +diff -ruN '--exclude=*.orig' a/src/terminal.rs b/src/terminal.rs +--- a/src/terminal.rs 2026-06-27 12:35:25.293090904 -0700 ++++ b/src/terminal.rs 2026-06-27 12:35:25.293090904 -0700 @@ -114,6 +114,11 @@ { sys::is_raw_mode_enabled() @@ -278,9 +444,9 @@ diff -ruN '--exclude=*.orig' a/src/terminal.rs src/terminal.rs } /// Enables raw mode. -diff -ruN '--exclude=*.orig' a/src/tty.rs src/tty.rs ---- a/src/tty.rs 2026-03-21 16:44:07.904027465 -0700 -+++ b/src/tty.rs 2026-03-21 16:45:45.828014137 -0700 +diff -ruN '--exclude=*.orig' a/src/tty.rs b/src/tty.rs +--- a/src/tty.rs 2026-06-27 12:35:25.293090904 -0700 ++++ b/src/tty.rs 2026-06-27 12:35:25.293090904 -0700 @@ -52,3 +52,10 @@ ok == 1 } diff --git a/registry/native/patches/crates/crossterm/0001-wasi-support.patch b/registry/native/patches/crates/crossterm/0001-wasi-support.patch index 7c6bcddba..f6db66749 100644 --- a/registry/native/patches/crates/crossterm/0001-wasi-support.patch +++ b/registry/native/patches/crates/crossterm/0001-wasi-support.patch @@ -72,10 +72,11 @@ diff -ruN '--exclude=*.orig' src/event/read.rs src/event/read.rs diff -ruN '--exclude=*.orig' src/event/source/wasi.rs src/event/source/wasi.rs --- a/src/event/source/wasi.rs 1969-12-31 16:00:00.000000000 -0800 +++ b/src/event/source/wasi.rs 2026-03-20 11:48:21.468925937 -0700 -@@ -0,0 +1,58 @@ +@@ -0,0 +1,139 @@ +//! WASI event source — reads raw bytes from stdin and parses ANSI escape sequences. + -+use std::io::{self, Read}; ++use std::collections::VecDeque; ++use std::io; +use std::time::Duration; + +#[cfg(feature = "event-stream")] @@ -83,52 +84,132 @@ diff -ruN '--exclude=*.orig' src/event/source/wasi.rs src/event/source/wasi.rs +use super::{EventSource, InternalEvent}; +use crate::event::sys::unix::parse::parse_event; + ++#[link(wasm_import_module = "host_tty")] ++unsafe extern "C" { ++ fn read(ptr: *mut u8, len: usize, timeout_ms: usize) -> usize; ++} ++ ++const BLOCKING_TIMEOUT_MS: usize = usize::MAX; ++const CONTINUATION_TIMEOUT_MS: usize = 10; ++ ++fn timeout_to_ms(timeout: Option) -> usize { ++ match timeout { ++ None => BLOCKING_TIMEOUT_MS, ++ Some(timeout) => timeout ++ .as_millis() ++ .min((BLOCKING_TIMEOUT_MS - 1) as u128) as usize, ++ } ++} ++ +/// WASI event source that reads from stdin and parses terminal input. +pub(crate) struct WasiEventSource { -+ buffer: Vec, ++ parser: Parser, +} + +impl WasiEventSource { + pub fn new() -> io::Result { + Ok(Self { -+ buffer: Vec::with_capacity(256), ++ parser: Parser::default(), + }) + } +} + +impl EventSource for WasiEventSource { -+ fn try_read(&mut self, _timeout: Option) -> io::Result> { -+ // Read available bytes from stdin -+ let mut buf = [0u8; 64]; -+ let n = io::stdin().read(&mut buf)?; ++ fn try_read(&mut self, timeout: Option) -> io::Result> { ++ if let Some(event) = self.parser.next() { ++ return Ok(Some(event)); ++ } ++ ++ let mut buf = [0u8; 1]; ++ let n = unsafe { read(buf.as_mut_ptr(), buf.len(), timeout_to_ms(timeout)) }; + if n == 0 { + return Ok(None); + } + -+ self.buffer.extend_from_slice(&buf[..n]); ++ self.parser.advance(&buf[..n], true); ++ while self.parser.has_pending_prefix() { ++ if let Some(event) = self.parser.next() { ++ return Ok(Some(event)); ++ } ++ ++ let n = unsafe { read(buf.as_mut_ptr(), buf.len(), CONTINUATION_TIMEOUT_MS) }; ++ if n == 0 { ++ self.parser.finish(); ++ break; ++ } ++ self.parser.advance(&buf[..n], true); ++ } ++ ++ Ok(self.parser.next()) ++ } ++ ++ #[cfg(feature = "event-stream")] ++ fn waker(&self) -> Waker { ++ unimplemented!("event-stream not supported on WASI") ++ } ++} ++ ++#[derive(Debug)] ++struct Parser { ++ buffer: Vec, ++ internal_events: VecDeque, ++} + -+ // Try to parse buffered bytes into an event -+ let more_available = self.buffer.len() > 1; -+ match parse_event(&self.buffer, more_available) { ++impl Default for Parser { ++ fn default() -> Self { ++ Self { ++ buffer: Vec::with_capacity(256), ++ internal_events: VecDeque::with_capacity(128), ++ } ++ } ++} ++ ++impl Parser { ++ fn has_pending_prefix(&self) -> bool { ++ !self.buffer.is_empty() ++ } ++ ++ fn finish(&mut self) { ++ if self.buffer.is_empty() { ++ return; ++ } ++ ++ match parse_event(&self.buffer, false) { + Ok(Some(event)) => { ++ self.internal_events.push_back(event); + self.buffer.clear(); -+ Ok(Some(event)) -+ } -+ Ok(None) => { -+ // Need more bytes — return None and try again next call -+ Ok(None) + } -+ Err(_) => { -+ // Failed to parse — clear buffer and continue ++ Ok(None) | Err(_) => { + self.buffer.clear(); -+ Ok(None) + } + } + } + -+ #[cfg(feature = "event-stream")] -+ fn waker(&self) -> Waker { -+ unimplemented!("event-stream not supported on WASI") ++ fn advance(&mut self, buffer: &[u8], more: bool) { ++ for (idx, byte) in buffer.iter().enumerate() { ++ let more = idx + 1 < buffer.len() || more; ++ ++ self.buffer.push(*byte); ++ ++ match parse_event(&self.buffer, more) { ++ Ok(Some(event)) => { ++ self.internal_events.push_back(event); ++ self.buffer.clear(); ++ } ++ Ok(None) => {} ++ Err(_) => { ++ self.buffer.clear(); ++ } ++ } ++ } ++ } ++} ++ ++impl Iterator for Parser { ++ type Item = InternalEvent; ++ ++ fn next(&mut self) -> Option { ++ self.internal_events.pop_front() + } +} diff -ruN '--exclude=*.orig' src/event/source.rs src/event/source.rs @@ -196,7 +277,7 @@ diff -ruN '--exclude=*.orig' src/event.rs src/event.rs diff -ruN '--exclude=*.orig' src/terminal/sys/wasi.rs src/terminal/sys/wasi.rs --- a/src/terminal/sys/wasi.rs 1969-12-31 16:00:00.000000000 -0800 +++ b/src/terminal/sys/wasi.rs 2026-03-20 11:47:29.268842595 -0700 -@@ -0,0 +1,53 @@ +@@ -0,0 +1,66 @@ +//! WASI-specific terminal functions. +//! +//! On WASI, the terminal is accessed through the WasmVM PTY. @@ -210,16 +291,29 @@ diff -ruN '--exclude=*.orig' src/terminal/sys/wasi.rs src/terminal/sys/wasi.rs + +static RAW_MODE_ENABLED: AtomicBool = AtomicBool::new(false); + ++#[link(wasm_import_module = "host_tty")] ++unsafe extern "C" { ++ fn set_raw_mode(enabled: u32) -> u32; ++} ++ +pub(crate) fn is_raw_mode_enabled() -> bool { + RAW_MODE_ENABLED.load(Ordering::SeqCst) +} + +pub(crate) fn enable_raw_mode() -> io::Result<()> { ++ let errno = unsafe { set_raw_mode(1) }; ++ if errno != 0 { ++ return Err(io::Error::from_raw_os_error(errno as i32)); ++ } + RAW_MODE_ENABLED.store(true, Ordering::SeqCst); + Ok(()) +} + +pub(crate) fn disable_raw_mode() -> io::Result<()> { ++ let errno = unsafe { set_raw_mode(0) }; ++ if errno != 0 { ++ return Err(io::Error::from_raw_os_error(errno as i32)); ++ } + RAW_MODE_ENABLED.store(false, Ordering::SeqCst); + Ok(()) +} @@ -247,8 +341,8 @@ diff -ruN '--exclude=*.orig' src/terminal/sys/wasi.rs src/terminal/sys/wasi.rs +} + +#[cfg(feature = "events")] -+pub fn supports_keyboard_enhancement() -> bool { -+ false ++pub fn supports_keyboard_enhancement() -> io::Result { ++ Ok(false) +} diff -ruN '--exclude=*.orig' src/terminal/sys.rs src/terminal/sys.rs --- a/src/terminal/sys.rs 1973-11-29 13:33:09.000000000 -0800 diff --git a/registry/native/patches/crates/fd-lock/0001-wasi-support.patch b/registry/native/patches/crates/fd-lock/0001-wasi-support.patch new file mode 100644 index 000000000..c6ed095cb --- /dev/null +++ b/registry/native/patches/crates/fd-lock/0001-wasi-support.patch @@ -0,0 +1,179 @@ +diff -ruN '--exclude=*.orig' a/src/sys/mod.rs b/src/sys/mod.rs +--- a/src/sys/mod.rs 2026-06-27 14:11:52.511433635 -0700 ++++ b/src/sys/mod.rs 2026-06-27 14:11:52.511433635 -0700 +@@ -13,6 +13,5 @@ + } else { + mod unsupported; + pub use unsupported::*; +- pub(crate) use std::os::fd::AsFd as AsOpenFile; + } + } +diff -ruN '--exclude=*.orig' a/src/sys/unsupported/mod.rs b/src/sys/unsupported/mod.rs +--- a/src/sys/unsupported/mod.rs 2026-06-27 14:11:52.511433635 -0700 ++++ b/src/sys/unsupported/mod.rs 2026-06-27 14:11:52.515433634 -0700 +@@ -2,8 +2,10 @@ + mod rw_lock; + mod write_guard; + +-pub(crate) mod utils; +- + pub use read_guard::RwLockReadGuard; + pub use rw_lock::RwLock; + pub use write_guard::RwLockWriteGuard; ++ ++pub(crate) trait AsOpenFile {} ++ ++impl AsOpenFile for T {} +diff -ruN '--exclude=*.orig' a/src/sys/unsupported/read_guard.rs b/src/sys/unsupported/read_guard.rs +--- a/src/sys/unsupported/read_guard.rs 2026-06-27 14:11:52.511433635 -0700 ++++ b/src/sys/unsupported/read_guard.rs 2026-06-27 14:11:52.515433634 -0700 +@@ -1,31 +1,28 @@ + use std::ops; +-use std::os::fd::AsFd; + +-use super::RwLock; ++use super::{AsOpenFile, RwLock}; + + #[derive(Debug)] +-pub struct RwLockReadGuard<'lock, T: AsFd> { ++pub struct RwLockReadGuard<'lock, T: AsOpenFile> { + lock: &'lock RwLock, + } + +-impl<'lock, T: AsFd> RwLockReadGuard<'lock, T> { ++impl<'lock, T: AsOpenFile> RwLockReadGuard<'lock, T> { + pub(crate) fn new(lock: &'lock RwLock) -> Self { +- panic!("target unsupported") ++ Self { lock } + } + } + +-impl ops::Deref for RwLockReadGuard<'_, T> { ++impl ops::Deref for RwLockReadGuard<'_, T> { + type Target = T; + + #[inline] + fn deref(&self) -> &Self::Target { +- panic!("target unsupported") ++ &self.lock.inner + } + } + +-impl Drop for RwLockReadGuard<'_, T> { ++impl Drop for RwLockReadGuard<'_, T> { + #[inline] +- fn drop(&mut self) { +- panic!("target unsupported") +- } ++ fn drop(&mut self) {} + } +diff -ruN '--exclude=*.orig' a/src/sys/unsupported/rw_lock.rs b/src/sys/unsupported/rw_lock.rs +--- a/src/sys/unsupported/rw_lock.rs 2026-06-27 14:11:52.511433635 -0700 ++++ b/src/sys/unsupported/rw_lock.rs 2026-06-27 14:11:52.515433634 -0700 +@@ -1,37 +1,36 @@ +-use std::io::{self, Error, ErrorKind}; +-use std::os::fd::AsFd; ++use std::io::{self, Error}; + +-use super::{RwLockReadGuard, RwLockWriteGuard}; ++use super::{AsOpenFile, RwLockReadGuard, RwLockWriteGuard}; + + #[derive(Debug)] +-pub struct RwLock { ++pub struct RwLock { + pub(crate) inner: T, + } + +-impl RwLock { ++impl RwLock { + #[inline] + pub fn new(inner: T) -> Self { +- panic!("target unsupported") ++ RwLock { inner } + } + + #[inline] + pub fn write(&mut self) -> io::Result> { +- panic!("target unsupported") ++ Ok(RwLockWriteGuard::new(self)) + } + + #[inline] + pub fn try_write(&mut self) -> Result, Error> { +- panic!("target unsupported") ++ Ok(RwLockWriteGuard::new(self)) + } + + #[inline] + pub fn read(&self) -> io::Result> { +- panic!("target unsupported") ++ Ok(RwLockReadGuard::new(self)) + } + + #[inline] + pub fn try_read(&self) -> Result, Error> { +- panic!("target unsupported") ++ Ok(RwLockReadGuard::new(self)) + } + + #[inline] +@@ -39,6 +38,6 @@ + where + T: Sized, + { +- panic!("target unsupported") ++ self.inner + } + } +diff -ruN '--exclude=*.orig' a/src/sys/unsupported/write_guard.rs b/src/sys/unsupported/write_guard.rs +--- a/src/sys/unsupported/write_guard.rs 2026-06-27 14:11:52.511433635 -0700 ++++ b/src/sys/unsupported/write_guard.rs 2026-06-27 14:11:52.515433634 -0700 +@@ -1,38 +1,35 @@ + use std::ops; +-use std::os::fd::AsFd; + +-use super::RwLock; ++use super::{AsOpenFile, RwLock}; + + #[derive(Debug)] +-pub struct RwLockWriteGuard<'lock, T: AsFd> { ++pub struct RwLockWriteGuard<'lock, T: AsOpenFile> { + lock: &'lock mut RwLock, + } + +-impl<'lock, T: AsFd> RwLockWriteGuard<'lock, T> { ++impl<'lock, T: AsOpenFile> RwLockWriteGuard<'lock, T> { + pub(crate) fn new(lock: &'lock mut RwLock) -> Self { +- panic!("target unsupported") ++ Self { lock } + } + } + +-impl ops::Deref for RwLockWriteGuard<'_, T> { ++impl ops::Deref for RwLockWriteGuard<'_, T> { + type Target = T; + + #[inline] + fn deref(&self) -> &Self::Target { +- panic!("target unsupported") ++ &self.lock.inner + } + } + +-impl ops::DerefMut for RwLockWriteGuard<'_, T> { ++impl ops::DerefMut for RwLockWriteGuard<'_, T> { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { +- panic!("target unsupported") ++ &mut self.lock.inner + } + } + +-impl Drop for RwLockWriteGuard<'_, T> { ++impl Drop for RwLockWriteGuard<'_, T> { + #[inline] +- fn drop(&mut self) { +- panic!("target unsupported") +- } ++ fn drop(&mut self) {} + }