Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ crates/execution/.agentos-pyodide-cache/
crates/execution/async-out.txt
crates/sidecar/.tmp-sidecar-tests/
packages/browser/.cache/
examples/browser-terminal/.cache/
examples/browser-terminal/.rivetkit-data/
examples/browser-terminal/test-results/
examples/experiments/browser-base-shell/.cache/

# Vendored V8 bridge bundles staged at release time for crates.io publishing
crates/execution/assets/generated/
Expand Down
10 changes: 5 additions & 5 deletions crates/agentos-actor-plugin/src/actions/shell.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
//! Shell actions — the actor-side port of the `AgentOs` PTY shell surface
//! (`openShell` / `writeShell` / `resizeShell` / `closeShell` / `waitShell`).
//!
//! `open_shell` subscribes the shell's stdout/stderr streams and pumps each
//! chunk to connected clients as `shellData` / `shellStderr` broadcasts (the
//! event-driven mirror of the TS `onShellData` subscription); a third task
//! `open_shell` subscribes the shell's ordered terminal/stderr streams. It pumps
//! the combined terminal bytes to clients as `shellData` and preserves the
//! dedicated diagnostic channel as `shellStderr`; a third task
//! broadcasts `shellExit` when the shell process exits. Pump task handles are
//! tracked in [`super::Vars::shell_tasks`] so VM teardown aborts them.

Expand Down Expand Up @@ -106,13 +106,13 @@ pub fn open_shell(
})?;
let shell_id = handle.shell_id;

let mut data_stream = vm.on_shell_data(&shell_id)?;
let mut terminal_stream = vm.on_shell_terminal_data(&shell_id)?;
let mut stderr_stream = vm.on_shell_stderr(&shell_id)?;

let data_host = host.clone();
let data_shell_id = shell_id.clone();
vars.shell_tasks.push(tokio::spawn(async move {
while let Some(chunk) = data_stream.next().await {
while let Some(chunk) = terminal_stream.next().await {
broadcast_event(
&data_host,
b"shellData",
Expand Down
4 changes: 4 additions & 0 deletions crates/client/src/agent_os.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,14 @@ pub(crate) struct ProcessEntry {
/// `data_tx` carries stdout only, matching TS where the kernel handle's `onData` is fed exclusively
/// by `stdoutHandlers`. `stderr_tx` is the dedicated stderr channel that backs the `on_stderr` option
/// and `on_shell_stderr`, matching TS where stderr reaches the host only through `stderrHandlers`.
/// `terminal_tx` carries both streams in their original wire order for terminal renderers. Keeping
/// that merge at the single wire-event consumer avoids reordering terminal control sequences and
/// prompts when stdout/stderr are consumed by independent async tasks.
pub(crate) struct ShellEntry {
pub pid: u32,
pub data_tx: broadcast::Sender<Vec<u8>>,
pub stderr_tx: broadcast::Sender<Vec<u8>>,
pub terminal_tx: broadcast::Sender<Vec<u8>>,
/// The sidecar-side process id used on the wire.
pub process_id: String,
/// Spawn-readiness gate. Seeded `false`; flips to `true` once the background `Execute` request is
Expand Down
25 changes: 24 additions & 1 deletion crates/client/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use crate::error::ClientError;
use crate::process::{install_output_callback, OutputCallback, ProcessStatus, StdinInput};
use crate::stream::ByteStream;

/// Channel capacity for a shell's data / stderr broadcasts.
/// Channel capacity for a shell's data / stderr / ordered terminal broadcasts.
const SHELL_DATA_CHANNEL_CAPACITY: usize = 1024;

/// Maximum active or spawning terminals created by `connect_terminal` per VM.
Expand Down Expand Up @@ -242,6 +242,7 @@ impl AgentOs {

let (data_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
let (stderr_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
let (terminal_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
// Spawn-readiness gate: write/close await this before issuing their wire request.
let (spawned_tx, _) = tokio::sync::watch::channel(false);
// Exit-code channel backing `wait_shell`.
Expand All @@ -259,6 +260,7 @@ impl AgentOs {
pid: 0,
data_tx: data_tx.clone(),
stderr_tx: stderr_tx.clone(),
terminal_tx: terminal_tx.clone(),
process_id: process_id.clone(),
spawned_tx: spawned_tx.clone(),
exit_tx: exit_tx.clone(),
Expand Down Expand Up @@ -352,6 +354,9 @@ impl AgentOs {
if output.process_id != route_process_id {
continue;
}
// Preserve the exact wire order for PTY terminal renderers before also
// exposing the existing stdout/stderr-specific subscriptions.
let _ = terminal_tx.send(output.chunk.clone());
// stdout -> data stream; stderr -> separate stderr stream (TS routing).
match output.channel {
StreamChannel::Stdout => {
Expand Down Expand Up @@ -418,12 +423,14 @@ impl AgentOs {

let (data_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
let (stderr_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
let (terminal_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
let (spawned_tx, _) = tokio::sync::watch::channel(false);

let entry = ShellEntry {
pid: 0,
data_tx: data_tx.clone(),
stderr_tx: stderr_tx.clone(),
terminal_tx: terminal_tx.clone(),
process_id: process_id.clone(),
spawned_tx: spawned_tx.clone(),
// The caller-supplied exit channel doubles as the entry's `wait_shell` source.
Expand Down Expand Up @@ -502,6 +509,7 @@ impl AgentOs {
if output.process_id != route_process_id {
continue;
}
let _ = terminal_tx.send(output.chunk.clone());
// Both stdout and stderr are appended to the same terminal output buffer
// (the agent reads a single combined stream), matching the TS handle.
on_output(&output.chunk);
Expand Down Expand Up @@ -774,6 +782,21 @@ impl AgentOs {
.ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))
}

/// Subscribe to stdout and stderr in the exact order received from the sidecar. This is the
/// PTY-facing stream: terminal control sequences, command output, and prompts must not be split
/// across independently scheduled consumers. The stdout-only and stderr-only subscriptions
/// remain available for callers that need channel identity.
pub fn on_shell_terminal_data(
&self,
shell_id: &str,
) -> std::result::Result<ByteStream, ClientError> {
self.inner()
.shells
.read(shell_id, |_, entry| entry.terminal_tx.subscribe())
.map(ByteStream::new)
.ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))
}

/// Resize a shell's PTY winsize. SYNC fire-and-forget, mirroring the TS `ShellHandle.resize`
/// (which dispatches `resizePty` in the background after the spawn lands). Errors with
/// [`ClientError::ShellNotFound`].
Expand Down
62 changes: 59 additions & 3 deletions crates/execution/assets/runners/wasi-module.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ if (typeof globalThis !== "undefined" && typeof globalThis.__agentOSWasiModule =
const __agentOSWasiFiletypeRegularFile = 4;
const __agentOSWasiFiletypeSymbolicLink = 7;
const __agentOSWasiLookupSymlinkFollow = 1;
const __agentOSWasiFstflagsAtim = 1;
const __agentOSWasiFstflagsAtimNow = 2;
const __agentOSWasiFstflagsMtim = 4;
const __agentOSWasiFstflagsMtimNow = 8;
const __agentOSWasiOpenCreate = 1;
const __agentOSWasiOpenDirectory = 2;
const __agentOSWasiOpenExclusive = 4;
Expand Down Expand Up @@ -147,6 +151,7 @@ if (typeof globalThis !== "undefined" && typeof globalThis.__agentOSWasiModule =
fd_write: (...args) => this._fdWrite(...args),
path_create_directory: (...args) => this._pathCreateDirectory(...args),
path_filestat_get: (...args) => this._pathFilestatGet(...args),
path_filestat_set_times: (...args) => this._pathFilestatSetTimes(...args),
path_link: (...args) => this._pathLink(...args),
path_open: (...args) => this._pathOpen(...args),
path_readlink: (...args) => this._pathReadlink(...args),
Expand Down Expand Up @@ -1227,9 +1232,10 @@ if (typeof globalThis !== "undefined" && typeof globalThis.__agentOSWasiModule =
typeof bridgeStat?.applySyncPromise === "function" &&
typeof resolved?.guestPath === "string"
) {
return this._measureWasiPhase(follow ? "bridgeStatSync" : "bridgeLstatSync", () =>
bridgeStat.applySyncPromise(void 0, [resolved.guestPath])
);
return this._measureWasiPhase(follow ? "bridgeStatSync" : "bridgeLstatSync", () => {
const stats = bridgeStat.applySyncPromise(void 0, [resolved.guestPath]);
return typeof stats === "string" ? JSON.parse(stats) : stats;
});
}
const fsPath = this._resolvedFsPath(resolved);
return this._measureWasiPhase(follow ? "fsModuleStatSync" : "fsModuleLstatSync", () =>
Expand Down Expand Up @@ -2348,6 +2354,56 @@ if (typeof globalThis !== "undefined" && typeof globalThis.__agentOSWasiModule =
}
}

_pathFilestatSetTimes(fd, lookupFlags, pathPtr, pathLen, atim, mtim, fstFlags) {
try {
const resolved = this._resolveDescriptorPath(fd, pathPtr, pathLen);
if (resolved.error !== __agentOSWasiErrnoSuccess) {
return resolved.error;
}
if (resolved.readOnly) {
return __agentOSWasiErrnoRofs;
}
const flags = Number(fstFlags) >>> 0;
if (
(flags & __agentOSWasiFstflagsAtim) !== 0 &&
(flags & __agentOSWasiFstflagsAtimNow) !== 0
) {
return __agentOSWasiErrnoInval;
}
if (
(flags & __agentOSWasiFstflagsMtim) !== 0 &&
(flags & __agentOSWasiFstflagsMtimNow) !== 0
) {
return __agentOSWasiErrnoInval;
}
const fsPath = this._resolvedFsPath(resolved);
const follow = (Number(lookupFlags) & __agentOSWasiLookupSymlinkFollow) !== 0;
const stats = follow
? __agentOSFs().statSync(fsPath)
: __agentOSFs().lstatSync(fsPath);
const nowSeconds = Date.now() / 1000;
const atimeSeconds = (flags & __agentOSWasiFstflagsAtimNow) !== 0
? nowSeconds
: (flags & __agentOSWasiFstflagsAtim) !== 0
? Number(atim) / 1e9
: Number(stats.atimeMs || 0) / 1000;
const mtimeSeconds = (flags & __agentOSWasiFstflagsMtimNow) !== 0
? nowSeconds
: (flags & __agentOSWasiFstflagsMtim) !== 0
? Number(mtim) / 1e9
: Number(stats.mtimeMs || 0) / 1000;
this._clearStatCache();
if (!follow && typeof __agentOSFs().lutimesSync === "function") {
__agentOSFs().lutimesSync(fsPath, atimeSeconds, mtimeSeconds);
} else {
__agentOSFs().utimesSync(fsPath, atimeSeconds, mtimeSeconds);
}
return __agentOSWasiErrnoSuccess;
} catch (error) {
return this._mapFsError(error);
}
}

_pathReadlink(fd, pathPtr, pathLen, bufPtr, bufLen, bufUsedPtr) {
try {
const resolved = this._resolveDescriptorPath(fd, pathPtr, pathLen);
Expand Down
1 change: 1 addition & 0 deletions crates/execution/assets/wasi-preview1-imports.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"fd_write",
"path_create_directory",
"path_filestat_get",
"path_filestat_set_times",
"path_link",
"path_open",
"path_readlink",
Expand Down
4 changes: 4 additions & 0 deletions crates/kernel/src/pty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ impl Error for PtyError {}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct LineDisciplineConfig {
pub icrnl: Option<bool>,
pub canonical: Option<bool>,
pub echo: Option<bool>,
pub isig: Option<bool>,
Expand Down Expand Up @@ -757,6 +758,9 @@ impl PtyManager {
if let Some(canonical) = config.canonical {
pty.termios.icanon = canonical;
}
if let Some(icrnl) = config.icrnl {
pty.termios.icrnl = icrnl;
}
if let Some(echo) = config.echo {
pty.termios.echo = echo;
}
Expand Down
Loading
Loading