Skip to content

Commit 27b48c5

Browse files
authored
feat(pty): native terminal support for interactive shells (#137)
Adds real PTY/terminal plumbing so guest shells (brush) run like docker run -it: kernel PTY, sidecar terminal protocol, and WASI crossterm support. Includes the crossterm cursor-position fix (real DSR query instead of a (0,0) stub) so the reedline prompt anchors correctly and Enter no longer clears the screen, plus a C pty_probe fixture for protocol testing.
1 parent ee95479 commit 27b48c5

30 files changed

Lines changed: 1635 additions & 292 deletions

File tree

crates/bridge/bridge-contract.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@
219219
"_sqliteStatementFinalizeRaw",
220220
"_kernelStdioWriteRaw",
221221
"_kernelPollRaw",
222+
"_kernelIsattyRaw",
223+
"_kernelTtySizeRaw",
222224
"_ptySetRawMode"
223225
]
224226
},

crates/execution/assets/v8-bridge.source.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3943,6 +3943,16 @@ var __bridge = (() => {
39433943
classification: "hardened",
39443944
rationale: "Host kernel poll bridge reference for multi-fd readiness waits."
39453945
},
3946+
{
3947+
name: "_kernelIsattyRaw",
3948+
classification: "hardened",
3949+
rationale: "Host kernel TTY detection bridge reference for WASM terminal commands."
3950+
},
3951+
{
3952+
name: "_kernelTtySizeRaw",
3953+
classification: "hardened",
3954+
rationale: "Host kernel TTY size bridge reference for WASM terminal commands."
3955+
},
39463956
{
39473957
name: "_ptySetRawMode",
39483958
classification: "hardened",
@@ -6409,6 +6419,8 @@ var __bridge = (() => {
64096419
var _processResourceUsage = createBridgeSyncFacade("process.resourceUsage");
64106420
var _processVersions = createBridgeSyncFacade("process.versions");
64116421
var _kernelPollRaw = createBridgeSyncFacade("_kernelPollRaw");
6422+
var _kernelIsattyRaw = createBridgeSyncFacade("_kernelIsattyRaw");
6423+
var _kernelTtySizeRaw = createBridgeSyncFacade("_kernelTtySizeRaw");
64126424
function decodeBridgeJson(value) {
64136425
return typeof value === "string" ? JSON.parse(value) : value;
64146426
}

crates/execution/src/javascript.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ const V8_WALL_CLOCK_LIMIT_MS_ENV: &str = "AGENTOS_V8_WALL_CLOCK_LIMIT_MS";
8181
const NODE_SYNC_RPC_DEFAULT_DATA_BYTES: usize = 4 * 1024 * 1024;
8282
const NODE_SYNC_RPC_DEFAULT_WAIT_TIMEOUT_MS: u64 = 30_000;
8383
const NODE_SYNC_RPC_RESPONSE_QUEUE_CAPACITY: usize = 1;
84+
const FORWARD_KERNEL_STDIN_RPC_ENV: &str = "AGENTOS_FORWARD_KERNEL_STDIN_RPC";
8485
// Defense-in-depth headroom: a transient burst of guest events (e.g. a chatty
8586
// tool/skill turn) should be absorbed by the buffer, so the producer only ever
8687
// hits backpressure under a genuinely stuck consumer rather than on every spike.
@@ -481,6 +482,7 @@ struct LocalBridgeState {
481482
next_timer_id: u64,
482483
timers: Arc<Mutex<HashMap<u64, LocalTimerEntry>>>,
483484
kernel_stdin: Arc<LocalKernelStdinBridge>,
485+
forward_kernel_stdin_rpc: bool,
484486
v8_session: Option<V8SessionHandle>,
485487
/// Optional read-only reader over the mounted `node_modules` VFS, supplied by
486488
/// the sidecar. When present, the bridge thread resolves module-resolution
@@ -1888,6 +1890,10 @@ impl JavascriptExecutionEngine {
18881890
local_bridge.v8_session = Some(v8_session.clone());
18891891
local_bridge.module_reader = module_reader;
18901892
local_bridge.module_resolution = GuestModuleResolution::from_env(&request.env);
1893+
local_bridge.forward_kernel_stdin_rpc = request
1894+
.env
1895+
.get(FORWARD_KERNEL_STDIN_RPC_ENV)
1896+
.is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true"));
18911897
let events = spawn_v8_event_bridge(
18921898
frame_receiver,
18931899
pending_sync_rpc.clone(),
@@ -3031,6 +3037,7 @@ impl LocalBridgeState {
30313037
bytes[15],
30323038
))))
30333039
}
3040+
"_kernelStdinRead" | "_kernelStdinReadRaw" if self.forward_kernel_stdin_rpc => None,
30343041
"_kernelStdinRead" | "_kernelStdinReadRaw" => Some(LocalBridgeCallResult::Immediate(
30353042
self.kernel_stdin.read(args),
30363043
)),

crates/execution/src/node_import_cache.rs

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const NODE_IMPORT_CACHE_MATERIALIZE_TIMEOUT_MS_ENV: &str =
1717
"AGENTOS_NODE_IMPORT_CACHE_MATERIALIZE_TIMEOUT_MS";
1818
const NODE_IMPORT_CACHE_SCHEMA_VERSION: &str = "1";
1919
const NODE_IMPORT_CACHE_LOADER_VERSION: &str = "8";
20-
const NODE_IMPORT_CACHE_ASSET_VERSION: &str = "71";
20+
const NODE_IMPORT_CACHE_ASSET_VERSION: &str = "78";
2121
const NODE_IMPORT_CACHE_DIR_PREFIX: &str = "agentos-node-import-cache";
2222
const DEFAULT_NODE_IMPORT_CACHE_MATERIALIZE_TIMEOUT: Duration = Duration::from_secs(30);
2323
const PYODIDE_DIST_DIR: &str = "pyodide-dist";
@@ -9228,17 +9228,24 @@ function decodeBase64ToUint8Array(value) {
92289228
return bytes;
92299229
}
92309230
9231-
function readKernelStdinChunk(maxBytes) {
9231+
function readKernelStdinChunk(maxBytes, timeoutMs = 10) {
92329232
const requestedLength = Math.max(1, Number(maxBytes) >>> 0);
9233+
const numericTimeoutMs = Number(timeoutMs);
9234+
const blocking = !Number.isFinite(numericTimeoutMs) || numericTimeoutMs >= 0xffffffff;
9235+
const deadline = blocking ? 0 : Date.now() + Math.max(0, numericTimeoutMs);
92339236
while (true) {
9234-
const response = callSyncRpc('__kernel_stdin_read', [requestedLength, 10]);
9237+
const waitMs = blocking ? 10 : Math.max(0, Math.min(10, deadline - Date.now()));
9238+
const response = callSyncRpc('__kernel_stdin_read', [requestedLength, waitMs]);
92359239
if (response && typeof response.dataBase64 === 'string') {
92369240
return Buffer.from(response.dataBase64, 'base64');
92379241
}
92389242
if (response && response.done === true) {
92399243
return null;
92409244
}
9241-
Atomics.wait(syntheticWaitArray, 0, 0, 10);
9245+
if (!blocking && Date.now() >= deadline) {
9246+
return Buffer.alloc(0);
9247+
}
9248+
Atomics.wait(syntheticWaitArray, 0, 0, blocking ? 10 : Math.max(0, Math.min(10, deadline - Date.now())));
92429249
}
92439250
}
92449251
@@ -12380,7 +12387,12 @@ const hostUserImport = {
1238012387
},
1238112388
isatty(fd, retBoolPtr) {
1238212389
const descriptor = Number(fd) >>> 0;
12383-
const isTerminal = descriptor <= 2 ? 0 : 0;
12390+
let isTerminal = 0;
12391+
try {
12392+
isTerminal = callSyncRpc('__kernel_isatty', [descriptor]) === true ? 1 : 0;
12393+
} catch {
12394+
isTerminal = 0;
12395+
}
1238412396
return writeGuestUint32(retBoolPtr, isTerminal);
1238512397
},
1238612398
getpwuid(uid, bufPtr, bufLen, retLenPtr) {
@@ -12393,6 +12405,67 @@ const hostUserImport = {
1239312405
},
1239412406
};
1239512407
12408+
const hostTtyImport = {
12409+
isatty(fd) {
12410+
try {
12411+
const result = callSyncRpc('__kernel_isatty', [Number(fd) >>> 0]);
12412+
return result === true || result === 1 ? 1 : 0;
12413+
} catch (error) {
12414+
process?.stderr?.write?.(`WARN host_tty.isatty failed: ${error?.stack || error}\n`);
12415+
return 0;
12416+
}
12417+
},
12418+
get_size(fd, colsPtr, rowsPtr) {
12419+
try {
12420+
if (!(instanceMemory instanceof WebAssembly.Memory)) {
12421+
return WASI_ERRNO_FAULT;
12422+
}
12423+
const size = callSyncRpc('__kernel_tty_size', [Number(fd) >>> 0]);
12424+
if (!size || typeof size !== 'object') {
12425+
return WASI_ERRNO_FAULT;
12426+
}
12427+
const colsValue = Array.isArray(size) ? size[0] : size.cols;
12428+
const rowsValue = Array.isArray(size) ? size[1] : size.rows;
12429+
const cols = Math.max(0, Math.min(0xffff, Number(colsValue) >>> 0));
12430+
const rows = Math.max(0, Math.min(0xffff, Number(rowsValue) >>> 0));
12431+
const view = new DataView(instanceMemory.buffer);
12432+
view.setUint16(Number(colsPtr) >>> 0, cols, true);
12433+
view.setUint16(Number(rowsPtr) >>> 0, rows, true);
12434+
return WASI_ERRNO_SUCCESS;
12435+
} catch (error) {
12436+
process?.stderr?.write?.(`WARN host_tty.get_size failed: ${error?.stack || error}\n`);
12437+
return WASI_ERRNO_FAULT;
12438+
}
12439+
},
12440+
set_raw_mode(enabled) {
12441+
try {
12442+
callSyncRpc('__pty_set_raw_mode', [Number(enabled) !== 0]);
12443+
return WASI_ERRNO_SUCCESS;
12444+
} catch (error) {
12445+
process?.stderr?.write?.(`WARN host_tty.set_raw_mode failed: ${error?.stack || error}\n`);
12446+
return WASI_ERRNO_FAULT;
12447+
}
12448+
},
12449+
read(ptr, len, timeoutMs = 10) {
12450+
try {
12451+
if (!(instanceMemory instanceof WebAssembly.Memory)) {
12452+
return 0;
12453+
}
12454+
const requestedLength = Math.max(1, Number(len) >>> 0);
12455+
const chunk = readKernelStdinChunk(requestedLength, Number(timeoutMs) >>> 0);
12456+
if (!chunk || chunk.length === 0) {
12457+
return 0;
12458+
}
12459+
const written = Math.min(chunk.length, requestedLength);
12460+
new Uint8Array(instanceMemory.buffer).set(chunk.subarray(0, written), Number(ptr) >>> 0);
12461+
return written >>> 0;
12462+
} catch (error) {
12463+
process?.stderr?.write?.(`WARN host_tty.read failed: ${error?.stack || error}\n`);
12464+
return 0;
12465+
}
12466+
},
12467+
};
12468+
1239612469
const HOST_FS_MODE_REGULAR = 0o100644;
1239712470
const HOST_FS_MODE_CHARACTER = 0o020666;
1239812471
const HOST_FS_MODE_FIFO = 0o010600;
@@ -12874,7 +12947,7 @@ wasiImport.fd_read = (fd, iovs, iovsLen, nreadPtr) => {
1287412947
const sidecarManagedProcess =
1287512948
typeof process?.env?.AGENTOS_SANDBOX_ROOT === 'string' &&
1287612949
process.env.AGENTOS_SANDBOX_ROOT.length > 0;
12877-
if (sidecarManagedProcess || KERNEL_STDIO_SYNC_RPC) {
12950+
if (typeof callSyncRpc === 'function') {
1287812951
try {
1287912952
const requestedLength = (() => {
1288012953
if (!(instanceMemory instanceof WebAssembly.Memory)) {
@@ -12888,7 +12961,7 @@ wasiImport.fd_read = (fd, iovs, iovsLen, nreadPtr) => {
1288812961
}
1288912962
return total >>> 0;
1289012963
})();
12891-
const chunk = readKernelStdinChunk(requestedLength);
12964+
const chunk = readKernelStdinChunk(requestedLength, 0xffffffff);
1289212965
if (!chunk || chunk.length === 0) {
1289312966
return writeGuestUint32(nreadPtr, 0);
1289412967
}
@@ -13637,6 +13710,7 @@ const instance = new WebAssembly.Instance(module, {
1363713710
: limitedHostProcessImport,
1363813711
host_net: permissionTier === 'full' ? hostNetImport : undefined,
1363913712
host_user: hostUserImport,
13713+
host_tty: hostTtyImport,
1364013714
host_fs: hostFsImport,
1364113715
});
1364213716

crates/execution/src/v8_runtime.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,8 @@ pub fn map_bridge_method(method: &str) -> (&str, bool) {
287287
"_kernelStdinReadRaw" => ("__kernel_stdin_read", false),
288288
"_kernelStdioWriteRaw" => ("__kernel_stdio_write", false),
289289
"_kernelPollRaw" => ("__kernel_poll", false),
290+
"_kernelIsattyRaw" => ("__kernel_isatty", false),
291+
"_kernelTtySizeRaw" => ("__kernel_tty_size", false),
290292

291293
// Network operations
292294
"_networkHttpServerListenRaw" => ("net.http_listen", false),
@@ -577,6 +579,7 @@ mod tests {
577579
"_netSocketSetNoDelayRaw",
578580
"_kernelStdioWriteRaw",
579581
"_kernelPollRaw",
582+
"_kernelTtySizeRaw",
580583
"_netSocketUpgradeTlsRaw",
581584
"_tlsGetCiphersRaw",
582585
"_dgramSocketAddressRaw",

crates/execution/src/wasm.rs

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2157,11 +2157,17 @@ fn build_wasm_internal_env(
21572157
.filter(|(key, _)| key.starts_with("AGENTOS_"))
21582158
.map(|(key, value)| (key.clone(), value.clone()))
21592159
.collect::<BTreeMap<_, _>>();
2160-
2160+
if let Some(value) = request.env.get("SECURE_EXEC_KEEP_STDIN_OPEN") {
2161+
internal_env.insert(String::from("SECURE_EXEC_KEEP_STDIN_OPEN"), value.clone());
2162+
}
21612163
internal_env.insert(
21622164
WASM_MODULE_PATH_ENV.to_string(),
21632165
resolved_module.specifier.clone(),
21642166
);
2167+
internal_env.insert(
2168+
String::from("AGENTOS_FORWARD_KERNEL_STDIN_RPC"),
2169+
String::from("1"),
2170+
);
21652171
if let Ok(module_bytes) = fs::read(&resolved_module.resolved_path) {
21662172
internal_env.insert(
21672173
String::from("AGENTOS_WASM_MODULE_BASE64"),
@@ -2199,8 +2205,6 @@ fn build_wasm_internal_env(
21992205
} else {
22002206
internal_env.remove(WASM_PREWARM_ONLY_ENV);
22012207
}
2202-
internal_env.remove("SECURE_EXEC_KEEP_STDIN_OPEN");
2203-
22042208
internal_env
22052209
}
22062210

@@ -2282,7 +2286,7 @@ const __agentOSRequireBuiltin = (specifier) => {{
22822286
}}
22832287
throw new Error(`secure-exec WASM bootstrap cannot load ${{specifier}}`);
22842288
}};
2285-
if (typeof globalThis !== "undefined" && typeof globalThis.__agentOSWasiModule === "undefined") {{
2289+
if (typeof globalThis !== "undefined") {{
22862290
const __agentOSFs = () => __agentOSRequireBuiltin("node:fs");
22872291
const __agentOSPath = () => __agentOSRequireBuiltin("node:path");
22882292
const __agentOSCrypto = () => __agentOSRequireBuiltin("node:crypto");
@@ -3444,7 +3448,7 @@ if (typeof globalThis !== "undefined" && typeof globalThis.__agentOSWasiModule =
34443448
const sidecarManagedProcess =
34453449
typeof process?.env?.AGENTOS_SANDBOX_ROOT === "string" &&
34463450
process.env.AGENTOS_SANDBOX_ROOT.length > 0;
3447-
if (syncRpc && (sidecarManagedProcess || __agentOSKernelStdioSyncRpcEnabled())) {{
3451+
if (syncRpc) {{
34483452
try {{
34493453
let chunk = null;
34503454
while (true) {{
@@ -4367,6 +4371,21 @@ if (typeof globalThis !== "undefined" && typeof globalThis.__agentOSSyncRpc ===
43674371
throw new Error("secure-exec WASM kernel poll bridge is unavailable");
43684372
}}
43694373
return _kernelPollRaw.applySync(void 0, args);
4374+
case "__kernel_isatty":
4375+
if (typeof _kernelIsattyRaw === "undefined") {{
4376+
throw new Error("secure-exec WASM kernel isatty bridge is unavailable");
4377+
}}
4378+
return _kernelIsattyRaw.applySync(void 0, args);
4379+
case "__kernel_tty_size":
4380+
if (typeof _kernelTtySizeRaw === "undefined") {{
4381+
throw new Error("secure-exec WASM kernel tty size bridge is unavailable");
4382+
}}
4383+
return _kernelTtySizeRaw.applySync(void 0, args);
4384+
case "__pty_set_raw_mode":
4385+
if (typeof _ptySetRawMode === "undefined") {{
4386+
throw new Error("secure-exec WASM PTY raw-mode bridge is unavailable");
4387+
}}
4388+
return _ptySetRawMode.applySync(void 0, args);
43704389
case "child_process.spawn": {{
43714390
if (typeof _childProcessSpawnStart === "undefined") {{
43724391
throw new Error("secure-exec WASM child_process bridge is unavailable");

crates/kernel/src/kernel.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ use crate::process_table::{
2727
ProcessTableError, ProcessWaitResult, SigmaskHow, SignalSet, DEFAULT_PROCESS_UMASK, SIGCONT,
2828
SIGPIPE, SIGSTOP, SIGTSTP, SIGWINCH,
2929
};
30-
use crate::pty::{LineDisciplineConfig, PartialTermios, PtyError, PtyManager, Termios};
30+
use crate::pty::{
31+
LineDisciplineConfig, PartialTermios, PtyError, PtyManager, PtyWindowSize, Termios,
32+
};
3133
use crate::resource_accounting::{
3234
measure_filesystem_usage, FileSystemUsage, ResourceAccountant, ResourceError, ResourceLimits,
3335
ResourceSnapshot, DEFAULT_MAX_OPEN_FDS,
@@ -2488,6 +2490,16 @@ impl<F: VirtualFileSystem + 'static> KernelVm<F> {
24882490
Ok(self.ptys.is_slave(entry.description.id()))
24892491
}
24902492

2493+
pub fn pty_window_size(
2494+
&self,
2495+
requester_driver: &str,
2496+
pid: u32,
2497+
fd: u32,
2498+
) -> KernelResult<PtyWindowSize> {
2499+
let description = self.description_for_fd(requester_driver, pid, fd)?;
2500+
Ok(self.ptys.window_size(description.id())?)
2501+
}
2502+
24912503
pub fn pty_set_discipline(
24922504
&self,
24932505
requester_driver: &str,

0 commit comments

Comments
 (0)