From afe726a2f9cb002276090710ad809b9c6f40ea26 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 12 Jul 2026 15:00:59 -0700 Subject: [PATCH] fix(shell): restore reliable interactive startup [skip ci] --- CLAUDE.md | 10 +++ .../src/actions/contract_surface.rs | 5 +- crates/execution/src/wasm.rs | 28 ++++++- justfile | 81 ++++++++++++++++++- packages/core/tests/brush-interactive.test.ts | 14 ++-- packages/shell/CLAUDE.md | 6 +- packages/shell/src/actor-vm.ts | 2 + packages/shell/src/main.ts | 12 ++- packages/shell/tests/cli.test.ts | 18 ++++- 9 files changed, 154 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b0298183e0..c0b7f45a3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,16 @@ the guest — over inventing a softer fallback that hides the failure. they do not scan `node_modules` or parse adapter manifests for discovery. - TypeScript and Rust clients must stay behaviorally identical. Any public method or wire behavior change in one client must be mirrored in the other. +- Clients are thin transport adapters, not runtime policy owners. They may + validate and serialize explicit caller input, forward requests, route host + callbacks/events, and retain host-only state that the sidecar cannot access. + VM defaults, base environment, filesystem/bootstrap policy, default software, + permission policy, agent/session orchestration, prompt assembly, and other + behavior shared across clients belong in the sidecar/runtime. +- Behavioral parity must come from one sidecar-owned implementation, not copied + TypeScript/Rust/actor constants or parallel state machines. Prefer omitted + wire fields meaning "use the sidecar default"; clients should send overrides + only when the caller explicitly supplied them. - Agent adapters must use real upstream SDKs. Do not replace SDK adapters with direct API-call stubs. diff --git a/crates/agentos-actor-plugin/src/actions/contract_surface.rs b/crates/agentos-actor-plugin/src/actions/contract_surface.rs index 1298f929e1..212b08863c 100644 --- a/crates/agentos-actor-plugin/src/actions/contract_surface.rs +++ b/crates/agentos-actor-plugin/src/actions/contract_surface.rs @@ -316,9 +316,8 @@ pub fn render_actor_actions_ts() -> String { let mut out = String::new(); out.push_str("// @generated by agentos-actor-plugin. Do not edit.\n"); - out.push_str("// This file is generated locally and is intentionally not committed to Git.\n"); - out.push_str("// Regenerated by the agentos-actor-plugin build script; build the crate\n"); - out.push_str("// (e.g. `cargo build -p agentos-actor-plugin`) to refresh it.\n"); + out.push_str("// This file is committed so package builds do not need to compile the native\n"); + out.push_str("// plugin just to regenerate TypeScript action types.\n"); for import in TYPE_IMPORTS { render_import(&mut out, import); diff --git a/crates/execution/src/wasm.rs b/crates/execution/src/wasm.rs index d8e6dd3c43..8a7a806dc2 100644 --- a/crates/execution/src/wasm.rs +++ b/crates/execution/src/wasm.rs @@ -110,6 +110,14 @@ const WASM_SIDECAR_ROUTED_FS_SYNC_METHODS: &[&str] = &[ "fs.writeFileSync", "fs.writeSync", ]; +const WASM_SIDECAR_ROUTED_KERNEL_SYNC_METHODS: &[&str] = &[ + "__kernel_isatty", + "__kernel_poll", + "__kernel_stdin_read", + "__kernel_stdio_write", + "__kernel_tty_size", + "__pty_set_raw_mode", +]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WasmSignalDispositionAction { @@ -1525,7 +1533,8 @@ fn wasm_sync_rpc_method_routes_through_sidecar_kernel( internal_sync_rpc: &WasmInternalSyncRpc, ) -> bool { internal_sync_rpc.route_fs_through_sidecar - && WASM_SIDECAR_ROUTED_FS_SYNC_METHODS.contains(&request.method.as_str()) + && (WASM_SIDECAR_ROUTED_FS_SYNC_METHODS.contains(&request.method.as_str()) + || WASM_SIDECAR_ROUTED_KERNEL_SYNC_METHODS.contains(&request.method.as_str())) } fn translate_wasm_guest_path( @@ -3842,7 +3851,8 @@ mod tests { DEFAULT_WASM_RUNNER_HEAP_LIMIT_MB, NODE_WASI_MODULE_SOURCE, WASM_CAPTURED_OUTPUT_LIMIT_BYTES, WASM_MAX_FUEL_ENV, WASM_MAX_MEMORY_BYTES_ENV, WASM_MAX_STACK_BYTES_ENV, WASM_PAGE_BYTES, WASM_SANDBOX_ROOT_ENV, - WASM_SIDECAR_ROUTED_FS_SYNC_METHODS, WASM_SYNC_READ_LIMIT_BYTES, + WASM_SIDECAR_ROUTED_FS_SYNC_METHODS, WASM_SIDECAR_ROUTED_KERNEL_SYNC_METHODS, + WASM_SYNC_READ_LIMIT_BYTES, }; use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::fs; @@ -4620,7 +4630,7 @@ mod tests { } #[test] - fn wasm_sidecar_managed_fs_methods_route_to_kernel_sync_rpc() { + fn wasm_sidecar_managed_methods_route_to_kernel_sync_rpc() { let mut standalone = WasmInternalSyncRpc { module_guest_paths: Vec::new(), module_host_path: PathBuf::from("/tmp/module.wasm"), @@ -4658,6 +4668,18 @@ mod tests { ); } + for method in WASM_SIDECAR_ROUTED_KERNEL_SYNC_METHODS { + let request = wasm_sync_rpc_request(method); + assert!( + wasm_sync_rpc_method_routes_through_sidecar_kernel(&request, &sidecar_managed), + "{method} should route through the sidecar kernel for managed WASI executions" + ); + assert!( + !wasm_sync_rpc_method_routes_through_sidecar_kernel(&request, &standalone), + "{method} should stay local for standalone/prewarm WASI execution" + ); + } + standalone.route_fs_through_sidecar = true; let non_fs_request = wasm_sync_rpc_request("child_process.spawn"); assert!(!wasm_sync_rpc_method_routes_through_sidecar_kernel( diff --git a/justfile b/justfile index 43fcfe55f5..541c0f31e5 100644 --- a/justfile +++ b/justfile @@ -50,7 +50,86 @@ install-shell: (cd packages/shell && PATH="$global_bin_dir:$PATH" pnpm link --global) shell *args: - NODE_OPTIONS="--no-deprecation ${NODE_OPTIONS:-}" pnpm --filter @rivet-dev/agentos-shell exec tsx src/main.ts -i -t "$@" + #!/usr/bin/env bash + set -euo pipefail + actor_mode=false + for arg in "$@"; do + if [[ "$arg" == "--actor" ]]; then + actor_mode=true + fi + done + if [[ ! -x packages/shell/node_modules/.bin/tsx \ + || ! -e packages/shell/node_modules/@agentos-software/codex-cli \ + || ! -d packages/build-tools/node_modules ]]; then + pnpm install --force + fi + missing_registry_packages=() + for package_json in packages/shell/node_modules/@agentos-software/*/package.json; do + IFS=$'\t' read -r package_name package_main < <(node -e ' + const manifest = require(require("node:path").resolve(process.argv[1])); + console.log(`${manifest.name}\t${manifest.main ?? ""}`); + ' "$package_json") + package_dir="${package_json%/package.json}" + if [[ -n "$package_main" && ( ! -e "$package_dir/${package_main#./}" \ + || ! -e "$package_dir/dist/package.aospkg" ) ]]; then + missing_registry_packages+=("$package_name") + fi + done + if (( ${#missing_registry_packages[@]} > 0 )); then + pnpm --filter @agentos-software/manifest build + pnpm --filter @rivet-dev/agentos-toolchain build + registry_filters=() + for package_name in "${missing_registry_packages[@]}"; do + registry_filters+=(--filter "$package_name") + done + pnpm "${registry_filters[@]}" build + fi + if [[ ! -e registry/software/common/dist/index.js ]]; then + pnpm --filter @agentos-software/common build + fi + if [[ ! -e packages/runtime-core/dist/index.js \ + || ! -e packages/core/dist/index.js \ + || ! -e packages/agentos/dist/index.js ]]; then + pnpm --filter @rivet-dev/agentos-runtime-core build + pnpm --filter @rivet-dev/agentos-core build + pnpm --filter @rivet-dev/agentos build + fi + if [[ "$actor_mode" == true ]]; then + r6_root="${AGENTOS_R6_ROOT:-$PWD/../r6}" + rivetkit_loader="$r6_root/rivetkit-typescript/packages/rivetkit/node_modules/tsx/dist/loader.mjs" + if [[ ! -e "$r6_root/pnpm-lock.yaml" ]]; then + echo "just shell --actor requires the Rivet repo at $r6_root (override with AGENTOS_R6_ROOT)" >&2 + exit 1 + fi + if [[ ! -e "$rivetkit_loader" ]]; then + pnpm --dir "$r6_root" install --frozen-lockfile --filter 'rivetkit...' + fi + if [[ ! -e "$r6_root/shared/typescript/virtual-websocket/dist/mod.js" \ + || ! -e "$r6_root/rivetkit-typescript/packages/traces/dist/tsup/index.js" \ + || ! -e "$r6_root/rivetkit-typescript/packages/workflow-engine/dist/tsup/index.js" \ + || ! -e "$r6_root/engine/sdks/typescript/envoy-protocol/dist/index.js" \ + || ! -e "$r6_root/rivetkit-typescript/packages/rivetkit-wasm/pkg/rivetkit_wasm.js" ]]; then + pnpm --dir "$r6_root" --filter 'rivetkit...' build + fi + fi + cargo_packages=(-p agentos-sidecar) + if [[ "$actor_mode" == true ]]; then + cargo_packages+=(-p agentos-actor-plugin) + fi + CARGO_TARGET_DIR="$PWD/target" cargo build "${cargo_packages[@]}" + actor_env=() + if [[ "$actor_mode" == true ]]; then + case "$(uname -s)" in + Darwin) plugin_name=libagentos_actor_plugin.dylib ;; + Linux) plugin_name=libagentos_actor_plugin.so ;; + *) plugin_name=agentos_actor_plugin.dll ;; + esac + actor_env+=(AGENTOS_PLUGIN_BIN="$PWD/target/debug/$plugin_name") + fi + env "${actor_env[@]}" \ + AGENTOS_SIDECAR_BIN="$PWD/target/debug/agentos-sidecar" \ + NODE_OPTIONS="--no-deprecation ${NODE_OPTIONS:-}" \ + pnpm --filter @rivet-dev/agentos-shell exec tsx src/main.ts "$@" # Run the agentos-sdk.dev site (landing + /docs) locally with hot reload docs: diff --git a/packages/core/tests/brush-interactive.test.ts b/packages/core/tests/brush-interactive.test.ts index 4329e833cd..07317e374c 100644 --- a/packages/core/tests/brush-interactive.test.ts +++ b/packages/core/tests/brush-interactive.test.ts @@ -33,10 +33,12 @@ import type { AgentOs } from "../src/index.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, "../../.."); -const SIDECAR_BINARY = resolve(REPO_ROOT, "target/debug/agentos-sidecar"); +const SIDECAR_BINARY = + process.env.AGENTOS_SIDECAR_BIN ?? + resolve(REPO_ROOT, "target/debug/agentos-sidecar"); const REGISTRY_SH_CANDIDATES = [ - "../secure-exec/registry/native/target/wasm32-wasip1/release/commands/sh", - "../secure-exec-provides/registry/native/target/wasm32-wasip1/release/commands/sh", + "packages/runtime-core/commands/sh", + "registry/native/target/wasm32-wasip1/release/commands/sh", ].map((candidate) => resolve(REPO_ROOT, candidate)); const REGISTRY_SH = REGISTRY_SH_CANDIDATES.find((candidate) => existsSync(candidate), @@ -72,10 +74,8 @@ async function waitFor(term: Terminal, text: string, timeoutMs = 20000): Promise throw new Error(`timeout waiting for ${JSON.stringify(text)}\n${snapshot("timeout", term)}`); } -// Requires the `sh` wasm command built locally (`make` in the secure-exec -// sibling's registry/native). CI consumes published @agentos-software packages -// and does not build wasm commands, so skip when the artifact is absent rather -// than failing the suite. +// Requires the vendored or locally-built `sh` wasm command. Skip when the +// artifact is absent rather than failing suites that do not build WASM commands. describe.skipIf(REGISTRY_SH === undefined)("brush interactive PTY repaint", () => { let vm: AgentOs | undefined; let term: Terminal | undefined; diff --git a/packages/shell/CLAUDE.md b/packages/shell/CLAUDE.md index 560fdcd30d..21e058d520 100644 --- a/packages/shell/CLAUDE.md +++ b/packages/shell/CLAUDE.md @@ -1,4 +1,8 @@ # agentos-shell +- Run the local interactive shell from the repo root with `just shell`. If workspace dependency links are missing, the recipe repairs the partially installed workspace with `pnpm install --force`. It also rebuilds missing shell software and direct runtime entrypoints, then visibly builds the sidecar at `target/debug/agentos-sidecar` before opening the guest prompt. Cold registry and sidecar builds can take a while; later runs are incremental. +- Keep `just shell` a transparent shim over the shell CLI: forward its arguments without injecting CLI flags. The CLI itself defaults stdin attachment and TTY mode on; callers can opt out with `--no-interactive` or `--no-tty`. +- `just shell` always uses the in-repo AgentOS sidecar it just built, even if the host environment exports `AGENTOS_SIDECAR_BIN`. To deliberately test another binary, invoke the shell CLI directly with that override. +- `just shell --actor` additionally builds and pins the in-repo actor plugin. Actor development requires the Rivet repo at `../r6` (override with `AGENTOS_R6_ROOT`); the recipe installs the filtered `rivetkit` dependency graph there when its `tsx` loader is missing. - Do not implement or route through a custom/synthetic shell, prompt, line editor, or command parser; interactive shell mode must launch native Bash through the terminal/PTY path so behavior matches `docker run -it bash`. -- Keep `agentos-shell` loading every command-providing package from secure-exec `registry/software/`; when that registry changes, update the imports, package dependencies, and smoke coverage here. +- Keep `agentos-shell` loading every command-providing package from the in-repo `registry/software/`; when that registry changes, update the imports, package dependencies, and smoke coverage here. diff --git a/packages/shell/src/actor-vm.ts b/packages/shell/src/actor-vm.ts index ec1b638a50..02274061b2 100644 --- a/packages/shell/src/actor-vm.ts +++ b/packages/shell/src/actor-vm.ts @@ -30,6 +30,7 @@ const POOL_NAME = "agentos-shell"; export interface ActorShellVmOptions { software: SoftwareInput[]; mounts: MountConfig[]; + defaultSoftware?: boolean; limits?: unknown; } @@ -256,6 +257,7 @@ export async function createActorShellVm( AGENTOS_SHELL_ACTOR_OPTIONS: JSON.stringify({ software: options.software, mounts: options.mounts, + defaultSoftware: options.defaultSoftware, ...(options.limits ? { limits: options.limits } : {}), }), AGENTOS_R6_ROOT: r6, diff --git a/packages/shell/src/main.ts b/packages/shell/src/main.ts index 8765ecc3c9..40940929ab 100644 --- a/packages/shell/src/main.ts +++ b/packages/shell/src/main.ts @@ -4,7 +4,8 @@ * Goal: `agentos-shell` should feel like the VM equivalent of `docker run`. * * Keep the CLI surface intentionally close to Docker's process flags: - * `-i/--interactive` keeps stdin attached, `-t/--tty` connects a terminal, + * stdin and TTY mode default on; `--no-interactive` and `--no-tty` disable them. + * `-i/--interactive` and `-t/--tty` remain accepted for Docker CLI familiarity, * `-e/--env` and `--env-file` inject environment variables, `-v/--volume` * and `--mount type=bind,...` mount host paths, and `-w/--workdir` chooses * the guest cwd. When TTY mode is requested, the guest command goes through @@ -239,9 +240,11 @@ function parseCli(argv: string[]): CliOptions { .argument("[command]", "guest command to run", "bash") .argument("[args...]", "guest command arguments") .addOption( - new Option("-i, --interactive", "keep stdin attached").default(false), + new Option("-i, --interactive", "keep stdin attached").default(true), ) - .addOption(new Option("-t, --tty", "connect a terminal").default(false)) + .addOption(new Option("--no-interactive", "detach stdin")) + .addOption(new Option("-t, --tty", "connect a terminal").default(true)) + .addOption(new Option("--no-tty", "disable terminal mode")) .addOption( new Option( "--actor", @@ -688,11 +691,12 @@ const env = buildEnv(cli); const mounts = buildMounts(cli); const vm: ShellVmHandle = cli.actor - ? await createActorShellVm({ software, mounts }) + ? await createActorShellVm({ software, mounts, defaultSoftware: false }) : await AgentOs.create({ mounts, permissions: allowAll, software, + defaultSoftware: false, }); let exitCode = 1; diff --git a/packages/shell/tests/cli.test.ts b/packages/shell/tests/cli.test.ts index a1c5762548..250a6b6f6d 100644 --- a/packages/shell/tests/cli.test.ts +++ b/packages/shell/tests/cli.test.ts @@ -24,7 +24,9 @@ describe("agentos-shell cli", () => { expect(result.status).toBe(0); expect(result.stdout).toContain("agentos-shell"); expect(result.stdout).toContain("-i, --interactive"); + expect(result.stdout).toContain("--no-interactive"); expect(result.stdout).toContain("-t, --tty"); + expect(result.stdout).toContain("--no-tty"); expect(result.stdout).toContain("-e, --env "); expect(result.stdout).toContain("-v, --volume "); expect(result.stderr).not.toContain("agent-os shell"); @@ -62,13 +64,23 @@ describe("agentos-shell cli", () => { expect(result.stdout).toContain("mounted"); }); - test("keeps stdin attached when -i is set", () => { - const result = runCli(["--interactive", "--", "bash"], "echo hello\n"); + test("keeps stdin attached by default", () => { + const result = runCli(["--", "bash"], "echo hello\n"); expect(result.status).toBe(0); expect(result.stdout).toContain("hello"); }); + test("detaches stdin when --no-interactive is set", () => { + const result = runCli( + ["--no-interactive", "--", "bash"], + "echo should-not-run\n", + ); + + expect(result.status).toBe(0); + expect(result.stdout).not.toContain("should-not-run"); + }); + test("runs a command through terminal mode when -t is set", () => { const result = runCli([ "--workdir", @@ -85,7 +97,7 @@ describe("agentos-shell cli", () => { }); test("default terminal mode launches bash instead of the synthetic shell", () => { - const result = runCli(["--interactive", "--tty"], "echo $0\nexit\n"); + const result = runCli([], "echo $0\nexit\n"); expect(result.status).toBe(0); expect(result.stdout).toContain("bash");