Skip to content
Merged
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
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,18 @@ jobs:
- uses: actions/download-artifact@v4
with:
name: wasm-commands
path: packages/runtime-core/commands
# Restore the canonical build output so registry packages can stage
# from the same path used by a local `just registry-native` build.
path: registry/native/target/wasm32-wasip1/release/commands
- run: node packages/runtime-core/scripts/copy-wasm-commands.mjs --require
- run: |
if grep -qE '^[[:space:]]*-[[:space:]]*website[[:space:]]*$' pnpm-workspace.yaml; then
npx turbo build --filter='!@agentos/website'
else
npx turbo build
fi
- name: Verify complete coreutils runtime package
run: pnpm --filter @agentos-software/coreutils build:runtime
- run: cargo clippy --workspace --all-targets -- -D warnings
- run: cargo test -p agentos-protocol -- --test-threads=1
- run: cargo test -p agentos-sidecar -- --test-threads=1
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ registry/native/c/vendor/
registry/native/c/libs/
registry/native/c/.cache/
registry/native/c/sysroot/
registry/software/coreutils/bin/
registry/software/*/wasm/
registry/software/*/agentos-package.meta.json
registry/.last-publish-hash
Expand Down
20 changes: 20 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,26 @@ the guest — over inventing a softer fallback that hides the failure.
only when the caller explicitly supplied them.
- Agent adapters must use real upstream SDKs. Do not replace SDK adapters with
direct API-call stubs.
- Native registry command binaries are generated artifacts. Do not commit
`registry/software/coreutils/bin/`; a fresh checkout intentionally has no
staged coreutils commands. Any VM, browser demo, or test that needs `sh` or
coreutils must build and stage the complete package from the repository root:

```bash
pnpm install --frozen-lockfile
just registry-native
pnpm --filter @agentos-software/coreutils build:runtime
```

`just registry-native` compiles the patched Rust and C WASI sources into
`registry/native/target/wasm32-wasip1/release/commands/`; the pnpm command
then stages `bin/` and assembles `dist/package/`. `just registry-native-cmd
sh` builds only one development artifact and is not sufficient to assemble
the complete coreutils package. The ordinary `build` script may create an
empty placeholder for source-only repository checks; that placeholder is not
runnable and must never be treated as proof that coreutils was built.
Publishing coreutils must run the strict `build:runtime` lifecycle and fail
when the generated command set is absent or incomplete.

## Publishing

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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-data stream and optional
//! diagnostic stderr tap. It pumps terminal bytes to clients as `shellData` and
//! preserves channel-specific diagnostics as `shellStderr`; a third task
//! broadcasts `shellExit` when the shell process exits. Pump task handles are
//! tracked in [`super::Vars::shell_tasks`] so VM teardown aborts them.

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_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
6 changes: 3 additions & 3 deletions crates/client/src/agent_os.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ pub(crate) struct ProcessEntry {

/// A PTY-backed shell (TS `_shells` value). Keyed by synthetic `shell-N` id.
///
/// `data_tx` carries stdout only, matching TS where the kernel handle's `onData` is fed exclusively
/// by `stdoutHandlers`. `stderr_tx` is the dedicated stderr channel that backs the `on_stderr` option
/// and `on_shell_stderr`, matching TS where stderr reaches the host only through `stderrHandlers`.
/// `data_tx` carries stdout and stderr in their original wire order for terminal renderers.
/// `stderr_tx` is an optional channel-specific diagnostic tap backing the `on_stderr` option and
/// `on_shell_stderr`; terminal consumers must not render both streams or stderr would be duplicated.
pub(crate) struct ShellEntry {
pub pid: u32,
pub data_tx: broadcast::Sender<Vec<u8>>,
Expand Down
77 changes: 36 additions & 41 deletions crates/client/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,10 @@
//! spawned via [`ExecuteRequest`]: its `process_id` is what `write_shell`/`close_shell` address on
//! the wire, while the public boundary keeps the synthetic `shell-N` id.
//!
//! Stream routing mirrors the TS real-process spawn path exactly: the public `data` stream
//! (`on_shell_data`) carries stdout ONLY, because TS wires only the kernel handle's `onData` (fed
//! exclusively by `stdoutHandlers`) into the data handlers. stderr is delivered on a SEPARATE channel
//! (`on_shell_stderr` + the [`OpenShellOptions::on_stderr`] callback), matching TS where stderr
//! reaches the host only through `stderrHandlers` / the `onStderr` option. Fanning stderr into the
//! data stream is only correct for the synthetic-prompt PTY path, which this native real-process path
//! does not implement.
//! Stream routing mirrors the TS PTY path: the public `data` stream (`on_shell_data`) carries stdout
//! and stderr in the order received from the sidecar. stderr is also delivered on an optional
//! channel-specific diagnostic tap (`on_shell_stderr` + [`OpenShellOptions::on_stderr`]); terminal
//! renderers consume only `data` so prompts and control sequences are neither reordered nor doubled.

use std::collections::BTreeMap;
use std::sync::atomic::{AtomicUsize, Ordering};
Expand All @@ -31,7 +28,7 @@ use crate::error::ClientError;
use crate::process::{install_output_callback, OutputCallback, ProcessStatus, StdinInput};
use crate::stream::ByteStream;

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

/// Maximum active or spawning terminals created by `connect_terminal` per VM.
Expand All @@ -47,9 +44,9 @@ const DEFAULT_SHELL_COMMAND: &str = "sh";

/// Options for `open_shell`.
///
/// `on_stderr` mirrors the TS `OpenShellOptions.onStderr` raw-byte callback: it is the dedicated
/// path stderr reaches the caller (stderr is never fanned into the data stream). It is seeded into
/// the stderr fan-out at open time, matching the TS `stderrHandlers.add(options.onStderr)` behavior.
/// `on_stderr` mirrors the TS `OpenShellOptions.onStderr` raw-byte callback. It is an optional
/// stderr-only diagnostic tap; the same bytes are already present once in ordered shell data, so a
/// terminal renderer must not consume both surfaces.
#[derive(Default)]
pub struct OpenShellOptions {
pub command: Option<String>,
Expand Down Expand Up @@ -230,9 +227,9 @@ impl AgentOs {
/// on a background task because the wire spawn is async. The exit task is tracked in the
/// pending-shell-exit set so `dispose` can drain it (two-phase teardown).
///
/// Stdout is fanned into the shell's `data` broadcast (`on_shell_data`); stderr is fanned into a
/// SEPARATE `stderr` broadcast (`on_shell_stderr` + the [`OpenShellOptions::on_stderr`] callback),
/// matching the TS real-process routing where stderr never reaches the data stream.
/// Stdout and stderr are fanned into the shell's ordered `data` broadcast (`on_shell_data`).
/// Stderr is also fanned into a dedicated diagnostic broadcast (`on_shell_stderr` and the
/// [`OpenShellOptions::on_stderr`] callback); terminal renderers should consume only `data`.
pub fn open_shell(&self, mut options: OpenShellOptions) -> Result<ShellHandle> {
let inner = self.inner();
let counter = inner.shell_counter.fetch_add(1, Ordering::SeqCst) + 1;
Expand Down Expand Up @@ -352,14 +349,12 @@ impl AgentOs {
if output.process_id != route_process_id {
continue;
}
// stdout -> data stream; stderr -> separate stderr stream (TS routing).
match output.channel {
StreamChannel::Stdout => {
let _ = data_tx.send(output.chunk);
}
StreamChannel::Stderr => {
let _ = stderr_tx.send(output.chunk);
}
// Publish every PTY chunk from this single wire-event consumer so terminal
// control sequences retain their original stdout/stderr order.
let _ = data_tx.send(output.chunk.clone());
if output.channel == StreamChannel::Stderr {
// Channel identity remains available as an optional diagnostic tap.
let _ = stderr_tx.send(output.chunk);
}
}
EventPayload::ProcessExitedEvent(exited) => {
Expand Down Expand Up @@ -502,8 +497,11 @@ impl AgentOs {
if output.process_id != route_process_id {
continue;
}
// Both stdout and stderr are appended to the same terminal output buffer
// (the agent reads a single combined stream), matching the TS handle.
let _ = data_tx.send(output.chunk.clone());
if output.channel == StreamChannel::Stderr {
let _ = stderr_tx.send(output.chunk.clone());
}
// Both channels are appended exactly once to the terminal output buffer.
on_output(&output.chunk);
}
EventPayload::ProcessExitedEvent(exited) => {
Expand Down Expand Up @@ -559,8 +557,8 @@ impl AgentOs {
/// be addressed by other shell methods. Killed during dispose via the ACP-terminal registry.
///
/// Mirrors the TS `connectTerminal`, which routes its `onData`/`onStderr` callbacks through
/// `openShell`. The Rust port opens a shell, wires the caller's `on_data` to the shell's data
/// stream and `on_stderr` to the shell's stderr stream, then returns the shell's pid. Host
/// `openShell`. The Rust port opens a shell, wires the caller's `on_data` to ordered terminal data
/// and `on_stderr` to the optional diagnostic tap, then returns the shell's pid. Host
/// stdin binding, terminal raw-mode, and SIGWINCH/resize forwarding are host-process concerns
/// that have no native wire op and are intentionally not bound here.
pub async fn connect_terminal(&self, options: ConnectTerminalOptions) -> Result<u32> {
Expand All @@ -575,9 +573,8 @@ impl AgentOs {
let (stderr_tx, _) =
tokio::sync::broadcast::channel::<Vec<u8>>(SHELL_DATA_CHANNEL_CAPACITY);

// Wire the caller's onData/onStderr to the terminal's streams (TS routes both through the
// shell handle's onData/onStderr). onData defaults to host stdout in TS; the Rust port has no
// host process stdout to bind to, so it only fans out when a sink is supplied.
// onData defaults to host stdout in TS; the Rust port has no host process stdout to bind to,
// so it only fans out when a sink is supplied. onStderr is diagnostic and independent.
if let Some(cb) = on_data {
install_output_callback(data_tx.clone(), cb);
}
Expand Down Expand Up @@ -631,13 +628,9 @@ impl AgentOs {
if output.process_id != route_process_id {
continue;
}
match output.channel {
StreamChannel::Stdout => {
let _ = data_tx.send(output.chunk);
}
StreamChannel::Stderr => {
let _ = stderr_tx.send(output.chunk);
}
let _ = data_tx.send(output.chunk.clone());
if output.channel == StreamChannel::Stderr {
let _ = stderr_tx.send(output.chunk);
}
}
EventPayload::ProcessExitedEvent(exited) => {
Expand Down Expand Up @@ -752,9 +745,10 @@ impl AgentOs {
}
}

/// Subscribe to a shell's stdout data. SYNC register; multi-handler; dropping the returned stream
/// is the unsubscribe. Carries stdout ONLY (stderr is on `on_shell_stderr`). Errors with
/// [`ClientError::ShellNotFound`].
/// Subscribe to a shell's ordered terminal data. SYNC register; multi-handler; dropping the
/// returned stream is the unsubscribe. Carries stdout and stderr exactly once in wire order.
/// Use [`Self::on_shell_stderr`] only as a channel-specific diagnostic tap, not as a second
/// terminal-rendering stream. Errors with [`ClientError::ShellNotFound`].
pub fn on_shell_data(&self, shell_id: &str) -> std::result::Result<ByteStream, ClientError> {
self.inner()
.shells
Expand All @@ -764,8 +758,9 @@ impl AgentOs {
}

/// Subscribe to a shell's stderr. SYNC register; multi-handler; dropping the returned stream is
/// the unsubscribe. This is the dedicated stderr channel backing the TS `onStderr` option; stderr
/// is never fanned into `on_shell_data`. Errors with [`ClientError::ShellNotFound`].
/// the unsubscribe. This is the optional diagnostic channel backing the TS `onStderr` option;
/// stderr is also present once in ordered `on_shell_data`. Errors with
/// [`ClientError::ShellNotFound`].
pub fn on_shell_stderr(&self, shell_id: &str) -> std::result::Result<ByteStream, ClientError> {
self.inner()
.shells
Expand Down
Loading
Loading