diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4f230ac9..357ac3f16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,19 +10,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - # Check out the secure-exec sibling repo that packages/core links to via - # `"@secure-exec/core": "link:../../../secure-exec/packages/core"`. That - # link resolves to `/../secure-exec/packages/core`, i.e. - # `$GITHUB_WORKSPACE/../secure-exec/packages/core`. actions/checkout can - # only write inside the workspace, so we check out into a subdir and then - # symlink it to the sibling path the link expects. - - uses: actions/checkout@v4 - with: - repository: rivet-dev/secure-exec - ref: main - path: _secure-exec-sibling - - name: Place secure-exec at the sibling path the link expects - run: ln -s "$GITHUB_WORKSPACE/_secure-exec-sibling" "$GITHUB_WORKSPACE/../secure-exec" - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: @@ -36,16 +23,9 @@ jobs: with: workspaces: | . -> target - # Build the link target so its dist/ exists. The `@secure-exec/core` - # subpath exports (./descriptors, ./vm-config, ./sidecar-client) resolve - # to dist/*.{js,d.ts}; without these the agent-os tsc build cannot find - # the module. The core build = protocol compile (Node) + a lightweight - # `cargo test -p secure-exec-vm-config` (pure serde/ts-rs crate, no V8 - # bridge / native build) + tsc, so this stays cheap in CI. - - name: Install + build @secure-exec/core (link target) - run: | - pnpm -C "$GITHUB_WORKSPACE/_secure-exec-sibling" install --frozen-lockfile - pnpm -C "$GITHUB_WORKSPACE/_secure-exec-sibling" --filter @secure-exec/core build + # `@secure-exec/core` now resolves from the npm catalog (published 0.3.0), + # so a plain frozen install pulls it from the registry — no sibling + # checkout/symlink/build is needed. - run: pnpm install --frozen-lockfile - run: pnpm build - run: pnpm --dir scripts/publish run check-types @@ -66,10 +46,12 @@ jobs: - run: node scripts/check-secure-exec-package-boundary.mjs - run: cargo fmt --check - run: cargo clippy --workspace --all-targets -- -D warnings - - run: cargo test -p agent-os-protocol -- --test-threads=1 - - run: cargo test -p agent-os-sidecar -- --test-threads=1 - - run: cargo test -p agent-os-sidecar-browser -- --test-threads=1 - - run: cargo test -p agent-os-client -- --test-threads=1 + - run: cargo test -p agentos-protocol -- --test-threads=1 + - run: cargo test -p agentos-sidecar -- --test-threads=1 + # NOTE: agentos-sidecar-browser is intentionally excluded from the + # workspace (it depends on secure-exec-sidecar-browser, unpublished on + # crates.io), so it is not tested here. Re-add once that crate publishes. + - run: cargo test -p agentos-client -- --test-threads=1 - run: pnpm check-types - run: pnpm lint - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 25d915aa0..87818f5fe 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -109,17 +109,11 @@ jobs: key: ${{ runner.os }}-${{ matrix.target }}-rusty-v8-${{ hashFiles('Cargo.lock') }} restore-keys: | ${{ runner.os }}-${{ matrix.target }}-rusty-v8- - # agent-os-sidecar path-depends on the secure-exec crates, which live in the - # sibling repo (preview crates are not published to crates.io). Clone the - # matching preview branch and install its pnpm workspace so the V8 bridge - # build (invoked by secure-exec's cargo build.rs) has esbuild + bridge srcs. - - name: Checkout secure-exec sibling - run: | - git clone --depth 1 --branch split/runtime-preview \ - https://github.com/rivet-dev/secure-exec.git ../secure-exec - (cd ../secure-exec && pnpm install --frozen-lockfile) - # The V8 bridge build script (invoked by cargo build.rs) needs the pnpm - # workspace installed so esbuild and v8-bridge.source.js are available. + # All Rust dependencies resolve from published registries — no sibling + # checkouts: the secure-exec runtime crates from crates.io (0.3.0, which + # vendor the prebuilt V8 bridge) and the RivetKit native-plugin ABI crate + # from crates.io (rivet-actor-plugin-abi). pnpm install is still needed for + # the workspace tooling consumed by the build. - run: pnpm install --frozen-lockfile - name: Build sidecar binary id: build @@ -128,13 +122,13 @@ jobs: out="target/sidecar-artifacts/${{ matrix.platform }}" mkdir -p "$out" if [ "${{ needs.context.outputs.trigger }}" = "release" ]; then - cargo build --release -p agent-os-sidecar --target ${{ matrix.target }} + cargo build --release -p agentos-sidecar --target ${{ matrix.target }} profile="release" else - cargo build -p agent-os-sidecar --target ${{ matrix.target }} + cargo build -p agentos-sidecar --target ${{ matrix.target }} profile="debug" fi - cp "target/${{ matrix.target }}/${profile}/agent-os-sidecar" "$out/agent-os-sidecar" + cp "target/${{ matrix.target }}/${profile}/agentos-sidecar" "$out/agentos-sidecar" echo "dir=$out" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@v4 with: @@ -142,13 +136,78 @@ jobs: path: ${{ steps.build.outputs.dir }} if-no-files-found: error + # --------------------------------------------------------------------------- + # build-plugin — agent-os actor plugin cdylib (debug preview / release) + # --------------------------------------------------------------------------- + # Ships inside the @rivet-dev/agentos-plugin- npm packages, declared + # as optionalDependencies of @rivet-dev/agentos. The cdylib path-depends on the + # secure-exec crates (sibling repo) AND the RivetKit native-plugin ABI crate + # (r6 sibling, not on crates.io yet), so both siblings are cloned for cargo to + # resolve the workspace path deps. + build-plugin: + needs: [context] + name: "Build plugin (${{ matrix.platform }})" + strategy: + fail-fast: false + matrix: + include: + - platform: linux-x64-gnu + runner: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + workspaces: . -> target + key: plugin-${{ matrix.target }}-${{ needs.context.outputs.trigger }} + - uses: actions/cache@v4 + with: + path: ~/.cargo/.rusty_v8 + key: ${{ runner.os }}-${{ matrix.target }}-rusty-v8-${{ hashFiles('Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.target }}-rusty-v8- + # All Rust deps resolve from crates.io (secure-exec 0.3.0 + the + # rivet-actor-plugin-abi ABI crate) — no sibling checkouts required. + - run: pnpm install --frozen-lockfile + - name: Build plugin cdylib + id: build + run: | + set -euo pipefail + out="target/plugin-artifacts/${{ matrix.platform }}" + mkdir -p "$out" + if [ "${{ needs.context.outputs.trigger }}" = "release" ]; then + cargo build --release -p agentos-actor-plugin --target ${{ matrix.target }} + profile="release" + else + cargo build -p agentos-actor-plugin --target ${{ matrix.target }} + profile="debug" + fi + cp "target/${{ matrix.target }}/${profile}/libagentos_actor_plugin.so" \ + "$out/libagentos_actor_plugin.so" + echo "dir=$out" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@v4 + with: + name: plugin-${{ matrix.platform }} + path: ${{ steps.build.outputs.dir }} + if-no-files-found: error + # --------------------------------------------------------------------------- # publish-npm — place binaries, build TS, publish all packages (all triggers) # --------------------------------------------------------------------------- publish-npm: - needs: [context, build-sidecar] + needs: [context, build-sidecar, build-plugin] name: "Publish npm" - if: ${{ !cancelled() && needs.build-sidecar.result == 'success' }} + if: ${{ !cancelled() && needs.build-sidecar.result == 'success' && needs.build-plugin.result == 'success' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -164,24 +223,45 @@ jobs: with: pattern: sidecar-* path: artifacts + - uses: actions/download-artifact@v4 + with: + pattern: plugin-* + path: artifacts - name: Place sidecar binaries into platform packages run: | set -euo pipefail for p in $SIDECAR_PLATFORMS; do - agent_bin="artifacts/sidecar-${p}/agent-os-sidecar" + agent_bin="artifacts/sidecar-${p}/agentos-sidecar" agent_dest="packages/sidecar-binary/npm/${p}" if [ ! -f "$agent_bin" ]; then - echo "::error::missing agent-os-sidecar binary artifact for ${p}" + echo "::error::missing agentos-sidecar binary artifact for ${p}" exit 1 fi if [ ! -d "$agent_dest" ]; then echo "::error::missing platform package dir $agent_dest" exit 1 fi - cp "$agent_bin" "${agent_dest}/agent-os-sidecar" - chmod +x "${agent_dest}/agent-os-sidecar" + cp "$agent_bin" "${agent_dest}/agentos-sidecar" + chmod +x "${agent_dest}/agentos-sidecar" echo "Placed binaries for ${p}" done + - name: Place plugin cdylib into platform packages + run: | + set -euo pipefail + for p in $SIDECAR_PLATFORMS; do + lib="artifacts/plugin-${p}/libagentos_actor_plugin.so" + dest="packages/agentos-plugin/npm/${p}" + if [ ! -f "$lib" ]; then + echo "::error::missing plugin cdylib artifact for ${p}" + exit 1 + fi + if [ ! -d "$dest" ]; then + echo "::error::missing plugin platform package dir $dest" + exit 1 + fi + cp "$lib" "${dest}/libagentos_actor_plugin.so" + echo "Placed plugin cdylib for ${p}" + done - name: Bump package versions for build (version-only) run: | pnpm --filter=publish exec tsx src/ci/bin.ts bump-versions \ @@ -190,7 +270,8 @@ jobs: - name: Build TypeScript packages run: | npx turbo build \ - --filter='!@rivet-dev/agent-os-playground' \ + --filter='!@rivet-dev/agentos-playground' \ + --filter='!@agentos/website' \ --filter='!./examples/*' - name: Finalize package versions for publish (inject optionalDeps) run: | @@ -244,12 +325,12 @@ jobs: # (downloaded by the published execution crate's build.rs). mkdir -p release-assets for p in $SIDECAR_PLATFORMS; do - agent_bin="artifacts/sidecar-${p}/agent-os-sidecar" + agent_bin="artifacts/sidecar-${p}/agentos-sidecar" if [ -f "$agent_bin" ]; then target="${PLATFORM_TARGET[$p]}" - cp "$agent_bin" "release-assets/agent-os-sidecar-${target}" + cp "$agent_bin" "release-assets/agentos-sidecar-${target}" else - echo "::warning::missing agent-os-sidecar binary for ${p}" + echo "::warning::missing agentos-sidecar binary for ${p}" fi done for f in \ @@ -319,14 +400,8 @@ jobs: key: ${{ runner.os }}-x86_64-unknown-linux-gnu-rusty-v8-${{ hashFiles('Cargo.lock') }} restore-keys: | ${{ runner.os }}-x86_64-unknown-linux-gnu-rusty-v8- - # a6 crates path-depend on the secure-exec runtime crates (sibling repo). - # Clone the matching preview branch + install its pnpm workspace so the V8 - # bridge build (secure-exec's cargo build.rs) has esbuild + bridge sources. - - name: Checkout secure-exec sibling - run: | - git clone --depth 1 --branch split/runtime-preview \ - https://github.com/rivet-dev/secure-exec.git ../secure-exec - (cd ../secure-exec && pnpm install --frozen-lockfile) + # All Rust deps resolve from crates.io (secure-exec 0.3.0 + the + # rivet-actor-plugin-abi ABI crate) — no sibling checkouts required. - run: pnpm install --frozen-lockfile - name: Bump Cargo versions run: | diff --git a/CLAUDE.md b/CLAUDE.md index 3e8a7717d..3188bf3b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,9 @@ Agent OS is the agent-facing wrapper around secure-exec. It provides ACP session ## Boundaries -- Local Agent OS development dependencies on secure-exec must point to `../secure-exec`. +- secure-exec dependency workflow. Manage the secure-exec dependency ONLY through `scripts/secure-exec-dep.mjs` (the `just secure-exec-*` recipes); never hand-edit the `path` / `version` / `catalog:` pins. + - Testing against local secure-exec changes: run `just secure-exec-local` to repoint npm (`link:`) and crates (`path = "../secure-exec/..."`) at the sibling checkout, then `node scripts/secure-exec-dep.mjs set-crate-version ` so the Cargo version requirement matches the sibling crate version (otherwise cargo cannot resolve the path deps). Use `just secure-exec-status` to inspect. This mode is for local builds/tests ONLY. + - Pushing changes that depend on secure-exec changes: NEVER push with local (`path:` / `link:`) dependencies. First preview-publish the secure-exec changes to their own secure-exec branch (the `preview-publish-secure-exec` flow), then point agent-os back at that exact published version with `just secure-exec-pinned` + `just secure-exec-set-version ` (and `set-crate-version ` for the crates). Only commit/push the pinned-to-remote state. - Keep generic runtime, kernel, VFS, language execution, and registry software behavior in secure-exec. - Agent OS owns ACP, sessions, agent adapters, toolkit semantics, quickstarts, and the AgentOs facade. - Call OS instances VMs, never sandboxes. @@ -12,6 +14,14 @@ Agent OS is the agent-facing wrapper around secure-exec. It provides ACP session ## Security Model +Trust model (decide which side of the boundary something is on before judging whether it is a security bug). Three components: + +- **Client** (trusted, *except for anything it submits for execution*). The AgentOs client / wire caller. The client and every value it configures are trusted: `CreateVmConfig`, mount descriptors and plugin configs (host_dir paths, S3 endpoints/credentials, Google Drive, sandbox-agent), the permission policy, network allowlist, resource limits, env, and DNS overrides. Configuration is **not** an attack surface. The only untrusted thing the client supplies is the code/payload it asks to run, because that runs in the executor. +- **Sidecar** (trusted; the TCB and enforcement point). The agent-os sidecar embeds and extends secure-exec; it brokers client requests and owns the kernel, VFS, mounts/plugins, socket table, and permission policy, and enforces the boundary against the executor. +- **Executor** — V8 isolates or WASM (untrusted; the adversary). Runs guest JS/Python/WASM plus any third-party/npm/agent-generated code. Assume it is actively hostile; how code reached the executor never makes it trusted. + +**The security boundary is sidecar ↔ executor.** A defect that requires the client to supply a malicious config/endpoint/credential/policy is NOT a sandbox vulnerability (the client configures its own VM and already controls the host). Treat such hardening as defense-in-depth, not as an escape, and do not add validation that only guards trusted client-provided configuration. Corollaries: the permission policy/limits are trusted input but the guest is the subject they bind, so a guest *bypassing* an applied rule is in-scope; a host-backed mount's target/credentials are trusted, but confining the guest's I/O *through* it (symlink / `..` / TOCTOU escapes) is in-scope. The wire transport is single-client over stdio, so wire authn/authz-between-clients and VM-to-VM-via-forged-id concerns are out of scope until a multi-client transport exists. See secure-exec root `CLAUDE.md` → Trust Model for the canonical statement. + - Isolation is layered (defense in depth), like Cloudflare Workers. Untrusted guest code is isolated *within* the host process by V8/WASM virtualization today; host-level jailing (sandboxing the process itself) is a planned additional layer. Because the in-process layer is load-bearing: keep the embedded V8 patched to current security releases, and never let one isolate take down the shared process — a per-isolate failure (heap OOM, CPU runaway) must terminate that isolate, not abort the host process. - Match Cloudflare Workers wherever it makes sense. Use Workers' published behavior as the reference point for isolation semantics, resource limits, and egress defaults — e.g. ~128 MiB memory per isolate, bounded CPU time, default-deny network egress. Resource limits must be bounded by default (never `None`/0 for memory, heap, stack, or CPU time); operators may raise them. @@ -27,11 +37,13 @@ Agent OS is the agent-facing wrapper around secure-exec. It provides ACP session - Agent OS extension payloads use the secure-exec `Ext` envelope with Agent OS-owned namespaces and generated ACP payloads. - Keep ACP decoding and session state in Agent OS wrapper code, not in secure-exec core sidecar code. - The agent-os sidecar wrapper embeds and extends secure-exec; secure-exec must remain free of ACP, agent, and session dependencies. +- Prefer the agent-os sidecar wrapper for heavy lifting. Multi-step ACP/session orchestration, state machines, and anything that would otherwise cost several client→sidecar round-trips belong in the sidecar (`crates/agent-os-sidecar`), exposed as a single wire request; the TypeScript (`packages/core`) and Rust (`crates/client`) clients stay thin forwarders and must BOTH expose it. Rationale: (a) keep clients simple and in parity, (b) cut client↔sidecar latency. Keep logic client-side only when it needs state the sidecar cannot reach — e.g. RivetKit actor durable storage (`ctx.db_*`/SQLite), which the sidecar has no access to. Even then, the sidecar must not pull ACP/session deps into secure-exec core. ## Website And Docs - The Agent OS website and docs live in `website/` (Astro + Starlight) and deploy to `agentos-sdk.dev` (docs at `agentos-sdk.dev/docs`). The marketing pages and docs were migrated out of `rivet.dev/agent-os` and `rivet.dev/docs/agent-os`, which now 301-redirect to this domain. - Docs styling is owned by the shared **`@rivet-dev/docs-theme`** repo (`github.com/rivet-dev/docs-theme`), consumed via `github:rivet-dev/docs-theme#` and wired in via `...docsTheme(starlight, siteConfig)`. To change any docs styling (palette, header, sidebar, code blocks, fonts), edit that repo and follow its CLAUDE.md release workflow — never restyle docs in `website/src`. This site owns only content + `website/docs.config.mjs` (sidebar icons via each item's `attrs['data-icon']`). +- Architecture reference docs live in `website/src/content/docs/docs/architecture/` and are surfaced in `website/docs.config.mjs` under Reference → Advanced → Architecture. Treat these pages as the canonical human-facing architecture reference. When architecture behavior changes or new architecture is added, recommend the corresponding docs update to the user; do not proactively edit the docs unless the user asks for docs work or the task explicitly includes it. - The core quickstart under `examples/quickstart/` and the RivetKit example must stay behaviorally identical. - Every quickstart change needs a matching automated test in the same change. - Confirm the docs repo path with the user before editing Agent OS docs. diff --git a/Cargo.lock b/Cargo.lock index 3a6165005..1a75a7c28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,10 +15,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] -name = "agent-os-client" +name = "agentos-actor-plugin" version = "0.2.0-rc.3" dependencies = [ - "agent-os-protocol", + "agentos-client", + "anyhow", + "base64 0.22.1", + "bytes", + "ciborium", + "futures", + "http 1.4.0", + "rivet-actor-plugin-abi", + "rusqlite", + "serde", + "serde_bytes", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "agentos-client" +version = "0.2.0-rc.3" +dependencies = [ + "agentos-protocol", "anyhow", "async-trait", "base64 0.22.1", @@ -43,7 +65,7 @@ dependencies = [ ] [[package]] -name = "agent-os-protocol" +name = "agentos-protocol" version = "0.2.0-rc.3" dependencies = [ "rivet-vbare-compiler", @@ -52,10 +74,10 @@ dependencies = [ ] [[package]] -name = "agent-os-sidecar" +name = "agentos-sidecar" version = "0.2.0-rc.3" dependencies = [ - "agent-os-protocol", + "agentos-protocol", "secure-exec-bridge", "secure-exec-sidecar", "secure-exec-vm-config", @@ -66,16 +88,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "agent-os-sidecar-browser" -version = "0.2.0-rc.3" -dependencies = [ - "agent-os-protocol", - "agent-os-sidecar", - "secure-exec-bridge", - "secure-exec-sidecar-browser", -] - [[package]] name = "ahash" version = "0.8.12" @@ -2614,6 +2626,18 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rivet-actor-plugin-abi" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482a4eb394807ff71045199ed94082539ef333f9e0f86b256f68889cb713f9fd" +dependencies = [ + "anyhow", + "base64 0.22.1", + "ciborium", + "serde", +] + [[package]] name = "rivet-vbare-compiler" version = "0.0.5" @@ -2861,7 +2885,9 @@ dependencies = [ [[package]] name = "secure-exec-bridge" -version = "0.3.0-rc.1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20741bbfc7bfff685280b99cb19b22f1d1c3c4ee49f5eb81a49ab9eef4d6f03" dependencies = [ "serde", "serde_json", @@ -2869,7 +2895,9 @@ dependencies = [ [[package]] name = "secure-exec-client" -version = "0.3.0-rc.1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "263314d0a18643a10d5b765cbea6590979e271414416fb3b78201b7fd3ca546d" dependencies = [ "futures", "parking_lot", @@ -2882,7 +2910,9 @@ dependencies = [ [[package]] name = "secure-exec-execution" -version = "0.3.0-rc.1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940c30e9cf5e241418db55886a6a7d2cfae25a4135bcb4ac3b24f92affa18e17" dependencies = [ "base64 0.22.1", "ciborium", @@ -2898,7 +2928,9 @@ dependencies = [ [[package]] name = "secure-exec-kernel" -version = "0.3.0-rc.1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b026becba2617cce8ac13f70d1b5ff880e59ecefebc7f4ca4285f6a0b81c63e" dependencies = [ "base64 0.22.1", "getrandom 0.2.17", @@ -2911,7 +2943,9 @@ dependencies = [ [[package]] name = "secure-exec-sidecar" -version = "0.3.0-rc.1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dfcfacea683dbec65f46ee5e759594347130ff217f88a7dc5f7b2dec76fd773" dependencies = [ "aws-config", "aws-credential-types", @@ -2954,17 +2988,11 @@ dependencies = [ "vbare", ] -[[package]] -name = "secure-exec-sidecar-browser" -version = "0.3.0-rc.1" -dependencies = [ - "secure-exec-bridge", - "secure-exec-kernel", -] - [[package]] name = "secure-exec-v8-runtime" -version = "0.3.0-rc.1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e941ef18b855edc4464bd86ab5dc833f8f24c3cf76bfc97fbe4eb4a260257fb2" dependencies = [ "ciborium", "crossbeam-channel", @@ -2978,7 +3006,9 @@ dependencies = [ [[package]] name = "secure-exec-vm-config" -version = "0.3.0-rc.1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10102bbab0c0332119f06fe75bd5f74b99cf0d886300361c8bdb337c7e074272" dependencies = [ "serde", "serde_json", @@ -3033,6 +3063,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" diff --git a/Cargo.toml b/Cargo.toml index f24fa3ead..16ece0418 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,17 @@ [workspace] resolver = "2" members = [ - "crates/agent-os-protocol", - "crates/agent-os-sidecar", - "crates/agent-os-sidecar-browser", + "crates/agentos-protocol", + "crates/agentos-sidecar", + # NOTE: crates/agentos-sidecar-browser is intentionally excluded from the + # default workspace members. It path/version-depends on + # `secure-exec-sidecar-browser`, which secure-exec does not publish to + # crates.io. Keeping it a member would make the whole workspace unresolvable + # from crates.io (the dylib preview pins secure-exec to published crates.io + # `0.3.0`). It is unrelated to the actor-plugin cdylib + sidecar binary the + # preview ships. Re-add it once secure-exec publishes the browser crate. "crates/client", + "crates/agentos-actor-plugin", ] [workspace.package] @@ -18,17 +25,17 @@ repository = "https://github.com/rivet-dev/agent-os" # `cargo publish` can emit a registry-resolvable dependency). The release # tooling rewrites these versions together with `workspace.package.version`. [workspace.dependencies] -agent-os-bridge = { package = "secure-exec-bridge", path = "../secure-exec/crates/bridge", version = "0.3.0-rc.1" } -agent-os-protocol = { path = "crates/agent-os-protocol", version = "0.2.0-rc.3" } -agent-os-sidecar = { path = "crates/agent-os-sidecar", version = "0.2.0-rc.3" } -agent-os-sidecar-browser = { path = "crates/agent-os-sidecar-browser", version = "0.2.0-rc.3" } -agent-os-kernel = { package = "secure-exec-kernel", path = "../secure-exec/crates/kernel", version = "0.3.0-rc.1" } -agent-os-execution = { package = "secure-exec-execution", path = "../secure-exec/crates/execution", version = "0.3.0-rc.1" } -agent-os-v8-runtime = { package = "secure-exec-v8-runtime", path = "../secure-exec/crates/v8-runtime", version = "0.3.0-rc.1" } -secure-exec-client = { path = "../secure-exec/crates/secure-exec-client", version = "0.3.0-rc.1" } -secure-exec-bridge = { path = "../secure-exec/crates/bridge", version = "0.3.0-rc.1" } -secure-exec-sidecar = { path = "../secure-exec/crates/sidecar", version = "0.3.0-rc.1" } -secure-exec-sidecar-browser = { path = "../secure-exec/crates/sidecar-browser", version = "0.3.0-rc.1" } -secure-exec-vm-config = { path = "../secure-exec/crates/vm-config", version = "0.3.0-rc.1" } +agent-os-bridge = { package = "secure-exec-bridge", version = "0.3.0" } +agentos-protocol = { path = "crates/agentos-protocol", version = "0.2.0-rc.3" } +agentos-sidecar = { path = "crates/agentos-sidecar", version = "0.2.0-rc.3" } +agentos-sidecar-browser = { path = "crates/agentos-sidecar-browser", version = "0.2.0-rc.3" } +agent-os-kernel = { package = "secure-exec-kernel", version = "0.3.0" } +agent-os-execution = { package = "secure-exec-execution", version = "0.3.0" } +agent-os-v8-runtime = { package = "secure-exec-v8-runtime", version = "0.3.0" } +secure-exec-client = { version = "0.3.0" } +secure-exec-bridge = { version = "0.3.0" } +secure-exec-sidecar = { version = "0.3.0" } +secure-exec-sidecar-browser = { version = "0.3.0" } +secure-exec-vm-config = { version = "0.3.0" } vbare = "0.0.4" vbare-compiler = { package = "rivet-vbare-compiler", version = "0.0.5" } diff --git a/crates/agentos-actor-plugin/Cargo.toml b/crates/agentos-actor-plugin/Cargo.toml new file mode 100644 index 000000000..789a97213 --- /dev/null +++ b/crates/agentos-actor-plugin/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "agentos-actor-plugin" +version.workspace = true +edition.workspace = true +description = "Agent OS actor as a RivetKit native actor plugin (cdylib). Implements the plugin side of rivet-actor-plugin-abi; spawns the agent-os sidecar via the unmodified agentos-client." +# Distributed as a prebuilt cdylib inside the @rivet-dev/agentos-plugin- +# npm packages, not as a crates.io library — and it path-depends on the r6 abi +# crate which the crates.io publish flow does not clone. Keep it out of crates.io. +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +# RivetKit native-plugin ABI crate, consumed from crates.io like any other +# published dependency (no repo clone / git / local path). Published from the +# rivet repo's `feat/dylib-actor-plugin` preview (commit 5d959bc1), matching the +# `rivetkit@0.0.0-feat-dylib-actor-plugin.5d959bc` npm preview the TS forwarder uses. +rivet-actor-plugin-abi = "=2.3.2" +agentos-client = { path = "../client" } +tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros"] } +tokio-util = "0.7" +anyhow = "1" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" +serde_bytes = "0.11" +ciborium = "0.2" +base64 = "0.22" +bytes = "1" +http = "1" +uuid = { version = "1", features = ["v4"] } +tracing = "0.1" +futures = "0.3" + +[dev-dependencies] +# Real SQLite backing the mock host in the persistence e2e test (the durable +# storage the VM's sqlite_vfs callback drives through the db_* ABI contract). +rusqlite = { version = "0.32", features = ["bundled"] } diff --git a/crates/agentos-actor-plugin/src/actions/cron.rs b/crates/agentos-actor-plugin/src/actions/cron.rs new file mode 100644 index 000000000..f97bf8ec0 --- /dev/null +++ b/crates/agentos-actor-plugin/src/actions/cron.rs @@ -0,0 +1,93 @@ +//! Cron actions. The client's `CronJobOptions` / `CronAction` / +//! `CronJobInfo` are not serde types (they carry closures), so we define +//! serde DTOs here and map to/from the client types. + +use agentos_client::{AgentOs, CronAction, CronJobOptions, CronOverlap}; +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; + +/// `{ type: "exec", command, args }` | `{ type: "session", agentType, prompt }`. +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum CronActionDto { + Exec { + command: String, + #[serde(default)] + args: Vec, + }, + Session { + agent_type: String, + prompt: String, + }, +} + +/// Options object for `scheduleCron(...)`. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CronJobOptionsDto { + #[serde(default)] + pub id: Option, + pub schedule: String, + pub action: CronActionDto, + #[serde(default)] + pub overlap: Option, +} + +/// `{ id }` returned by `scheduleCron`. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScheduledCronDto { + pub id: String, +} + +/// One entry returned by `listCronJobs`. `last_run` / `next_run` are +/// epoch-millis timestamps serialized as `f64` so they cross the napi +/// boundary as JS `number`s (not `BigInt`s), matching the core API. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CronJobInfoDto { + pub id: String, + pub schedule: String, + pub overlap: CronOverlap, + pub last_run: Option, + pub next_run: Option, +} + +fn to_action(dto: CronActionDto) -> CronAction { + match dto { + CronActionDto::Exec { command, args } => CronAction::Exec { command, args }, + CronActionDto::Session { agent_type, prompt } => CronAction::Session { + agent_type, + prompt, + options: None, + }, + } +} + +pub fn schedule_cron(vm: &AgentOs, dto: CronJobOptionsDto) -> Result { + let options = CronJobOptions { + id: dto.id, + schedule: dto.schedule, + action: to_action(dto.action), + overlap: dto.overlap, + }; + let handle = vm.schedule_cron(options).map_err(|e| anyhow!(e))?; + Ok(ScheduledCronDto { id: handle.id }) +} + +pub fn list_cron_jobs(vm: &AgentOs) -> Vec { + vm.list_cron_jobs() + .into_iter() + .map(|info| CronJobInfoDto { + id: info.id, + schedule: info.schedule, + overlap: info.overlap, + last_run: info.last_run.map(|t| t.timestamp_millis() as f64), + next_run: info.next_run.map(|t| t.timestamp_millis() as f64), + }) + .collect() +} + +pub fn cancel_cron_job(vm: &AgentOs, id: &str) { + vm.cancel_cron_job(id); +} diff --git a/crates/agentos-actor-plugin/src/actions/filesystem.rs b/crates/agentos-actor-plugin/src/actions/filesystem.rs new file mode 100644 index 000000000..f708a6cdc --- /dev/null +++ b/crates/agentos-actor-plugin/src/actions/filesystem.rs @@ -0,0 +1,213 @@ +//! Filesystem actions. Each helper takes `&AgentOs` plus typed args +//! and delegates to the matching upstream `AgentOs::*` method. DTOs +//! used by batch operations live here too so the dispatcher arms can +//! deserialize/serialize directly without re-declaring shapes. + +use agentos_client::{ + AgentOs, BatchReadResult, BatchWriteEntry, BatchWriteResult, DeleteOptions, DirEntry, + FileContent, MkdirOptions, ReaddirRecursiveOptions, VirtualStat, +}; +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +/// `readFile(path)` — port of [`AgentOs::read_file`]. +pub async fn read_file(vm: &AgentOs, path: &str) -> Result> { + vm.read_file(path) + .await + .inspect_err(|error| tracing::error!(?error, path, "read_file failed")) +} + +/// `writeFile(path, contents)` — port of [`AgentOs::write_file`]. +pub async fn write_file(vm: &AgentOs, path: &str, contents: Vec) -> Result<()> { + vm.write_file(path, FileContent::Bytes(contents)) + .await + .inspect_err(|error| tracing::error!(?error, path, "write_file failed")) +} + +/// `stat(path)` — port of [`AgentOs::stat`]. Returns the [`VirtualStat`] +/// structure directly; the rivetkit encoder handles cross-encoding +/// translation (bare / cbor / json) at the framework layer. +pub async fn stat(vm: &AgentOs, path: &str) -> Result { + vm.stat(path).await +} + +/// `mkdir(path)` — port of [`AgentOs::mkdir`]. Always recursive so the +/// JS shim's "create parent dirs if needed" expectation holds; the +/// driver tests rely on this. +pub async fn mkdir(vm: &AgentOs, path: &str) -> Result<()> { + vm.mkdir(path, MkdirOptions { recursive: true }).await +} + +/// `readdir(path)` — port of [`AgentOs::readdir`]. Returns the +/// (unsorted) child names, including `.` and `..`. Sorting / filtering +/// is up to the caller. +pub async fn readdir(vm: &AgentOs, path: &str) -> Result> { + vm.readdir(path).await +} + +/// `exists(path)` — port of [`AgentOs::exists`]. +pub async fn exists(vm: &AgentOs, path: &str) -> Result { + vm.exists(path).await +} + +/// `move(from, to)` — port of [`AgentOs::move_path`]. Named `move_path` +/// in Rust because `move` is a keyword. +pub async fn move_path(vm: &AgentOs, from: &str, to: &str) -> Result<()> { + vm.move_path(from, to).await +} + +/// Options for `deleteFile`. TS sends `{ recursive?: boolean }`. +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteOptionsArg { + #[serde(default)] + pub recursive: bool, +} + +/// `deleteFile(path, options?)` — port of [`AgentOs::delete`]. Honors the +/// `recursive` option so directory deletes match JS semantics. +pub async fn delete_file(vm: &AgentOs, path: &str, recursive: bool) -> Result<()> { + vm.delete(path, DeleteOptions { recursive }).await +} + +/// `writeFiles(entries)` — port of [`AgentOs::write_files`]. Per-entry +/// failures are reported in the [`BatchWriteResultDto`]'s `success` / +/// `error` fields rather than as a top-level error. +pub async fn write_files( + vm: &AgentOs, + entries: Vec, +) -> Vec { + let entries: Vec = entries + .into_iter() + .map(|entry| BatchWriteEntry { + path: entry.path, + content: FileContent::Bytes(entry.content.into_bytes()), + }) + .collect(); + vm.write_files(entries) + .await + .into_iter() + .map(BatchWriteResultDto::from) + .collect() +} + +/// `readFiles(paths)` — port of [`AgentOs::read_files`]. Per-entry +/// failures are reported as `content: None` plus an error string. +pub async fn read_files(vm: &AgentOs, paths: Vec) -> Vec { + vm.read_files(paths) + .await + .into_iter() + .map(BatchReadResultDto::from) + .collect() +} + +/// `readdirRecursive(path)` — port of [`AgentOs::readdir_recursive`]. +/// Returns every reachable entry with its type and size. Unbounded +/// depth; the JS shim passes no max-depth in the driver tests so this +/// arm defaults to `ReaddirRecursiveOptions::default()`. +pub async fn readdir_recursive(vm: &AgentOs, path: &str) -> Result> { + vm.readdir_recursive(path, ReaddirRecursiveOptions::default()) + .await +} + +// --------------------------------------------------------------------------- +// Action argument / reply DTOs +// --------------------------------------------------------------------------- + +/// Accept either a CBOR text string, a CBOR byte string (via `ByteBuf`), or +/// the `["$Uint8Array", base64]` wrapper that TS encoders emit when the +/// outer codec is JSON-compatible. Used by `writeFile` and `writeFiles`. +#[derive(Deserialize)] +#[serde(untagged)] +pub enum WriteFileContent { + String(String), + Bytes(serde_bytes::ByteBuf), + Wrapped(JsonCompatUint8Array), +} + +impl WriteFileContent { + pub fn into_bytes(self) -> Vec { + match self { + Self::String(s) => s.into_bytes(), + Self::Bytes(b) => b.into_vec(), + Self::Wrapped(w) => w.bytes, + } + } +} + +/// Deserializer for the `["$Uint8Array", base64]` envelope. Part of +/// [`WriteFileContent`]'s untagged enum so the same arms accept wrapped +/// bytes from the JSON encoder path. +pub struct JsonCompatUint8Array { + bytes: Vec, +} + +impl<'de> Deserialize<'de> for JsonCompatUint8Array { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; + let (tag, base64): (String, String) = Deserialize::deserialize(deserializer)?; + if tag != "$Uint8Array" { + return Err(serde::de::Error::custom(format!( + "expected $Uint8Array wrapper, got {tag}" + ))); + } + let bytes = BASE64 + .decode(&base64) + .map_err(|error| serde::de::Error::custom(format!("base64 decode: {error}")))?; + Ok(Self { bytes }) + } +} + +/// Argument entry for `writeFiles`. TS sends `[{path, content}, ...]` +/// where `content` follows the same coercion rules as `writeFile`. +#[derive(Deserialize)] +pub struct WriteFilesEntryArg { + pub path: String, + pub content: WriteFileContent, +} + +/// Reply entry for `writeFiles`. Mirrors `BatchWriteResult` in a +/// serializable form. `error` is `None` on success. +#[derive(Serialize)] +pub struct BatchWriteResultDto { + pub path: String, + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl From for BatchWriteResultDto { + fn from(value: BatchWriteResult) -> Self { + Self { + path: value.path, + success: value.success, + error: value.error, + } + } +} + +/// Reply entry for `readFiles`. `content` is wrapped via `serde_bytes` +/// so the `JsonCompatAdapter` re-wraps it as `["$Uint8Array", base64]` +/// for JSON encoders. `None` content + `Some(error)` indicates that the +/// specific file failed without aborting the whole batch. +#[derive(Serialize)] +pub struct BatchReadResultDto { + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl From for BatchReadResultDto { + fn from(value: BatchReadResult) -> Self { + Self { + path: value.path, + content: value.content.map(serde_bytes::ByteBuf::from), + error: value.error, + } + } +} diff --git a/crates/agentos-actor-plugin/src/actions/mod.rs b/crates/agentos-actor-plugin/src/actions/mod.rs new file mode 100644 index 000000000..428296d87 --- /dev/null +++ b/crates/agentos-actor-plugin/src/actions/mod.rs @@ -0,0 +1,360 @@ +//! Action dispatcher — the plugin-side port of `rivetkit-agent-os::actions`. +//! +//! Each arm decodes its positional args via `abi::codec::decode_positional` +//! (TS sends args as a CBOR array) and replies via [`reply_ok`] / [`reply_err`] +//! over the host vtable. `reply_ok` runs the value through +//! `encode_json_compat_to_vec` — byte-exact with rivetkit's `ActionCall::ok`, +//! so the `["$Uint8Array", base64]` byte-wrapping round-trips identically. +//! +//! The pure-`AgentOs` helper modules (filesystem/process/network/cron) are +//! verbatim copies of the rivetkit-agent-os helpers; `session`/`preview` swap +//! rivetkit's `Ctx` for [`HostCtx`] (durable storage via `db_*`). + +pub mod cron; +pub mod filesystem; +pub mod network; +pub mod preview; +pub mod process; +pub mod session; + +use std::collections::HashMap; + +use agentos_client::AgentOs; +use anyhow::Result; +use rivet_actor_plugin_abi as abi; +use serde::de::DeserializeOwned; +use serde::Serialize; +use tokio::task::JoinHandle; + +use crate::host_ctx::HostCtx; +use filesystem::{WriteFileContent, WriteFilesEntryArg}; + +/// Ephemeral per-VM-lifetime actor state (session-resume, spec §3/§5/§8), +/// ported from `rivetkit-agent-os::actor::Vars`. Reconstructed on each wake from +/// the durable SQLite tables + the freshly created VM; intentionally NOT +/// persisted. +#[derive(Default)] +pub struct Vars { + /// `external_session_id -> live_session_id`. + pub live_sessions: HashMap, + /// `live_session_id -> capture pump task`. + pub capture_tasks: HashMap>, +} + +impl Vars { + /// Resolve a client-facing `external_session_id` to the live ACP session id, + /// falling back to the external id itself (native / not-yet-resumed case). + pub fn live_id<'a>(&'a self, external_session_id: &'a str) -> &'a str { + self.live_sessions + .get(external_session_id) + .map(String::as_str) + .unwrap_or(external_session_id) + } + + /// Abort and clear all in-flight capture tasks. Called on VM teardown + /// (sleep / destroy / run-loop exit). + pub fn clear(&mut self) { + for (_, task) in self.capture_tasks.drain() { + task.abort(); + } + self.live_sessions.clear(); + } +} + +/// Decode positional CBOR args into `T`. +fn decode_as(args: &[u8]) -> Result { + abi::codec::decode_positional(args) +} + +/// Reply success: encode `value` with the JSON-compat byte wrapping (byte-exact +/// with rivetkit's `ActionCall::ok`) and send it over the host vtable. +fn reply_ok(host: &HostCtx, token: u64, value: &T) { + match abi::codec::encode_json_compat_to_vec(value) { + Ok(bytes) => { + host.reply_ok(token, bytes); + } + Err(error) => { + host.reply_err(token, &format!("encode action response: {error}")); + } + } +} + +/// Reply failure with the error message (matches `ActionCall::err`). +fn reply_err(host: &HostCtx, token: u64, error: anyhow::Error) { + let message = error.to_string(); + host.log_warn(&format!("agent-os action failed: {message}")); + host.reply_err(token, &message); +} + +/// Dispatch one decoded action against a live VM. `host` provides the actor's +/// SQLite database (via `db_*`) for the persistence-backed arms (signed preview +/// URLs + session metadata); `vm` is the live `AgentOs`; `vars` is the +/// ephemeral session-resume state. +pub(crate) async fn dispatch( + host: &HostCtx, + vm: &AgentOs, + vars: &mut Vars, + name: &str, + args: &[u8], + token: u64, +) { + match name { + "readFile" => match decode_as::<(String,)>(args) { + Ok((path,)) => match filesystem::read_file(vm, &path).await { + // Wrap as serde_bytes so it serializes as a byte string, which + // the JSON-compat encoder re-wraps as `["$Uint8Array", base64]`. + Ok(bytes) => reply_ok(host, token, &serde_bytes::ByteBuf::from(bytes)), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "writeFile" => match decode_as::<(String, WriteFileContent)>(args) { + Ok((path, contents)) => { + match filesystem::write_file(vm, &path, contents.into_bytes()).await { + Ok(()) => reply_ok(host, token, &()), + Err(error) => reply_err(host, token, error), + } + } + Err(error) => reply_err(host, token, error), + }, + "stat" => match decode_as::<(String,)>(args) { + Ok((path,)) => match filesystem::stat(vm, &path).await { + Ok(vstat) => reply_ok(host, token, &vstat), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "mkdir" => match decode_as::<(String,)>(args) { + Ok((path,)) => match filesystem::mkdir(vm, &path).await { + Ok(()) => reply_ok(host, token, &()), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "readdir" => match decode_as::<(String,)>(args) { + Ok((path,)) => match filesystem::readdir(vm, &path).await { + Ok(entries) => reply_ok(host, token, &entries), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "exists" => match decode_as::<(String,)>(args) { + Ok((path,)) => match filesystem::exists(vm, &path).await { + Ok(present) => reply_ok(host, token, &present), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "move" => match decode_as::<(String, String)>(args) { + Ok((from, to)) => match filesystem::move_path(vm, &from, &to).await { + Ok(()) => reply_ok(host, token, &()), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "deleteFile" => { + // TS may omit the trailing options object (array length 1 or 2). + let decoded = decode_as::<(String, Option)>(args) + .map(|(path, options)| (path, options.unwrap_or_default().recursive)) + .or_else(|_| decode_as::<(String,)>(args).map(|(path,)| (path, false))); + match decoded { + Ok((path, recursive)) => { + match filesystem::delete_file(vm, &path, recursive).await { + Ok(()) => reply_ok(host, token, &()), + Err(error) => reply_err(host, token, error), + } + } + Err(error) => reply_err(host, token, error), + } + } + "writeFiles" => match decode_as::<(Vec,)>(args) { + Ok((entries,)) => { + let results = filesystem::write_files(vm, entries).await; + reply_ok(host, token, &results); + } + Err(error) => reply_err(host, token, error), + }, + "readFiles" => match decode_as::<(Vec,)>(args) { + Ok((paths,)) => { + let results = filesystem::read_files(vm, paths).await; + reply_ok(host, token, &results); + } + Err(error) => reply_err(host, token, error), + }, + "readdirRecursive" => match decode_as::<(String,)>(args) { + Ok((path,)) => match filesystem::readdir_recursive(vm, &path).await { + Ok(entries) => reply_ok(host, token, &entries), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "exec" => match decode_as::<(String,)>(args) { + Ok((command,)) => match process::exec(vm, &command).await { + Ok(result) => reply_ok(host, token, &result), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "spawn" => match decode_as::<(String, Vec)>(args) { + Ok((command, spawn_args)) => match process::spawn(vm, &command, spawn_args) { + Ok(handle) => reply_ok(host, token, &handle), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "waitProcess" => match decode_as::<(u32,)>(args) { + Ok((pid,)) => match process::wait_process(vm, pid).await { + Ok(code) => reply_ok(host, token, &code), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "killProcess" => match decode_as::<(u32,)>(args) { + Ok((pid,)) => match process::kill_process(vm, pid) { + Ok(()) => reply_ok(host, token, &()), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "stopProcess" => match decode_as::<(u32,)>(args) { + Ok((pid,)) => match process::stop_process(vm, pid) { + Ok(()) => reply_ok(host, token, &()), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "listProcesses" => { + let processes = process::list_processes(vm); + reply_ok(host, token, &processes); + } + "allProcesses" => match process::all_processes(vm).await { + Ok(processes) => reply_ok(host, token, &processes), + Err(error) => reply_err(host, token, error), + }, + "processTree" => match process::process_tree(vm).await { + Ok(tree) => reply_ok(host, token, &tree), + Err(error) => reply_err(host, token, error), + }, + "getProcess" => match decode_as::<(u32,)>(args) { + Ok((pid,)) => match process::get_process(vm, pid) { + Ok(info) => reply_ok(host, token, &info), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "writeProcessStdin" => match decode_as::<(u32, WriteFileContent)>(args) { + Ok((pid, data)) => match process::write_process_stdin(vm, pid, data) { + Ok(()) => reply_ok(host, token, &()), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "closeProcessStdin" => match decode_as::<(u32,)>(args) { + Ok((pid,)) => match process::close_process_stdin(vm, pid) { + Ok(()) => reply_ok(host, token, &()), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "vmFetch" => { + // Trailing options object is optional (length 2 or 3). + let decoded = decode_as::<(u16, String, Option)>(args) + .map(|(port, url, options)| (port, url, options.unwrap_or_default())) + .or_else(|_| { + decode_as::<(u16, String)>(args) + .map(|(port, url)| (port, url, network::FetchOptions::default())) + }); + match decoded { + Ok((port, url, options)) => match network::fetch(vm, port, &url, options).await { + Ok(response) => reply_ok(host, token, &response), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + } + } + "scheduleCron" => match decode_as::<(cron::CronJobOptionsDto,)>(args) { + Ok((options,)) => match cron::schedule_cron(vm, options) { + Ok(handle) => reply_ok(host, token, &handle), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "listCronJobs" => reply_ok(host, token, &cron::list_cron_jobs(vm)), + "cancelCronJob" => match decode_as::<(String,)>(args) { + Ok((id,)) => { + cron::cancel_cron_job(vm, &id); + reply_ok(host, token, &()); + } + Err(error) => reply_err(host, token, error), + }, + "createSession" => { + // Trailing options object is optional (length 1 or 2). + let decoded = decode_as::<(String, Option)>(args) + .map(|(agent_type, options)| (agent_type, options.unwrap_or_default())) + .or_else(|_| { + decode_as::<(String,)>(args).map(|(agent_type,)| { + (agent_type, session::CreateSessionOptionsDto::default()) + }) + }); + match decoded { + Ok((agent_type, options)) => { + match session::create_session(host, vm, vars, &agent_type, options).await { + Ok(id) => reply_ok(host, token, &id), + Err(error) => { + tracing::error!(?error, agent_type, "create_session failed"); + reply_err(host, token, error) + } + } + } + Err(error) => reply_err(host, token, error), + } + } + "sendPrompt" => match decode_as::<(String, String)>(args) { + Ok((session_id, text)) => { + match session::send_prompt(host, vm, vars, &session_id, &text).await { + Ok(result) => reply_ok(host, token, &result), + Err(error) => reply_err(host, token, error), + } + } + Err(error) => reply_err(host, token, error), + }, + "closeSession" => match decode_as::<(String,)>(args) { + Ok((session_id,)) => match session::close_session(host, vm, vars, &session_id).await { + Ok(()) => reply_ok(host, token, &()), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "listPersistedSessions" => match session::list_persisted_sessions(host).await { + Ok(sessions) => reply_ok(host, token, &sessions), + Err(error) => reply_err(host, token, error), + }, + "getSessionEvents" => match decode_as::<(String,)>(args) { + Ok((session_id,)) => match session::get_session_events(host, &session_id).await { + Ok(events) => reply_ok(host, token, &events), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "createSignedPreviewUrl" => match decode_as::<(u16, u64)>(args) { + Ok((port, ttl_seconds)) => match preview::create(host, port, ttl_seconds).await { + Ok(dto) => reply_ok(host, token, &dto), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + "expireSignedPreviewUrl" => match decode_as::<(String,)>(args) { + Ok((token_arg,)) => match preview::expire(host, &token_arg).await { + Ok(()) => reply_ok(host, token, &()), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + other => { + host.reply_err( + token, + &format!("agent-os action not implemented yet: {other}"), + ); + } + } +} diff --git a/crates/agentos-actor-plugin/src/actions/network.rs b/crates/agentos-actor-plugin/src/actions/network.rs new file mode 100644 index 000000000..7aebfde63 --- /dev/null +++ b/crates/agentos-actor-plugin/src/actions/network.rs @@ -0,0 +1,70 @@ +//! Network actions: `vmFetch` routes an HTTP request to a service +//! listening on a guest loopback port via [`AgentOs::fetch`]. + +use std::collections::BTreeMap; + +use agentos_client::AgentOs; +use anyhow::Result; +use bytes::Bytes; +use serde::{Deserialize, Serialize}; + +/// Optional request shape for `vmFetch(port, url, options?)`. +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FetchOptions { + #[serde(default)] + pub method: Option, + #[serde(default)] + pub headers: Option>, + #[serde(default)] + pub body: Option>, +} + +/// JSON-serializable response returned to the TS client. `body` is wrapped +/// via `serde_bytes` so the rivetkit `JsonCompatAdapter` re-encodes it as +/// `["$Uint8Array", base64]`, which the TS client decodes back to a +/// `Uint8Array` (the shape the example's `TextDecoder` expects). +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FetchResponseDto { + pub status: u16, + pub headers: BTreeMap, + pub body: serde_bytes::ByteBuf, +} + +/// `vmFetch(port, url, options?)` — port of [`AgentOs::fetch`]. +pub async fn fetch( + vm: &AgentOs, + port: u16, + url: &str, + options: FetchOptions, +) -> Result { + let method = options.method.as_deref().unwrap_or("GET"); + let mut builder = http::Request::builder().method(method).uri(url); + if let Some(headers) = &options.headers { + for (name, value) in headers { + builder = builder.header(name.as_str(), value.as_str()); + } + } + let body = Bytes::from(options.body.unwrap_or_default()); + let request = builder.body(body)?; + + let response = vm.fetch(port, request).await?; + let status = response.status().as_u16(); + let headers = response + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_owned(), + value.to_str().unwrap_or_default().to_owned(), + ) + }) + .collect(); + let body = serde_bytes::ByteBuf::from(response.into_body().to_vec()); + Ok(FetchResponseDto { + status, + headers, + body, + }) +} diff --git a/crates/agentos-actor-plugin/src/actions/preview.rs b/crates/agentos-actor-plugin/src/actions/preview.rs new file mode 100644 index 000000000..43c4454b0 --- /dev/null +++ b/crates/agentos-actor-plugin/src/actions/preview.rs @@ -0,0 +1,109 @@ +//! Preview URL actions. These are a rivetkit-actor-layer feature, not part +//! of the core `AgentOs` API: they issue a signed, time-limited token that +//! maps an external request path to a guest loopback port. The actor's HTTP +//! event handler (`crate::run`) proxies `/preview/{token}/...` requests to +//! that port via [`agentos_client::AgentOs::fetch`]. +//! +//! Tokens are persisted to the actor's SQLite database (`agent_os_preview_tokens`) +//! via `ctx.db_*`, so issued previews survive actor sleep/wake. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::host_ctx::HostCtx; +use anyhow::Result; +use serde::Serialize; +use serde_json::json; +use uuid::Uuid; + +use crate::persistence::{query_rows, run_stmt}; + +/// Default lifetime of a signed preview URL: one hour. +const PREVIEW_TTL_MS: i64 = 60 * 60 * 1000; + +/// `{ path, token, port, expiresAt }` returned by `createSignedPreviewUrl`. +/// +/// `expires_at` is an epoch-millis timestamp serialized as `f64` so it +/// crosses the napi boundary as a JS `number` (not a `BigInt`), matching the +/// core API and the example's `new Date(expiresAt)` usage. Millisecond +/// timestamps are exactly representable in `f64` well past the year 10000. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SignedPreviewUrlDto { + pub path: String, + pub token: String, + pub port: u16, + pub expires_at: f64, +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +/// Issue a signed preview URL for `port`, valid for `ttl_seconds` (falling back +/// to [`PREVIEW_TTL_MS`] when the caller passes `0`). +pub async fn create(ctx: &HostCtx, port: u16, ttl_seconds: u64) -> Result { + let token = Uuid::new_v4().to_string(); + let created_at = now_ms(); + let ttl_ms = if ttl_seconds == 0 { + PREVIEW_TTL_MS + } else { + (ttl_seconds as i64).saturating_mul(1000) + }; + let expires_at = created_at + ttl_ms; + run_stmt( + ctx, + "INSERT INTO agent_os_preview_tokens (token, port, created_at, expires_at) \ + VALUES (?, ?, ?, ?)", + &[ + json!(token), + json!(port), + json!(created_at), + json!(expires_at), + ], + ) + .await?; + Ok(SignedPreviewUrlDto { + // The `/request` prefix routes through rivetkit's raw-actor-HTTP path + // (RegistryHttpRoute::UserRawRequest); the gateway strips `/request` + // before the actor sees it, so `proxy_preview` receives `/preview/`. + // Without this prefix the gateway classifies the path as NotFound (404). + path: format!("/request/preview/{token}"), + token, + port, + expires_at: expires_at as f64, + }) +} + +/// Revoke a previously issued preview token. Idempotent. +pub async fn expire(ctx: &HostCtx, token: &str) -> Result<()> { + run_stmt( + ctx, + "DELETE FROM agent_os_preview_tokens WHERE token = ?", + &[json!(token)], + ) + .await +} + +/// Resolve `token` to its target port if it exists and has not expired. +/// Expired tokens are pruned as a side effect. +pub async fn resolve(ctx: &HostCtx, token: &str) -> Result> { + let rows = query_rows( + ctx, + "SELECT port, expires_at FROM agent_os_preview_tokens WHERE token = ?", + &[json!(token)], + ) + .await?; + let Some(row) = rows.into_iter().next() else { + return Ok(None); + }; + let expires_at = row.get("expires_at").and_then(|v| v.as_i64()).unwrap_or(0); + let port = row.get("port").and_then(|v| v.as_i64()).unwrap_or(0) as u16; + if expires_at <= now_ms() { + expire(ctx, token).await?; + return Ok(None); + } + Ok(Some(port)) +} diff --git a/crates/agentos-actor-plugin/src/actions/process.rs b/crates/agentos-actor-plugin/src/actions/process.rs new file mode 100644 index 000000000..b154d0be0 --- /dev/null +++ b/crates/agentos-actor-plugin/src/actions/process.rs @@ -0,0 +1,110 @@ +//! Process actions. Each helper takes `&AgentOs` plus typed args and +//! delegates to the matching upstream `AgentOs::*` method. DTOs used +//! by `exec` and other arms that need camelCase serialization live +//! here so the dispatcher arms can reply directly. + +use agentos_client::{ + AgentOs, ExecOptions, ExecResult, ProcessInfo, ProcessTreeNode, SpawnHandle, SpawnOptions, + SpawnedProcessInfo, +}; +use anyhow::Result; +use serde::Serialize; + +/// `exec(command)` — port of [`AgentOs::exec`] with default options. +/// Returns an [`ExecResultDto`] with camelCase `exitCode` for the JS side. +pub async fn exec(vm: &AgentOs, command: &str) -> Result { + vm.exec(command, ExecOptions::default()) + .await + .map(ExecResultDto::from) +} + +/// `spawn(command, args)` — port of [`AgentOs::spawn`]. Returns the +/// [`SpawnHandle`] `{ pid }` directly; the underlying type already +/// derives `Serialize`. +pub fn spawn(vm: &AgentOs, command: &str, args: Vec) -> Result { + vm.spawn(command, args, SpawnOptions::default()) +} + +/// `waitProcess(pid)` — port of [`AgentOs::wait_process`]. Returns the +/// exit code (`i32`). +pub async fn wait_process(vm: &AgentOs, pid: u32) -> Result { + vm.wait_process(pid).await.map_err(anyhow::Error::from) +} + +/// `killProcess(pid)` — port of [`AgentOs::kill_process`] (sync). +pub fn kill_process(vm: &AgentOs, pid: u32) -> Result<()> { + vm.kill_process(pid).map_err(anyhow::Error::from) +} + +/// `stopProcess(pid)` — port of [`AgentOs::stop_process`] (sync). +pub fn stop_process(vm: &AgentOs, pid: u32) -> Result<()> { + vm.stop_process(pid).map_err(anyhow::Error::from) +} + +/// `listProcesses()` — port of [`AgentOs::list_processes`]. Returns the +/// SDK-spawned processes (not kernel processes); already camelCase via +/// `#[serde(rename = "exitCode")]` on `SpawnedProcessInfo`. +pub fn list_processes(vm: &AgentOs) -> Vec { + vm.list_processes() +} + +/// `allProcesses()` — port of [`AgentOs::all_processes`]. Returns the +/// full kernel process snapshot. +pub async fn all_processes(vm: &AgentOs) -> Result> { + vm.all_processes().await +} + +/// `processTree()` — port of [`AgentOs::process_tree`]. Returns the +/// kernel process forest. +pub async fn process_tree(vm: &AgentOs) -> Result> { + vm.process_tree().await +} + +/// `getProcess(pid)` — port of [`AgentOs::get_process`] (sync). +pub fn get_process(vm: &AgentOs, pid: u32) -> Result { + vm.get_process(pid).map_err(anyhow::Error::from) +} + +/// `writeProcessStdin(pid, data)` — port of +/// [`AgentOs::write_process_stdin`]. Accepts string or bytes content +/// via the same coercion rules as `writeFile`. +pub fn write_process_stdin( + vm: &AgentOs, + pid: u32, + data: super::filesystem::WriteFileContent, +) -> Result<()> { + use agentos_client::StdinInput; + let stdin = StdinInput::Bytes(data.into_bytes()); + vm.write_process_stdin(pid, stdin) + .map_err(anyhow::Error::from) +} + +/// `closeProcessStdin(pid)` — port of [`AgentOs::close_process_stdin`]. +pub fn close_process_stdin(vm: &AgentOs, pid: u32) -> Result<()> { + vm.close_process_stdin(pid).map_err(anyhow::Error::from) +} + +// --------------------------------------------------------------------------- +// Action reply DTOs +// --------------------------------------------------------------------------- + +/// Serializable mirror of [`ExecResult`] with camelCase `exitCode`. The +/// upstream type doesn't derive `Serialize`, and the field name is +/// `exit_code` (snake_case) which the JS test expects as `exitCode`. +#[derive(Serialize)] +pub struct ExecResultDto { + #[serde(rename = "exitCode")] + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +impl From for ExecResultDto { + fn from(value: ExecResult) -> Self { + Self { + exit_code: value.exit_code, + stdout: value.stdout, + stderr: value.stderr, + } + } +} diff --git a/crates/agentos-actor-plugin/src/actions/session.rs b/crates/agentos-actor-plugin/src/actions/session.rs new file mode 100644 index 000000000..83fd35833 --- /dev/null +++ b/crates/agentos-actor-plugin/src/actions/session.rs @@ -0,0 +1,378 @@ +//! Agent session actions: create an ACP agent session, send prompts, +//! and close it. Ports of [`AgentOs::create_session`] / `prompt` / +//! `close_session`. +//! +//! Session metadata is persisted to the actor's SQLite database +//! (`agent_os_sessions`, with streamed events in `agent_os_session_events`) +//! via `ctx.db_*`, so the set of sessions survives actor sleep/wake. The live +//! ACP session itself lives in the VM and is recreated on demand. + +use std::collections::BTreeMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::host_ctx::HostCtx; +use agentos_client::{AgentOs, CreateSessionOptions}; +use anyhow::{anyhow, Result}; +use futures::StreamExt; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value as JsonValue}; + +use super::Vars; +use crate::persistence::{ + insert_session_event, query_rows, reconstruct_transcript_to_file, run_stmt, +}; + +/// Options object for `createSession(agentType, options?)`. +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateSessionOptionsDto { + #[serde(default)] + pub cwd: Option, + #[serde(default)] + pub env: BTreeMap, + #[serde(default)] + pub skip_os_instructions: bool, + #[serde(default)] + pub additional_instructions: Option, +} + +/// `{ sessionId }` returned by `createSession`. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionIdDto { + pub session_id: String, +} + +/// Result of `sendPrompt` exposed to the TS client. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PromptResultDto { + pub text: String, +} + +/// One row of `listPersistedSessions`. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PersistedSessionDto { + pub session_id: String, + pub agent_type: String, + pub created_at: f64, +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +/// Subscribe to the live `session/update` stream for `live_session_id` and +/// spawn a task that persists each event under `external_session_id` (spec §5). +/// +/// The subscription is broadcast-backed, so aborting the spawned task — which +/// drops the stream — is the unsubscribe. The handle is tracked in +/// [`Vars::capture_tasks`] keyed by the live id so it can be cancelled on close +/// / sleep / destroy. Re-subscribing for the same live id first aborts any +/// existing pump so we never run two pumps for one session. +fn spawn_event_capture( + ctx: &HostCtx, + vm: &AgentOs, + vars: &mut Vars, + external_session_id: &str, + live_session_id: &str, +) { + let (mut stream, subscription) = match vm.on_session_event(live_session_id) { + Ok(sub) => sub, + Err(error) => { + tracing::warn!(?error, live_session_id, "on_session_event subscribe failed"); + return; + } + }; + // Replace any existing pump for this live id. + if let Some(old) = vars.capture_tasks.remove(live_session_id) { + old.abort(); + } + let ctx = ctx.clone(); + let external = external_session_id.to_owned(); + let handle = tokio::spawn(async move { + // Keep the RAII guard alive for the lifetime of the pump; dropping the + // stream (on abort / channel close) is the unsubscribe. + let _subscription = subscription; + while let Some(notification) = stream.next().await { + let event_json = match serde_json::to_string(¬ification) { + Ok(json) => json, + Err(error) => { + tracing::warn!(?error, "failed to encode captured session event"); + continue; + } + }; + if let Err(error) = insert_session_event(&ctx, &external, &event_json).await { + tracing::warn!(?error, external, "failed to persist captured session event"); + } + } + }); + vars.capture_tasks + .insert(live_session_id.to_owned(), handle); +} + +pub async fn create_session( + ctx: &HostCtx, + vm: &AgentOs, + vars: &mut Vars, + agent_type: &str, + dto: CreateSessionOptionsDto, +) -> Result { + let options = CreateSessionOptions { + cwd: dto.cwd, + env: dto.env, + skip_os_instructions: dto.skip_os_instructions, + additional_instructions: dto.additional_instructions, + ..CreateSessionOptions::default() + }; + let session_id = vm.create_session(agent_type, options).await?.session_id; + // Persist session metadata so the set of sessions survives sleep/wake. Capture the REAL + // agent capabilities + info (not a `"{}"` placeholder) so the resume path can capability-gate + // the native `session/load` tier after a wake, when the live session is gone. See + // `resume_session` for how these are read back. + let capabilities = vm + .get_session_capabilities(&session_id) + .and_then(|caps| serde_json::to_string(&caps).ok()) + .unwrap_or_else(|| "{}".to_owned()); + let agent_info = vm + .get_session_agent_info(&session_id) + .and_then(|info| serde_json::to_string(&info).ok()); + run_stmt( + ctx, + "INSERT OR REPLACE INTO agent_os_sessions \ + (session_id, agent_type, capabilities, agent_info, created_at) \ + VALUES (?, ?, ?, ?, ?)", + &[ + json!(session_id), + json!(agent_type), + json!(capabilities), + agent_info.map(JsonValue::String).unwrap_or(JsonValue::Null), + json!(now_ms()), + ], + ) + .await?; + // At create time `external == live`; capture every `session/update` for this + // session under the external id (spec §3/§5). + spawn_event_capture(ctx, vm, vars, &session_id, &session_id); + Ok(SessionIdDto { session_id }) +} + +pub async fn send_prompt( + ctx: &HostCtx, + vm: &AgentOs, + vars: &mut Vars, + session_id: &str, + text: &str, +) -> Result { + // Lazy-resume trigger (spec §8): a prompt for a session that is persisted in + // `agent_os_sessions` but absent from `Vars.live_sessions` means the VM was + // recreated since the session was last live — resume it before forwarding. + // `session_id` here is the client-facing `external_session_id`. + // + // Canonical resume state-machine documentation lives on the sidecar handler + // in `crates/agentos-sidecar/src/acp_extension.rs` (spec §6); this is just + // the actor-side trigger that drives it. + if !vars.live_sessions.contains_key(session_id) && !is_session_live(vm, session_id) { + if session_is_persisted(ctx, session_id).await? { + resume_session(ctx, vm, vars, session_id).await?; + } + } + + // Record the outbound prompt text as a synthetic `user_prompt` event BEFORE + // the prompt streams, so the transcript turn ordering is correct (the prompt + // row precedes the agent `session/update` rows for this turn). Stored under + // the stable external id (spec §4/§5). + let prompt_event = json!({ + "method": "user_prompt", + "params": { "text": text }, + }); + if let Err(error) = insert_session_event(ctx, session_id, &prompt_event.to_string()).await { + tracing::warn!(?error, session_id, "failed to persist user_prompt event"); + } + + // Forward to the live id (== external for native/not-yet-resumed sessions). + let live_session_id = vars.live_id(session_id).to_owned(); + let result = vm.prompt(&live_session_id, text).await?; + Ok(PromptResultDto { text: result.text }) +} + +pub async fn close_session( + ctx: &HostCtx, + vm: &AgentOs, + vars: &mut Vars, + session_id: &str, +) -> Result<()> { + // Stop event capture + drop the remap for this external session. + let live_session_id = vars.live_id(session_id).to_owned(); + if let Some(task) = vars.capture_tasks.remove(&live_session_id) { + task.abort(); + } + vars.live_sessions.remove(session_id); + vm.close_session(&live_session_id).map_err(|e| anyhow!(e))?; + // Drop persisted metadata + events (explicit, since SQLite FK cascade is + // only enforced when `PRAGMA foreign_keys = ON`). + run_stmt( + ctx, + "DELETE FROM agent_os_session_events WHERE session_id = ?", + &[json!(session_id)], + ) + .await?; + run_stmt( + ctx, + "DELETE FROM agent_os_sessions WHERE session_id = ?", + &[json!(session_id)], + ) + .await?; + Ok(()) +} + +/// List the sessions persisted for this actor (`listPersistedSessions`). +pub async fn list_persisted_sessions(ctx: &HostCtx) -> Result> { + let rows = query_rows( + ctx, + "SELECT session_id, agent_type, created_at FROM agent_os_sessions \ + ORDER BY created_at", + &[], + ) + .await?; + Ok(rows + .into_iter() + .map(|row| PersistedSessionDto { + session_id: row + .get("session_id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_owned(), + agent_type: row + .get("agent_type") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_owned(), + created_at: row.get("created_at").and_then(|v| v.as_i64()).unwrap_or(0) as f64, + }) + .collect()) +} + +/// Return the persisted ACP events for a session, ordered by sequence +/// (`getSessionEvents`). Each event is the stored JSON-RPC notification. +pub async fn get_session_events(ctx: &HostCtx, session_id: &str) -> Result> { + let rows = query_rows( + ctx, + "SELECT event FROM agent_os_session_events WHERE session_id = ? ORDER BY seq", + &[json!(session_id)], + ) + .await?; + Ok(rows + .into_iter() + .filter_map(|row| { + row.get("event") + .and_then(|v| v.as_str()) + .and_then(|raw| serde_json::from_str::(raw).ok()) + }) + .collect()) +} + +/// True when an ACP session with this id is currently live in the VM. +fn is_session_live(vm: &AgentOs, session_id: &str) -> bool { + vm.list_sessions() + .iter() + .any(|info| info.session_id == session_id) +} + +/// True when `external_session_id` has a persisted registry row in +/// `agent_os_sessions` (so it is resumable). +async fn session_is_persisted(ctx: &HostCtx, external_session_id: &str) -> Result { + let rows = query_rows( + ctx, + "SELECT session_id FROM agent_os_sessions WHERE session_id = ? LIMIT 1", + &[json!(external_session_id)], + ) + .await?; + Ok(!rows.is_empty()) +} + +/// Read the persisted `(agent_type, capabilities)` for a session from the +/// registry, returning the parsed capabilities JSON (`{}` if absent/unparsable). +async fn read_session_registry( + ctx: &HostCtx, + external_session_id: &str, +) -> Result<(String, JsonValue)> { + let rows = query_rows( + ctx, + "SELECT agent_type, capabilities FROM agent_os_sessions WHERE session_id = ? LIMIT 1", + &[json!(external_session_id)], + ) + .await?; + let row = rows + .into_iter() + .next() + .ok_or_else(|| anyhow!("no persisted session {external_session_id} to resume"))?; + let agent_type = row + .get("agent_type") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_owned(); + let capabilities = row + .get("capabilities") + .and_then(|v| v.as_str()) + .and_then(|raw| serde_json::from_str::(raw).ok()) + .unwrap_or_else(|| json!({})); + Ok((agent_type, capabilities)) +} + +/// Resume a persisted-but-not-live session in the freshly recreated VM +/// (spec §6/§8). Reads the registry caps, reconstructs the transcript file from +/// `agent_os_session_events`, calls the sidecar resume orchestration via the +/// client, records the `external -> live` remap, and starts event capture for +/// the live session. +/// +/// The canonical resume state machine (native `session/load`/`resume` tier with +/// the `unknown_session` fallthrough, then the universal `session/new` + +/// transcript-preamble fallback) lives on the sidecar handler in +/// `crates/agentos-sidecar/src/acp_extension.rs` (spec §6). This actor function +/// only supplies the durable inputs (caps + transcript path) and records the +/// remap the sidecar returns. +pub async fn resume_session( + ctx: &HostCtx, + vm: &AgentOs, + vars: &mut Vars, + external_session_id: &str, +) -> Result<()> { + let (agent_type, _capabilities) = read_session_registry(ctx, external_session_id).await?; + + // Disposable on-demand render of the canonical event log; handed to the + // sidecar so a fallback agent can read prior context with its file tools. + let transcript_path = reconstruct_transcript_to_file(ctx, external_session_id).await?; + + // Call the sidecar resume orchestration through the client. The contract is + // `AcpResumeSessionRequest { sessionId, agentType, transcriptPath?, cwd, env }` + // (spec §6); it returns the live session id (== external for the native tier, + // a new id for the `session/new` fallback). The actor records the remap. + // + // TODO(session-resume): the `agentos_client::AgentOs::resume_session` method + // is being implemented in parallel against the same spec §6 contract and is + // not present in the pinned client yet. Once it lands, replace the error + // below with the real call + remap: + // + // let live_session_id = vm + // .resume_session(external_session_id, &agent_type, Some(&transcript_path)) + // .await? + // .session_id; + // // The remap lives SOLELY in the actor (spec §3): record external -> live + // // and capture the live session's events under the stable external id. + // vars.live_sessions + // .insert(external_session_id.to_owned(), live_session_id.clone()); + // spawn_event_capture(ctx, vm, vars, external_session_id, &live_session_id); + // return Ok(()); + let _ = (&agent_type, vm, vars); + Err(anyhow!( + "resume_session: client `resume_session` not yet available \ + (transcript reconstructed at {transcript_path}); blocked on the \ + parallel sidecar/client implementation of the spec §6 \ + AcpResumeSessionRequest contract" + )) +} diff --git a/crates/agentos-actor-plugin/src/config.rs b/crates/agentos-actor-plugin/src/config.rs new file mode 100644 index 000000000..857100571 --- /dev/null +++ b/crates/agentos-actor-plugin/src/config.rs @@ -0,0 +1,111 @@ +//! Plugin-side `config_json` deserializer — ported from the deleted r6 +//! `rivetkit-napi/src/agent_os.rs` `AgentOsConfigJson` (spec §6.6/§7: the +//! config schema is agent-os-owned and lives plugin-side; r6 treats +//! `config_json` as an opaque passthrough string). +//! +//! `config_json` is a JSON-encoded subset of [`AgentOsConfig`]. Fields that +//! cannot be represented in JSON (`schedule_driver`, `MountConfig::driver`, the +//! `sidecar_js_bridge_callback`) are intentionally absent; passing them must +//! fail loud, enforced by `deny_unknown_fields`. + +use agentos_client::{ + AgentOsConfig, AgentOsLimits, AgentOsSidecarConfig, MountConfig, MountPlugin, Permissions, + RootFilesystemConfig, SoftwareInput, +}; +use anyhow::{Context, Result}; + +/// Serializable mirror of [`AgentOsConfig`]. `deny_unknown_fields` enforces +/// fail-loud behavior when callers pass fields outside this allow-list +/// (including non-serializable fields like `schedule_driver`). +#[derive(serde::Deserialize, Default, Clone)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AgentOsConfigJson { + #[serde(default)] + software: Vec, + #[serde(default)] + additional_instructions: Option, + #[serde(default)] + module_access_cwd: Option, + #[serde(default)] + loopback_exempt_ports: Vec, + #[serde(default)] + allowed_node_builtins: Option>, + #[serde(default)] + permissions: Option, + #[serde(default)] + mounts: Vec, + #[serde(default)] + root_filesystem: Option, + #[serde(default)] + limits: Option, + #[serde(default)] + sidecar: Option, +} + +#[derive(serde::Deserialize, Clone)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct NativeMountJson { + path: String, + plugin: MountPlugin, + #[serde(default)] + read_only: bool, +} + +#[derive(serde::Deserialize, Clone)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct SidecarJson { + #[serde(default)] + pool: Option, +} + +impl AgentOsConfigJson { + /// Parse a `config_json` envelope. An empty/whitespace string is treated as + /// the default config (the client supplied no overrides). + pub(crate) fn parse(config_json: &str) -> Result { + if config_json.trim().is_empty() { + return Ok(Self::default()); + } + serde_json::from_str(config_json).context("agent-os config JSON parse error") + } + + /// Build a fresh [`AgentOsConfig`] (non-`Clone`, so rebuilt per bring-up). + /// + /// `fallback_pool` is the per-plugin-runtime sidecar pool used when the + /// client did not configure one explicitly. Per spec §7 the plugin never + /// uses the global `"default"` pool: a unique-per-runtime pool gives one + /// sidecar process per plugin runtime, shared across the actors it hosts and + /// isolated from other dlopen loads. + pub(crate) fn to_agent_os_config(&self, fallback_pool: &str) -> AgentOsConfig { + let sidecar = match &self.sidecar { + // Client-configured pool is trusted; honor it verbatim. + Some(sidecar) => AgentOsSidecarConfig::Shared { + pool: sidecar.pool.clone(), + }, + // No client config → isolate this plugin runtime on its own pool. + None => AgentOsSidecarConfig::Shared { + pool: Some(fallback_pool.to_owned()), + }, + }; + AgentOsConfig { + software: self.software.clone(), + loopback_exempt_ports: self.loopback_exempt_ports.clone(), + allowed_node_builtins: self.allowed_node_builtins.clone(), + module_access_cwd: self.module_access_cwd.clone(), + additional_instructions: self.additional_instructions.clone(), + permissions: self.permissions.clone(), + mounts: self + .mounts + .iter() + .map(|mount| MountConfig::Native { + path: mount.path.clone(), + plugin: mount.plugin.clone(), + read_only: mount.read_only, + }) + .collect(), + root_filesystem: self.root_filesystem.clone().unwrap_or_default(), + limits: self.limits.clone(), + sidecar: Some(sidecar), + ..AgentOsConfig::default() + } + } +} diff --git a/crates/agentos-actor-plugin/src/host_ctx.rs b/crates/agentos-actor-plugin/src/host_ctx.rs new file mode 100644 index 000000000..744047afe --- /dev/null +++ b/crates/agentos-actor-plugin/src/host_ctx.rs @@ -0,0 +1,185 @@ +//! Plugin-side host bridge — the inverse of the RivetKit host vtable impl. +//! +//! Wraps the `HostVtable` the host hands to `rivet_actor_run` and exposes safe +//! async/sync methods the actor run loop calls: durable storage (`db_*`), event +//! pull (`next_event`), replies, broadcast. Each async op is a sync submit + +//! completion callback bridged to an `await` via a oneshot (spec §5.4), driven +//! on the plugin runtime. Depends only on `rivet-actor-plugin-abi` + `tokio` +//! (no `agent-os-client`), so it builds independently of the secure-exec layer. + +#![allow(dead_code)] + +use std::ffi::c_void; + +use rivet_actor_plugin_abi as abi; +use tokio::sync::oneshot; + +/// `Send` wrapper so an `AbiResult` (which holds raw pointers) can travel +/// through the oneshot channel and the spawned actor future stays `Send`. +struct SendResult(abi::AbiResult); +unsafe impl Send for SendResult {} + +/// Refcounted handle to the host actor context. `Clone` bumps the host refcount +/// (`ctx_clone`) so detached tasks can hold it; `Drop` releases it. +pub(crate) struct HostCtx { + vtable: abi::HostVtable, +} + +unsafe impl Send for HostCtx {} +unsafe impl Sync for HostCtx {} + +impl Clone for HostCtx { + fn clone(&self) -> Self { + let mut v = self.vtable; + v.ctx = (self.vtable.ctx_clone)(self.vtable.ctx); + Self { vtable: v } + } +} + +impl Drop for HostCtx { + fn drop(&mut self) { + (self.vtable.ctx_release)(self.vtable.ctx); + } +} + +/// Completion callback the host invokes when an async op finishes: reclaims the +/// boxed oneshot sender and delivers the result. Panic-firewalled. +extern "C" fn complete(user_data: *mut c_void, result: abi::AbiResult) { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { + let tx = Box::from_raw(user_data as *mut oneshot::Sender); + let _ = tx.send(SendResult(result)); + })); +} + +fn decode_result(result: abi::AbiResult) -> Result, String> { + match result.status { + abi::AbiStatus::Ok => Ok(unsafe { result.payload.into_vec() }), + _ => { + let bytes = unsafe { result.payload.into_vec() }; + Err(String::from_utf8_lossy(&bytes).into_owned()) + } + } +} + +/// Decode a `next_event` payload: `[tag u32 LE][reply_token u64 LE][bytes]`. +fn decode_event(bytes: &[u8]) -> Option<(u32, u64, Vec)> { + if bytes.len() < 12 { + return None; + } + let tag = u32::from_le_bytes(bytes[0..4].try_into().ok()?); + let token = u64::from_le_bytes(bytes[4..12].try_into().ok()?); + Some((tag, token, bytes[12..].to_vec())) +} + +impl HostCtx { + /// Adopt a strong ref to the host ctx handed in via `rivet_actor_run`'s + /// `HostVtable`. The host retains and releases its own ref independently + /// after run cleanup, so the plugin clone balances this handle's `Drop`. + pub(crate) fn from_vtable(vtable: abi::HostVtable) -> Self { + (vtable.ctx_clone)(vtable.ctx); + Self { vtable } + } + + fn ctx(&self) -> *const c_void { + self.vtable.ctx + } + + pub(crate) async fn db_exec(&self, sql: Vec) -> Result, String> { + let (tx, rx) = oneshot::channel::(); + let ud = Box::into_raw(Box::new(tx)) as *mut c_void; + (self.vtable.db_exec)(self.ctx(), abi::OwnedBuf::from_vec(sql), complete, ud); + decode_result( + rx.await + .map(|r| r.0) + .unwrap_or_else(|_| abi::AbiResult::channel_closed()), + ) + } + + pub(crate) async fn db_query(&self, sql: Vec, params: Vec) -> Result, String> { + self.submit_sql(self.vtable.db_query, sql, params).await + } + + pub(crate) async fn db_run(&self, sql: Vec, params: Vec) -> Result, String> { + self.submit_sql(self.vtable.db_run, sql, params).await + } + + async fn submit_sql( + &self, + f: abi::DbSqlFn, + sql: Vec, + params: Vec, + ) -> Result, String> { + let (tx, rx) = oneshot::channel::(); + let ud = Box::into_raw(Box::new(tx)) as *mut c_void; + f( + self.ctx(), + abi::OwnedBuf::from_vec(sql), + abi::OwnedBuf::from_vec(params), + complete, + ud, + ); + decode_result( + rx.await + .map(|r| r.0) + .unwrap_or_else(|_| abi::AbiResult::channel_closed()), + ) + } + + /// Pull the next lifecycle event, or `None` when the stream is closed. + pub(crate) async fn next_event(&self) -> Option<(u32, u64, Vec)> { + let (tx, rx) = oneshot::channel::(); + let ud = Box::into_raw(Box::new(tx)) as *mut c_void; + (self.vtable.next_event)(self.ctx(), complete, ud); + let result = rx + .await + .map(|r| r.0) + .unwrap_or_else(|_| abi::AbiResult::channel_closed()); + match result.status { + abi::AbiStatus::Ok => { + let bytes = unsafe { result.payload.into_vec() }; + decode_event(&bytes) + } + _ => { + unsafe { result.payload.free_self() }; + None + } + } + } + + pub(crate) fn sql_is_enabled(&self) -> bool { + (self.vtable.sql_is_enabled)(self.ctx()) != 0 + } + + /// Signal actor startup to the host (required: the native-plugin factory is + /// built with manual startup-ready, so the host's `start()` caller awaits + /// this before the actor is considered live). `ok = false` reports a fatal + /// startup error with `msg`. + pub(crate) fn startup_ready(&self, ok: bool, msg: &str) { + let err = abi::BorrowedBuf::from_slice(msg.as_bytes()); + (self.vtable.startup_ready)(self.ctx(), u8::from(ok), err); + } + + pub(crate) fn reply_ok(&self, token: u64, payload: Vec) -> abi::AbiStatus { + (self.vtable.reply_ok)(self.ctx(), token, abi::OwnedBuf::from_vec(payload)) + } + + pub(crate) fn reply_err(&self, token: u64, msg: &str) -> abi::AbiStatus { + (self.vtable.reply_err)( + self.ctx(), + token, + abi::OwnedBuf::from_vec(msg.as_bytes().to_vec()), + ) + } + + pub(crate) fn broadcast(&self, name: Vec, payload: Vec) -> abi::AbiStatus { + (self.vtable.broadcast)( + self.ctx(), + abi::OwnedBuf::from_vec(name), + abi::OwnedBuf::from_vec(payload), + ) + } + + pub(crate) fn log_warn(&self, msg: &str) { + (self.vtable.log)(self.ctx(), 3, abi::BorrowedBuf::from_slice(msg.as_bytes())); + } +} diff --git a/crates/agentos-actor-plugin/src/http.rs b/crates/agentos-actor-plugin/src/http.rs new file mode 100644 index 000000000..809fd3c93 --- /dev/null +++ b/crates/agentos-actor-plugin/src/http.rs @@ -0,0 +1,124 @@ +//! HTTP preview proxy — plugin-side port of `rivetkit-agent-os::run::proxy_preview`. +//! +//! The host forwards a `RuntimeEvent::Http` as an `AbiEventTag::Http` event +//! whose payload is a CBOR [`HttpReqWire`]; the plugin replies (`reply_ok`) with +//! a CBOR [`HttpRespWire`]. These wire structs MUST stay field-identical to the +//! host's (`rivetkit-core::actor::native_plugin`) so CBOR round-trips. +//! +//! A `/preview/{token}/...` request resolves the token to a guest loopback port +//! (persisted in `agent_os_preview_tokens`) and forwards the remainder to that +//! port via [`AgentOs::fetch`]. An unmatched path, an unknown/expired token, or +//! a VM that is not up all reply `404` — which, like rivetkit's +//! `http.reply_status(404)`, is a successful HTTP response, not a reply error. + +use std::collections::HashMap; +use std::io::Cursor; + +use agentos_client::AgentOs; +use bytes::Bytes; + +use crate::actions::preview; +use crate::host_ctx::HostCtx; + +/// HTTP request forwarded from the host (`Http` tag payload), CBOR-encoded. +/// Field-identical to the host's `HttpReqWire`. +#[derive(serde::Deserialize)] +struct HttpReqWire { + method: String, + uri: String, + headers: HashMap, + #[serde(with = "serde_bytes")] + body: Vec, +} + +/// HTTP response the plugin returns in its reply, CBOR-encoded. Field-identical +/// to the host's `HttpRespWire`. +#[derive(serde::Serialize)] +struct HttpRespWire { + status: u16, + headers: HashMap, + #[serde(with = "serde_bytes")] + body: Vec, +} + +fn encode_resp(status: u16, headers: HashMap, body: Vec) -> Vec { + let wire = HttpRespWire { + status, + headers, + body, + }; + let mut out = Vec::new(); + let _ = ciborium::into_writer(&wire, &mut out); + out +} + +/// A bodyless response with `status` (the `/preview` 404 path + error cases). +fn status_response(status: u16) -> Vec { + encode_resp(status, HashMap::new(), Vec::new()) +} + +/// Proxy a `/preview/{token}/...` request to the guest port the token was issued +/// for. Returns the CBOR `HttpRespWire` bytes to hand to `reply_ok`. +pub(crate) async fn proxy_preview( + host: &HostCtx, + vm: Option<&AgentOs>, + req_bytes: &[u8], +) -> Vec { + let req: HttpReqWire = match ciborium::from_reader(Cursor::new(req_bytes)) { + Ok(req) => req, + Err(_) => return status_response(400), + }; + + // The forwarded uri is request-target form; take just the path. + let path = req + .uri + .parse::() + .map(|uri| uri.path().to_owned()) + .unwrap_or_else(|_| req.uri.clone()); + + let Some(rest) = path.strip_prefix("/preview/") else { + return status_response(404); + }; + let (token, forward_path) = match rest.split_once('/') { + Some((token, tail)) => (token.to_owned(), format!("/{tail}")), + None => (rest.to_owned(), "/".to_owned()), + }; + + let port = match preview::resolve(host, &token).await { + Ok(Some(port)) => port, + _ => return status_response(404), + }; + let Some(vm) = vm else { + return status_response(404); + }; + + let mut builder = http::Request::builder() + .method(req.method.as_str()) + .uri(&forward_path); + for (name, value) in &req.headers { + builder = builder.header(name.as_str(), value.as_str()); + } + let forwarded = match builder.body(Bytes::from(req.body)) { + Ok(forwarded) => forwarded, + Err(_) => return status_response(400), + }; + + match vm.fetch(port, forwarded).await { + Ok(response) => { + let status = response.status().as_u16(); + let headers = response + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_owned(), + String::from_utf8_lossy(value.as_bytes()).into_owned(), + ) + }) + .collect(); + let body = response.into_body().to_vec(); + encode_resp(status, headers, body) + } + Err(_) => status_response(502), + } +} diff --git a/crates/agentos-actor-plugin/src/lib.rs b/crates/agentos-actor-plugin/src/lib.rs new file mode 100644 index 000000000..39bd20d00 --- /dev/null +++ b/crates/agentos-actor-plugin/src/lib.rs @@ -0,0 +1,345 @@ +//! Agent OS actor plugin (`cdylib`) — the **plugin side** of +//! `rivet-actor-plugin-abi`, the inverse of the RivetKit host loader. +//! +//! RivetKit `dlopen`s this library, verifies the ABI, and drives the agent-os +//! actor through the exported symbols. This crate owns the plugin tokio runtime +//! and (in the run-loop port that builds on this foundation) imports the +//! **unmodified** `agent-os-client` to spawn + drive the sidecar, calling back +//! into the host `HostVtable` for durable storage and events. +//! +//! This file is the export/ABI/runtime skeleton (spec phase 4 foundation). The +//! actor run loop + the plugin-side host-vtable bridge are layered on top next. + +#![allow(unsafe_op_in_unsafe_fn)] + +use std::ffi::c_void; + +use rivet_actor_plugin_abi as abi; +use tokio_util::sync::CancellationToken; + +mod actions; +mod config; +mod host_ctx; +mod http; +mod persistence; +mod vm; + +#[cfg(test)] +mod persistence_e2e; + +use std::sync::Arc; + +/// Process-global plugin state created once per `dlopen` (spec §5.2): the +/// plugin's own tokio runtime (`enable_all` — the time driver is required by +/// agent-os-client hot paths). +struct Plugin { + runtime: tokio::runtime::Runtime, + /// Unique sidecar pool for this plugin runtime (spec §7): one sidecar + /// process per dlopen, shared across the actors it hosts, never the global + /// `"default"` pool. + pool: String, +} + +fn write_err(out: *mut abi::OwnedBuf, msg: &str) { + if !out.is_null() { + unsafe { + *out = abi::OwnedBuf::from_vec(msg.as_bytes().to_vec()); + } + } +} + +#[no_mangle] +pub extern "C" fn rivet_actor_abi_magic() -> u64 { + abi::RIVET_ACTOR_ABI_MAGIC +} + +#[no_mangle] +pub extern "C" fn rivet_actor_abi_version() -> u64 { + abi::RIVET_ACTOR_ABI_VERSION +} + +#[no_mangle] +pub extern "C" fn rivet_actor_plugin_init(out_err: *mut abi::OwnedBuf) -> *mut c_void { + let built = std::panic::catch_unwind(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + }); + match built { + Ok(Ok(runtime)) => { + let pool = format!("agentos-plugin-{}", uuid::Uuid::new_v4()); + Box::into_raw(Box::new(Plugin { runtime, pool })) as *mut c_void + } + Ok(Err(e)) => { + write_err(out_err, &format!("build plugin runtime: {e}")); + std::ptr::null_mut() + } + Err(_) => { + write_err(out_err, "panic building plugin runtime"); + std::ptr::null_mut() + } + } +} + +/// Opaque factory handle: the plugin runtime handle the actor loop spawns on + +/// the resolved sidecar binary path. (`config_json` parsing into the full +/// `AgentOsActorConfig` is layered on next; the sidecar path is what the VM +/// bring-up needs immediately.) +struct Factory { + runtime: tokio::runtime::Handle, + sidecar_path: String, + /// Parsed client `config_json` (spec §7); rebuilt into a fresh + /// `AgentOsConfig` per VM bring-up because `AgentOsConfig` is non-`Clone`. + config: Arc, + /// Per-plugin-runtime sidecar pool, copied from the owning `Plugin`. + pool: String, +} + +#[no_mangle] +pub extern "C" fn rivet_actor_factory_new( + plugin: *mut c_void, + config_json: abi::BorrowedBuf, + sidecar_path: abi::BorrowedBuf, + out_err: *mut abi::OwnedBuf, +) -> *mut c_void { + if plugin.is_null() { + write_err(out_err, "null plugin handle"); + return std::ptr::null_mut(); + } + let plugin_ref = unsafe { &*(plugin as *const Plugin) }; + let runtime = plugin_ref.runtime.handle().clone(); + let pool = plugin_ref.pool.clone(); + let sidecar_path = unsafe { String::from_utf8_lossy(sidecar_path.as_slice()).into_owned() }; + let config_str = unsafe { String::from_utf8_lossy(config_json.as_slice()).into_owned() }; + let config = match config::AgentOsConfigJson::parse(&config_str) { + Ok(config) => Arc::new(config), + Err(error) => { + write_err(out_err, &format!("parse config_json: {error}")); + return std::ptr::null_mut(); + } + }; + Box::into_raw(Box::new(Factory { + runtime, + sidecar_path, + config, + pool, + })) as *mut c_void +} + +/// Send wrapper for the host completion `user_data` so it can move into the +/// spawned actor task. +struct SendUserData(*mut c_void); +unsafe impl Send for SendUserData {} + +struct RunGuard { + done: abi::CompletionFn, + ud: SendUserData, + fired: bool, +} + +impl RunGuard { + fn finish(&mut self, result: abi::AbiResult) { + if !self.fired { + self.fired = true; + (self.done)(self.ud.0, result); + } + } +} + +impl Drop for RunGuard { + fn drop(&mut self) { + self.finish(abi::AbiResult::status_only(abi::AbiStatus::Cancelled)); + } +} + +/// Run one actor instance: build the `HostCtx` bridge over the host vtable, +/// spawn the actor loop on the plugin runtime, and signal completion when the +/// event stream closes (host cancel). The VM-dispatch layer (decode actions + +/// drive the sidecar via `agent-os-client`) slots into `actor_loop` next. +#[no_mangle] +pub extern "C" fn rivet_actor_run( + factory: *mut c_void, + host: *const abi::HostVtable, + done: abi::CompletionFn, + user_data: *mut c_void, +) -> *mut c_void { + let instance = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { + let factory = &*(factory as *const Factory); + let host_ctx = host_ctx::HostCtx::from_vtable(*host); + let sidecar_path = factory.sidecar_path.clone(); + let config = factory.config.clone(); + let pool = factory.pool.clone(); + let ud = SendUserData(user_data); + let cancel = CancellationToken::new(); + let run_cancel = cancel.clone(); + let handle = factory.runtime.spawn(async move { + let mut guard = RunGuard { + done, + ud, + fired: false, + }; + actor_loop(host_ctx, sidecar_path, config, pool, run_cancel).await; + guard.finish(abi::AbiResult::ok(abi::OwnedBuf::empty())); + }); + Instance { + abort: Some(handle.abort_handle()), + cancel, + } + })) + .unwrap_or_else(|_| { + done( + user_data, + abi::AbiResult::status_only(abi::AbiStatus::Panic), + ); + Instance { + abort: None, + cancel: CancellationToken::new(), + } + }); + Box::into_raw(Box::new(instance)) as *mut c_void +} + +/// Plugin-side actor run loop (ported from `rivetkit-agent-os::run`): drains +/// host lifecycle events via the `HostCtx` bridge, brings the VM up lazily on +/// the first action, tears it down on Sleep/Destroy, and ends when the host +/// closes the stream. Action dispatch (decode + drive the VM) is the remaining +/// layer; until it lands, actions reply with a clear not-yet-ported error. +async fn actor_loop( + host: host_ctx::HostCtx, + sidecar_path: String, + config: Arc, + pool: String, + cancel: CancellationToken, +) { + let mut vm: Option = None; + let mut vars = actions::Vars::default(); + // Ensure the agent-os schema exists before handling events (best-effort; + // mirrors rivetkit-agent-os run.rs). + if host.sql_is_enabled() { + let _ = persistence::migrate(&host).await; + } + // Signal readiness: the native-plugin factory uses manual startup-ready, so + // the host's `start()` caller blocks until we report this. The VM is brought + // up lazily on the first action, so the actor is ready once the schema is in + // place and the event loop is about to run. + host.startup_ready(true, ""); + loop { + let event = tokio::select! { + _ = cancel.cancelled() => break, + event = host.next_event() => event, + }; + let Some((tag, token, payload)) = event else { + break; + }; + match abi::AbiEventTag::from_u32(tag) { + Some(abi::AbiEventTag::Action) => { + if let Err(error) = + vm::ensure_vm(&host, &sidecar_path, &config, &pool, &mut vm).await + { + host.log_warn(&format!("agent-os vm bring-up failed: {error}")); + let _ = host.reply_err(token, &error); + continue; + } + let Some(vm_ref) = vm.as_ref() else { + let _ = host.reply_err(token, "vm unavailable after bring-up"); + continue; + }; + match abi::decode_action_payload(&payload) { + Ok((name, action_args)) => { + actions::dispatch(&host, vm_ref, &mut vars, &name, &action_args, token) + .await; + } + Err(_) => { + let _ = host.reply_err(token, "malformed action event payload"); + } + } + } + Some(abi::AbiEventTag::Http) => { + // Preview proxy: do NOT bring the VM up for HTTP (matches r6's + // run loop, which passes `vm.as_ref()`); no VM → 404. + let response = http::proxy_preview(&host, vm.as_ref(), &payload).await; + let _ = host.reply_ok(token, response); + } + Some(abi::AbiEventTag::ConnPreflight) => { + let _ = host.reply_ok(token, Vec::new()); + } + Some(abi::AbiEventTag::ConnOpen) => { + let _ = host.reply_ok(token, Vec::new()); + } + Some(abi::AbiEventTag::Sleep) => { + vars.clear(); + vm::shutdown_vm(&host, &mut vm, "sleep").await; + let _ = host.reply_ok(token, Vec::new()); + } + Some(abi::AbiEventTag::Destroy) => { + vars.clear(); + vm::shutdown_vm(&host, &mut vm, "destroy").await; + let _ = host.reply_ok(token, Vec::new()); + } + Some(t) if t.needs_reply() => { + let _ = host.reply_err(token, "event not supported by agent-os actor"); + } + _ => {} + } + } + // Stream closed (cancel/teardown): best-effort VM shutdown. + vars.clear(); + vm::shutdown_vm(&host, &mut vm, "error").await; +} + +struct Instance { + abort: Option, + cancel: CancellationToken, +} + +#[no_mangle] +pub extern "C" fn rivet_actor_cancel(instance: *mut c_void) { + if instance.is_null() { + return; + } + let _ = std::panic::catch_unwind(|| unsafe { + let inst = &*(instance as *const Instance); + inst.cancel.cancel(); + }); +} + +#[no_mangle] +pub extern "C" fn rivet_actor_grace_deadline(instance: *mut c_void) { + if instance.is_null() { + return; + } + let _ = std::panic::catch_unwind(|| unsafe { + let inst = &*(instance as *const Instance); + inst.cancel.cancel(); + if let Some(abort) = inst.abort.as_ref() { + abort.abort(); + } + }); +} + +#[no_mangle] +pub extern "C" fn rivet_actor_instance_free(instance: *mut c_void) { + if !instance.is_null() { + let _ = std::panic::catch_unwind(|| unsafe { + drop(Box::from_raw(instance as *mut Instance)); + }); + } +} + +#[no_mangle] +pub extern "C" fn rivet_actor_factory_free(factory: *mut c_void) { + if !factory.is_null() { + let _ = std::panic::catch_unwind(|| unsafe { + drop(Box::from_raw(factory as *mut Factory)); + }); + } +} + +#[no_mangle] +pub extern "C" fn rivet_actor_plugin_shutdown(plugin: *mut c_void) { + if !plugin.is_null() { + let _ = std::panic::catch_unwind(|| unsafe { + drop(Box::from_raw(plugin as *mut Plugin)); + }); + } +} diff --git a/crates/agentos-actor-plugin/src/persistence.rs b/crates/agentos-actor-plugin/src/persistence.rs new file mode 100644 index 000000000..e98fccc4f --- /dev/null +++ b/crates/agentos-actor-plugin/src/persistence.rs @@ -0,0 +1,1093 @@ +//! Durable-storage persistence — ported from `rivetkit-agent-os::persistence`, +//! with `HostCtx` substituted for rivetkit's `Ctx`. This is the SQL substrate +//! every fs op + the preview/session logic sits on: the schema migration and +//! the `query_rows`/`run_stmt` helpers that marshal params as CBOR JSON arrays +//! and decode rows as CBOR JSON objects (the `db_*` wire contract). +//! +//! The ~24 fs-op handlers (readFile/writeFile/readDir/stat/mkdir/rename/...) are +//! ported on top of these helpers next — each is a direct substitution of +//! `host` for `ctx`. + +#![allow(dead_code)] + +use std::io::Cursor; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, bail, Result}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use serde_json::{json, Map as JsonMap, Value as JsonValue}; + +use crate::host_ctx::HostCtx; + +const DEFAULT_FILE_MODE: i64 = 0o100644; +const DEFAULT_DIR_MODE: i64 = 0o040755; +#[allow(dead_code)] +const DEFAULT_SYMLINK_MODE: i64 = 0o120777; + +pub(crate) const MIGRATION_SQL: &str = "\ +CREATE TABLE IF NOT EXISTS agent_os_preview_tokens ( + token TEXT PRIMARY KEY, + port INTEGER NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_preview_tokens_expires_at + ON agent_os_preview_tokens(expires_at); +CREATE TABLE IF NOT EXISTS agent_os_fs_entries ( + path TEXT PRIMARY KEY, + is_directory INTEGER NOT NULL DEFAULT 0, + content BLOB, + mode INTEGER NOT NULL DEFAULT 33188, + uid INTEGER NOT NULL DEFAULT 0, + gid INTEGER NOT NULL DEFAULT 0, + size INTEGER NOT NULL DEFAULT 0, + atime_ms INTEGER NOT NULL, + mtime_ms INTEGER NOT NULL, + ctime_ms INTEGER NOT NULL, + birthtime_ms INTEGER NOT NULL, + symlink_target TEXT, + nlink INTEGER NOT NULL DEFAULT 1 +); +CREATE INDEX IF NOT EXISTS idx_fs_entries_parent + ON agent_os_fs_entries(path); +CREATE TABLE IF NOT EXISTS agent_os_sessions ( + session_id TEXT PRIMARY KEY, + agent_type TEXT NOT NULL, + capabilities TEXT NOT NULL, + agent_info TEXT, + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS agent_os_session_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + seq INTEGER NOT NULL, + event TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (session_id) REFERENCES agent_os_sessions(session_id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_session_events_session_seq + ON agent_os_session_events(session_id, seq); +"; + +/// Run the agent-os schema migration against the actor's SQLite database. +/// Idempotent; called once at the top of the actor run loop. +pub(crate) async fn migrate(host: &HostCtx) -> Result<()> { + host.db_exec(MIGRATION_SQL.as_bytes().to_vec()) + .await + .map_err(|e| anyhow!("agent-os schema migration failed: {e}"))?; + Ok(()) +} + +/// Encode positional bind params as the CBOR JSON array the `db_*` API expects. +pub(crate) fn cbor_params(values: &[JsonValue]) -> Result> { + let mut buf = Vec::new(); + ciborium::into_writer(&JsonValue::Array(values.to_vec()), &mut buf)?; + Ok(buf) +} + +/// Decode a `db_query` CBOR result into object rows (column -> value). +pub(crate) fn decode_rows(bytes: &[u8]) -> Result>> { + if bytes.is_empty() { + return Ok(Vec::new()); + } + let value: JsonValue = ciborium::from_reader(Cursor::new(bytes))?; + Ok(match value { + JsonValue::Array(rows) => rows + .into_iter() + .filter_map(|row| match row { + JsonValue::Object(map) => Some(map), + _ => None, + }) + .collect(), + _ => Vec::new(), + }) +} + +/// Run a parameterized query and return decoded object rows. +pub(crate) async fn query_rows( + host: &HostCtx, + sql: &str, + params: &[JsonValue], +) -> Result>> { + let encoded = cbor_params(params)?; + let bytes = host + .db_query(sql.as_bytes().to_vec(), encoded) + .await + .map_err(|e| anyhow!(e))?; + decode_rows(&bytes) +} + +/// Run a parameterized statement that returns no rows (INSERT/UPDATE/DELETE). +pub(crate) async fn run_stmt(host: &HostCtx, sql: &str, params: &[JsonValue]) -> Result<()> { + let encoded = cbor_params(params)?; + host.db_run(sql.as_bytes().to_vec(), encoded) + .await + .map_err(|e| anyhow!(e))?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// sqlite_vfs filesystem dispatch (ported from rivetkit-agent-os::persistence, +// `ctx` -> `host`). This batch implements the read path; the write/rare ops +// are ported on top of the same helpers next. +// --------------------------------------------------------------------------- + +/// Dispatch a guest fs op to actor durable storage. Returns the op's JSON +/// result (or `None` for void ops). Ops not yet ported return `ENOSYS`. +pub(crate) async fn handle_fs_call( + host: &HostCtx, + operation: &str, + args: &JsonValue, +) -> Result> { + ensure_fs_root(host).await?; + match operation { + "readFile" => Ok(Some(json!(read_file(host, required_path(args)?).await?))), + "writeFile" => { + write_file( + host, + required_path(args)?, + required_string(args, "content")?, + optional_i64(args, "mode").unwrap_or(DEFAULT_FILE_MODE), + ) + .await?; + Ok(None) + } + "createFileExclusive" => { + create_file_exclusive( + host, + required_path(args)?, + required_string(args, "content")?, + optional_i64(args, "mode").unwrap_or(DEFAULT_FILE_MODE), + ) + .await?; + Ok(None) + } + "readDir" => Ok(Some(JsonValue::Array( + read_dir(host, required_path(args)?) + .await? + .into_iter() + .map(JsonValue::String) + .collect(), + ))), + "readDirWithTypes" => Ok(Some(JsonValue::Array( + read_dir_entries(host, required_path(args)?) + .await? + .into_iter() + .map(|entry| { + json!({ + "name": entry.name, + "isDirectory": entry.is_directory, + "isSymbolicLink": entry.symlink_target.is_some(), + }) + }) + .collect(), + ))), + "createDir" => { + create_dir( + host, + required_path(args)?, + optional_i64(args, "mode").unwrap_or(DEFAULT_DIR_MODE), + false, + ) + .await?; + Ok(None) + } + "mkdir" => { + let recursive = args + .get("recursive") + .and_then(JsonValue::as_bool) + .unwrap_or(false); + create_dir( + host, + required_path(args)?, + optional_i64(args, "mode").unwrap_or(DEFAULT_DIR_MODE), + recursive, + ) + .await?; + Ok(None) + } + "exists" => Ok(Some(json!(lookup_entry(host, required_path(args)?) + .await? + .is_some()))), + "stat" => Ok(Some(stat_json( + lookup_entry_required(host, required_path(args)?).await?, + ))), + "lstat" => Ok(Some(stat_json( + lookup_entry_required(host, required_path(args)?).await?, + ))), + "realpath" => Ok(Some(json!(normalize_path(required_path(args)?)?))), + "removeFile" => { + remove_file(host, required_path(args)?).await?; + Ok(None) + } + "removeDir" => { + remove_dir(host, required_path(args)?).await?; + Ok(None) + } + "rename" => { + rename_entry( + host, + required_string(args, "oldPath")?, + required_string(args, "newPath")?, + ) + .await?; + Ok(None) + } + "symlink" => { + symlink_entry( + host, + required_string(args, "target")?, + required_string(args, "path")?, + ) + .await?; + Ok(None) + } + "readLink" => Ok(Some(json!(read_link(host, required_path(args)?).await?))), + "link" => { + link_entry( + host, + required_string(args, "oldPath")?, + required_string(args, "newPath")?, + ) + .await?; + Ok(None) + } + "chmod" => { + update_one_field( + host, + required_path(args)?, + "mode", + json!(required_i64(args, "mode")?), + ) + .await?; + Ok(None) + } + "chown" => { + update_owner( + host, + required_path(args)?, + required_i64(args, "uid")?, + required_i64(args, "gid")?, + ) + .await?; + Ok(None) + } + "utimes" => { + update_times( + host, + required_path(args)?, + required_i64(args, "atimeMs")?, + required_i64(args, "mtimeMs")?, + ) + .await?; + Ok(None) + } + "truncate" => { + truncate_file(host, required_path(args)?, required_len(args)?).await?; + Ok(None) + } + "pread" => Ok(Some(json!( + pread_file( + host, + required_path(args)?, + required_i64(args, "offset")?, + required_len(args)?, + ) + .await? + ))), + operation => bail!("ENOSYS unsupported sqlite_vfs operation {operation}"), + } +} + +#[derive(Clone, Debug)] +struct FsEntry { + path: String, + name: String, + is_directory: bool, + content: Option, + mode: i64, + uid: i64, + gid: i64, + size: i64, + atime_ms: i64, + mtime_ms: i64, + ctime_ms: i64, + birthtime_ms: i64, + symlink_target: Option, + nlink: i64, +} + +impl FsEntry { + fn from_row(mut row: JsonMap) -> Result { + let path = string_col(&mut row, "path")?; + Ok(Self { + name: basename(&path), + path, + is_directory: int_col(&mut row, "is_directory")? != 0, + content: optional_content_col(&mut row, "content")?, + mode: int_col(&mut row, "mode")?, + uid: int_col(&mut row, "uid")?, + gid: int_col(&mut row, "gid")?, + size: int_col(&mut row, "size")?, + atime_ms: int_col(&mut row, "atime_ms")?, + mtime_ms: int_col(&mut row, "mtime_ms")?, + ctime_ms: int_col(&mut row, "ctime_ms")?, + birthtime_ms: int_col(&mut row, "birthtime_ms")?, + symlink_target: optional_string_col(&mut row, "symlink_target")?, + nlink: int_col(&mut row, "nlink")?, + }) + } +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +async fn ensure_fs_root(host: &HostCtx) -> Result<()> { + let now = now_ms(); + run_stmt( + host, + "INSERT OR IGNORE INTO agent_os_fs_entries + (path, is_directory, content, mode, uid, gid, size, atime_ms, mtime_ms, ctime_ms, birthtime_ms, symlink_target, nlink) + VALUES (?, 1, NULL, ?, 0, 0, 0, ?, ?, ?, ?, NULL, 2)", + &[ + json!("/"), + json!(DEFAULT_DIR_MODE), + json!(now), + json!(now), + json!(now), + json!(now), + ], + ) + .await +} + +async fn lookup_entry(host: &HostCtx, path: &str) -> Result> { + let path = normalize_path(path)?; + let rows = query_rows( + host, + "SELECT path, is_directory, content, mode, uid, gid, size, atime_ms, mtime_ms, ctime_ms, birthtime_ms, symlink_target, nlink + FROM agent_os_fs_entries WHERE path = ?", + &[json!(path)], + ) + .await?; + rows.into_iter().next().map(FsEntry::from_row).transpose() +} + +async fn lookup_entry_required(host: &HostCtx, path: &str) -> Result { + lookup_entry(host, path) + .await? + .ok_or_else(|| anyhow!("ENOENT no such file or directory: {}", path)) +} + +async fn read_file(host: &HostCtx, path: &str) -> Result { + let entry = lookup_entry_required(host, path).await?; + if entry.is_directory { + bail!("EISDIR is a directory: {}", entry.path); + } + Ok(entry.content.unwrap_or_default()) +} + +// --- ctx-free helpers (copied verbatim from rivetkit-agent-os::persistence) --- + +#[allow(dead_code)] +fn parent_path(path: &str) -> Option { + if path == "/" { + return None; + } + let path = path.trim_end_matches('/'); + let index = path.rfind('/')?; + if index == 0 { + Some("/".to_owned()) + } else { + Some(path[..index].to_owned()) + } +} + +fn normalize_path(path: &str) -> Result { + if path.is_empty() { + bail!("ENOENT empty path"); + } + let mut parts = Vec::new(); + for part in path.split('/') { + if part.is_empty() || part == "." { + continue; + } + if part == ".." { + parts.pop(); + continue; + } + parts.push(part); + } + if parts.is_empty() { + Ok("/".to_owned()) + } else { + Ok(format!("/{}", parts.join("/"))) + } +} + +fn basename(path: &str) -> String { + if path == "/" { + return "/".to_owned(); + } + path.rsplit('/').next().unwrap_or(path).to_owned() +} + +fn required_path(args: &JsonValue) -> Result<&str> { + required_string_ref(args, "path") +} + +fn required_string_ref<'a>(args: &'a JsonValue, key: &str) -> Result<&'a str> { + args.get(key) + .and_then(JsonValue::as_str) + .ok_or_else(|| anyhow!("EINVAL missing string arg {key}")) +} + +#[allow(dead_code)] +fn decode_content(content: &str) -> Result> { + BASE64 + .decode(content) + .map_err(|error| anyhow!("EINVAL invalid base64 file content: {error}")) +} + +fn string_col(row: &mut JsonMap, key: &str) -> Result { + row.remove(key) + .and_then(|value| value.as_str().map(str::to_owned)) + .ok_or_else(|| anyhow!("sqlite_vfs row missing string column {key}")) +} + +fn optional_string_col(row: &mut JsonMap, key: &str) -> Result> { + match row.remove(key) { + Some(JsonValue::Null) | None => Ok(None), + Some(JsonValue::String(value)) => Ok(Some(value)), + Some(value) => bail!("sqlite_vfs row column {key} expected string/null, got {value:?}"), + } +} + +fn optional_content_col(row: &mut JsonMap, key: &str) -> Result> { + match row.remove(key) { + Some(JsonValue::Null) | None => Ok(None), + Some(JsonValue::String(value)) => Ok(Some(value)), + Some(JsonValue::Array(bytes)) => { + let raw = bytes + .into_iter() + .map(|value| { + value + .as_u64() + .and_then(|byte| u8::try_from(byte).ok()) + .ok_or_else(|| { + anyhow!("sqlite_vfs blob column {key} contains non-byte value") + }) + }) + .collect::>>()?; + Ok(Some(String::from_utf8(raw)?)) + } + Some(value) => { + bail!("sqlite_vfs row column {key} expected blob/string/null, got {value:?}") + } + } +} + +fn int_col(row: &mut JsonMap, key: &str) -> Result { + row.remove(key) + .and_then(|value| value.as_i64()) + .ok_or_else(|| anyhow!("sqlite_vfs row missing integer column {key}")) +} + +// --- write/mkdir/readdir op handlers (ctx -> host) --- + +async fn ensure_parent_dir(host: &HostCtx, path: &str) -> Result<()> { + let Some(parent) = parent_path(path) else { + return Ok(()); + }; + let parent = lookup_entry_required(host, &parent).await?; + if !parent.is_directory { + bail!("ENOTDIR parent is not a directory: {}", parent.path); + } + Ok(()) +} + +async fn write_file(host: &HostCtx, path: &str, content: String, mode: i64) -> Result<()> { + let path = normalize_path(path)?; + ensure_parent_dir(host, &path).await?; + let size = decoded_len(&content)?; + let now = now_ms(); + if let Some(existing) = lookup_entry(host, &path).await? { + if existing.is_directory { + bail!("EISDIR is a directory: {path}"); + } + run_stmt( + host, + "UPDATE agent_os_fs_entries + SET is_directory = 0, content = ?, mode = ?, size = ?, mtime_ms = ?, ctime_ms = ?, symlink_target = NULL, nlink = 1 + WHERE path = ?", + &[ + json!(content), + json!(mode), + json!(size), + json!(now), + json!(now), + json!(path), + ], + ) + .await?; + return Ok(()); + } + insert_entry(host, &path, false, Some(content), mode, size, None, 1, now).await +} + +async fn create_file_exclusive( + host: &HostCtx, + path: &str, + content: String, + mode: i64, +) -> Result<()> { + let path = normalize_path(path)?; + if lookup_entry(host, &path).await?.is_some() { + bail!("EEXIST file exists: {path}"); + } + ensure_parent_dir(host, &path).await?; + let size = decoded_len(&content)?; + insert_entry( + host, + &path, + false, + Some(content), + mode, + size, + None, + 1, + now_ms(), + ) + .await +} + +async fn create_dir(host: &HostCtx, path: &str, mode: i64, recursive: bool) -> Result<()> { + let path = normalize_path(path)?; + if path == "/" { + return Ok(()); + } + if let Some(existing) = lookup_entry(host, &path).await? { + if recursive && existing.is_directory { + return Ok(()); + } + bail!("EEXIST file exists: {path}"); + } + if recursive { + let mut parents = Vec::new(); + let mut cursor = parent_path(&path); + while let Some(parent) = cursor { + if parent == "/" { + break; + } + parents.push(parent.clone()); + cursor = parent_path(&parent); + } + parents.reverse(); + for parent in parents { + if let Some(existing) = lookup_entry(host, &parent).await? { + if !existing.is_directory { + bail!("ENOTDIR parent is not a directory: {}", existing.path); + } + continue; + } + insert_entry(host, &parent, true, None, mode, 0, None, 2, now_ms()).await?; + } + } else { + ensure_parent_dir(host, &path).await?; + } + insert_entry(host, &path, true, None, mode, 0, None, 2, now_ms()).await +} + +async fn read_dir(host: &HostCtx, path: &str) -> Result> { + Ok(read_dir_entries(host, path) + .await? + .into_iter() + .map(|entry| entry.name) + .collect()) +} + +async fn read_dir_entries(host: &HostCtx, path: &str) -> Result> { + let path = normalize_path(path)?; + let entry = lookup_entry_required(host, &path).await?; + if !entry.is_directory { + bail!("ENOTDIR not a directory: {path}"); + } + let prefix = if path == "/" { + "/".to_owned() + } else { + format!("{path}/") + }; + let rows = query_rows( + host, + "SELECT path, is_directory, content, mode, uid, gid, size, atime_ms, mtime_ms, ctime_ms, birthtime_ms, symlink_target, nlink + FROM agent_os_fs_entries WHERE path LIKE ? AND path != ? ORDER BY path", + &[json!(format!("{prefix}%")), json!(path)], + ) + .await?; + rows.into_iter() + .map(FsEntry::from_row) + .filter_map(|entry| match entry { + Ok(entry) if parent_path(&entry.path).as_deref() == Some(path.as_str()) => { + Some(Ok(entry)) + } + Ok(_) => None, + Err(error) => Some(Err(error)), + }) + .collect() +} + +#[allow(clippy::too_many_arguments)] +async fn insert_entry( + host: &HostCtx, + path: &str, + is_directory: bool, + content: Option, + mode: i64, + size: i64, + symlink_target: Option, + nlink: i64, + now: i64, +) -> Result<()> { + run_stmt( + host, + "INSERT INTO agent_os_fs_entries + (path, is_directory, content, mode, uid, gid, size, atime_ms, mtime_ms, ctime_ms, birthtime_ms, symlink_target, nlink) + VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?)", + &[ + json!(path), + json!(if is_directory { 1 } else { 0 }), + content.map_or(JsonValue::Null, JsonValue::String), + json!(mode), + json!(size), + json!(now), + json!(now), + json!(now), + json!(now), + symlink_target.map_or(JsonValue::Null, JsonValue::String), + json!(nlink), + ], + ) + .await +} + +fn required_string(args: &JsonValue, key: &str) -> Result { + Ok(required_string_ref(args, key)?.to_owned()) +} + +fn optional_i64(args: &JsonValue, key: &str) -> Option { + args.get(key).and_then(JsonValue::as_i64) +} + +fn decoded_len(content: &str) -> Result { + Ok(decode_content(content)?.len() as i64) +} + +fn required_i64(args: &JsonValue, key: &str) -> Result { + optional_i64(args, key).ok_or_else(|| anyhow!("EINVAL missing integer arg {key}")) +} + +fn required_len(args: &JsonValue) -> Result { + optional_i64(args, "len") + .or_else(|| optional_i64(args, "length")) + .ok_or_else(|| anyhow!("EINVAL missing integer arg length")) +} + +// --- remove/rename/symlink/link/chmod/chown/utimes/truncate/pread handlers --- + +async fn remove_file(host: &HostCtx, path: &str) -> Result<()> { + let entry = lookup_entry_required(host, path).await?; + if entry.is_directory { + bail!("EISDIR is a directory: {}", entry.path); + } + run_stmt( + host, + "DELETE FROM agent_os_fs_entries WHERE path = ?", + &[json!(entry.path)], + ) + .await +} + +async fn remove_dir(host: &HostCtx, path: &str) -> Result<()> { + let entry = lookup_entry_required(host, path).await?; + if !entry.is_directory { + bail!("ENOTDIR not a directory: {}", entry.path); + } + if entry.path == "/" { + bail!("EBUSY cannot remove root directory"); + } + if !read_dir_entries(host, &entry.path).await?.is_empty() { + bail!("ENOTEMPTY directory not empty: {}", entry.path); + } + run_stmt( + host, + "DELETE FROM agent_os_fs_entries WHERE path = ?", + &[json!(entry.path)], + ) + .await +} + +async fn rename_entry(host: &HostCtx, old_path: String, new_path: String) -> Result<()> { + let old_path = normalize_path(&old_path)?; + let new_path = normalize_path(&new_path)?; + if old_path == "/" { + bail!("EBUSY cannot rename root directory"); + } + let entry = lookup_entry_required(host, &old_path).await?; + ensure_parent_dir(host, &new_path).await?; + if entry.is_directory && new_path.starts_with(&format!("{old_path}/")) { + bail!("EINVAL cannot move directory into itself"); + } + if let Some(existing) = lookup_entry(host, &new_path).await? { + if existing.is_directory && !read_dir_entries(host, &existing.path).await?.is_empty() { + bail!("ENOTEMPTY target directory not empty: {}", existing.path); + } + run_stmt( + host, + "DELETE FROM agent_os_fs_entries WHERE path = ?", + &[json!(existing.path)], + ) + .await?; + } + let old_prefix = format!("{old_path}/"); + let new_prefix = format!("{new_path}/"); + let rows = query_rows( + host, + "SELECT path FROM agent_os_fs_entries WHERE path = ? OR path LIKE ? ORDER BY path", + &[json!(old_path), json!(format!("{old_prefix}%"))], + ) + .await?; + for row in rows { + let path = row + .get("path") + .and_then(JsonValue::as_str) + .ok_or_else(|| anyhow!("sqlite_vfs rename row missing path"))?; + let next_path = if path == old_path { + new_path.clone() + } else { + format!("{new_prefix}{}", &path[old_prefix.len()..]) + }; + run_stmt( + host, + "UPDATE agent_os_fs_entries SET path = ?, ctime_ms = ? WHERE path = ?", + &[json!(next_path), json!(now_ms()), json!(path)], + ) + .await?; + } + Ok(()) +} + +async fn symlink_entry(host: &HostCtx, target: String, path: String) -> Result<()> { + let path = normalize_path(&path)?; + if lookup_entry(host, &path).await?.is_some() { + bail!("EEXIST file exists: {path}"); + } + ensure_parent_dir(host, &path).await?; + insert_entry( + host, + &path, + false, + None, + DEFAULT_SYMLINK_MODE, + target.len() as i64, + Some(target), + 1, + now_ms(), + ) + .await +} + +async fn read_link(host: &HostCtx, path: &str) -> Result { + let entry = lookup_entry_required(host, path).await?; + entry + .symlink_target + .ok_or_else(|| anyhow!("EINVAL not a symbolic link: {}", entry.path)) +} + +async fn link_entry(host: &HostCtx, old_path: String, new_path: String) -> Result<()> { + let old_path = normalize_path(&old_path)?; + let new_path = normalize_path(&new_path)?; + if lookup_entry(host, &new_path).await?.is_some() { + bail!("EEXIST file exists: {new_path}"); + } + ensure_parent_dir(host, &new_path).await?; + let entry = lookup_entry_required(host, &old_path).await?; + if entry.is_directory { + bail!("EPERM cannot hard-link directory: {old_path}"); + } + insert_entry( + host, + &new_path, + false, + entry.content, + entry.mode, + entry.size, + entry.symlink_target, + 1, + now_ms(), + ) + .await?; + update_one_field(host, &old_path, "nlink", json!(entry.nlink + 1)).await +} + +async fn update_owner(host: &HostCtx, path: &str, uid: i64, gid: i64) -> Result<()> { + let path = normalize_path(path)?; + lookup_entry_required(host, &path).await?; + run_stmt( + host, + "UPDATE agent_os_fs_entries SET uid = ?, gid = ?, ctime_ms = ? WHERE path = ?", + &[json!(uid), json!(gid), json!(now_ms()), json!(path)], + ) + .await +} + +async fn update_times(host: &HostCtx, path: &str, atime_ms: i64, mtime_ms: i64) -> Result<()> { + let path = normalize_path(path)?; + lookup_entry_required(host, &path).await?; + run_stmt( + host, + "UPDATE agent_os_fs_entries SET atime_ms = ?, mtime_ms = ?, ctime_ms = ? WHERE path = ?", + &[ + json!(atime_ms), + json!(mtime_ms), + json!(now_ms()), + json!(path), + ], + ) + .await +} + +async fn truncate_file(host: &HostCtx, path: &str, len: i64) -> Result<()> { + if len < 0 { + bail!("EINVAL negative truncate length"); + } + let entry = lookup_entry_required(host, path).await?; + if entry.is_directory { + bail!("EISDIR is a directory: {}", entry.path); + } + let mut bytes = decode_content(entry.content.as_deref().unwrap_or_default())?; + bytes.resize(len as usize, 0); + let content = BASE64.encode(bytes); + run_stmt( + host, + "UPDATE agent_os_fs_entries SET content = ?, size = ?, mtime_ms = ?, ctime_ms = ? WHERE path = ?", + &[ + json!(content), + json!(len), + json!(now_ms()), + json!(now_ms()), + json!(entry.path), + ], + ) + .await +} + +async fn pread_file(host: &HostCtx, path: &str, offset: i64, len: i64) -> Result { + if offset < 0 || len < 0 { + bail!("EINVAL negative pread offset or length"); + } + let entry = lookup_entry_required(host, path).await?; + if entry.is_directory { + bail!("EISDIR is a directory: {}", entry.path); + } + let bytes = decode_content(entry.content.as_deref().unwrap_or_default())?; + let start = (offset as usize).min(bytes.len()); + let end = start.saturating_add(len as usize).min(bytes.len()); + Ok(BASE64.encode(&bytes[start..end])) +} + +async fn update_one_field(host: &HostCtx, path: &str, field: &str, value: JsonValue) -> Result<()> { + let path = normalize_path(path)?; + lookup_entry_required(host, &path).await?; + let sql = match field { + "mode" => "UPDATE agent_os_fs_entries SET mode = ?, ctime_ms = ? WHERE path = ?", + "nlink" => "UPDATE agent_os_fs_entries SET nlink = ?, ctime_ms = ? WHERE path = ?", + _ => bail!("EINVAL unsupported update field {field}"), + }; + run_stmt(host, sql, &[value, json!(now_ms()), json!(path)]).await +} + +fn stat_json(entry: FsEntry) -> JsonValue { + json!({ + "dev": 0, + "ino": stable_ino(&entry.path), + "mode": entry.mode, + "nlink": entry.nlink, + "uid": entry.uid, + "gid": entry.gid, + "rdev": 0, + "size": entry.size, + "blocks": if entry.size == 0 { 0 } else { (entry.size + 511) / 512 }, + "atimeMs": entry.atime_ms, + "mtimeMs": entry.mtime_ms, + "ctimeMs": entry.ctime_ms, + "birthtimeMs": entry.birthtime_ms, + "atimeNsec": (entry.atime_ms % 1000) * 1_000_000, + "mtimeNsec": (entry.mtime_ms % 1000) * 1_000_000, + "ctimeNsec": (entry.ctime_ms % 1000) * 1_000_000, + "birthtimeNsec": (entry.birthtime_ms % 1000) * 1_000_000, + "isDirectory": entry.is_directory, + "isSymbolicLink": entry.symlink_target.is_some(), + }) +} + +fn stable_ino(path: &str) -> u64 { + let mut hash = 0xcbf29ce484222325u64; + for byte in path.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +// --------------------------------------------------------------------------- +// Session-event persistence (spec §4/§5) — ported from `rivetkit-agent-os`'s +// `persistence.rs`, `HostCtx` substituted for rivetkit's `Ctx`. +// +// `agent_os_session_events` is the canonical append-only conversation log, +// keyed by `external_session_id`. `seq` is INTERNAL ordering only. +// --------------------------------------------------------------------------- + +/// Append one captured event to `agent_os_session_events` under the stable +/// `external_session_id`, allocating the next per-session `seq` (`MAX(seq)+1`). +pub(crate) async fn insert_session_event( + host: &HostCtx, + external_session_id: &str, + event_json: &str, +) -> Result<()> { + let rows = query_rows( + host, + "SELECT MAX(seq) AS max_seq FROM agent_os_session_events WHERE session_id = ?", + &[json!(external_session_id)], + ) + .await?; + let next_seq = rows + .first() + .and_then(|row| row.get("max_seq")) + .and_then(JsonValue::as_i64) + .map(|max| max + 1) + .unwrap_or(0); + run_stmt( + host, + "INSERT INTO agent_os_session_events (session_id, seq, event, created_at) \ + VALUES (?, ?, ?, ?)", + &[ + json!(external_session_id), + json!(next_seq), + json!(event_json), + json!(now_ms()), + ], + ) + .await +} + +/// Render the persisted event log for `external_session_id` to a Markdown +/// transcript, write it through the same sqlite_vfs path the guest reads, and +/// return that path (spec §7). Idempotent: overwritten fresh each resume. +pub(crate) async fn reconstruct_transcript_to_file( + host: &HostCtx, + external_session_id: &str, +) -> Result { + let rows = query_rows( + host, + "SELECT event FROM agent_os_session_events WHERE session_id = ? ORDER BY seq", + &[json!(external_session_id)], + ) + .await?; + let events: Vec = rows + .into_iter() + .filter_map(|mut row| { + row.remove("event") + .and_then(|v| match v { + JsonValue::String(raw) => Some(raw), + _ => None, + }) + .and_then(|raw| serde_json::from_str::(&raw).ok()) + }) + .collect(); + + let markdown = render_transcript_markdown(external_session_id, &events); + + let path = format!("/root/.agentos/threads/{external_session_id}.md"); + create_dir(host, "/root/.agentos/threads", DEFAULT_DIR_MODE, true).await?; + // The callback stores base64 file content (see `decode_content`). + write_file(host, &path, BASE64.encode(markdown), DEFAULT_FILE_MODE).await?; + Ok(path) +} + +/// Render captured ACP events to a role-labeled Markdown transcript. Pure / +/// deterministic so reconstruction is idempotent. +fn render_transcript_markdown(external_session_id: &str, events: &[JsonValue]) -> String { + let mut out = String::new(); + out.push_str(&format!("# Session transcript: {external_session_id}\n")); + + for event in events { + if event.get("method").and_then(JsonValue::as_str) == Some("user_prompt") { + if let Some(text) = event + .get("params") + .and_then(|p| p.get("text")) + .and_then(JsonValue::as_str) + { + out.push_str(&format!("\n## User\n\n{text}\n")); + } + continue; + } + + let Some(update) = event.get("params").and_then(|p| p.get("update")) else { + continue; + }; + let kind = update + .get("sessionUpdate") + .and_then(JsonValue::as_str) + .unwrap_or(""); + match kind { + "agent_message_chunk" | "agent_thought_chunk" => { + if let Some(text) = update + .get("content") + .and_then(|c| c.get("text")) + .and_then(JsonValue::as_str) + { + if kind == "agent_thought_chunk" { + out.push_str(&format!("\n## Assistant (thinking)\n\n{text}\n")); + } else { + out.push_str(&format!("\n## Assistant\n\n{text}\n")); + } + } + } + "tool_call" | "tool_call_update" => { + let title = update + .get("title") + .and_then(JsonValue::as_str) + .or_else(|| update.get("kind").and_then(JsonValue::as_str)) + .unwrap_or("tool call"); + let status = update + .get("status") + .and_then(JsonValue::as_str) + .unwrap_or(""); + out.push_str(&format!("\n### Tool call: {title}")); + if !status.is_empty() { + out.push_str(&format!(" ({status})")); + } + out.push('\n'); + if let Some(content) = update.get("content").and_then(JsonValue::as_array) { + for item in content { + if let Some(text) = item + .get("content") + .and_then(|c| c.get("text")) + .and_then(JsonValue::as_str) + .or_else(|| item.get("text").and_then(JsonValue::as_str)) + { + out.push_str(&format!("\n```\n{text}\n```\n")); + } + } + } + } + _ => {} + } + } + + out +} diff --git a/crates/agentos-actor-plugin/src/persistence_e2e.rs b/crates/agentos-actor-plugin/src/persistence_e2e.rs new file mode 100644 index 000000000..231c274ef --- /dev/null +++ b/crates/agentos-actor-plugin/src/persistence_e2e.rs @@ -0,0 +1,408 @@ +//! End-to-end test of the durable-persistence layer against a REAL SQLite. +//! +//! Drives the actual `persistence::handle_fs_call` dispatch (the storage +//! callback the VM's `sqlite_vfs` root invokes) through a mock `HostVtable` +//! whose `db_*` functions execute against an in-memory rusqlite `Connection`, +//! speaking the exact CBOR `db_*` wire contract the plugin uses. No VM, no +//! sidecar — this isolates and proves the durable-storage core (the 24 fs ops, +//! the migration, base64 content, the CBOR params/rows marshalling). + +use std::ffi::c_void; +use std::io::Cursor; +use std::sync::atomic::{AtomicIsize, Ordering}; +use std::sync::Mutex; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use rivet_actor_plugin_abi as abi; +use rusqlite::types::Value as SqlValue; +use rusqlite::Connection; +use serde_json::{json, Map, Value as JsonValue}; + +use crate::host_ctx::HostCtx; +use crate::persistence; + +/// Mock host state: the actor's SQLite database. +struct MockHost { + conn: Mutex, + refs: AtomicIsize, +} + +extern "C" fn ctx_clone(ctx: *const c_void) -> *const c_void { + host_of(ctx).refs.fetch_add(1, Ordering::SeqCst); + ctx +} +extern "C" fn ctx_release(ctx: *const c_void) { + host_of(ctx).refs.fetch_sub(1, Ordering::SeqCst); +} +extern "C" fn sql_is_enabled(_ctx: *const c_void) -> u8 { + 1 +} + +// Unused-by-persistence vtable stubs. +extern "C" fn next_event(_ctx: *const c_void, done: abi::CompletionFn, ud: *mut c_void) { + done(ud, abi::AbiResult::channel_closed()); +} +extern "C" fn reply_ok(_c: *const c_void, _t: u64, _p: abi::OwnedBuf) -> abi::AbiStatus { + abi::AbiStatus::Ok +} +extern "C" fn reply_err(_c: *const c_void, _t: u64, _p: abi::OwnedBuf) -> abi::AbiStatus { + abi::AbiStatus::Ok +} +extern "C" fn startup_ready(_c: *const c_void, _ok: u8, _e: abi::BorrowedBuf) {} +extern "C" fn broadcast(_c: *const c_void, _n: abi::OwnedBuf, _p: abi::OwnedBuf) -> abi::AbiStatus { + abi::AbiStatus::Ok +} +extern "C" fn log(_c: *const c_void, _level: i32, _msg: abi::BorrowedBuf) {} +extern "C" fn state_get(_c: *const c_void) -> abi::OwnedBuf { + abi::OwnedBuf::empty() +} +extern "C" fn state_set(_c: *const c_void, state: abi::OwnedBuf) -> abi::AbiStatus { + unsafe { + state.free_self(); + } + abi::AbiStatus::Ok +} +extern "C" fn actor_identity(_c: *const c_void) -> abi::OwnedBuf { + abi::OwnedBuf::empty() +} +extern "C" fn state_save( + _c: *const c_void, + state: abi::OwnedBuf, + done: abi::CompletionFn, + ud: *mut c_void, +) { + unsafe { + state.free_self(); + } + done(ud, abi::AbiResult::ok(abi::OwnedBuf::empty())); +} +extern "C" fn request_save( + _c: *const c_void, + _immediate: u8, + _has_max_wait: u8, + _max_wait_ms: u32, +) -> abi::AbiStatus { + abi::AbiStatus::Ok +} +extern "C" fn request_save_and_wait( + _c: *const c_void, + _immediate: u8, + _has_max_wait: u8, + _max_wait_ms: u32, + done: abi::CompletionFn, + ud: *mut c_void, +) { + done(ud, abi::AbiResult::ok(abi::OwnedBuf::empty())); +} +extern "C" fn sleep(_c: *const c_void) -> abi::AbiResult { + abi::AbiResult::status_only(abi::AbiStatus::Cancelled) +} +extern "C" fn actor_aborted(_c: *const c_void) -> u8 { + 0 +} +extern "C" fn wait_actor_abort(_c: *const c_void, done: abi::CompletionFn, ud: *mut c_void) { + done(ud, abi::AbiResult::ok(abi::OwnedBuf::empty())); +} +extern "C" fn keep_awake_enter(_c: *const c_void) -> abi::AbiResult { + abi::AbiResult::status_only(abi::AbiStatus::Cancelled) +} +extern "C" fn keep_awake_exit(_c: *const c_void, _token: u64) -> abi::AbiStatus { + abi::AbiStatus::Ok +} +extern "C" fn keep_awake_count(_c: *const c_void) -> u64 { + 0 +} +extern "C" fn async_unavailable( + _c: *const c_void, + request: abi::OwnedBuf, + done: abi::CompletionFn, + ud: *mut c_void, +) { + unsafe { + request.free_self(); + } + done( + ud, + abi::AbiResult::err(abi::OwnedBuf::from_vec( + b"not available in persistence test".to_vec(), + )), + ); +} +extern "C" fn hibernatable_ws_ack( + _c: *const c_void, + gateway_id: abi::OwnedBuf, + request_id: abi::OwnedBuf, + _server_message_index: u16, +) -> abi::AbiResult { + unsafe { + gateway_id.free_self(); + request_id.free_self(); + } + abi::AbiResult::status_only(abi::AbiStatus::Cancelled) +} +extern "C" fn conn_send(_c: *const c_void, request: abi::OwnedBuf) -> abi::AbiResult { + unsafe { + request.free_self(); + } + abi::AbiResult::status_only(abi::AbiStatus::Cancelled) +} + +fn host_of<'a>(ctx: *const c_void) -> &'a MockHost { + unsafe { &*(ctx as *const MockHost) } +} + +/// CBOR `[v1, v2, ...]` (the plugin's `cbor_params`) → rusqlite bind values. +fn decode_params(bytes: &[u8]) -> Vec { + if bytes.is_empty() { + return Vec::new(); + } + let value: JsonValue = ciborium::from_reader(Cursor::new(bytes)).expect("decode params cbor"); + let JsonValue::Array(items) = value else { + return Vec::new(); + }; + items + .into_iter() + .map(|v| match v { + JsonValue::Null => SqlValue::Null, + JsonValue::Bool(b) => SqlValue::Integer(b as i64), + JsonValue::String(s) => SqlValue::Text(s), + JsonValue::Number(n) => { + if let Some(i) = n.as_i64() { + SqlValue::Integer(i) + } else { + SqlValue::Real(n.as_f64().unwrap_or(0.0)) + } + } + other => SqlValue::Text(other.to_string()), + }) + .collect() +} + +fn sql_to_json(v: SqlValue) -> JsonValue { + match v { + SqlValue::Null => JsonValue::Null, + SqlValue::Integer(i) => json!(i), + SqlValue::Real(f) => json!(f), + SqlValue::Text(s) => JsonValue::String(s), + SqlValue::Blob(b) => JsonValue::String(BASE64.encode(b)), + } +} + +extern "C" fn db_exec( + ctx: *const c_void, + sql: abi::OwnedBuf, + done: abi::CompletionFn, + ud: *mut c_void, +) { + let host = host_of(ctx); + let sql = String::from_utf8(unsafe { sql.into_vec() }).expect("utf8 sql"); + let conn = host.conn.lock().unwrap(); + match conn.execute_batch(&sql) { + Ok(()) => done(ud, abi::AbiResult::ok(abi::OwnedBuf::from_vec(Vec::new()))), + Err(e) => done( + ud, + abi::AbiResult::err(abi::OwnedBuf::from_vec(e.to_string().into_bytes())), + ), + } +} + +fn run_query(host: &MockHost, sql: &str, params: Vec) -> rusqlite::Result> { + let conn = host.conn.lock().unwrap(); + let mut stmt = conn.prepare(sql)?; + let col_names: Vec = stmt.column_names().iter().map(|s| s.to_string()).collect(); + let ncols = col_names.len(); + let rows = stmt.query_map(rusqlite::params_from_iter(params), move |row| { + let mut obj = Map::new(); + for (i, name) in col_names.iter().enumerate().take(ncols) { + let v: SqlValue = row.get(i)?; + obj.insert(name.clone(), sql_to_json(v)); + } + Ok(JsonValue::Object(obj)) + })?; + let collected: Vec = rows.collect::>()?; + let mut buf = Vec::new(); + ciborium::into_writer(&JsonValue::Array(collected), &mut buf).expect("encode rows cbor"); + Ok(buf) +} + +extern "C" fn db_query( + ctx: *const c_void, + sql: abi::OwnedBuf, + params: abi::OwnedBuf, + done: abi::CompletionFn, + ud: *mut c_void, +) { + let host = host_of(ctx); + let sql = String::from_utf8(unsafe { sql.into_vec() }).expect("utf8 sql"); + let params = decode_params(&unsafe { params.into_vec() }); + match run_query(host, &sql, params) { + Ok(bytes) => done(ud, abi::AbiResult::ok(abi::OwnedBuf::from_vec(bytes))), + Err(e) => done( + ud, + abi::AbiResult::err(abi::OwnedBuf::from_vec(e.to_string().into_bytes())), + ), + } +} + +extern "C" fn db_run( + ctx: *const c_void, + sql: abi::OwnedBuf, + params: abi::OwnedBuf, + done: abi::CompletionFn, + ud: *mut c_void, +) { + let host = host_of(ctx); + let sql = String::from_utf8(unsafe { sql.into_vec() }).expect("utf8 sql"); + let params = decode_params(&unsafe { params.into_vec() }); + let conn = host.conn.lock().unwrap(); + match conn.execute(&sql, rusqlite::params_from_iter(params)) { + Ok(_) => done(ud, abi::AbiResult::ok(abi::OwnedBuf::from_vec(Vec::new()))), + Err(e) => done( + ud, + abi::AbiResult::err(abi::OwnedBuf::from_vec(e.to_string().into_bytes())), + ), + } +} + +fn mock_host_ctx(host: &MockHost) -> HostCtx { + let vtable = abi::HostVtable { + abi_version: abi::RIVET_ACTOR_ABI_VERSION, + ctx: host as *const MockHost as *const c_void, + ctx_clone, + ctx_release, + db_exec, + db_query, + db_run, + sql_is_enabled, + state_get, + state_set, + actor_identity, + state_save, + request_save, + request_save_and_wait, + sleep, + actor_aborted, + wait_actor_abort, + keep_awake_enter, + keep_awake_exit, + keep_awake_count, + kv_get: async_unavailable, + kv_put: async_unavailable, + kv_delete: async_unavailable, + kv_batch_get: async_unavailable, + kv_batch_put: async_unavailable, + kv_batch_delete: async_unavailable, + kv_delete_range: async_unavailable, + kv_list_prefix: async_unavailable, + kv_list_range: async_unavailable, + schedule_after: async_unavailable, + schedule_at: async_unavailable, + set_alarm: async_unavailable, + scheduled_events: async_unavailable, + conn_list: async_unavailable, + conn_disconnect: async_unavailable, + hibernatable_ws_ack, + conn_send, + next_event, + reply_ok, + reply_err, + startup_ready, + broadcast, + log, + }; + HostCtx::from_vtable(vtable) +} + +#[tokio::test] +async fn persistence_round_trips_fs_ops_against_real_sqlite() { + let host_state = MockHost { + conn: Mutex::new(Connection::open_in_memory().expect("open sqlite")), + refs: AtomicIsize::new(1), + }; + + { + let host = mock_host_ctx(&host_state); + assert_eq!(host_state.refs.load(Ordering::SeqCst), 2); + + // Schema migration (real MIGRATION_SQL through db_exec). + persistence::migrate(&host).await.expect("migrate"); + + let path = "/work/hello.txt"; + let content = b"hello durable world"; + let content_b64 = BASE64.encode(content); + + // mkdir -p /work, then writeFile. + persistence::handle_fs_call( + &host, + "mkdir", + &json!({ "path": "/work", "recursive": true }), + ) + .await + .expect("mkdir"); + persistence::handle_fs_call( + &host, + "writeFile", + &json!({ "path": path, "content": content_b64 }), + ) + .await + .expect("writeFile"); + + // exists → true + let exists = persistence::handle_fs_call(&host, "exists", &json!({ "path": path })) + .await + .expect("exists"); + assert_eq!(exists, Some(JsonValue::Bool(true)), "file should exist"); + + // readFile → the same base64 content (round-trip through SQLite). + let read = persistence::handle_fs_call(&host, "readFile", &json!({ "path": path })) + .await + .expect("readFile"); + assert_eq!( + read, + Some(JsonValue::String(content_b64.clone())), + "readFile must return the stored content" + ); + + // stat → object reporting the decoded byte length. + let stat = persistence::handle_fs_call(&host, "stat", &json!({ "path": path })) + .await + .expect("stat") + .expect("stat returns an object"); + assert_eq!( + stat.get("size").and_then(JsonValue::as_i64), + Some(content.len() as i64), + "stat size must equal the decoded content length" + ); + + // readDir of /work lists the file. + let entries = persistence::handle_fs_call(&host, "readDir", &json!({ "path": "/work" })) + .await + .expect("readDir") + .expect("readDir array"); + let names: Vec<&str> = entries + .as_array() + .unwrap() + .iter() + .filter_map(JsonValue::as_str) + .collect(); + assert!( + names.contains(&"hello.txt"), + "readDir should list hello.txt, got {names:?}" + ); + + // removeFile → exists is now false. + persistence::handle_fs_call(&host, "removeFile", &json!({ "path": path })) + .await + .expect("removeFile"); + let exists_after = persistence::handle_fs_call(&host, "exists", &json!({ "path": path })) + .await + .expect("exists after remove"); + assert_eq!( + exists_after, + Some(JsonValue::Bool(false)), + "file should be gone after removeFile" + ); + } + + assert_eq!(host_state.refs.load(Ordering::SeqCst), 1); +} diff --git a/crates/agentos-actor-plugin/src/vm.rs b/crates/agentos-actor-plugin/src/vm.rs new file mode 100644 index 000000000..94bc1bcf5 --- /dev/null +++ b/crates/agentos-actor-plugin/src/vm.rs @@ -0,0 +1,104 @@ +//! VM lifecycle + durable-storage bridge — ported from `rivetkit-agent-os`'s +//! `run.rs`/`persistence.rs`, with `HostCtx` substituted for rivetkit's `Ctx`. +//! +//! `ensure_vm` brings up the `AgentOs` VM lazily with a `js_bridge` root whose +//! filesystem callback routes guest fs ops to actor durable storage via +//! `HostCtx.db_*` (spec §6.3 hot path). `agent-os-client` is imported unmodified. + +use std::sync::Arc; + +use agentos_client::{ + AgentOs, AgentOsConfig, MountPlugin, RootFilesystemConfig, RootFilesystemKind, + SidecarJsBridgeCall, SidecarJsBridgeCallback, +}; +use serde_json::{json, Value}; + +use crate::config::AgentOsConfigJson; +use crate::host_ctx::HostCtx; + +/// Build the `AgentOsConfig` from the client-supplied `config` and overlay the +/// actor-DB `js_bridge` root + the durable-storage callback bound to `host`. +/// +/// Mirrors r6's `run::configure_actor_db_root`: the actor-DB root and the +/// storage callback are overlaid **only** when the client did not configure +/// them, so an explicit client root/callback is honored. `pool` is the +/// per-plugin-runtime sidecar pool (spec §7). +pub(crate) fn build_config( + sidecar_path: &str, + host: HostCtx, + config: &AgentOsConfigJson, + pool: &str, +) -> AgentOsConfig { + let mut options = config.to_agent_os_config(pool); + + // Overlay the actor-DB callback root only when the client left it default. + if options.root_filesystem == RootFilesystemConfig::default() { + options.root_filesystem = RootFilesystemConfig { + kind: RootFilesystemKind::Native, + native_plugin: Some(MountPlugin { + id: "js_bridge".to_owned(), + config: Some(json!({ + "mountId": "agentos-actor-root", + })), + }), + ..RootFilesystemConfig::default() + }; + } + + // Overlay the durable-storage callback only when none was configured. + if options.sidecar_js_bridge_callback.is_none() { + let storage_host = host; + let callback: SidecarJsBridgeCallback = Arc::new(move |call: SidecarJsBridgeCall| { + let host = storage_host.clone(); + Box::pin(async move { handle_storage(&host, call).await }) + }); + options.sidecar_js_bridge_callback = Some(callback); + } + + options.sidecar_binary_path = Some(sidecar_path.to_owned()); + options +} + +/// Bring up the VM if not already running; broadcast `vmBooted` on success. +pub(crate) async fn ensure_vm( + host: &HostCtx, + sidecar_path: &str, + config: &AgentOsConfigJson, + pool: &str, + vm: &mut Option, +) -> Result<(), String> { + if vm.is_some() { + return Ok(()); + } + let config = build_config(sidecar_path, host.clone(), config, pool); + let handle = AgentOs::create(config) + .await + .map_err(|error| format!("agent-os vm bring-up failed: {error}"))?; + *vm = Some(handle); + let _ = host.broadcast(b"vmBooted".to_vec(), b"{}".to_vec()); + Ok(()) +} + +/// Tear down the VM if running; broadcast `vmShutdown` afterward. +pub(crate) async fn shutdown_vm(host: &HostCtx, vm: &mut Option, reason: &str) { + let Some(handle) = vm.take() else { + return; + }; + if let Err(error) = handle.shutdown().await { + host.log_warn(&format!("agent-os vm shutdown error ({reason}): {error}")); + } + let payload = format!("{{\"reason\":\"{reason}\"}}"); + let _ = host.broadcast(b"vmShutdown".to_vec(), payload.into_bytes()); +} + +/// Durable-storage callback: routes a sidecar fs op to actor SQLite via +/// `HostCtx.db_*`. This exercises the storage bridge end-to-end; the full +/// op set (~24 ops in `persistence.rs`) is ported on top of this. +async fn handle_storage( + host: &HostCtx, + call: SidecarJsBridgeCall, +) -> Result, String> { + crate::persistence::handle_fs_call(host, &call.operation, &call.args) + .await + .map_err(|error| error.to_string()) +} diff --git a/crates/agent-os-protocol/Cargo.toml b/crates/agentos-protocol/Cargo.toml similarity index 92% rename from crates/agent-os-protocol/Cargo.toml rename to crates/agentos-protocol/Cargo.toml index d9745d427..cc4d71289 100644 --- a/crates/agent-os-protocol/Cargo.toml +++ b/crates/agentos-protocol/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "agent-os-protocol" +name = "agentos-protocol" version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/agent-os-protocol/build.rs b/crates/agentos-protocol/build.rs similarity index 100% rename from crates/agent-os-protocol/build.rs rename to crates/agentos-protocol/build.rs diff --git a/crates/agent-os-protocol/protocol/agent_os_acp_v1.bare b/crates/agentos-protocol/protocol/agent_os_acp_v1.bare similarity index 66% rename from crates/agent-os-protocol/protocol/agent_os_acp_v1.bare rename to crates/agentos-protocol/protocol/agent_os_acp_v1.bare index 8c45924d0..72976c98c 100644 --- a/crates/agent-os-protocol/protocol/agent_os_acp_v1.bare +++ b/crates/agentos-protocol/protocol/agent_os_acp_v1.bare @@ -37,11 +37,26 @@ type AcpCloseSessionRequest struct { sessionId: str } +# Resume a session that exists in durable storage but is not live in the current +# VM (e.g. after a Rivet actor slept and woke with a fresh VM). The sidecar runs +# the stateless resume state machine (native session/load when the agent supports +# it, else a fresh session/new + transcript continuation preamble). `cwd`/`env` +# describe the fresh adapter launch used by the fallback tier. `transcriptPath`, +# when present, is a guest-readable path the fallback preamble points the agent at. +type AcpResumeSessionRequest struct { + sessionId: str + agentType: str + transcriptPath: optional + cwd: str + env: map +} + type AcpRequest union { AcpCreateSessionRequest | AcpSessionRequest | AcpGetSessionStateRequest | - AcpCloseSessionRequest + AcpCloseSessionRequest | + AcpResumeSessionRequest } type AcpSessionCreatedResponse struct { @@ -75,6 +90,16 @@ type AcpSessionClosedResponse struct { sessionId: str } +# Result of AcpResumeSessionRequest. `sessionId` is the live ACP session id after +# resume: equal to the requested id for native loads, or the freshly assigned id +# for the fallback tier (the caller remaps external -> live). `mode` is "native" +# (session/load|resume succeeded) or "fallback" (a new session was created and the +# transcript-continuation preamble was armed for the next prompt). +type AcpSessionResumedResponse struct { + sessionId: str + mode: str +} + type AcpErrorResponse struct { code: str message: str @@ -85,6 +110,7 @@ type AcpResponse union { AcpSessionRpcResponse | AcpSessionStateResponse | AcpSessionClosedResponse | + AcpSessionResumedResponse | AcpErrorResponse } diff --git a/crates/agent-os-protocol/src/generated.rs b/crates/agentos-protocol/src/generated.rs similarity index 100% rename from crates/agent-os-protocol/src/generated.rs rename to crates/agentos-protocol/src/generated.rs diff --git a/crates/agent-os-protocol/src/lib.rs b/crates/agentos-protocol/src/lib.rs similarity index 100% rename from crates/agent-os-protocol/src/lib.rs rename to crates/agentos-protocol/src/lib.rs diff --git a/crates/agent-os-protocol/tests/roundtrip.rs b/crates/agentos-protocol/tests/roundtrip.rs similarity index 97% rename from crates/agent-os-protocol/tests/roundtrip.rs rename to crates/agentos-protocol/tests/roundtrip.rs index 628b8f232..e6980e058 100644 --- a/crates/agent-os-protocol/tests/roundtrip.rs +++ b/crates/agentos-protocol/tests/roundtrip.rs @@ -1,4 +1,4 @@ -use agent_os_protocol::generated::v1::{ +use agentos_protocol::generated::v1::{ AcpCreateSessionRequest, AcpRequest, AcpResponse, AcpRuntimeKind, AcpSessionCreatedResponse, }; diff --git a/crates/agent-os-sidecar-browser/Cargo.toml b/crates/agentos-sidecar-browser/Cargo.toml similarity index 70% rename from crates/agent-os-sidecar-browser/Cargo.toml rename to crates/agentos-sidecar-browser/Cargo.toml index a5c196173..202b80af9 100644 --- a/crates/agent-os-sidecar-browser/Cargo.toml +++ b/crates/agentos-sidecar-browser/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "agent-os-sidecar-browser" +name = "agentos-sidecar-browser" version.workspace = true edition.workspace = true license.workspace = true @@ -7,7 +7,7 @@ repository.workspace = true description = "Browser Agent OS sidecar wrapper" [dependencies] -agent-os-protocol = { workspace = true } -agent-os-sidecar = { workspace = true } +agentos-protocol = { workspace = true } +agentos-sidecar = { workspace = true } secure-exec-bridge = { workspace = true } secure-exec-sidecar-browser = { workspace = true } diff --git a/crates/agent-os-sidecar-browser/src/lib.rs b/crates/agentos-sidecar-browser/src/lib.rs similarity index 97% rename from crates/agent-os-sidecar-browser/src/lib.rs rename to crates/agentos-sidecar-browser/src/lib.rs index 32181db65..5dc3e663e 100644 --- a/crates/agent-os-sidecar-browser/src/lib.rs +++ b/crates/agentos-sidecar-browser/src/lib.rs @@ -2,7 +2,7 @@ //! Agent OS browser sidecar wrapper. -use agent_os_sidecar_wrapper::AcpExtension; +use agentos_sidecar_wrapper::AcpExtension; use secure_exec_sidecar_browser::{ BrowserExtension, BrowserExtensionContext, BrowserSidecar, BrowserSidecarBridge, BrowserSidecarConfig, BrowserSidecarError, @@ -28,7 +28,7 @@ impl Default for BrowserAcpExtension { impl BrowserExtension for BrowserAcpExtension { fn namespace(&self) -> &str { - agent_os_protocol::ACP_EXTENSION_NAMESPACE + agentos_protocol::ACP_EXTENSION_NAMESPACE } fn handle_request( @@ -62,7 +62,7 @@ where #[cfg(test)] mod tests { use super::*; - use agent_os_protocol::ACP_EXTENSION_NAMESPACE; + use agentos_protocol::ACP_EXTENSION_NAMESPACE; #[test] fn browser_extensions_register_acp_namespace() { diff --git a/crates/agent-os-sidecar/AGENTS.md b/crates/agentos-sidecar/AGENTS.md similarity index 100% rename from crates/agent-os-sidecar/AGENTS.md rename to crates/agentos-sidecar/AGENTS.md diff --git a/crates/agent-os-sidecar/CLAUDE.md b/crates/agentos-sidecar/CLAUDE.md similarity index 100% rename from crates/agent-os-sidecar/CLAUDE.md rename to crates/agentos-sidecar/CLAUDE.md diff --git a/crates/agent-os-sidecar/Cargo.toml b/crates/agentos-sidecar/Cargo.toml similarity index 80% rename from crates/agent-os-sidecar/Cargo.toml rename to crates/agentos-sidecar/Cargo.toml index 60570bfb8..b7b94865e 100644 --- a/crates/agent-os-sidecar/Cargo.toml +++ b/crates/agentos-sidecar/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "agent-os-sidecar" +name = "agentos-sidecar" version.workspace = true edition.workspace = true license.workspace = true @@ -7,14 +7,14 @@ repository.workspace = true description = "Native Agent OS sidecar binary" [lib] -name = "agent_os_sidecar_wrapper" +name = "agentos_sidecar_wrapper" [[bin]] -name = "agent-os-sidecar" +name = "agentos-sidecar" path = "src/main.rs" [dependencies] -agent-os-protocol = { workspace = true } +agentos-protocol = { workspace = true } serde_json = "1.0" serde_bare = "0.5" secure-exec-sidecar = { workspace = true } diff --git a/crates/agent-os-sidecar/src/acp_extension.rs b/crates/agentos-sidecar/src/acp_extension.rs similarity index 71% rename from crates/agent-os-sidecar/src/acp_extension.rs rename to crates/agentos-sidecar/src/acp_extension.rs index a0c3b08c3..0791659a9 100644 --- a/crates/agent-os-sidecar/src/acp_extension.rs +++ b/crates/agentos-sidecar/src/acp_extension.rs @@ -1,14 +1,17 @@ use std::collections::{BTreeMap, HashMap}; +use std::fs::OpenOptions; +use std::io::Write; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{Duration, Instant}; -use agent_os_protocol::generated::v1::{ +use agentos_protocol::generated::v1::{ AcpCallback, AcpCallbackResponse, AcpCloseSessionRequest, AcpCreateSessionRequest, AcpErrorResponse, AcpEvent, AcpGetSessionStateRequest, AcpHostRequestCallback, - AcpPermissionCallback, AcpRequest, AcpResponse, AcpRuntimeKind, AcpSessionClosedResponse, - AcpSessionCreatedResponse, AcpSessionEvent, AcpSessionRequest, AcpSessionStateResponse, + AcpPermissionCallback, AcpRequest, AcpResponse, AcpResumeSessionRequest, AcpRuntimeKind, + AcpSessionClosedResponse, AcpSessionCreatedResponse, AcpSessionEvent, AcpSessionRequest, + AcpSessionResumedResponse, AcpSessionStateResponse, }; -use agent_os_protocol::ACP_EXTENSION_NAMESPACE; +use agentos_protocol::ACP_EXTENSION_NAMESPACE; use secure_exec_sidecar::limits::DEFAULT_ACP_MAX_READ_LINE_BYTES; use secure_exec_sidecar::wire::{ CloseStdinRequest, EventPayload, ExecuteRequest, GuestFilesystemCallRequest, @@ -26,6 +29,25 @@ const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10); const SESSION_NEW_TIMEOUT: Duration = Duration::from_secs(30); const SESSION_CLOSE_TIMEOUT: Duration = Duration::from_secs(5); const ACP_CANCEL_METHOD: &str = "session/cancel"; +/// Transcript-continuation preamble prepended (once) to the first prompt after a +/// fallback resume. Lossy-but-universal floor: the agent is handed a *pointer* to +/// the rendered transcript and reads it on demand with its own file tools. `{path}` +/// is substituted with the guest-readable transcript path. Tunable; see spec §6. +const CONTINUATION_PREAMBLE: &str = "You are continuing an earlier session. The full prior transcript is at `{path}`. Read it with your file tools if you need context before answering."; +/// Reserved `env` key on `AcpResumeSessionRequest` carrying the adapter bin +/// entrypoint. The resume wire request intentionally omits a dedicated +/// `adapterEntrypoint` field; the thin client resolves it exactly as it does for +/// create and forwards it through `env` under this key so the sidecar still owns +/// the launch. Stripped before the adapter process env is assembled. +const RESUME_ADAPTER_ENTRYPOINT_ENV: &str = "AGENT_OS_RESUME_ADAPTER_ENTRYPOINT"; +const ACP_TRACE_PATH_ENV: &str = "AGENT_OS_ACP_TRACE_PATH"; +/// ACP protocol version used for the resume handshake. Lockstep single version. +const ACP_RESUME_PROTOCOL_VERSION: i32 = 1; +/// Client capabilities advertised during the resume `initialize`. Mirrors the +/// client's `defaultAcpClientCapabilities()` so resumed sessions behave like +/// freshly created ones. +const DEFAULT_RESUME_CLIENT_CAPABILITIES: &str = + "{\"fs\":{\"readTextFile\":true,\"writeTextFile\":true},\"terminal\":true}"; const OPENCODE_SYSTEM_PROMPT_PATH: &str = "/tmp/agentos-system-prompt.md"; const OPENCODE_DEFAULT_CONTEXT_PATHS: [&str; 11] = [ ".github/copilot-instructions.md", @@ -69,6 +91,12 @@ struct AcpSessionRecord { next_request_id: i64, closed: bool, exit_code: Option, + /// Set by the resume fallback tier (`session/new` instead of native + /// `session/load`). The transcript-continuation preamble is prepended, once, + /// as a leading text content block on this session's next `session/prompt`, + /// then cleared. See `CONTINUATION_PREAMBLE` and the resume state machine on + /// `AcpExtension::resume_session`. + pending_preamble: Option, } impl AcpExtension { @@ -91,6 +119,7 @@ impl AcpExtension { AcpHandlerOutput::response(self.close_session(ctx, request).await) } AcpRequest::AcpSessionRequest(request) => self.session_request(ctx, request).await, + AcpRequest::AcpResumeSessionRequest(request) => self.resume_session(ctx, request).await, }; let payload = encode_response(response.response.unwrap_or_else(error_response))?; ExtensionResponse::with_wire_events(payload, response.events) @@ -136,12 +165,7 @@ impl AcpExtension { .create_session_inner(&mut ctx, &request, &process_id) .await; if bootstrap.is_err() { - let _ = ctx - .kill_process_wire(KillProcessRequest { - process_id: process_id.clone(), - signal: String::from("SIGTERM"), - }) - .await; + kill_process_best_effort(&mut ctx, &process_id).await; } let bootstrap = match bootstrap { Ok(bootstrap) => bootstrap, @@ -162,17 +186,8 @@ impl AcpExtension { next_request_id: 3, closed: false, exit_code: None, + pending_preamble: None, }; - if let Err(error) = ctx - .bind_process_to_session(&session.session_id, &process_id) - .await - { - return AcpHandlerOutput::response(Err(error)); - } - self.sessions - .lock() - .await - .insert(session.session_id.clone(), session.clone()); let mut events = Vec::new(); for notification in bootstrap.notifications { @@ -181,14 +196,32 @@ impl AcpExtension { notification, })) { Ok(event) => event, - Err(error) => return AcpHandlerOutput::response(Err(error)), + Err(error) => { + kill_process_best_effort(&mut ctx, &process_id).await; + return AcpHandlerOutput::response(Err(error)); + } }; match ctx.ext_event_wire(event) { Ok(event) => events.push(event), - Err(error) => return AcpHandlerOutput::response(Err(error)), + Err(error) => { + kill_process_best_effort(&mut ctx, &process_id).await; + return AcpHandlerOutput::response(Err(error)); + } } } + if let Err(error) = ctx + .bind_process_to_session(&session.session_id, &process_id) + .await + { + kill_process_best_effort(&mut ctx, &process_id).await; + return AcpHandlerOutput::response(Err(error)); + } + self.sessions + .lock() + .await + .insert(session.session_id.clone(), session.clone()); + AcpHandlerOutput { response: Ok(AcpResponse::AcpSessionCreatedResponse( session.created_response(), @@ -446,7 +479,7 @@ impl AcpExtension { ); let caller_connection_id = ownership_connection_id(ctx.ownership()); - let (process_id, rpc_id, mut stdout_buffer) = { + let (process_id, rpc_id, mut stdout_buffer, pending_preamble) = { let mut sessions = self.sessions.lock().await; let Some(session) = sessions.get_mut(&request.session_id) else { return AcpHandlerOutput::response(Err(SidecarError::InvalidState(format!( @@ -468,12 +501,24 @@ impl AcpExtension { } let rpc_id = session.next_request_id; session.next_request_id += 1; + // Take (and clear) any armed transcript-continuation preamble. It is + // consumed once, on this session's first `session/prompt` after a + // fallback resume; non-prompt methods leave it untouched. + let pending_preamble = if request.method == "session/prompt" { + session.pending_preamble.take() + } else { + None + }; ( session.process_id.clone(), rpc_id, std::mem::take(&mut session.stdout_buffer), + pending_preamble, ) }; + if let Some(preamble) = pending_preamble.as_deref() { + prepend_prompt_preamble(&mut outbound_params, preamble); + } let method = request.method.clone(); let timeout = request_timeout(&method); let outbound = json!({ @@ -494,7 +539,16 @@ impl AcpExtension { .await { Ok(exchange) => exchange, - Err(error) => return AcpHandlerOutput::response(Err(error)), + Err(error) => { + if let Some(preamble) = pending_preamble { + if let Some(session) = self.sessions.lock().await.get_mut(&request.session_id) { + if session.pending_preamble.is_none() { + session.pending_preamble = Some(preamble); + } + } + } + return AcpHandlerOutput::response(Err(error)); + } }; if let Some(session) = self.sessions.lock().await.get_mut(&request.session_id) { @@ -548,7 +602,7 @@ impl AcpExtension { AcpHandlerOutput { response: Ok(AcpResponse::AcpSessionRpcResponse( - agent_os_protocol::generated::v1::AcpSessionRpcResponse { + agentos_protocol::generated::v1::AcpSessionRpcResponse { session_id: request.session_id, response: match serde_json::to_string(&exchange.response) { Ok(response) => response, @@ -564,6 +618,333 @@ impl AcpExtension { } } + // ----------------------------------------------------------------------- + // Resume state machine (spec §6 / §8) — CANONICAL doc comment. + // + // `resume_session` is the *stateless* orchestration that re-attaches a session + // which exists in the actor's durable storage but is not live in this VM + // (e.g. after a Rivet actor slept and woke with a fresh VM). The actor is the + // lazy-resume trigger: a prompt arrives for an `external_session_id` that is + // known to `agent_os_sessions` but absent from `Vars.live_sessions`, so the + // actor reconstructs the transcript, calls the client `resume_session`, then + // remaps `external -> live` and forwards the (preamble-prefixed) prompt. + // + // This handler holds NO event state and NO durable remap: it only knows live + // ids for the current VM lifetime, which keeps the "ACP session events are + // live-only" invariant intact (no event buffer / cursor replay is added). + // + // resume(sessionId, agentType, transcriptPath?, cwd, env): + // # Launch a fresh adapter and probe its real capabilities via `initialize` + // # (capabilities cannot be trusted across a wake; we re-probe here). + // caps = initialize(agentType) # agentCapabilities from the adapter + // + // # Tier 1 — native (capability-gated optimization). + // if caps.loadSession || caps.resume: + // r = session/load (sessionId) # or session/resume + // ok -> return { sessionId, mode: "native" } + // UNKNOWN_SESSION -> fall through # store didn't survive the wake + // other error -> propagate + // + // # Tier 2 — universal fallback (no adapter code, no capability needed). + // live = session/new(agentType, cwd, env) + // if transcriptPath present: + // arm CONTINUATION_PREAMBLE(transcriptPath) on `live`'s next prompt + // return { sessionId: live, mode: "fallback" } # caller remaps external->live + // + // The `UNKNOWN_SESSION` discriminator is a JSON-RPC error with + // `error.data.kind === "unknown_session"`, following the `acp_timeout` + // convention; only it triggers fallthrough. Transport/timeout errors propagate. + async fn resume_session( + &self, + mut ctx: ExtensionContext<'_>, + request: AcpResumeSessionRequest, + ) -> AcpHandlerOutput { + // Reconstruct a create-shaped request so we reuse the exact adapter launch + // + initialize flow. Resume does not carry MCP servers or extra instructions + // (the durable transcript, not re-injected instructions, carries context); + // skip the base OS instructions for the same reason — they were already + // delivered to the original session. + let create_like = AcpCreateSessionRequest { + agent_type: request.agent_type.clone(), + runtime: AcpRuntimeKind::JavaScript, + // The resume request does not carry the adapter entrypoint; the caller + // resolves it the same way create does and forwards it through `env` + // under the reserved key below. This keeps the resume wire request + // minimal while letting the sidecar own the launch. + adapter_entrypoint: match request.env.get(RESUME_ADAPTER_ENTRYPOINT_ENV) { + Some(entrypoint) => entrypoint.clone(), + None => { + return AcpHandlerOutput::response(Err(SidecarError::InvalidState(format!( + "resume request missing reserved env `{RESUME_ADAPTER_ENTRYPOINT_ENV}` (adapter entrypoint)" + )))); + } + }, + cwd: request.cwd.clone(), + args: Vec::new(), + env: { + let mut env = request.env.clone(); + env.remove(RESUME_ADAPTER_ENTRYPOINT_ENV); + env + }, + protocol_version: ACP_RESUME_PROTOCOL_VERSION, + client_capabilities: DEFAULT_RESUME_CLIENT_CAPABILITIES.to_string(), + mcp_servers: "[]".to_string(), + skip_os_instructions: true, + additional_instructions: None, + }; + + let process_id = self.allocate_process_id("acp-agent"); + let mut args = create_like.args.clone(); + let mut env = hash_to_btree(create_like.env.clone()); + env.insert( + String::from("SECURE_EXEC_KEEP_STDIN_OPEN"), + String::from("1"), + ); + if let Err(error) = self + .apply_prompt_injection(&mut ctx, &create_like, &mut args, &mut env) + .await + { + return AcpHandlerOutput::response(Err(error)); + } + + let started = match ctx + .spawn_process_wire(ExecuteRequest { + process_id: process_id.clone(), + command: None, + runtime: Some(convert_runtime(create_like.runtime.clone())), + entrypoint: Some(create_like.adapter_entrypoint.clone()), + args, + env: env.into_iter().collect(), + cwd: Some(create_like.cwd.clone()), + wasm_permission_tier: None, + }) + .await + { + Ok(started) => started, + Err(error) => return AcpHandlerOutput::response(Err(error)), + }; + + let outcome = self + .resume_session_inner(&mut ctx, &request, &create_like, &process_id) + .await; + if outcome.is_err() { + kill_process_best_effort(&mut ctx, &process_id).await; + } + let outcome = match outcome { + Ok(outcome) => outcome, + Err(error) => return AcpHandlerOutput::response(Err(error)), + }; + + let session = AcpSessionRecord { + session_id: outcome.bootstrap.session_id.clone(), + owner_connection_id: ownership_connection_id(ctx.ownership()), + agent_type: request.agent_type.clone(), + process_id: process_id.clone(), + pid: started.pid, + modes: outcome.bootstrap.modes, + config_options: outcome.bootstrap.config_options, + agent_capabilities: outcome.bootstrap.agent_capabilities, + agent_info: outcome.bootstrap.agent_info, + stdout_buffer: outcome.bootstrap.stdout_buffer, + next_request_id: outcome.next_request_id, + closed: false, + exit_code: None, + // Fallback arms the transcript-continuation preamble for the first prompt. + pending_preamble: outcome.pending_preamble, + }; + + let mut events = Vec::new(); + for notification in outcome.bootstrap.notifications { + let event = match encode_event(AcpEvent::AcpSessionEvent(AcpSessionEvent { + session_id: session.session_id.clone(), + notification, + })) { + Ok(event) => event, + Err(error) => { + kill_process_best_effort(&mut ctx, &process_id).await; + return AcpHandlerOutput::response(Err(error)); + } + }; + match ctx.ext_event_wire(event) { + Ok(event) => events.push(event), + Err(error) => { + kill_process_best_effort(&mut ctx, &process_id).await; + return AcpHandlerOutput::response(Err(error)); + } + } + } + + if let Err(error) = ctx + .bind_process_to_session(&session.session_id, &process_id) + .await + { + kill_process_best_effort(&mut ctx, &process_id).await; + return AcpHandlerOutput::response(Err(error)); + } + self.sessions + .lock() + .await + .insert(session.session_id.clone(), session.clone()); + + AcpHandlerOutput { + response: Ok(AcpResponse::AcpSessionResumedResponse( + AcpSessionResumedResponse { + session_id: session.session_id, + mode: outcome.mode, + }, + )), + events, + } + } + + /// Drive the resume handshake: `initialize`, then native `session/load` (when + /// the adapter advertises it) or the `session/new` fallback. Returns the + /// bootstrap state plus the chosen `mode` and any armed preamble. + async fn resume_session_inner( + &self, + ctx: &mut ExtensionContext<'_>, + request: &AcpResumeSessionRequest, + create_like: &AcpCreateSessionRequest, + process_id: &str, + ) -> Result { + let mut stdout = String::new(); + let mut notifications = Vec::new(); + let client_capabilities = + parse_json_text(&create_like.client_capabilities, "clientCapabilities")?; + + let initialize = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": create_like.protocol_version, + "clientCapabilities": client_capabilities, + }, + }); + let initialize_response = send_json_rpc_request( + ctx, + process_id, + initialize, + 1, + INITIALIZE_TIMEOUT, + &mut stdout, + None, + ) + .await?; + notifications.extend(initialize_response.notifications); + let init_result = response_result(initialize_response.response, "ACP initialize")?; + validate_initialize_result(&init_result, create_like.protocol_version)?; + + let agent_capabilities = init_result.get("agentCapabilities").cloned(); + + // Tier 1 — native (capability-gated). Re-probed caps decide eligibility. + if let Some(native_resume_method) = native_resume_method(agent_capabilities.as_ref()) { + let load = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": native_resume_method, + "params": { + "sessionId": request.session_id, + "cwd": request.cwd, + "mcpServers": [], + }, + }); + let mut load_response = send_json_rpc_request( + ctx, + process_id, + load, + 2, + SESSION_NEW_TIMEOUT, + &mut stdout, + None, + ) + .await?; + notifications.extend(load_response.notifications); + trace_acp_response(native_resume_method, &load_response.response); + normalize_unknown_session_error(&mut load_response.response); + + if load_response.response.get("error").is_none() { + let load_result = response_result( + load_response.response, + &format!("ACP {native_resume_method}"), + )?; + let bootstrap = build_resume_bootstrap( + request.session_id.clone(), + &init_result, + &load_result, + &request.agent_type, + agent_capabilities.as_ref(), + stdout, + notifications, + )?; + return Ok(ResumeOutcome { + bootstrap, + mode: String::from("native"), + next_request_id: 3, + pending_preamble: None, + }); + } + + // Native load failed. Only the `unknown_session` sentinel falls through + // to the universal fallback; every other error propagates (surfaced + // verbatim via `response_result`, which returns Err when `error` is set). + if !is_unknown_session_error(&load_response.response) { + return Err(response_result( + load_response.response, + &format!("ACP {native_resume_method}"), + ) + .expect_err("native resume error object must map to a SidecarError")); + } + // fall through to Tier 2 + } + + // Tier 2 — universal fallback. A fresh session, plus the transcript pointer. + let session_new = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "session/new", + "params": { + "cwd": request.cwd, + "mcpServers": [], + }, + }); + let session_response = send_json_rpc_request( + ctx, + process_id, + session_new, + 2, + SESSION_NEW_TIMEOUT, + &mut stdout, + None, + ) + .await?; + notifications.extend(session_response.notifications); + let session_result = response_result(session_response.response, "ACP session/new")?; + let live_session_id = session_id_from_session_result(&session_result, process_id); + + let pending_preamble = request + .transcript_path + .as_deref() + .filter(|path| !path.is_empty()) + .map(|path| CONTINUATION_PREAMBLE.replace("{path}", path)); + + let bootstrap = build_resume_bootstrap( + live_session_id, + &init_result, + &session_result, + &request.agent_type, + agent_capabilities.as_ref(), + stdout, + notifications, + )?; + Ok(ResumeOutcome { + bootstrap, + mode: String::from("fallback"), + next_request_id: 3, + pending_preamble, + }) + } + fn allocate_process_id(&self, prefix: &str) -> String { let id = self.next_process_id.fetch_add(1, Ordering::Relaxed) + 1; format!("{prefix}-{id}") @@ -639,6 +1020,7 @@ impl Extension for AcpExtension { AcpRequest::AcpCreateSessionRequest(_) | AcpRequest::AcpGetSessionStateRequest(_) | AcpRequest::AcpCloseSessionRequest(_) + | AcpRequest::AcpResumeSessionRequest(_) | AcpRequest::AcpSessionRequest(_) => None, } } @@ -671,6 +1053,18 @@ struct CreateSessionBootstrap { notifications: Vec, } +/// Result of the resume state machine (`resume_session_inner`). +#[derive(Debug)] +struct ResumeOutcome { + bootstrap: CreateSessionBootstrap, + /// `"native"` (session/load|resume) or `"fallback"` (session/new + preamble). + mode: String, + /// First request id available for post-resume RPCs (initialize=1, load/new=2). + next_request_id: i64, + /// Transcript-continuation preamble armed for the first prompt (fallback only). + pending_preamble: Option, +} + impl AcpSessionRecord { fn created_response(&self) -> AcpSessionCreatedResponse { AcpSessionCreatedResponse { @@ -1052,7 +1446,7 @@ fn encode_interrupted_cancel_response(session_id: &str) -> Option> { fn encode_session_rpc_response(session_id: &str, response: Value) -> Option> { let response = AcpResponse::AcpSessionRpcResponse( - agent_os_protocol::generated::v1::AcpSessionRpcResponse { + agentos_protocol::generated::v1::AcpSessionRpcResponse { session_id: session_id.to_string(), response: serde_json::to_string(&response).ok()?, }, @@ -1506,6 +1900,158 @@ fn session_id_from_session_result(session_result: &Map, fallback: .unwrap_or_else(|| fallback.to_string()) } +/// Prepend the transcript-continuation preamble as a leading text content block +/// on a `session/prompt`'s `prompt` array. This is the fallback-tier mechanism +/// for handing the agent a pointer to the prior transcript: it rides in-band on +/// the user's first post-resume prompt (a single turn) rather than as a separate +/// RPC, so the agent sees one coherent prompt. A missing/non-array `prompt` is +/// initialized to a single-element array so the preamble is still delivered. +fn prepend_prompt_preamble(params: &mut Map, preamble: &str) { + let block = json!({ "type": "text", "text": preamble }); + match params.get_mut("prompt").and_then(Value::as_array_mut) { + Some(prompt) => prompt.insert(0, block), + None => { + params.insert(String::from("prompt"), Value::Array(vec![block])); + } + } +} + +async fn kill_process_best_effort(ctx: &mut ExtensionContext<'_>, process_id: &str) { + let _ = ctx + .kill_process_wire(KillProcessRequest { + process_id: process_id.to_owned(), + signal: String::from("SIGTERM"), + }) + .await; +} + +/// Return the adapter native-resume RPC method from re-probed +/// `agentCapabilities`. Prefer ACP `loadSession`/`session/load`; fall back to the +/// non-standard `resume`/`session/resume` capability some adapters expose. +fn native_resume_method(agent_capabilities: Option<&Value>) -> Option<&'static str> { + let Some(caps) = agent_capabilities.and_then(Value::as_object) else { + return None; + }; + if caps + .get("loadSession") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return Some("session/load"); + } + if caps.get("resume").and_then(Value::as_bool).unwrap_or(false) { + return Some("session/resume"); + } + None +} + +fn trace_acp_response(method: &str, response: &Value) { + // Test-only diagnostics for compatibility regressions: the OpenCode resume + // test captures the raw native-resume response before normalization so we + // notice upstream error-shape changes. The env var is sidecar-process + // trusted input, not guest-controlled runtime surface. + let Ok(path) = std::env::var(ACP_TRACE_PATH_ENV) else { + return; + }; + if path.is_empty() { + return; + } + let payload = json!({ + "method": method, + "response": response, + }); + let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) else { + return; + }; + let _ = writeln!(file, "{payload}"); +} + +/// Normalize adapter-specific "no such session" errors from `session/load` into +/// the shared `unknown_session` discriminator used by the resume state machine. +/// +/// OpenCode currently reports a missing session as JSON-RPC `-32603` with +/// `error.data.details == "NotFoundError"`: its ACP server converts thrown +/// non-`RequestError` exceptions into `internalError({ details: error.message })`, +/// and `Session.get` throws a `NotFoundError` whose message is the class name. +/// Convert exactly that shape into `error.data.kind = "unknown_session"` before +/// fallback matching. Do not broaden this to message substrings or all +/// `-32603`/`-32602` errors; malformed `session/load` must still propagate. +fn normalize_unknown_session_error(response: &mut Value) { + let Some(error) = response.get_mut("error").and_then(Value::as_object_mut) else { + return; + }; + let code = error.get("code").and_then(Value::as_i64); + let Some(data) = error.get_mut("data").and_then(Value::as_object_mut) else { + return; + }; + let details = data.get("details").and_then(Value::as_str); + if code == Some(-32603) && details == Some("NotFoundError") { + data.insert( + String::from("kind"), + Value::String(String::from("unknown_session")), + ); + } +} + +/// Detect a normalized adapter "no such session" error from `session/load` and +/// treat it as the `unknown_session` fallthrough sentinel (the durable store did +/// not survive the VM teardown). Only this triggers the Tier 2 fallback; +/// transport/timeout errors propagate. +/// +/// The matcher is intentionally strict: by the time it runs, adapter-specific +/// shapes must already be normalized by [`normalize_unknown_session_error`]. +/// This prevents a malformed load request or unrelated internal error from +/// silently resetting the user's context via a fresh fallback session. +fn is_unknown_session_error(response: &Value) -> bool { + response + .get("error") + .and_then(Value::as_object) + .and_then(|error| error.get("data")) + .and_then(Value::as_object) + .and_then(|d| d.get("kind")) + .and_then(Value::as_str) + .is_some_and(|kind| kind == "unknown_session") +} + +/// Build the post-resume bootstrap state from `initialize` + `session/load|new` +/// results. Mirrors the config-option / modes / capabilities derivation at the +/// tail of `create_session_inner` so a resumed session hydrates identically to a +/// freshly created one. +fn build_resume_bootstrap( + session_id: String, + init_result: &Map, + session_result: &Map, + agent_type: &str, + agent_capabilities: Option<&Value>, + stdout_buffer: String, + notifications: Vec, +) -> Result { + let mut config_options = init_result + .get("configOptions") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if let Some(overrides) = session_result + .get("configOptions") + .and_then(Value::as_array) + { + config_options = overrides.clone(); + } + if !config_options.iter().any(is_model_config_option) { + config_options.extend(derive_config_options(agent_type, session_result)); + } + + Ok(CreateSessionBootstrap { + session_id, + modes: json_field(session_result, init_result, "modes")?, + config_options: json_array_to_strings(config_options)?, + agent_capabilities: json_optional_string(agent_capabilities)?, + agent_info: json_optional_string(init_result.get("agentInfo"))?, + stdout_buffer, + notifications, + }) +} + fn append_stdout_chunk( buffer: &mut String, chunk: &[u8], @@ -1701,13 +2247,70 @@ fn error_code(error: &SidecarError) -> String { #[cfg(test)] mod tests { use super::*; - use agent_os_protocol::PROTOCOL_VERSION; + use agentos_protocol::PROTOCOL_VERSION; #[test] fn acp_extension_uses_agent_os_namespace() { assert_eq!(AcpExtension::new().namespace(), ACP_EXTENSION_NAMESPACE); } + #[test] + fn unknown_session_normalization_pins_opencode_shape() { + let mut opencode = serde_json::json!({ + "error": { "code": -32603, "message": "Internal error", "data": { "details": "NotFoundError" } } + }); + normalize_unknown_session_error(&mut opencode); + assert_eq!( + opencode.pointer("/error/data/kind").and_then(Value::as_str), + Some("unknown_session") + ); + assert!(is_unknown_session_error(&opencode)); + + let mut malformed = serde_json::json!({ + "error": { "code": -32602, "message": "Invalid params", + "data": { "_errors": [], "sessionId": { "_errors": ["expected string"] } } } + }); + normalize_unknown_session_error(&mut malformed); + assert!(!is_unknown_session_error(&malformed)); + + let mut other_internal = serde_json::json!({ + "error": { "code": -32603, "message": "Internal error", "data": { "details": "SomethingElse" } } + }); + normalize_unknown_session_error(&mut other_internal); + assert!(!is_unknown_session_error(&other_internal)); + } + + #[test] + fn unknown_session_matcher_recognizes_normalized_sentinel_only() { + assert!(is_unknown_session_error(&serde_json::json!({ + "error": { "code": -32000, "message": "x", "data": { "kind": "unknown_session" } } + }))); + + // Raw OpenCode shape must be normalized before matching. + assert!(!is_unknown_session_error(&serde_json::json!({ + "error": { "code": -32603, "message": "Internal error", "data": { "details": "NotFoundError" } } + }))); + assert!(!is_unknown_session_error(&serde_json::json!({ + "error": { "code": -32602, "message": "Invalid params", + "data": { "_errors": [], "sessionId": { "_errors": ["expected string"] } } } + }))); + // Must NOT match: a -32603 internal error that is NOT a NotFoundError. + assert!(!is_unknown_session_error(&serde_json::json!({ + "error": { "code": -32603, "message": "Internal error", "data": { "details": "SomethingElse" } } + }))); + // Must NOT match: NotFoundError under a non--32603 code (different failure). + assert!(!is_unknown_session_error(&serde_json::json!({ + "error": { "code": -32000, "data": { "details": "NotFoundError" } } + }))); + // Must NOT match: a successful response or a bare transport error. + assert!(!is_unknown_session_error( + &serde_json::json!({ "result": {} }) + )); + assert!(!is_unknown_session_error(&serde_json::json!({ + "error": { "code": -32603, "message": "Internal error" } + }))); + } + #[test] fn initialize_protocol_version_is_validated() { let result = Map::from_iter([( diff --git a/crates/agent-os-sidecar/src/lib.rs b/crates/agentos-sidecar/src/lib.rs similarity index 90% rename from crates/agent-os-sidecar/src/lib.rs rename to crates/agentos-sidecar/src/lib.rs index e8cd7bfe6..712708d65 100644 --- a/crates/agent-os-sidecar/src/lib.rs +++ b/crates/agentos-sidecar/src/lib.rs @@ -13,7 +13,7 @@ pub fn extensions() -> Vec> { #[cfg(test)] mod tests { use super::*; - use agent_os_protocol::ACP_EXTENSION_NAMESPACE; + use agentos_protocol::ACP_EXTENSION_NAMESPACE; #[test] fn extensions_register_acp_namespace() { diff --git a/crates/agent-os-sidecar/src/main.rs b/crates/agentos-sidecar/src/main.rs similarity index 77% rename from crates/agent-os-sidecar/src/main.rs rename to crates/agentos-sidecar/src/main.rs index a8962e437..cdc17b202 100644 --- a/crates/agent-os-sidecar/src/main.rs +++ b/crates/agentos-sidecar/src/main.rs @@ -9,9 +9,9 @@ fn main() { .with_max_level(tracing::Level::ERROR) .init(); if let Err(error) = - secure_exec_sidecar::stdio::run_with_extensions(agent_os_sidecar_wrapper::extensions()) + secure_exec_sidecar::stdio::run_with_extensions(agentos_sidecar_wrapper::extensions()) { - tracing::error!(?error, "agent-os-sidecar startup failed"); + tracing::error!(?error, "agentos-sidecar startup failed"); std::process::exit(1); } } diff --git a/crates/agent-os-sidecar/tests/acp_adapter_stderr.rs b/crates/agentos-sidecar/tests/acp_adapter_stderr.rs similarity index 97% rename from crates/agent-os-sidecar/tests/acp_adapter_stderr.rs rename to crates/agentos-sidecar/tests/acp_adapter_stderr.rs index 77544e7c3..30de3762d 100644 --- a/crates/agent-os-sidecar/tests/acp_adapter_stderr.rs +++ b/crates/agentos-sidecar/tests/acp_adapter_stderr.rs @@ -7,7 +7,7 @@ //! //! In the current source the adapter runs inside the VM and the shared exchange //! loop `send_json_rpc_request()` in -//! `crates/agent-os-sidecar/src/acp_extension.rs` now (a) forwards adapter +//! `crates/agentos-sidecar/src/acp_extension.rs` now (a) forwards adapter //! stderr to `tracing::error!(... "ACP adapter stderr")` and (b) observes the //! adapter `ProcessExitedEvent` and returns //! `SidecarError::InvalidState("ACP adapter process {id} exited with code {} ...")`. @@ -30,11 +30,11 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; -use agent_os_protocol::generated::v1::{ +use agentos_protocol::generated::v1::{ AcpCreateSessionRequest, AcpErrorResponse, AcpRequest, AcpResponse, AcpRuntimeKind, AcpSessionRequest, }; -use agent_os_protocol::{ACP_EXTENSION_NAMESPACE, PROTOCOL_VERSION as ACP_PROTOCOL_VERSION}; +use agentos_protocol::{ACP_EXTENSION_NAMESPACE, PROTOCOL_VERSION as ACP_PROTOCOL_VERSION}; use bridge_support::RecordingBridge; use secure_exec_sidecar::wire::{ AuthenticateRequest, ConnectionOwnership, CreateVmRequest, ExtEnvelope, GuestRuntimeKind, @@ -225,7 +225,7 @@ fn new_sidecar(name: &str) -> NativeSidecar { compile_cache_root: Some(temp_dir(name).join("cache")), ..NativeSidecarConfig::default() }, - agent_os_sidecar_wrapper::extensions(), + agentos_sidecar_wrapper::extensions(), ) .expect("create native sidecar") } @@ -330,7 +330,7 @@ fn allow_all_permissions() -> vm_config::PermissionsPolicy { fn temp_dir(name: &str) -> PathBuf { let root = std::env::temp_dir().join(format!( - "agent-os-sidecar-{name}-{}", + "agentos-sidecar-{name}-{}", SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system time before unix epoch") diff --git a/crates/agent-os-sidecar/tests/acp_extension.rs b/crates/agentos-sidecar/tests/acp_extension.rs similarity index 91% rename from crates/agent-os-sidecar/tests/acp_extension.rs rename to crates/agentos-sidecar/tests/acp_extension.rs index 9308c6b90..56f5b3104 100644 --- a/crates/agent-os-sidecar/tests/acp_extension.rs +++ b/crates/agentos-sidecar/tests/acp_extension.rs @@ -7,12 +7,12 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; -use agent_os_protocol::generated::v1::{ +use agentos_protocol::generated::v1::{ AcpCallback, AcpCallbackResponse, AcpCloseSessionRequest, AcpCreateSessionRequest, AcpEvent, AcpGetSessionStateRequest, AcpHostRequestCallbackResponse, AcpPermissionCallbackResponse, AcpRequest, AcpResponse, AcpRuntimeKind, AcpSessionRequest, }; -use agent_os_protocol::{ACP_EXTENSION_NAMESPACE, PROTOCOL_VERSION as ACP_PROTOCOL_VERSION}; +use agentos_protocol::{ACP_EXTENSION_NAMESPACE, PROTOCOL_VERSION as ACP_PROTOCOL_VERSION}; use bridge_support::RecordingBridge; use secure_exec_sidecar::wire::{ AuthenticateRequest, ConnectionOwnership, CreateVmRequest, EventFrame, EventPayload, @@ -395,6 +395,8 @@ fn acp_get_session_state_denies_cross_connection_session_id() { leaked, "cross-connection read of another connection's ACP session state", ); + + close_owned_session(&mut sidecar, 7, &victim_conn, &victim_session, &victim_vm); } /// AOS-ACP-1 (P1 / J.4 cross-connection ACP close): a second connection @@ -415,6 +417,7 @@ fn acp_get_session_state_denies_cross_connection_session_id() { fn acp_close_session_denies_cross_connection_session_id() { assert_node_available(); let mut sidecar = new_sidecar("agent-os-acp-cross-conn-close"); + install_default_acp_callback_handler(&mut sidecar); // Victim connection creates a real ACP session. let victim_conn = authenticate(&mut sidecar); @@ -457,7 +460,12 @@ fn acp_close_session_denies_cross_connection_session_id() { ); let attacker_session = open_session(&mut sidecar, &attacker_conn); let attacker_cwd = temp_dir("agent-os-acp-cross-conn-close-attacker-cwd"); - let attacker_vm = create_vm(&mut sidecar, &attacker_conn, &attacker_session, &attacker_cwd); + let attacker_vm = create_vm( + &mut sidecar, + &attacker_conn, + &attacker_session, + &attacker_cwd, + ); let close_result = dispatch_acp( &mut sidecar, @@ -520,6 +528,8 @@ fn acp_close_session_denies_cross_connection_session_id() { "victim must still be able to prompt its own ACP session after an \ attacker's cross-connection close attempt, got {prompt:?}" ); + + close_owned_session(&mut sidecar, 9, &victim_conn, &victim_session, &victim_vm); } /// AOS-ACP-2 (P1 / J.4 cross-connection ACP drive): a second connection @@ -540,6 +550,7 @@ fn acp_close_session_denies_cross_connection_session_id() { fn acp_session_request_denies_cross_connection_prompt_and_cancel() { assert_node_available(); let mut sidecar = new_sidecar("agent-os-acp-cross-conn-drive"); + install_default_acp_callback_handler(&mut sidecar); // Victim connection creates a real ACP session. let victim_conn = authenticate(&mut sidecar); @@ -581,7 +592,12 @@ fn acp_session_request_denies_cross_connection_prompt_and_cancel() { ); let attacker_session = open_session(&mut sidecar, &attacker_conn); let attacker_cwd = temp_dir("agent-os-acp-cross-conn-drive-attacker-cwd"); - let attacker_vm = create_vm(&mut sidecar, &attacker_conn, &attacker_session, &attacker_cwd); + let attacker_vm = create_vm( + &mut sidecar, + &attacker_conn, + &attacker_session, + &attacker_cwd, + ); // Attacker tries to DRIVE the victim's adapter by its session id. The mock // adapter expects `session/prompt` to be RPC id 3 (the victim's first drive); @@ -674,6 +690,8 @@ fn acp_session_request_denies_cross_connection_prompt_and_cancel() { "victim's own prompt must round-trip cleanly (id/stdout not corrupted by attacker)" ); assert_eq!(prompt_response["result"]["sessionId"], "adapter-session"); + + close_owned_session(&mut sidecar, 10, &victim_conn, &victim_session, &victim_vm); } /// Assert an ACP response is a deny that is INDISTINGUISHABLE from a missing @@ -699,6 +717,71 @@ fn assert_indistinguishable_deny(response: AcpResponse, what: &str) { ); } +fn install_default_acp_callback_handler(sidecar: &mut NativeSidecar) { + sidecar.set_wire_sidecar_request_handler(|frame| match frame.payload { + SidecarRequestPayload::ExtEnvelope(envelope) => { + assert_eq!(envelope.namespace, ACP_EXTENSION_NAMESPACE); + let callback: AcpCallback = + serde_bare::from_slice(&envelope.payload).expect("decode ACP callback"); + let response = match callback { + AcpCallback::AcpPermissionCallback(callback) => { + AcpCallbackResponse::AcpPermissionCallbackResponse( + AcpPermissionCallbackResponse { + permission_id: callback.permission_id, + reply: String::from("once"), + }, + ) + } + AcpCallback::AcpHostRequestCallback(callback) => { + let request: Value = + serde_json::from_str(&callback.request).expect("host callback request"); + assert_eq!(request["method"], "fs/read_text_file"); + AcpCallbackResponse::AcpHostRequestCallbackResponse( + AcpHostRequestCallbackResponse { + response: Some(String::from( + r#"{"jsonrpc":"2.0","id":100,"result":{"content":"host callback ok"}}"#, + )), + }, + ) + } + }; + Ok(SidecarResponseFrame { + schema: frame.schema, + request_id: frame.request_id, + ownership: frame.ownership, + payload: SidecarResponsePayload::ExtEnvelope(ExtEnvelope { + namespace: envelope.namespace, + payload: serde_bare::to_vec(&response).expect("encode callback response"), + }), + }) + } + other => panic!("unexpected sidecar callback: {other:?}"), + }); +} + +fn close_owned_session( + sidecar: &mut NativeSidecar, + request_id: i64, + connection_id: &str, + session_id: &str, + vm_id: &str, +) { + let closed = dispatch_acp( + sidecar, + request_id, + connection_id, + session_id, + vm_id, + AcpRequest::AcpCloseSessionRequest(AcpCloseSessionRequest { + session_id: String::from("adapter-session"), + }), + ); + assert!( + matches!(closed, AcpResponse::AcpSessionClosedResponse(_)), + "owner cleanup close must succeed, got {closed:?}" + ); +} + fn decode_single_acp_session_event(events: &[EventFrame]) -> Value { assert_eq!(events.len(), 1); let EventPayload::ExtEnvelope(envelope) = &events[0].payload else { @@ -934,7 +1017,7 @@ fn new_sidecar(name: &str) -> NativeSidecar { compile_cache_root: Some(temp_dir(name).join("cache")), ..NativeSidecarConfig::default() }, - agent_os_sidecar_wrapper::extensions(), + agentos_sidecar_wrapper::extensions(), ) .expect("create native sidecar") } @@ -1039,7 +1122,7 @@ fn allow_all_permissions() -> vm_config::PermissionsPolicy { fn temp_dir(name: &str) -> PathBuf { let root = std::env::temp_dir().join(format!( - "agent-os-sidecar-{name}-{}", + "agentos-sidecar-{name}-{}", SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system time before unix epoch") diff --git a/crates/agent-os-sidecar/tests/acp_request_timeout.rs b/crates/agentos-sidecar/tests/acp_request_timeout.rs similarity index 100% rename from crates/agent-os-sidecar/tests/acp_request_timeout.rs rename to crates/agentos-sidecar/tests/acp_request_timeout.rs diff --git a/crates/agent-os-sidecar/tests/support/bridge.rs b/crates/agentos-sidecar/tests/support/bridge.rs similarity index 100% rename from crates/agent-os-sidecar/tests/support/bridge.rs rename to crates/agentos-sidecar/tests/support/bridge.rs diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index ef9c4f92e..6075d935b 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "agent-os-client" +name = "agentos-client" version.workspace = true edition.workspace = true license.workspace = true @@ -10,7 +10,7 @@ description = "High-level Rust client SDK for the Agent OS native sidecar (1:1 p # Reuse the secure-exec BARE wire schema + IPC transport. No new wire types are defined here. secure-exec-client = { workspace = true } secure-exec-vm-config = { workspace = true } -agent-os-protocol = { workspace = true } +agentos-protocol = { workspace = true } agent-os-bridge = { workspace = true } anyhow = "1" diff --git a/crates/client/src/agent_os.rs b/crates/client/src/agent_os.rs index 84b03fb67..23c9734dd 100644 --- a/crates/client/src/agent_os.rs +++ b/crates/client/src/agent_os.rs @@ -16,11 +16,11 @@ use serde_json::{Map, Value}; use tokio::sync::{broadcast, oneshot, watch}; use tokio::task::JoinHandle; -use agent_os_protocol::generated::v1::{ +use agentos_protocol::generated::v1::{ AcpCallback, AcpCallbackResponse, AcpEvent, AcpHostRequestCallbackResponse, AcpPermissionCallbackResponse, }; -use agent_os_protocol::ACP_EXTENSION_NAMESPACE; +use agentos_protocol::ACP_EXTENSION_NAMESPACE; use secure_exec_client::wire; use secure_exec_vm_config as vm_config; @@ -39,7 +39,7 @@ use crate::session::{ PermissionRouteRequest, PermissionRouteResult, SessionConfigOption, SessionModeState, }; use crate::sidecar::{AgentOsSidecar, AgentOsSidecarPlacement, AgentOsSidecarVmLease}; -use crate::transport::{SidecarTransport, WireSidecarCallback}; +use crate::transport::{SidecarProcess, WireSidecarCallback}; use secure_exec_client::TransportError; use once_cell::sync::OnceCell; @@ -87,6 +87,30 @@ pub(crate) struct AcpTerminalEntry { pub exit_task: JoinHandle<()>, } +/// Mutable output state of a host-request ACP terminal (mirrors the TS `AcpTerminalEntry` +/// `output` / `truncated` accumulation behavior). +pub(crate) struct HostAcpTerminalOutput { + /// Accumulated UTF-8 terminal output (stdout + stderr interleaved, like the TS handle). + pub buffer: String, + pub truncated: bool, + /// Byte limit; `output` is trimmed from the front once it exceeds this. Mirrors the TS + /// `outputByteLimit` (default 1 MiB). + pub output_byte_limit: usize, +} + +/// A host-request ACP terminal created via `terminal/create` (mirrors the TS `_acpTerminals` +/// value). Backed by a real PTY shell (`open_shell`); the background fan-out task accumulates +/// output and records the exit code. +pub(crate) struct HostAcpTerminal { + /// The backing shell id (`shell-N`) used for `terminal/write` / `terminal/resize` / + /// `terminal/kill`. + pub shell_id: String, + /// Shared output buffer updated by the fan-out task and read by `terminal/output`. + pub output: Arc>, + /// Exit code once the process has exited (`None` while running). Mirrors `exitCode`. + pub exit_rx: watch::Receiver>, +} + /// An ACP session (TS `_sessions` value). Keyed by ACP session id. pub(crate) struct SessionEntry { pub agent_type: String, @@ -122,7 +146,7 @@ pub struct AgentOs { pub(crate) struct AgentOsInner { // Transport / connection / VM handle. - pub(crate) transport: Arc, + pub(crate) transport: Arc, pub(crate) connection_id: String, pub(crate) session_id: String, pub(crate) vm_id: String, @@ -154,6 +178,11 @@ pub(crate) struct AgentOsInner { pub(crate) acp_terminals: SccHashMap, pub(crate) acp_terminal_count: AtomicUsize, pub(crate) acp_terminal_lifecycle_lock: tokio::sync::Mutex<()>, + /// Host-request ACP terminals created via `terminal/create` (TS `_acpTerminals`). Keyed by the + /// `acp-terminal-N` id the agent uses in subsequent `terminal/*` calls. + pub(crate) host_acp_terminals: SccHashMap, + /// Monotonic counter for the `acp-terminal-N` ids (TS `_acpTerminalCounter`). + pub(crate) host_acp_terminal_counter: AtomicU64, // Session registries. pub(crate) sessions: SccHashMap, @@ -501,6 +530,8 @@ impl AgentOs { acp_terminals: SccHashMap::new(), acp_terminal_count: AtomicUsize::new(0), acp_terminal_lifecycle_lock: tokio::sync::Mutex::new(()), + host_acp_terminals: SccHashMap::new(), + host_acp_terminal_counter: AtomicU64::new(0), sessions: SccHashMap::new(), closed_session_ids: parking_lot::Mutex::new(VecDeque::new()), closing_session_ids: SccHashSet::new(), @@ -592,6 +623,19 @@ impl AgentOs { exit_tasks.push(task); } } + + // Tear down host-request ACP terminals (`terminal/create`). Close the backing shell, which + // sends SIGTERM, removes the shell entry, and ends the fan-out/exit task; the task itself is + // tracked in `pending_shell_exits` above and drained with the other shell exit tasks. + let mut host_terminal_shells = Vec::new(); + self.inner.host_acp_terminals.retain(|_, terminal| { + host_terminal_shells.push(terminal.shell_id.clone()); + false + }); + for shell_id in host_terminal_shells { + let _ = self.close_shell(&shell_id); + } + if !exit_tasks.is_empty() { let mut drain_tasks = exit_tasks; if tokio::time::timeout( @@ -648,7 +692,7 @@ impl AgentOs { &self.inner } - pub(crate) fn transport(&self) -> &Arc { + pub(crate) fn transport(&self) -> &Arc { &self.inner.transport } @@ -948,9 +992,56 @@ fn serialize_limits_config_for_sidecar( }) } +/// Hosts the VM may reach by default (egress). The default network policy is an +/// allowlist of the common hosted LLM provider API endpoints so the standard +/// agent quickstart works with zero network configuration, while still matching +/// the Workers-style default-deny egress model: every other host is denied +/// unless the client widens the `network` permission. Clients opt out by +/// configuring `network` explicitly (e.g. `{ network: "allow" }`). +const DEFAULT_EGRESS_HOSTS: &[&str] = &[ + "api.anthropic.com", + "api.openai.com", + "generativelanguage.googleapis.com", + "openrouter.ai", +]; + +/// Resource patterns for the default egress allowlist. Network permission +/// resources are `dns://` for name resolution and `tcp://:` +/// for the connection itself, so each allowed host needs both forms. +fn default_egress_patterns() -> Vec { + DEFAULT_EGRESS_HOSTS + .iter() + .flat_map(|host| [format!("dns://{host}"), format!("tcp://{host}:*")]) + .collect() +} + +/// vm_config variant of the default egress allowlist (deny-by-default rule set). +fn default_network_egress_scope_config() -> vm_config::PatternPermissionScope { + vm_config::PatternPermissionScope::Rules(vm_config::PatternPermissionRuleSet { + default: Some(vm_config::PermissionMode::Deny), + rules: vec![vm_config::PatternPermissionRule { + mode: vm_config::PermissionMode::Allow, + operations: vec!["*".to_string()], + patterns: default_egress_patterns(), + }], + }) +} + +/// Wire variant of the default egress allowlist (deny-by-default rule set). +fn default_network_egress_scope() -> wire::PatternPermissionScope { + wire::PatternPermissionScope::PatternPermissionRuleSet(wire::PatternPermissionRuleSet { + default: Some(wire::PermissionMode::Deny), + rules: vec![wire::PatternPermissionRule { + mode: wire::PermissionMode::Allow, + operations: vec!["*".to_string()], + patterns: default_egress_patterns(), + }], + }) +} + fn permissions_policy_config(config: &AgentOsConfig) -> vm_config::PermissionsPolicy { let Some(permissions) = config.permissions.as_ref() else { - return allow_all_permissions_policy_config(); + return default_permissions_policy_config(); }; vm_config::PermissionsPolicy { @@ -968,9 +1059,7 @@ fn permissions_policy_config(config: &AgentOsConfig) -> vm_config::PermissionsPo .network .as_ref() .map(serialize_pattern_permissions_config) - .unwrap_or(vm_config::PatternPermissionScope::Mode( - vm_config::PermissionMode::Allow, - )), + .unwrap_or_else(default_network_egress_scope_config), ), child_process: Some( permissions @@ -1011,14 +1100,16 @@ fn permissions_policy_config(config: &AgentOsConfig) -> vm_config::PermissionsPo } } -fn allow_all_permissions_policy_config() -> vm_config::PermissionsPolicy { +/// Default permission policy when the client supplies no `permissions`: +/// allow-all for fs/childProcess/process/env/tool (the VM is itself the +/// isolation boundary), with network egress restricted to the default LLM +/// allowlist (see [`default_network_egress_scope_config`]). +fn default_permissions_policy_config() -> vm_config::PermissionsPolicy { vm_config::PermissionsPolicy { fs: Some(vm_config::FsPermissionScope::Mode( vm_config::PermissionMode::Allow, )), - network: Some(vm_config::PatternPermissionScope::Mode( - vm_config::PermissionMode::Allow, - )), + network: Some(default_network_egress_scope_config()), child_process: Some(vm_config::PatternPermissionScope::Mode( vm_config::PermissionMode::Allow, )), @@ -1333,30 +1424,9 @@ async fn handle_acp_ext_callback( }) } AcpCallback::AcpHostRequestCallback(callback) => { - let response = serde_json::from_str::(&callback.request) - .ok() - .and_then(|request| { - let id = request - .get("id") - .cloned() - .unwrap_or(serde_json::Value::Null); - let method = request - .get("method") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"); - serde_json::to_string(&serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": -32601, - "message": format!("Method not found: {method}"), - "data": { "method": method }, - }, - })) - .ok() - }); + let response = dispatch_acp_host_request(ownership, &callback.request).await; AcpCallbackResponse::AcpHostRequestCallbackResponse(AcpHostRequestCallbackResponse { - response, + response: Some(response), }) } }; @@ -1386,6 +1456,784 @@ async fn route_permission_request( client.deliver_sidecar_permission_request(request).await } +// --------------------------------------------------------------------------- +// ACP host-request dispatch (mirrors TS `_dispatchAcpSidecarRequest` -> +// `_handleSupportedAcpSidecarRequest`) +// --------------------------------------------------------------------------- + +/// The default `terminal/create` output cap (1 MiB), matching the TS reference. +const ACP_TERMINAL_DEFAULT_OUTPUT_BYTE_LIMIT: usize = 1_048_576; + +/// A JSON-RPC error raised while handling an ACP host request. Mirrors the TS `AcpDispatchError`. +struct AcpDispatchError { + code: i64, + message: String, + data: Option, +} + +impl AcpDispatchError { + fn new(code: i64, message: impl Into) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } + + fn with_data(code: i64, message: impl Into, data: Value) -> Self { + Self { + code, + message: message.into(), + data: Some(data), + } + } +} + +impl From for AcpDispatchError { + fn from(error: ClientError) -> Self { + match error { + // Preserve the kernel errno code where one exists (e.g. ENOENT), surfaced through the + // JSON-RPC `data.code`, while keeping a JSON-RPC internal-error envelope. + ClientError::Kernel { code, message } => { + AcpDispatchError::with_data(-32603, message, serde_json::json!({ "code": code })) + } + other => AcpDispatchError::new(-32603, other.to_string()), + } + } +} + +impl From for AcpDispatchError { + fn from(error: anyhow::Error) -> Self { + // The filesystem methods return `anyhow::Result`; downcast to recover the kernel errno where + // the underlying cause is a `ClientError::Kernel` (so e.g. ENOENT survives into `data.code`). + match error.downcast::() { + Ok(client_error) => client_error.into(), + Err(error) => AcpDispatchError::new(-32603, error.to_string()), + } + } +} + +/// Decode the inbound JSON-RPC request, dispatch it to the matching VM operation, and serialize the +/// JSON-RPC response (success or error). Always returns a valid JSON-RPC response string; the +/// `id`/`error` shape mirrors `_dispatchAcpSidecarRequest`. +async fn dispatch_acp_host_request(ownership: &wire::OwnershipScope, request: &str) -> String { + let parsed = serde_json::from_str::(request); + let (id, method, params_value) = match parsed { + Ok(value) => { + let id = value.get("id").cloned().unwrap_or(Value::Null); + let method = value + .get("method") + .and_then(Value::as_str) + .map(str::to_string); + (id, method, value.get("params").cloned()) + } + Err(error) => { + return acp_error_response(Value::Null, -32700, &format!("Parse error: {error}"), None); + } + }; + + let Some(method) = method else { + return acp_error_response(id, -32600, "Invalid Request: missing method", None); + }; + + match handle_acp_host_request(ownership, &method, params_value).await { + Ok(result) => serde_json::to_string(&serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + })) + .unwrap_or_else(|error| acp_error_response(Value::Null, -32603, &error.to_string(), None)), + Err(error) => acp_error_response(id, error.code, &error.message, error.data), + } +} + +fn acp_error_response(id: Value, code: i64, message: &str, data: Option) -> String { + let mut error = serde_json::json!({ + "code": code, + "message": message, + }); + if let Some(data) = data { + if let Some(map) = error.as_object_mut() { + map.insert("data".to_string(), data); + } + } + serde_json::to_string(&serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "error": error, + })) + .unwrap_or_else(|_| { + String::from(r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"failed to encode error response"}}"#) + }) +} + +/// Resolve the `AgentOs` that owns the VM named in `ownership`, mirroring `route_permission_request`. +fn resolve_acp_agent(ownership: &wire::OwnershipScope) -> Result { + let vm_id = wire_ownership_vm_id(ownership).unwrap_or(""); + let inner = vm_permission_routers() + .read(vm_id, |_, weak| weak.clone()) + .and_then(|weak| weak.upgrade()); + inner + .map(|inner| AgentOs { inner }) + .ok_or_else(|| AcpDispatchError::new(-32603, "VM is no longer available")) +} + +/// Mirror of TS `_handleSupportedAcpSidecarRequest`: dispatch the JSON-RPC method to the matching VM +/// operation. Returns the JSON-RPC `result` value on success. +async fn handle_acp_host_request( + ownership: &wire::OwnershipScope, + method: &str, + params_value: Option, +) -> Result { + let params = acp_params(method, params_value)?; + match method { + crate::session::ACP_PERMISSION_METHOD => { + handle_acp_permission_request(ownership, method, ¶ms).await + } + "fs/read" | "fs/read_text_file" => { + let agent = resolve_acp_agent(ownership)?; + handle_acp_read_file(&agent, ¶ms).await + } + "fs/write" | "fs/write_text_file" => { + let agent = resolve_acp_agent(ownership)?; + handle_acp_write_file(&agent, ¶ms).await + } + "fs/readDir" | "fs/read_dir" => { + let agent = resolve_acp_agent(ownership)?; + handle_acp_read_dir(&agent, ¶ms).await + } + "terminal/create" => { + let agent = resolve_acp_agent(ownership)?; + handle_acp_create_terminal(&agent, ¶ms) + } + "terminal/write" => { + let agent = resolve_acp_agent(ownership)?; + handle_acp_write_terminal(&agent, ¶ms) + } + "terminal/output" | "terminal/read" => { + let agent = resolve_acp_agent(ownership)?; + handle_acp_read_terminal(&agent, ¶ms) + } + "terminal/wait_for_exit" | "terminal/waitForExit" => { + let agent = resolve_acp_agent(ownership)?; + handle_acp_wait_for_terminal_exit(&agent, ¶ms).await + } + "terminal/kill" => { + let agent = resolve_acp_agent(ownership)?; + handle_acp_kill_terminal(&agent, ¶ms) + } + "terminal/release" | "terminal/close" => { + let agent = resolve_acp_agent(ownership)?; + handle_acp_release_terminal(&agent, ¶ms) + } + "terminal/resize" => { + let agent = resolve_acp_agent(ownership)?; + handle_acp_resize_terminal(&agent, ¶ms) + } + other => Err(AcpDispatchError::with_data( + -32601, + format!("Method not found: {other}"), + serde_json::json!({ "method": other }), + )), + } +} + +// --- ACP host-request param helpers (mirror TS `_acpParams` / `_require*` / `_optional*`) --- + +fn acp_params( + method: &str, + params_value: Option, +) -> Result, AcpDispatchError> { + match params_value { + None | Some(Value::Null) => Ok(Map::new()), + Some(Value::Object(map)) => Ok(map), + Some(_) => Err(AcpDispatchError::new( + -32602, + format!("{method} requires object params"), + )), + } +} + +fn require_acp_string( + params: &Map, + name: &str, + method: &str, +) -> Result { + match params.get(name).and_then(Value::as_str) { + Some(value) => Ok(value.to_string()), + None => Err(AcpDispatchError::new( + -32602, + format!("{method} requires a string {name}"), + )), + } +} + +fn optional_acp_string( + params: &Map, + name: &str, + method: &str, +) -> Result, AcpDispatchError> { + match params.get(name) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => Ok(Some(value.clone())), + Some(_) => Err(AcpDispatchError::new( + -32602, + format!("{method} requires {name} to be a string when provided"), + )), + } +} + +fn optional_acp_number( + params: &Map, + name: &str, + method: &str, +) -> Result, AcpDispatchError> { + match params.get(name) { + None | Some(Value::Null) => Ok(None), + Some(value) => match value.as_f64() { + Some(number) if number.is_finite() => Ok(Some(number)), + _ => Err(AcpDispatchError::new( + -32602, + format!("{method} requires {name} to be a number when provided"), + )), + }, + } +} + +fn optional_acp_string_array( + params: &Map, + name: &str, + method: &str, +) -> Result>, AcpDispatchError> { + match params.get(name) { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(items)) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + match item.as_str() { + Some(value) => out.push(value.to_string()), + None => { + return Err(AcpDispatchError::new( + -32602, + format!( + "{method} requires {name} to be an array of strings when provided" + ), + )) + } + } + } + Ok(Some(out)) + } + Some(_) => Err(AcpDispatchError::new( + -32602, + format!("{method} requires {name} to be an array of strings when provided"), + )), + } +} + +/// Parse the ACP `env` param, accepting either an object map or a `[{ name, value }]` array, matching +/// the TS `_optionalAcpEnvParam`. +fn optional_acp_env( + params: &Map, + name: &str, + method: &str, +) -> Result>, AcpDispatchError> { + match params.get(name) { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(items)) => { + let mut env = BTreeMap::new(); + for entry in items { + let Some(record) = entry.as_object() else { + return Err(AcpDispatchError::new( + -32602, + format!("{method} requires {name} entries to be {{ name, value }} objects"), + )); + }; + match ( + record.get("name").and_then(Value::as_str), + record.get("value").and_then(Value::as_str), + ) { + (Some(key), Some(value)) => { + env.insert(key.to_string(), value.to_string()); + } + _ => { + return Err(AcpDispatchError::new( + -32602, + format!( + "{method} requires {name} entries to be {{ name, value }} objects" + ), + )) + } + } + } + Ok(Some(env)) + } + Some(Value::Object(map)) => { + let mut env = BTreeMap::new(); + for (key, value) in map { + match value.as_str() { + Some(value) => { + env.insert(key.clone(), value.to_string()); + } + None => { + return Err(AcpDispatchError::new( + -32602, + format!("{method} requires {name} values to be strings"), + )) + } + } + } + Ok(Some(env)) + } + Some(_) => Err(AcpDispatchError::new( + -32602, + format!("{method} requires {name} to be an object or name/value array"), + )), + } +} + +// --- fs/* handlers --- + +async fn handle_acp_read_file( + agent: &AgentOs, + params: &Map, +) -> Result { + let method = "fs/read"; + let path = require_acp_string(params, "path", method)?; + let line = optional_acp_number(params, "line", method)?; + let limit = optional_acp_number(params, "limit", method)?; + let encoding = optional_acp_string(params, "encoding", method)?; + let bytes = agent.read_file(&path).await?; + if encoding.as_deref() == Some("base64") { + use base64::engine::general_purpose::STANDARD as BASE64; + use base64::Engine as _; + return Ok(serde_json::json!({ "content": BASE64.encode(&bytes) })); + } + let text = String::from_utf8_lossy(&bytes).into_owned(); + if line.is_none() && limit.is_none() { + return Ok(serde_json::json!({ "content": text })); + } + let start_line = line.map(|n| n.trunc() as i64).unwrap_or(1).max(1); + let lines: Vec<&str> = text.split('\n').collect(); + let start_index = (start_line - 1).max(0) as usize; + let selected: Vec<&str> = match limit { + None => lines.into_iter().skip(start_index).collect(), + Some(limit) => { + let limit = limit.trunc().max(0.0) as usize; + lines.into_iter().skip(start_index).take(limit).collect() + } + }; + Ok(serde_json::json!({ "content": selected.join("\n") })) +} + +async fn handle_acp_write_file( + agent: &AgentOs, + params: &Map, +) -> Result { + let method = "fs/write"; + let path = require_acp_string(params, "path", method)?; + let content = require_acp_string(params, "content", method)?; + let encoding = optional_acp_string(params, "encoding", method)?; + if encoding.as_deref() == Some("base64") { + use base64::engine::general_purpose::STANDARD as BASE64; + use base64::Engine as _; + let decoded = BASE64.decode(content.as_bytes()).map_err(|error| { + AcpDispatchError::new( + -32602, + format!("{method} content is not valid base64: {error}"), + ) + })?; + agent.write_file(&path, decoded).await?; + } else { + agent.write_file(&path, content).await?; + } + Ok(Value::Null) +} + +async fn handle_acp_read_dir( + agent: &AgentOs, + params: &Map, +) -> Result { + let method = "fs/readDir"; + let path = require_acp_string(params, "path", method)?; + let entries = agent.acp_read_dir_with_types(&path).await?; + let mapped: Vec = entries + .into_iter() + .map(|entry| { + let child_path = if path == "/" { + format!("/{}", entry.name) + } else { + format!("{path}/{}", entry.name) + }; + let entry_type = if entry.is_symbolic_link { + "symlink" + } else if entry.is_directory { + "directory" + } else { + "file" + }; + serde_json::json!({ + "name": entry.name, + "path": child_path, + "type": entry_type, + }) + }) + .collect(); + Ok(serde_json::json!({ "entries": mapped })) +} + +// --- session/request_permission handler --- + +async fn handle_acp_permission_request( + ownership: &wire::OwnershipScope, + method: &str, + params: &Map, +) -> Result { + let session_id = require_acp_string(params, "sessionId", method)?; + + let result = route_permission_request( + ownership, + PermissionRouteRequest { + session_id: session_id.clone(), + // The host-request id is not available here as the permission key; use a generated key + // scoped to the session so concurrent permission requests do not collide. + permission_id: format!("acp-permission-{}", uuid::Uuid::new_v4()), + params: Value::Object(params.clone()), + }, + ) + .await; + + // `reply: None` means the session/VM is gone or the request timed out -> cancelled outcome. + let reply = match result.reply.as_deref() { + Some("always") => PermissionDecision::Always, + Some("once") => PermissionDecision::Once, + _ => PermissionDecision::Reject, + }; + Ok(build_acp_permission_result(reply, params)) +} + +#[derive(Clone, Copy)] +enum PermissionDecision { + Always, + Once, + Reject, +} + +/// Mirror of TS `_normalizeAcpPermissionOptionId`: pick the matching option id from the request's +/// `options`, falling back to the canonical id for the decision. +fn normalize_acp_permission_option_id( + options: Option<&Vec>, + decision: PermissionDecision, +) -> String { + let (option_ids, kinds, fallback): (&[&str], &[&str], &str) = match decision { + PermissionDecision::Always => ( + &["always", "allow_always"], + &["allow_always"], + "allow_always", + ), + PermissionDecision::Once => (&["once", "allow_once"], &["allow_once"], "allow_once"), + PermissionDecision::Reject => (&["reject", "reject_once"], &["reject_once"], "reject_once"), + }; + if let Some(options) = options { + for option in options { + let Some(record) = option.as_object() else { + continue; + }; + let option_id = record.get("optionId").and_then(Value::as_str); + let kind = record.get("kind").and_then(Value::as_str); + let matches = option_id.is_some_and(|id| option_ids.contains(&id)) + || kind.is_some_and(|k| kinds.contains(&k)); + if matches { + if let Some(id) = option_id { + return id.to_string(); + } + } + } + } + fallback.to_string() +} + +/// Mirror of TS `_buildAcpPermissionResult`: produce `{ outcome: { outcome: "selected", optionId } }`. +fn build_acp_permission_result(decision: PermissionDecision, params: &Map) -> Value { + let options = params.get("options").and_then(Value::as_array); + let option_id = normalize_acp_permission_option_id(options, decision); + serde_json::json!({ + "outcome": { + "outcome": "selected", + "optionId": option_id, + } + }) +} + +// --- terminal/* handlers --- + +fn require_acp_terminal_id( + params: &Map, + method: &str, +) -> Result { + require_acp_string(params, "terminalId", method) +} + +fn handle_acp_create_terminal( + agent: &AgentOs, + params: &Map, +) -> Result { + let method = "terminal/create"; + let command = require_acp_string(params, "command", method)?; + let args = optional_acp_string_array(params, "args", method)?; + let env = optional_acp_env(params, "env", method)?; + let cwd = optional_acp_string(params, "cwd", method)?; + let cols = optional_acp_number(params, "cols", method)?; + let rows = optional_acp_number(params, "rows", method)?; + let output_byte_limit = optional_acp_number(params, "outputByteLimit", method)? + .map(|n| n.trunc().max(0.0) as usize) + .unwrap_or(ACP_TERMINAL_DEFAULT_OUTPUT_BYTE_LIMIT); + + let counter = agent + .inner() + .host_acp_terminal_counter + .fetch_add(1, Ordering::SeqCst) + + 1; + let terminal_id = format!("acp-terminal-{counter}"); + + let output = Arc::new(parking_lot::Mutex::new(HostAcpTerminalOutput { + buffer: String::new(), + truncated: false, + output_byte_limit, + })); + let (exit_tx, exit_rx) = watch::channel::>(None); + + // Build the PTY shell. Both stdout and stderr are appended to the same output buffer, mirroring + // the TS handle where `onData` and `onStderr` both append to `terminal.output`. + let mut shell_options = crate::shell::OpenShellOptions { + command: Some(command), + cwd, + ..Default::default() + }; + if let Some(args) = args { + shell_options.args = args; + } + if let Some(env) = env { + shell_options.env = env; + } + if let Some(cols) = cols { + shell_options.cols = Some(cols.trunc() as u16); + } + if let Some(rows) = rows { + shell_options.rows = Some(rows.trunc() as u16); + } + // Both stdout and stderr are appended to the single combined output buffer inside + // `acp_open_terminal`'s fan-out task (mirroring the TS handle's `onData`/`onStderr`). + let buffer_sink = output.clone(); + let handle = agent + .acp_open_terminal(shell_options, exit_tx, move |data: &[u8]| { + append_acp_terminal_output(&buffer_sink, data); + }) + .map_err(|error| AcpDispatchError::new(-32603, error.to_string()))?; + let shell_id = handle.shell_id.clone(); + + let entry = HostAcpTerminal { + shell_id, + output, + exit_rx, + }; + if agent + .inner() + .host_acp_terminals + .insert(terminal_id.clone(), entry) + .is_err() + { + return Err(AcpDispatchError::new( + -32603, + format!("ACP terminal id collision: {terminal_id}"), + )); + } + + Ok(serde_json::json!({ "terminalId": terminal_id })) +} + +fn append_acp_terminal_output( + output: &Arc>, + data: &[u8], +) { + let chunk = String::from_utf8_lossy(data); + if chunk.is_empty() { + return; + } + let mut state = output.lock(); + state.buffer.push_str(&chunk); + let limit = state.output_byte_limit; + if state.buffer.len() > limit { + // Trim from the front to the limit, on a char boundary, matching the TS slice-to-limit + // behavior (which trims to the last `limit` UTF-16 code units; bytes are an acceptable port). + let overflow = state.buffer.len() - limit; + let mut cut = overflow; + while cut < state.buffer.len() && !state.buffer.is_char_boundary(cut) { + cut += 1; + } + state.buffer = state.buffer.split_off(cut); + state.truncated = true; + } +} + +fn handle_acp_write_terminal( + agent: &AgentOs, + params: &Map, +) -> Result { + let method = "terminal/write"; + let terminal_id = require_acp_terminal_id(params, method)?; + let shell_id = acp_terminal_shell_id(agent, &terminal_id)?; + let data = require_acp_string(params, "data", method)?; + let encoding = optional_acp_string(params, "encoding", method)?; + let input = if encoding.as_deref() == Some("base64") { + use base64::engine::general_purpose::STANDARD as BASE64; + use base64::Engine as _; + let decoded = BASE64.decode(data.as_bytes()).map_err(|error| { + AcpDispatchError::new( + -32602, + format!("{method} data is not valid base64: {error}"), + ) + })?; + crate::process::StdinInput::Bytes(decoded) + } else { + crate::process::StdinInput::Text(data) + }; + agent + .write_shell(&shell_id, input) + .map_err(|error| AcpDispatchError::new(-32603, error.to_string()))?; + Ok(Value::Null) +} + +fn handle_acp_read_terminal( + agent: &AgentOs, + params: &Map, +) -> Result { + let method = "terminal/output"; + let terminal_id = require_acp_terminal_id(params, method)?; + agent + .inner() + .host_acp_terminals + .read(&terminal_id, |_, terminal| { + let (output, truncated) = { + let state = terminal.output.lock(); + (state.buffer.clone(), state.truncated) + }; + let mut result = serde_json::json!({ + "output": output, + "truncated": truncated, + }); + if let Some(exit_code) = *terminal.exit_rx.borrow() { + if let Some(map) = result.as_object_mut() { + map.insert( + "exitStatus".to_string(), + serde_json::json!({ "exitCode": exit_code, "signal": Value::Null }), + ); + } + } + result + }) + .ok_or_else(|| { + AcpDispatchError::new(-32602, format!("ACP terminal not found: {terminal_id}")) + }) +} + +async fn handle_acp_wait_for_terminal_exit( + agent: &AgentOs, + params: &Map, +) -> Result { + let method = "terminal/wait_for_exit"; + let terminal_id = require_acp_terminal_id(params, method)?; + let mut exit_rx = agent + .inner() + .host_acp_terminals + .read(&terminal_id, |_, terminal| terminal.exit_rx.clone()) + .ok_or_else(|| { + AcpDispatchError::new(-32602, format!("ACP terminal not found: {terminal_id}")) + })?; + let exit_code = loop { + if let Some(code) = *exit_rx.borrow() { + break code; + } + if exit_rx.changed().await.is_err() { + // Sender dropped (terminal released / VM disposed) without a recorded + // exit code. Surface that as an abnormal exit instead of pretending + // the terminal completed cleanly with exit 0. + break exit_rx.borrow().unwrap_or(1); + } + }; + Ok(serde_json::json!({ "exitCode": exit_code, "signal": Value::Null })) +} + +fn handle_acp_kill_terminal( + agent: &AgentOs, + params: &Map, +) -> Result { + let method = "terminal/kill"; + let terminal_id = require_acp_terminal_id(params, method)?; + let shell_id = acp_terminal_shell_id(agent, &terminal_id)?; + // The native shell API only exposes SIGTERM teardown via `close_shell`'s kill; the explicit + // `signal` param is accepted for parity but the underlying kill is fixed to SIGTERM. The terminal + // entry is retained (matching TS `kill`, which does not delete the terminal) so `terminal/output` + // and `terminal/wait_for_exit` still work afterward. + agent + .acp_kill_terminal_shell(&shell_id) + .map_err(|error| AcpDispatchError::new(-32603, error.to_string()))?; + Ok(Value::Null) +} + +fn handle_acp_release_terminal( + agent: &AgentOs, + params: &Map, +) -> Result { + let method = "terminal/release"; + let terminal_id = require_acp_terminal_id(params, method)?; + let Some((_, terminal)) = agent.inner().host_acp_terminals.remove(&terminal_id) else { + return Err(AcpDispatchError::new( + -32602, + format!("ACP terminal not found: {terminal_id}"), + )); + }; + // If the process has not exited yet, kill it (TS releases by killing when `exitCode === null`). + if terminal.exit_rx.borrow().is_none() { + let _ = agent.acp_kill_terminal_shell(&terminal.shell_id); + } + // Closing the shell removes the registry entry and ends the fan-out/exit task naturally. + let _ = agent.close_shell(&terminal.shell_id); + Ok(Value::Null) +} + +fn handle_acp_resize_terminal( + agent: &AgentOs, + params: &Map, +) -> Result { + let method = "terminal/resize"; + let terminal_id = require_acp_terminal_id(params, method)?; + let shell_id = acp_terminal_shell_id(agent, &terminal_id)?; + let cols = optional_acp_number(params, "cols", method)?; + let rows = optional_acp_number(params, "rows", method)?; + let (Some(cols), Some(rows)) = (cols, rows) else { + return Err(AcpDispatchError::new( + -32602, + format!("{method} requires numeric cols and rows"), + )); + }; + agent + .resize_shell(&shell_id, cols.trunc() as u16, rows.trunc() as u16) + .map_err(|error| AcpDispatchError::new(-32603, error.to_string()))?; + Ok(Value::Null) +} + +/// Look up the backing shell id for a host-request terminal, or a JSON-RPC -32602 error. +fn acp_terminal_shell_id(agent: &AgentOs, terminal_id: &str) -> Result { + agent + .inner() + .host_acp_terminals + .read(terminal_id, |_, terminal| terminal.shell_id.clone()) + .ok_or_else(|| { + AcpDispatchError::new(-32602, format!("ACP terminal not found: {terminal_id}")) + }) +} + /// The transport callback that answers guest tool invocations by running the matching host tool. fn host_callback_callback() -> WireSidecarCallback { Arc::new(|payload, ownership| { @@ -2645,7 +3493,7 @@ fn serialize_mounts(config: &AgentOsConfig) -> Result fn permissions_policy(config: &AgentOsConfig) -> wire::PermissionsPolicy { let Some(permissions) = config.permissions.as_ref() else { - return allow_all_permissions_policy(); + return default_permissions_policy(); }; wire::PermissionsPolicy { @@ -2663,9 +3511,7 @@ fn permissions_policy(config: &AgentOsConfig) -> wire::PermissionsPolicy { .network .as_ref() .map(serialize_pattern_permissions) - .unwrap_or(wire::PatternPermissionScope::PermissionMode( - wire::PermissionMode::Allow, - )), + .unwrap_or_else(default_network_egress_scope), ), child_process: Some( permissions @@ -2706,14 +3552,16 @@ fn permissions_policy(config: &AgentOsConfig) -> wire::PermissionsPolicy { } } -fn allow_all_permissions_policy() -> wire::PermissionsPolicy { +/// Default permission policy (wire form) when the client supplies no +/// `permissions`: allow-all for fs/childProcess/process/env/tool, with network +/// egress restricted to the default LLM allowlist +/// (see [`default_network_egress_scope`]). +fn default_permissions_policy() -> wire::PermissionsPolicy { wire::PermissionsPolicy { fs: Some(wire::FsPermissionScope::PermissionMode( wire::PermissionMode::Allow, )), - network: Some(wire::PatternPermissionScope::PermissionMode( - wire::PermissionMode::Allow, - )), + network: Some(default_network_egress_scope()), child_process: Some(wire::PatternPermissionScope::PermissionMode( wire::PermissionMode::Allow, )), @@ -2815,7 +3663,7 @@ fn rejected_to_error(rejected: wire::RejectedResponse) -> ClientError { #[cfg(test)] mod tests { use super::{ - allow_all_permissions_policy, permissions_policy, serialize_create_vm_config_for_sidecar, + default_permissions_policy, permissions_policy, serialize_create_vm_config_for_sidecar, serialize_root_filesystem_config_for_sidecar, }; use crate::config::{ @@ -2837,13 +3685,41 @@ mod tests { }; #[test] - fn permissions_policy_defaults_to_allow_all_when_unset() { + fn permissions_policy_defaults_to_default_policy_when_unset() { assert_eq!( permissions_policy(&AgentOsConfig::default()), - allow_all_permissions_policy() + default_permissions_policy() ); } + #[test] + fn default_network_egress_is_llm_allowlist_not_allow_all() { + let policy = permissions_policy(&AgentOsConfig::default()); + + // fs/childProcess/process/env stay allow-all (the VM is the boundary). + assert_eq!( + policy.child_process, + Some(PatternPermissionScope::PermissionMode( + WirePermissionMode::Allow + )) + ); + + // Network egress is a deny-by-default allowlist of LLM provider hosts, + // covering both DNS resolution and the TCP connection for each host. + let Some(PatternPermissionScope::PatternPermissionRuleSet(rules)) = policy.network else { + panic!("expected default network egress to be a rule set, not allow-all"); + }; + assert_eq!(rules.default, Some(WirePermissionMode::Deny)); + assert_eq!(rules.rules.len(), 1); + assert_eq!(rules.rules[0].mode, WirePermissionMode::Allow); + let patterns = &rules.rules[0].patterns; + assert!(patterns.contains(&"dns://api.anthropic.com".to_string())); + assert!(patterns.contains(&"tcp://api.anthropic.com:*".to_string())); + assert!(patterns.contains(&"dns://api.openai.com".to_string())); + assert!(patterns.contains(&"dns://generativelanguage.googleapis.com".to_string())); + assert!(patterns.contains(&"dns://openrouter.ai".to_string())); + } + #[test] fn permissions_policy_preserves_configured_denies_and_allows_omitted_domains() { let policy = permissions_policy(&AgentOsConfig { diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs index 071e0ab37..7a61f6f7d 100644 --- a/crates/client/src/config.rs +++ b/crates/client/src/config.rs @@ -43,10 +43,10 @@ pub struct AgentOsConfig { pub limits: Option, /// Sidecar placement/config. Default: shared `default` pool. pub sidecar: Option, - /// Absolute path to the `agent-os-sidecar` binary, resolved from the npm - /// package on the TypeScript side. Threaded to `SidecarTransport::spawn` + /// Absolute path to the `agentos-sidecar` binary, resolved from the npm + /// package on the TypeScript side. Threaded to `SidecarProcess::spawn` /// (mirroring rivetkit's `engine_binary_path`) instead of relying on the - /// `AGENT_OS_SIDECAR_BIN` env var. `None` falls back to env, then `PATH`. + /// `AGENTOS_SIDECAR_BIN` env var. `None` falls back to env, then `PATH`. pub sidecar_binary_path: Option, } diff --git a/crates/client/src/fs.rs b/crates/client/src/fs.rs index 389651cc0..0d84968f8 100644 --- a/crates/client/src/fs.rs +++ b/crates/client/src/fs.rs @@ -772,6 +772,28 @@ impl AgentOs { self.kernel_readdir(path).await } + /// List directory entries with their resolved type, mirroring the TS `readDirWithTypes` used by + /// the ACP `fs/readDir` host request. `.`/`..` are filtered by the caller. A symlink is reported + /// as a symlink (lstat-style, not followed); other entries are stat'd as directory vs file. + pub(crate) async fn acp_read_dir_with_types(&self, path: &str) -> Result> { + Self::assert_safe_absolute_path(path)?; + let names = self.kernel_readdir(path).await?; + let mut entries = Vec::with_capacity(names.len()); + for name in names { + if name == "." || name == ".." { + continue; + } + let full_path = Self::join_child(path, &name); + let stat = self.kernel_lstat(&full_path).await?; + entries.push(VirtualDirEntry { + name, + is_directory: stat.is_directory, + is_symbolic_link: stat.is_symbolic_link, + }); + } + Ok(entries) + } + /// Recursive BFS listing; symlinks recorded but NOT descended; a stat failure aborts the call. pub async fn readdir_recursive( &self, diff --git a/crates/client/src/json_rpc.rs b/crates/client/src/json_rpc.rs index 78a1a3819..f479937b5 100644 --- a/crates/client/src/json_rpc.rs +++ b/crates/client/src/json_rpc.rs @@ -59,6 +59,34 @@ pub struct AcpTimeoutErrorData { pub recent_activity: Vec, } +/// Structured `data` for an "unknown session" error (`kind: "unknown_session"`). +/// +/// Mirrors `UnknownSessionErrorData` in `packages/core/src/json-rpc.ts`. The +/// sidecar normalizes an adapter's native "no such session" error from +/// `session/load` into this shape so resume orchestration can distinguish "the +/// store didn't survive the wake — fall through to a fresh session" from a +/// transport/timeout error (which must propagate). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnknownSessionErrorData { + pub kind: String, + /// Optional metadata. The discriminator is `kind` alone — the sidecar's + /// normalized error carries only `kind`, so this stays optional to keep the + /// sidecar and client contracts aligned with the TS mirror. + #[serde(rename = "sessionId", skip_serializing_if = "Option::is_none", default)] + pub session_id: Option, +} + +/// Whether a JSON-RPC error's `data` is an [`UnknownSessionErrorData`] +/// (discriminated by `kind == "unknown_session"`; `sessionId` is optional). +/// Mirrors the TS `isUnknownSessionErrorData()` discriminator. +pub fn is_unknown_session(error: &JsonRpcError) -> bool { + error + .data + .as_ref() + .and_then(|data| data.as_object()) + .is_some_and(|data| data.get("kind").and_then(Value::as_str) == Some("unknown_session")) +} + /// A JSON-RPC 2.0 notification (no id). `params` is opaque JSON. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct JsonRpcNotification { diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 379d3ef43..1537362f6 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -6,7 +6,7 @@ //! `AgentOs` client (`packages/core/src/agent-os.ts`): every public method, option type, return //! type, event, and error maps across with identical semantics. //! -//! The client spawns the native `agent-os-sidecar` binary and speaks the existing framed BARE +//! The client spawns the native `agentos-sidecar` binary and speaks the existing framed BARE //! protocol over its stdio (see [`transport`]). It does NOT embed the kernel in-process and does NOT //! define a new sidecar wire protocol. The generated Secure Exec schema surface comes from //! `secure_exec_client::wire`; Agent OS layers ACP/session semantics on top of those generated wire @@ -90,11 +90,13 @@ pub use shell::{ConnectTerminalOptions, OpenShellOptions, ShellHandle}; pub use session::{ AgentCapabilities, AgentInfo, AgentRegistryEntry, ConfigAllowedValue, CreateSessionOptions, McpServerConfig, PermissionReply, PermissionRequest, PromptCapabilities, PromptResult, - SessionConfigOption, SessionId, SessionInfo, SessionInitData, SessionMode, SessionModeState, + ResumeSessionOptions, ResumeSessionResult, SessionConfigOption, SessionId, SessionInfo, + SessionInitData, SessionMode, SessionModeState, }; pub use json_rpc::{ - AcpTimeoutErrorData, JsonRpcError, JsonRpcId, JsonRpcNotification, JsonRpcResponse, + is_unknown_session, AcpTimeoutErrorData, JsonRpcError, JsonRpcId, JsonRpcNotification, + JsonRpcResponse, UnknownSessionErrorData, }; pub use cron::{ diff --git a/crates/client/src/session.rs b/crates/client/src/session.rs index 753777b3f..e1b366414 100644 --- a/crates/client/src/session.rs +++ b/crates/client/src/session.rs @@ -8,7 +8,7 @@ //! [`JsonRpcResponse`] whose `error` field may be set. use std::collections::{BTreeMap, BTreeSet}; - +use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::atomic::Ordering; @@ -17,16 +17,16 @@ use futures::Stream; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use agent_os_protocol::generated::v1::{ +use agentos_protocol::generated::v1::{ AcpCloseSessionRequest, AcpCreateSessionRequest, AcpGetSessionStateRequest, AcpRequest, - AcpResponse, AcpRuntimeKind, AcpSessionCreatedResponse, AcpSessionRequest, - AcpSessionStateResponse, + AcpResponse, AcpResumeSessionRequest, AcpRuntimeKind, AcpSessionCreatedResponse, + AcpSessionRequest, AcpSessionStateResponse, }; -use agent_os_protocol::ACP_EXTENSION_NAMESPACE; +use agentos_protocol::ACP_EXTENSION_NAMESPACE; use secure_exec_client::wire; use crate::agent_os::{AgentOs, SessionEntry}; -use crate::config::ToolKit; +use crate::config::{AgentOsConfig, MountConfig, ToolKit}; use crate::error::ClientError; use crate::json_rpc::{JsonRpcError, JsonRpcId, JsonRpcNotification, JsonRpcResponse}; use crate::stream::Subscription; @@ -35,6 +35,17 @@ use crate::{CLOSED_SESSION_ID_RETENTION_LIMIT, PERMISSION_TIMEOUT_MS}; /// ACP method name for legacy permission requests/responses. const LEGACY_PERMISSION_METHOD: &str = "request/permission"; +/// Reserved `env` key on `AcpResumeSessionRequest` carrying the resolved adapter +/// bin entrypoint. The resume wire request omits a dedicated `adapterEntrypoint` +/// field; the sidecar reads the entrypoint from this key and strips it before +/// launching the adapter. Must stay in sync with the sidecar constant of the same +/// name in `crates/agentos-sidecar/src/acp_extension.rs`. +const RESUME_ADAPTER_ENTRYPOINT_ENV: &str = "AGENT_OS_RESUME_ADAPTER_ENTRYPOINT"; + +/// ACP method name for permission requests issued by the agent to the host (TS +/// `ACP_PERMISSION_METHOD`). Used by the host-request ACP dispatcher in `agent_os.rs`. +pub(crate) const ACP_PERMISSION_METHOD: &str = "session/request_permission"; + /// Maximum in-flight session RPC requests per session. const SESSION_PENDING_REQUEST_LIMIT: usize = 1024; @@ -113,7 +124,7 @@ struct AgentConfigDef { fn agent_config(agent_type: &str) -> Option { Some(match agent_type { "pi" => AgentConfigDef { - acp_adapter: "@rivet-dev/agent-os-pi", + acp_adapter: "@rivet-dev/agentos-pi", agent_package: "@mariozechner/pi-coding-agent", default_env: &[], }, @@ -123,18 +134,18 @@ fn agent_config(agent_type: &str) -> Option { default_env: &[], }, "opencode" => AgentConfigDef { - acp_adapter: "@rivet-dev/agent-os-opencode", - agent_package: "@rivet-dev/agent-os-opencode", + acp_adapter: "@rivet-dev/agentos-opencode", + agent_package: "@rivet-dev/agentos-opencode", default_env: &[ ("OPENCODE_DISABLE_CONFIG_DEP_INSTALL", "1"), ("OPENCODE_DISABLE_EMBEDDED_WEB_UI", "1"), ], }, "claude" => AgentConfigDef { - acp_adapter: "@rivet-dev/agent-os-claude", + acp_adapter: "@rivet-dev/agentos-claude", agent_package: "@anthropic-ai/claude-agent-sdk", default_env: &[ - ("CLAUDE_AGENT_SDK_CLIENT_APP", "@rivet-dev/agent-os"), + ("CLAUDE_AGENT_SDK_CLIENT_APP", "@rivet-dev/agentos"), ("CLAUDE_CODE_SIMPLE", "1"), ("CLAUDE_CODE_FORCE_AGENT_OS_RIPGREP", "1"), ("CLAUDE_CODE_DEFER_GROWTHBOOK_INIT", "1"), @@ -157,21 +168,43 @@ fn agent_config(agent_type: &str) -> Option { }) } -/// Resolve a package's VM bin entrypoint from the host `node_modules` (port of TS -/// `_resolvePackageBin`, using `module_access_cwd` rather than software roots). Returns the -/// guest-visible path `/root/node_modules//`. +/// Resolve a package's VM bin entrypoint from the host `node_modules` (port of +/// TS `_resolvePackageBin`). Prefer legacy `module_access_cwd/node_modules`, +/// then fall back to the host directory backing a native `/root/node_modules` +/// mount. The latter is the RivetKit actor path: the TS shim no longer forwards +/// `moduleAccessCwd`; callers explicitly mount the desired `node_modules` +/// directory instead. fn resolve_package_bin( - module_access_cwd: &str, + config: &AgentOsConfig, package_name: &str, bin_name: Option<&str>, ) -> std::result::Result { - let pkg_json_path = std::path::Path::new(module_access_cwd) - .join("node_modules") - .join(package_name) - .join("package.json"); - let contents = std::fs::read_to_string(&pkg_json_path).map_err(|error| { - ClientError::Sidecar(format!("cannot read {}: {error}", pkg_json_path.display())) - })?; + let mut candidates = Vec::new(); + let module_access_cwd = config + .module_access_cwd + .clone() + .unwrap_or_else(|| ".".to_string()); + candidates.push( + Path::new(&module_access_cwd) + .join("node_modules") + .join(package_name) + .join("package.json"), + ); + candidates.extend(node_modules_mount_package_json_paths(config, package_name)); + + let contents = candidates + .iter() + .find_map(|path| std::fs::read_to_string(path).ok()) + .ok_or_else(|| { + let looked = candidates + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", "); + ClientError::Sidecar(format!( + "cannot resolve package {package_name}: no package.json found (looked in {looked})" + )) + })?; let pkg: Value = serde_json::from_str(&contents).map_err(|error| { ClientError::Sidecar(format!("invalid package.json for {package_name}: {error}")) })?; @@ -191,6 +224,35 @@ fn resolve_package_bin( Ok(format!("/root/node_modules/{package_name}/{bin_entry}")) } +fn node_modules_mount_package_json_paths( + config: &AgentOsConfig, + package_name: &str, +) -> Vec { + config + .mounts + .iter() + .filter_map(|mount| { + let MountConfig::Native { + path, + plugin, + read_only: _, + } = mount + else { + return None; + }; + if path != "/root/node_modules" || plugin.id != "host_dir" { + return None; + } + let host_path = plugin + .config + .as_ref() + .and_then(|config| config.get("hostPath")) + .and_then(Value::as_str)?; + Some(Path::new(host_path).join(package_name).join("package.json")) + }) + .collect() +} + /// MCP server config used by `create_session`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] @@ -229,6 +291,30 @@ pub struct SessionId { pub session_id: String, } +/// Result of `resume_session`. `session_id` is the live ACP session id in the +/// fresh VM: equal to the requested id for native loads, or a freshly assigned id +/// for the fallback tier — the caller (e.g. the actor) remaps `external -> live`. +/// `mode` is `"native"` or `"fallback"`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResumeSessionResult { + #[serde(rename = "sessionId")] + pub session_id: String, + pub mode: String, +} + +/// Options for `resume_session`. Mirrors the durability-dependent fields the +/// sidecar fallback tier needs to re-launch the adapter, plus the transcript +/// pointer. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ResumeSessionOptions { + /// Guest-readable path to the reconstructed transcript. When present, the + /// fallback tier arms a continuation preamble pointing the agent at it. + pub transcript_path: Option, + /// Default `"/home/user"`. + pub cwd: Option, + pub env: BTreeMap, +} + /// Result of `prompt`. #[derive(Debug, Clone, PartialEq)] pub struct PromptResult { @@ -1174,17 +1260,12 @@ impl AgentOs { /// shared modules this task may not edit. Returns an empty list until that infrastructure is /// added. See `todosLeft`. pub fn list_agents(&self) -> Vec { - let module_access_cwd = self - .config() - .module_access_cwd - .clone() - .unwrap_or_else(|| ".".to_string()); BUILTIN_AGENT_IDS .iter() .filter_map(|id| { let config = agent_config(id)?; let installed = - resolve_package_bin(&module_access_cwd, config.acp_adapter, None).is_ok(); + resolve_package_bin(self.config(), config.acp_adapter, None).is_ok(); Some(AgentRegistryEntry { id: (*id).to_string(), acp_adapter: config.acp_adapter.to_string(), @@ -1207,15 +1288,10 @@ impl AgentOs { ) -> Result { let config = agent_config(agent_type) .ok_or_else(|| ClientError::Sidecar(format!("Unknown agent type: {agent_type}")))?; - let module_access_cwd = self - .config() - .module_access_cwd - .clone() - .unwrap_or_else(|| ".".to_string()); // Resolve the ACP adapter's VM bin entrypoint from the host node_modules (mirrors TS // `_resolveAdapterBin` / `_resolvePackageBin`). - let adapter_entrypoint = resolve_package_bin(&module_access_cwd, config.acp_adapter, None)?; + let adapter_entrypoint = resolve_package_bin(self.config(), config.acp_adapter, None)?; // Merge env: agent default_env (lowest) -> user env (wins). let mut env: BTreeMap = config @@ -1229,7 +1305,7 @@ impl AgentOs { if (agent_type == "pi" || agent_type == "pi-cli") && !env.contains_key("PI_ACP_PI_COMMAND") { if let Ok(pi_command) = - resolve_package_bin(&module_access_cwd, config.agent_package, Some("pi")) + resolve_package_bin(self.config(), config.agent_package, Some("pi")) { env.insert("PI_ACP_PI_COMMAND".to_string(), pi_command); } @@ -1339,6 +1415,89 @@ impl AgentOs { } } + /// Resume a session that exists in durable storage but is not live in this VM + /// (e.g. after a Rivet actor slept and woke with a fresh VM). Thin forwarder: + /// resolves the agent config + adapter entrypoint exactly as `create_session` + /// does, then forwards a single [`AcpResumeSessionRequest`] to the sidecar, + /// which owns the resume state machine (native `session/load` when supported, + /// else `session/new` + transcript-continuation preamble). The returned + /// `session_id` is the live id in this VM (equal to `session_id` for native + /// loads, freshly assigned for the fallback); the caller remaps + /// `external -> live`. The new live session is registered + hydrated locally so + /// subsequent prompts route to it. + /// + /// Resume depends on a durable root; on a non-durable (default in-memory) root + /// there is no surviving store and the fallback tier always runs. + pub async fn resume_session( + &self, + session_id: &str, + agent_type: &str, + options: ResumeSessionOptions, + ) -> Result { + let config = agent_config(agent_type) + .ok_or_else(|| ClientError::Sidecar(format!("Unknown agent type: {agent_type}")))?; + let adapter_entrypoint = resolve_package_bin(self.config(), config.acp_adapter, None)?; + + // Merge env: agent default_env (lowest) -> user env (wins), then carry the + // resolved adapter entrypoint under the sidecar's reserved key (the resume + // wire request has no dedicated `adapterEntrypoint` field). + let mut env: BTreeMap = config + .default_env + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + for (key, value) in &options.env { + env.insert(key.clone(), value.clone()); + } + if (agent_type == "pi" || agent_type == "pi-cli") && !env.contains_key("PI_ACP_PI_COMMAND") + { + if let Ok(pi_command) = + resolve_package_bin(self.config(), config.agent_package, Some("pi")) + { + env.insert("PI_ACP_PI_COMMAND".to_string(), pi_command); + } + } + env.insert( + RESUME_ADAPTER_ENTRYPOINT_ENV.to_string(), + adapter_entrypoint, + ); + + let cwd = options + .cwd + .clone() + .unwrap_or_else(|| "/home/user".to_string()); + + let response = self + .send_acp_request(AcpRequest::AcpResumeSessionRequest( + AcpResumeSessionRequest { + session_id: session_id.to_string(), + agent_type: agent_type.to_string(), + transcript_path: options.transcript_path.clone(), + cwd, + env: env.into_iter().collect(), + }, + )) + .await?; + let AcpResponse::AcpSessionResumedResponse(resumed) = response else { + return Err(unexpected_acp_response("AcpResumeSessionRequest", response).into()); + }; + + // Register + hydrate the live session so subsequent prompts route to it. + let empty_state = SessionStateResponse { + modes: None, + config_options: Vec::new(), + agent_capabilities: None, + agent_info: None, + }; + self.register_session(&resumed.session_id, agent_type, &empty_state) + .await?; + + Ok(ResumeSessionResult { + session_id: resumed.session_id, + mode: resumed.mode, + }) + } + /// Destroy a session. Best-effort `cancel_session` then internal close. pub async fn destroy_session(&self, session_id: &str) -> Result<()> { self.require_session(session_id, |_| ())?; diff --git a/crates/client/src/shell.rs b/crates/client/src/shell.rs index 5979a3505..e7cd09194 100644 --- a/crates/client/src/shell.rs +++ b/crates/client/src/shell.rs @@ -367,6 +367,155 @@ impl AgentOs { Ok(ShellHandle { shell_id }) } + /// Open a PTY-backed terminal for the ACP `terminal/create` host request. Like [`open_shell`] it + /// registers a `shell-N` entry (so `write_shell`/`resize_shell`/`close_shell` address it), but the + /// background fan-out also (a) appends every stdout/stderr chunk to the caller's output buffer via + /// `on_output`, and (b) records the process exit code into `exit_tx` so `terminal/output` and + /// `terminal/wait_for_exit` can observe it. Mirrors the TS `_handleAcpCreateTerminal`, which builds + /// the terminal on top of `openShell` and tracks `output` / `exitCode` / `waitPromise`. + pub(crate) fn acp_open_terminal( + &self, + options: OpenShellOptions, + exit_tx: tokio::sync::watch::Sender>, + on_output: impl Fn(&[u8]) + Send + Sync + 'static, + ) -> Result { + let inner = self.inner(); + let counter = inner.shell_counter.fetch_add(1, Ordering::SeqCst) + 1; + let shell_id = format!("shell-{counter}"); + let process_id = format!("shell-{}", Uuid::new_v4()); + + let (data_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY); + let (stderr_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(), + process_id: process_id.clone(), + spawned_tx: spawned_tx.clone(), + }; + let _ = inner.shells.insert(shell_id.clone(), entry); + + let command = options + .command + .clone() + .unwrap_or_else(|| DEFAULT_SHELL_COMMAND.to_string()); + let execute = wire::ExecuteRequest { + process_id: process_id.clone(), + command: Some(command), + runtime: None, + entrypoint: None, + args: options.args.clone(), + env: options.env.clone().into_iter().collect(), + cwd: options.cwd.clone(), + wasm_permission_tier: None, + }; + + let agent = self.clone(); + let ownership = self.vm_ownership(); + let route_process_id = process_id.clone(); + let exit_shell_id = shell_id.clone(); + let exit_key = counter; + let on_output = std::sync::Arc::new(on_output); + let handle = tokio::spawn(async move { + let mut events = agent.transport().subscribe_wire_events(); + + let response = match agent + .transport() + .request_wire( + ownership.clone(), + wire::RequestPayload::ExecuteRequest(execute), + ) + .await + { + Ok(response) => response, + Err(error) => { + tracing::warn!(?error, shell_id = %exit_shell_id, "acp_open_terminal spawn failed"); + agent.inner().shells.remove(&exit_shell_id); + agent.inner().pending_shell_exits.remove(&exit_key); + let _ = exit_tx.send(Some(1)); + return; + } + }; + + if let wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse { + pid: Some(pid), + .. + }) = response + { + agent + .inner() + .shells + .update(&exit_shell_id, |_, existing| existing.pid = pid); + } + let _ = spawned_tx.send(true); + + let mut exit_code: i32 = 0; + loop { + let (_scope, payload) = match events.recv().await { + Ok(value) => value, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + }; + match payload { + EventPayload::ProcessOutputEvent(output) => { + 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. + on_output(&output.chunk); + } + EventPayload::ProcessExitedEvent(exited) => { + if exited.process_id == route_process_id { + exit_code = exited.exit_code; + break; + } + } + EventPayload::VmLifecycleEvent(_) + | EventPayload::StructuredEvent(_) + | EventPayload::ExtEnvelope(_) => {} + } + } + + agent.inner().pending_shell_exits.remove(&exit_key); + agent.inner().shells.remove_if(&exit_shell_id, |existing| { + existing.process_id == route_process_id + }); + let _ = exit_tx.send(Some(exit_code)); + }); + + // The fan-out/exit task is tracked in `pending_shell_exits` (drained by `dispose`), exactly + // like `open_shell`. It ends naturally when the process exits or is killed via + // `close_shell` / `acp_kill_terminal_shell`. + let _ = inner.pending_shell_exits.insert(counter, handle); + Ok(ShellHandle { shell_id }) + } + + /// Kill the backing process of an ACP terminal shell (SIGTERM), without removing the shell entry + /// or the host-terminal registry entry. Used by `terminal/kill`, which (unlike `close_shell` / + /// `terminal/release`) leaves the terminal addressable for output/exit queries afterward. + pub(crate) fn acp_kill_terminal_shell( + &self, + shell_id: &str, + ) -> std::result::Result<(), ClientError> { + let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?; + let agent = self.clone(); + let ownership = self.vm_ownership(); + tokio::spawn(async move { + wait_for_spawn(spawned_rx).await; + let payload = wire::RequestPayload::KillProcessRequest(wire::KillProcessRequest { + process_id, + signal: String::from("SIGTERM"), + }); + if let Err(error) = agent.transport().request_wire(ownership, payload).await { + tracing::warn!(?error, "acp_kill_terminal_shell failed"); + } + }); + Ok(()) + } + /// Connect a terminal bound to host stdio. Returns a PID. NOT tracked in the shells map; cannot /// be addressed by other shell methods. Killed during dispose via the ACP-terminal registry. /// diff --git a/crates/client/src/sidecar.rs b/crates/client/src/sidecar.rs index 901011915..992352a08 100644 --- a/crates/client/src/sidecar.rs +++ b/crates/client/src/sidecar.rs @@ -16,18 +16,18 @@ use secure_exec_client::wire; use crate::agent_os::AgentOs; use crate::error::ClientError; -use crate::transport::SidecarTransport; +use crate::transport::SidecarProcess; /// Maximum shared sidecar pool entries retained process-wide. const SHARED_SIDECAR_POOL_LIMIT: usize = 1024; /// Env var that overrides the Agent OS wrapper sidecar binary path. -const AGENT_OS_SIDECAR_BIN_ENV: &str = "AGENT_OS_SIDECAR_BIN"; +const AGENTOS_SIDECAR_BIN_ENV: &str = "AGENTOS_SIDECAR_BIN"; /// The lazily-established shared sidecar process + authenticated connection. Multiple VMs in the same /// (shared) sidecar reuse this single process/connection, each opening its own session + VM on it. pub(crate) struct SharedConnection { - pub(crate) transport: Arc, + pub(crate) transport: Arc, pub(crate) connection_id: String, } @@ -113,7 +113,7 @@ pub struct AgentOsSidecar { pub(crate) shared_pool: Option, pub(crate) state: AtomicU8, pub(crate) active_vm_count: AtomicU32, - /// Absolute path to the `agent-os-sidecar` binary, threaded from `AgentOsConfig` when present. + /// Absolute path to the `agentos-sidecar` binary, threaded from `AgentOsConfig` when present. /// Otherwise `ensure_connection` resolves the Agent OS env fallback and passes an explicit path /// to the generic transport. pub(crate) sidecar_binary_path: Option, @@ -142,12 +142,12 @@ impl AgentOsSidecar { } /// Get (or lazily establish) the shared sidecar process + authenticated connection. The first - /// caller spawns the `agent-os-sidecar` child and runs the `Authenticate` handshake; subsequent + /// caller spawns the `agentos-sidecar` child and runs the `Authenticate` handshake; subsequent /// callers reuse the same transport + connection id. This is what makes a shared sidecar host /// multiple VMs in one process. pub(crate) async fn ensure_connection( &self, - ) -> Result<(Arc, String, usize), ClientError> { + ) -> Result<(Arc, String, usize), ClientError> { let mut guard = self.connection.lock().await; if let Some(existing) = guard.as_ref() { let max_frame = existing.transport.max_frame_bytes(); @@ -158,7 +158,7 @@ impl AgentOsSidecar { )); } - let transport = SidecarTransport::spawn(Some(self.resolved_sidecar_binary_path())).await?; + let transport = SidecarProcess::spawn(Some(self.resolved_sidecar_binary_path())).await?; let authed = match transport .request_wire( wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership { @@ -208,8 +208,8 @@ impl AgentOsSidecar { fn resolved_sidecar_binary_path(&self) -> String { self.sidecar_binary_path .clone() - .or_else(|| std::env::var(AGENT_OS_SIDECAR_BIN_ENV).ok()) - .unwrap_or_else(|| "agent-os-sidecar".to_string()) + .or_else(|| std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok()) + .unwrap_or_else(|| "agentos-sidecar".to_string()) } /// Snapshot the sidecar's current state. SYNC. @@ -364,7 +364,7 @@ fn shared_sidecar_pool_limit_error() -> ClientError { } impl AgentOs { - /// Create an explicit sidecar handle. `sidecar_id` defaults to `agent-os-sidecar-`. + /// Create an explicit sidecar handle. `sidecar_id` defaults to `agentos-sidecar-`. /// /// Parity with TypeScript `createAgentOsSidecarInternal`: the explicit handle carries an /// `Explicit` placement whose `sidecar_id` echoes the resolved id and has no shared pool. @@ -372,7 +372,7 @@ impl AgentOs { sidecar_id: Option, ) -> Result, ClientError> { let sidecar_id = - sidecar_id.unwrap_or_else(|| format!("agent-os-sidecar-{}", Uuid::new_v4())); + sidecar_id.unwrap_or_else(|| format!("agentos-sidecar-{}", Uuid::new_v4())); let placement = AgentOsSidecarPlacement::Explicit { sidecar_id: sidecar_id.clone(), }; @@ -468,8 +468,8 @@ mod tests { #[test] fn sidecar_binary_path_prefers_explicit_wrapper_path() { let _guard = ENV_LOCK.lock().expect("env lock"); - let previous = std::env::var(AGENT_OS_SIDECAR_BIN_ENV).ok(); - std::env::set_var(AGENT_OS_SIDECAR_BIN_ENV, "/tmp/from-env"); + let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok(); + std::env::set_var(AGENTOS_SIDECAR_BIN_ENV, "/tmp/from-env"); let sidecar = AgentOsSidecar::new( "explicit-test", AgentOsSidecarPlacement::Explicit { @@ -481,34 +481,34 @@ mod tests { assert_eq!(sidecar.resolved_sidecar_binary_path(), "/tmp/from-config"); - restore_env(AGENT_OS_SIDECAR_BIN_ENV, previous); + restore_env(AGENTOS_SIDECAR_BIN_ENV, previous); } #[test] fn sidecar_binary_path_uses_agent_os_env_fallback() { let _guard = ENV_LOCK.lock().expect("env lock"); - let previous = std::env::var(AGENT_OS_SIDECAR_BIN_ENV).ok(); - std::env::set_var(AGENT_OS_SIDECAR_BIN_ENV, "/tmp/agent-os-sidecar"); + let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok(); + std::env::set_var(AGENTOS_SIDECAR_BIN_ENV, "/tmp/agentos-sidecar"); let sidecar = shared("env-test", SidecarState::Ready); assert_eq!( sidecar.resolved_sidecar_binary_path(), - "/tmp/agent-os-sidecar" + "/tmp/agentos-sidecar" ); - restore_env(AGENT_OS_SIDECAR_BIN_ENV, previous); + restore_env(AGENTOS_SIDECAR_BIN_ENV, previous); } #[test] fn sidecar_binary_path_defaults_to_agent_os_wrapper() { let _guard = ENV_LOCK.lock().expect("env lock"); - let previous = std::env::var(AGENT_OS_SIDECAR_BIN_ENV).ok(); - std::env::remove_var(AGENT_OS_SIDECAR_BIN_ENV); + let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok(); + std::env::remove_var(AGENTOS_SIDECAR_BIN_ENV); let sidecar = shared("default-test", SidecarState::Ready); - assert_eq!(sidecar.resolved_sidecar_binary_path(), "agent-os-sidecar"); + assert_eq!(sidecar.resolved_sidecar_binary_path(), "agentos-sidecar"); - restore_env(AGENT_OS_SIDECAR_BIN_ENV, previous); + restore_env(AGENTOS_SIDECAR_BIN_ENV, previous); } fn restore_env(key: &str, value: Option) { diff --git a/crates/client/src/transport.rs b/crates/client/src/transport.rs index 81cf677c1..df219ab4c 100644 --- a/crates/client/src/transport.rs +++ b/crates/client/src/transport.rs @@ -1 +1,2 @@ -pub use secure_exec_client::{SidecarTransport, WireSidecarCallback}; +pub use secure_exec_client::SidecarTransport as SidecarProcess; +pub use secure_exec_client::WireSidecarCallback; diff --git a/crates/client/tests/common/mod.rs b/crates/client/tests/common/mod.rs index 8881c5071..17a895829 100644 --- a/crates/client/tests/common/mod.rs +++ b/crates/client/tests/common/mod.rs @@ -1,30 +1,30 @@ -//! Shared e2e helpers: resolve/point at the real `agent-os-sidecar` binary and build VMs. +//! Shared e2e helpers: resolve/point at the real `agentos-sidecar` binary and build VMs. //! -//! Resolve order for the binary: `AGENT_OS_SIDECAR_BIN`, else `/target/debug/agent-os-sidecar`. -//! Build it first with `cargo build -p agent-os-sidecar`. +//! Resolve order for the binary: `AGENTOS_SIDECAR_BIN`, else `/target/debug/agentos-sidecar`. +//! Build it first with `cargo build -p agentos-sidecar`. #![allow(dead_code)] use std::path::PathBuf; use std::sync::Once; -use agent_os_client::config::{AgentOsConfig, AgentOsSidecarConfig, MountConfig, MountPlugin}; -use agent_os_client::AgentOs; +use agentos_client::config::{AgentOsConfig, AgentOsSidecarConfig, MountConfig, MountPlugin}; +use agentos_client::AgentOs; static INIT: Once = Once::new(); pub fn ensure_sidecar_env() { INIT.call_once(|| { - if std::env::var("AGENT_OS_SIDECAR_BIN").is_err() { + if std::env::var("AGENTOS_SIDECAR_BIN").is_err() { let bin = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../target/debug/agent-os-sidecar"); + .join("../../target/debug/agentos-sidecar"); // `std::env::set_var` is `unsafe` in the Rust 2024 edition (process-global mutation that // can race other threads reading the environment). This runs once, single-threaded, under // `Once::call_once` before any VM is created. The `allow` keeps it warning-free on the // 2021 edition, where the call is still safe. #[allow(unused_unsafe)] unsafe { - std::env::set_var("AGENT_OS_SIDECAR_BIN", bin); + std::env::set_var("AGENTOS_SIDECAR_BIN", bin); } } }); @@ -33,7 +33,7 @@ pub fn ensure_sidecar_env() { /// Whether the sidecar binary is present. pub fn sidecar_available() -> bool { ensure_sidecar_env(); - std::env::var("AGENT_OS_SIDECAR_BIN") + std::env::var("AGENTOS_SIDECAR_BIN") .map(|path| PathBuf::from(path).exists()) .unwrap_or(false) } @@ -54,7 +54,7 @@ pub fn require_sidecar(test_name: &str) -> bool { eprintln!("skipping {message}"); false } else { - panic!("{message}; build it with `cargo build -p agent-os-sidecar` or set AGENT_OS_CLIENT_ALLOW_E2E_SKIPS=1 for local skip-only runs"); + panic!("{message}; build it with `cargo build -p agentos-sidecar` or set AGENT_OS_CLIENT_ALLOW_E2E_SKIPS=1 for local skip-only runs"); } } @@ -167,10 +167,10 @@ pub async fn new_vm_with_commands() -> Option { ensure_sidecar_env(); let wasm_dir = coreutils_wasm_dir()?; let config = AgentOsConfig { - software: vec![agent_os_client::SoftwareInput { + software: vec![agentos_client::SoftwareInput { package: wasm_dir.to_string_lossy().into_owned(), version: None, - kind: agent_os_client::SoftwareKind::WasmCommands, + kind: agentos_client::SoftwareKind::WasmCommands, }], ..Default::default() }; @@ -185,7 +185,7 @@ pub async fn new_vm_with_commands() -> Option { /// registry WASM command packages are absent (the common case in unbuilt trees), so the /// process/shell/fetch suites can gate cleanly without each re-implementing the probe. pub async fn wasm_commands_available(os: &AgentOs) -> bool { - os.exec("sh", agent_os_client::ExecOptions::default()) + os.exec("sh", agentos_client::ExecOptions::default()) .await .is_ok() } diff --git a/crates/client/tests/cron_e2e.rs b/crates/client/tests/cron_e2e.rs index 5636bd988..014349e32 100644 --- a/crates/client/tests/cron_e2e.rs +++ b/crates/client/tests/cron_e2e.rs @@ -1,4 +1,4 @@ -//! Cron e2e against a real `agent-os-sidecar`. Cron is client-side logic (CronManager + +//! Cron e2e against a real `agentos-sidecar`. Cron is client-side logic (CronManager + //! TimerScheduleDriver); a `Callback` action runs in-process, so this needs no V8/WASM. //! //! Covers: a near-future one-shot callback actually fires and emits Fire/Complete events, and the @@ -9,7 +9,7 @@ mod common; use std::sync::Arc; use std::time::Duration; -use agent_os_client::{CronAction, CronEvent, CronJobOptions}; +use agentos_client::{CronAction, CronEvent, CronJobOptions}; use chrono::Utc; #[tokio::test] diff --git a/crates/client/tests/cron_grammar_e2e.rs b/crates/client/tests/cron_grammar_e2e.rs index 62ed2e63e..75befdb3f 100644 --- a/crates/client/tests/cron_grammar_e2e.rs +++ b/crates/client/tests/cron_grammar_e2e.rs @@ -8,7 +8,7 @@ mod common; -use agent_os_client::{AgentOs, ClientError, CronAction, CronJobOptions}; +use agentos_client::{AgentOs, ClientError, CronAction, CronJobOptions}; use chrono::Utc; fn noop_action() -> CronAction { diff --git a/crates/client/tests/e2e_smoke.rs b/crates/client/tests/e2e_smoke.rs index e04c44733..92dc977d9 100644 --- a/crates/client/tests/e2e_smoke.rs +++ b/crates/client/tests/e2e_smoke.rs @@ -1,23 +1,23 @@ -//! Smoke e2e: the client spawns a real `agent-os-sidecar`, runs the full create handshake, does a +//! Smoke e2e: the client spawns a real `agentos-sidecar`, runs the full create handshake, does a //! filesystem round-trip through the kernel VFS, and shuts down cleanly. //! //! Filesystem ops are used (not `exec`) because they go straight through the kernel VFS and do not //! require WASM command packages, which are not checked into git. //! -//! Requires the sidecar binary. Resolve order: `AGENT_OS_SIDECAR_BIN`, else -//! `/target/debug/agent-os-sidecar`. Build it first: `cargo build -p agent-os-sidecar`. +//! Requires the sidecar binary. Resolve order: `AGENTOS_SIDECAR_BIN`, else +//! `/target/debug/agentos-sidecar`. Build it first: `cargo build -p agentos-sidecar`. use std::path::PathBuf; -use agent_os_client::config::AgentOsConfig; -use agent_os_client::fs::FileContent; -use agent_os_client::AgentOs; +use agentos_client::config::AgentOsConfig; +use agentos_client::fs::FileContent; +use agentos_client::AgentOs; fn sidecar_bin() -> PathBuf { - if let Ok(path) = std::env::var("AGENT_OS_SIDECAR_BIN") { + if let Ok(path) = std::env::var("AGENTOS_SIDECAR_BIN") { return PathBuf::from(path); } - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/debug/agent-os-sidecar") + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/debug/agentos-sidecar") } #[tokio::test] @@ -25,10 +25,10 @@ async fn smoke_connect_and_filesystem_round_trip() { let bin = sidecar_bin(); assert!( bin.exists(), - "sidecar binary not found at {} (run: cargo build -p agent-os-sidecar)", + "sidecar binary not found at {} (run: cargo build -p agentos-sidecar)", bin.display() ); - std::env::set_var("AGENT_OS_SIDECAR_BIN", &bin); + std::env::set_var("AGENTOS_SIDECAR_BIN", &bin); let os = AgentOs::create(AgentOsConfig::default()) .await diff --git a/crates/client/tests/exec_command_line_e2e.rs b/crates/client/tests/exec_command_line_e2e.rs index 72fba9bd8..c5af5f1cd 100644 --- a/crates/client/tests/exec_command_line_e2e.rs +++ b/crates/client/tests/exec_command_line_e2e.rs @@ -9,7 +9,7 @@ mod common; -use agent_os_client::ExecOptions; +use agentos_client::ExecOptions; #[tokio::test] async fn exec_command_line_paths() { diff --git a/crates/client/tests/fetch_e2e.rs b/crates/client/tests/fetch_e2e.rs index 9c5a08ca6..4478a543b 100644 --- a/crates/client/tests/fetch_e2e.rs +++ b/crates/client/tests/fetch_e2e.rs @@ -1,4 +1,4 @@ -//! Port-based virtual `fetch` e2e against a real `agent-os-sidecar`. +//! Port-based virtual `fetch` e2e against a real `agentos-sidecar`. //! //! `fetch` dispatches to a guest HTTP server listening on a port INSIDE the kernel (never the host). //! Standing up that guest listener requires the V8/JS guest runtime, which may be broken in this @@ -15,7 +15,7 @@ mod common; -use agent_os_client::AgentOs; +use agentos_client::AgentOs; use bytes::Bytes; use futures::StreamExt; diff --git a/crates/client/tests/fs_e2e.rs b/crates/client/tests/fs_e2e.rs index cc6e1c551..599908e7f 100644 --- a/crates/client/tests/fs_e2e.rs +++ b/crates/client/tests/fs_e2e.rs @@ -1,4 +1,4 @@ -//! Filesystem e2e against a real `agent-os-sidecar`. Filesystem ops go straight through the kernel +//! Filesystem e2e against a real `agentos-sidecar`. Filesystem ops go straight through the kernel //! VFS (no V8/WASM), so this is a clean, client-focused surface. //! //! One VM, many assertions (quality over quantity): text + binary round-trips, batch (never-rejects), @@ -7,10 +7,10 @@ mod common; -use agent_os_client::fs::{ +use agentos_client::fs::{ BatchWriteEntry, DeleteOptions, DirEntryType, FileContent, MkdirOptions, }; -use agent_os_client::ClientError; +use agentos_client::ClientError; #[tokio::test] async fn base_layer_exposes_default_files() { diff --git a/crates/client/tests/lifecycle_e2e.rs b/crates/client/tests/lifecycle_e2e.rs index c4011851c..75cd15177 100644 --- a/crates/client/tests/lifecycle_e2e.rs +++ b/crates/client/tests/lifecycle_e2e.rs @@ -1,9 +1,9 @@ -//! Lifecycle e2e against a real `agent-os-sidecar`: independent VMs, post-shutdown isolation, and +//! Lifecycle e2e against a real `agentos-sidecar`: independent VMs, post-shutdown isolation, and //! idempotent shutdown. No V8/WASM required. mod common; -use agent_os_client::fs::FileContent; +use agentos_client::fs::FileContent; #[tokio::test] async fn lifecycle_independent_vms_and_idempotent_shutdown() { diff --git a/crates/client/tests/mount_e2e.rs b/crates/client/tests/mount_e2e.rs index 60be26cdf..a63a4ee46 100644 --- a/crates/client/tests/mount_e2e.rs +++ b/crates/client/tests/mount_e2e.rs @@ -3,15 +3,15 @@ mod common; use std::fs; use std::path::{Path, PathBuf}; -use agent_os_client::config::{AgentOsConfig, MountConfig, MountPlugin}; -use agent_os_client::AgentOs; +use agentos_client::config::{AgentOsConfig, MountConfig, MountPlugin}; +use agentos_client::AgentOs; use uuid::Uuid; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn create_forwards_native_mounts() { if !common::sidecar_available() { panic!( - "create_forwards_native_mounts: sidecar binary is not built; build it with `cargo build -p agent-os-sidecar`" + "create_forwards_native_mounts: sidecar binary is not built; build it with `cargo build -p agentos-sidecar`" ); } diff --git a/crates/client/tests/os_instructions_e2e.rs b/crates/client/tests/os_instructions_e2e.rs index bea33944d..d4ba692b6 100644 --- a/crates/client/tests/os_instructions_e2e.rs +++ b/crates/client/tests/os_instructions_e2e.rs @@ -13,11 +13,11 @@ use std::collections::BTreeMap; use std::path::Path; use std::sync::Arc; -use agent_os_client::config::{ +use agentos_client::config::{ AgentOsConfig, AgentOsSidecarConfig, FsPermissions, HostTool, PatternPermissions, PermissionMode, Permissions, ToolKit, }; -use agent_os_client::{AgentOs, CreateSessionOptions}; +use agentos_client::{AgentOs, CreateSessionOptions}; use serde_json::json; use uuid::Uuid; @@ -170,7 +170,7 @@ fn injected_prompt(argv: &[String]) -> &str { async fn create_session_injects_assembled_system_prompt() { if !common::sidecar_available() { panic!( - "create_session_injects_assembled_system_prompt: sidecar binary is not built; build it with `cargo build -p agent-os-sidecar`" + "create_session_injects_assembled_system_prompt: sidecar binary is not built; build it with `cargo build -p agentos-sidecar`" ); } common::ensure_sidecar_env(); @@ -196,7 +196,7 @@ async fn create_session_injects_assembled_system_prompt() { async fn create_session_injects_host_tool_reference_from_client_config() { if !common::sidecar_available() { panic!( - "create_session_injects_host_tool_reference_from_client_config: sidecar binary is not built; build it with `cargo build -p agent-os-sidecar`" + "create_session_injects_host_tool_reference_from_client_config: sidecar binary is not built; build it with `cargo build -p agentos-sidecar`" ); } common::ensure_sidecar_env(); @@ -238,7 +238,7 @@ async fn create_session_injects_host_tool_reference_from_client_config() { async fn create_session_skip_os_instructions_drops_base_but_keeps_additional() { if !common::sidecar_available() { panic!( - "create_session_skip_os_instructions_drops_base_but_keeps_additional: sidecar binary is not built; build it with `cargo build -p agent-os-sidecar`" + "create_session_skip_os_instructions_drops_base_but_keeps_additional: sidecar binary is not built; build it with `cargo build -p agentos-sidecar`" ); } common::ensure_sidecar_env(); diff --git a/crates/client/tests/pi_session_e2e.rs b/crates/client/tests/pi_session_e2e.rs index 33ffb2c93..8a42d4cb6 100644 --- a/crates/client/tests/pi_session_e2e.rs +++ b/crates/client/tests/pi_session_e2e.rs @@ -1,4 +1,4 @@ -//! Real Pi agent session e2e against a real `agent-os-sidecar`. +//! Real Pi agent session e2e against a real `agentos-sidecar`. //! //! The HONEST regression gate for the agent-session path. When a built Pi adapter is available it //! ASSERTS that `create_session("pi")` succeeds and that a real prompt round-trips through the Pi @@ -23,9 +23,9 @@ use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::time::Duration; -use agent_os_client::config::AgentOsConfig; -use agent_os_client::fs::MkdirOptions; -use agent_os_client::{AgentOs, CreateSessionOptions}; +use agentos_client::config::AgentOsConfig; +use agentos_client::fs::MkdirOptions; +use agentos_client::{AgentOs, CreateSessionOptions}; const LLMOCK_SENTINEL: &str = "PONG_FROM_LLMOCK"; diff --git a/crates/client/tests/process_e2e.rs b/crates/client/tests/process_e2e.rs index ccd4d0137..49061dea5 100644 --- a/crates/client/tests/process_e2e.rs +++ b/crates/client/tests/process_e2e.rs @@ -1,4 +1,4 @@ -//! Process e2e against a real `agent-os-sidecar`. +//! Process e2e against a real `agentos-sidecar`. //! //! `exec`/`spawn` require WASM command packages (sh/echo/cat). This suite fails fast by default when //! those packages are unavailable; set `AGENT_OS_CLIENT_ALLOW_E2E_SKIPS=1` only for local skip-only @@ -12,7 +12,7 @@ mod common; use std::sync::{Arc, Mutex}; -use agent_os_client::{ClientError, ExecOptions, SpawnOptions, StdinInput}; +use agentos_client::{ClientError, ExecOptions, SpawnOptions, StdinInput}; use futures::StreamExt; #[tokio::test] diff --git a/crates/client/tests/scaffold.rs b/crates/client/tests/scaffold.rs index 47f4a6d18..fd429b8e1 100644 --- a/crates/client/tests/scaffold.rs +++ b/crates/client/tests/scaffold.rs @@ -5,7 +5,7 @@ //! implementations. This file only asserts the crate's public surface is wired so the test target //! compiles before any method bodies exist. -use agent_os_client::{ +use agentos_client::{ ACP_PROTOCOL_VERSION, CLOSED_SESSION_ID_RETENTION_LIMIT, CRON_JOB_LIMIT, PERMISSION_TIMEOUT_MS, SHELL_DISPOSE_TIMEOUT_MS, VM_READY_TIMEOUT_MS, }; diff --git a/crates/client/tests/session_e2e.rs b/crates/client/tests/session_e2e.rs index fbc8c7ae5..a1dc2509f 100644 --- a/crates/client/tests/session_e2e.rs +++ b/crates/client/tests/session_e2e.rs @@ -1,4 +1,4 @@ -//! Agent session (ACP) e2e against a real `agent-os-sidecar`. +//! Agent session (ACP) e2e against a real `agentos-sidecar`. //! //! `create_session` requires agent adapters + a mock LLM + V8 execution. In this environment the //! client. This suite fails fast by default when session creation is unavailable; set @@ -13,8 +13,8 @@ mod common; use std::collections::BTreeMap; -use agent_os_client::fs::FileContent; -use agent_os_client::{AgentOs, ClientError, CreateSessionOptions}; +use agentos_client::fs::FileContent; +use agentos_client::{AgentOs, ClientError, CreateSessionOptions}; use futures::StreamExt; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; @@ -82,7 +82,7 @@ async fn try_create_session_with_options( } } -fn agent_message_chunk_text(notification: &agent_os_client::JsonRpcNotification) -> Option<&str> { +fn agent_message_chunk_text(notification: &agentos_client::JsonRpcNotification) -> Option<&str> { let params = notification.params.as_ref()?; let update = params.get("update").unwrap_or(params); if update.get("sessionUpdate").and_then(|value| value.as_str()) != Some("agent_message_chunk") { diff --git a/crates/client/tests/shell_e2e.rs b/crates/client/tests/shell_e2e.rs index 80c4783e3..9d8f0cb04 100644 --- a/crates/client/tests/shell_e2e.rs +++ b/crates/client/tests/shell_e2e.rs @@ -1,4 +1,4 @@ -//! Shell / PTY e2e against a real `agent-os-sidecar`. +//! Shell / PTY e2e against a real `agentos-sidecar`. //! //! `open_shell` spawns a PTY-backed `sh` (a WASM command). This suite fails fast by default when //! that command is unavailable; set `AGENT_OS_CLIENT_ALLOW_E2E_SKIPS=1` only for local skip-only @@ -10,7 +10,7 @@ mod common; -use agent_os_client::{ClientError, OpenShellOptions, StdinInput}; +use agentos_client::{ClientError, OpenShellOptions, StdinInput}; use futures::StreamExt; #[tokio::test] diff --git a/crates/client/tests/sidecar_pool_e2e.rs b/crates/client/tests/sidecar_pool_e2e.rs index 7d9c40f91..78807a5d5 100644 --- a/crates/client/tests/sidecar_pool_e2e.rs +++ b/crates/client/tests/sidecar_pool_e2e.rs @@ -4,7 +4,7 @@ mod common; -use agent_os_client::fs::FileContent; +use agentos_client::fs::FileContent; #[tokio::test] async fn shared_sidecar_pooling_reuses_one_process() { diff --git a/crates/client/tests/wasm_command_mount_e2e.rs b/crates/client/tests/wasm_command_mount_e2e.rs index 372008fb8..b46fde243 100644 --- a/crates/client/tests/wasm_command_mount_e2e.rs +++ b/crates/client/tests/wasm_command_mount_e2e.rs @@ -12,7 +12,7 @@ mod common; -use agent_os_client::ExecOptions; +use agentos_client::ExecOptions; #[tokio::test] async fn wasm_command_software_mounts_into_vm() { diff --git a/examples/quickstart/package.json b/examples/quickstart/package.json index df8e0474f..53de7a6ab 100644 --- a/examples/quickstart/package.json +++ b/examples/quickstart/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agent-os-quickstart", + "name": "@rivet-dev/agentos-quickstart", "version": "0.1.0", "private": true, "description": "Quickstart examples for Agent OS. The scripts in this package are the supported example entrypoints.", @@ -22,16 +22,16 @@ "pi-extensions": "node --import tsx src/pi-extensions.ts" }, "dependencies": { - "@rivet-dev/agent-os-core": "workspace:*", - "@rivet-dev/agent-os-sandbox": "workspace:*", + "@rivet-dev/agentos-core": "workspace:*", + "@rivet-dev/agentos-sandbox": "workspace:*", "sandbox-agent": "^0.4.2", "dockerode": "^4.0.9", "get-port": "^7.1.0", "@agent-os-pkgs/common": "catalog:", "@agent-os-pkgs/git": "catalog:", - "@rivet-dev/agent-os-claude": "workspace:*", - "@rivet-dev/agent-os-opencode": "workspace:*", - "@rivet-dev/agent-os-pi": "workspace:*", + "@rivet-dev/agentos-claude": "workspace:*", + "@rivet-dev/agentos-opencode": "workspace:*", + "@rivet-dev/agentos-pi": "workspace:*", "@secure-exec/s3": "catalog:", "zod": "^4.1.11" }, diff --git a/examples/quickstart/src/agent-session.ts b/examples/quickstart/src/agent-session.ts index e341ab598..35b44bac7 100644 --- a/examples/quickstart/src/agent-session.ts +++ b/examples/quickstart/src/agent-session.ts @@ -3,12 +3,12 @@ // NOTE: This example requires an API key for the chosen agent and a working // agent runtime. It may not complete in all environments. -import claude from "@rivet-dev/agent-os-claude"; +import claude from "@rivet-dev/agentos-claude"; import common from "@agent-os-pkgs/common"; -import type { SoftwareInput } from "@rivet-dev/agent-os-core"; -import { AgentOs } from "@rivet-dev/agent-os-core"; -import opencode from "@rivet-dev/agent-os-opencode"; -import pi from "@rivet-dev/agent-os-pi"; +import type { SoftwareInput } from "@rivet-dev/agentos-core"; +import { AgentOs } from "@rivet-dev/agentos-core"; +import opencode from "@rivet-dev/agentos-opencode"; +import pi from "@rivet-dev/agentos-pi"; const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; diff --git a/examples/quickstart/src/bash.ts b/examples/quickstart/src/bash.ts index 8b9e05c36..98db013fc 100644 --- a/examples/quickstart/src/bash.ts +++ b/examples/quickstart/src/bash.ts @@ -1,7 +1,7 @@ // Run shell commands inside the VM. import common from "@agent-os-pkgs/common"; -import { AgentOs } from "@rivet-dev/agent-os-core"; +import { AgentOs } from "@rivet-dev/agentos-core"; const vm = await AgentOs.create({ software: [common] }); diff --git a/examples/quickstart/src/cron.ts b/examples/quickstart/src/cron.ts index a34c8202c..1f5e81298 100644 --- a/examples/quickstart/src/cron.ts +++ b/examples/quickstart/src/cron.ts @@ -1,7 +1,7 @@ // Cron scheduling: schedule recurring commands inside the VM. import common from "@agent-os-pkgs/common"; -import { AgentOs } from "@rivet-dev/agent-os-core"; +import { AgentOs } from "@rivet-dev/agentos-core"; const vm = await AgentOs.create({ software: [common] }); diff --git a/examples/quickstart/src/filesystem.ts b/examples/quickstart/src/filesystem.ts index ee9f68b85..f58159d39 100644 --- a/examples/quickstart/src/filesystem.ts +++ b/examples/quickstart/src/filesystem.ts @@ -11,7 +11,7 @@ // }], // }); -import { AgentOs } from "@rivet-dev/agent-os-core"; +import { AgentOs } from "@rivet-dev/agentos-core"; const vm = await AgentOs.create(); diff --git a/examples/quickstart/src/git.ts b/examples/quickstart/src/git.ts index 3962a5915..9c22714fc 100644 --- a/examples/quickstart/src/git.ts +++ b/examples/quickstart/src/git.ts @@ -3,7 +3,7 @@ import { createRequire } from "node:module"; import { dirname, resolve } from "node:path"; import common from "@agent-os-pkgs/common"; -import { AgentOs } from "@rivet-dev/agent-os-core"; +import { AgentOs } from "@rivet-dev/agentos-core"; import git from "@agent-os-pkgs/git"; type ExecResult = { @@ -14,7 +14,7 @@ type ExecResult = { const require = createRequire(import.meta.url); const MODULE_ACCESS_CWD = resolve( - dirname(require.resolve("@rivet-dev/agent-os-core")), + dirname(require.resolve("@rivet-dev/agentos-core")), "..", ); const GIT_QUICKSTART_PERMISSIONS = { diff --git a/examples/quickstart/src/hello-world.ts b/examples/quickstart/src/hello-world.ts index 5626a4ef1..f3523528f 100644 --- a/examples/quickstart/src/hello-world.ts +++ b/examples/quickstart/src/hello-world.ts @@ -1,6 +1,6 @@ // Minimal agentOS example: create a VM, write a file, read it back. -import { AgentOs } from "@rivet-dev/agent-os-core"; +import { AgentOs } from "@rivet-dev/agentos-core"; const vm = await AgentOs.create(); diff --git a/examples/quickstart/src/network.ts b/examples/quickstart/src/network.ts index c1c728905..33a2295f3 100644 --- a/examples/quickstart/src/network.ts +++ b/examples/quickstart/src/network.ts @@ -4,7 +4,7 @@ // Note: Preview URLs (createSignedPreviewUrl) are only available in the // RivetKit actor wrapper, not in the core API. See examples/agent-os/ for that. -import { AgentOs } from "@rivet-dev/agent-os-core"; +import { AgentOs } from "@rivet-dev/agentos-core"; function settleWithin(promise: Promise, ms: number): Promise { return Promise.race([ diff --git a/examples/quickstart/src/nodejs.ts b/examples/quickstart/src/nodejs.ts index 227077f6d..7cc5a7b91 100644 --- a/examples/quickstart/src/nodejs.ts +++ b/examples/quickstart/src/nodejs.ts @@ -1,7 +1,7 @@ // Run a Node.js script inside the VM that does filesystem operations. import common from "@agent-os-pkgs/common"; -import { AgentOs } from "@rivet-dev/agent-os-core"; +import { AgentOs } from "@rivet-dev/agentos-core"; const vm = await AgentOs.create({ software: [common] }); diff --git a/examples/quickstart/src/pi-extensions.ts b/examples/quickstart/src/pi-extensions.ts index 180a974e3..117f78a95 100644 --- a/examples/quickstart/src/pi-extensions.ts +++ b/examples/quickstart/src/pi-extensions.ts @@ -15,8 +15,8 @@ import { createRequire } from "node:module"; import { dirname, resolve } from "node:path"; import common from "@agent-os-pkgs/common"; -import { AgentOs } from "@rivet-dev/agent-os-core"; -import pi from "@rivet-dev/agent-os-pi"; +import { AgentOs } from "@rivet-dev/agentos-core"; +import pi from "@rivet-dev/agentos-pi"; const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; const ANTHROPIC_BASE_URL = process.env.ANTHROPIC_BASE_URL; @@ -27,7 +27,7 @@ if (!ANTHROPIC_API_KEY) { const require = createRequire(import.meta.url); const MODULE_ACCESS_CWD = resolve( - dirname(require.resolve("@rivet-dev/agent-os-core")), + dirname(require.resolve("@rivet-dev/agentos-core")), "..", ); const HOME_DIR = "/home/user"; diff --git a/examples/quickstart/src/processes.ts b/examples/quickstart/src/processes.ts index 5a7cb45bb..9c27d5a6f 100644 --- a/examples/quickstart/src/processes.ts +++ b/examples/quickstart/src/processes.ts @@ -1,7 +1,7 @@ // Execute commands and manage processes inside the VM. import common from "@agent-os-pkgs/common"; -import { AgentOs } from "@rivet-dev/agent-os-core"; +import { AgentOs } from "@rivet-dev/agentos-core"; const vm = await AgentOs.create({ software: [common] }); diff --git a/examples/quickstart/src/s3-filesystem.ts b/examples/quickstart/src/s3-filesystem.ts index 42dd10c9a..58be673b6 100644 --- a/examples/quickstart/src/s3-filesystem.ts +++ b/examples/quickstart/src/s3-filesystem.ts @@ -14,7 +14,7 @@ // S3 harness so `pnpm --dir examples/quickstart exec tsx src/s3-filesystem.ts` // still exercises the real quickstart flow against signed S3 requests. -import { AgentOs } from "@rivet-dev/agent-os-core"; +import { AgentOs } from "@rivet-dev/agentos-core"; import { createS3Backend } from "@secure-exec/s3"; import type { MockS3ServerHandle } from "../../../packages/core/src/test/mock-s3.js"; import { startMockS3Server } from "../../../packages/core/src/test/mock-s3.js"; diff --git a/examples/quickstart/src/sandbox.ts b/examples/quickstart/src/sandbox.ts index f3980d553..813c249f5 100644 --- a/examples/quickstart/src/sandbox.ts +++ b/examples/quickstart/src/sandbox.ts @@ -4,11 +4,11 @@ // at /sandbox, and registers the sandbox toolkit for running commands. import common from "@agent-os-pkgs/common"; -import { AgentOs } from "@rivet-dev/agent-os-core"; +import { AgentOs } from "@rivet-dev/agentos-core"; import { createSandboxFs, createSandboxToolkit, -} from "@rivet-dev/agent-os-sandbox"; +} from "@rivet-dev/agentos-sandbox"; const SANDBOX_QUICKSTART_PERMISSIONS = { fs: "allow", diff --git a/examples/quickstart/src/tools.ts b/examples/quickstart/src/tools.ts index eea9d2a26..34e4e59cf 100644 --- a/examples/quickstart/src/tools.ts +++ b/examples/quickstart/src/tools.ts @@ -4,7 +4,7 @@ // Each toolkit becomes a set of tools accessible at AGENTOS_TOOLS_PORT. // Node scripts inside the VM can call the server directly with fetch. -import { AgentOs, hostTool, toolKit } from "@rivet-dev/agent-os-core"; +import { AgentOs, hostTool, toolKit } from "@rivet-dev/agentos-core"; import { z } from "zod"; const weatherToolkit = toolKit({ diff --git a/justfile b/justfile index b7c24ebdd..f2a285a5e 100644 --- a/justfile +++ b/justfile @@ -14,24 +14,36 @@ secure-exec-pinned: secure-exec-local: node scripts/secure-exec-dep.mjs local -# Bump the pinned secure-exec version across the whole workspace (npm + crates). +# Bump the pinned @secure-exec/* npm version (core/s3/google-drive/sandbox). secure-exec-set-version VERSION: + node scripts/secure-exec-dep.mjs set-secure-exec-version "{{ VERSION }}" + +# Bump the pinned @agent-os-pkgs/* software-package npm version. +agent-os-pkgs-set-version VERSION: + node scripts/secure-exec-dep.mjs set-agent-os-pkgs-version "{{ VERSION }}" + +# Bump BOTH scopes at once (only when secure-exec + software publish in lockstep). +secure-exec-set-all-versions VERSION: node scripts/secure-exec-dep.mjs set-version "{{ VERSION }}" +# Bump the @secure-exec/* crate version requirement (must match the sibling crate version). +secure-exec-set-crate-version VERSION: + node scripts/secure-exec-dep.mjs set-crate-version "{{ VERSION }}" + # Show the current secure-exec dependency mode + pinned versions. secure-exec-status: node scripts/secure-exec-dep.mjs status dev-shell *args: - pnpm --filter @rivet-dev/agent-os-dev-shell dev-shell -- "$@" + pnpm --filter @rivet-dev/agentos-dev-shell dev-shell -- "$@" # Run the agentos-sdk.dev site (landing + /docs) locally with hot reload docs: - pnpm --filter @agent-os/website dev + pnpm --filter @agentos/website dev # Build the agentos-sdk.dev site to website/dist docs-build: - pnpm --filter @agent-os/website build + pnpm --filter @agentos/website build test-bounded cmd='pnpm test': #!/usr/bin/env bash diff --git a/package.json b/package.json index a998f1b04..068b9c7e7 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agent-os-workspace", + "name": "@rivet-dev/agentos-workspace", "private": true, "packageManager": "pnpm@10.13.1", "engines": { @@ -8,24 +8,24 @@ "scripts": { "start": "npx turbo watch build", "build": "npx turbo build", - "test": "pnpm --dir packages/core build && pnpm --dir packages/dev-shell build && pnpm --dir packages/dev-shell check-types && pnpm --dir packages/dev-shell test && npx turbo test --concurrency=1 --filter='!@rivet-dev/agent-os-dev-shell'", + "test": "pnpm --dir packages/core build && pnpm --dir packages/dev-shell build && pnpm --dir packages/dev-shell check-types && pnpm --dir packages/dev-shell test && npx turbo test --concurrency=1 --filter='!@rivet-dev/agentos-dev-shell'", "test:migration-parity": "pnpm --dir packages/core exec vitest run tests/migration-parity.test.ts --reporter=verbose", "test:post-python-parity": "pnpm --dir packages/core build && pnpm --dir packages/core exec vitest run tests/agent-os-base-filesystem.test.ts && pnpm --dir packages/dev-shell exec vitest run test/dev-shell.integration.test.ts && ./node_modules/.bin/vitest run --testTimeout=55000 --hookTimeout=30000 registry/tests/kernel/cross-runtime-terminal.test.ts registry/tests/kernel/ctrl-c-shell-behavior.test.ts registry/tests/kernel/node-binary-behavior.test.ts registry/tests/kernel/e2e-project-matrix.test.ts", "test:watch": "npx turbo watch test", "check-types": "npx turbo check-types --concurrency=1", "lint": "pnpm biome check .", "fmt": "pnpm biome check --write --diagnostic-level=error .", - "shell": "pnpm --filter @rivet-dev/agent-os-shell shell" + "shell": "pnpm --filter @rivet-dev/agentos-shell shell" }, "devDependencies": { "@biomejs/biome": "^2.3", "@copilotkit/llmock": "^1.6.0", - "@rivet-dev/agent-os-core": "workspace:*", - "@rivet-dev/agent-os-claude": "workspace:*", - "@rivet-dev/agent-os-codex-agent": "workspace:*", + "@rivet-dev/agentos-core": "workspace:*", + "@rivet-dev/agentos-claude": "workspace:*", + "@rivet-dev/agentos-codex-agent": "workspace:*", "@agent-os-pkgs/common": "catalog:", - "@secure-exec/core": "link:../secure-exec/packages/core", - "@rivet-dev/agent-os-pi": "workspace:*", + "@secure-exec/core": "catalog:", + "@rivet-dev/agentos-pi": "workspace:*", "@types/node": "^22.19.15", "jszip": "^3.10.1", "pdf-lib": "^1.17.1", @@ -33,6 +33,11 @@ "typescript": "^5.9.2" }, "resolutions": { - "@rivet-dev/agent-os-core": "workspace:*" + "@rivet-dev/agentos-core": "workspace:*" + }, + "pnpm": { + "overrides": { + "@rivetkit/rivetkit-wasm": "2.3.2" + } } } diff --git a/packages/agent-os-sandbox/package.json b/packages/agent-os-sandbox/package.json index 4f776338f..24779345a 100644 --- a/packages/agent-os-sandbox/package.json +++ b/packages/agent-os-sandbox/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agent-os-sandbox", + "name": "@rivet-dev/agentos-sandbox", "version": "0.2.0-rc.3", "type": "module", "license": "Apache-2.0", @@ -21,7 +21,7 @@ "test": "vitest run" }, "dependencies": { - "@rivet-dev/agent-os-core": "workspace:*", + "@rivet-dev/agentos-core": "workspace:*", "@secure-exec/sandbox": "catalog:", "sandbox-agent": "^0.4.2", "zod": "^4.1.11" diff --git a/packages/agent-os-sandbox/src/toolkit.ts b/packages/agent-os-sandbox/src/toolkit.ts index d0b9c6189..3d8d89ee6 100644 --- a/packages/agent-os-sandbox/src/toolkit.ts +++ b/packages/agent-os-sandbox/src/toolkit.ts @@ -3,7 +3,7 @@ * as host tools for agents running inside an agentOS VM. */ -import type { HostTool, ToolKit } from "@rivet-dev/agent-os-core"; +import type { HostTool, ToolKit } from "@rivet-dev/agentos-core"; import type { SandboxAgent } from "sandbox-agent"; import { z } from "zod"; diff --git a/packages/agent-os-sandbox/tests/vm-integration.test.ts b/packages/agent-os-sandbox/tests/vm-integration.test.ts index 8dab78c17..f8df57e91 100644 --- a/packages/agent-os-sandbox/tests/vm-integration.test.ts +++ b/packages/agent-os-sandbox/tests/vm-integration.test.ts @@ -1,7 +1,7 @@ import common from "@agent-os-pkgs/common"; -import { AgentOs } from "@rivet-dev/agent-os-core"; -import type { MockSandboxAgentHandle } from "@rivet-dev/agent-os-core/test/sandbox-agent"; -import { startMockSandboxAgent } from "@rivet-dev/agent-os-core/test/sandbox-agent"; +import { AgentOs } from "@rivet-dev/agentos-core"; +import type { MockSandboxAgentHandle } from "@rivet-dev/agentos-core/test/sandbox-agent"; +import { startMockSandboxAgent } from "@rivet-dev/agentos-core/test/sandbox-agent"; import { afterAll, afterEach, diff --git a/packages/agentos-plugin/npm/README.md b/packages/agentos-plugin/npm/README.md new file mode 100644 index 000000000..ecdca6607 --- /dev/null +++ b/packages/agentos-plugin/npm/README.md @@ -0,0 +1,16 @@ +# @rivet-dev/agentos-plugin-\ + +Platform-specific prebuilt Agent OS actor plugin cdylib +(`libagentos_actor_plugin.{so,dylib,dll}`). + +These packages are **not installed directly**. They are declared as +`optionalDependencies` of [`@rivet-dev/agentos`](../../agentos), so npm installs +only the one matching the current host's `os`/`cpu`/`libc` at install time. The +`@rivet-dev/agentos` runtime resolver (`src/plugin-binary.ts`) then `require`s +the matching package and hands the cdylib path to RivetKit's generic +native-plugin ABI. + +The binary is built in CI (`.github/workflows/publish.yaml`, `build-plugin` +job: `cargo build -p agentos-actor-plugin`) and copied into the matching +platform directory before publish. The committed directories contain only the +`package.json` describing the platform. diff --git a/packages/agentos-plugin/npm/linux-arm64-gnu/package.json b/packages/agentos-plugin/npm/linux-arm64-gnu/package.json new file mode 100644 index 000000000..3c00b73c4 --- /dev/null +++ b/packages/agentos-plugin/npm/linux-arm64-gnu/package.json @@ -0,0 +1,26 @@ +{ + "name": "@rivet-dev/agentos-plugin-linux-arm64-gnu", + "version": "0.2.0-rc.3", + "description": "Agent OS actor plugin cdylib for Linux arm64 (glibc)", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/rivet-dev/agent-os.git", + "directory": "packages/agentos-plugin/npm/linux-arm64-gnu" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "files": [ + "libagentos_actor_plugin.so" + ], + "engines": { + "node": ">=20" + } +} diff --git a/packages/agentos-plugin/npm/linux-x64-gnu/package.json b/packages/agentos-plugin/npm/linux-x64-gnu/package.json new file mode 100644 index 000000000..41569d72d --- /dev/null +++ b/packages/agentos-plugin/npm/linux-x64-gnu/package.json @@ -0,0 +1,26 @@ +{ + "name": "@rivet-dev/agentos-plugin-linux-x64-gnu", + "version": "0.2.0-rc.3", + "description": "Agent OS actor plugin cdylib for Linux x64 (glibc)", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/rivet-dev/agent-os.git", + "directory": "packages/agentos-plugin/npm/linux-x64-gnu" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "files": [ + "libagentos_actor_plugin.so" + ], + "engines": { + "node": ">=20" + } +} diff --git a/packages/agentos/package.json b/packages/agentos/package.json new file mode 100644 index 000000000..b982e9437 --- /dev/null +++ b/packages/agentos/package.json @@ -0,0 +1,47 @@ +{ + "name": "@rivet-dev/agentos", + "version": "0.2.0-rc.3", + "description": "Agent OS actor for RivetKit — thin TS forwarder that loads the native actor plugin (cdylib) via RivetKit's generic native-plugin ABI.", + "license": "Apache-2.0", + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/rivet-dev/agentos.git", + "directory": "packages/agentos" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "check-types": "tsc --noEmit", + "build": "tsc", + "test": "vitest run" + }, + "dependencies": { + "@agent-os-pkgs/common": "catalog:", + "@rivet-dev/agentos-core": "workspace:*", + "@rivet-dev/agentos-sidecar": "workspace:*", + "zod": "^4.1.11" + }, + "peerDependencies": { + "rivetkit": ">=0.0.0" + }, + "devDependencies": { + "@types/node": "^22.19.15", + "rivetkit": "0.0.0-feat-dylib-actor-plugin.c44621f", + "vitest": "^2.1.8" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/agentos/src/actor.ts b/packages/agentos/src/actor.ts new file mode 100644 index 000000000..aba0f7765 --- /dev/null +++ b/packages/agentos/src/actor.ts @@ -0,0 +1,369 @@ +/** + * Rust-backed `agentOs(...)` definition. + * + * Produces an `ActorDefinition` whose `nativeFactoryBuilder` constructs a + * native-actor-plugin factory through `runtime.createNativePluginFactory(...)` + * (NAPI → `dlopen` of the agent-os actor plugin cdylib, the inverse of the + * generic host loader). All lifecycle, state, and action dispatch live in the + * Rust plugin (`crates/agentos-actor-plugin`). This JS shim only validates + * configuration, resolves the plugin + sidecar binaries, and hands the opaque + * config envelope across the bridge — it owns no agent-os runtime logic. + */ + +import { sep } from "node:path"; +import common from "@agent-os-pkgs/common"; +import { getSidecarPath } from "@rivet-dev/agentos-sidecar"; +import { + actor, + type ActorDefinition, + type ActorFactoryHandle, + type CoreRuntime, + type DatabaseProvider, + type NapiNativePluginOptions, + type RawAccess, +} from "rivetkit"; +import { + type AgentOsActorConfig, + type AgentOsActorConfigInput, + agentOsActorConfigSchema, +} from "./config.js"; +import { getPluginPath } from "./plugin-binary.js"; +import type { AgentOsActorState, AgentOsActorVars } from "./types.js"; + +/** + * Build the JSON envelope the Rust plugin consumes. The Rust deserializer + * uses `deny_unknown_fields`, so the envelope must stay in lock-step with + * `crates/agentos-actor-plugin/src/config.rs::AgentOsConfigJson`. + * + * Software threading: each software descriptor is flattened (meta packages + * such as `common` are arrays of descriptors) and mapped to the Rust + * `SoftwareInput { package, kind }`. The agent-os-client resolves an + * ABSOLUTE `package` directly (its `resolve_software` lets an absolute path + * bypass the `node_modules` prefix), so the descriptor's already-resolved + * `commandDir` (wasm commands) / `packageDir` (agents/tools) is forwarded as + * `package`. + */ +interface SoftwareDescriptorLike { + commandDir?: string; + packageDir?: string; + requires?: string[]; + agent?: unknown; + hostTool?: unknown; + toolkit?: unknown; +} + +interface NativeMountLike { + path: string; + plugin: { + id: string; + config?: unknown; + }; + readOnly?: boolean; +} + +/** + * A native `host_dir` mount of a host `node_modules` directory at + * `/root/node_modules`, the serializable form `agentOs({ options: { mounts } })` + * accepts across the NAPI boundary. + */ +export interface NodeModulesMountConfig { + path: "/root/node_modules"; + plugin: { id: "host_dir"; config: { hostPath: string; readOnly: boolean } }; + readOnly: boolean; +} + +/** + * Mount a host `node_modules` directory into the VM at `/root/node_modules`. + * + * This is the explicit, mount-based replacement for the removed `moduleAccessCwd` + * mechanism: the VM module resolver reads the mounted tree through the kernel + * VFS, so the caller supplies exactly the `node_modules` directory whose + * packages should resolve in the guest. + * + * @param hostNodeModulesDir Absolute host path to a `node_modules` directory. + * @param opts.readOnly Defaults to `true`; the mount is read-only. + */ +export function nodeModulesMount( + hostNodeModulesDir: string, + opts?: { readOnly?: boolean }, +): NodeModulesMountConfig { + const readOnly = opts?.readOnly ?? true; + return { + path: "/root/node_modules", + plugin: { + id: "host_dir", + config: { hostPath: hostNodeModulesDir, readOnly }, + }, + readOnly, + }; +} + +/** + * Derive the `node_modules` root that contains an installed package directory. + * For an agent descriptor whose `packageDir` is `/node_modules/@scope/pkg`, + * this returns `/node_modules` — the hoist root that also holds the agent's + * `requires` (the ACP adapter + agent SDK) and their transitive deps under a + * flat (npm) install. Returns `undefined` when `packageDir` is not inside a + * `node_modules` tree (e.g. a linked monorepo checkout), where the caller must + * supply an explicit `nodeModulesMount(...)`. + */ +function nodeModulesRootOf(packageDir: string): string | undefined { + const parts = packageDir.split(sep); + const idx = parts.lastIndexOf("node_modules"); + if (idx === -1) return undefined; + return parts.slice(0, idx + 1).join(sep); +} + +/** + * Agents run their ACP adapter + SDK inside the VM from `/root/node_modules`. + * Rather than make the caller hand-write `nodeModulesMount(...)`, auto-derive a + * read-only `host_dir` mount of the `node_modules` root that holds the agent + * packages (`requires`) from each agent descriptor's installed `packageDir`. + * + * An explicit `/root/node_modules` mount in `options.mounts` always wins. If + * agents resolve to more than one distinct `node_modules` root the derivation is + * ambiguous and the caller must mount explicitly. + */ +function withAutoAgentNodeModulesMount( + mounts: NativeMountLike[] | undefined, + descriptors: SoftwareDescriptorLike[], +): NativeMountLike[] | undefined { + if (mounts?.some((mount) => mount.path === "/root/node_modules")) { + return mounts; + } + + const roots = new Set(); + for (const d of descriptors) { + if (!d.agent || typeof d.packageDir !== "string") continue; + const root = nodeModulesRootOf(d.packageDir); + if (root) roots.add(root); + } + + if (roots.size === 0) return mounts; + if (roots.size > 1) { + throw new Error( + "agentOs() could not auto-mount agent node_modules: agents resolved to " + + `multiple node_modules roots (${[...roots].join(", ")}). Pass an ` + + "explicit nodeModulesMount(...) in options.mounts.", + ); + } + + const [hostNodeModulesDir] = [...roots]; + return [...(mounts ?? []), nodeModulesMount(hostNodeModulesDir)]; +} + +/** + * Stable identity for a software descriptor, used to de-duplicate the + * auto-injected default bundle against software the caller passed explicitly. + * Resolved `commandDir`/`packageDir` paths are the most reliable key; `name` + * is the fallback for descriptors without a directory. + */ +function softwareIdentity(d: SoftwareDescriptorLike): string { + if (typeof d.commandDir === "string") return `dir:${d.commandDir}`; + if (typeof d.packageDir === "string") return `dir:${d.packageDir}`; + const name = (d as { name?: unknown }).name; + if (typeof name === "string") return `name:${name}`; + return JSON.stringify(d); +} + +function flattenSoftware(input: unknown, out: SoftwareDescriptorLike[]): void { + if (input == null) return; + if (Array.isArray(input)) { + for (const item of input) flattenSoftware(item, out); + return; + } + if (typeof input === "object") out.push(input as SoftwareDescriptorLike); +} + +export function buildConfigJson( + parsed: AgentOsActorConfig, +): string { + const descriptors: SoftwareDescriptorLike[] = []; + flattenSoftware( + (parsed.options as { software?: unknown })?.software, + descriptors, + ); + + // Auto-include the default software bundle (`@agent-os-pkgs/common`: `sh` + + // coreutils + the standard CLI tools agents rely on) unless the caller opted + // out with `defaultSoftware: false`. Anything already listed in `software` + // (e.g. an explicit `common`) is not duplicated. Prepended so the baseline + // tools come first, matching the previous explicit `[common, ...]` ordering. + const defaultSoftwareEnabled = + (parsed.options as { defaultSoftware?: unknown })?.defaultSoftware !== false; + if (defaultSoftwareEnabled) { + const defaults: SoftwareDescriptorLike[] = []; + flattenSoftware(common, defaults); + const seen = new Set(descriptors.map(softwareIdentity)); + const toPrepend = defaults.filter((d) => !seen.has(softwareIdentity(d))); + descriptors.unshift(...toPrepend); + } + + const software: Array<{ package: string; kind?: string }> = []; + for (const d of descriptors) { + if (typeof d.commandDir === "string") { + // Wasm command directory (kind defaults to WasmCommands on the Rust side). + software.push({ package: d.commandDir }); + } else if (typeof d.packageDir === "string") { + // Agent SDK / host-tool package: forwarded but not mounted as commands. + // `kind` matches the kebab-case serde tags of the Rust `SoftwareKind` + // enum (`wasm-commands` / `agent` / `tool`). + software.push({ + package: d.packageDir, + kind: d.hostTool || d.toolkit ? "tool" : "agent", + }); + } + } + + // `/root/node_modules` (agent ACP adapter + SDK + transitive dep resolution) + // is auto-derived from the agent descriptors so the standard quickstart needs + // no manual `nodeModulesMount(...)`: see `withAutoAgentNodeModulesMount`. An + // explicit `/root/node_modules` mount in `options.mounts` always wins. The VM + // module resolver reads the mounted tree through the kernel VFS. + const options = (parsed.options ?? {}) as Record; + const mounts = withAutoAgentNodeModulesMount( + serializeNativeMounts(options.mounts), + descriptors, + ); + const sidecar = serializeSidecar(options.sidecar); + return JSON.stringify({ + software, + additionalInstructions: options.additionalInstructions, + loopbackExemptPorts: options.loopbackExemptPorts, + allowedNodeBuiltins: options.allowedNodeBuiltins, + permissions: options.permissions, + rootFilesystem: options.rootFilesystem, + mounts, + limits: options.limits, + sidecar, + }); +} + +function serializeNativeMounts(input: unknown): NativeMountLike[] | undefined { + if (input == null) return undefined; + if (!Array.isArray(input)) { + throw new Error("agentOs() options.mounts must be an array"); + } + return input.map((mount, index) => { + if (!mount || typeof mount !== "object") { + throw new Error(`agentOs() options.mounts[${index}] must be an object`); + } + const record = mount as Record; + if (record.driver !== undefined) { + throw new Error( + "agentOs() only supports Native mounts across the NAPI boundary; Plain mounts with driver callbacks are not serializable", + ); + } + if (record.filesystem !== undefined) { + throw new Error( + "agentOs() only supports Native mounts across the NAPI boundary; Overlay mounts are not serializable", + ); + } + const plugin = record.plugin; + if ( + typeof record.path !== "string" || + !plugin || + typeof plugin !== "object" || + typeof (plugin as Record).id !== "string" + ) { + throw new Error( + `agentOs() options.mounts[${index}] must be a Native mount with { path, plugin: { id, config? } }`, + ); + } + return { + path: record.path, + plugin: { + id: (plugin as Record).id as string, + config: (plugin as Record).config, + }, + readOnly: + typeof record.readOnly === "boolean" ? record.readOnly : undefined, + }; + }); +} + +function serializeSidecar(input: unknown): { pool?: string } | undefined { + if (input == null) return undefined; + if (!input || typeof input !== "object") { + throw new Error("agentOs() options.sidecar must be an object"); + } + const record = input as Record; + if (record.kind === "explicit" || record.handle !== undefined) { + throw new Error( + "agentOs() only supports sidecar shared pool configuration across the NAPI boundary; explicit sidecar handles are not serializable", + ); + } + if (record.kind !== undefined && record.kind !== "shared") { + throw new Error('agentOs() options.sidecar.kind must be "shared"'); + } + return typeof record.pool === "string" ? { pool: record.pool } : {}; +} + +function buildNativeFactoryBuilder( + parsed: AgentOsActorConfig, +): (runtime: CoreRuntime) => ActorFactoryHandle { + return (runtime) => { + if (runtime.kind !== "napi") { + throw new Error( + `agentOs() is only supported on the native NAPI runtime (current runtime kind: ${runtime.kind})`, + ); + } + if (!runtime.createNativePluginFactory) { + throw new Error( + "runtime.createNativePluginFactory is not implemented on the active CoreRuntime", + ); + } + const options: NapiNativePluginOptions = { + // Resolve the prebuilt agent-os actor plugin cdylib; RivetKit `dlopen`s + // it through the generic native-plugin ABI. + pluginPath: getPluginPath(), + // Opaque config envelope the plugin parses (config.rs::AgentOsConfigJson). + configJson: buildConfigJson(parsed), + // Resolve the prebuilt sidecar binary from the npm package so the plugin + // spawns the bundled binary rather than relying on `agentos-sidecar` + // being on PATH. + sidecarPath: getSidecarPath(), + }; + return runtime.createNativePluginFactory(options); + }; +} + +/** + * Type alias for the `agentOs(...)` return type. Events are not typed at the + * TS surface because the Rust plugin owns the broadcast set and the + * test/client surface uses `any` for actions. + */ +export type AgentOsActorDefinition = ActorDefinition< + AgentOsActorState, + TConnParams, + undefined, + AgentOsActorVars, + undefined, + DatabaseProvider, + Record, + Record, + any +>; + +export function agentOs( + config: AgentOsActorConfigInput, +): AgentOsActorDefinition { + const parsed = agentOsActorConfigSchema.parse( + config, + ) as AgentOsActorConfig; + + // Construct a minimal definition through the existing actor() helper, then + // attach the Rust factory builder marker. The actions block stays empty + // because no JS-side action ever runs: the engine driver branches on + // `nativeFactoryBuilder` before reaching the JS dispatch path. + const actorOptions = (parsed as { actorOptions?: Record }) + .actorOptions; + const definition = actor({ + actions: {}, + ...(actorOptions ? { options: actorOptions } : {}), + } as Parameters< + typeof actor + >[0]) as unknown as AgentOsActorDefinition; + definition.nativeFactoryBuilder = buildNativeFactoryBuilder(parsed); + return definition; +} diff --git a/packages/agentos/src/config.ts b/packages/agentos/src/config.ts new file mode 100644 index 000000000..575e7ac76 --- /dev/null +++ b/packages/agentos/src/config.ts @@ -0,0 +1,79 @@ +import type { + AgentOsOptions, + JsonRpcNotification, + PermissionRequest, +} from "@rivet-dev/agentos-core"; +import type { ActorContext, BeforeConnectContext } from "rivetkit"; +import { z } from "zod/v4"; +import type { AgentOsActorState, AgentOsActorVars } from "./types.js"; + +const zFunction = < + T extends (...args: any[]) => any = (...args: unknown[]) => unknown, +>() => z.custom((val) => typeof val === "function"); + +const AgentOsOptionsSchema = z.custom( + (val) => typeof val === "object" && val !== null, +); + +export const agentOsActorConfigSchema = z + .object({ + options: AgentOsOptionsSchema.optional(), + preview: z + .object({ + defaultExpiresInSeconds: z.number().positive().default(3600), + maxExpiresInSeconds: z.number().positive().default(86400), + }) + .strict() + .prefault(() => ({})), + onBeforeConnect: zFunction().optional(), + onSessionEvent: zFunction().optional(), + onPermissionRequest: zFunction().optional(), + }) + .strict(); + +// --- Typed config types (generic callbacks overlaid on the Zod schema) --- + +type AgentOsActorContext = ActorContext< + AgentOsActorState, + TConnParams, + undefined, + AgentOsActorVars, + undefined, + any +>; + +interface AgentOsActorConfigCallbacks { + onBeforeConnect?: ( + c: BeforeConnectContext< + AgentOsActorState, + AgentOsActorVars, + undefined, + any + >, + params: TConnParams, + ) => void | Promise; + onSessionEvent?: ( + c: AgentOsActorContext, + sessionId: string, + event: JsonRpcNotification, + ) => void | Promise; + onPermissionRequest?: ( + c: AgentOsActorContext, + sessionId: string, + request: PermissionRequest, + ) => void | Promise; +} + +// Parsed config (after Zod defaults/transforms applied). +export type AgentOsActorConfig = Omit< + z.infer, + "onBeforeConnect" | "onSessionEvent" | "onPermissionRequest" +> & + AgentOsActorConfigCallbacks; + +// Input config (what users pass in before Zod transforms). +export type AgentOsActorConfigInput = Omit< + z.input, + "onBeforeConnect" | "onSessionEvent" | "onPermissionRequest" +> & + AgentOsActorConfigCallbacks; diff --git a/packages/agentos/src/index.ts b/packages/agentos/src/index.ts new file mode 100644 index 000000000..4942f4945 --- /dev/null +++ b/packages/agentos/src/index.ts @@ -0,0 +1,45 @@ +// Rust-backed agent-os actor surface (native actor plugin / cdylib). +// +// Only the `agentOs()` definition function, the config schema, the +// `nodeModulesMount` helper, the plugin-path resolver, and the public domain +// types are exported. All actor lifecycle + action dispatch live in the Rust +// plugin (`crates/agentos-actor-plugin`), loaded by RivetKit via the generic +// native-plugin ABI. + +export { + agentOs, + type AgentOsActorDefinition, + buildConfigJson, + nodeModulesMount, + type NodeModulesMountConfig, +} from "./actor.js"; + +export { + type AgentOsActorConfig, + type AgentOsActorConfigInput, + agentOsActorConfigSchema, +} from "./config.js"; + +export { getPluginPath } from "./plugin-binary.js"; + +export type { + AgentOsActionContext, + AgentOsActorState, + AgentOsActorVars, + AgentOsEvents, + CronEventPayload, + PermissionRequestPayload, + PersistedSessionEvent, + PersistedSessionRecord, + ProcessExitPayload, + ProcessOutputPayload, + PromptResult, + SerializableCronAction, + SerializableCronJobInfo, + SerializableCronJobOptions, + SessionEventPayload, + SessionRecord, + ShellDataPayload, + VmBootedPayload, + VmShutdownPayload, +} from "./types.js"; diff --git a/packages/agentos/src/plugin-binary.ts b/packages/agentos/src/plugin-binary.ts new file mode 100644 index 000000000..0cd342bd8 --- /dev/null +++ b/packages/agentos/src/plugin-binary.ts @@ -0,0 +1,97 @@ +// Platform-specific resolver for the prebuilt agent-os actor plugin cdylib +// (`libagentos_actor_plugin.{so,dylib,dll}`). Mirrors the sidecar-binary +// resolver (`@rivet-dev/agentos-sidecar`): the library ships inside one of the +// `@rivet-dev/agentos-plugin-` packages, declared as +// optionalDependencies so npm installs only the one matching the current +// `os`/`cpu`/`libc` at install time (spec phase 6). +// +// Resolution priority: +// 1. `AGENTOS_PLUGIN_BIN` env var (absolute path override). +// 2. A cargo build output under the repo `target/{release,debug}/` (dev). +// 3. The platform-specific `@rivet-dev/agentos-plugin-` package. + +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const PLUGIN_BIN_ENV = "AGENTOS_PLUGIN_BIN"; + +/** The cdylib filename for the current platform. */ +function libraryName(): string { + switch (process.platform) { + case "darwin": + return "libagentos_actor_plugin.dylib"; + case "win32": + return "agentos_actor_plugin.dll"; + default: + return "libagentos_actor_plugin.so"; + } +} + +function getPlatformPackageName(): string | null { + const { platform, arch } = process; + switch (platform) { + case "linux": + if (arch === "x64") return "@rivet-dev/agentos-plugin-linux-x64-gnu"; + if (arch === "arm64") return "@rivet-dev/agentos-plugin-linux-arm64-gnu"; + break; + default: + break; + } + return null; +} + +/** + * Resolve the absolute path to the agent-os actor plugin cdylib. RivetKit + * `dlopen`s this path through the generic native-plugin ABI. + */ +export function getPluginPath(): string { + const override = process.env[PLUGIN_BIN_ENV]; + if (override) { + if (!existsSync(override)) { + throw new Error( + `${PLUGIN_BIN_ENV} is set to ${override} but the file does not exist`, + ); + } + return override; + } + + const lib = libraryName(); + + // Dev: a cargo build output under the repo `target/`. `import.meta.url` is + // `packages/agentos/dist/plugin-binary.js`, so the repo root is three levels up. + const here = dirname(fileURLToPath(import.meta.url)); + for (const profile of ["release", "debug"]) { + const candidate = join(here, "..", "..", "..", "target", profile, lib); + if (existsSync(candidate)) { + return candidate; + } + } + + // Prod: the platform-specific package. + const platformPkg = getPlatformPackageName(); + if (!platformPkg) { + throw new Error( + `@rivet-dev/agentos: unsupported platform ${process.platform}/${process.arch}. ` + + "The Agent OS actor plugin currently supports linux x64 and arm64. " + + `Set ${PLUGIN_BIN_ENV} to a local cdylib to override.`, + ); + } + + const require = createRequire(import.meta.url); + let pkgJsonPath: string; + try { + pkgJsonPath = require.resolve(`${platformPkg}/package.json`); + } catch { + throw new Error( + `@rivet-dev/agentos: platform package ${platformPkg} is not installed.\n` + + "This usually means the platform is unsupported or optionalDependencies\n" + + `were skipped during install. Try: npm install --include=optional ${platformPkg}\n` + + `Or set ${PLUGIN_BIN_ENV} to a local cdylib, or build it with\n` + + "`cargo build -p agentos-actor-plugin`.", + ); + } + + return join(dirname(pkgJsonPath), lib); +} diff --git a/packages/agentos/src/types.ts b/packages/agentos/src/types.ts new file mode 100644 index 000000000..7b6f8e51d --- /dev/null +++ b/packages/agentos/src/types.ts @@ -0,0 +1,148 @@ +import type { + AgentCapabilities, + AgentInfo, + AgentOs, + CronEvent, + JsonRpcNotification, + JsonRpcResponse, + PermissionRequest, +} from "@rivet-dev/agentos-core"; +import type { ActionContext } from "rivetkit"; + +// --- Actor state (persisted across sleep/wake) --- + +// biome-ignore lint/complexity/noBannedTypes: empty state placeholder, consumers extend via generics +export type AgentOsActorState = {}; + +// --- Actor vars (ephemeral, recreated on wake) --- + +export interface AgentOsActorVars { + agentOs: AgentOs | null; + activeSessionIds: Set; + activeProcesses: Set; + activeHooks: Set>; + activeShells: Set; + sessions: Set; +} + +// --- Event payloads --- + +export interface SessionEventPayload { + sessionId: string; + event: JsonRpcNotification; +} + +export interface PermissionRequestPayload { + sessionId: string; + request: PermissionRequest; +} + +export type VmBootedPayload = Record; + +export interface VmShutdownPayload { + reason: "sleep" | "destroy" | "error"; +} + +export interface ProcessOutputPayload { + pid: number; + stream: "stdout" | "stderr"; + data: Uint8Array; +} + +export interface ProcessExitPayload { + pid: number; + exitCode: number; +} + +export interface ShellDataPayload { + shellId: string; + data: Uint8Array; +} + +export interface CronEventPayload { + event: CronEvent; +} + +// --- Event schema map (used by actor() events config) --- + +export interface AgentOsEvents { + sessionEvent: SessionEventPayload; + permissionRequest: PermissionRequestPayload; + vmBooted: VmBootedPayload; + vmShutdown: VmShutdownPayload; + processOutput: ProcessOutputPayload; + processExit: ProcessExitPayload; + shellData: ShellDataPayload; + cronEvent: CronEventPayload; +} + +// --- Prompt result --- + +/** Result from sendPrompt. */ +export interface PromptResult { + /** Raw JSON-RPC response from the ACP adapter. */ + response: JsonRpcResponse; + /** Accumulated agent text output from streamed message chunks. */ + text: string; +} + +// --- Session serialization --- + +export interface SessionRecord { + sessionId: string; + agentType: string; + capabilities: AgentCapabilities; + agentInfo: AgentInfo | null; +} + +// --- Persisted session types --- + +export interface PersistedSessionRecord { + sessionId: string; + agentType: string; + capabilities: AgentCapabilities; + agentInfo: AgentInfo | null; + createdAt: number; +} + +export interface PersistedSessionEvent { + sessionId: string; + seq: number; + event: JsonRpcNotification; + createdAt: number; +} + +// --- Serializable cron action (excludes callback type) --- + +export type SerializableCronAction = + | { type: "session"; agentType: string; prompt: string; cwd?: string } + | { type: "exec"; command: string; args?: string[] }; + +export interface SerializableCronJobOptions { + id?: string; + schedule: string; + action: SerializableCronAction; + overlap?: "allow" | "skip" | "queue"; +} + +export interface SerializableCronJobInfo { + id: string; + schedule: string; + action: SerializableCronAction; + overlap: "allow" | "skip" | "queue"; + lastRun?: string; + nextRun?: string; + runCount: number; + running: boolean; +} + +// --- Action context alias --- + +export type AgentOsActionContext = ActionContext< + AgentOsActorState, + TConnParams, + undefined, + AgentOsActorVars, + undefined, + any +>; diff --git a/packages/agentos/tests/actor.test.ts b/packages/agentos/tests/actor.test.ts new file mode 100644 index 000000000..b32a00a86 --- /dev/null +++ b/packages/agentos/tests/actor.test.ts @@ -0,0 +1,565 @@ +import { type ChildProcess, execFile, spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { createServer } from "node:net"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeAll, describe, expect, test } from "vitest"; +import type { + ActorFactoryHandle, + CoreRuntime, + NapiNativePluginOptions, +} from "rivetkit"; +import { createClient } from "rivetkit/client"; +import common from "@agent-os-pkgs/common"; +import { + agentOs, + buildConfigJson, + getPluginPath, + nodeModulesMount, +} from "../src/index.js"; + +const testDir = dirname(fileURLToPath(import.meta.url)); +function findRepoRoot(start: string): string { + let current = start; + while (true) { + const manifest = join(current, "Cargo.toml"); + if ( + existsSync(manifest) && + readFileSync(manifest, "utf8").includes("crates/agentos-actor-plugin") + ) { + return current; + } + const parent = dirname(current); + if (parent === current) { + throw new Error(`failed to find agent-os repo root from ${start}`); + } + current = parent; + } +} + +const repoRoot = findRepoRoot(testDir); +const r6Root = join(repoRoot, "..", "r6"); +const r6RivetkitPackageRoot = join( + r6Root, + "rivetkit-typescript", + "packages", + "rivetkit", +); +const runtimeFixturePath = join( + testDir, + "fixtures", + "agentos-runtime-server.ts", +); +const tsxLoaderPath = join( + r6RivetkitPackageRoot, + "node_modules", + "tsx", + "dist", + "loader.mjs", +); +const execFileAsync = promisify(execFile); +let runtime: ChildProcess | undefined; +let runtimeLogs = { stdout: "", stderr: "" }; +const pluginFilename = + process.platform === "darwin" + ? "libagentos_actor_plugin.dylib" + : process.platform === "win32" + ? "agentos_actor_plugin.dll" + : "libagentos_actor_plugin.so"; + +function bytesToString(value: unknown): string { + if (value instanceof Uint8Array) return Buffer.from(value).toString("utf8"); + if (Array.isArray(value)) return Buffer.from(value).toString("utf8"); + if (typeof value === "string") return value; + throw new Error(`unexpected readFile result: ${String(value)}`); +} + +function childOutput(): string { + return [runtimeLogs.stdout, runtimeLogs.stderr].filter(Boolean).join("\n"); +} + +async function stopRuntime(child: ChildProcess): Promise { + if (child.exitCode !== null) return; + child.kill("SIGINT"); + await new Promise((resolve) => { + const timeout = setTimeout(() => { + if (child.exitCode === null) child.kill("SIGKILL"); + }, 5_000); + child.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); +} + +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close(() => { + if (!address || typeof address === "string") { + reject(new Error("failed to allocate a TCP port")); + return; + } + resolve(address.port); + }); + }); + }); +} + +async function waitForHealth(endpoint: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (runtime?.exitCode !== null && runtime !== undefined) { + throw new Error( + `agentos runtime exited before health check passed:\n${childOutput()}`, + ); + } + try { + const response = await fetch(`${endpoint}/health`); + if (response.ok) return; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error( + `timed out waiting for engine health at ${endpoint}\n${childOutput()}`, + ); +} + +async function upsertNormalRunnerConfig( + endpoint: string, + namespace: string, + token: string | undefined, + poolName: string, +): Promise { + const authHeaders = token ? { Authorization: `Bearer ${token}` } : {}; + const datacentersResponse = await fetch( + `${endpoint}/datacenters?namespace=${encodeURIComponent(namespace)}`, + { headers: authHeaders }, + ); + if (!datacentersResponse.ok) { + throw new Error( + `failed to list datacenters: ${datacentersResponse.status} ${await datacentersResponse.text()}`, + ); + } + const datacentersBody = (await datacentersResponse.json()) as { + datacenters: Array<{ name: string }>; + }; + const datacenter = datacentersBody.datacenters[0]?.name; + if (!datacenter) throw new Error("engine returned no datacenters"); + + const response = await fetch( + `${endpoint}/runner-configs/${encodeURIComponent(poolName)}?namespace=${encodeURIComponent(namespace)}`, + { + method: "PUT", + headers: { + ...authHeaders, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + datacenters: { + [datacenter]: { + normal: {}, + }, + }, + }), + }, + ); + if (!response.ok) { + throw new Error( + `failed to upsert runner config ${poolName}: ${response.status} ${await response.text()}`, + ); + } +} + +async function waitForEnvoy( + endpoint: string, + namespace: string, + token: string | undefined, + poolName: string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + const authHeaders = token ? { Authorization: `Bearer ${token}` } : {}; + while (Date.now() < deadline) { + if (runtime?.exitCode !== null && runtime !== undefined) { + throw new Error( + `agentos runtime exited before envoy registration:\n${childOutput()}`, + ); + } + const response = await fetch( + `${endpoint}/envoys?namespace=${encodeURIComponent(namespace)}&name=${encodeURIComponent(poolName)}`, + { headers: authHeaders }, + ); + if (response.ok) { + const body = (await response.json()) as { + envoys: Array<{ envoy_key: string }>; + }; + if (body.envoys.length > 0) return; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error( + `timed out waiting for envoy registration in ${poolName}\n${childOutput()}`, + ); +} + +async function waitForActorReady( + callback: () => Promise, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + return await callback(); + } catch (error) { + lastError = error; + const message = error instanceof Error ? error.message : String(error); + const code = + typeof error === "object" && + error !== null && + "code" in error && + typeof error.code === "string" + ? error.code + : undefined; + if ( + !( + (code && + /^(no_envoys|actor_ready_timeout|actor_wake_retries_exceeded|service_unavailable)$/.test( + code, + )) || + /(no_envoys|actor_ready_timeout|actor_wake_retries_exceeded|service_unavailable)/.test( + message, + ) + ) + ) { + throw error instanceof Error + ? new Error(`${error.message}\n${childOutput()}`, { + cause: error, + }) + : error; + } + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw lastError instanceof Error + ? lastError + : new Error("timed out waiting for actor readiness"); +} + +describe("@rivet-dev/agentos native plugin package bridge", () => { + beforeAll(async () => { + await execFileAsync( + "cargo", + [ + "build", + "--manifest-path", + join(repoRoot, "Cargo.toml"), + "-p", + "agentos-sidecar", + "-p", + "agentos-actor-plugin", + ], + { + cwd: repoRoot, + env: process.env, + maxBuffer: 1024 * 1024 * 20, + }, + ); + const sidecarPath = join(repoRoot, "target", "debug", "agentos-sidecar"); + expect(existsSync(sidecarPath)).toBe(true); + process.env.AGENTOS_SIDECAR_BIN = sidecarPath; + process.env.AGENTOS_PLUGIN_BIN = join( + repoRoot, + "target", + "debug", + pluginFilename, + ); + expect(existsSync(process.env.AGENTOS_PLUGIN_BIN)).toBe(true); + const r6EngineBinary = join(r6Root, "target", "debug", "rivet-engine"); + if (existsSync(r6EngineBinary)) { + process.env.RIVET_ENGINE_BINARY = r6EngineBinary; + } + }, 120_000); + + afterEach(async () => { + if (runtime) { + await stopRuntime(runtime); + runtime = undefined; + } + }, 30_000); + + test("resolves the dev-built actor plugin cdylib", () => { + const pluginPath = getPluginPath(); + expect(pluginPath).toBe( + join(repoRoot, "target", "debug", pluginFilename), + ); + expect(existsSync(pluginPath)).toBe(true); + }); + + test("serializes config and hands plugin paths to the NAPI runtime", () => { + const definition = agentOs({ + options: { + additionalInstructions: ["stay deterministic"], + loopbackExemptPorts: [4020], + mounts: [nodeModulesMount("/host/project/node_modules")], + sidecar: { kind: "shared", pool: "agentos-smoke" }, + }, + }); + const expectedHandle = Symbol("native-factory") as unknown as ActorFactoryHandle; + const calls: NapiNativePluginOptions[] = []; + const runtime = { + kind: "napi", + createNativePluginFactory(options: NapiNativePluginOptions) { + calls.push(options); + return expectedHandle; + }, + } as CoreRuntime; + + const handle = definition.nativeFactoryBuilder?.(runtime); + + expect(handle).toBe(expectedHandle); + expect(calls).toHaveLength(1); + expect(calls[0].pluginPath).toBe(getPluginPath()); + expect(calls[0].sidecarPath).toBe(process.env.AGENTOS_SIDECAR_BIN); + expect(JSON.parse(calls[0].configJson)).toMatchObject({ + additionalInstructions: ["stay deterministic"], + loopbackExemptPorts: [4020], + sidecar: { pool: "agentos-smoke" }, + mounts: [ + { + path: "/root/node_modules", + plugin: { + id: "host_dir", + config: { + hostPath: "/host/project/node_modules", + readOnly: true, + }, + }, + readOnly: true, + }, + ], + }); + }); + + test("buildConfigJson keeps software descriptors pointed at package roots", () => { + const configJson = buildConfigJson({ + options: { + // Disable the default bundle so this stays focused on the mapping. + defaultSoftware: false, + software: [ + { commandDir: "/abs/wasm-command" }, + { packageDir: "/abs/agent-package", agent: {} }, + { packageDir: "/abs/tool-package", hostTool: {} }, + ], + }, + preview: { + defaultExpiresInSeconds: 3600, + maxExpiresInSeconds: 86400, + }, + } as never); + + expect(JSON.parse(configJson).software).toEqual([ + { package: "/abs/wasm-command" }, + { package: "/abs/agent-package", kind: "agent" }, + { package: "/abs/tool-package", kind: "tool" }, + ]); + }); + + test("auto-injects the default common software bundle unless disabled", () => { + const withDefault = JSON.parse( + buildConfigJson({ + options: { software: [{ commandDir: "/x/wasm" }] }, + } as never), + ); + const pkgs = withDefault.software.map((s: { package: string }) => s.package); + expect(pkgs).toContain("/x/wasm"); + // common (sh + coreutils + tools) is injected from @agent-os-pkgs/*. + expect(pkgs.some((p: string) => p.includes("@agent-os-pkgs"))).toBe(true); + expect(withDefault.software.length).toBeGreaterThan(1); + + const noDefault = JSON.parse( + buildConfigJson({ + options: { software: [{ commandDir: "/x/wasm" }], defaultSoftware: false }, + } as never), + ); + expect(noDefault.software).toEqual([{ package: "/x/wasm" }]); + }); + + test("does not duplicate an explicitly-provided default package", () => { + const onlyDefault = JSON.parse( + buildConfigJson({ options: {} } as never), + ).software.length; + const withExplicitCommon = JSON.parse( + buildConfigJson({ options: { software: [common] } } as never), + ).software.length; + // Passing common explicitly must not double the injected bundle. + expect(withExplicitCommon).toBe(onlyDefault); + }); + + test("auto-derives /root/node_modules mount from an agent's installed package dir", () => { + const config = JSON.parse( + buildConfigJson({ + options: { + software: [ + { commandDir: "/proj/node_modules/@agent-os-pkgs/coreutils/wasm" }, + { + packageDir: "/proj/node_modules/@rivet-dev/agentos-pi", + requires: [ + "@rivet-dev/agentos-pi", + "@mariozechner/pi-coding-agent", + ], + agent: { id: "pi" }, + }, + ], + }, + } as never), + ); + + expect(config.mounts).toEqual([ + { + path: "/root/node_modules", + plugin: { + id: "host_dir", + config: { hostPath: "/proj/node_modules", readOnly: true }, + }, + readOnly: true, + }, + ]); + }); + + test("explicit /root/node_modules mount overrides the auto-derived one", () => { + const config = JSON.parse( + buildConfigJson({ + options: { + software: [ + { + packageDir: "/proj/node_modules/@rivet-dev/agentos-pi", + agent: { id: "pi" }, + }, + ], + mounts: [nodeModulesMount("/custom/node_modules")], + }, + } as never), + ); + + expect(config.mounts).toHaveLength(1); + expect(config.mounts[0].plugin.config.hostPath).toBe( + "/custom/node_modules", + ); + }); + + test("does not auto-mount when an agent package is not inside node_modules", () => { + const config = JSON.parse( + buildConfigJson({ + options: { + software: [{ packageDir: "/abs/agent-package", agent: { id: "x" } }], + }, + } as never), + ); + + expect(config.mounts).toBeUndefined(); + }); + + test("boots a VM through the dylib actor and handles filesystem actions", async () => { + const poolName = `agentos-package-${crypto.randomUUID()}`; + const namespace = "default"; + const token = "dev"; + const enginePort = await getFreePort(); + let client: Awaited>> | undefined; + try { + const endpoint = `http://127.0.0.1:${enginePort}`; + runtimeLogs = { stdout: "", stderr: "" }; + runtime = spawn(process.execPath, ["--import", tsxLoaderPath, runtimeFixturePath], { + cwd: r6RivetkitPackageRoot, + env: { + ...process.env, + RIVET_TOKEN: token, + RIVET_NAMESPACE: namespace, + RIVETKIT_TEST_ENDPOINT: endpoint, + RIVETKIT_TEST_POOL_NAME: poolName, + AGENTOS_TEST_SIDECAR_POOL: poolName, + RIVET_RUN_ENGINE_HOST: "127.0.0.1", + RIVET_RUN_ENGINE_PORT: String(enginePort), + ESBK_TSCONFIG_PATH: join( + r6RivetkitPackageRoot, + "tsconfig.json", + ), + TSX_TSCONFIG_PATH: join( + r6RivetkitPackageRoot, + "tsconfig.json", + ), + RIVETKIT_STORAGE_PATH: mkdtempSync( + join(tmpdir(), "agentos-package-smoke-"), + ), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + runtime.stdout?.on("data", (chunk) => { + runtimeLogs.stdout += chunk.toString(); + }); + runtime.stderr?.on("data", (chunk) => { + runtimeLogs.stderr += chunk.toString(); + }); + + await waitForHealth(endpoint, 90_000); + await upsertNormalRunnerConfig( + endpoint, + namespace, + token, + poolName, + ); + await waitForEnvoy( + endpoint, + namespace, + token, + poolName, + 30_000, + ); + client = createClient({ + endpoint, + token, + namespace, + poolName, + disableMetadataLookup: true, + }); + const handle = await waitForActorReady( + () => + (client as any).os.create([ + `agentos-package-${crypto.randomUUID()}`, + ]), + 30_000, + ); + + await waitForActorReady( + () => + handle.writeFile( + "/tmp/agentos-package-smoke.txt", + "hello dylib", + ), + 30_000, + ); + + expect( + bytesToString( + await waitForActorReady( + () => handle.readFile("/tmp/agentos-package-smoke.txt"), + 30_000, + ), + ), + ).toBe("hello dylib"); + } finally { + await client?.dispose(); + if (runtime) { + await stopRuntime(runtime); + runtime = undefined; + } + } + }, 120_000); +}); diff --git a/packages/agentos/tests/fixtures/agentos-runtime-server.ts b/packages/agentos/tests/fixtures/agentos-runtime-server.ts new file mode 100644 index 000000000..4115824f1 --- /dev/null +++ b/packages/agentos/tests/fixtures/agentos-runtime-server.ts @@ -0,0 +1,52 @@ +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { setup } from "rivetkit"; +import { agentOs } from "../../src/index.js"; +import { buildNativeRegistry } from "../../../../../r6/rivetkit-typescript/packages/rivetkit/src/registry/native"; + +const fixtureDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(fixtureDir, "../../../.."); +const r6Root = resolve(repoRoot, "../r6"); +const repoEngineBinary = join(r6Root, "target/debug/rivet-engine"); + +function resolveEngineBinaryPath(): string | undefined { + if (existsSync(repoEngineBinary)) return repoEngineBinary; + return process.env.RIVET_ENGINE_BINARY; +} + +const registry = setup({ + use: { + os: agentOs({ + options: { + permissions: { + fs: "allow", + network: "allow", + childProcess: "allow", + process: "allow", + env: "allow", + }, + sidecar: { + kind: "shared", + pool: process.env.AGENTOS_TEST_SIDECAR_POOL, + }, + }, + }), + }, + endpoint: process.env.RIVETKIT_TEST_ENDPOINT ?? "http://127.0.0.1:6642", + namespace: process.env.RIVET_NAMESPACE ?? "default", + token: process.env.RIVET_TOKEN ?? "dev", + envoy: { + poolName: process.env.RIVETKIT_TEST_POOL_NAME ?? "agentos-package", + }, + runtime: "native", + shutdown: { disableSignalHandlers: true }, +}); + +const { registry: nativeRegistry, serveConfig } = await buildNativeRegistry( + registry.parseConfig(), +); +const engineBinaryPath = resolveEngineBinaryPath(); +if (engineBinaryPath) serveConfig.engineBinaryPath = engineBinaryPath; + +await nativeRegistry.serve(serveConfig); diff --git a/packages/agentos/tsconfig.json b/packages/agentos/tsconfig.json new file mode 100644 index 000000000..3877f082e --- /dev/null +++ b/packages/agentos/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "declaration": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/browser/package.json b/packages/browser/package.json index 45aeac969..17bfa2e83 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agent-os-browser", + "name": "@rivet-dev/agentos-browser", "version": "0.2.0-rc.3", "type": "module", "license": "Apache-2.0", diff --git a/packages/core/package.json b/packages/core/package.json index d2e5b0007..49881e851 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agent-os-core", + "name": "@rivet-dev/agentos-core", "version": "0.2.0-rc.3", "type": "module", "license": "Apache-2.0", @@ -55,9 +55,9 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1019.0", - "@rivet-dev/agent-os-sidecar": "workspace:*", + "@rivet-dev/agentos-sidecar": "workspace:*", "@rivetkit/bare-ts": "^0.6.2", - "@secure-exec/core": "link:../../../secure-exec/packages/core", + "@secure-exec/core": "catalog:", "@xterm/headless": "^6.0.0", "better-sqlite3": "^12.8.0", "croner": "^10.0.1", @@ -74,14 +74,14 @@ "@browserbasehq/sdk": "2.10.0", "@bare-ts/tools": "0.15.0", "@copilotkit/llmock": "^1.6.0", - "@rivet-dev/agent-os-sandbox": "link:../agent-os-sandbox", + "@rivet-dev/agentos-sandbox": "link:../agent-os-sandbox", "@agent-os-pkgs/git": "catalog:", "@secure-exec/google-drive": "catalog:", "@secure-exec/s3": "catalog:", "@mariozechner/pi-coding-agent": "^0.60.0", - "@rivet-dev/agent-os-claude": "link:../../registry/agent/claude", + "@rivet-dev/agentos-claude": "link:../../registry/agent/claude", "@agent-os-pkgs/codex": "catalog:", - "@rivet-dev/agent-os-codex-agent": "link:../../registry/agent/codex", + "@rivet-dev/agentos-codex-agent": "link:../../registry/agent/codex", "@agent-os-pkgs/coreutils": "catalog:", "@agent-os-pkgs/curl": "catalog:", "@agent-os-pkgs/diffutils": "catalog:", @@ -92,9 +92,9 @@ "@agent-os-pkgs/grep": "catalog:", "@agent-os-pkgs/gzip": "catalog:", "@agent-os-pkgs/jq": "catalog:", - "@rivet-dev/agent-os-opencode": "link:../../registry/agent/opencode", - "@rivet-dev/agent-os-pi": "link:../../registry/agent/pi", - "@rivet-dev/agent-os-pi-cli": "link:../../registry/agent/pi-cli", + "@rivet-dev/agentos-opencode": "link:../../registry/agent/opencode", + "@rivet-dev/agentos-pi": "link:../../registry/agent/pi", + "@rivet-dev/agentos-pi-cli": "link:../../registry/agent/pi-cli", "@agent-os-pkgs/ripgrep": "catalog:", "@agent-os-pkgs/sed": "catalog:", "@agent-os-pkgs/tar": "catalog:", diff --git a/packages/core/pnpm-lock.yaml b/packages/core/pnpm-lock.yaml index e299c6c86..5ee98adb6 100644 --- a/packages/core/pnpm-lock.yaml +++ b/packages/core/pnpm-lock.yaml @@ -54,13 +54,13 @@ importers: '@mariozechner/pi-coding-agent': specifier: ^0.60.0 version: 0.60.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) - '@rivet-dev/agent-os-claude': + '@rivet-dev/agentos-claude': specifier: link:../../registry/agent/claude version: link:../../registry/agent/claude '@secure-exec/codex': specifier: link:../../registry/software/codex version: link:../../registry/software/codex - '@rivet-dev/agent-os-codex-agent': + '@rivet-dev/agentos-codex-agent': specifier: link:../../registry/agent/codex version: link:../../registry/agent/codex '@secure-exec/coreutils': @@ -93,13 +93,13 @@ importers: '@secure-exec/jq': specifier: link:../../registry/software/jq version: link:../../registry/software/jq - '@rivet-dev/agent-os-opencode': + '@rivet-dev/agentos-opencode': specifier: link:../../registry/agent/opencode version: link:../../registry/agent/opencode - '@rivet-dev/agent-os-pi': + '@rivet-dev/agentos-pi': specifier: link:../../registry/agent/pi version: link:../../registry/agent/pi - '@rivet-dev/agent-os-pi-cli': + '@rivet-dev/agentos-pi-cli': specifier: link:../../registry/agent/pi-cli version: link:../../registry/agent/pi-cli '@secure-exec/ripgrep': diff --git a/packages/core/scripts/compile-agent-os-protocol.mjs b/packages/core/scripts/compile-agent-os-protocol.mjs index 4d9efd4ee..8b499d4a3 100644 --- a/packages/core/scripts/compile-agent-os-protocol.mjs +++ b/packages/core/scripts/compile-agent-os-protocol.mjs @@ -8,7 +8,7 @@ const packageDir = path.resolve(scriptDir, ".."); const repoRoot = path.resolve(packageDir, "../.."); const schemaPath = path.join( repoRoot, - "crates/agent-os-protocol/protocol/agent_os_acp_v1.bare", + "crates/agentos-protocol/protocol/agent_os_acp_v1.bare", ); const outputPath = path.join( packageDir, diff --git a/packages/core/src/agent-os.ts b/packages/core/src/agent-os.ts index 97b6b3cc9..371795774 100644 --- a/packages/core/src/agent-os.ts +++ b/packages/core/src/agent-os.ts @@ -90,6 +90,14 @@ export type { ConnectTerminalOptions } from "./runtime-compat.js"; const ACP_PROTOCOL_VERSION = 1; const ACP_EXTENSION_NAMESPACE = "dev.rivet.agent-os.acp"; const SHELL_DISPOSE_TIMEOUT_MS = 5_000; +/** + * Reserved `env` key on `AcpResumeSessionRequest` carrying the resolved adapter + * bin entrypoint. The resume wire request omits a dedicated `adapterEntrypoint` + * field; the sidecar reads the entrypoint from this key and strips it before + * launching the adapter. Must stay in sync with the sidecar constant of the same + * name in `crates/agentos-sidecar/src/acp_extension.rs`. + */ +const RESUME_ADAPTER_ENTRYPOINT_ENV = "AGENT_OS_RESUME_ADAPTER_ENTRYPOINT"; function defaultAcpClientCapabilities(): Record { return { @@ -230,7 +238,7 @@ import { createAgentOsSidecarClient, NATIVE_SIDECAR_FRAME_TIMEOUT_MS, NativeSidecarKernelProxy, - NativeSidecarProcessClient, + SidecarProcess, type RootFilesystemEntry, type SidecarRegisteredHostCallbackDefinition, type SidecarRequestFrame, @@ -282,7 +290,7 @@ interface AgentOsVmAdmin extends InProcessSidecarVmAdmin { hostMounts: HostMountInfo[]; env: Record; permissions: Permissions; - sidecarClient: NativeSidecarProcessClient; + sidecarClient: SidecarProcess; sidecarSession: AuthenticatedSession; sidecarVm: CreatedVm; snapshotRootFilesystem?: () => Promise; @@ -466,6 +474,14 @@ export interface AgentOsOptions { * meta-packages that export arrays of sub-packages work directly. */ software?: SoftwareInput[]; + /** + * Whether to auto-include the default software bundle (`@agent-os-pkgs/common` + * — `sh` + coreutils + the standard CLI tools agents rely on) in addition to + * any `software` you pass. Defaults to `true`; set `false` for a bare VM with + * only the software you list explicitly. Entries already present in `software` + * are not duplicated. + */ + defaultSoftware?: boolean; /** Loopback ports to exempt from SSRF checks (for testing with host-side mock servers). */ loopbackExemptPorts?: number[]; /** @@ -542,6 +558,39 @@ export interface CreateSessionOptions { additionalInstructions?: string; } +/** + * Options for {@link AgentOs.resumeSession}. + * + * Resume depends on a durable root: after a Rivet actor sleeps (VM destroyed) and + * wakes (fresh VM, actor SQLite intact) the caller can keep prompting an existing + * session. On a non-durable (default in-memory) root there is no surviving store, + * so the sidecar's universal fallback tier always runs and the transcript pointer + * is the only continuity mechanism. + */ +export interface ResumeSessionOptions { + /** + * Guest-readable path to the reconstructed transcript. When present, the + * fallback tier arms a continuation preamble pointing the agent at it. + */ + transcriptPath?: string; + /** Working directory for the resumed agent session (default `/home/user`). */ + cwd?: string; + /** Environment variables to pass to the resumed agent process. */ + env?: Record; +} + +/** Result from {@link AgentOs.resumeSession}. */ +export interface ResumeSessionResult { + /** + * The live ACP session id in the fresh VM: equal to the requested id for + * native loads, or a freshly assigned id for the fallback tier — the caller + * remaps `external -> live`. + */ + sessionId: string; + /** `"native"` (session/load|resume) or `"fallback"` (session/new + preamble). */ + mode: string; +} + export interface SessionInfo { sessionId: string; agentType: string; @@ -1048,7 +1097,7 @@ const KERNEL_POSIX_BOOTSTRAP_DIRS = [ const NODE_RUNTIME_BOOTSTRAP_COMMANDS = ["node", "npm", "npx"] as const; const KERNEL_COMMAND_STUB = "#!/bin/sh\n# kernel command stub\n"; const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); -const SIDECAR_BINARY = join(REPO_ROOT, "target/debug/agent-os-sidecar"); +const SIDECAR_BINARY = join(REPO_ROOT, "target/debug/agentos-sidecar"); const SIDECAR_BUILD_INPUTS = [ join(REPO_ROOT, "Cargo.toml"), join(REPO_ROOT, "Cargo.lock"), @@ -1341,7 +1390,7 @@ function buildLiveBootstrapDirectoryEntries( } async function bootstrapLiveBootstrapDirectories( - client: NativeSidecarProcessClient, + client: SidecarProcess, session: AuthenticatedSession, vm: CreatedVm, config: RootFilesystemConfig | undefined, @@ -1405,9 +1454,9 @@ function convertSidecarRootSnapshotEntries( function ensureNativeSidecarBinary(): string { // A published install has no in-repo Cargo workspace to build from: resolve - // the prebuilt platform binary (or the AGENT_OS_SIDECAR_BIN override). + // the prebuilt platform binary (or the AGENTOS_SIDECAR_BIN override). if ( - process.env.AGENT_OS_SIDECAR_BIN || + process.env.AGENTOS_SIDECAR_BIN || !existsSync(join(REPO_ROOT, "Cargo.toml")) ) { return resolvePublishedSidecarBinary(); @@ -1423,14 +1472,14 @@ function ensureNativeSidecarBinary(): string { if (sidecarBinaryNeedsBuild()) { const cargoBinary = findCargoBinary(); if (cargoBinary) { - execFileSync(cargoBinary, ["build", "-q", "-p", "agent-os-sidecar"], { + execFileSync(cargoBinary, ["build", "-q", "-p", "agentos-sidecar"], { cwd: REPO_ROOT, stdio: "pipe", }); } else if (!existsSync(SIDECAR_BINARY)) { execFileSync( resolveCargoBinary(), - ["build", "-q", "-p", "agent-os-sidecar"], + ["build", "-q", "-p", "agentos-sidecar"], { cwd: REPO_ROOT, stdio: "pipe", @@ -2333,7 +2382,7 @@ function jsonSchemaType(schema: unknown): string | undefined { } async function registerToolkitsOnSidecar( - client: NativeSidecarProcessClient, + client: SidecarProcess, session: AuthenticatedSession, vm: CreatedVm, toolKits: ToolKit[], @@ -2404,7 +2453,7 @@ export class AgentOs { private _env: Record; private _rootFilesystem: VirtualFileSystem; private _sidecarLease: AgentOsSidecarVmLease | null = null; - private readonly _sidecarClient: NativeSidecarProcessClient; + private readonly _sidecarClient: SidecarProcess; private readonly _sidecarSession: AuthenticatedSession; private readonly _sidecarVm: CreatedVm; private readonly _disposeSidecarEventListener: () => void; @@ -2417,7 +2466,7 @@ export class AgentOs { hostMounts: HostMountInfo[], env: Record, rootFilesystem: VirtualFileSystem, - sidecarClient: NativeSidecarProcessClient, + sidecarClient: SidecarProcess, sidecarSession: AuthenticatedSession, sidecarVm: CreatedVm, ) { @@ -2478,7 +2527,7 @@ export class AgentOs { let toolReference = ""; let rootBridge: NativeSidecarKernelProxy | null = null; let kernel: Kernel | null = null; - let client: NativeSidecarProcessClient | null = null; + let client: SidecarProcess | null = null; let toolShimDir: string | null = null; let cleanedUp = false; @@ -2509,7 +2558,7 @@ export class AgentOs { commandDirs: preparedCommandDirs.commandDirs, shimDir: toolShimDir, }); - client = NativeSidecarProcessClient.spawn({ + client = SidecarProcess.spawn({ cwd: REPO_ROOT, command: ensureNativeSidecarBinary(), args: [], @@ -3459,7 +3508,7 @@ export class AgentOs { } private _handleSidecarEvent( - event: Parameters[0] extends ( + event: Parameters[0] extends ( event: infer T, ) => void ? T @@ -3884,6 +3933,80 @@ export class AgentOs { return { sessionId: created.sessionId }; } + /** + * Resume a session that exists in durable storage but is not live in this VM + * (e.g. after a Rivet actor slept and woke with a fresh VM). Thin forwarder: + * resolves the agent config + adapter entrypoint exactly as {@link createSession} + * does, then forwards a single `AcpResumeSessionRequest` to the sidecar, which + * owns the resume state machine (native `session/load` when the agent supports + * it, else `session/new` + a transcript-continuation preamble). The returned + * `sessionId` is the live id in this VM (equal to the requested id for native + * loads, freshly assigned for the fallback); the caller remaps `external -> live`. + * The new live session is registered + hydrated locally so subsequent prompts + * route to it. + * + * Resume depends on a durable root; on a non-durable (default in-memory) root + * there is no surviving store and the fallback tier always runs. + */ + async resumeSession( + sessionId: string, + agentType: AgentType | string, + options?: ResumeSessionOptions, + ): Promise { + const config = this._resolveAgentConfig(agentType); + if (!config) { + throw new Error(`Unknown agent type: ${agentType}`); + } + + const adapterEntrypoint = this._resolveAdapterBin(config.acpAdapter); + let launchEnv = { ...config.defaultEnv, ...options?.env }; + const sessionCwd = options?.cwd ?? "/home/user"; + if ( + (agentType === "pi" || agentType === "pi-cli") && + !launchEnv.PI_ACP_PI_COMMAND + ) { + launchEnv = { + ...launchEnv, + PI_ACP_PI_COMMAND: this._resolvePackageBin(config.agentPackage, "pi"), + }; + } + // The resume wire request has no dedicated `adapterEntrypoint` field; carry + // the resolved entrypoint through env under the sidecar's reserved key. The + // sidecar reads it and strips it before launching the adapter. + launchEnv = { + ...launchEnv, + [RESUME_ADAPTER_ENTRYPOINT_ENV]: adapterEntrypoint, + }; + + const response = await this._sendAcpRequest({ + tag: "AcpResumeSessionRequest", + val: { + sessionId, + agentType: String(agentType), + transcriptPath: options?.transcriptPath ?? null, + cwd: sessionCwd, + env: new Map(Object.entries(launchEnv)), + }, + }); + if (response.tag !== "AcpSessionResumedResponse") { + throw new Error(`unexpected resume_session response: ${response.tag}`); + } + const { sessionId: liveSessionId, mode } = response.val; + + // Register + hydrate the live session so subsequent prompts route to it. + const session = sessionEntryFromInit(liveSessionId, String(agentType), {}); + this._closedSessionIds.delete(liveSessionId); + this._sessions.set(liveSessionId, session); + try { + await this._hydrateSessionState(session); + } catch (error) { + this._removeSession(liveSessionId); + throw error; + } + + return { sessionId: liveSessionId, mode }; + } + /** * Resolve the VM bin entry point of an ACP adapter package. * Reads from the host filesystem since kernel.readFile() resolves through @@ -5036,7 +5159,7 @@ export class AgentOsSidecar { function createAgentOsSidecarInternal( options: AgentOsCreateSidecarOptions = {}, ): AgentOsSidecar { - const sidecarId = options.sidecarId ?? `agent-os-sidecar-${randomUUID()}`; + const sidecarId = options.sidecarId ?? `agentos-sidecar-${randomUUID()}`; return new AgentOsSidecar(sidecarId, { kind: "explicit", sidecarId, diff --git a/packages/core/src/agents.ts b/packages/core/src/agents.ts index 68a784f49..171019364 100644 --- a/packages/core/src/agents.ts +++ b/packages/core/src/agents.ts @@ -24,7 +24,7 @@ export interface AgentConfig { export const AGENT_CONFIGS = { pi: { - acpAdapter: "@rivet-dev/agent-os-pi", + acpAdapter: "@rivet-dev/agentos-pi", agentPackage: "@mariozechner/pi-coding-agent", }, "pi-cli": { @@ -32,15 +32,15 @@ export const AGENT_CONFIGS = { agentPackage: "@mariozechner/pi-coding-agent", }, opencode: { - acpAdapter: "@rivet-dev/agent-os-opencode", - agentPackage: "@rivet-dev/agent-os-opencode", + acpAdapter: "@rivet-dev/agentos-opencode", + agentPackage: "@rivet-dev/agentos-opencode", defaultEnv: { OPENCODE_DISABLE_CONFIG_DEP_INSTALL: "1", OPENCODE_DISABLE_EMBEDDED_WEB_UI: "1", }, }, claude: { - acpAdapter: "@rivet-dev/agent-os-claude", + acpAdapter: "@rivet-dev/agentos-claude", agentPackage: "@anthropic-ai/claude-agent-sdk", defaultEnv: { CLAUDE_AGENT_SDK_CLIENT_APP: "@rivet-dev/agent-os", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 762f4a613..99cd55541 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,6 +20,9 @@ export { createSnapshotExport, } from "./layers.js"; export { defineSoftware } from "./packages.js"; -export { isAcpTimeoutErrorData } from "./json-rpc.js"; +export { + isAcpTimeoutErrorData, + isUnknownSessionErrorData, +} from "./json-rpc.js"; export { createInMemoryFileSystem, KernelError } from "./runtime-compat.js"; export type * from "./types.js"; diff --git a/packages/core/src/json-rpc.ts b/packages/core/src/json-rpc.ts index cf1002d7a..74d6411ae 100644 --- a/packages/core/src/json-rpc.ts +++ b/packages/core/src/json-rpc.ts @@ -9,7 +9,26 @@ export interface AcpTimeoutErrorData { recentActivity: string[]; } -export type JsonRpcErrorData = AcpTimeoutErrorData | Record; +/** + * Structured error payload meaning "the session id is not known to the agent" — + * e.g. a `session/load` / `session/resume` against a session whose on-disk store did + * not survive a VM teardown. The sidecar normalizes the adapter's native error into + * this shape so the resume orchestration can distinguish "fall through to a fresh + * session" from a transport/timeout error (which must propagate). Mirrors the + * `acp_timeout` convention. + */ +export interface UnknownSessionErrorData { + kind: "unknown_session"; + /** Optional metadata. The discriminator is `kind` alone — the sidecar's + * normalized error carries only `kind`, so this stays optional to keep the + * sidecar and client contracts aligned. */ + sessionId?: string; +} + +export type JsonRpcErrorData = + | AcpTimeoutErrorData + | UnknownSessionErrorData + | Record; export interface JsonRpcRequest { jsonrpc: "2.0"; @@ -55,3 +74,16 @@ export function isAcpTimeoutErrorData( record.recentActivity.every((entry) => typeof entry === "string") ); } + +export function isUnknownSessionErrorData( + value: unknown, +): value is UnknownSessionErrorData { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const record = value as Record; + return ( + record.kind === "unknown_session" && + (record.sessionId === undefined || typeof record.sessionId === "string") + ); +} diff --git a/packages/core/src/runtime-compat.ts b/packages/core/src/runtime-compat.ts index fe6e4275a..4bdd2c74a 100644 --- a/packages/core/src/runtime-compat.ts +++ b/packages/core/src/runtime-compat.ts @@ -18,7 +18,7 @@ import { type LocalCompatMount, NATIVE_SIDECAR_FRAME_TIMEOUT_MS, NativeSidecarKernelProxy, - NativeSidecarProcessClient, + SidecarProcess, type RootFilesystemEntry, serializeMountConfigForSidecar, } from "./sidecar/rpc-client.js"; @@ -76,7 +76,7 @@ const KERNEL_POSIX_BOOTSTRAP_DIRS = [ "/var/tmp", ] as const; const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); -const SIDECAR_BINARY = path.join(REPO_ROOT, "target/debug/agent-os-sidecar"); +const SIDECAR_BINARY = path.join(REPO_ROOT, "target/debug/agentos-sidecar"); const SIDECAR_BUILD_INPUTS = [ path.join(REPO_ROOT, "Cargo.toml"), path.join(REPO_ROOT, "Cargo.lock"), @@ -1814,9 +1814,9 @@ function sidecarBinaryNeedsBuild(): boolean { function ensureNativeSidecarBinary(): string { // A published install has no in-repo Cargo workspace to build from: resolve - // the prebuilt platform binary (or the AGENT_OS_SIDECAR_BIN override). + // the prebuilt platform binary (or the AGENTOS_SIDECAR_BIN override). if ( - process.env.AGENT_OS_SIDECAR_BIN || + process.env.AGENTOS_SIDECAR_BIN || !fsSync.existsSync(path.join(REPO_ROOT, "Cargo.toml")) ) { return resolvePublishedSidecarBinary(); @@ -1831,14 +1831,14 @@ function ensureNativeSidecarBinary(): string { if (sidecarBinaryNeedsBuild()) { const cargoBinary = findCargoBinary(); if (cargoBinary) { - execFileSync(cargoBinary, ["build", "-q", "-p", "agent-os-sidecar"], { + execFileSync(cargoBinary, ["build", "-q", "-p", "agentos-sidecar"], { cwd: REPO_ROOT, stdio: "pipe", }); } else if (!fsSync.existsSync(SIDECAR_BINARY)) { execFileSync( resolveCargoBinary(), - ["build", "-q", "-p", "agent-os-sidecar"], + ["build", "-q", "-p", "agentos-sidecar"], { cwd: REPO_ROOT, stdio: "pipe", @@ -1993,7 +1993,7 @@ async function snapshotFilesystemEntries( } async function materializeSnapshotEntriesIntoVm( - client: NativeSidecarProcessClient, + client: SidecarProcess, session: AuthenticatedSession, vm: CreatedVm, entries: RootFilesystemEntry[], @@ -2421,7 +2421,7 @@ class NativeKernel implements Kernel { readonly timerTable = {}; readonly vfs: VirtualFileSystem; - private client: NativeSidecarProcessClient | null = null; + private client: SidecarProcess | null = null; private session: AuthenticatedSession | null = null; private vm: CreatedVm | null = null; private proxy: NativeSidecarKernelProxy | null = null; @@ -2817,7 +2817,7 @@ class NativeKernel implements Kernel { bootstrapEntries: [], }; - const client = NativeSidecarProcessClient.spawn({ + const client = SidecarProcess.spawn({ cwd: REPO_ROOT, command: ensureNativeSidecarBinary(), args: [], diff --git a/packages/core/src/sidecar/agent-os-protocol.ts b/packages/core/src/sidecar/agent-os-protocol.ts index 9152653d1..5517da7ee 100644 --- a/packages/core/src/sidecar/agent-os-protocol.ts +++ b/packages/core/src/sidecar/agent-os-protocol.ts @@ -212,11 +212,46 @@ export function writeAcpCloseSessionRequest(bc: bare.ByteCursor, x: AcpCloseSess bare.writeString(bc, x.sessionId) } +/** + * Resume a session that exists in durable storage but is not live in the current + * VM (e.g. after a Rivet actor slept and woke with a fresh VM). The sidecar runs + * the stateless resume state machine (native session/load when the agent supports + * it, else a fresh session/new + transcript continuation preamble). `cwd`/`env` + * describe the fresh adapter launch used by the fallback tier. `transcriptPath`, + * when present, is a guest-readable path the fallback preamble points the agent at. + */ +export type AcpResumeSessionRequest = { + readonly sessionId: string + readonly agentType: string + readonly transcriptPath: string | null + readonly cwd: string + readonly env: ReadonlyMap +} + +export function readAcpResumeSessionRequest(bc: bare.ByteCursor): AcpResumeSessionRequest { + return { + sessionId: bare.readString(bc), + agentType: bare.readString(bc), + transcriptPath: read2(bc), + cwd: bare.readString(bc), + env: read1(bc), + } +} + +export function writeAcpResumeSessionRequest(bc: bare.ByteCursor, x: AcpResumeSessionRequest): void { + bare.writeString(bc, x.sessionId) + bare.writeString(bc, x.agentType) + write2(bc, x.transcriptPath) + bare.writeString(bc, x.cwd) + write1(bc, x.env) +} + export type AcpRequest = | { readonly tag: "AcpCreateSessionRequest"; readonly val: AcpCreateSessionRequest } | { readonly tag: "AcpSessionRequest"; readonly val: AcpSessionRequest } | { readonly tag: "AcpGetSessionStateRequest"; readonly val: AcpGetSessionStateRequest } | { readonly tag: "AcpCloseSessionRequest"; readonly val: AcpCloseSessionRequest } + | { readonly tag: "AcpResumeSessionRequest"; readonly val: AcpResumeSessionRequest } export function readAcpRequest(bc: bare.ByteCursor): AcpRequest { const offset = bc.offset @@ -230,6 +265,8 @@ export function readAcpRequest(bc: bare.ByteCursor): AcpRequest { return { tag: "AcpGetSessionStateRequest", val: readAcpGetSessionStateRequest(bc) } case 3: return { tag: "AcpCloseSessionRequest", val: readAcpCloseSessionRequest(bc) } + case 4: + return { tag: "AcpResumeSessionRequest", val: readAcpResumeSessionRequest(bc) } default: { bc.offset = offset throw new bare.BareError(offset, "invalid tag") @@ -259,6 +296,11 @@ export function writeAcpRequest(bc: bare.ByteCursor, x: AcpRequest): void { writeAcpCloseSessionRequest(bc, x.val) break } + case "AcpResumeSessionRequest": { + bare.writeU8(bc, 4) + writeAcpResumeSessionRequest(bc, x.val) + break + } } } @@ -423,6 +465,30 @@ export function writeAcpSessionClosedResponse(bc: bare.ByteCursor, x: AcpSession bare.writeString(bc, x.sessionId) } +/** + * Result of AcpResumeSessionRequest. `sessionId` is the live ACP session id after + * resume: equal to the requested id for native loads, or the freshly assigned id + * for the fallback tier (the caller remaps external -> live). `mode` is "native" + * (session/load|resume succeeded) or "fallback" (a new session was created and the + * transcript-continuation preamble was armed for the next prompt). + */ +export type AcpSessionResumedResponse = { + readonly sessionId: string + readonly mode: string +} + +export function readAcpSessionResumedResponse(bc: bare.ByteCursor): AcpSessionResumedResponse { + return { + sessionId: bare.readString(bc), + mode: bare.readString(bc), + } +} + +export function writeAcpSessionResumedResponse(bc: bare.ByteCursor, x: AcpSessionResumedResponse): void { + bare.writeString(bc, x.sessionId) + bare.writeString(bc, x.mode) +} + export type AcpErrorResponse = { readonly code: string readonly message: string @@ -445,6 +511,7 @@ export type AcpResponse = | { readonly tag: "AcpSessionRpcResponse"; readonly val: AcpSessionRpcResponse } | { readonly tag: "AcpSessionStateResponse"; readonly val: AcpSessionStateResponse } | { readonly tag: "AcpSessionClosedResponse"; readonly val: AcpSessionClosedResponse } + | { readonly tag: "AcpSessionResumedResponse"; readonly val: AcpSessionResumedResponse } | { readonly tag: "AcpErrorResponse"; readonly val: AcpErrorResponse } export function readAcpResponse(bc: bare.ByteCursor): AcpResponse { @@ -460,6 +527,8 @@ export function readAcpResponse(bc: bare.ByteCursor): AcpResponse { case 3: return { tag: "AcpSessionClosedResponse", val: readAcpSessionClosedResponse(bc) } case 4: + return { tag: "AcpSessionResumedResponse", val: readAcpSessionResumedResponse(bc) } + case 5: return { tag: "AcpErrorResponse", val: readAcpErrorResponse(bc) } default: { bc.offset = offset @@ -490,8 +559,13 @@ export function writeAcpResponse(bc: bare.ByteCursor, x: AcpResponse): void { writeAcpSessionClosedResponse(bc, x.val) break } - case "AcpErrorResponse": { + case "AcpSessionResumedResponse": { bare.writeU8(bc, 4) + writeAcpSessionResumedResponse(bc, x.val) + break + } + case "AcpErrorResponse": { + bare.writeU8(bc, 5) writeAcpErrorResponse(bc, x.val) break } diff --git a/packages/core/src/sidecar/binary.ts b/packages/core/src/sidecar/binary.ts index ccd6a709b..31bc5105c 100644 --- a/packages/core/src/sidecar/binary.ts +++ b/packages/core/src/sidecar/binary.ts @@ -8,17 +8,17 @@ interface SidecarBinaryModule { /** * Resolve the prebuilt sidecar binary for a published (non-repo) install. * - * Honors `AGENT_OS_SIDECAR_BIN` as an absolute-path override, otherwise + * Honors `AGENTOS_SIDECAR_BIN` as an absolute-path override, otherwise * resolves the platform-specific binary shipped by the - * `@rivet-dev/agent-os-sidecar` package. In-repo developer builds use the local + * `@rivet-dev/agentos-sidecar` package. In-repo developer builds use the local * cargo build path instead and never reach this function. */ export function resolvePublishedSidecarBinary(): string { - const override = process.env.AGENT_OS_SIDECAR_BIN; + const override = process.env.AGENTOS_SIDECAR_BIN; if (override) { if (!existsSync(override)) { throw new Error( - `AGENT_OS_SIDECAR_BIN is set to ${override} but the file does not exist`, + `AGENTOS_SIDECAR_BIN is set to ${override} but the file does not exist`, ); } return override; @@ -27,12 +27,12 @@ export function resolvePublishedSidecarBinary(): string { const require = createRequire(import.meta.url); let mod: SidecarBinaryModule; try { - mod = require("@rivet-dev/agent-os-sidecar") as SidecarBinaryModule; + mod = require("@rivet-dev/agentos-sidecar") as SidecarBinaryModule; } catch (error) { throw new Error( - "failed to resolve the Agent OS sidecar binary: the @rivet-dev/agent-os-sidecar " + - "package is not installed. Install it, or set AGENT_OS_SIDECAR_BIN to a local " + - `agent-os-sidecar binary. (${(error as Error).message})`, + "failed to resolve the Agent OS sidecar binary: the @rivet-dev/agentos-sidecar " + + "package is not installed. Install it, or set AGENTOS_SIDECAR_BIN to a local " + + `agentos-sidecar binary. (${(error as Error).message})`, ); } return mod.getSidecarPath(); diff --git a/packages/core/src/sidecar/native-process-client.ts b/packages/core/src/sidecar/native-process-client.ts index 79f44c850..6b1b9f216 100644 --- a/packages/core/src/sidecar/native-process-client.ts +++ b/packages/core/src/sidecar/native-process-client.ts @@ -1,6 +1,6 @@ export { NATIVE_SIDECAR_FRAME_TIMEOUT_MS, - NativeSidecarProcessClient, + NativeSidecarProcessClient as SidecarProcess, SidecarEventBufferOverflow, SidecarProcessError, SidecarProcessExited, @@ -11,7 +11,7 @@ export type { CreatedVm, ExtEnvelope, GuestFilesystemStat, - NativeSidecarSpawnOptions, + NativeSidecarSpawnOptions as SidecarSpawnOptions, RootFilesystemEntry, RootFilesystemLowerDescriptor, SidecarEventSelector, diff --git a/packages/core/src/sidecar/process.ts b/packages/core/src/sidecar/process.ts index 4bea2139f..9f0f60b00 100644 --- a/packages/core/src/sidecar/process.ts +++ b/packages/core/src/sidecar/process.ts @@ -1,17 +1,17 @@ import { - NativeSidecarProcessClient, - type NativeSidecarSpawnOptions, + SidecarProcess, + type SidecarSpawnOptions, } from "./native-process-client.js"; export interface AgentOsSidecarProcessHandle { - client: NativeSidecarProcessClient; + client: SidecarProcess; dispose(): Promise; } export function spawnAgentOsSidecar( - options: NativeSidecarSpawnOptions, + options: SidecarSpawnOptions, ): AgentOsSidecarProcessHandle { - const client = NativeSidecarProcessClient.spawn(options); + const client = SidecarProcess.spawn(options); return { client, dispose: () => client.dispose(), diff --git a/packages/core/src/sidecar/rpc-client.ts b/packages/core/src/sidecar/rpc-client.ts index ef5f4012f..6c65df410 100644 --- a/packages/core/src/sidecar/rpc-client.ts +++ b/packages/core/src/sidecar/rpc-client.ts @@ -32,7 +32,7 @@ import type { AuthenticatedSession, CreatedVm, GuestFilesystemStat, - NativeSidecarProcessClient, + SidecarProcess, SidecarProcessSnapshotEntry, SidecarSignalHandlerRegistration, SidecarSocketStateEntry, @@ -283,7 +283,7 @@ interface TrackedProcessEntry { } interface NativeSidecarKernelProxyOptions { - client: NativeSidecarProcessClient; + client: SidecarProcess; session: AuthenticatedSession; vm: CreatedVm; env: Record; @@ -303,7 +303,7 @@ export class NativeSidecarKernelProxy { readonly processes = new Map(); private readonly defaultExecCwd: string | undefined; - private readonly client: NativeSidecarProcessClient; + private readonly client: SidecarProcess; private readonly session: AuthenticatedSession; private readonly vm: CreatedVm; private readonly localMounts: LocalCompatMount[]; @@ -2226,7 +2226,7 @@ export type { AuthenticatedSession, CreatedVm, GuestFilesystemStat, - NativeSidecarSpawnOptions, + SidecarSpawnOptions, RootFilesystemEntry, SidecarEventSelector, SidecarPermissionsPolicy, @@ -2239,7 +2239,7 @@ export type { } from "./native-process-client.js"; export { NATIVE_SIDECAR_FRAME_TIMEOUT_MS, - NativeSidecarProcessClient, + SidecarProcess, SidecarEventBufferOverflow, SidecarProcessError, SidecarProcessExited, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0b0d3af83..f1f4e84ff 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -30,6 +30,8 @@ export type { ProcessTreeNode, PromptResult, ReaddirRecursiveOptions, + ResumeSessionOptions, + ResumeSessionResult, RootFilesystemConfig, RootLowerInput, SessionConfigOption, @@ -65,6 +67,7 @@ export type { JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, + UnknownSessionErrorData, } from "./json-rpc.js"; export type { FilesystemSnapshotExport, diff --git a/packages/core/tests/agent-config-environment.test.ts b/packages/core/tests/agent-config-environment.test.ts index 20690ba13..a0355429e 100644 --- a/packages/core/tests/agent-config-environment.test.ts +++ b/packages/core/tests/agent-config-environment.test.ts @@ -1,9 +1,9 @@ import { resolve } from "node:path"; -import claude from "@rivet-dev/agent-os-claude"; +import claude from "@rivet-dev/agentos-claude"; import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; -import opencode from "@rivet-dev/agent-os-opencode"; -import pi from "@rivet-dev/agent-os-pi"; -import piCli from "@rivet-dev/agent-os-pi-cli"; +import opencode from "@rivet-dev/agentos-opencode"; +import pi from "@rivet-dev/agentos-pi"; +import piCli from "@rivet-dev/agentos-pi-cli"; import { describe, expect, test } from "vitest"; import { AgentOs, type AgentInfo } from "../src/agent-os.js"; import type { SoftwareInput } from "../src/packages.js"; diff --git a/packages/core/tests/claude-session.test.ts b/packages/core/tests/claude-session.test.ts index e2399f477..186295fb8 100644 --- a/packages/core/tests/claude-session.test.ts +++ b/packages/core/tests/claude-session.test.ts @@ -1,7 +1,7 @@ import { resolve } from "node:path"; import type { Fixture, LLMock, ToolCall } from "@copilotkit/llmock"; import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; -import claude from "@rivet-dev/agent-os-claude"; +import claude from "@rivet-dev/agentos-claude"; import { afterAll, afterEach, diff --git a/packages/core/tests/codex-session.test.ts b/packages/core/tests/codex-session.test.ts index 6f00c54fc..88055ce1c 100644 --- a/packages/core/tests/codex-session.test.ts +++ b/packages/core/tests/codex-session.test.ts @@ -1,5 +1,5 @@ import { resolve } from "node:path"; -import codex from "@rivet-dev/agent-os-codex-agent"; +import codex from "@rivet-dev/agentos-codex-agent"; import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; import { afterEach, describe, expect, test } from "vitest"; import { AgentOs } from "../src/agent-os.js"; diff --git a/packages/core/tests/filesystem.test.ts b/packages/core/tests/filesystem.test.ts index 6aec3262d..a01be6fb6 100644 --- a/packages/core/tests/filesystem.test.ts +++ b/packages/core/tests/filesystem.test.ts @@ -1,7 +1,7 @@ import { resolve } from "node:path"; import type { Fixture, ToolCall } from "@copilotkit/llmock"; import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; -import claude from "@rivet-dev/agent-os-claude"; +import claude from "@rivet-dev/agentos-claude"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { AgentOs } from "../src/index.js"; import { diff --git a/packages/core/tests/helpers/opencode-helper.ts b/packages/core/tests/helpers/opencode-helper.ts index 06e32f8c0..32d3a1ed0 100644 --- a/packages/core/tests/helpers/opencode-helper.ts +++ b/packages/core/tests/helpers/opencode-helper.ts @@ -34,7 +34,7 @@ async function mkdirpVm(vm: AgentOs, targetPath: string): Promise { export function resolveOpenCodeAdapterBinPath(hostProjectDir: string): string { const hostPkgJson = join( hostProjectDir, - "node_modules/@rivet-dev/agent-os-opencode/package.json", + "node_modules/@rivet-dev/agentos-opencode/package.json", ); const pkg = JSON.parse(readFileSync(hostPkgJson, "utf-8")); @@ -45,11 +45,11 @@ export function resolveOpenCodeAdapterBinPath(hostProjectDir: string): string { binEntry = Object.values(pkg.bin)[0] as string; } else { throw new Error( - "No bin entry in @rivet-dev/agent-os-opencode package.json", + "No bin entry in @rivet-dev/agentos-opencode package.json", ); } - return `/root/node_modules/@rivet-dev/agent-os-opencode/${binEntry}`; + return `/root/node_modules/@rivet-dev/agentos-opencode/${binEntry}`; } export async function createVmOpenCodeHome( diff --git a/packages/core/tests/native-sidecar-process-permissions.test.ts b/packages/core/tests/native-sidecar-process-permissions.test.ts index b0791ebae..19db1e5bd 100644 --- a/packages/core/tests/native-sidecar-process-permissions.test.ts +++ b/packages/core/tests/native-sidecar-process-permissions.test.ts @@ -17,12 +17,12 @@ import { import { findCargoBinary, resolveCargoBinary } from "../src/sidecar/cargo.js"; const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); -const SIDECAR_BINARY = join(REPO_ROOT, "target/debug/agent-os-sidecar"); +const SIDECAR_BINARY = join(REPO_ROOT, "target/debug/agentos-sidecar"); function ensureSidecarBinaryReady(): void { const cargoBinary = findCargoBinary(); if (cargoBinary) { - execFileSync(cargoBinary, ["build", "-q", "-p", "agent-os-sidecar"], { + execFileSync(cargoBinary, ["build", "-q", "-p", "agentos-sidecar"], { cwd: REPO_ROOT, stdio: "pipe", }); @@ -32,7 +32,7 @@ function ensureSidecarBinaryReady(): void { if (!existsSync(SIDECAR_BINARY)) { execFileSync( resolveCargoBinary(), - ["build", "-q", "-p", "agent-os-sidecar"], + ["build", "-q", "-p", "agentos-sidecar"], { cwd: REPO_ROOT, stdio: "pipe", @@ -75,7 +75,7 @@ describe("native sidecar process client permissions", () => { test("writes declarative permissions policies with child_process wire keys", async () => { const fixtureRoot = mkdtempSync( - join(tmpdir(), "agent-os-sidecar-permissions-"), + join(tmpdir(), "agentos-sidecar-permissions-"), ); cleanupPaths.push(fixtureRoot); const capturePath = join(fixtureRoot, "captured-requests.json"); @@ -321,7 +321,7 @@ describe("native sidecar process client permissions", () => { test("inspection RPCs are denied by default and allowed with explicit inspect permissions", async () => { const fixtureRoot = mkdtempSync( - join(tmpdir(), "agent-os-sidecar-inspection-permissions-"), + join(tmpdir(), "agentos-sidecar-inspection-permissions-"), ); cleanupPaths.push(fixtureRoot); ensureSidecarBinaryReady(); @@ -585,7 +585,7 @@ describe("native sidecar process client permissions", () => { test("keeps single-star fs permission globs within one path segment", async () => { const fixtureRoot = mkdtempSync( - join(tmpdir(), "agent-os-sidecar-permission-glob-"), + join(tmpdir(), "agentos-sidecar-permission-glob-"), ); cleanupPaths.push(fixtureRoot); ensureSidecarBinaryReady(); diff --git a/packages/core/tests/native-sidecar-process.test.ts b/packages/core/tests/native-sidecar-process.test.ts index 234b5fb7c..f2a7c0636 100644 --- a/packages/core/tests/native-sidecar-process.test.ts +++ b/packages/core/tests/native-sidecar-process.test.ts @@ -37,7 +37,7 @@ import { serializePermissionsForSidecar } from "../src/sidecar/permissions.js"; import { REGISTRY_SOFTWARE } from "./helpers/registry-commands.js"; const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); -const SIDECAR_BINARY = join(REPO_ROOT, "target/debug/agent-os-sidecar"); +const SIDECAR_BINARY = join(REPO_ROOT, "target/debug/agentos-sidecar"); const REGISTRY_COMMANDS_DIR = (() => { const commandPackage = REGISTRY_SOFTWARE.find((pkg) => pkg.commands?.some((command) => command.name === "sh"), @@ -63,7 +63,7 @@ const ALLOW_ALL_SIDECAR_PERMISSIONS = serializePermissionsForSidecar( function ensureSidecarBinaryReady(): void { const cargoBinary = findCargoBinary(); if (cargoBinary) { - execFileSync(cargoBinary, ["build", "-q", "-p", "agent-os-sidecar"], { + execFileSync(cargoBinary, ["build", "-q", "-p", "agentos-sidecar"], { cwd: REPO_ROOT, stdio: "pipe", }); @@ -73,7 +73,7 @@ function ensureSidecarBinaryReady(): void { if (!existsSync(SIDECAR_BINARY)) { execFileSync( resolveCargoBinary(), - ["build", "-q", "-p", "agent-os-sidecar"], + ["build", "-q", "-p", "agentos-sidecar"], { cwd: REPO_ROOT, stdio: "pipe", @@ -371,7 +371,7 @@ describe("native sidecar process client", () => { test("dispatches BARE sidecar_request frames to the registered handler", async () => { const fixtureRoot = mkdtempSync( - join(tmpdir(), "agent-os-sidecar-request-"), + join(tmpdir(), "agentos-sidecar-request-"), ); cleanupPaths.push(fixtureRoot); const capturePath = join(fixtureRoot, "captured-response.json"); @@ -481,7 +481,7 @@ describe("native sidecar process client", () => { test("dispose forcibly terminates a sidecar that ignores stdin closure", async () => { const fixtureRoot = mkdtempSync( - join(tmpdir(), "agent-os-sidecar-dispose-"), + join(tmpdir(), "agentos-sidecar-dispose-"), ); cleanupPaths.push(fixtureRoot); const driverPath = join(fixtureRoot, "stuck-sidecar.mjs"); @@ -542,7 +542,7 @@ describe("native sidecar process client", () => { test("caps buffered events and fails fast when 10k unmatched events arrive before draining", async () => { const fixtureRoot = mkdtempSync( - join(tmpdir(), "agent-os-sidecar-event-buffer-"), + join(tmpdir(), "agentos-sidecar-event-buffer-"), ); cleanupPaths.push(fixtureRoot); const driverPath = join(fixtureRoot, "overflow-sidecar.mjs"); @@ -631,7 +631,7 @@ describe("native sidecar process client", () => { test("rejects in-flight requests immediately when the sidecar child exits", async () => { const fixtureRoot = mkdtempSync( - join(tmpdir(), "agent-os-sidecar-child-exit-"), + join(tmpdir(), "agentos-sidecar-child-exit-"), ); cleanupPaths.push(fixtureRoot); const driverPath = join(fixtureRoot, "fake-sidecar.mjs"); @@ -762,7 +762,7 @@ describe("native sidecar process client", () => { cwd: REPO_ROOT, command: join( tmpdir(), - `agent-os-sidecar-missing-${process.pid}-${Date.now()}`, + `agentos-sidecar-missing-${process.pid}-${Date.now()}`, ), args: [], frameTimeoutMs: 30_000, @@ -1254,7 +1254,7 @@ describe("native sidecar process client", () => { test("configures native mounts and streams stdin through the real Rust sidecar binary", async () => { const fixtureRoot = mkdtempSync(join(tmpdir(), "agent-os-native-sidecar-")); const hostMountRoot = mkdtempSync( - join(tmpdir(), "agent-os-sidecar-host-dir-"), + join(tmpdir(), "agentos-sidecar-host-dir-"), ); cleanupPaths.push(fixtureRoot, hostMountRoot); writeFileSync( diff --git a/packages/core/tests/opencode-headless.test.ts b/packages/core/tests/opencode-headless.test.ts index 7f24a0408..171f1f9c8 100644 --- a/packages/core/tests/opencode-headless.test.ts +++ b/packages/core/tests/opencode-headless.test.ts @@ -22,11 +22,11 @@ describe("OpenCode VM package", () => { const script = ` const fs = require("fs"); -const pkgPath = "/root/node_modules/@rivet-dev/agent-os-opencode/package.json"; +const pkgPath = "/root/node_modules/@rivet-dev/agentos-opencode/package.json"; const manifestPath = - "/root/node_modules/@rivet-dev/agent-os-opencode/dist/opencode-acp.manifest.json"; + "/root/node_modules/@rivet-dev/agentos-opencode/dist/opencode-acp.manifest.json"; const bundlePath = - "/root/node_modules/@rivet-dev/agent-os-opencode/dist/opencode-acp/acp.js"; + "/root/node_modules/@rivet-dev/agentos-opencode/dist/opencode-acp/acp.js"; const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); @@ -54,7 +54,7 @@ console.log("legacyWrapper:" + fs.existsSync("/root/node_modules/opencode-ai/pac const exitCode = await vm.waitProcess(pid); expect(exitCode, `Failed. stderr: ${stderr}`).toBe(0); - expect(stdout).toContain("pkg:@rivet-dev/agent-os-opencode"); + expect(stdout).toContain("pkg:@rivet-dev/agentos-opencode"); expect(stdout).toContain("bundle:true"); expect(stdout).toContain("sourceRepo:anomalyco/opencode"); expect(stdout).toContain("sourceVersion:1.3.13"); @@ -65,7 +65,7 @@ console.log("legacyWrapper:" + fs.existsSync("/root/node_modules/opencode-ai/pac const script = ` const fs = require("fs"); const modulePath = - "/root/node_modules/@rivet-dev/agent-os-opencode/dist/opencode-acp/acp.js"; + "/root/node_modules/@rivet-dev/agentos-opencode/dist/opencode-acp/acp.js"; const source = fs.readFileSync(modulePath, "utf8"); console.log("hasAcpCommand:" + source.includes("AcpCommand")); `; diff --git a/packages/core/tests/opencode-session.test.ts b/packages/core/tests/opencode-session.test.ts index d4a272c06..4192e6e5b 100644 --- a/packages/core/tests/opencode-session.test.ts +++ b/packages/core/tests/opencode-session.test.ts @@ -3,10 +3,11 @@ import { type IncomingMessage, type ServerResponse, } from "node:http"; -import { resolve } from "node:path"; -import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; import type { Fixture, ToolCall } from "@copilotkit/llmock"; -import opencode from "@rivet-dev/agent-os-opencode"; +import opencode from "@rivet-dev/agentos-opencode"; import { describe, expect, test } from "vitest"; import type { AgentCapabilities, AgentInfo } from "../src/agent-os.js"; import { AgentOs } from "../src/agent-os.js"; @@ -21,9 +22,46 @@ import { createVmWorkspace, readVmText, } from "./helpers/opencode-helper.js"; -import { REGISTRY_SOFTWARE } from "./helpers/registry-commands.js"; +import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; const MODULE_ACCESS_CWD = resolve(import.meta.dirname, ".."); +const REGISTRY_COMMAND_DIR_CANDIDATES = [ + resolve( + import.meta.dirname, + "../../../registry/native/target/wasm32-wasip1/release/commands", + ), + resolve( + import.meta.dirname, + "../../../../secure-exec/registry/native/target/wasm32-wasip1/release/commands", + ), +]; + +function findShellCommandDir(): string | null { + for (const candidate of REGISTRY_COMMAND_DIR_CANDIDATES) { + if ( + existsSync(candidate) && + existsSync(resolve(candidate, "sh")) && + existsSync(resolve(candidate, "bash")) + ) { + return candidate; + } + } + return null; +} + +const shellCommandDir = findShellCommandDir(); +const shellSoftware = shellCommandDir + ? [ + { + commandDir: shellCommandDir, + commands: [ + { name: "sh", permissionTier: "full" }, + { name: "bash", permissionTier: "full", aliasOf: "sh" }, + ], + }, + ] + : []; +const testWithShell = shellCommandDir ? test : test.skip; type LlmockMessage = { role?: string; @@ -206,7 +244,14 @@ async function createOpenCodeVm(mockUrl: string): Promise { return AgentOs.create({ loopbackExemptPorts: [Number(new URL(mockUrl).port)], mounts: moduleAccessMounts(MODULE_ACCESS_CWD), - software: [opencode, ...REGISTRY_SOFTWARE], + software: [opencode, ...shellSoftware], + }); +} + +async function createOpenCodeOnlyVm(mockUrl: string): Promise { + return AgentOs.create({ + loopbackExemptPorts: [Number(new URL(mockUrl).port)], + software: [opencode], }); } @@ -512,6 +557,165 @@ describe("OpenCode session API integration", () => { } }, 120_000); + test("real OpenCode missing session/load degrades to transcript fallback", async () => { + const { mock, url } = await startLlmock([DEFAULT_TEXT_FIXTURE]); + const traceDir = mkdtempSync(join(tmpdir(), "agent-os-opencode-trace-")); + const tracePath = join(traceDir, "acp.jsonl"); + const previousTracePath = process.env.AGENT_OS_ACP_TRACE_PATH; + process.env.AGENT_OS_ACP_TRACE_PATH = tracePath; + + let vm: AgentOs | undefined; + let liveSessionId: string | undefined; + try { + vm = await createOpenCodeOnlyVm(url); + const homeDir = await createVmOpenCodeHome(vm, url); + const workspaceDir = await createVmWorkspace(vm); + const externalSessionId = "missing-opencode-session"; + const transcriptPath = `/root/.agentos/threads/${externalSessionId}.md`; + + const resumed = await vm.resumeSession(externalSessionId, "opencode", { + cwd: workspaceDir, + env: { + HOME: homeDir, + ANTHROPIC_API_KEY: "mock-key", + }, + transcriptPath, + }); + liveSessionId = resumed.sessionId; + + expect(resumed.mode).toBe("fallback"); + expect(resumed.sessionId).not.toBe(externalSessionId); + + const { response } = await vm.prompt( + liveSessionId, + "Continue from the missing native session.", + ); + expect(response.error).toBeUndefined(); + + expect( + mock + .getRequests() + .some((request) => + hasUserMessageContaining( + request, + "You are continuing an earlier session", + ), + ), + ).toBe(true); + expect( + mock + .getRequests() + .some((request) => hasUserMessageContaining(request, transcriptPath)), + ).toBe(true); + + const traces = readFileSync(tracePath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + const loadTrace = traces.find((trace) => trace.method === "session/load"); + expect(loadTrace?.response).toMatchObject({ + error: { + code: -32603, + data: { + details: "NotFoundError", + }, + }, + }); + expect( + (loadTrace?.response as { error?: { data?: { kind?: unknown } } }).error + ?.data?.kind, + ).toBeUndefined(); + } finally { + if (liveSessionId) { + vm?.closeSession(liveSessionId); + } + if (vm) { + await vm.dispose(); + } + await stopLlmock(mock); + if (previousTracePath === undefined) { + delete process.env.AGENT_OS_ACP_TRACE_PATH; + } else { + process.env.AGENT_OS_ACP_TRACE_PATH = previousTracePath; + } + rmSync(traceDir, { recursive: true, force: true }); + } + }, 120_000); + + test("real OpenCode session/load resumes an existing native session", async () => { + const firstPrompt = "Remember the native resume token: orchid-2718."; + const secondPrompt = "What native resume token did I give you earlier?"; + const { mock, url } = await startLlmock([ + createAnthropicFixture( + { + predicate: (req) => hasUserMessageContaining(req, firstPrompt), + }, + { content: "I will remember orchid-2718." }, + ), + createAnthropicFixture( + { + predicate: (req) => hasUserMessageContaining(req, secondPrompt), + }, + { content: "The token was orchid-2718." }, + ), + ]); + const vm = await createOpenCodeOnlyVm(url); + + let sessionId: string | undefined; + try { + const homeDir = await createVmOpenCodeHome(vm, url); + const workspaceDir = await createVmWorkspace(vm); + sessionId = ( + await vm.createSession("opencode", { + cwd: workspaceDir, + env: { + HOME: homeDir, + ANTHROPIC_API_KEY: "mock-key", + }, + }) + ).sessionId; + + const firstResponse = await vm.prompt(sessionId, firstPrompt); + expect(firstResponse.response.error).toBeUndefined(); + vm.closeSession(sessionId); + + const resumed = await vm.resumeSession(sessionId, "opencode", { + cwd: workspaceDir, + env: { + HOME: homeDir, + ANTHROPIC_API_KEY: "mock-key", + }, + transcriptPath: `/root/.agentos/threads/${sessionId}.md`, + }); + + expect(resumed).toMatchObject({ + mode: "native", + sessionId, + }); + + const secondResponse = await vm.prompt(resumed.sessionId, secondPrompt); + expect(secondResponse.response.error).toBeUndefined(); + + const secondRequest = mock + .getRequests() + .find((request) => hasUserMessageContaining(request, secondPrompt)); + expect(secondRequest).toBeDefined(); + expect(hasUserMessageContaining(secondRequest, firstPrompt)).toBe(true); + expect( + hasUserMessageContaining( + secondRequest, + "You are continuing an earlier session", + ), + ).toBe(false); + } finally { + if (sessionId) { + vm.closeSession(sessionId); + } + await vm.dispose(); + await stopLlmock(mock); + } + }, 120_000); + test("surfaces OpenCode cancelSession() honestly through the Agent OS session API", async () => { const { mock, url } = await startLlmock([ { @@ -573,103 +777,107 @@ describe("OpenCode session API integration", () => { } }, 120_000); - test("supports real OpenCode permission approval through the Agent OS session API", async () => { - const fixtures = [ - createAnthropicFixture( - { - predicate: (req) => !hasAnyToolResult(req), - }, - { - toolCalls: [ - { - name: "bash", - arguments: JSON.stringify({ - command: "printf 'perm-ok' > perm-output.txt", - description: "write perm-ok", - }), - }, - ], - }, - ), - createAnthropicFixture( - { - predicate: (req) => hasAnyToolResult(req), - }, - { content: "perm-output.txt was written after approval." }, - ), - createAnthropicFixture( - { - predicate: (req) => - hasUserMessageContaining( - req, - "Generate a title for this conversation:", - ), - }, - { content: "Permission approval check" }, - ), - ]; - const { mock, url } = await startLlmock(fixtures); - const vm = await createOpenCodeVm(url); - - let sessionId: string | undefined; - const permissionIds: string[] = []; - const permissionParams: Record[] = []; - const permissionResponses: Promise[] = []; - try { - const homeDir = await createVmOpenCodeHome(vm, url, { - permission: { bash: "ask" }, - }); - const workspaceDir = await createVmWorkspace(vm); - sessionId = ( - await vm.createSession("opencode", { - cwd: workspaceDir, - env: { - HOME: homeDir, - ANTHROPIC_API_KEY: "mock-key", + testWithShell( + "supports real OpenCode permission approval through the Agent OS session API", + async () => { + const fixtures = [ + createAnthropicFixture( + { + predicate: (req) => !hasAnyToolResult(req), }, - }) - ).sessionId; + { + toolCalls: [ + { + name: "bash", + arguments: JSON.stringify({ + command: "echo perm-ok > perm-output.txt", + description: "write perm-ok", + }), + }, + ], + }, + ), + createAnthropicFixture( + { + predicate: (req) => hasAnyToolResult(req), + }, + { content: "perm-output.txt was written after approval." }, + ), + createAnthropicFixture( + { + predicate: (req) => + hasUserMessageContaining( + req, + "Generate a title for this conversation:", + ), + }, + { content: "Permission approval check" }, + ), + ]; + const { mock, url } = await startLlmock(fixtures); + const vm = await createOpenCodeVm(url); + + let sessionId: string | undefined; + const permissionIds: string[] = []; + const permissionParams: Record[] = []; + const permissionResponses: Promise[] = []; + try { + const homeDir = await createVmOpenCodeHome(vm, url, { + permission: { bash: "ask" }, + }); + const workspaceDir = await createVmWorkspace(vm); + sessionId = ( + await vm.createSession("opencode", { + cwd: workspaceDir, + env: { + HOME: homeDir, + ANTHROPIC_API_KEY: "mock-key", + }, + }) + ).sessionId; + + vm.onPermissionRequest(sessionId, (request) => { + permissionIds.push(request.permissionId); + permissionParams.push(request.params); + permissionResponses.push( + vm.respondPermission(sessionId!, request.permissionId, "once"), + ); + }); - vm.onPermissionRequest(sessionId, (request) => { - permissionIds.push(request.permissionId); - permissionParams.push(request.params); - permissionResponses.push( - vm.respondPermission(sessionId!, request.permissionId, "once"), + const { response } = await vm.prompt( + sessionId, + "Use bash to write perm-ok into perm-output.txt.", ); - }); - - const { response } = await vm.prompt( - sessionId, - "Use bash to write perm-ok into perm-output.txt.", - ); - expect(response.error).toBeUndefined(); - expect(permissionIds).toHaveLength(1); - expect( - (permissionParams[0]?._acpMethod as string | undefined) ?? "", - ).toBe("session/request_permission"); - expect( - ( - permissionParams[0]?.options as - | Array<{ optionId?: string }> - | undefined - )?.map((option) => option.optionId), - ).toEqual(["once", "always", "reject"]); - await expect(Promise.all(permissionResponses)).resolves.toEqual([ - expect.objectContaining({ - result: expect.objectContaining({ via: "sidecar-request" }), - }), - ]); - expect(await readVmText(vm, `${workspaceDir}/perm-output.txt`)).toBe( - "perm-ok", - ); - } finally { - if (sessionId) { - vm.closeSession(sessionId); + expect(response.error).toBeUndefined(); + expect(permissionIds).toHaveLength(1); + expect( + (permissionParams[0]?._acpMethod as string | undefined) ?? "", + ).toBe("session/request_permission"); + expect( + ( + permissionParams[0]?.options as + | Array<{ optionId?: string }> + | undefined + )?.map((option) => option.optionId), + ).toEqual(["once", "always", "reject"]); + await expect(Promise.all(permissionResponses)).resolves.toEqual([ + expect.objectContaining({ + result: expect.objectContaining({ via: "sidecar-request" }), + }), + ]); + expect(await readVmText(vm, `${workspaceDir}/perm-output.txt`)).toBe( + "perm-ok\n", + ); + } finally { + if (sessionId) { + vm.closeSession(sessionId); + } + await vm.dispose(); + await stopLlmock(mock); } - await vm.dispose(); - await stopLlmock(mock); - } - }, 120_000); + }, + 120_000, + ); test("supports real OpenCode permission rejection through the Agent OS session API", async () => { const toolCall = { diff --git a/packages/core/tests/pi-acp-adapter.test.ts b/packages/core/tests/pi-acp-adapter.test.ts index 56fda1688..ec3037e5c 100644 --- a/packages/core/tests/pi-acp-adapter.test.ts +++ b/packages/core/tests/pi-acp-adapter.test.ts @@ -1,5 +1,5 @@ import { resolve } from "node:path"; -import piCli from "@rivet-dev/agent-os-pi-cli"; +import piCli from "@rivet-dev/agentos-pi-cli"; import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { AgentOs } from "../src/agent-os.js"; diff --git a/packages/core/tests/pi-cli-headless.test.ts b/packages/core/tests/pi-cli-headless.test.ts index 3e3f592f6..c40e4e219 100644 --- a/packages/core/tests/pi-cli-headless.test.ts +++ b/packages/core/tests/pi-cli-headless.test.ts @@ -2,7 +2,7 @@ import { resolve } from "node:path"; import type { Fixture, ToolCall } from "@copilotkit/llmock"; import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; import common from "@agent-os-pkgs/common"; -import piCli from "@rivet-dev/agent-os-pi-cli"; +import piCli from "@rivet-dev/agentos-pi-cli"; import { describe, expect, test } from "vitest"; import { AgentOs } from "../src/agent-os.js"; import { hasRegistryCommands } from "./helpers/registry-commands.js"; diff --git a/packages/core/tests/pi-extensions.test.ts b/packages/core/tests/pi-extensions.test.ts index bbfd57718..11548ed8c 100644 --- a/packages/core/tests/pi-extensions.test.ts +++ b/packages/core/tests/pi-extensions.test.ts @@ -1,5 +1,5 @@ import { resolve } from "node:path"; -import pi from "@rivet-dev/agent-os-pi"; +import pi from "@rivet-dev/agentos-pi"; import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; import { describe, expect, test } from "vitest"; import { AgentOs } from "../src/agent-os.js"; diff --git a/packages/core/tests/pi-headless.test.ts b/packages/core/tests/pi-headless.test.ts index 31fbcc9db..2ff7bddcc 100644 --- a/packages/core/tests/pi-headless.test.ts +++ b/packages/core/tests/pi-headless.test.ts @@ -2,7 +2,7 @@ import { resolve } from "node:path"; import type { Fixture, ToolCall } from "@copilotkit/llmock"; import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; import common from "@agent-os-pkgs/common"; -import pi from "@rivet-dev/agent-os-pi"; +import pi from "@rivet-dev/agentos-pi"; import { describe, expect, test } from "vitest"; import type { AgentCapabilities, AgentInfo } from "../src/agent-os.js"; import { AgentOs } from "../src/agent-os.js"; diff --git a/packages/core/tests/pi-sdk-adapter.test.ts b/packages/core/tests/pi-sdk-adapter.test.ts index abd477521..78afe5e42 100644 --- a/packages/core/tests/pi-sdk-adapter.test.ts +++ b/packages/core/tests/pi-sdk-adapter.test.ts @@ -1,5 +1,5 @@ import { resolve } from "node:path"; -import pi from "@rivet-dev/agent-os-pi"; +import pi from "@rivet-dev/agentos-pi"; import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { AgentOs } from "../src/agent-os.js"; @@ -26,7 +26,7 @@ describe("pi-sdk software projection", () => { test("projects the SDK adapter package and PI agent package into the VM", async () => { const script = ` const fs = require("fs"); -console.log("adapter:" + fs.existsSync("/root/node_modules/@rivet-dev/agent-os-pi/package.json")); +console.log("adapter:" + fs.existsSync("/root/node_modules/@rivet-dev/agentos-pi/package.json")); console.log("agent:" + fs.existsSync("/root/node_modules/@mariozechner/pi-coding-agent/package.json")); `; await vm.writeFile("/tmp/pi-sdk-projection.mjs", script); diff --git a/packages/core/tests/pi-sdk-boot-probe.test.ts b/packages/core/tests/pi-sdk-boot-probe.test.ts index 7d4d546f6..8f5998d52 100644 --- a/packages/core/tests/pi-sdk-boot-probe.test.ts +++ b/packages/core/tests/pi-sdk-boot-probe.test.ts @@ -1,5 +1,5 @@ import { resolve } from "node:path"; -import pi from "@rivet-dev/agent-os-pi"; +import pi from "@rivet-dev/agentos-pi"; import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; import { describe, expect, test } from "vitest"; import { AgentOs } from "../src/agent-os.js"; diff --git a/packages/core/tests/pi-tool-llmock.test.ts b/packages/core/tests/pi-tool-llmock.test.ts index 54a5c6b02..a916c42c3 100644 --- a/packages/core/tests/pi-tool-llmock.test.ts +++ b/packages/core/tests/pi-tool-llmock.test.ts @@ -2,7 +2,7 @@ import { resolve } from "node:path"; import type { Fixture, ToolCall } from "@copilotkit/llmock"; import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; import common from "@agent-os-pkgs/common"; -import pi from "@rivet-dev/agent-os-pi"; +import pi from "@rivet-dev/agentos-pi"; import { describe, expect, test } from "vitest"; import { AgentOs } from "../src/agent-os.js"; import { hasRegistryCommands } from "./helpers/registry-commands.js"; diff --git a/packages/core/tests/pi-vanilla-bash.test.ts b/packages/core/tests/pi-vanilla-bash.test.ts index 41156c3b7..e58dbf3e6 100644 --- a/packages/core/tests/pi-vanilla-bash.test.ts +++ b/packages/core/tests/pi-vanilla-bash.test.ts @@ -2,7 +2,7 @@ import { resolve } from "node:path"; import type { Fixture, ToolCall } from "@copilotkit/llmock"; import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; import common from "@agent-os-pkgs/common"; -import pi from "@rivet-dev/agent-os-pi"; +import pi from "@rivet-dev/agentos-pi"; import { describe, expect, test } from "vitest"; import { AgentOs } from "../src/agent-os.js"; import { diff --git a/packages/core/tests/public-api-exports.test.ts b/packages/core/tests/public-api-exports.test.ts index e2c4eed93..c86c096fd 100644 --- a/packages/core/tests/public-api-exports.test.ts +++ b/packages/core/tests/public-api-exports.test.ts @@ -3,6 +3,7 @@ import { InvalidScheduleError, PastScheduleError, isAcpTimeoutErrorData, + isUnknownSessionErrorData, nodeModulesMount, type AcpTimeoutErrorData, type AgentOsLimits, @@ -17,8 +18,11 @@ import { type OpenShellOptions, type PromptCapabilities, type PromptResult, + type ResumeSessionOptions, + type ResumeSessionResult, type StdioChannel, type TimingMitigation, + type UnknownSessionErrorData, } from "../src/index.js"; describe("root public API exports", () => { @@ -36,8 +40,11 @@ describe("root public API exports", () => { void (null as OpenShellOptions | null); void (null as PromptCapabilities | null); void (null as PromptResult | null); + void (null as ResumeSessionOptions | null); + void (null as ResumeSessionResult | null); void (null as StdioChannel | null); void (null as TimingMitigation | null); + void (null as UnknownSessionErrorData | null); expect(true).toBe(true); }); @@ -70,6 +77,22 @@ describe("root public API exports", () => { expect(isAcpTimeoutErrorData({ kind: "other" })).toBe(false); }); + test("re-exports unknown-session discriminator helper from the root entrypoint", () => { + const unknownSession: UnknownSessionErrorData = { + kind: "unknown_session", + sessionId: "sess-123", + }; + + expect(isUnknownSessionErrorData(unknownSession)).toBe(true); + // `sessionId` is optional — the discriminator is `kind` alone, matching the + // sidecar's normalized `{kind}`-only shape. + expect(isUnknownSessionErrorData({ kind: "unknown_session" })).toBe(true); + expect( + isUnknownSessionErrorData({ kind: "unknown_session", sessionId: 5 }), + ).toBe(false); + expect(isUnknownSessionErrorData({ kind: "other" })).toBe(false); + }); + test("re-exports cron scheduling errors from the root entrypoint", () => { expect(new InvalidScheduleError("tomorrow").name).toBe( "InvalidScheduleError", diff --git a/packages/core/tests/sandbox-integration.test.ts b/packages/core/tests/sandbox-integration.test.ts index b8556dbcb..787faff79 100644 --- a/packages/core/tests/sandbox-integration.test.ts +++ b/packages/core/tests/sandbox-integration.test.ts @@ -2,7 +2,7 @@ import common from "@agent-os-pkgs/common"; import { createSandboxFs, createSandboxToolkit, -} from "@rivet-dev/agent-os-sandbox"; +} from "@rivet-dev/agentos-sandbox"; import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest"; import { AgentOs } from "../src/index.js"; import type { MockSandboxAgentHandle } from "../src/test/sandbox-agent.js"; diff --git a/packages/core/tests/session-cleanup.test.ts b/packages/core/tests/session-cleanup.test.ts index 8602f3cff..f10e7e9bf 100644 --- a/packages/core/tests/session-cleanup.test.ts +++ b/packages/core/tests/session-cleanup.test.ts @@ -7,10 +7,10 @@ import { import { readlink, readdir } from "node:fs/promises"; import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; import { resolve } from "node:path"; -import claude from "@rivet-dev/agent-os-claude"; -import opencode from "@rivet-dev/agent-os-opencode"; -import pi from "@rivet-dev/agent-os-pi"; -import piCli from "@rivet-dev/agent-os-pi-cli"; +import claude from "@rivet-dev/agentos-claude"; +import opencode from "@rivet-dev/agentos-opencode"; +import pi from "@rivet-dev/agentos-pi"; +import piCli from "@rivet-dev/agentos-pi-cli"; import { describe, expect, test } from "vitest"; import { AgentOs } from "../src/agent-os.js"; import { diff --git a/packages/core/tests/session-resume.test.ts b/packages/core/tests/session-resume.test.ts new file mode 100644 index 000000000..898e76eda --- /dev/null +++ b/packages/core/tests/session-resume.test.ts @@ -0,0 +1,330 @@ +import { resolve } from "node:path"; +import { describe, expect, test } from "vitest"; +import { AgentOs } from "../src/agent-os.js"; +import type { SoftwareInput } from "../src/packages.js"; +import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; + +// L2 (agent-os side): exercise the sidecar resume orchestration state machine +// end-to-end against the REAL agentos-sidecar with a MOCK ACP adapter (no LLM). +// +// Spec: .agent/specs/session-resume.md §6 +// - Tier 1 (native): agent advertises `loadSession` -> sidecar runs +// `initialize` then `session/load`; on success mode "native", id preserved. +// - unknown_session fallthrough: `session/load` returns the OpenCode-shape error +// ({code:-32603, data:{details:"NotFoundError"}}) or data.kind=="unknown_session" +// -> fall through to Tier 2. +// - Tier 2 (fallback): `session/new`, mode "fallback", new live id, and a +// continuation preamble prepended to the next `session/prompt`. + +const MODULE_ACCESS_CWD = resolve(import.meta.dirname, ".."); +const MOCK_ADAPTER_PATH = "/tmp/mock-session-resume-adapter.mjs"; + +const SYNTHETIC_AGENT = { + name: "session-resume-mock", + type: "agent" as const, + packageDir: MODULE_ACCESS_CWD, + requires: [], + agent: { + id: "synthetic", + acpAdapter: "session-resume-mock-adapter", + agentPackage: "session-resume-mock-agent", + }, +}; + +// Single configurable mock ACP adapter. Its behavior is selected at launch time +// via the `MOCK_RESUME_SCENARIO` env var, which the host forwards through +// `resumeSession(..., { env })` (the sidecar passes `env` straight to the +// adapter process). Scenarios: +// - "native": advertise loadSession; session/load -> ok +// - "resume-only": advertise resume; session/resume -> ok +// - "fallthrough": advertise loadSession; session/load -> NotFoundError error +// - "no-loadsession": do NOT advertise loadSession (straight to fallback) +// +// On `session/prompt` the adapter echoes the exact prompt blocks it received back +// as an `agent_message_chunk` text update so the test can assert on the +// continuation preamble being prepended. +const MOCK_ACP_ADAPTER = String.raw` +let buffer = ""; + +const SCENARIO = process.env.MOCK_RESUME_SCENARIO || "native"; +const NATIVE_SESSION_ID = "mock-native-session"; +const FALLBACK_SESSION_ID = "mock-fallback-session"; + +const modes = { + currentModeId: "default", + availableModes: [ + { id: "default", label: "Default" }, + { id: "plan", label: "Plan" }, + ], +}; + +function write(obj) { + process.stdout.write(JSON.stringify(obj) + "\n"); +} + +function writeResponse(id, result) { + write({ jsonrpc: "2.0", id, result }); +} + +function writeError(id, code, message, data) { + write({ + jsonrpc: "2.0", + id, + error: { code, message, ...(data ? { data } : {}) }, + }); +} + +function writeNotification(method, params) { + write({ jsonrpc: "2.0", method, params }); +} + +process.stdin.resume(); +process.stdin.on("data", (chunk) => { + const text = + chunk instanceof Uint8Array ? new TextDecoder().decode(chunk) : String(chunk); + buffer += text; + + while (true) { + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex === -1) break; + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + if (!line.trim()) continue; + + const msg = JSON.parse(line); + if (msg.id === undefined) continue; + + switch (msg.method) { + case "initialize": { + const agentCapabilities = { + promptCapabilities: {}, + }; + if (SCENARIO === "resume-only") { + agentCapabilities.resume = true; + } else if (SCENARIO !== "no-loadsession") { + agentCapabilities.loadSession = true; + } + writeResponse(msg.id, { + protocolVersion: 1, + agentInfo: { name: "mock-resume-agent", version: "1.0.0" }, + agentCapabilities, + modes, + }); + break; + } + case "session/load": { + if (SCENARIO === "native") { + // Native resume reuses the requested session id (the sidecar keeps + // request.session_id for native loads); just acknowledge success. + writeResponse(msg.id, { modes }); + } else if (SCENARIO === "fallthrough") { + // OpenCode-shape "no such session" sentinel: -32603 + NotFoundError. + writeError(msg.id, -32603, "Internal error", { + details: "NotFoundError", + }); + } else { + writeError(msg.id, -32601, "Method not found", { + method: "session/load", + }); + } + break; + } + case "session/resume": { + if (SCENARIO === "resume-only") { + writeResponse(msg.id, { modes }); + } else { + writeError(msg.id, -32601, "Method not found", { + method: "session/resume", + }); + } + break; + } + case "session/new": { + writeResponse(msg.id, { sessionId: FALLBACK_SESSION_ID, modes }); + break; + } + case "session/prompt": { + // Echo the exact prompt blocks the adapter received as a message chunk so + // the host (and test) can observe any sidecar-prepended preamble. + const blocks = Array.isArray(msg.params?.prompt) ? msg.params.prompt : []; + writeNotification("session/update", { + sessionId: msg.params?.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: JSON.stringify(blocks) }, + }, + }); + writeResponse(msg.id, { stopReason: "end_turn" }); + break; + } + case "session/cancel": + writeResponse(msg.id, {}); + break; + default: + writeError(msg.id, -32601, "Method not found", { method: msg.method }); + break; + } + } +}); +`; + +function useMockAdapterBin(vm: AgentOs, scriptPath: string): () => void { + const withPrivateResolver = vm as AgentOs & { + _resolveAdapterBin: (pkg: string) => string; + }; + const originalResolve = withPrivateResolver._resolveAdapterBin; + withPrivateResolver._resolveAdapterBin = () => scriptPath; + return () => { + withPrivateResolver._resolveAdapterBin = originalResolve; + }; +} + +async function createMockAgentVm(software: SoftwareInput[]): Promise { + return AgentOs.create({ + mounts: moduleAccessMounts(MODULE_ACCESS_CWD), + software, + }); +} + +describe("sidecar resume orchestration (mock ACP adapter)", () => { + test("Tier 1 native: loadSession advertised + session/load ok -> mode native, id preserved", async () => { + const vm = await createMockAgentVm([SYNTHETIC_AGENT]); + const restore = useMockAdapterBin(vm, MOCK_ADAPTER_PATH); + let liveSessionId: string | undefined; + + try { + await vm.writeFile(MOCK_ADAPTER_PATH, MOCK_ACP_ADAPTER); + + const externalSessionId = "external-session-native"; + const result = await vm.resumeSession(externalSessionId, "synthetic", { + env: { MOCK_RESUME_SCENARIO: "native" }, + }); + liveSessionId = result.sessionId; + + expect(result.mode).toBe("native"); + // Native load reuses the requested id: external == live. + expect(result.sessionId).toBe(externalSessionId); + } finally { + restore(); + if (liveSessionId) { + vm.closeSession(liveSessionId); + } + await vm.dispose(); + } + }); + + test("Tier 1 native: resume advertised + session/resume ok -> mode native, id preserved", async () => { + const vm = await createMockAgentVm([SYNTHETIC_AGENT]); + const restore = useMockAdapterBin(vm, MOCK_ADAPTER_PATH); + let liveSessionId: string | undefined; + + try { + await vm.writeFile(MOCK_ADAPTER_PATH, MOCK_ACP_ADAPTER); + + const externalSessionId = "external-session-resume-only"; + const result = await vm.resumeSession(externalSessionId, "synthetic", { + env: { MOCK_RESUME_SCENARIO: "resume-only" }, + }); + liveSessionId = result.sessionId; + + expect(result.mode).toBe("native"); + expect(result.sessionId).toBe(externalSessionId); + } finally { + restore(); + if (liveSessionId) { + vm.closeSession(liveSessionId); + } + await vm.dispose(); + } + }); + + test("unknown_session fallthrough: session/load NotFoundError -> mode fallback, new live id, preamble prepended", async () => { + const vm = await createMockAgentVm([SYNTHETIC_AGENT]); + const restore = useMockAdapterBin(vm, MOCK_ADAPTER_PATH); + let liveSessionId: string | undefined; + + try { + await vm.writeFile(MOCK_ADAPTER_PATH, MOCK_ACP_ADAPTER); + + const externalSessionId = "external-session-fallthrough"; + const transcriptPath = "/root/.agentos/threads/external-session-fallthrough.md"; + const result = await vm.resumeSession(externalSessionId, "synthetic", { + transcriptPath, + env: { MOCK_RESUME_SCENARIO: "fallthrough" }, + }); + liveSessionId = result.sessionId; + + // session/load returned the unknown_session sentinel, so the sidecar fell + // through to Tier 2: a fresh session/new id, mode "fallback". + expect(result.mode).toBe("fallback"); + expect(result.sessionId).toBe("mock-fallback-session"); + expect(result.sessionId).not.toBe(externalSessionId); + + // The next session/prompt must arrive at the adapter with the continuation + // preamble prepended as a leading text block. The adapter echoes the exact + // prompt blocks it received back as the agent message text. + const { text } = await vm.prompt(liveSessionId, "what did we discuss?"); + const receivedBlocks = JSON.parse(text) as Array<{ + type: string; + text: string; + }>; + + expect(receivedBlocks.length).toBe(2); + // Leading block is the injected preamble pointing at the transcript path. + expect(receivedBlocks[0].type).toBe("text"); + expect(receivedBlocks[0].text).toContain( + "You are continuing an earlier session", + ); + expect(receivedBlocks[0].text).toContain(transcriptPath); + // Original user text follows. + expect(receivedBlocks[1].text).toBe("what did we discuss?"); + + // Preamble is single-turn: a second prompt has no leading preamble block. + const second = await vm.prompt(liveSessionId, "second turn"); + const secondBlocks = JSON.parse(second.text) as Array<{ + type: string; + text: string; + }>; + expect(secondBlocks.length).toBe(1); + expect(secondBlocks[0].text).toBe("second turn"); + } finally { + restore(); + if (liveSessionId) { + vm.closeSession(liveSessionId); + } + await vm.dispose(); + } + }); + + test("no loadSession capability -> straight to fallback (session/new)", async () => { + const vm = await createMockAgentVm([SYNTHETIC_AGENT]); + const restore = useMockAdapterBin(vm, MOCK_ADAPTER_PATH); + let liveSessionId: string | undefined; + + try { + await vm.writeFile(MOCK_ADAPTER_PATH, MOCK_ACP_ADAPTER); + + const externalSessionId = "external-session-nocap"; + const result = await vm.resumeSession(externalSessionId, "synthetic", { + env: { MOCK_RESUME_SCENARIO: "no-loadsession" }, + }); + liveSessionId = result.sessionId; + + // No loadSession capability -> Tier 1 is skipped entirely; fallback runs. + expect(result.mode).toBe("fallback"); + expect(result.sessionId).toBe("mock-fallback-session"); + + // No transcriptPath was supplied, so no preamble is armed. + const { text } = await vm.prompt(liveSessionId, "hello"); + const blocks = JSON.parse(text) as Array<{ type: string; text: string }>; + expect(blocks.length).toBe(1); + expect(blocks[0].text).toBe("hello"); + } finally { + restore(); + if (liveSessionId) { + vm.closeSession(liveSessionId); + } + await vm.dispose(); + } + }); +}); diff --git a/packages/core/tests/software-projection.test.ts b/packages/core/tests/software-projection.test.ts index f0a1eaf07..d506286b3 100644 --- a/packages/core/tests/software-projection.test.ts +++ b/packages/core/tests/software-projection.test.ts @@ -1,5 +1,5 @@ import common, { coreutils } from "@agent-os-pkgs/common"; -import pi from "@rivet-dev/agent-os-pi"; +import pi from "@rivet-dev/agentos-pi"; import { afterEach, describe, expect, test } from "vitest"; import { AgentOs } from "../src/agent-os.js"; @@ -43,8 +43,8 @@ describe("software projection on the sidecar path", () => { "const fs = require('node:fs');", "console.log('node_modules', fs.existsSync('/root/node_modules'));", "console.log('scope', fs.readdirSync('/root/node_modules/@rivet-dev').includes('agent-os-pi'));", - "console.log('adapter', fs.existsSync('/root/node_modules/@rivet-dev/agent-os-pi/package.json'));", - "console.log('adapterResolved', Boolean(require.resolve('@rivet-dev/agent-os-pi')));", + "console.log('adapter', fs.existsSync('/root/node_modules/@rivet-dev/agentos-pi/package.json'));", + "console.log('adapterResolved', Boolean(require.resolve('@rivet-dev/agentos-pi')));", "console.log('agent', fs.existsSync('/root/node_modules/@mariozechner/pi-coding-agent/package.json'));", ].join(" "), ], @@ -81,7 +81,7 @@ describe("software projection on the sidecar path", () => { [ "const fs = require('node:fs');", "try {", - " fs.appendFileSync('/root/node_modules/@rivet-dev/agent-os-pi/package.json', '\\nblocked');", + " fs.appendFileSync('/root/node_modules/@rivet-dev/agentos-pi/package.json', '\\nblocked');", " console.log('write:unexpected-success');", "} catch (error) {", " console.log('writeError', error && error.code);", diff --git a/packages/dev-shell/package.json b/packages/dev-shell/package.json index 7bdf83c13..5f2c06a36 100644 --- a/packages/dev-shell/package.json +++ b/packages/dev-shell/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agent-os-dev-shell", + "name": "@rivet-dev/agentos-dev-shell", "private": true, "type": "module", "bin": { @@ -12,7 +12,7 @@ "test": "NODE_OPTIONS=--max-old-space-size=256 pnpm exec vitest run --fileParallelism=false --reporter=verbose" }, "dependencies": { - "@rivet-dev/agent-os-core": "workspace:*", + "@rivet-dev/agentos-core": "workspace:*", "pino": "^10.3.1" }, "devDependencies": { diff --git a/packages/dev-shell/src/kernel.ts b/packages/dev-shell/src/kernel.ts index 505a0c125..9c094dd26 100644 --- a/packages/dev-shell/src/kernel.ts +++ b/packages/dev-shell/src/kernel.ts @@ -10,8 +10,8 @@ import type { ManagedProcess, ShellHandle, VirtualFileSystem, -} from "@rivet-dev/agent-os-core/internal/runtime-compat"; -import * as runtimeCompat from "@rivet-dev/agent-os-core/internal/runtime-compat"; +} from "@rivet-dev/agentos-core/internal/runtime-compat"; +import * as runtimeCompat from "@rivet-dev/agentos-core/internal/runtime-compat"; import type { DebugLogger } from "./debug-logger.js"; import { createDebugLogger, createNoopLogger } from "./debug-logger.js"; import type { WorkspacePaths } from "./shared.js"; diff --git a/packages/dev-shell/test/dev-shell-cli.integration.test.ts b/packages/dev-shell/test/dev-shell-cli.integration.test.ts index 6cbff4634..b795224e4 100644 --- a/packages/dev-shell/test/dev-shell-cli.integration.test.ts +++ b/packages/dev-shell/test/dev-shell-cli.integration.test.ts @@ -17,7 +17,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const workspaceRoot = path.resolve(__dirname, "..", "..", ".."); const justfilePath = path.join(workspaceRoot, "justfile"); const fallbackRecipe = - 'pnpm --filter @rivet-dev/agent-os-dev-shell dev-shell -- "$@"'; + 'pnpm --filter @rivet-dev/agentos-dev-shell dev-shell -- "$@"'; resolveWorkspacePaths(__dirname); function resolveExecutable(binaryName: string): string | undefined { @@ -77,7 +77,7 @@ function createDevShellWrapperProcess(args: string[]) { "pnpm", [ "--filter", - "@rivet-dev/agent-os-dev-shell", + "@rivet-dev/agentos-dev-shell", "dev-shell", "--", ...forwardedArgs, @@ -97,9 +97,9 @@ function stripJustPreamble(output: string): string { (line) => line.length > 0 && !line.startsWith( - "pnpm --filter @rivet-dev/agent-os-dev-shell dev-shell --", + "pnpm --filter @rivet-dev/agentos-dev-shell dev-shell --", ) && - !line.startsWith("> @rivet-dev/agent-os-dev-shell@ dev-shell ") && + !line.startsWith("> @rivet-dev/agentos-dev-shell@ dev-shell ") && !line.startsWith("> pnpm exec tsx src/shell.ts ") && !line.startsWith("> tsx src/shell.ts ") && !line.startsWith( diff --git a/packages/dev-shell/test/terminal-harness.ts b/packages/dev-shell/test/terminal-harness.ts index 9943a69f3..745e8815e 100644 --- a/packages/dev-shell/test/terminal-harness.ts +++ b/packages/dev-shell/test/terminal-harness.ts @@ -1,4 +1,4 @@ -import type { Kernel } from "@rivet-dev/agent-os-core/test/runtime"; +import type { Kernel } from "@rivet-dev/agentos-core/test/runtime"; import { Terminal } from "@xterm/headless"; type ShellHandle = ReturnType; diff --git a/packages/playground/frontend/app.ts b/packages/playground/frontend/app.ts index be03b0eb9..ddfa602dc 100644 --- a/packages/playground/frontend/app.ts +++ b/packages/playground/frontend/app.ts @@ -5,7 +5,7 @@ import { type NodeRuntimeDriver, type StdioChannel, type StdioEvent, -} from "@rivet-dev/agent-os-browser"; +} from "@rivet-dev/agentos-browser"; type Language = "nodejs"; type TypeScriptApi = typeof import("typescript"); @@ -562,7 +562,7 @@ function ensureWorkspacePanels(): void {
SDK Usage
-
import { allowAll, createBrowserDriver, createBrowserRuntimeDriverFactory } from "@rivet-dev/agent-os-browser"; +
import { allowAll, createBrowserDriver, createBrowserRuntimeDriverFactory } from "@rivet-dev/agentos-browser"; // Create a browser-backed runtime const system = await createBrowserDriver({ diff --git a/packages/playground/frontend/runtime-harness.ts b/packages/playground/frontend/runtime-harness.ts index a94e16369..df03b3216 100644 --- a/packages/playground/frontend/runtime-harness.ts +++ b/packages/playground/frontend/runtime-harness.ts @@ -6,7 +6,7 @@ import { type ExecResult, type NodeRuntimeDriver, type TimingMitigation, -} from "@rivet-dev/agent-os-browser"; +} from "@rivet-dev/agentos-browser"; type HarnessStdioEvent = { channel: "stdout" | "stderr"; diff --git a/packages/playground/package.json b/packages/playground/package.json index 003d926d0..825093837 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agent-os-playground", + "name": "@rivet-dev/agentos-playground", "private": true, "type": "module", "scripts": { @@ -12,8 +12,8 @@ "test": "pnpm exec vitest run ./tests/" }, "dependencies": { - "@rivet-dev/agent-os-core": "workspace:*", - "@rivet-dev/agent-os-browser": "workspace:*" + "@rivet-dev/agentos-core": "workspace:*", + "@rivet-dev/agentos-browser": "workspace:*" }, "devDependencies": { "@types/node": "^22.19.15", diff --git a/packages/playground/scripts/build-worker.ts b/packages/playground/scripts/build-worker.ts index 9b7fd7ac5..a7fc442cb 100644 --- a/packages/playground/scripts/build-worker.ts +++ b/packages/playground/scripts/build-worker.ts @@ -46,7 +46,7 @@ const nodeBuiltinAlias = { } as const; const playgroundAlias = { - "@rivet-dev/agent-os-browser": resolve( + "@rivet-dev/agentos-browser": resolve( playgroundDir, "../browser/src/index.ts", ), diff --git a/packages/playground/tsconfig.json b/packages/playground/tsconfig.json index 9dc08e796..29be6536b 100644 --- a/packages/playground/tsconfig.json +++ b/packages/playground/tsconfig.json @@ -6,7 +6,7 @@ "moduleResolution": "NodeNext", "lib": ["ES2022", "DOM", "DOM.Iterable"], "paths": { - "@rivet-dev/agent-os-browser": ["../browser/dist/index.d.ts"] + "@rivet-dev/agentos-browser": ["../browser/dist/index.d.ts"] }, "strict": true, "esModuleInterop": true, diff --git a/packages/posix/package.json b/packages/posix/package.json index 873d1b8a0..7989179c4 100644 --- a/packages/posix/package.json +++ b/packages/posix/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agent-os-posix", + "name": "@rivet-dev/agentos-posix", "version": "0.2.0-rc.3", "description": "POSIX runtime driver — WASI-based Unix userland for Agent OS", "type": "module", diff --git a/packages/python/package.json b/packages/python/package.json index 347a04419..49af7b622 100644 --- a/packages/python/package.json +++ b/packages/python/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agent-os-python", + "name": "@rivet-dev/agentos-python", "version": "0.2.0-rc.3", "type": "module", "license": "Apache-2.0", diff --git a/packages/secure-exec-typescript/tsconfig.json b/packages/secure-exec-typescript/tsconfig.json index 0a034e7e5..2a45691c2 100644 --- a/packages/secure-exec-typescript/tsconfig.json +++ b/packages/secure-exec-typescript/tsconfig.json @@ -11,7 +11,7 @@ "paths": { "@secure-exec/typescript": ["./src/index.ts"], "secure-exec": ["../secure-exec/dist/index.d.ts"], - "@rivet-dev/agent-os-core/internal/runtime-compat": [ + "@rivet-dev/agentos-core/internal/runtime-compat": [ "../core/dist/runtime-compat.d.ts" ] } diff --git a/packages/secure-exec-typescript/vitest.config.ts b/packages/secure-exec-typescript/vitest.config.ts index beb9cf872..7d7ee1557 100644 --- a/packages/secure-exec-typescript/vitest.config.ts +++ b/packages/secure-exec-typescript/vitest.config.ts @@ -4,7 +4,7 @@ export default { resolve: { alias: [ { - find: "@rivet-dev/agent-os-core/internal/runtime-compat", + find: "@rivet-dev/agentos-core/internal/runtime-compat", replacement: resolve(__dirname, "../core/dist/runtime-compat.js"), }, { diff --git a/packages/secure-exec/package.json b/packages/secure-exec/package.json index cab9ed75a..29a6b2eaf 100644 --- a/packages/secure-exec/package.json +++ b/packages/secure-exec/package.json @@ -25,7 +25,7 @@ "test:smoke": "tsc --noEmit -p tests/tsconfig.quickstart.json" }, "dependencies": { - "@rivet-dev/agent-os-core": "workspace:*" + "@rivet-dev/agentos-core": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.2", diff --git a/packages/secure-exec/src/index.ts b/packages/secure-exec/src/index.ts index e2253e2d7..b60f095ad 100644 --- a/packages/secure-exec/src/index.ts +++ b/packages/secure-exec/src/index.ts @@ -14,7 +14,6 @@ export type { ExecResult, Kernel, KernelInterface, - ModuleAccessOptions, NetworkAdapter, NodeRuntimeDriver, NodeRuntimeDriverFactory, @@ -31,7 +30,7 @@ export type { StdioHook, TimingMitigation, VirtualFileSystem, -} from "@rivet-dev/agent-os-core/internal/runtime-compat"; +} from "@rivet-dev/agentos-core/internal/runtime-compat"; export { allowAll, allowAllChildProcess, @@ -54,4 +53,4 @@ export { readDirWithTypes, rename, stat, -} from "@rivet-dev/agent-os-core/internal/runtime-compat"; +} from "@rivet-dev/agentos-core/internal/runtime-compat"; diff --git a/packages/secure-exec/tests/public-api.test.ts b/packages/secure-exec/tests/public-api.test.ts index 119a63ce2..15ad9ee86 100644 --- a/packages/secure-exec/tests/public-api.test.ts +++ b/packages/secure-exec/tests/public-api.test.ts @@ -45,7 +45,7 @@ import { type StdioHook, type TimingMitigation, type VirtualFileSystem, -} from "@rivet-dev/agent-os-core/internal/runtime-compat"; +} from "@rivet-dev/agentos-core/internal/runtime-compat"; import * as secureExec from "secure-exec"; import { describe, expect, it } from "vitest"; diff --git a/packages/secure-exec/tsconfig.json b/packages/secure-exec/tsconfig.json index dc50ff753..bb2036bc5 100644 --- a/packages/secure-exec/tsconfig.json +++ b/packages/secure-exec/tsconfig.json @@ -9,7 +9,7 @@ "outDir": "./dist", "rootDir": "./src", "paths": { - "@rivet-dev/agent-os-core/internal/runtime-compat": [ + "@rivet-dev/agentos-core/internal/runtime-compat": [ "../core/dist/runtime-compat.d.ts" ] } diff --git a/packages/shell/package.json b/packages/shell/package.json index f1a9c5356..3f1cc3861 100644 --- a/packages/shell/package.json +++ b/packages/shell/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agent-os-shell", + "name": "@rivet-dev/agentos-shell", "private": true, "type": "module", "bin": { @@ -12,7 +12,7 @@ "test": "pnpm build && vitest run --fileParallelism=false" }, "dependencies": { - "@rivet-dev/agent-os-core": "workspace:*", + "@rivet-dev/agentos-core": "workspace:*", "@agent-os-pkgs/common": "catalog:", "@agent-os-pkgs/jq": "catalog:", "@agent-os-pkgs/ripgrep": "catalog:", diff --git a/packages/shell/src/main.ts b/packages/shell/src/main.ts index f45bd275e..2d07e3fcc 100644 --- a/packages/shell/src/main.ts +++ b/packages/shell/src/main.ts @@ -5,8 +5,8 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import codex from "@agent-os-pkgs/codex"; import common from "@agent-os-pkgs/common"; -import { AgentOs } from "@rivet-dev/agent-os-core"; -import type { SoftwareInput } from "@rivet-dev/agent-os-core"; +import { AgentOs } from "@rivet-dev/agentos-core"; +import type { SoftwareInput } from "@rivet-dev/agentos-core"; import fd from "@agent-os-pkgs/fd"; import file from "@agent-os-pkgs/file"; import jq from "@agent-os-pkgs/jq"; diff --git a/packages/sidecar-binary/index.d.ts b/packages/sidecar-binary/index.d.ts index e665364fb..ab2474c70 100644 --- a/packages/sidecar-binary/index.d.ts +++ b/packages/sidecar-binary/index.d.ts @@ -1,11 +1,11 @@ /** - * Resolve the absolute path to the prebuilt `agent-os-sidecar` binary for the + * Resolve the absolute path to the prebuilt `agentos-sidecar` binary for the * current platform. * * Resolution priority: - * 1. `AGENT_OS_SIDECAR_BIN` env var (absolute path override). - * 2. A `agent-os-sidecar` binary placed next to this package (dev builds). - * 3. The platform-specific `@rivet-dev/agent-os-sidecar-` package. + * 1. `AGENTOS_SIDECAR_BIN` env var (absolute path override). + * 2. A `agentos-sidecar` binary placed next to this package (dev builds). + * 3. The platform-specific `@rivet-dev/agentos-sidecar-` package. * * @throws if the platform is unsupported or no binary can be found. */ diff --git a/packages/sidecar-binary/index.js b/packages/sidecar-binary/index.js index 341290b02..fe0540f33 100644 --- a/packages/sidecar-binary/index.js +++ b/packages/sidecar-binary/index.js @@ -1,19 +1,19 @@ "use strict"; -// Platform-specific resolver for the prebuilt `agent-os-sidecar` binary. The -// binary itself ships inside one of the `@rivet-dev/agent-os-sidecar-` +// Platform-specific resolver for the prebuilt `agentos-sidecar` binary. The +// binary itself ships inside one of the `@rivet-dev/agentos-sidecar-` // packages, declared as optionalDependencies so npm only installs the one // matching the current `os`/`cpu`/`libc` at install time. // // Resolution priority: -// 1. `AGENT_OS_SIDECAR_BIN` env var (absolute path override). -// 2. A `agent-os-sidecar` binary placed next to this package (dev builds). -// 3. The platform-specific `@rivet-dev/agent-os-sidecar-` package. +// 1. `AGENTOS_SIDECAR_BIN` env var (absolute path override). +// 2. A `agentos-sidecar` binary placed next to this package (dev builds). +// 3. The platform-specific `@rivet-dev/agentos-sidecar-` package. const { existsSync } = require("node:fs"); const { join, dirname } = require("node:path"); -const BINARY_NAME = "agent-os-sidecar"; +const BINARY_NAME = "agentos-sidecar"; // No runtime chmod: the platform packages are published with `npm publish`, // which preserves the binary's 0755 executable bit (pnpm publish would strip @@ -24,8 +24,8 @@ function getPlatformPackageName() { const { platform, arch } = process; switch (platform) { case "linux": - if (arch === "x64") return "@rivet-dev/agent-os-sidecar-linux-x64-gnu"; - if (arch === "arm64") return "@rivet-dev/agent-os-sidecar-linux-arm64-gnu"; + if (arch === "x64") return "@rivet-dev/agentos-sidecar-linux-x64-gnu"; + if (arch === "arm64") return "@rivet-dev/agentos-sidecar-linux-arm64-gnu"; break; default: break; @@ -34,11 +34,11 @@ function getPlatformPackageName() { } function getSidecarPath() { - const override = process.env.AGENT_OS_SIDECAR_BIN; + const override = process.env.AGENTOS_SIDECAR_BIN; if (override) { if (!existsSync(override)) { throw new Error( - `AGENT_OS_SIDECAR_BIN is set to ${override} but the file does not exist`, + `AGENTOS_SIDECAR_BIN is set to ${override} but the file does not exist`, ); } return override; @@ -52,9 +52,9 @@ function getSidecarPath() { const platformPkg = getPlatformPackageName(); if (!platformPkg) { throw new Error( - `@rivet-dev/agent-os-sidecar: unsupported platform ${process.platform}/${process.arch}. ` + + `@rivet-dev/agentos-sidecar: unsupported platform ${process.platform}/${process.arch}. ` + "The Agent OS sidecar currently supports linux x64 and arm64. " + - "Set AGENT_OS_SIDECAR_BIN to a local agent-os-sidecar binary to override.", + "Set AGENTOS_SIDECAR_BIN to a local agentos-sidecar binary to override.", ); } @@ -63,10 +63,10 @@ function getSidecarPath() { pkgJsonPath = require.resolve(`${platformPkg}/package.json`); } catch { throw new Error( - `@rivet-dev/agent-os-sidecar: platform package ${platformPkg} is not installed.\n` + + `@rivet-dev/agentos-sidecar: platform package ${platformPkg} is not installed.\n` + "This usually means the platform is unsupported or optionalDependencies were\n" + `skipped during install. Try: npm install --include=optional ${platformPkg}\n` + - "Or set AGENT_OS_SIDECAR_BIN to a local agent-os-sidecar binary.", + "Or set AGENTOS_SIDECAR_BIN to a local agentos-sidecar binary.", ); } diff --git a/packages/sidecar-binary/npm/linux-arm64-gnu/package.json b/packages/sidecar-binary/npm/linux-arm64-gnu/package.json index d2cf98e80..5360d2ce7 100644 --- a/packages/sidecar-binary/npm/linux-arm64-gnu/package.json +++ b/packages/sidecar-binary/npm/linux-arm64-gnu/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agent-os-sidecar-linux-arm64-gnu", + "name": "@rivet-dev/agentos-sidecar-linux-arm64-gnu", "version": "0.2.0-rc.3", "description": "Agent OS native sidecar binary for Linux arm64 (glibc)", "license": "Apache-2.0", @@ -18,7 +18,7 @@ "glibc" ], "files": [ - "agent-os-sidecar" + "agentos-sidecar" ], "engines": { "node": ">=20" diff --git a/packages/sidecar-binary/npm/linux-x64-gnu/package.json b/packages/sidecar-binary/npm/linux-x64-gnu/package.json index c84b3e141..466c68fe2 100644 --- a/packages/sidecar-binary/npm/linux-x64-gnu/package.json +++ b/packages/sidecar-binary/npm/linux-x64-gnu/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agent-os-sidecar-linux-x64-gnu", + "name": "@rivet-dev/agentos-sidecar-linux-x64-gnu", "version": "0.2.0-rc.3", "description": "Agent OS native sidecar binary for Linux x64 (glibc)", "license": "Apache-2.0", @@ -18,7 +18,7 @@ "glibc" ], "files": [ - "agent-os-sidecar" + "agentos-sidecar" ], "engines": { "node": ">=20" diff --git a/packages/sidecar-binary/package.json b/packages/sidecar-binary/package.json index 7eea35d76..4f1b0e38c 100644 --- a/packages/sidecar-binary/package.json +++ b/packages/sidecar-binary/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agent-os-sidecar", + "name": "@rivet-dev/agentos-sidecar", "version": "0.2.0-rc.3", "description": "Platform-specific resolver for the Agent OS native sidecar binary", "license": "Apache-2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47509878a..9b2b6b2a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,23 +67,24 @@ catalogs: specifier: 0.0.0-split-runtime-preview.5d46b14 version: 0.0.0-split-runtime-preview.5d46b14 '@secure-exec/core': - specifier: 0.0.0-split-runtime-preview.5d46b14 - version: 0.0.0-split-runtime-preview.5d46b14 + specifier: 0.3.0 + version: 0.3.0 '@secure-exec/google-drive': - specifier: 0.0.0-split-runtime-preview.5d46b14 - version: 0.0.0-split-runtime-preview.5d46b14 + specifier: 0.3.0 + version: 0.3.0 '@secure-exec/nodejs': specifier: 0.2.1 version: 0.2.1 '@secure-exec/s3': - specifier: 0.0.0-split-runtime-preview.5d46b14 - version: 0.0.0-split-runtime-preview.5d46b14 + specifier: 0.3.0 + version: 0.3.0 '@secure-exec/sandbox': - specifier: 0.0.0-split-runtime-preview.5d46b14 - version: 0.0.0-split-runtime-preview.5d46b14 + specifier: 0.3.0 + version: 0.3.0 overrides: - '@rivet-dev/agent-os-core': workspace:* + '@rivet-dev/agentos-core': workspace:* + '@rivetkit/rivetkit-wasm': 2.3.2 importers: @@ -98,21 +99,21 @@ importers: '@copilotkit/llmock': specifier: ^1.6.0 version: 1.6.0 - '@rivet-dev/agent-os-claude': + '@rivet-dev/agentos-claude': specifier: workspace:* version: link:registry/agent/claude - '@rivet-dev/agent-os-codex-agent': + '@rivet-dev/agentos-codex-agent': specifier: workspace:* version: link:registry/agent/codex - '@rivet-dev/agent-os-core': + '@rivet-dev/agentos-core': specifier: workspace:* version: link:packages/core - '@rivet-dev/agent-os-pi': + '@rivet-dev/agentos-pi': specifier: workspace:* version: link:registry/agent/pi '@secure-exec/core': - specifier: link:../secure-exec/packages/core - version: link:../secure-exec/packages/core + specifier: 'catalog:' + version: 0.3.0 '@types/node': specifier: ^22.19.15 version: 22.19.15 @@ -137,24 +138,24 @@ importers: '@agent-os-pkgs/git': specifier: 'catalog:' version: 0.0.0-split-runtime-preview.5d46b14 - '@rivet-dev/agent-os-claude': + '@rivet-dev/agentos-claude': specifier: workspace:* version: link:../../registry/agent/claude - '@rivet-dev/agent-os-core': + '@rivet-dev/agentos-core': specifier: workspace:* version: link:../../packages/core - '@rivet-dev/agent-os-opencode': + '@rivet-dev/agentos-opencode': specifier: workspace:* version: link:../../registry/agent/opencode - '@rivet-dev/agent-os-pi': + '@rivet-dev/agentos-pi': specifier: workspace:* version: link:../../registry/agent/pi - '@rivet-dev/agent-os-sandbox': + '@rivet-dev/agentos-sandbox': specifier: workspace:* version: link:../../packages/agent-os-sandbox '@secure-exec/s3': specifier: 'catalog:' - version: 0.0.0-split-runtime-preview.5d46b14 + version: 0.3.0 dockerode: specifier: ^4.0.9 version: 4.0.10 @@ -180,12 +181,12 @@ importers: packages/agent-os-sandbox: dependencies: - '@rivet-dev/agent-os-core': + '@rivet-dev/agentos-core': specifier: workspace:* version: link:../core '@secure-exec/sandbox': specifier: 'catalog:' - version: 0.0.0-split-runtime-preview.5d46b14(dockerode@4.0.10)(get-port@7.2.0)(zod@4.3.6) + version: 0.3.0(dockerode@4.0.10)(get-port@7.2.0)(zod@4.3.6) sandbox-agent: specifier: ^0.4.2 version: 0.4.2(dockerode@4.0.10)(get-port@7.2.0)(zod@4.3.6) @@ -206,6 +207,31 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.19.15) + packages/agentos: + dependencies: + '@agent-os-pkgs/common': + specifier: 'catalog:' + version: 0.0.0-split-runtime-preview.5d46b14 + '@rivet-dev/agentos-core': + specifier: workspace:* + version: link:../core + '@rivet-dev/agentos-sidecar': + specifier: workspace:* + version: link:../sidecar-binary + zod: + specifier: ^4.1.11 + version: 4.3.6 + devDependencies: + '@types/node': + specifier: ^22.19.15 + version: 22.19.15 + rivetkit: + specifier: 0.0.0-feat-dylib-actor-plugin.c44621f + version: 0.0.0-feat-dylib-actor-plugin.c44621f(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0)(ws@8.20.0(bufferutil@4.1.0)) + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.15) + packages/browser: dependencies: sucrase: @@ -230,15 +256,15 @@ importers: '@aws-sdk/client-s3': specifier: ^3.1019.0 version: 3.1020.0 - '@rivet-dev/agent-os-sidecar': + '@rivet-dev/agentos-sidecar': specifier: workspace:* version: link:../sidecar-binary '@rivetkit/bare-ts': specifier: ^0.6.2 version: 0.6.2 '@secure-exec/core': - specifier: link:../../../secure-exec/packages/core - version: link:../../../secure-exec/packages/core + specifier: 'catalog:' + version: 0.3.0 '@xterm/headless': specifier: ^6.0.0 version: 6.0.0 @@ -336,30 +362,30 @@ importers: '@mariozechner/pi-coding-agent': specifier: ^0.60.0 version: 0.60.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(bufferutil@4.1.0)(ws@8.20.0(bufferutil@4.1.0))(zod@4.3.6) - '@rivet-dev/agent-os-claude': + '@rivet-dev/agentos-claude': specifier: link:../../registry/agent/claude version: link:../../registry/agent/claude - '@rivet-dev/agent-os-codex-agent': + '@rivet-dev/agentos-codex-agent': specifier: link:../../registry/agent/codex version: link:../../registry/agent/codex - '@rivet-dev/agent-os-opencode': + '@rivet-dev/agentos-opencode': specifier: link:../../registry/agent/opencode version: link:../../registry/agent/opencode - '@rivet-dev/agent-os-pi': + '@rivet-dev/agentos-pi': specifier: link:../../registry/agent/pi version: link:../../registry/agent/pi - '@rivet-dev/agent-os-pi-cli': + '@rivet-dev/agentos-pi-cli': specifier: link:../../registry/agent/pi-cli version: link:../../registry/agent/pi-cli - '@rivet-dev/agent-os-sandbox': + '@rivet-dev/agentos-sandbox': specifier: link:../agent-os-sandbox version: link:../agent-os-sandbox '@secure-exec/google-drive': specifier: 'catalog:' - version: 0.0.0-split-runtime-preview.5d46b14 + version: 0.3.0 '@secure-exec/s3': specifier: 'catalog:' - version: 0.0.0-split-runtime-preview.5d46b14 + version: 0.3.0 '@types/node': specifier: ^22.10.2 version: 22.19.15 @@ -384,7 +410,7 @@ importers: packages/dev-shell: dependencies: - '@rivet-dev/agent-os-core': + '@rivet-dev/agentos-core': specifier: workspace:* version: link:../core pino: @@ -409,10 +435,10 @@ importers: packages/playground: dependencies: - '@rivet-dev/agent-os-browser': + '@rivet-dev/agentos-browser': specifier: workspace:* version: link:../browser - '@rivet-dev/agent-os-core': + '@rivet-dev/agentos-core': specifier: workspace:* version: link:../core devDependencies: @@ -439,7 +465,7 @@ importers: dependencies: '@secure-exec/core': specifier: 'catalog:' - version: 0.0.0-split-runtime-preview.5d46b14 + version: 0.3.0 devDependencies: '@types/node': specifier: ^22.10.2 @@ -461,7 +487,7 @@ importers: dependencies: '@secure-exec/core': specifier: 'catalog:' - version: 0.0.0-split-runtime-preview.5d46b14 + version: 0.3.0 pyodide: specifier: ^0.28.3 version: 0.28.3(bufferutil@4.1.0) @@ -481,7 +507,7 @@ importers: packages/secure-exec: dependencies: - '@rivet-dev/agent-os-core': + '@rivet-dev/agentos-core': specifier: workspace:* version: link:../core devDependencies: @@ -571,7 +597,7 @@ importers: '@agent-os-pkgs/zip': specifier: 'catalog:' version: 0.0.0-split-runtime-preview.5d46b14 - '@rivet-dev/agent-os-core': + '@rivet-dev/agentos-core': specifier: workspace:* version: link:../core devDependencies: @@ -598,7 +624,7 @@ importers: '@anthropic-ai/claude-agent-sdk': specifier: ^0.2.87 version: 0.2.87(@cfworker/json-schema@4.1.1)(zod@4.3.6) - '@rivet-dev/agent-os-core': + '@rivet-dev/agentos-core': specifier: workspace:* version: link:../../../packages/core zod: @@ -627,7 +653,7 @@ importers: registry/agent/opencode: dependencies: - '@rivet-dev/agent-os-core': + '@rivet-dev/agentos-core': specifier: workspace:* version: link:../../../packages/core devDependencies: @@ -652,7 +678,7 @@ importers: '@mariozechner/pi-coding-agent': specifier: ^0.60.0 version: 0.60.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(bufferutil@4.1.0)(ws@8.20.0(bufferutil@4.1.0))(zod@4.3.6) - '@rivet-dev/agent-os-core': + '@rivet-dev/agentos-core': specifier: workspace:* version: link:../../../packages/core devDependencies: @@ -668,7 +694,7 @@ importers: '@mariozechner/pi-coding-agent': specifier: ^0.60.0 version: 0.60.0 - '@rivet-dev/agent-os-core': + '@rivet-dev/agentos-core': specifier: workspace:* version: link:../../../packages/core pi-acp: @@ -714,22 +740,22 @@ importers: dependencies: '@astrojs/react': specifier: ^4.2.0 - version: 4.4.2(@types/node@24.13.2)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(jiti@1.21.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tsx@4.21.0)(yaml@2.8.3) + version: 4.4.2(@types/node@24.13.2)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(jiti@1.21.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tsx@4.21.0)(yaml@2.9.0) '@astrojs/sitemap': specifier: ^3.2.0 version: 3.7.3 '@astrojs/starlight': specifier: ^0.36.0 - version: 0.36.3(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)) + version: 0.36.3(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0)) '@astrojs/tailwind': specifier: ^6.0.0 - version: 6.0.2(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) + version: 6.0.2(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0)) '@rivet-dev/docs-theme': specifier: github:rivet-dev/docs-theme#v0.2.0 - version: https://codeload.github.com/rivet-dev/docs-theme/tar.gz/aa489f5252b356bb3f2a5b07532ccd7d70c6d35c(@astrojs/starlight@0.36.3(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)))(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)) + version: https://codeload.github.com/rivet-dev/docs-theme/tar.gz/aa489f5252b356bb3f2a5b07532ccd7d70c6d35c(@astrojs/starlight@0.36.3(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0)))(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0)) astro: specifier: ^5.18.2 - version: 5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0) framer-motion: specifier: ^12.0.0 version: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -747,7 +773,7 @@ importers: version: 0.33.5 tailwindcss: specifier: ^3.4.0 - version: 3.4.19(tsx@4.21.0)(yaml@2.8.3) + version: 3.4.19(tsx@4.21.0)(yaml@2.9.0) devDependencies: '@types/react': specifier: ^19.0.0 @@ -760,7 +786,7 @@ importers: version: 5.9.3 vite: specifier: ^6.4.1 - version: 6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3) + version: 6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.9.0) packages: @@ -992,6 +1018,11 @@ packages: zod: optional: true + '@asteasolutions/zod-to-openapi@8.5.0': + resolution: {integrity: sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==} + peerDependencies: + zod: ^4.0.0 + '@astrojs/compiler@2.13.1': resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==} @@ -1937,6 +1968,19 @@ packages: peerDependencies: hono: ^4 + '@hono/zod-openapi@1.4.0': + resolution: {integrity: sha512-AFchqR1N/NxfI4hUOSGI2/g8zLROxA1OE7Oh5JJFlTaGxhrdRyH+93gd0tIBpb0z8s9r8hUoNnaOBfHbdb4NMw==} + engines: {node: '>=16.0.0'} + peerDependencies: + hono: '>=4.10.0' + zod: ^4.0.0 + + '@hono/zod-validator@0.8.0': + resolution: {integrity: sha512-5uS4S1/LKtZQYvD4BtpPUFkOv8d1wNxHHrChm26buMiEYc1FrHWvDUaKVBwkiVtvSExHSpLGDvcnpI2Copyj9w==} + peerDependencies: + hono: '>=4.10.0' + zod: ^3.25.0 || ^4.0.0 + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -2317,6 +2361,11 @@ packages: '@cfworker/json-schema': optional: true + '@napi-rs/cli@2.18.4': + resolution: {integrity: sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==} + engines: {node: '>= 10'} + hasBin: true + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2498,6 +2547,95 @@ packages: resolution: {integrity: sha512-3qndQUQXLdwafMEqfhz24hUtDPcsf1Bu3q52Kb8MqeH8JUh3h6R4HYW3ZJXiQsLcyYyFM68PuIwlLRlg1xDEpg==} engines: {node: ^14.18.0 || >=16.0.0} + '@rivetkit/engine-cli-darwin-arm64@0.0.0-feat-dylib-actor-plugin.c44621f': + resolution: {integrity: sha512-eQW1WTZEe51zXRdI9lqUI9lF/A8rTqVLK9P7ysYNXIl4H1NWv36rFepdKK/chSgrig4/xLRuch2MHtjxLPDPew==} + engines: {node: '>= 20.0.0'} + cpu: [arm64] + os: [darwin] + + '@rivetkit/engine-cli-darwin-x64@0.0.0-feat-dylib-actor-plugin.c44621f': + resolution: {integrity: sha512-NNMNULr3XIyKKAwQcc8RyUBpL+l5vfiFtWZPGVRgpVRYysU4RG5IwZa0uXzraf2yzkgZDym9U3UPkwHmsNthOA==} + engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [darwin] + + '@rivetkit/engine-cli-linux-arm64-musl@0.0.0-feat-dylib-actor-plugin.c44621f': + resolution: {integrity: sha512-DlsxDN7tIiOs89GTDZS9ID/ZNiDvKA0vJ+ojq5LDqcp8X+dFb2uLuMLjaPXVwurkzXLoaDzz20ctnUm44muWLw==} + engines: {node: '>= 20.0.0'} + cpu: [arm64] + os: [linux] + + '@rivetkit/engine-cli-linux-x64-musl@0.0.0-feat-dylib-actor-plugin.c44621f': + resolution: {integrity: sha512-rBijR7naYpOyQnnK1gwFx6dDPTwGoON1wfia1fVCLJlpYcqqD5aVg4PU0oINRF9NPBqPW4vfVWSBKWHyF4YLFA==} + engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [linux] + + '@rivetkit/engine-cli@0.0.0-feat-dylib-actor-plugin.c44621f': + resolution: {integrity: sha512-gRmWkoU8ZiyaqYSHbl6nmAijREHEYdtQYUgA9CwxQa5Y3pglm+N3x4raOKUvmn+Xe1iV5KuHHyREhOO6Hkr5/Q==} + engines: {node: '>= 20.0.0'} + + '@rivetkit/engine-envoy-protocol@0.0.0-feat-dylib-actor-plugin.c44621f': + resolution: {integrity: sha512-NJeYJdE1sbSqOyknL7w32K/hIOta3uOc5eZgagL/kaGrrFKlzaVi1BWAgzNbSdWHgGpqfMN3XtdwW/XllN9vHQ==} + + '@rivetkit/on-change@6.0.1': + resolution: {integrity: sha512-QBN/KRBXLJdCgN4gBTL3XAc/zKm58atSnieXWMOyFSPmo6F1/yIVV/LTRdvAktfCttrGx7W6c32i/lwqCHWnsQ==} + engines: {node: '>=20'} + + '@rivetkit/rivetkit-napi-darwin-arm64@0.0.0-feat-dylib-actor-plugin.c44621f': + resolution: {integrity: sha512-Yg4vhX57FcE6ajn5qym6hRlzJY2ExoRJ1NXw3h2v2C3Fr+6VD+R0C7/voiv+u8OutUld2SImt62Er+cS9JT3tw==} + engines: {node: '>= 20.0.0'} + cpu: [arm64] + os: [darwin] + + '@rivetkit/rivetkit-napi-darwin-x64@0.0.0-feat-dylib-actor-plugin.c44621f': + resolution: {integrity: sha512-fThfkJnRIAmCuAvgqz2cWyYY1uIzOybcXfumCSfSGfU6bS8d0jFBjaHnAZ3dgrncS+nBwK/0rqFt2JHLs7IXOA==} + engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [darwin] + + '@rivetkit/rivetkit-napi-linux-arm64-gnu@0.0.0-feat-dylib-actor-plugin.c44621f': + resolution: {integrity: sha512-ZixJthmxswfHx0DTyjyuYP2L602rYAK1v7eKixXJjGNosjpXNVKkYnV5ML/kf5NbjZ3qeN4GONGR4Zf3WznuZQ==} + engines: {node: '>= 20.0.0'} + cpu: [arm64] + os: [linux] + + '@rivetkit/rivetkit-napi-linux-arm64-musl@0.0.0-feat-dylib-actor-plugin.c44621f': + resolution: {integrity: sha512-X1EpEj1TaYGfA2fOAUoJrnGg3AQynJBiKW0Q5KBVqJn3ofENQIomwCPrjb0jh+ctJg0EeRyEw1wC7Tj0cfPfjg==} + engines: {node: '>= 20.0.0'} + cpu: [arm64] + os: [linux] + + '@rivetkit/rivetkit-napi-linux-x64-gnu@0.0.0-feat-dylib-actor-plugin.c44621f': + resolution: {integrity: sha512-6L23MfUDBCasZBAOn5XveNNxLeBRqzSgWM7vpXkeWLn2Cz75IELHekJNEmnmM1qgWSahfCyTL2OCZh8MBHJAzQ==} + engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [linux] + + '@rivetkit/rivetkit-napi-linux-x64-musl@0.0.0-feat-dylib-actor-plugin.c44621f': + resolution: {integrity: sha512-cYTxBkW/V3STXTC2nHpp9vXcahkmRLXD7Y2HptqJNYGlcMs/tlUJM7YPQSuhrfpQxIgcdZJKAvsKBWStKexROA==} + engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [linux] + + '@rivetkit/rivetkit-napi@0.0.0-feat-dylib-actor-plugin.c44621f': + resolution: {integrity: sha512-rpy4ObZRyqnUfHDf1Bo4QyGsQvYeAEhwPQk7806ObeguqrUozoST4eRHSLbvkOfwISdaHVuNjD6PySF4VWOwPg==} + engines: {node: '>= 20.0.0'} + + '@rivetkit/rivetkit-wasm@2.3.2': + resolution: {integrity: sha512-HNBzh6uQFi4ZYL0tHAZ6nrYKoGCfa+kObLCwdS+/ZzGePV7WGUl20Do1bHjBtnVgp9UwzQu+lka8kFwKfqm6WA==} + + '@rivetkit/traces@0.0.0-feat-dylib-actor-plugin.c44621f': + resolution: {integrity: sha512-J903UVLDSJ1XdYDQnBVvs1SxnNJW/geoHc7t6l7g0tUrwovmA34XACIYhsZRJU0hJGCiIGp/ByuOLPOlkDlp/A==} + engines: {node: '>=18.0.0'} + + '@rivetkit/virtual-websocket@0.0.0-feat-dylib-actor-plugin.c44621f': + resolution: {integrity: sha512-tBHwzPHBd3Wi5ACbdJgwXiDICvNjSEztptXAKkUcQ6OeFsVwPEJNwQEVIoCGBSFHUi9YFdAWKv8tDN5NATc1ZQ==} + + '@rivetkit/workflow-engine@0.0.0-feat-dylib-actor-plugin.c44621f': + resolution: {integrity: sha512-Y6DHCLM7IpLOvSVIhj+LQH38+ffnB84Sy2g3JeLinpyd2ku/iLC7aBvxj1KX43S54PeslU6Tz8kgi2B5jRfA/A==} + engines: {node: '>=18.0.0'} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -2667,32 +2805,32 @@ packages: resolution: {integrity: sha512-trO//ypJBSt5xkewuol9LOykvDgHwUXq8R+yQVS+0CmpN3lYUtewHkb+At9RVGRhDMmJZY2oasaXDnhfurQ33w==} hasBin: true - '@secure-exec/core@0.0.0-split-runtime-preview.5d46b14': - resolution: {integrity: sha512-U7oWReKG4K+7o0x+nP2qx0CUYGoO2RvRbUZJXhy2iG2ce+Axe/+o5j/NaondRu1C37Tx2EjVwjP1hNhmD0DTNA==} - '@secure-exec/core@0.2.1': resolution: {integrity: sha512-HsnUv6gClpMA1BBRmX86j30TKTZtgJC/fO1tVavr7IpM2zNKbHU8LgSlBd7mv2SNy02ImTmU/GnQ3aYB4NSbEg==} - '@secure-exec/google-drive@0.0.0-split-runtime-preview.5d46b14': - resolution: {integrity: sha512-va7ZZZPTnEmYUCVHutX0oiq/B5dFvCVMCWTb4CVn4lbkIyqb9erCELPklJpUZVS7iztLYxyf/yJpWuhAddXZdw==} + '@secure-exec/core@0.3.0': + resolution: {integrity: sha512-/mk8OIyibjVxOrs8r2qu40aE58Vm2pCy2A6+DPiJYY3Ch1MJ3AeVcPtd0AUrLinTHQJM5Ps8K8iLWvLLSI5VbQ==} + + '@secure-exec/google-drive@0.3.0': + resolution: {integrity: sha512-10FTqMZuZ1k19nGBYFSqcf574YUFrgPB3G/ZFCUm9h8G+LPGSBhMZfzS17gTDwUCGm02WgYMWVSx9d6h7JWzQA==} '@secure-exec/nodejs@0.2.1': resolution: {integrity: sha512-UJMJqVFxexlHJV0Q9nWURvrz6GElj8673DDOOFln6FHR6JS+9SaSU3eISrN158DuNC3SFi4rgjb/scKnK4YOYQ==} - '@secure-exec/s3@0.0.0-split-runtime-preview.5d46b14': - resolution: {integrity: sha512-X7stPV4f5/J+t3qBd/8YQFty9iNFNCD7ipAp0rwVDVNdhjA1xyAMKvQXfG11rbe2Y5o1P43k5cT6Z10pZtFevw==} + '@secure-exec/s3@0.3.0': + resolution: {integrity: sha512-yrAt3qiuvT3GNxnGcyKEhrbA9uC4Hj13wVNL6ZbHZ12fjmENyaxhSRdoeKB1kD36iqWtOp6qOE2mTrcdohZaQQ==} - '@secure-exec/sandbox@0.0.0-split-runtime-preview.5d46b14': - resolution: {integrity: sha512-/CS91LGj03r0ylUeb+5uf+dKt9w/gcBXo11dvtfs6ZNSf0TRSayvHxHEwagN7ABarOGJT3uc1DEhp63/XsGGUQ==} + '@secure-exec/sandbox@0.3.0': + resolution: {integrity: sha512-sOXqjLbxAs/dsccIy27eFlgKZZM9iY16/IILahFbxlrxZ8J1gmzHOxKn4ixmE0Nv8JUkm6+bxQ/YnJWcApN5cA==} - '@secure-exec/sidecar-linux-x64-gnu@0.0.0-split-runtime-preview.5d46b14': - resolution: {integrity: sha512-litPZ/HgzA1VjjOGaBuP5uLj6PXiTGJJF7q12NXM7xN+nqFrSDRrwgByPde4TxX2kbk1zP+hYM08S+qqdrAwxQ==} + '@secure-exec/sidecar-linux-x64-gnu@0.3.0': + resolution: {integrity: sha512-s/fWwtnngpV29y9v1x/KpiUUYPVL6cof+EESaYaVkahUyqHYGN/OfGTSMuKsJqlm0WwyYwjnBDu6gGCUdo84cQ==} engines: {node: '>=20'} cpu: [x64] os: [linux] - '@secure-exec/sidecar@0.0.0-split-runtime-preview.5d46b14': - resolution: {integrity: sha512-Zfj6i7UNikc4SXtyBYRcMydHFEsrmUT0VnG8lt8hTz6Ux6YgBmIXmzMZntyJnDfa+ayJoYbNnfcjEiy4AvTr8Q==} + '@secure-exec/sidecar@0.3.0': + resolution: {integrity: sha512-v/vVM+y2GtTtzJ3Q+G0NRp/WpGDmCrgQgu4nYW+423QrzXFMgSmkTE8akNQcksDaVL/vxLipp3HIJ2/X44aAKg==} engines: {node: '>=20'} '@secure-exec/v8-darwin-arm64@0.2.1': @@ -3065,6 +3203,9 @@ packages: '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/retry@0.12.2': + resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} + '@types/sax@1.2.7': resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} @@ -3918,6 +4059,98 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} + drizzle-orm@0.44.7: + resolution: {integrity: sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dset@3.1.4: resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} engines: {node: '>=4'} @@ -4159,6 +4392,9 @@ packages: fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdb-tuple@1.0.0: + resolution: {integrity: sha512-8jSvKPCYCgTpi9Pt87qlfTk6griyMx4Gk3Xv31Dp72Qp8b6XgIyFsMm8KzPmFJ9iJ8K4pGvRxvOS8D0XGnrkjw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -4563,6 +4799,9 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -4640,6 +4879,10 @@ packages: resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} engines: {node: '>= 0.4'} + is-network-error@1.3.2: + resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==} + engines: {node: '>=16'} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -4815,6 +5058,10 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -5303,6 +5550,9 @@ packages: zod: optional: true + openapi3-ts@4.6.0: + resolution: {integrity: sha512-a4sfn6L2sIShhtzJqmjGrARvxAW/3F2BJDdyRVvNF9VhAsZSh5hSyI3a9TNvmzBxXmq66nY5LNT5bQcBxYAZZg==} + os-browserify@0.3.0: resolution: {integrity: sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==} @@ -5334,6 +5584,10 @@ packages: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} + p-retry@6.2.1: + resolution: {integrity: sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==} + engines: {node: '>=16.17'} + p-timeout@3.2.0: resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} engines: {node: '>=8'} @@ -5829,6 +6083,21 @@ packages: resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==} engines: {node: '>= 0.8'} + rivetkit@0.0.0-feat-dylib-actor-plugin.c44621f: + resolution: {integrity: sha512-/HF9QCzCtqnz6TbdcTS53IzngzIb/00VW1s15o43oATC1OpThvR2WVTpRk5dw33ViLIiMbGP8CzLwg3vvW8ZiA==} + engines: {node: '>=22.0.0'} + peerDependencies: + drizzle-kit: ^0.31.2 + eventsource: ^4.0.0 + ws: ^8.0.0 + peerDependenciesMeta: + drizzle-kit: + optional: true + eventsource: + optional: true + ws: + optional: true + rollup@4.60.1: resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -6469,6 +6738,10 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + uuid@12.0.1: + resolution: {integrity: sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -6478,6 +6751,10 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vbare@0.0.4: + resolution: {integrity: sha512-QsxSVw76NqYUWYPVcQmOnQPX8buIVjgn+yqldTHlWISulBTB9TJ9rnzZceDu+GZmycOtzsmuPbPN1YNxvK12fg==} + engines: {node: '>=18.0.0'} + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -6688,6 +6965,11 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -7020,6 +7302,11 @@ snapshots: optionalDependencies: zod: 4.3.6 + '@asteasolutions/zod-to-openapi@8.5.0(zod@4.3.6)': + dependencies: + openapi3-ts: 4.6.0 + zod: 4.3.6 + '@astrojs/compiler@2.13.1': {} '@astrojs/internal-helpers@0.7.6': {} @@ -7050,12 +7337,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/mdx@4.3.14(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))': + '@astrojs/mdx@4.3.14(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0))': dependencies: '@astrojs/markdown-remark': 6.3.11 '@mdx-js/mdx': 3.1.1 acorn: 8.17.0 - astro: 5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + astro: 5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0) es-module-lexer: 1.7.0 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -7073,15 +7360,15 @@ snapshots: dependencies: prismjs: 1.30.0 - '@astrojs/react@4.4.2(@types/node@24.13.2)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(jiti@1.21.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tsx@4.21.0)(yaml@2.8.3)': + '@astrojs/react@4.4.2(@types/node@24.13.2)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(jiti@1.21.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tsx@4.21.0)(yaml@2.9.0)': dependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@vitejs/plugin-react': 4.7.0(vite@6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3)) + '@vitejs/plugin-react': 4.7.0(vite@6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.9.0)) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) ultrahtml: 1.6.0 - vite: 6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3) + vite: 6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -7102,17 +7389,17 @@ snapshots: stream-replace-string: 2.0.0 zod: 4.3.6 - '@astrojs/starlight@0.36.3(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))': + '@astrojs/starlight@0.36.3(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0))': dependencies: '@astrojs/markdown-remark': 6.3.11 - '@astrojs/mdx': 4.3.14(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)) + '@astrojs/mdx': 4.3.14(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0)) '@astrojs/sitemap': 3.7.3 '@pagefind/default-ui': 1.5.2 '@types/hast': 3.0.4 '@types/js-yaml': 4.0.9 '@types/mdast': 4.0.4 - astro: 5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) - astro-expressive-code: 0.41.7(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)) + astro: 5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0) + astro-expressive-code: 0.41.7(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0)) bcp-47: 2.1.0 hast-util-from-html: 2.0.3 hast-util-select: 6.0.4 @@ -7135,13 +7422,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/tailwind@6.0.2(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': + '@astrojs/tailwind@6.0.2(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0))': dependencies: - astro: 5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + astro: 5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0) autoprefixer: 10.5.0(postcss@8.5.8) postcss: 8.5.8 postcss-load-config: 4.0.2(postcss@8.5.8) - tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - ts-node @@ -8271,6 +8558,19 @@ snapshots: dependencies: hono: 4.12.9 + '@hono/zod-openapi@1.4.0(hono@4.12.9)(zod@4.3.6)': + dependencies: + '@asteasolutions/zod-to-openapi': 8.5.0(zod@4.3.6) + '@hono/zod-validator': 0.8.0(hono@4.12.9)(zod@4.3.6) + hono: 4.12.9 + openapi3-ts: 4.6.0 + zod: 4.3.6 + + '@hono/zod-validator@0.8.0(hono@4.12.9)(zod@4.3.6)': + dependencies: + hono: 4.12.9 + zod: 4.3.6 + '@img/colour@1.1.0': optional: true @@ -8762,6 +9062,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/cli@2.18.4': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8894,13 +9196,87 @@ snapshots: - supports-color optional: true - '@rivet-dev/docs-theme@https://codeload.github.com/rivet-dev/docs-theme/tar.gz/aa489f5252b356bb3f2a5b07532ccd7d70c6d35c(@astrojs/starlight@0.36.3(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)))(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))': + '@rivet-dev/docs-theme@https://codeload.github.com/rivet-dev/docs-theme/tar.gz/aa489f5252b356bb3f2a5b07532ccd7d70c6d35c(@astrojs/starlight@0.36.3(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0)))(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0))': dependencies: - '@astrojs/starlight': 0.36.3(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)) - astro: 5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + '@astrojs/starlight': 0.36.3(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0)) + astro: 5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0) '@rivetkit/bare-ts@0.6.2': {} + '@rivetkit/engine-cli-darwin-arm64@0.0.0-feat-dylib-actor-plugin.c44621f': + optional: true + + '@rivetkit/engine-cli-darwin-x64@0.0.0-feat-dylib-actor-plugin.c44621f': + optional: true + + '@rivetkit/engine-cli-linux-arm64-musl@0.0.0-feat-dylib-actor-plugin.c44621f': + optional: true + + '@rivetkit/engine-cli-linux-x64-musl@0.0.0-feat-dylib-actor-plugin.c44621f': + optional: true + + '@rivetkit/engine-cli@0.0.0-feat-dylib-actor-plugin.c44621f': + optionalDependencies: + '@rivetkit/engine-cli-darwin-arm64': 0.0.0-feat-dylib-actor-plugin.c44621f + '@rivetkit/engine-cli-darwin-x64': 0.0.0-feat-dylib-actor-plugin.c44621f + '@rivetkit/engine-cli-linux-arm64-musl': 0.0.0-feat-dylib-actor-plugin.c44621f + '@rivetkit/engine-cli-linux-x64-musl': 0.0.0-feat-dylib-actor-plugin.c44621f + + '@rivetkit/engine-envoy-protocol@0.0.0-feat-dylib-actor-plugin.c44621f': + dependencies: + '@rivetkit/bare-ts': 0.6.2 + + '@rivetkit/on-change@6.0.1': {} + + '@rivetkit/rivetkit-napi-darwin-arm64@0.0.0-feat-dylib-actor-plugin.c44621f': + optional: true + + '@rivetkit/rivetkit-napi-darwin-x64@0.0.0-feat-dylib-actor-plugin.c44621f': + optional: true + + '@rivetkit/rivetkit-napi-linux-arm64-gnu@0.0.0-feat-dylib-actor-plugin.c44621f': + optional: true + + '@rivetkit/rivetkit-napi-linux-arm64-musl@0.0.0-feat-dylib-actor-plugin.c44621f': + optional: true + + '@rivetkit/rivetkit-napi-linux-x64-gnu@0.0.0-feat-dylib-actor-plugin.c44621f': + optional: true + + '@rivetkit/rivetkit-napi-linux-x64-musl@0.0.0-feat-dylib-actor-plugin.c44621f': + optional: true + + '@rivetkit/rivetkit-napi@0.0.0-feat-dylib-actor-plugin.c44621f': + dependencies: + '@napi-rs/cli': 2.18.4 + '@rivetkit/engine-envoy-protocol': 0.0.0-feat-dylib-actor-plugin.c44621f + optionalDependencies: + '@rivetkit/rivetkit-napi-darwin-arm64': 0.0.0-feat-dylib-actor-plugin.c44621f + '@rivetkit/rivetkit-napi-darwin-x64': 0.0.0-feat-dylib-actor-plugin.c44621f + '@rivetkit/rivetkit-napi-linux-arm64-gnu': 0.0.0-feat-dylib-actor-plugin.c44621f + '@rivetkit/rivetkit-napi-linux-arm64-musl': 0.0.0-feat-dylib-actor-plugin.c44621f + '@rivetkit/rivetkit-napi-linux-x64-gnu': 0.0.0-feat-dylib-actor-plugin.c44621f + '@rivetkit/rivetkit-napi-linux-x64-musl': 0.0.0-feat-dylib-actor-plugin.c44621f + + '@rivetkit/rivetkit-wasm@2.3.2': {} + + '@rivetkit/traces@0.0.0-feat-dylib-actor-plugin.c44621f': + dependencies: + '@rivetkit/bare-ts': 0.6.2 + cbor-x: 1.6.4 + fdb-tuple: 1.0.0 + vbare: 0.0.4 + + '@rivetkit/virtual-websocket@0.0.0-feat-dylib-actor-plugin.c44621f': {} + + '@rivetkit/workflow-engine@0.0.0-feat-dylib-actor-plugin.c44621f': + dependencies: + '@rivetkit/bare-ts': 0.6.2 + cbor-x: 1.6.4 + fdb-tuple: 1.0.0 + pino: 9.14.0 + vbare: 0.0.4 + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/pluginutils@5.4.0(rollup@4.60.1)': @@ -9014,18 +9390,18 @@ snapshots: '@sandbox-agent/cli-win32-x64': 0.4.2 optional: true - '@secure-exec/core@0.0.0-split-runtime-preview.5d46b14': - dependencies: - '@rivetkit/bare-ts': 0.6.2 - '@secure-exec/sidecar': 0.0.0-split-runtime-preview.5d46b14 - '@secure-exec/core@0.2.1': dependencies: better-sqlite3: 12.8.0 - '@secure-exec/google-drive@0.0.0-split-runtime-preview.5d46b14': + '@secure-exec/core@0.3.0': dependencies: - '@secure-exec/core': 0.0.0-split-runtime-preview.5d46b14 + '@rivetkit/bare-ts': 0.6.2 + '@secure-exec/sidecar': 0.3.0 + + '@secure-exec/google-drive@0.3.0': + dependencies: + '@secure-exec/core': 0.3.0 '@secure-exec/nodejs@0.2.1': dependencies: @@ -9038,13 +9414,13 @@ snapshots: node-stdlib-browser: 1.3.1 web-streams-polyfill: 4.2.0 - '@secure-exec/s3@0.0.0-split-runtime-preview.5d46b14': + '@secure-exec/s3@0.3.0': dependencies: - '@secure-exec/core': 0.0.0-split-runtime-preview.5d46b14 + '@secure-exec/core': 0.3.0 - '@secure-exec/sandbox@0.0.0-split-runtime-preview.5d46b14(dockerode@4.0.10)(get-port@7.2.0)(zod@4.3.6)': + '@secure-exec/sandbox@0.3.0(dockerode@4.0.10)(get-port@7.2.0)(zod@4.3.6)': dependencies: - '@secure-exec/core': 0.0.0-split-runtime-preview.5d46b14 + '@secure-exec/core': 0.3.0 sandbox-agent: 0.4.2(dockerode@4.0.10)(get-port@7.2.0)(zod@4.3.6) transitivePeerDependencies: - '@cloudflare/sandbox' @@ -9058,12 +9434,12 @@ snapshots: - modal - zod - '@secure-exec/sidecar-linux-x64-gnu@0.0.0-split-runtime-preview.5d46b14': + '@secure-exec/sidecar-linux-x64-gnu@0.3.0': optional: true - '@secure-exec/sidecar@0.0.0-split-runtime-preview.5d46b14': + '@secure-exec/sidecar@0.3.0': optionalDependencies: - '@secure-exec/sidecar-linux-x64-gnu': 0.0.0-split-runtime-preview.5d46b14 + '@secure-exec/sidecar-linux-x64-gnu': 0.3.0 '@secure-exec/v8-darwin-arm64@0.2.1': optional: true @@ -9564,6 +9940,8 @@ snapshots: '@types/retry@0.12.0': {} + '@types/retry@0.12.2': {} + '@types/sax@1.2.7': dependencies: '@types/node': 22.19.15 @@ -9585,7 +9963,7 @@ snapshots: '@vercel/oidc@3.1.0': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3))': + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) @@ -9593,7 +9971,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3) + vite: 6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -9774,12 +10152,12 @@ snapshots: astring@1.9.0: {} - astro-expressive-code@0.41.7(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)): + astro-expressive-code@0.41.7(astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0)): dependencies: - astro: 5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + astro: 5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0) rehype-expressive-code: 0.41.7 - astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): + astro@5.18.2(@types/node@24.13.2)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0): dependencies: '@astrojs/compiler': 2.13.1 '@astrojs/internal-helpers': 0.7.6 @@ -9836,8 +10214,8 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.5(aws4fetch@1.0.20) vfile: 6.0.3 - vite: 6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3) - vitefu: 1.1.3(vite@6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3)) + vite: 6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.9.0) + vitefu: 1.1.3(vite@6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 21.1.1 yocto-spinner: 0.2.3 @@ -10573,6 +10951,11 @@ snapshots: dotenv@16.6.1: {} + drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0): + optionalDependencies: + '@opentelemetry/api': 1.9.0 + better-sqlite3: 12.8.0 + dset@3.1.4: {} dunder-proto@1.0.1: @@ -10926,6 +11309,8 @@ snapshots: dependencies: pend: 1.2.0 + fdb-tuple@1.0.0: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -11514,6 +11899,10 @@ snapshots: inline-style-parser@0.2.7: {} + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + ip-address@10.1.0: {} ipaddr.js@1.9.1: {} @@ -11578,6 +11967,8 @@ snapshots: call-bind: 1.0.8 define-properties: 1.2.1 + is-network-error@1.3.2: {} + is-number@7.0.0: {} is-plain-obj@4.1.0: {} @@ -11732,6 +12123,10 @@ snapshots: longest-streak@3.1.0: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + loupe@3.2.1: {} lru-cache@10.4.3: {} @@ -12504,6 +12899,10 @@ snapshots: ws: 8.20.0(bufferutil@4.1.0) zod: 4.3.6 + openapi3-ts@4.6.0: + dependencies: + yaml: 2.9.0 + os-browserify@0.3.0: {} p-finally@1.0.0: {} @@ -12535,6 +12934,12 @@ snapshots: '@types/retry': 0.12.0 retry: 0.13.1 + p-retry@6.2.1: + dependencies: + '@types/retry': 0.12.2 + is-network-error: 1.3.2 + retry: 0.13.1 + p-timeout@3.2.0: dependencies: p-finally: 1.0.0 @@ -12773,14 +13178,14 @@ snapshots: optionalDependencies: postcss: 8.5.8 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.3): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.8 tsx: 4.21.0 - yaml: 2.8.3 + yaml: 2.9.0 postcss-nested@6.2.0(postcss@8.5.8): dependencies: @@ -13185,6 +13590,60 @@ snapshots: hash-base: 3.1.2 inherits: 2.0.4 + rivetkit@0.0.0-feat-dylib-actor-plugin.c44621f(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0)(ws@8.20.0(bufferutil@4.1.0)): + dependencies: + '@hono/zod-openapi': 1.4.0(hono@4.12.9)(zod@4.3.6) + '@rivetkit/bare-ts': 0.6.2 + '@rivetkit/engine-cli': 0.0.0-feat-dylib-actor-plugin.c44621f + '@rivetkit/engine-envoy-protocol': 0.0.0-feat-dylib-actor-plugin.c44621f + '@rivetkit/on-change': 6.0.1 + '@rivetkit/rivetkit-napi': 0.0.0-feat-dylib-actor-plugin.c44621f + '@rivetkit/rivetkit-wasm': 2.3.2 + '@rivetkit/traces': 0.0.0-feat-dylib-actor-plugin.c44621f + '@rivetkit/virtual-websocket': 0.0.0-feat-dylib-actor-plugin.c44621f + '@rivetkit/workflow-engine': 0.0.0-feat-dylib-actor-plugin.c44621f + cbor-x: 1.6.4 + drizzle-orm: 0.44.7(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0) + hono: 4.12.9 + invariant: 2.2.4 + p-retry: 6.2.1 + pino: 9.14.0 + uuid: 12.0.1 + vbare: 0.0.4 + zod: 4.3.6 + optionalDependencies: + ws: 8.20.0(bufferutil@4.1.0) + transitivePeerDependencies: + - '@aws-sdk/client-rds-data' + - '@cloudflare/workers-types' + - '@electric-sql/pglite' + - '@libsql/client' + - '@libsql/client-wasm' + - '@neondatabase/serverless' + - '@op-engineering/op-sqlite' + - '@opentelemetry/api' + - '@planetscale/database' + - '@prisma/client' + - '@tidbcloud/serverless' + - '@types/better-sqlite3' + - '@types/pg' + - '@types/sql.js' + - '@upstash/redis' + - '@vercel/postgres' + - '@xata.io/client' + - better-sqlite3 + - bun-types + - expo-sqlite + - gel + - knex + - kysely + - mysql2 + - pg + - postgres + - prisma + - sql.js + - sqlite3 + rollup@4.60.1: dependencies: '@types/estree': 1.0.8 @@ -13599,7 +14058,7 @@ snapshots: picocolors: 1.1.1 sax: 1.6.0 - tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3): + tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -13618,7 +14077,7 @@ snapshots: postcss: 8.5.8 postcss-import: 15.1.0(postcss@8.5.8) postcss-js: 4.1.0(postcss@8.5.8) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.3) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.9.0) postcss-nested: 6.2.0(postcss@8.5.8) postcss-selector-parser: 6.1.4 resolve: 1.22.11 @@ -13936,10 +14395,14 @@ snapshots: uuid@11.1.0: {} + uuid@12.0.1: {} + uuid@9.0.1: {} vary@1.1.2: {} + vbare@0.0.4: {} + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -13982,7 +14445,7 @@ snapshots: '@types/node': 22.19.15 fsevents: 2.3.3 - vite@6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3): + vite@6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -13995,11 +14458,11 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 tsx: 4.21.0 - yaml: 2.8.3 + yaml: 2.9.0 - vitefu@1.1.3(vite@6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3)): + vitefu@1.1.3(vite@6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.9.0)): optionalDependencies: - vite: 6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3) + vite: 6.4.3(@types/node@24.13.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.9.0) vitest@2.1.9(@types/node@22.19.15): dependencies: @@ -14112,6 +14575,8 @@ snapshots: yaml@2.8.3: {} + yaml@2.9.0: {} + yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 62f366601..6d37c0df6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - packages/agent-os-sandbox + - packages/agentos - packages/browser - packages/core - packages/dev-shell @@ -53,9 +54,9 @@ catalog: '@agent-os-pkgs/unzip': 0.0.0-split-runtime-preview.5d46b14 '@agent-os-pkgs/yq': 0.0.0-split-runtime-preview.5d46b14 '@agent-os-pkgs/zip': 0.0.0-split-runtime-preview.5d46b14 - '@secure-exec/core': 0.0.0-split-runtime-preview.5d46b14 - '@secure-exec/google-drive': 0.0.0-split-runtime-preview.5d46b14 + '@secure-exec/core': 0.3.0 + '@secure-exec/google-drive': 0.3.0 '@secure-exec/nodejs': 0.2.1 - '@secure-exec/s3': 0.0.0-split-runtime-preview.5d46b14 - '@secure-exec/sandbox': 0.0.0-split-runtime-preview.5d46b14 + '@secure-exec/s3': 0.3.0 + '@secure-exec/sandbox': 0.3.0 # <<< secure-exec catalog <<< diff --git a/registry/agent/claude/package.json b/registry/agent/claude/package.json index abbb2b9d9..62f97a550 100644 --- a/registry/agent/claude/package.json +++ b/registry/agent/claude/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agent-os-claude", + "name": "@rivet-dev/agentos-claude", "version": "0.2.0-rc.3", "type": "module", "license": "Apache-2.0", @@ -23,7 +23,7 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.16.1", "@anthropic-ai/claude-agent-sdk": "^0.2.87", - "@rivet-dev/agent-os-core": "workspace:*", + "@rivet-dev/agentos-core": "workspace:*", "zod": "^4.1.11" }, "devDependencies": { diff --git a/registry/agent/claude/src/index.ts b/registry/agent/claude/src/index.ts index b16434fce..34f192bea 100644 --- a/registry/agent/claude/src/index.ts +++ b/registry/agent/claude/src/index.ts @@ -1,4 +1,4 @@ -import { defineSoftware } from "@rivet-dev/agent-os-core"; +import { defineSoftware } from "@rivet-dev/agentos-core"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -12,7 +12,7 @@ const claude = defineSoftware({ requires: ["@anthropic-ai/claude-agent-sdk"], agent: { id: "claude", - acpAdapter: "@rivet-dev/agent-os-claude", + acpAdapter: "@rivet-dev/agentos-claude", agentPackage: "@anthropic-ai/claude-agent-sdk", staticEnv: { CLAUDE_AGENT_SDK_CLIENT_APP: "@rivet-dev/agent-os", diff --git a/registry/agent/codex/package.json b/registry/agent/codex/package.json index 4e2b91cc1..f7507b432 100644 --- a/registry/agent/codex/package.json +++ b/registry/agent/codex/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agent-os-codex-agent", + "name": "@rivet-dev/agentos-codex-agent", "version": "0.2.0-rc.3", "type": "module", "license": "Apache-2.0", diff --git a/registry/agent/opencode/package.json b/registry/agent/opencode/package.json index b60464de4..061c5d2da 100644 --- a/registry/agent/opencode/package.json +++ b/registry/agent/opencode/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agent-os-opencode", + "name": "@rivet-dev/agentos-opencode", "version": "0.2.0-rc.3", "type": "module", "license": "Apache-2.0", @@ -20,7 +20,7 @@ "check-types": "tsc --noEmit" }, "dependencies": { - "@rivet-dev/agent-os-core": "workspace:*" + "@rivet-dev/agentos-core": "workspace:*" }, "devDependencies": { "bun": "1.3.11", diff --git a/registry/agent/opencode/scripts/build-opencode-acp.mjs b/registry/agent/opencode/scripts/build-opencode-acp.mjs index a60c99f55..c11d219ae 100644 --- a/registry/agent/opencode/scripts/build-opencode-acp.mjs +++ b/registry/agent/opencode/scripts/build-opencode-acp.mjs @@ -3544,7 +3544,7 @@ async function assertBundleClean(bundlePath) { async function main() { if (!existsSync(bunBin)) { throw new Error( - `bun is not installed for @rivet-dev/agent-os-opencode (expected ${bunBin}). Run pnpm install first.`, + `bun is not installed for @rivet-dev/agentos-opencode (expected ${bunBin}). Run pnpm install first.`, ); } diff --git a/registry/agent/opencode/src/index.ts b/registry/agent/opencode/src/index.ts index 2cd345d03..97c23b28a 100644 --- a/registry/agent/opencode/src/index.ts +++ b/registry/agent/opencode/src/index.ts @@ -1,4 +1,4 @@ -import { defineSoftware } from "@rivet-dev/agent-os-core"; +import { defineSoftware } from "@rivet-dev/agentos-core"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -9,13 +9,13 @@ const opencode = defineSoftware({ name: "opencode", type: "agent" as const, packageDir, - requires: ["@rivet-dev/agent-os-opencode"], + requires: ["@rivet-dev/agentos-opencode"], agent: { id: "opencode", // OpenCode still speaks ACP natively, but Agent OS runs a source-built // Node ACP bundle entirely inside the VM rather than a host binary wrapper. - acpAdapter: "@rivet-dev/agent-os-opencode", - agentPackage: "@rivet-dev/agent-os-opencode", + acpAdapter: "@rivet-dev/agentos-opencode", + agentPackage: "@rivet-dev/agentos-opencode", staticEnv: { OPENCODE_DISABLE_CONFIG_DEP_INSTALL: "1", OPENCODE_DISABLE_EMBEDDED_WEB_UI: "1", diff --git a/registry/agent/pi-cli/package.json b/registry/agent/pi-cli/package.json index eedbf454a..eba4f49a5 100644 --- a/registry/agent/pi-cli/package.json +++ b/registry/agent/pi-cli/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agent-os-pi-cli", + "name": "@rivet-dev/agentos-pi-cli", "version": "0.2.0-rc.3", "type": "module", "license": "Apache-2.0", @@ -16,7 +16,7 @@ "check-types": "tsc --noEmit" }, "dependencies": { - "@rivet-dev/agent-os-core": "workspace:*", + "@rivet-dev/agentos-core": "workspace:*", "@mariozechner/pi-coding-agent": "^0.60.0", "pi-acp": "^0.0.23" }, diff --git a/registry/agent/pi-cli/src/index.ts b/registry/agent/pi-cli/src/index.ts index 8b34cfad9..524785841 100644 --- a/registry/agent/pi-cli/src/index.ts +++ b/registry/agent/pi-cli/src/index.ts @@ -1,4 +1,4 @@ -import { defineSoftware } from "@rivet-dev/agent-os-core"; +import { defineSoftware } from "@rivet-dev/agentos-core"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; diff --git a/registry/agent/pi/package.json b/registry/agent/pi/package.json index ab009845a..c2baec0fe 100644 --- a/registry/agent/pi/package.json +++ b/registry/agent/pi/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agent-os-pi", + "name": "@rivet-dev/agentos-pi", "version": "0.2.0-rc.3", "type": "module", "license": "Apache-2.0", @@ -20,7 +20,7 @@ "check-types": "tsc --noEmit" }, "dependencies": { - "@rivet-dev/agent-os-core": "workspace:*", + "@rivet-dev/agentos-core": "workspace:*", "@agentclientprotocol/sdk": "^0.16.1", "@mariozechner/pi-coding-agent": "^0.60.0", "@mariozechner/pi-ai": "^0.60.0" diff --git a/registry/agent/pi/src/adapter.ts b/registry/agent/pi/src/adapter.ts index 9910d301d..5808460ac 100644 --- a/registry/agent/pi/src/adapter.ts +++ b/registry/agent/pi/src/adapter.ts @@ -74,6 +74,7 @@ type SessionManagerLike = { type ModelLike = { id: string; provider: string; + baseUrl?: string; reasoning?: boolean; }; @@ -663,7 +664,7 @@ async function createAgentSession(options: { const homeDir = process.env.HOME || "/home/user"; const agentDir = join(homeDir, ".pi", "agent"); const settingsManager = SettingsManager.create(cwd, agentDir); - return createPiAgentSession({ + const result = await createPiAgentSession({ cwd, agentDir, sessionManager: options.sessionManager, @@ -672,6 +673,18 @@ async function createAgentSession(options: { tools: options.tools, customTools: options.tools, }); + applyAnthropicBaseUrlOverride(result.session); + return result; +} + +function applyAnthropicBaseUrlOverride(session: PiSessionLike): void { + const baseUrl = process.env.ANTHROPIC_BASE_URL; + if (!baseUrl) return; + const agent = (session as { agent?: { state?: { model?: ModelLike } } }).agent; + const model = agent?.state?.model; + if (model?.provider !== "anthropic") return; + if (!agent?.state) return; + agent.state.model = { ...model, baseUrl }; } // ── CLI argument parsing ──────────────────────────────────────────── diff --git a/registry/agent/pi/src/index.ts b/registry/agent/pi/src/index.ts index 6949da1b1..cc4bf7f13 100644 --- a/registry/agent/pi/src/index.ts +++ b/registry/agent/pi/src/index.ts @@ -1,4 +1,4 @@ -import { defineSoftware } from "@rivet-dev/agent-os-core"; +import { defineSoftware } from "@rivet-dev/agentos-core"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -9,10 +9,10 @@ const pi = defineSoftware({ name: "pi", type: "agent" as const, packageDir, - requires: ["@rivet-dev/agent-os-pi", "@mariozechner/pi-coding-agent"], + requires: ["@rivet-dev/agentos-pi", "@mariozechner/pi-coding-agent"], agent: { id: "pi", - acpAdapter: "@rivet-dev/agent-os-pi", + acpAdapter: "@rivet-dev/agentos-pi", agentPackage: "@mariozechner/pi-coding-agent", }, }); diff --git a/scripts/benchmarks/bench-utils.ts b/scripts/benchmarks/bench-utils.ts index 95b87da14..56fbd7dc6 100644 --- a/scripts/benchmarks/bench-utils.ts +++ b/scripts/benchmarks/bench-utils.ts @@ -1,7 +1,7 @@ -import { AgentOs, type SoftwareInput } from "@rivet-dev/agent-os-core"; +import { AgentOs, type SoftwareInput } from "@rivet-dev/agentos-core"; import { coreutils } from "@agent-os-pkgs/common"; -import claude from "@rivet-dev/agent-os-claude"; -import pi from "@rivet-dev/agent-os-pi"; +import claude from "@rivet-dev/agentos-claude"; +import pi from "@rivet-dev/agentos-pi"; import { LLMock } from "@copilotkit/llmock"; import os from "node:os"; import { resolve } from "node:path"; diff --git a/scripts/benchmarks/echo.bench.ts b/scripts/benchmarks/echo.bench.ts index 09c6c7a06..2c26bcc85 100644 --- a/scripts/benchmarks/echo.bench.ts +++ b/scripts/benchmarks/echo.bench.ts @@ -13,7 +13,7 @@ * Usage: npx tsx benchmarks/echo.bench.ts */ -import type { AgentOs } from "@rivet-dev/agent-os-core"; +import type { AgentOs } from "@rivet-dev/agentos-core"; import { BATCH_SIZES, ECHO_COMMAND, diff --git a/scripts/benchmarks/memory.bench.ts b/scripts/benchmarks/memory.bench.ts index 7ccc35c08..92a254e12 100644 --- a/scripts/benchmarks/memory.bench.ts +++ b/scripts/benchmarks/memory.bench.ts @@ -18,7 +18,7 @@ * npx tsx --expose-gc benchmarks/memory.bench.ts --workload=claude-session --count=1 */ -import type { AgentOs } from "@rivet-dev/agent-os-core"; +import type { AgentOs } from "@rivet-dev/agentos-core"; import { readFileSync, readdirSync } from "node:fs"; import { WORKLOADS, diff --git a/scripts/check-agent-os-client-protocol-compat.mjs b/scripts/check-agent-os-client-protocol-compat.mjs index ffe8c54a5..91f862dff 100644 --- a/scripts/check-agent-os-client-protocol-compat.mjs +++ b/scripts/check-agent-os-client-protocol-compat.mjs @@ -67,8 +67,8 @@ export function checkAgentOsClientProtocolCompat(options = {}) { join(root, "crates/client/tests"), ]; const agentOsSidecarRoots = [ - join(root, "crates/agent-os-sidecar/src"), - join(root, "crates/agent-os-sidecar/tests"), + join(root, "crates/agentos-sidecar/src"), + join(root, "crates/agentos-sidecar/tests"), ]; const errors = []; for (const filePath of clientRoots.flatMap((scanRoot) => diff --git a/scripts/check-agent-os-client-protocol-compat.test.mjs b/scripts/check-agent-os-client-protocol-compat.test.mjs index ebb0830e6..c80c2b5be 100644 --- a/scripts/check-agent-os-client-protocol-compat.test.mjs +++ b/scripts/check-agent-os-client-protocol-compat.test.mjs @@ -6,7 +6,7 @@ import test from "node:test"; import { checkAgentOsClientProtocolCompat } from "./check-agent-os-client-protocol-compat.mjs"; function withFixture(fn) { - const root = mkdtempSync(join(tmpdir(), "agent-os-client-protocol-compat-")); + const root = mkdtempSync(join(tmpdir(), "agentos-client-protocol-compat-")); try { return fn(root); } finally { @@ -51,12 +51,12 @@ test("allows generated wire imports and wire auth version", () => { }); }); -test("allows agent-os-sidecar generated wire imports", () => { +test("allows agentos-sidecar generated wire imports", () => { withFixture((root) => { writeSidecar(root); write( root, - "crates/agent-os-sidecar/src/acp_extension.rs", + "crates/agentos-sidecar/src/acp_extension.rs", [ "use secure_exec_sidecar::wire::{", "\tCloseStdinRequest, EventPayload, ExecuteRequest, GuestFilesystemCallRequest,", @@ -75,12 +75,12 @@ test("allows agent-os-sidecar generated wire imports", () => { }); }); -test("rejects agent-os-sidecar primitive protocol imports", () => { +test("rejects agentos-sidecar primitive protocol imports", () => { withFixture((root) => { writeSidecar(root); write( root, - "crates/agent-os-sidecar/src/acp_extension.rs", + "crates/agentos-sidecar/src/acp_extension.rs", [ "use secure_exec_sidecar::protocol::{", "\tCloseStdinRequest, EventPayload, ExecuteRequest, GuestFilesystemCallRequest,", @@ -96,13 +96,13 @@ test("rejects agent-os-sidecar primitive protocol imports", () => { ); assert.deepEqual(checkAgentOsClientProtocolCompat({ root }), [ - "crates/agent-os-sidecar/src/acp_extension.rs:1:5 imports the secure-exec sidecar compatibility protocol surface; use secure_exec_sidecar::wire for generated wire types", - "crates/agent-os-sidecar/src/acp_extension.rs:7:22 imports the secure-exec sidecar compatibility protocol surface; use secure_exec_sidecar::wire for generated wire types", + "crates/agentos-sidecar/src/acp_extension.rs:1:5 imports the secure-exec sidecar compatibility protocol surface; use secure_exec_sidecar::wire for generated wire types", + "crates/agentos-sidecar/src/acp_extension.rs:7:22 imports the secure-exec sidecar compatibility protocol surface; use secure_exec_sidecar::wire for generated wire types", ]); }); }); -test("rejects new agent-os-client live protocol imports outside the inventory", () => { +test("rejects new agentos-client live protocol imports outside the inventory", () => { withFixture((root) => { writeSidecar(root); write( @@ -117,7 +117,7 @@ test("rejects new agent-os-client live protocol imports outside the inventory", }); }); -test("rejects agent-os-client test protocol imports", () => { +test("rejects agentos-client test protocol imports", () => { withFixture((root) => { writeSidecar(root); write( @@ -132,47 +132,47 @@ test("rejects agent-os-client test protocol imports", () => { }); }); -test("rejects production agent-os-sidecar dispatch protocol imports", () => { +test("rejects production agentos-sidecar dispatch protocol imports", () => { withFixture((root) => { writeSidecar(root); write( root, - "crates/agent-os-sidecar/src/acp_extension.rs", + "crates/agentos-sidecar/src/acp_extension.rs", "use secure_exec_sidecar::protocol::{EventPayload, RequestFrame, SidecarRequestPayload};\n", ); assert.deepEqual(checkAgentOsClientProtocolCompat({ root }), [ - "crates/agent-os-sidecar/src/acp_extension.rs:1:5 imports the secure-exec sidecar compatibility protocol surface; use secure_exec_sidecar::wire for generated wire types", + "crates/agentos-sidecar/src/acp_extension.rs:1:5 imports the secure-exec sidecar compatibility protocol surface; use secure_exec_sidecar::wire for generated wire types", ]); }); }); -test("rejects agent-os-sidecar test protocol imports", () => { +test("rejects agentos-sidecar test protocol imports", () => { withFixture((root) => { writeSidecar(root); write( root, - "crates/agent-os-sidecar/tests/acp_extension.rs", + "crates/agentos-sidecar/tests/acp_extension.rs", "use secure_exec_sidecar::protocol::EventPayload;\n", ); assert.deepEqual(checkAgentOsClientProtocolCompat({ root }), [ - "crates/agent-os-sidecar/tests/acp_extension.rs:1:5 imports the secure-exec sidecar compatibility protocol surface; use secure_exec_sidecar::wire for generated wire types", + "crates/agentos-sidecar/tests/acp_extension.rs:1:5 imports the secure-exec sidecar compatibility protocol surface; use secure_exec_sidecar::wire for generated wire types", ]); }); }); -test("rejects production agent-os-sidecar qualified dispatch protocol paths", () => { +test("rejects production agentos-sidecar qualified dispatch protocol paths", () => { withFixture((root) => { writeSidecar(root); write( root, - "crates/agent-os-sidecar/src/acp_extension.rs", + "crates/agentos-sidecar/src/acp_extension.rs", "fn dispatch() { let _ = secure_exec_sidecar::protocol::RequestFrame::new; }\n", ); assert.deepEqual(checkAgentOsClientProtocolCompat({ root }), [ - "crates/agent-os-sidecar/src/acp_extension.rs:1:25 imports the secure-exec sidecar compatibility protocol surface; use secure_exec_sidecar::wire for generated wire types", + "crates/agentos-sidecar/src/acp_extension.rs:1:25 imports the secure-exec sidecar compatibility protocol surface; use secure_exec_sidecar::wire for generated wire types", ]); }); }); diff --git a/scripts/check-registry-software-split.test.mjs b/scripts/check-registry-software-split.test.mjs index aa670ca4a..61bf0f0de 100644 --- a/scripts/check-registry-software-split.test.mjs +++ b/scripts/check-registry-software-split.test.mjs @@ -39,14 +39,14 @@ test("accepts agent-os-pkgs registry software package metadata", () => { test("rejects stale Agent OS package names and metadata files", () => { withFixture((root) => { writeJson(root, "registry/software/grep/package.json", { - name: "@rivet-dev/agent-os-grep", + name: "@rivet-dev/agentos-grep", }); writeJson(root, "registry/software/grep/agent-os-package.json", { - name: "@rivet-dev/agent-os-grep", + name: "@rivet-dev/agentos-grep", }); assert.deepEqual(checkRegistrySoftwareSplit({ root }), [ - "registry/software/grep/package.json must be named @agent-os-pkgs/grep, found @rivet-dev/agent-os-grep", + "registry/software/grep/package.json must be named @agent-os-pkgs/grep, found @rivet-dev/agentos-grep", "registry/software/grep/agent-os-package.json must be renamed to secure-exec-package.json", "registry/software/grep/secure-exec-package.json is required", ]); @@ -73,7 +73,7 @@ test("rejects Agent OS dependencies inside software manifests", () => { writeJson(root, "registry/software/common/package.json", { name: "@agent-os-pkgs/common", dependencies: { - "@rivet-dev/agent-os-coreutils": "workspace:*", + "@rivet-dev/agentos-coreutils": "workspace:*", }, }); writeJson(root, "registry/software/common/secure-exec-package.json", { @@ -81,7 +81,7 @@ test("rejects Agent OS dependencies inside software manifests", () => { }); assert.deepEqual(checkRegistrySoftwareSplit({ root }), [ - "@agent-os-pkgs/common must not depend on Agent OS package @rivet-dev/agent-os-coreutils in registry software dependencies", + "@agent-os-pkgs/common must not depend on Agent OS package @rivet-dev/agentos-coreutils in registry software dependencies", ]); }); }); diff --git a/scripts/check-registry-test-runtime-boundary.mjs b/scripts/check-registry-test-runtime-boundary.mjs index d221173ce..9a4ca782e 100644 --- a/scripts/check-registry-test-runtime-boundary.mjs +++ b/scripts/check-registry-test-runtime-boundary.mjs @@ -9,8 +9,8 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const defaultRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const forbiddenSpecifiers = new Set([ - "@rivet-dev/agent-os-core/test/runtime", - "@rivet-dev/agent-os-core/internal/runtime-compat", + "@rivet-dev/agentos-core/test/runtime", + "@rivet-dev/agentos-core/internal/runtime-compat", "@secure-exec/core/test-runtime", ]); const allowedRegistryHelper = "registry/tests/helpers.ts"; diff --git a/scripts/check-registry-test-runtime-boundary.test.mjs b/scripts/check-registry-test-runtime-boundary.test.mjs index d42b7d35a..6cb03b67f 100644 --- a/scripts/check-registry-test-runtime-boundary.test.mjs +++ b/scripts/check-registry-test-runtime-boundary.test.mjs @@ -42,11 +42,11 @@ test("rejects direct Agent OS test runtime imports in registry tests", () => { write( root, "registry/tests/wasmvm/example.test.ts", - 'import { createWasmVmRuntime } from "@rivet-dev/agent-os-core/test/runtime";\n', + 'import { createWasmVmRuntime } from "@rivet-dev/agentos-core/test/runtime";\n', ); assert.deepEqual(checkRegistryTestRuntimeBoundary({ root }), [ - "registry/tests/wasmvm/example.test.ts must import registry test runtime helpers from ../helpers.js instead of @rivet-dev/agent-os-core/test/runtime", + "registry/tests/wasmvm/example.test.ts must import registry test runtime helpers from ../helpers.js instead of @rivet-dev/agentos-core/test/runtime", ]); }); }); @@ -56,11 +56,11 @@ test("rejects direct re-exports and requires", () => { write( root, "registry/tests/kernel/example.test.ts", - 'export { createKernel } from "@rivet-dev/agent-os-core/test/runtime";\nconst rt = require("@rivet-dev/agent-os-core/test/runtime");\n', + 'export { createKernel } from "@rivet-dev/agentos-core/test/runtime";\nconst rt = require("@rivet-dev/agentos-core/test/runtime");\n', ); assert.deepEqual(checkRegistryTestRuntimeBoundary({ root }), [ - "registry/tests/kernel/example.test.ts must import registry test runtime helpers from ../helpers.js instead of @rivet-dev/agent-os-core/test/runtime", + "registry/tests/kernel/example.test.ts must import registry test runtime helpers from ../helpers.js instead of @rivet-dev/agentos-core/test/runtime", ]); }); }); @@ -70,11 +70,11 @@ test("rejects direct Agent OS runtime compat imports in registry tests", () => { write( root, "registry/tests/wasmvm/example.test.ts", - 'import { createWasmVmRuntime } from "@rivet-dev/agent-os-core/internal/runtime-compat";\n', + 'import { createWasmVmRuntime } from "@rivet-dev/agentos-core/internal/runtime-compat";\n', ); assert.deepEqual(checkRegistryTestRuntimeBoundary({ root }), [ - "registry/tests/wasmvm/example.test.ts must import registry test runtime helpers from ../helpers.js instead of @rivet-dev/agent-os-core/internal/runtime-compat", + "registry/tests/wasmvm/example.test.ts must import registry test runtime helpers from ../helpers.js instead of @rivet-dev/agentos-core/internal/runtime-compat", ]); }); }); diff --git a/scripts/check-rust-package-metadata.mjs b/scripts/check-rust-package-metadata.mjs index cae2633f4..fe3b6f77f 100644 --- a/scripts/check-rust-package-metadata.mjs +++ b/scripts/check-rust-package-metadata.mjs @@ -7,19 +7,19 @@ const defaultRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const requiredPackages = [ { - name: "agent-os-protocol", - manifestPath: "crates/agent-os-protocol/Cargo.toml", - targets: [{ kind: "lib", name: "agent_os_protocol" }], + name: "agentos-protocol", + manifestPath: "crates/agentos-protocol/Cargo.toml", + targets: [{ kind: "lib", name: "agentos_protocol" }], }, { - name: "agent-os-sidecar", - manifestPath: "crates/agent-os-sidecar/Cargo.toml", - targets: [{ kind: "bin", name: "agent-os-sidecar" }], + name: "agentos-sidecar", + manifestPath: "crates/agentos-sidecar/Cargo.toml", + targets: [{ kind: "bin", name: "agentos-sidecar" }], }, { - name: "agent-os-client", + name: "agentos-client", manifestPath: "crates/client/Cargo.toml", - targets: [{ kind: "lib", name: "agent_os_client" }], + targets: [{ kind: "lib", name: "agentos_client" }], }, ]; diff --git a/scripts/check-rust-package-metadata.test.mjs b/scripts/check-rust-package-metadata.test.mjs index 175f036c3..bc8ba216f 100644 --- a/scripts/check-rust-package-metadata.test.mjs +++ b/scripts/check-rust-package-metadata.test.mjs @@ -20,15 +20,15 @@ function pkg(name, manifestPath, targets, overrides = {}) { const validMetadata = { packages: [ - pkg("agent-os-protocol", "crates/agent-os-protocol/Cargo.toml", [ - { kind: ["lib"], name: "agent_os_protocol" }, + pkg("agentos-protocol", "crates/agentos-protocol/Cargo.toml", [ + { kind: ["lib"], name: "agentos_protocol" }, ]), - pkg("agent-os-sidecar", "crates/agent-os-sidecar/Cargo.toml", [ - { kind: ["lib"], name: "agent_os_sidecar_wrapper" }, - { kind: ["bin"], name: "agent-os-sidecar" }, + pkg("agentos-sidecar", "crates/agentos-sidecar/Cargo.toml", [ + { kind: ["lib"], name: "agentos_sidecar_wrapper" }, + { kind: ["bin"], name: "agentos-sidecar" }, ]), - pkg("agent-os-client", "crates/client/Cargo.toml", [ - { kind: ["lib"], name: "agent_os_client" }, + pkg("agentos-client", "crates/client/Cargo.toml", [ + { kind: ["lib"], name: "agentos_client" }, ]), ], }; @@ -37,22 +37,22 @@ test("accepts expected Rust package metadata", () => { assert.deepEqual(checkRustPackageMetadata({ root, metadata: validMetadata }), []); }); -test("rejects stale agent-os-client lib target names", () => { +test("rejects stale agentos-client lib target names", () => { const metadata = structuredClone(validMetadata); - const client = metadata.packages.find((item) => item.name === "agent-os-client"); + const client = metadata.packages.find((item) => item.name === "agentos-client"); client.targets[0].name = "secure_exec_client"; assert.deepEqual(checkRustPackageMetadata({ root, metadata }), [ - "agent-os-client must expose a lib target named agent_os_client", + "agentos-client must expose a lib target named agentos_client", ]); }); test("rejects non-publishable required Rust packages", () => { const metadata = structuredClone(validMetadata); - const client = metadata.packages.find((item) => item.name === "agent-os-client"); + const client = metadata.packages.find((item) => item.name === "agentos-client"); client.publish = false; assert.deepEqual(checkRustPackageMetadata({ root, metadata }), [ - "agent-os-client must remain publishable", + "agentos-client must remain publishable", ]); }); diff --git a/scripts/check-secure-exec-package-boundary.test.mjs b/scripts/check-secure-exec-package-boundary.test.mjs index 21e0caed5..67a1b36e5 100644 --- a/scripts/check-secure-exec-package-boundary.test.mjs +++ b/scripts/check-secure-exec-package-boundary.test.mjs @@ -149,7 +149,7 @@ test("ignores compatibility wrappers that intentionally depend on Agent OS", () writePackage(root, "secure-exec", { name: "secure-exec", dependencies: { - "@rivet-dev/agent-os-core": "workspace:*", + "@rivet-dev/agentos-core": "workspace:*", }, }); @@ -187,12 +187,12 @@ test("rejects Agent OS manifest dependencies", () => { writePackage(root, "secure-exec-core", { name: "@secure-exec/core", dependencies: { - "@rivet-dev/agent-os-core": "workspace:*", + "@rivet-dev/agentos-core": "workspace:*", }, }); assert.deepEqual(checkSecureExecPackageBoundary({ root }), [ - "@secure-exec/core must not depend on Agent OS package @rivet-dev/agent-os-core (dependencies)", + "@secure-exec/core must not depend on Agent OS package @rivet-dev/agentos-core (dependencies)", ]); }); }); @@ -216,7 +216,7 @@ test("rejects Agent OS package descriptions and readmes", () => { "README.md": [ "# @secure-exec/core", "", - "Use this package with AgentOs from @rivet-dev/agent-os-core.", + "Use this package with AgentOs from @rivet-dev/agentos-core.", "", ].join("\n"), }, @@ -235,12 +235,12 @@ test("audits secure-exec packages outside packages directory", () => { writePackageAt(root, join(root, "registry/file-system/s3"), { name: "@secure-exec/s3", dependencies: { - "@rivet-dev/agent-os-core": "workspace:*", + "@rivet-dev/agentos-core": "workspace:*", }, }); assert.deepEqual(checkSecureExecPackageBoundary({ root }), [ - "@secure-exec/s3 must not depend on Agent OS package @rivet-dev/agent-os-core (dependencies)", + "@secure-exec/s3 must not depend on Agent OS package @rivet-dev/agentos-core (dependencies)", ]); }); }); @@ -260,12 +260,12 @@ test("rejects Agent OS source imports", () => { }, { "src/index.ts": - 'import { createVm } from "@rivet-dev/agent-os-core";\n', + 'import { createVm } from "@rivet-dev/agentos-core";\n', }, ); assert.deepEqual(checkSecureExecPackageBoundary({ root }), [ - "@secure-exec/core must not import Agent OS package @rivet-dev/agent-os-core (packages/secure-exec-core/src/index.ts)", + "@secure-exec/core must not import Agent OS package @rivet-dev/agentos-core (packages/secure-exec-core/src/index.ts)", ]); }); }); @@ -369,7 +369,7 @@ test("rejects relative imports into another package", () => { ); writePackage(root, "core", { - name: "@rivet-dev/agent-os-core", + name: "@rivet-dev/agentos-core", }); assert.deepEqual(checkSecureExecPackageBoundary({ root }), [ diff --git a/scripts/check-stale-split-names.mjs b/scripts/check-stale-split-names.mjs index d253c5361..daf974a25 100644 --- a/scripts/check-stale-split-names.mjs +++ b/scripts/check-stale-split-names.mjs @@ -73,8 +73,8 @@ const stalePatterns = [ }, { name: "legacy sidecar binary env var", - pattern: /\bAGENT_OS_SIDECAR_BINARY\b/g, - replacement: "AGENT_OS_SIDECAR_BIN", + pattern: /\bAGENTOS_SIDECAR_BINARY\b/g, + replacement: "AGENTOS_SIDECAR_BIN", }, { name: "legacy secure-exec repo path", @@ -97,7 +97,7 @@ const stalePatterns = [ replacement: "secure_exec_client::wire::PROTOCOL_NAME", }, { - name: "stale agent-os-client wire-surface documentation", + name: "stale agentos-client wire-surface documentation", pattern: /all\s+wire\s+types\s+are\s+reused\s+from\s+`secure_exec_client::protocol`/g, replacement: @@ -142,14 +142,14 @@ const stalePatterns = [ { name: "legacy core ACP implementation path", pattern: /crates\/sidecar\/src\/acp(?:\/(?:client|session)\.rs|\/)?/g, - replacement: "crates/agent-os-sidecar/src/acp_extension.rs", + replacement: "crates/agentos-sidecar/src/acp_extension.rs", pathPattern: /\.md$/, }, { name: "legacy core ACP create-session guidance", pattern: /crates\/sidecar\/src\/service\.rs[^.\n]*\bCreateSession\b/g, replacement: - "crates/agent-os-sidecar/src/acp_extension.rs create-session handling", + "crates/agentos-sidecar/src/acp_extension.rs create-session handling", pathPattern: /\.md$/, }, { diff --git a/scripts/check-stale-split-names.test.mjs b/scripts/check-stale-split-names.test.mjs index 4e296d6f4..6930e317b 100644 --- a/scripts/check-stale-split-names.test.mjs +++ b/scripts/check-stale-split-names.test.mjs @@ -25,7 +25,7 @@ test("accepts current split names", () => { write( root, "packages/core/src/example.ts", - 'process.env.SECURE_EXEC_KEEP_STDIN_OPEN = "1";\nprocess.env.AGENT_OS_SIDECAR_BIN = "/tmp/agent-os-sidecar";\n', + 'process.env.SECURE_EXEC_KEEP_STDIN_OPEN = "1";\nprocess.env.AGENTOS_SIDECAR_BIN = "/tmp/agentos-sidecar";\n', ); write(root, "Cargo.toml", '# secure exec lives at "../secure-exec"\n'); @@ -38,14 +38,14 @@ test("rejects stale env vars and legacy repo paths", () => { write( root, "packages/core/src/example.ts", - 'process.env.AGENT_OS_KEEP_STDIN_OPEN = "1";\nprocess.env.AGENT_OS_SIDECAR_BINARY = "/tmp/agent-os-sidecar";\n', + 'process.env.AGENT_OS_KEEP_STDIN_OPEN = "1";\nprocess.env.AGENTOS_SIDECAR_BINARY = "/tmp/agentos-sidecar";\n', ); write(root, "Cargo.toml", '# legacy path: "../se1"\n'); assert.deepEqual(checkStaleSplitNames({ root }), [ "Cargo.toml:1:17 uses legacy secure-exec repo path ../se1; use ../secure-exec or ~/secure-exec", "packages/core/src/example.ts:1:13 uses legacy stdin env var AGENT_OS_KEEP_STDIN_OPEN; use SECURE_EXEC_KEEP_STDIN_OPEN", - "packages/core/src/example.ts:2:13 uses legacy sidecar binary env var AGENT_OS_SIDECAR_BINARY; use AGENT_OS_SIDECAR_BIN", + "packages/core/src/example.ts:2:13 uses legacy sidecar binary env var AGENTOS_SIDECAR_BINARY; use AGENTOS_SIDECAR_BIN", ]); }); }); @@ -71,7 +71,7 @@ test("rejects compat protocol schema constants in Rust callers", () => { }); }); -test("rejects stale agent-os-client wire-surface docs", () => { +test("rejects stale agentos-client wire-surface docs", () => { withFixture((root) => { write( root, @@ -80,7 +80,7 @@ test("rejects stale agent-os-client wire-surface docs", () => { ); assert.deepEqual(checkStaleSplitNames({ root }), [ - "crates/client/src/lib.rs:1:33 uses stale agent-os-client wire-surface documentation all wire types are reused from `secure_exec_client::protocol`; use document secure_exec_client::wire as the generated schema surface", + "crates/client/src/lib.rs:1:33 uses stale agentos-client wire-surface documentation all wire types are reused from `secure_exec_client::protocol`; use document secure_exec_client::wire as the generated schema surface", ]); }); }); @@ -160,10 +160,10 @@ test("rejects stale core ACP relocation docs", () => { ); assert.deepEqual(checkStaleSplitNames({ root }), [ - "crates/CLAUDE.md:1:41 uses legacy core ACP implementation path crates/sidecar/src/acp/; use crates/agent-os-sidecar/src/acp_extension.rs", - "crates/CLAUDE.md:2:36 uses legacy core ACP implementation path crates/sidecar/src/acp/client.rs; use crates/agent-os-sidecar/src/acp_extension.rs", - "crates/CLAUDE.md:3:32 uses legacy core ACP implementation path crates/sidecar/src/acp/session.rs; use crates/agent-os-sidecar/src/acp_extension.rs", - "crates/CLAUDE.md:4:7 uses legacy core ACP create-session guidance crates/sidecar/src/service.rs`, `CreateSession; use crates/agent-os-sidecar/src/acp_extension.rs create-session handling", + "crates/CLAUDE.md:1:41 uses legacy core ACP implementation path crates/sidecar/src/acp/; use crates/agentos-sidecar/src/acp_extension.rs", + "crates/CLAUDE.md:2:36 uses legacy core ACP implementation path crates/sidecar/src/acp/client.rs; use crates/agentos-sidecar/src/acp_extension.rs", + "crates/CLAUDE.md:3:32 uses legacy core ACP implementation path crates/sidecar/src/acp/session.rs; use crates/agentos-sidecar/src/acp_extension.rs", + "crates/CLAUDE.md:4:7 uses legacy core ACP create-session guidance crates/sidecar/src/service.rs`, `CreateSession; use crates/agentos-sidecar/src/acp_extension.rs create-session handling", "crates/CLAUDE.md:5:7 uses legacy core ACP orchestration guidance ACP orchestration embedded in `service.rs`; use ACP orchestration embedded in `acp_extension.rs`", "crates/CLAUDE.md:6:25 uses legacy core ACP callback payload SidecarRequestPayload::AcpRequest; use ACP Ext callbacks", "crates/CLAUDE.md:6:63 uses legacy core ACP callback payload SidecarResponsePayload::AcpRequestResult; use ACP Ext callbacks", @@ -200,12 +200,12 @@ test("rejects stale secure-exec protocol schema names", () => { write( root, "crates/sidecar/src/wire.rs", - 'pub const PROTOCOL_NAME: &str = "agent-os-sidecar";\n', + 'pub const PROTOCOL_NAME: &str = "agentos-sidecar";\n', ); write( root, "crates/sidecar/protocol/README.md", - "- `ProtocolSchema.name` remains `agent-os-sidecar`\n- `ProtocolSchema.version` remains `1`\n", + "- `ProtocolSchema.name` remains `agentos-sidecar`\n- `ProtocolSchema.version` remains `1`\n", ); assert.deepEqual(checkStaleSplitNames({ root }), [ diff --git a/scripts/check-ts-split-boundary.mjs b/scripts/check-ts-split-boundary.mjs index bb3336606..4bce84104 100644 --- a/scripts/check-ts-split-boundary.mjs +++ b/scripts/check-ts-split-boundary.mjs @@ -221,14 +221,14 @@ export function auditTsSplitBoundary(options = {}) { const secureExecDependency = dependencySpec(agentOsCoreManifest, "@secure-exec/core"); checks.push( check( - "@rivet-dev/agent-os-core depends on sibling @secure-exec/core", - agentOsCoreManifest.name === "@rivet-dev/agent-os-core" && + "@rivet-dev/agentos-core depends on sibling @secure-exec/core", + agentOsCoreManifest.name === "@rivet-dev/agentos-core" && typeof secureExecDependency === "string" && secureExecDependency.includes("secure-exec/packages/core"), secureExecDependency ?? "missing", ), check( - "@rivet-dev/agent-os-core exports the AgentOs facade and sugar", + "@rivet-dev/agentos-core exports the AgentOs facade and sugar", requiredAgentOsExports.every((name) => agentOsCoreIndex.includes(name)), relative(agentOsRoot, join(agentOsCoreRoot, "src/index.ts")), ), @@ -250,7 +250,7 @@ export function auditTsSplitBoundary(options = {}) { const path = join(agentOsCoreRoot, file); checks.push( check( - `@rivet-dev/agent-os-core keeps ${file}`, + `@rivet-dev/agentos-core keeps ${file}`, existsSync(path) && statSync(path).isFile(), relative(agentOsRoot, path), ), diff --git a/scripts/check-ts-split-boundary.test.mjs b/scripts/check-ts-split-boundary.test.mjs index 44d9fd56b..19fb3abfd 100644 --- a/scripts/check-ts-split-boundary.test.mjs +++ b/scripts/check-ts-split-boundary.test.mjs @@ -63,7 +63,7 @@ function seedReadyFixture(root) { write(secureExecRoot, "packages/core/src/native-client.ts", "export const native = true;\n"); writeJson(agentOsRoot, "packages/core/package.json", { - name: "@rivet-dev/agent-os-core", + name: "@rivet-dev/agentos-core", dependencies: { "@secure-exec/core": "link:../../../secure-exec/packages/core", }, diff --git a/scripts/publish/src/lib/packages.test.ts b/scripts/publish/src/lib/packages.test.ts index c9d606c2f..4a1bc880b 100644 --- a/scripts/publish/src/lib/packages.test.ts +++ b/scripts/publish/src/lib/packages.test.ts @@ -32,14 +32,14 @@ test("discovers Agent OS sidecar resolver packages", () => { const names = packages.map((pkg) => pkg.name); const hasAgentOsPackages = names.some((name) => - name.startsWith("@rivet-dev/agent-os-"), + name.startsWith("@rivet-dev/agentos-"), ); if (hasAgentOsPackages) { - assert(names.includes("@rivet-dev/agent-os-sidecar-linux-x64-gnu")); - assert(names.includes("@rivet-dev/agent-os-sidecar")); + assert(names.includes("@rivet-dev/agentos-sidecar-linux-x64-gnu")); + assert(names.includes("@rivet-dev/agentos-sidecar")); assert( - names.indexOf("@rivet-dev/agent-os-sidecar-linux-x64-gnu") < - names.indexOf("@rivet-dev/agent-os-sidecar"), + names.indexOf("@rivet-dev/agentos-sidecar-linux-x64-gnu") < + names.indexOf("@rivet-dev/agentos-sidecar"), ); } @@ -83,7 +83,7 @@ test("discovers secure-exec-only staged packages", () => { const names = packages.map((pkg) => pkg.name); assert.deepEqual( - names.filter((name) => name.startsWith("@rivet-dev/agent-os-")), + names.filter((name) => name.startsWith("@rivet-dev/agentos-")), [], ); assert(names.includes("@secure-exec/browser")); @@ -107,9 +107,9 @@ test("builds platform map for the agent-os sidecar meta package", () => { const names = packages.map((pkg) => pkg.name); const metaMap = buildMetaPlatformMap(packages); - if (names.includes("@rivet-dev/agent-os-sidecar")) { - assert.deepEqual(metaMap.get("@rivet-dev/agent-os-sidecar"), [ - "@rivet-dev/agent-os-sidecar-linux-x64-gnu", + if (names.includes("@rivet-dev/agentos-sidecar")) { + assert.deepEqual(metaMap.get("@rivet-dev/agentos-sidecar"), [ + "@rivet-dev/agentos-sidecar-linux-x64-gnu", ]); } // a6 no longer publishes the secure-exec sidecar meta package. diff --git a/scripts/publish/src/lib/packages.ts b/scripts/publish/src/lib/packages.ts index 8d42cb55f..88155f171 100644 --- a/scripts/publish/src/lib/packages.ts +++ b/scripts/publish/src/lib/packages.ts @@ -32,11 +32,11 @@ export interface DiscoverPackagesOptions { * that must never be published even if their `private` flag is dropped. */ export const EXCLUDED = new Set([ - "@rivet-dev/agent-os-workspace", - "@rivet-dev/agent-os-dev-shell", - "@rivet-dev/agent-os-playground", - "@rivet-dev/agent-os-shell", - "@rivet-dev/agent-os-quickstart", + "@rivet-dev/agentos-workspace", + "@rivet-dev/agentos-dev-shell", + "@rivet-dev/agentos-playground", + "@rivet-dev/agentos-shell", + "@rivet-dev/agentos-quickstart", "secure-exec", "@secure-exec/typescript", "publish", @@ -59,8 +59,12 @@ export interface MetaPackageSpec { export const META_PACKAGES: readonly MetaPackageSpec[] = [ { - meta: "@rivet-dev/agent-os-sidecar", - platformPrefix: "@rivet-dev/agent-os-sidecar-", + meta: "@rivet-dev/agentos-sidecar", + platformPrefix: "@rivet-dev/agentos-sidecar-", + }, + { + meta: "@rivet-dev/agentos", + platformPrefix: "@rivet-dev/agentos-plugin-", }, ]; @@ -69,6 +73,12 @@ const SIDECAR_BINARY_PACKAGE_DIRS = [ "packages/sidecar/npm", ] as const; +// Platform-specific cdylib packages for the agent-os actor plugin +// (`@rivet-dev/agentos-plugin-`), injected as optionalDependencies of +// the `@rivet-dev/agentos` meta package. Same discovery shape as the sidecar +// binary packages: one dir per platform, allowlisted via sidecarPlatforms(). +const PLUGIN_BINARY_PACKAGE_DIRS = ["packages/agentos-plugin/npm"] as const; + export const SECURE_EXEC_WORKSPACE_PACKAGES = new Set([ "@secure-exec/browser", "@secure-exec/google-drive", @@ -136,7 +146,10 @@ export function discoverPackages( // the meta package resolves at install time. Only the allowlisted // platforms are included so unbuilt platform dirs are never published. const platformAllowlist = new Set(sidecarPlatforms()); - for (const packageDir of SIDECAR_BINARY_PACKAGE_DIRS) { + for (const packageDir of [ + ...SIDECAR_BINARY_PACKAGE_DIRS, + ...PLUGIN_BINARY_PACKAGE_DIRS, + ]) { const npmDir = join(repoRoot, packageDir); if (existsSync(npmDir)) { for (const entry of readdirSync(npmDir).sort()) { @@ -164,7 +177,8 @@ export function discoverPackages( for (const p of workspacePkgs) { if (!p.name) continue; if ( - !p.name.startsWith("@rivet-dev/agent-os-") && + !p.name.startsWith("@rivet-dev/agentos-") && + p.name !== "@rivet-dev/agentos" && !SECURE_EXEC_WORKSPACE_PACKAGES.has(p.name) ) { continue; @@ -202,7 +216,7 @@ export function buildMetaPlatformMap( export function assertDiscoverySanity(packages: Package[]): void { const byName = new Set(packages.map((p) => p.name)); const hasAgentOsPackages = packages.some((p) => - p.name.startsWith("@rivet-dev/agent-os-"), + p.name.startsWith("@rivet-dev/agentos-"), ); const hasSecureExecPackages = packages.some((p) => p.name.startsWith("@secure-exec/"), @@ -210,8 +224,8 @@ export function assertDiscoverySanity(packages: Package[]): void { const required: string[] = []; if (hasAgentOsPackages) { required.push( - "@rivet-dev/agent-os-core", - "@rivet-dev/agent-os-sidecar", + "@rivet-dev/agentos-core", + "@rivet-dev/agentos-sidecar", ); } if (hasSecureExecPackages) { diff --git a/scripts/publish/src/lib/rust-crates.test.ts b/scripts/publish/src/lib/rust-crates.test.ts index f184e0f44..96cf3229e 100644 --- a/scripts/publish/src/lib/rust-crates.test.ts +++ b/scripts/publish/src/lib/rust-crates.test.ts @@ -41,10 +41,9 @@ function assertBefore(crate: string, dependent: string) { test("Rust crate publish order satisfies internal dependencies", () => { assert.equal(new Set(RUST_CRATES).size, RUST_CRATES.length); - // Only a6-owned crates; secure-exec runtime crates are published by secure-exec. - assertBefore("agent-os-protocol", "agent-os-sidecar"); - assertBefore("agent-os-protocol", "agent-os-sidecar-browser"); - assertBefore("agent-os-protocol", "agent-os-client"); + // Only agentOS-owned crates; secure-exec runtime crates are published by secure-exec. + assertBefore("agentos-protocol", "agentos-sidecar"); + assertBefore("agentos-protocol", "agentos-client"); }); test("discovers the publishable Rust crate subset from a workspace", () => { @@ -55,25 +54,25 @@ test("discovers the publishable Rust crate subset from a workspace", () => { [ "[workspace]", "members = [", - ' "crates/agent-os-protocol",', - ' "crates/agent-os-sidecar",', + ' "crates/agentos-protocol",', + ' "crates/agentos-sidecar",', ' "crates/client",', "]", "", ].join("\n"), ); for (const [member, name] of [ - ["crates/agent-os-protocol", "agent-os-protocol"], - ["crates/agent-os-sidecar", "agent-os-sidecar"], - ["crates/client", "agent-os-client"], + ["crates/agentos-protocol", "agentos-protocol"], + ["crates/agentos-sidecar", "agentos-sidecar"], + ["crates/client", "agentos-client"], ]) { write(root, join(member, "Cargo.toml"), `[package]\nname = "${name}"\n`); } assert.deepEqual(discoverRustCrates(root), [ - "agent-os-protocol", - "agent-os-sidecar", - "agent-os-client", + "agentos-protocol", + "agentos-sidecar", + "agentos-client", ]); }); }); diff --git a/scripts/publish/src/lib/rust-crates.ts b/scripts/publish/src/lib/rust-crates.ts index 625e62b1b..fff4788ab 100644 --- a/scripts/publish/src/lib/rust-crates.ts +++ b/scripts/publish/src/lib/rust-crates.ts @@ -1,14 +1,17 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -// a6-OWNED crates published to crates.io in dependency order. The secure-exec -// runtime crates (bridge/kernel/v8-runtime/execution/sidecar/client) live in -// ../secure-exec and are published by secure-exec, not a6. +// agentOS-OWNED crates published to crates.io in dependency order. The +// secure-exec runtime crates (bridge/kernel/v8-runtime/execution/sidecar/client) +// are consumed from crates.io and published by secure-exec, not agentOS. The +// RivetKit native-plugin ABI crate (rivet-actor-plugin-abi) is published by the +// rivet repo. agentos-sidecar-browser is intentionally absent: it depends on the +// unpublished secure-exec-sidecar-browser crate and is excluded from the +// workspace (see Cargo.toml), so it cannot be published until that lands. export const RUST_CRATE_ORDER = [ - "agent-os-protocol", - "agent-os-sidecar", - "agent-os-sidecar-browser", - "agent-os-client", + "agentos-protocol", + "agentos-sidecar", + "agentos-client", ] as const; export type PublishableRustCrate = (typeof RUST_CRATE_ORDER)[number]; diff --git a/scripts/publish/src/lib/version.test.ts b/scripts/publish/src/lib/version.test.ts index 069bfba28..4cbcae390 100644 --- a/scripts/publish/src/lib/version.test.ts +++ b/scripts/publish/src/lib/version.test.ts @@ -20,7 +20,7 @@ test("bumpCargoVersions bumps [workspace.package] but NOT secure-exec dep requir version = "0.2.0" [workspace.dependencies] -agent-os-protocol = { path = "crates/agent-os-protocol", version = "0.2.0-rc.3" } +agentos-protocol = { path = "crates/agentos-protocol", version = "0.2.0-rc.3" } secure-exec-sidecar = { path = "../secure-exec/crates/sidecar", version = "0.2.0-rc.3" } secure-exec-client = { path = "../secure-exec/crates/secure-exec-client", version = "0.2.0-rc.3" } serde = "1" @@ -35,7 +35,7 @@ serde = "1" // ...a6-owned crate dep (path = "crates/...") bumped... assert.match( cargoToml, - /agent-os-protocol = \{ path = "crates\/agent-os-protocol", version = "0\.3\.0" \}/, + /agentos-protocol = \{ path = "crates\/agentos-protocol", version = "0\.3\.0" \}/, ); // ...but secure-exec crate dep requirements stay at the sibling version. assert.match( @@ -70,11 +70,11 @@ test("bumpPackageJsons injects agent-os sidecar platform optional dependency", a ].join("\n"), ); for (const [rel, name] of [ - ["packages/core", "@rivet-dev/agent-os-core"], - ["packages/sidecar-binary", "@rivet-dev/agent-os-sidecar"], + ["packages/core", "@rivet-dev/agentos-core"], + ["packages/sidecar-binary", "@rivet-dev/agentos-sidecar"], [ "packages/sidecar-binary/npm/linux-x64-gnu", - "@rivet-dev/agent-os-sidecar-linux-x64-gnu", + "@rivet-dev/agentos-sidecar-linux-x64-gnu", ], ]) { await writeJson(repoRoot, join(rel, "package.json"), { @@ -92,7 +92,7 @@ test("bumpPackageJsons injects agent-os sidecar platform optional dependency", a ), ); assert.deepEqual(sidecarManifest.optionalDependencies, { - "@rivet-dev/agent-os-sidecar-linux-x64-gnu": "0.3.0", + "@rivet-dev/agentos-sidecar-linux-x64-gnu": "0.3.0", }); } finally { await rm(repoRoot, { recursive: true, force: true }); diff --git a/scripts/publish/src/lib/version.ts b/scripts/publish/src/lib/version.ts index 194fe8992..f27f34994 100644 --- a/scripts/publish/src/lib/version.ts +++ b/scripts/publish/src/lib/version.ts @@ -146,7 +146,7 @@ export async function bumpPackageJsons( } if (!spec.startsWith("workspace:")) continue; const isOurPkg = - packageNames.has(dep) || dep.startsWith("@rivet-dev/agent-os-"); + packageNames.has(dep) || dep.startsWith("@rivet-dev/agentos-"); if (!isOurPkg) continue; deps[dep] = version; } @@ -192,7 +192,7 @@ export async function bumpCargoVersions( // crate deps (path = "../secure-exec/...") are intentionally NOT bumped — they // track the sibling crate version. next = next.replace( - /((?:agent-os|secure-exec)-[a-z0-9-]+ = \{ path = "crates\/[^"]+", version = ")[^"]+(" \})/g, + /((?:agentos|agent-os|secure-exec)-[a-z0-9-]+ = \{ path = "crates\/[^"]+", version = ")[^"]+(" \})/g, `$1${version}$2`, ); @@ -211,7 +211,7 @@ export async function bumpCargoVersions( /** * Rewrite non-package.json, non-Cargo source files to the given version. - * Called only by the local release cutter. Examples that pin `@rivet-dev/agent-os-*` + * Called only by the local release cutter. Examples that pin `@rivet-dev/agentos-*` * to a literal version (rather than `workspace:*`) get updated so released * examples carry the new version. `required: false` because a6 examples use * `workspace:*` today, so a no-match is expected and not an error. diff --git a/scripts/secure-exec-dep.mjs b/scripts/secure-exec-dep.mjs index 7f3e6158e..c587f26ce 100644 --- a/scripts/secure-exec-dep.mjs +++ b/scripts/secure-exec-dep.mjs @@ -58,6 +58,7 @@ const CRATES = { "secure-exec-client": "crates/secure-exec-client", "secure-exec-sidecar": "crates/sidecar", "secure-exec-sidecar-browser": "crates/sidecar-browser", + "secure-exec-vm-config": "crates/vm-config", }; // Seed versions (heterogeneous today; `set-version` unifies them after a publish). @@ -184,19 +185,33 @@ function versionFor(name, pinned) { if (name.startsWith("@agent-os-pkgs/")) return SEED_SOFTWARE_VERSION; return SEED_VERSIONS[name] ?? SEED_SOFTWARE_VERSION; } -function writeCatalog(setVersion) { +// Which managed group a catalog package belongs to. secure-exec (the runtime) +// and the @agent-os-pkgs/* software packages publish on independent cadences, so +// versions are set per scope. +// "secure-exec" -> @secure-exec/* swappable scope (core, s3, google-drive, sandbox) +// "agent-os-pkgs" -> @agent-os-pkgs/* renamed third-party software packages +// "registry-only" -> published-only deps pinned independently (e.g. @secure-exec/nodejs) +function catalogScope(name) { + if (REGISTRY_ONLY.has(name)) return "registry-only"; + if (name.startsWith("@agent-os-pkgs/")) return "agent-os-pkgs"; + return "secure-exec"; +} + +// scope: undefined => every managed group except registry-only; "secure-exec" or +// "agent-os-pkgs" => only that group is bumped, the others keep their existing pins. +function writeCatalog(setVersion, scope) { const wsPath = path.join(ROOT, "pnpm-workspace.yaml"); let text = readFileSync(wsPath, "utf8"); const existing = readVersions(); const names = collectManagedNames(); const lines = [CATALOG_BEGIN, "catalog:"]; for (const name of names) { - // REGISTRY_ONLY packages (e.g. @secure-exec/nodejs) are not part of the - // secure-exec split/preview; keep them at their existing released version. - const v = - setVersion && !REGISTRY_ONLY.has(name) - ? setVersion - : versionFor(name, existing); + const group = catalogScope(name); + // registry-only packages are never version-managed here; everything else is + // bumped only when it falls in the targeted scope (no scope = all groups). + const inScope = + group !== "registry-only" && (scope === undefined || group === scope); + const v = setVersion && inScope ? setVersion : versionFor(name, existing); lines.push(` '${name}': ${v}`); } lines.push(CATALOG_END); @@ -286,12 +301,35 @@ switch (cmd) { console.error("usage: set-version "); process.exit(1); } - // version-only: update the npm catalog (the published npm version). - // The cargo crate version is independent (it tracks the secure-exec crate - // workspace version, which a preview does NOT bump), so it is NOT touched - // here — manage it with `set-crate-version` when the sibling crates rebase. + // Bump EVERY managed npm package (both scopes) to one version. Only correct + // when secure-exec and the software packages publish at the same version; + // otherwise use the scoped commands below. writeCatalog(arg); - console.log(`secure-exec npm version pinned to ${arg} (catalog).`); + console.log(`all secure-exec + agent-os-pkgs npm versions pinned to ${arg} (catalog).`); + console.log("Run: pnpm install to refresh the lockfile."); + break; + } + case "set-secure-exec-version": { + if (!arg) { + console.error("usage: set-secure-exec-version "); + process.exit(1); + } + // Bump only the @secure-exec/* runtime scope (core, s3, google-drive, + // sandbox). The cargo crate version is independent — manage it with + // `set-crate-version` when the sibling crates rebase. + writeCatalog(arg, "secure-exec"); + console.log(`@secure-exec/* npm versions pinned to ${arg} (catalog).`); + console.log("Run: pnpm install to refresh the lockfile."); + break; + } + case "set-agent-os-pkgs-version": { + if (!arg) { + console.error("usage: set-agent-os-pkgs-version "); + process.exit(1); + } + // Bump only the @agent-os-pkgs/* software packages. + writeCatalog(arg, "agent-os-pkgs"); + console.log(`@agent-os-pkgs/* npm versions pinned to ${arg} (catalog).`); console.log("Run: pnpm install to refresh the lockfile."); break; } @@ -312,6 +350,8 @@ switch (cmd) { break; } default: - console.error("usage: secure-exec-dep.mjs |status>"); + console.error( + "usage: secure-exec-dep.mjs |set-secure-exec-version |set-agent-os-pkgs-version |set-crate-version >", + ); process.exit(1); } diff --git a/website/.astro/content-modules.mjs b/website/.astro/content-modules.mjs index 0efe088f7..fe70e0648 100644 --- a/website/.astro/content-modules.mjs +++ b/website/.astro/content-modules.mjs @@ -18,13 +18,13 @@ export default new Map([ ["src/content/docs/docs/networking.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fnetworking.mdx&astroContentModuleFlag=true")], ["src/content/docs/docs/permissions.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fpermissions.mdx&astroContentModuleFlag=true")], ["src/content/docs/docs/persistence.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fpersistence.mdx&astroContentModuleFlag=true")], -["src/content/docs/docs/processes.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fprocesses.mdx&astroContentModuleFlag=true")], ["src/content/docs/docs/queues.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fqueues.mdx&astroContentModuleFlag=true")], +["src/content/docs/docs/processes.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fprocesses.mdx&astroContentModuleFlag=true")], ["src/content/docs/docs/quickstart.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fquickstart.mdx&astroContentModuleFlag=true")], ["src/content/docs/docs/sandbox.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fsandbox.mdx&astroContentModuleFlag=true")], -["src/content/docs/docs/security-model.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fsecurity-model.mdx&astroContentModuleFlag=true")], ["src/content/docs/docs/security.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fsecurity.mdx&astroContentModuleFlag=true")], ["src/content/docs/docs/sessions.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fsessions.mdx&astroContentModuleFlag=true")], +["src/content/docs/docs/security-model.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fsecurity-model.mdx&astroContentModuleFlag=true")], ["src/content/docs/docs/software.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fsoftware.mdx&astroContentModuleFlag=true")], ["src/content/docs/docs/sqlite.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fsqlite.mdx&astroContentModuleFlag=true")], ["src/content/docs/docs/system-prompt.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fsystem-prompt.mdx&astroContentModuleFlag=true")], @@ -32,9 +32,10 @@ export default new Map([ ["src/content/docs/docs/versus-sandbox.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fversus-sandbox.mdx&astroContentModuleFlag=true")], ["src/content/docs/docs/webhooks.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fwebhooks.mdx&astroContentModuleFlag=true")], ["src/content/docs/docs/workflows.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fworkflows.mdx&astroContentModuleFlag=true")], +["src/content/docs/docs/agents/opencode.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fagents%2Fopencode.mdx&astroContentModuleFlag=true")], ["src/content/docs/docs/agents/amp.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fagents%2Famp.mdx&astroContentModuleFlag=true")], ["src/content/docs/docs/agents/claude.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fagents%2Fclaude.mdx&astroContentModuleFlag=true")], ["src/content/docs/docs/agents/codex.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fagents%2Fcodex.mdx&astroContentModuleFlag=true")], -["src/content/docs/docs/agents/opencode.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fagents%2Fopencode.mdx&astroContentModuleFlag=true")], -["src/content/docs/docs/agents/pi.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fagents%2Fpi.mdx&astroContentModuleFlag=true")]]); +["src/content/docs/docs/agents/pi.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Fagents%2Fpi.mdx&astroContentModuleFlag=true")], +["src/content/docs/docs/architecture/sessions-persistence.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Fdocs%2Farchitecture%2Fsessions-persistence.mdx&astroContentModuleFlag=true")]]); \ No newline at end of file diff --git a/website/.astro/data-store.json b/website/.astro/data-store.json index 61c340765..ac0ac51b5 100644 --- a/website/.astro/data-store.json +++ b/website/.astro/data-store.json @@ -1 +1 @@ -[["Map",1,2,9,10],"meta::meta",["Map",3,4,5,6,7,8],"astro-version","5.18.2","content-config-digest","3edc204469c72622","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"site\":\"https://agentos-sdk.dev\",\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"where\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":false,\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[null,null,null],\"rehypePlugins\":[null,[null,{\"experimentalHeadingIdCompat\":false}],null,[null,{\"themes\":[\"github-dark-default\"],\"defaultLocale\":\"en\",\"cascadeLayer\":\"starlight.components\",\"styleOverrides\":{\"borderRadius\":\"0.75rem\",\"borderWidth\":\"1px\",\"codePaddingBlock\":\"0.75rem\",\"codePaddingInline\":\"1rem\",\"codeFontFamily\":\"\\\"JetBrains Mono\\\", ui-monospace, SFMono-Regular, Menlo, monospace\",\"codeFontSize\":\"var(--sl-text-code)\",\"codeLineHeight\":\"var(--sl-line-height)\",\"uiFontFamily\":\"var(--__sl-font)\",\"textMarkers\":{\"lineDiffIndicatorMarginLeft\":\"0.25rem\",\"defaultChroma\":\"45\",\"backgroundOpacity\":\"60%\"},\"borderColor\":\"rgba(244, 241, 231, 0.1)\",\"codeBackground\":\"#0a0a0a\",\"frames\":{\"editorTabBarBackground\":\"#111110\",\"editorTabBarBorderBottomColor\":\"rgba(244, 241, 231, 0.1)\",\"editorActiveTabBackground\":\"#0a0a0a\",\"editorActiveTabIndicatorBottomColor\":\"#cb5a33\",\"terminalTitlebarBackground\":\"#111110\",\"terminalBackground\":\"#0a0a0a\",\"frameBoxShadowCssValue\":\"none\"}},\"plugins\":[{\"name\":\"Starlight Plugin\",\"hooks\":{}},{\"name\":\"astro-expressive-code\",\"hooks\":{}}]}]],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true,\"allowedDomains\":[],\"actionBodySizeLimit\":1048576},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false,\"svgo\":false},\"legacy\":{\"collections\":false},\"prefetch\":{\"prefetchAll\":true},\"i18n\":{\"defaultLocale\":\"en\",\"locales\":[\"en\"],\"routing\":{\"prefixDefaultLocale\":false,\"redirectToDefaultLocale\":false,\"fallbackType\":\"redirect\"}}}","docs",["Map",11,12,25,26,36,37,47,48,58,59,69,70,80,81,91,92,102,103,113,114,9,124,133,134,144,145,155,156,166,167,177,178,188,189,199,200,210,211,221,222,232,233,243,244,254,255,265,266,276,277,287,288,298,299,309,310,320,321,331,332,342,343,353,354,364,365,375,376,386,387,397,398,408,409],"docs/authentication",{"id":11,"data":13,"body":22,"filePath":23,"digest":24,"deferredRender":16},{"title":14,"description":15,"editUrl":16,"head":17,"template":18,"sidebar":19,"pagefind":16,"draft":20},"Authentication","Authenticate connections to agentOS actors using hooks.",true,[],"doc",{"hidden":20,"attrs":21},false,{},"agentOS uses the same authentication system as Rivet Actors. Validate credentials in `onBeforeConnect` or extract user data with `createConnState`.\n\nFor full documentation including JWT examples, role-based access control, rate limiting, and token caching, see [Actor Authentication](/docs/actors/authentication).\n\n## `onBeforeConnect`\n\nValidate credentials before allowing a connection. Throw an error to reject.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup, UserError } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n onBeforeConnect: async (c, params: { authToken: string }) => {\n const isValid = await validateToken(params.authToken);\n if (!isValid) {\n throw new UserError(\"Forbidden\", { code: \"forbidden\" });\n }\n },\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n## `createConnState`\n\nExtract user data from credentials and store it in connection state. Accessible in actions via `c.conn.state`.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup, UserError } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\ninterface ConnState {\n userId: string;\n role: string;\n}\n\nconst vm = agentOs({\n createConnState: async (c, params: { authToken: string }): Promise\u003CConnState> => {\n const payload = await validateToken(params.authToken);\n if (!payload) {\n throw new UserError(\"Forbidden\", { code: \"forbidden\" });\n }\n return { userId: payload.sub, role: payload.role };\n },\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n## Client usage\n\nPass credentials when connecting:\n\n```ts\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"], {\n params: { authToken: \"my-jwt-token\" },\n});\n```\n\nSee [Actor Authentication](/docs/actors/authentication) for more patterns including external auth providers, role-based access control, and token caching.","src/content/docs/docs/authentication.mdx","c6aeb6d387f2bd32","docs/benchmarks",{"id":25,"data":27,"body":33,"filePath":34,"digest":35,"deferredRender":16},{"title":28,"description":29,"editUrl":16,"head":30,"template":18,"sidebar":31,"pagefind":16,"draft":20},"Benchmarks","Performance benchmarks comparing agentOS to traditional sandbox providers.",[],{"hidden":20,"attrs":32},{},"These are the benchmark figures shown on the agentOS marketing page. All numbers are computed from the same data source used by the marketing page. For independent sandbox comparison data, see the [ComputeSDK benchmarks](https://www.computesdk.com/benchmarks/).\n\n## Cold start\n\nTime from requesting an execution to first code running. Measured using the sleep workload (a minimal VM running an idle Node.js process). Sandbox baseline: **E2B**, the fastest mainstream sandbox provider as of March 30, 2026. See [ComputeSDK benchmarks](https://www.computesdk.com/benchmarks/) for independent sandbox comparison data.\n\n| Metric | agentOS | Fastest sandbox (E2B) |\n|---|--:|--:|\n| Cold start p50 | 4.8 ms | 440 ms |\n| Cold start p95 | 5.6 ms | 950 ms |\n| Cold start p99 | 6.1 ms | 3,150 ms |\n\n## Memory per instance\n\nMeasured via staircase benchmarking:\n\n1. **Warmup.** A throwaway VM is created, started, and destroyed before measurement begins. This pays one-time costs (module cache, JIT compilation) that are amortized away in any real deployment where the host process is long-lived.\n2. **Baseline.** GC is forced twice (`--expose-gc`), then RSS is sampled across the entire process tree by reading `/proc/[pid]/statm` for the host process and all descendants. This captures child processes (e.g. V8 isolates running as separate processes) that `process.memoryUsage().rss` would miss.\n3. **Staircase.** VMs are added one at a time. After each VM starts and settles, GC is forced and RSS is sampled again. The delta from the previous sample is the incremental cost of that VM.\n4. **Average.** The per-VM cost is the mean of all step deltas.\n5. **Teardown.** All VMs are disposed and the reclaimed RSS is recorded.\n\nRSS is a process-wide metric that includes thread stacks and OS-mapped pages beyond the VM itself, so the reported figure is an upper bound on the true per-VM cost.\n\nSandbox baseline: **Daytona**, the cheapest mainstream sandbox provider as of March 30, 2026. Default sandbox: 1 vCPU + 1 GiB RAM.\n\n### Full coding agent\n\nPi coding agent session with MCP servers and mounted file systems.\n\n| Metric | agentOS | Cheapest sandbox (Daytona) |\n|---|--:|--:|\n| Memory per instance | ~131 MB | ~1024 MB |\n\n### Simple shell command\n\nMinimal shell workload running simple commands.\n\n| Metric | agentOS | Cheapest sandbox (Daytona) |\n|---|--:|--:|\n| Memory per instance | ~22 MB | ~1024 MB |\n\n## Cost per execution-second\n\nAssumes one agent per sandbox (needed for isolation) and 70% host utilization for self-hosted hardware (the industry-standard HPA scaling threshold). Cost formula: `server cost per second / concurrent executions per server`, where concurrent executions = `floor(server RAM / agent memory) × 0.7`.\n\nSandbox baseline: **Daytona** at $0.0504/vCPU-h + $0.0162/GiB-h with a 1 vCPU + 1 GiB minimum. Source: [daytona.io/pricing](https://www.daytona.io/pricing).\n\n### Full coding agent\n\n| Host tier | agentOS | Cheapest sandbox | Difference |\n|---|--:|--:|--:|\n| AWS ARM | $0.00000058/s | $0.000018/s | 32x cheaper |\n| AWS x86 | $0.00000072/s | $0.000018/s | 26x cheaper |\n| Hetzner ARM | $0.000000066/s | $0.000018/s | 281x cheaper |\n| Hetzner x86 | $0.00000011/s | $0.000018/s | 171x cheaper |\n\n### Simple shell command\n\n| Host tier | agentOS | Cheapest sandbox | Difference |\n|---|--:|--:|--:|\n| AWS ARM | $0.000000073/s | $0.000018/s | 254x cheaper |\n| AWS x86 | $0.000000090/s | $0.000018/s | 205x cheaper |\n| Hetzner ARM | $0.000000011/s | $0.000018/s | 1738x cheaper |\n| Hetzner x86 | $0.000000017/s | $0.000018/s | 1061x cheaper |\n\n## Test environment\n\n| Component | Details |\n|---|---|\n| CPU | 12th Gen Intel i7-12700KF, 12 cores / 20 threads @ 3.7 GHz, 25 MB cache |\n| RAM | 2× 32 GB DDR4 @ 2400 MT/s |\n| Node.js | v24.13.0 |\n| OS | Linux 6.1.0 (Debian), x86_64 |\n\n## Sandbox baselines\n\n| Comparison | Provider | Why this provider |\n|---|---|---|\n| Cold start | E2B | Fastest mainstream sandbox provider on [ComputeSDK](https://www.computesdk.com/benchmarks/) as of March 30, 2026 |\n| Memory and cost | Daytona | Cheapest mainstream sandbox provider as of March 30, 2026 ($0.0504/vCPU-h + $0.0162/GiB-h) |\n\nSelf-hosted hardware tiers: AWS t4g.micro (ARM, $0.0084/h, 1 GiB), AWS t3.micro (x86, $0.0104/h, 1 GiB), Hetzner CAX11 (ARM, €3.29/mo, 4 GiB), Hetzner CX22 (x86, €5.39/mo, 4 GiB). All on-demand pricing.\n\n## Reproducing\n\nagentOS benchmarks live in the [agent-os repository](https://github.com/rivet-dev/agent-os) under `scripts/benchmarks/`.","src/content/docs/docs/benchmarks.mdx","b6a087cd9516c3ec","docs/configuration",{"id":36,"data":38,"body":44,"filePath":45,"digest":46,"deferredRender":16},{"title":39,"description":40,"editUrl":16,"head":41,"template":18,"sidebar":42,"pagefind":16,"draft":20},"Configuration","Configure the agentOS VM options, preview settings, and lifecycle hooks.",[],{"hidden":20,"attrs":43},{},"`agentOs()` accepts the following configuration object.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport { nodeModulesMount } from \"@rivet-dev/agent-os-core\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: {\n // Filesystems to mount at boot. Use nodeModulesMount() to expose a host\n // node_modules tree at /root/node_modules.\n mounts: [nodeModulesMount(\"/path/to/project/node_modules\")],\n // Software packages to install in the VM (see /docs/software)\n software: [common, pi],\n // Ports exempt from SSRF checks\n loopbackExemptPorts: [3000],\n // Extra instructions appended to agent system prompts\n additionalInstructions: \"Always write tests first.\",\n },\n\n // Preview URL token lifetimes\n preview: {\n defaultExpiresInSeconds: 3600, // 1 hour (default)\n maxExpiresInSeconds: 86400, // 24 hours (default)\n },\n\n // Called when a client connects. Throw to reject. See /docs/authentication\n onBeforeConnect: async (c, params) => {\n const user = await verifyToken(params.token);\n if (!user) throw new Error(\"Unauthorized\");\n },\n // Called for every session event, server-side. Runs once per event.\n onSessionEvent: async (c, sessionId, event) => {\n console.log(\"Session event:\", sessionId, event.method);\n },\n // Called when an agent requests permission. See /docs/permissions\n onPermissionRequest: async (c, sessionId, request) => {\n await c.respondPermission(sessionId, request.permissionId, \"always\");\n },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n## Session options\n\nOptions passed to `createSession`. See [Sessions](/docs/sessions) for full documentation.\n\n## Timeouts\n\n| Setting | Default | Description |\n|---------|---------|-------------|\n| Action timeout | 15 minutes | Maximum time for any single action |\n| Sleep grace period | 15 minutes | Time before sleeping after all activity stops |\n\nThese are set internally by the `agentOs()` factory and cannot be overridden per-call. See [Persistence & Sleep](/docs/persistence) for details on the sleep lifecycle.","src/content/docs/docs/configuration.mdx","4b1075756e522ef7","docs/agent-to-agent",{"id":47,"data":49,"body":55,"filePath":56,"digest":57,"deferredRender":16},{"title":50,"description":51,"editUrl":16,"head":52,"template":18,"sidebar":53,"pagefind":16,"draft":20},"Agent-to-Agent Communication","Use host tools to let agents communicate with each other.",[],{"hidden":20,"attrs":54},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\nAgents communicate through [host tools](/docs/tools). You define a toolkit that lets one agent send work to another, and the agent calls it like any other CLI command.\n\n## Example: code writer + reviewer\n\nThis example creates a writer agent with a `review` tool. When the writer calls the tool, it reads the file from the writer's VM, writes it to a separate reviewer VM, and sends a review prompt.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\nimport { toolKit, hostTool } from \"@rivet-dev/agent-os-core\";\nimport { createClient } from \"rivetkit/client\";\nimport { z } from \"zod\";\n\n// Tool that bridges the writer to the reviewer\nconst reviewToolkit = toolKit({\n name: \"review\",\n description: \"Send code to the reviewer agent\",\n tools: {\n submit: hostTool({\n description: \"Submit a file for code review\",\n inputSchema: z.object({\n path: z.string().describe(\"Path to the file to review\"),\n }),\n execute: async (input) => {\n const client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\n const writerHandle = client.writer.getOrCreate([\"my-project\"]);\n const reviewerHandle = client.reviewer.getOrCreate([\"my-project\"]);\n\n // Read file from writer, write to reviewer\n const content = await writerHandle.readFile(input.path);\n await reviewerHandle.writeFile(input.path, content);\n\n // Ask the reviewer to review\n const session = await reviewerHandle.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n const response = await reviewerHandle.sendPrompt(\n session.sessionId,\n `Review the code at ${input.path} and list any issues.`,\n );\n await reviewerHandle.closeSession(session.sessionId);\n\n return { review: response };\n },\n }),\n },\n});\n\n// Writer has the review toolkit, reviewer is plain\nconst writer = agentOs({\n options: { software: [common, pi], toolKits: [reviewToolkit] },\n});\nconst reviewer = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { writer, reviewer } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst writerAgent = client.writer.getOrCreate([\"my-project\"]);\n\nconst session = await writerAgent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\n\n// The writer will call `agentos-review submit --path /home/user/api.ts`\n// when it's ready for a review\nawait writerAgent.sendPrompt(\n session.sessionId,\n \"Write a REST API at /home/user/api.ts, then submit it for review.\",\n);\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\nThe writer agent sees the review tool as a CLI command:\n\n```bash\nagentos-review submit --path /home/user/api.ts\n```\n\nWhen the writer calls this, the host tool reads the file from the writer's VM, writes it to the reviewer's VM, and sends a prompt to the reviewer. The review result is returned to the writer as JSON.\n\n## Why host tools?\n\nHost tools are the natural communication layer between agents because:\n\n- **The agent doesn't need to know about other agents.** It just calls a tool. You can swap the implementation without changing the agent's behavior.\n- **No credentials in the VM.** The host tool executes on the server, so it can access other agents directly without exposing connection details.\n- **Composable.** Chain any number of agents by adding more tools. Each tool is a self-contained bridge to another agent.\n\n## Recommendations\n\n- Each agent has its own isolated VM and filesystem. Use `readFile`/`writeFile` in host tools to pass files between them.\n- Use [Queues](/docs/queues) when agents need to process work asynchronously.\n- Use [Workflows](/docs/workflows) to make multi-agent pipelines durable across restarts.","src/content/docs/docs/agent-to-agent.mdx","7ec4903d4382dd7d","docs/crash-course",{"id":58,"data":60,"body":66,"filePath":67,"digest":68,"deferredRender":16},{"title":61,"description":62,"editUrl":16,"head":63,"template":18,"sidebar":64,"pagefind":16,"draft":20},"Crash Course","Run coding agents inside isolated VMs with full filesystem, process, and network control.",[],{"hidden":20,"attrs":65},{},"import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';\n\n\u003CAside type=\"note\">\nagentOS is in preview and the API is subject to change. If you run into issues, please [report them on GitHub](https://github.com/rivet-dev/rivet/issues) or [join our Discord](https://rivet.dev/discord).\n\u003C/Aside>\n\n{/* SKILL_OVERVIEW_START */}\n\n## Features\n\n- **Isolated VMs**: Each agent gets its own filesystem, processes, and networking. No shared state, no cross-contamination.\n- **Multi-Agent Support**: Run Amp, Claude Code, Codex, OpenCode, and PI with a unified API. Swap agents without changing your code.\n- **Host Tools**: Expose your JavaScript functions to agents as CLI commands. Direct binding with near-zero latency and automatic code mode for up to 80% token reduction.\n- **Persistent State**: Filesystem and transcripts survive sleep/wake cycles automatically. No external database needed.\n- **Orchestration**: Workflows, queues, cron jobs, and multi-agent coordination built on Rivet Actors.\n- **Hybrid Sandboxes**: Run agents in the lightweight VM by default. Spin up a full sandbox on demand for browsers, compilation, and desktop automation.\n\n## When to Use agentOS\n\n- **Coding agents**: Run any coding agent with full OS access, file editing, shell execution, and tool use.\n- **Automated pipelines**: CI-like workflows where agents clone repos, fix bugs, run tests, and open PRs.\n- **Multi-agent systems**: Coordinators dispatching to specialized agents, review pipelines, planning chains.\n- **Scheduled maintenance**: Cron-based agents that audit code, update dependencies, or generate reports.\n- **Collaborative workspaces**: Multiple users observing and interacting with the same agent session in realtime.\n\n## Minimal Project\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Subscribe to streaming events\nagent.on(\"sessionEvent\", (data) => {\n console.log(data.event);\n});\n\n// Create a session and send a prompt\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nconst response = await agent.sendPrompt(\n session.sessionId,\n \"Write a hello world script to /home/user/hello.js\",\n);\nconsole.log(response);\n\n// Read the file the agent created\nconst content = await agent.readFile(\"/home/user/hello.js\");\nconsole.log(new TextDecoder().decode(content));\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\nAfter the quickstart, customize your agent with the [Registry](/agent-os/registry).\n\n## Quick Reference\n\n### Sessions & Transcripts\n\nCreate agent sessions, send prompts, and stream responses in realtime. Transcripts are persisted automatically across sleep/wake cycles.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Stream events as they arrive\nagent.on(\"sessionEvent\", (data) => {\n console.log(data.event.method, data.event);\n});\n\n// Create a session with MCP servers\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n mcpServers: [\n {\n type: \"local\",\n command: \"npx\",\n args: [\"-y\", \"@modelcontextprotocol/server-filesystem\", \"/home/user\"],\n env: {},\n },\n ],\n});\n\n// Send a prompt and wait for the response\nconst response = await agent.sendPrompt(\n session.sessionId,\n \"List all files in the home directory\",\n);\nconsole.log(response);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n[Documentation](/docs/sessions)\n\n### Permissions\n\nApprove or deny agent tool use with human-in-the-loop patterns or auto-approve for trusted workloads.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\n// Auto-approve all permissions server-side\nconst vm = agentOs({\n onPermissionRequest: async (c, sessionId, request) => {\n await c.respondPermission(sessionId, request.permissionId, \"always\");\n },\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Or handle permissions client-side for human-in-the-loop\nagent.on(\"permissionRequest\", async (data) => {\n console.log(\"Permission requested:\", data.request);\n // \"once\" | \"always\" | \"reject\"\n await agent.respondPermission(data.sessionId, data.request.permissionId, \"once\");\n});\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n[Documentation](/docs/permissions)\n\n### Tools\n\nExpose your JavaScript functions to agents as CLI commands inside the VM. Agents call them as shell commands with auto-generated flags from Zod schemas.\n\n```ts\nimport { toolKit, hostTool } from \"@rivet-dev/agent-os-core\";\nimport { z } from \"zod\";\n\nconst myTools = toolKit({\n name: \"myapp\",\n description: \"Application tools\",\n tools: {\n createTicket: hostTool({\n description: \"Create a ticket in the issue tracker\",\n inputSchema: z.object({\n title: z.string().describe(\"Ticket title\"),\n priority: z.enum([\"low\", \"medium\", \"high\"]).describe(\"Priority level\"),\n }),\n execute: async (input) => {\n const ticket = await db.tickets.create(input);\n return { id: ticket.id, url: ticket.url };\n },\n }),\n },\n});\n\n// Agent calls: agentos-myapp createTicket --title \"Fix login\" --priority high\n```\n\n[Documentation](/docs/tools)\n\n### Filesystem\n\nRead, write, and manage files inside the VM. The `/home/user` directory is persisted automatically across sleep/wake cycles.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Write a file\nawait agent.writeFile(\"/home/user/config.json\", JSON.stringify({ key: \"value\" }));\n\n// Read a file\nconst content = await agent.readFile(\"/home/user/config.json\");\nconsole.log(new TextDecoder().decode(content));\n\n// List directory contents recursively\nconst files = await agent.readdirRecursive(\"/home/user\", { maxDepth: 2 });\nconsole.log(files);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n[Documentation](/docs/filesystem)\n\n### Processes & Shell\n\nExecute commands, spawn long-running processes, and open interactive shells.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// One-shot execution\nconst result = await agent.exec(\"echo hello && ls /home/user\");\nconsole.log(\"stdout:\", result.stdout);\nconsole.log(\"exit code:\", result.exitCode);\n\n// Spawn a long-running process\nagent.on(\"processOutput\", (data) => {\n console.log(`[pid ${data.pid}]`, new TextDecoder().decode(data.data));\n});\n\nconst { pid } = await agent.spawn(\"node\", [\"server.js\"]);\nconsole.log(\"Process ID:\", pid);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n[Documentation](/docs/processes)\n\n### Networking & Previews\n\nProxy HTTP requests into VMs with `vmFetch`. Create preview URLs for port forwarding VM services to shareable public URLs.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Fetch from a service running inside the VM\nconst response = await agent.vmFetch(3000, \"/api/health\");\nconsole.log(\"Status:\", response.status);\n\n// Create a preview URL (port forwarding to a public URL)\nconst preview = await agent.createSignedPreviewUrl(3000);\nconsole.log(\"Public URL:\", preview.path);\nconsole.log(\"Expires at:\", new Date(preview.expiresAt));\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n[Documentation](/docs/networking)\n\n### Cron Jobs\n\nSchedule recurring commands and agent sessions with cron expressions.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Schedule a command every hour\nawait agent.scheduleCron({\n schedule: \"0 * * * *\",\n action: { type: \"exec\", command: \"rm\", args: [\"-rf\", \"/tmp/cache/*\"] },\n});\n\n// Schedule an agent session daily at 9 AM\nawait agent.scheduleCron({\n schedule: \"0 9 * * *\",\n action: {\n type: \"session\",\n agentType: \"pi\",\n prompt: \"Review the codebase for security issues and write a report to /home/user/audit.md\",\n },\n});\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n[Documentation](/docs/cron)\n\n### Sandbox Mounting\n\nagentOS uses a hybrid model: agents run in a lightweight VM by default and mount a full sandbox on demand for heavy workloads like browsers, compilation, and desktop automation. Sandboxes are powered by [Sandbox Agent](https://sandboxagent.dev), so you can swap providers without changing agent code. Mount the sandbox as a filesystem and expose its process management as host tools.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport { SandboxAgent } from \"sandbox-agent\";\nimport { DockerProvider } from \"sandbox-agent/docker\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\nimport { createSandboxFs, createSandboxToolkit } from \"@rivet-dev/agent-os-sandbox\";\n\nconst sandbox = await SandboxAgent.start({\n sandbox: new DockerProvider(),\n});\n\nconst vm = agentOs({\n options: {\n software: [common, pi],\n mounts: [\n {\n path: \"/sandbox\",\n driver: createSandboxFs({ client: sandbox }),\n },\n ],\n toolKits: [createSandboxToolkit({ client: sandbox })],\n },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n[Documentation](/docs/sandbox)\n\n### Multiplayer & Realtime\n\nConnect multiple clients to the same agent VM. All subscribers see session output, process logs, and shell data in realtime.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\n// Client A: creates the session and sends prompts\nconst clientA = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agentA = clientA.vm.getOrCreate([\"shared-agent\"]);\nagentA.on(\"sessionEvent\", (data) => console.log(\"[A]\", data.event.method));\n\nconst session = await agentA.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait agentA.sendPrompt(session.sessionId, \"Build a REST API\");\n\n// Client B: observes the same session (separate process)\nconst clientB = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agentB = clientB.vm.getOrCreate([\"shared-agent\"]);\nagentB.on(\"sessionEvent\", (data) => console.log(\"[B]\", data.event.method));\n// Client B sees the same events as Client A\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n[Documentation](/docs/multiplayer)\n\n### Agent-to-Agent\n\nCompose specialized agents into pipelines. Each agent gets its own isolated VM and filesystem.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst coder = agentOs({\n options: { software: [common, pi] },\n});\nconst reviewer = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { coder, reviewer } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\n\n// Coder writes the feature\nconst coderAgent = client.coder.getOrCreate([\"feature-auth\"]);\nconst coderSession = await coderAgent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait coderAgent.sendPrompt(coderSession.sessionId, \"Implement the login feature\");\n\n// Pass files to the reviewer\nconst src = await coderAgent.readFile(\"/home/user/src/auth.ts\");\nconst reviewerAgent = client.reviewer.getOrCreate([\"feature-auth\"]);\nawait reviewerAgent.writeFile(\"/home/user/src/auth.ts\", src);\n\n// Reviewer checks the code\nconst reviewSession = await reviewerAgent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait reviewerAgent.sendPrompt(\n reviewSession.sessionId,\n \"Review auth.ts for security issues\",\n);\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n[Documentation](/docs/agent-to-agent)\n\n### Workflows\n\nOrchestrate multi-step agent tasks with durable workflows that survive crashes and restarts.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\nimport { actor, setup, workflow } from \"rivetkit\";\n\nconst automator = actor({\n workflows: {\n fixBug: workflow\u003C{ repo: string; issue: string }>(),\n },\n run: async (c) => {\n for await (const message of c.workflow.iter(\"fixBug\")) {\n const { repo, issue } = message.body;\n const agentHandle = c.actors.vm.getOrCreate([`fix-${issue}`]);\n\n await c.step(\"clone-repo\", async (c) => {\n return agentHandle.exec(`git clone ${repo} /home/user/repo`);\n });\n\n await c.step(\"fix-bug\", async (c) => {\n const session = await agentHandle.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n const response = await agentHandle.sendPrompt(\n session.sessionId,\n `Fix the bug described in issue: ${issue}`,\n );\n await agentHandle.closeSession(session.sessionId);\n return response;\n });\n\n await c.step(\"run-tests\", async (c) => {\n return agentHandle.exec(\"cd /home/user/repo && npm test\");\n });\n\n await message.complete();\n }\n },\n});\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { automator, vm } });\nregistry.start();\n```\n\n[Documentation](/docs/workflows)\n\n### SQLite\n\nUse actor-local SQLite as structured long-term memory that persists across sessions and sleep/wake cycles.\n\n```ts\nimport { actor, setup } from \"rivetkit\";\nimport { db } from \"rivetkit/db\";\n\nconst memoryAgent = actor({\n db: db({\n onMigrate: async (db) => {\n await db.execute(`\n CREATE TABLE IF NOT EXISTS memories (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n session_id TEXT NOT NULL,\n category TEXT NOT NULL,\n content TEXT NOT NULL,\n created_at INTEGER NOT NULL\n );\n `);\n },\n }),\n actions: {\n store: async (c, sessionId: string, category: string, content: string) => {\n await c.db.execute(\n \"INSERT INTO memories (session_id, category, content, created_at) VALUES (?, ?, ?, ?)\",\n sessionId, category, content, Date.now(),\n );\n },\n search: async (c, query: string) => {\n return c.db.execute(\n \"SELECT category, content FROM memories WHERE content LIKE ? ORDER BY created_at DESC LIMIT 20\",\n `%${query}%`,\n );\n },\n },\n});\n```\n\n[Documentation](/docs/sqlite)\n\n{/* SKILL_OVERVIEW_END */}","src/content/docs/docs/crash-course.mdx","228d33a355ee2e94","docs/cron",{"id":69,"data":71,"body":77,"filePath":78,"digest":79,"deferredRender":16},{"title":72,"description":73,"editUrl":16,"head":74,"template":18,"sidebar":75,"pagefind":16,"draft":20},"Cron Jobs","Schedule recurring commands and agent sessions in agentOS VMs.",[],{"hidden":20,"attrs":76},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n- **Cron expressions** for flexible scheduling (e.g. `\"0 9 * * *\"` for 9 AM daily)\n- **Two action types**: `exec` for commands, `session` for agent sessions\n- **Overlap modes**: `allow`, `skip`, or `queue` concurrent executions\n- **Event streaming** via `cronEvent` for monitoring job execution\n\n## Schedule a command\n\nRun a shell command on a recurring schedule.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Schedule a cleanup script every hour\nconst { id } = await agent.scheduleCron({\n schedule: \"0 * * * *\",\n action: {\n type: \"exec\",\n command: \"rm\",\n args: [\"-rf\", \"/tmp/cache/*\"],\n },\n});\nconsole.log(\"Cron job ID:\", id);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Schedule an agent session\n\nCreate a recurring agent session that runs a prompt on a schedule.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Run an agent every day at 9 AM to check for issues\nawait agent.scheduleCron({\n schedule: \"0 9 * * *\",\n action: {\n type: \"session\",\n agentType: \"pi\",\n prompt: \"Review the logs in /home/user/logs/ and summarize any errors\",\n options: { cwd: \"/home/user\" },\n },\n});\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Overlap modes\n\nControl what happens when a cron job triggers while a previous execution is still running.\n\n| Mode | Behavior |\n|------|----------|\n| `\"skip\"` | Skip this trigger if the previous run is still active |\n| `\"allow\"` | Allow concurrent executions (default) |\n| `\"queue\"` | Queue this trigger and run it after the previous one finishes |\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Queue overlapping executions\nawait agent.scheduleCron({\n schedule: \"*/5 * * * *\",\n overlap: \"queue\",\n action: {\n type: \"session\",\n agentType: \"pi\",\n prompt: \"Process the next batch of tasks\",\n },\n});\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Monitor cron events\n\nSubscribe to `cronEvent` to track job execution.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nagent.on(\"cronEvent\", (data) => {\n console.log(\"Cron event:\", data.event);\n});\n\nawait agent.scheduleCron({\n schedule: \"*/1 * * * *\",\n action: { type: \"exec\", command: \"echo\", args: [\"heartbeat\"] },\n});\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## List and cancel cron jobs\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// List all cron jobs\nconst jobs = await agent.listCronJobs();\nfor (const job of jobs) {\n console.log(job.id, job.schedule);\n}\n\n// Cancel a specific job\nawait agent.cancelCronJob(jobs[0].id);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Example: Heartbeat pattern\n\nSchedule a recurring agent session to periodically check on a task. This is the core pattern behind [OpenClaw](https://openclaw.org), where an agent wakes up on a schedule to review progress, take action, and go back to sleep.\n\n```ts\nawait agent.scheduleCron({\n schedule: \"*/30 * * * *\",\n overlap: \"skip\",\n action: {\n type: \"session\",\n agentType: \"pi\",\n prompt: \"Check the status of open issues and take any necessary action\",\n },\n});\n```\n\nThe agent sleeps between executions and only consumes resources when the cron job fires.\n\n## Recommendations\n\n- Use `\"skip\"` overlap mode for most jobs. This prevents unbounded concurrency if a job takes longer than the interval. The default is `\"allow\"`.\n- Use `\"queue\"` when every trigger must execute, even if they back up.\n- Cron jobs keep the actor alive while executing. The actor can sleep between executions.\n- Provide a custom `id` when scheduling to make it easier to manage and cancel jobs later.","src/content/docs/docs/cron.mdx","22419df3f27e683b","docs/core",{"id":80,"data":82,"body":88,"filePath":89,"digest":90,"deferredRender":16},{"title":83,"description":84,"editUrl":16,"head":85,"template":18,"sidebar":86,"pagefind":16,"draft":20},"Core Package","Use @rivet-dev/agent-os-core standalone for direct VM control without the Rivet Actor runtime.",[],{"hidden":20,"attrs":87},{},"## agentOS vs agentOS Core\n\nThe `agentOs()` actor (from `rivetkit/agent-os`) wraps the core package and adds:\n\n| | Core (`@rivet-dev/agent-os-core`) | Actor (`rivetkit/agent-os`) |\n|-|---|---|\n| Persistence | In-memory by default (pluggable via [mounts](#mounts)) | Persistent filesystem and sessions |\n| Distributed state | Manage yourself | Built-in distributed statefulness |\n| Stateful VMs | Complex to run yourself | Built into Rivet |\n| Sleep/wake | Manual `dispose()` / `create()` | Automatic |\n| Events | Direct callbacks | Broadcasted to all connected clients |\n| Preview URLs | None | Built-in signed URL server |\n| Multiplayer | N/A | Multiple clients on same actor |\n| Orchestration | N/A | Workflows, queues, cron |\n| Agent-to-agent communication | Custom | Built into [Rivet Actors](/docs/agent-to-agent) |\n| Authentication | Set up yourself | [Documentation](/docs/authentication) |\n\nWe recommend using [Rivet Actors](/docs/actors) because they provide a portable way to run agentOS on any infrastructure with built-in persistence, networking, and orchestration. Use the core package if you need the most bare-bones implementation possible.\n\n## Install\n\n```bash\nnpm install @rivet-dev/agent-os-core\n```\n\n## Boot a VM\n\n```ts\nimport { AgentOs } from \"@rivet-dev/agent-os-core\";\nimport common from \"@agent-os-pkgs/common\";\n\nconst vm = await AgentOs.create({\n software: [common],\n});\n\n// Run a command\nconst result = await vm.exec(\"echo hello\");\nconsole.log(result.stdout); // \"hello\\n\"\n\nawait vm.dispose();\n```\n\n## Filesystem\n\n```ts\nimport { AgentOs } from \"@rivet-dev/agent-os-core\";\nimport common from \"@agent-os-pkgs/common\";\n\nconst vm = await AgentOs.create({ software: [common] });\n\nawait vm.writeFile(\"/home/user/hello.txt\", \"Hello, world!\");\nconst content = await vm.readFile(\"/home/user/hello.txt\");\nconsole.log(new TextDecoder().decode(content));\n\nawait vm.mkdir(\"/home/user/src\");\nawait vm.writeFiles([\n { path: \"/home/user/src/index.ts\", content: \"console.log('hi');\" },\n { path: \"/home/user/src/utils.ts\", content: \"export const add = (a: number, b: number) => a + b;\" },\n]);\n\nconst entries = await vm.readdirRecursive(\"/home/user\");\nfor (const entry of entries) {\n console.log(entry.type, entry.path);\n}\n\nawait vm.dispose();\n```\n\n## Processes\n\n```ts\nimport { AgentOs } from \"@rivet-dev/agent-os-core\";\nimport common from \"@agent-os-pkgs/common\";\n\nconst vm = await AgentOs.create({ software: [common] });\n\n// One-shot execution\nconst result = await vm.exec(\"ls -la /home/user\");\nconsole.log(result.stdout);\n\n// Long-running process with streaming output\nawait vm.writeFile(\"/tmp/server.mjs\", 'import http from \"http\"; http.createServer((req, res) => res.end(\"ok\")).listen(3000); console.log(\"listening\");');\nconst proc = vm.spawn(\"node\", [\"/tmp/server.mjs\"]);\nvm.onProcessStdout(proc.pid, (data) => {\n console.log(\"stdout:\", new TextDecoder().decode(data));\n});\nvm.onProcessExit(proc.pid, (code) => {\n console.log(\"exited:\", code);\n});\n\n// Write to stdin\nvm.writeProcessStdin(proc.pid, \"some input\\n\");\n\n// Stop or kill\nvm.stopProcess(proc.pid);\n\nawait vm.dispose();\n```\n\n## Agent sessions\n\nThe core package returns a `sessionId` string. All session operations are called on the `vm` instance with the session ID.\n\n```ts\nimport { AgentOs } from \"@rivet-dev/agent-os-core\";\nimport common from \"@agent-os-pkgs/common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = await AgentOs.create({ software: [common, pi] });\n\nconst { sessionId } = await vm.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\n\n// Stream events (each event is a JSON-RPC notification)\nvm.onSessionEvent(sessionId, (event) => {\n console.log(event.method, event.params);\n});\n\n// Handle permissions\nvm.onPermissionRequest(sessionId, (request) => {\n console.log(\"Permission:\", request.description);\n // Reply with \"once\", \"always\", or \"reject\"\n vm.respondPermission(sessionId, request.permissionId, \"once\");\n});\n\n// Send a prompt. prompt() resolves to { response, text }, where `text` is the\n// accumulated agent message text and `response` is the raw JSON-RPC response.\nconst { text } = await vm.prompt(sessionId, \"Write a hello world script\");\nconsole.log(text);\n\n// Configure the session\nawait vm.setSessionModel(sessionId, \"claude-sonnet-4-6\");\nawait vm.setSessionMode(sessionId, \"plan\");\n\nvm.closeSession(sessionId);\nawait vm.dispose();\n```\n\nSession events are live-only: subscribe with `onSessionEvent()` before sending a\nprompt. There is no replay buffer or event history to read back after the fact.\n\n## Interactive shell\n\n```ts\nimport { AgentOs } from \"@rivet-dev/agent-os-core\";\nimport common from \"@agent-os-pkgs/common\";\n\nconst vm = await AgentOs.create({ software: [common] });\n\nconst { shellId } = vm.openShell();\n\nvm.onShellData(shellId, (data) => {\n process.stdout.write(new TextDecoder().decode(data));\n});\n\nvm.writeShell(shellId, \"echo hello from shell\\n\");\n\n// Resize terminal\nvm.resizeShell(shellId, 120, 40);\n\nvm.closeShell(shellId);\nawait vm.dispose();\n```\n\n## Networking\n\n```ts\nimport { AgentOs } from \"@rivet-dev/agent-os-core\";\nimport common from \"@agent-os-pkgs/common\";\n\nconst vm = await AgentOs.create({ software: [common] });\n\n// Start a server inside the VM\nawait vm.writeFile(\"/tmp/app.mjs\", 'import http from \"http\"; http.createServer((req, res) => res.end(\"hello\")).listen(3000);');\nvm.spawn(\"node\", [\"/tmp/app.mjs\"]);\n\n// Fetch from it\nconst response = await vm.fetch(3000, new Request(\"http://localhost/\"));\nconsole.log(await response.text());\n\nawait vm.dispose();\n```\n\n## Cron jobs\n\nThe core package supports a `\"callback\"` action type in addition to `\"exec\"` and `\"session\"`.\n\n```ts\nimport { AgentOs } from \"@rivet-dev/agent-os-core\";\nimport common from \"@agent-os-pkgs/common\";\n\nconst vm = await AgentOs.create({ software: [common] });\n\nconst job = vm.scheduleCron({\n id: \"cleanup\",\n schedule: \"0 * * * *\",\n action: { type: \"exec\", command: \"rm\", args: [\"-rf\", \"/tmp/cache\"] },\n});\n\n// Or use a callback (not available in the actor wrapper)\nvm.scheduleCron({\n schedule: \"*/5 * * * *\",\n action: {\n type: \"callback\",\n fn: async () => {\n console.log(\"Custom logic every 5 minutes\");\n },\n },\n});\n\nvm.onCronEvent((event) => {\n if (event.type === \"cron:fire\") console.log(\"Job fired:\", event.jobId);\n if (event.type === \"cron:complete\") console.log(\"Job done:\", event.jobId, event.durationMs, \"ms\");\n if (event.type === \"cron:error\") console.error(\"Job error:\", event.error);\n});\n\nconsole.log(vm.listCronJobs());\njob.cancel();\n\nawait vm.dispose();\n```\n\n## Mounts\n\nConfigure filesystem backends at boot time.\n\nNative mount plugins (host directories, S3, etc.) are passed via `plugin`, while\nJavaScript filesystem backends are passed via `driver`.\n\n```ts\nimport { AgentOs, createHostDirBackend, createInMemoryFileSystem } from \"@rivet-dev/agent-os-core\";\nimport { createS3Backend } from \"@secure-exec/s3\";\nimport common from \"@agent-os-pkgs/common\";\n\nconst vm = await AgentOs.create({\n software: [common],\n mounts: [\n // Host directory (read-only)\n { path: \"/mnt/code\", plugin: createHostDirBackend({ hostPath: \"/path/to/repo\", readOnly: true }) },\n // S3 bucket\n { path: \"/mnt/data\", plugin: createS3Backend({ bucket: \"my-bucket\", prefix: \"agent/\" }) },\n // In-memory scratch space\n { path: \"/mnt/scratch\", driver: createInMemoryFileSystem() },\n ],\n});\n\nconst files = await vm.readdir(\"/mnt/code\");\nconsole.log(files);\n\nawait vm.dispose();\n```\n\n## What you give up without the actor\n\n- **No built-in persistence.** The default filesystem is in-memory and lost on `dispose()`. You can configure your own [mounts](#mounts) (S3, host directories, etc.) for persistence.\n- **No sleep/wake.** You manage the full VM lifecycle yourself.\n- **No event broadcasting.** Events are local callbacks, not distributed to remote clients.\n- **No preview URLs.** No built-in HTTP server for sharing VM services.\n- **No multiplayer.** Single-process, single-client only.\n- **No orchestration.** No workflows, queues, or scheduling integration.\n- **No session persistence.** Session history is lost on dispose.\n\nIf you need any of these, use the [`agentOs()` actor](/docs/quickstart) instead.","src/content/docs/docs/core.mdx","e3cd4d1f9f9b1684","docs/deployment",{"id":91,"data":93,"body":99,"filePath":100,"digest":101,"deferredRender":16},{"title":94,"description":95,"editUrl":16,"head":96,"template":18,"sidebar":97,"pagefind":16,"draft":20},"Deployment","Choose the right deployment option for agentOS.",[],{"hidden":20,"attrs":98},{},"agentOS supports multiple deployment models depending on your needs.\n\n| Option | Description | Best for |\n|--------|-------------|----------|\n| **Local Dev** | Run locally with `npx rivetkit dev`. No infrastructure needed. | Development and testing |\n| **[Rivet Cloud](/cloud)** | Fully managed hosting on Rivet Compute or bring your own cloud (BYOC). | Teams that want zero-ops deployment |\n| **[Rivet Self-Hosted](/docs/self-hosting)** | Run the full Rivet platform on your own infrastructure. | Organizations that need full control |\n| **[Rivet Enterprise](/sales)** | Self-hosted with dedicated support, custom SLAs, and compliance reviews. | Regulated industries and large-scale deployments |\n| **agentOS Core** | Use `@rivet-dev/agent-os-core` directly without the Rivet platform. Embed the runtime in any Node.js backend. | Custom integrations and existing infrastructure |","src/content/docs/docs/deployment.mdx","a02615a45f9b750c","docs/events",{"id":102,"data":104,"body":110,"filePath":111,"digest":112,"deferredRender":16},{"title":105,"description":106,"editUrl":16,"head":107,"template":18,"sidebar":108,"pagefind":16,"draft":20},"Events","Full event catalog with payload shapes for agentOS.",[],{"hidden":20,"attrs":109},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n## Event types\n\n### sessionEvent\n\nEmitted for every agent session event (streaming output, errors, status changes).\n\n```ts\nagent.on(\"sessionEvent\", (data) => {\n // data.sessionId: string\n // data.event: JsonRpcNotification (method, params)\n console.log(data.sessionId, data.event.method, data.event.params);\n});\n```\n\nEvents are also persisted to SQLite for replay via `getSessionEvents`.\n\n### permissionRequest\n\nEmitted when an agent requests permission to use a tool.\n\n```ts\nagent.on(\"permissionRequest\", async (data) => {\n // data.sessionId: string\n // data.request: PermissionRequest (permissionId, description, params)\n console.log(\"Permission requested:\", data.request);\n\n await agent.respondPermission(data.sessionId, data.request.permissionId, \"once\");\n});\n```\n\nSee [Permissions](/docs/permissions) for approval patterns.\n\n### processOutput\n\nEmitted when a spawned process writes to stdout or stderr.\n\n```ts\nagent.on(\"processOutput\", (data) => {\n // data.pid: number\n // data.stream: \"stdout\" | \"stderr\"\n // data.data: Uint8Array\n const text = new TextDecoder().decode(data.data);\n console.log(`[${data.pid}] ${data.stream}: ${text}`);\n});\n```\n\n### processExit\n\nEmitted when a spawned process exits.\n\n```ts\nagent.on(\"processExit\", (data) => {\n // data.pid: number\n // data.exitCode: number\n console.log(`Process ${data.pid} exited with code ${data.exitCode}`);\n});\n```\n\n### shellData\n\nEmitted when an interactive shell produces output.\n\n```ts\nagent.on(\"shellData\", (data) => {\n // data.shellId: string\n // data.data: Uint8Array\n const text = new TextDecoder().decode(data.data);\n process.stdout.write(text);\n});\n```\n\n### cronEvent\n\nEmitted when a cron job runs.\n\n```ts\nagent.on(\"cronEvent\", (data) => {\n // data.event: CronEvent\n console.log(\"Cron event:\", data.event);\n});\n```\n\n### vmBooted\n\nEmitted when the VM finishes booting. No payload.\n\n```ts\nagent.on(\"vmBooted\", () => {\n console.log(\"VM is ready\");\n});\n```\n\n### vmShutdown\n\nEmitted when the VM is shutting down.\n\n```ts\nagent.on(\"vmShutdown\", (data) => {\n // data.reason: \"sleep\" | \"destroy\" | \"error\"\n console.log(\"VM shutting down:\", data.reason);\n});\n```\n\n## Client subscription pattern\n\nSubscribe to events before triggering actions to avoid missing early events.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Subscribe to all relevant events first\nagent.on(\"sessionEvent\", (data) => {\n console.log(\"Session:\", data.event.method);\n});\nagent.on(\"processOutput\", (data) => {\n console.log(\"Process:\", new TextDecoder().decode(data.data));\n});\nagent.on(\"processExit\", (data) => {\n console.log(\"Exit:\", data.pid, data.exitCode);\n});\n\n// Then trigger actions\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait agent.sendPrompt(session.sessionId, \"Run the test suite\");\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Event replay\n\nThere are two ways to replay session events:\n\n- **`getSequencedEvents`** returns events from the in-memory session. Each event has a `sequenceNumber` and a `notification` (the raw JSON-RPC notification). Use this for live reconnection while the VM is running.\n- **`getSessionEvents`** returns events from persisted storage (SQLite). Each event has a `seq`, `event`, and `createdAt`. Use this for transcript history, including when the VM is not running.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Replay events from sequence 0\nconst events = await agent.getSequencedEvents(\"session-id\", { since: 0 });\nfor (const e of events) {\n console.log(e.sequenceNumber, e.notification.method);\n}\n\n// Replay from persisted storage (works without running VM)\nconst persisted = await agent.getSessionEvents(\"session-id\");\nfor (const e of persisted) {\n console.log(e.seq, e.event.method, e.createdAt);\n}\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>","src/content/docs/docs/events.mdx","17dd9e05d4d11190","docs/filesystem",{"id":113,"data":115,"body":121,"filePath":122,"digest":123,"deferredRender":16},{"title":116,"description":117,"editUrl":16,"head":118,"template":18,"sidebar":119,"pagefind":16,"draft":20},"Filesystem","Read, write, mount, and manage files inside agentOS.",[],{"hidden":20,"attrs":120},{},"Every VM comes with a persistent filesystem out of the box. Files written anywhere in the VM are automatically saved to the Rivet Actor's built-in storage and restored on wake. No configuration needed.\n\n- **Persistent by default** backed by Rivet Actor storage, up to 10 GB\n- **Full POSIX filesystem** with read, write, mkdir, stat, move, delete\n- **Batch operations** for reading and writing multiple files at once\n- **Mount backends** for additional storage like S3, host directories, and overlays\n\n## Mounting filesystems\n\nThe default filesystem persists automatically across sleep/wake cycles with no setup required (up to 10 GB). For larger storage or external data, mount additional filesystem drivers via the `options.mounts` config.\n\nEach mount takes a `path` (where to mount inside the VM) and an optional `readOnly` flag. JavaScript filesystem backends like `createInMemoryFileSystem()` are passed as `driver`, while native plugin backends like `createHostDirBackend()`, `createS3Backend()`, and `createGoogleDriveBackend()` are passed as `plugin`.\n\n### In-memory\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport { createInMemoryFileSystem } from \"@rivet-dev/agent-os-core\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: {\n software: [common, pi],\n mounts: [\n { path: \"/mnt/scratch\", driver: createInMemoryFileSystem() },\n ],\n },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n### Host directory\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport { createHostDirBackend } from \"@rivet-dev/agent-os-core\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: {\n software: [common, pi],\n mounts: [\n { path: \"/mnt/code\", plugin: createHostDirBackend({ hostPath: \"/path/to/repo\" }), readOnly: true },\n ],\n },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n### S3\n\nInstall `@rivet-dev/agent-os-s3` for S3-compatible storage.\n\nUse `createS3Backend` to mount a bucket. Pass an optional `prefix` to scope storage to a key path within the bucket — useful for sharing one bucket across multiple agents.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport { createS3Backend } from \"@rivet-dev/agent-os-s3\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: {\n software: [common, pi],\n mounts: [\n {\n path: \"/mnt/data\",\n plugin: createS3Backend({\n bucket: \"my-bucket\",\n prefix: \"agent-data/\",\n region: \"us-east-1\",\n }),\n },\n ],\n },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n`createS3Backend` also accepts `credentials` (`{ accessKeyId, secretAccessKey }`) and a custom `endpoint` for S3-compatible providers.\n\n### Google Drive\n\nInstall `@rivet-dev/agent-os-google-drive` for Google Drive storage.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport { createGoogleDriveBackend } from \"@rivet-dev/agent-os-google-drive\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: {\n software: [common, pi],\n mounts: [\n {\n path: \"/mnt/drive\",\n plugin: createGoogleDriveBackend({\n credentials: {\n clientEmail: process.env.GOOGLE_DRIVE_CLIENT_EMAIL!,\n privateKey: process.env.GOOGLE_DRIVE_PRIVATE_KEY!,\n },\n folderId: process.env.GOOGLE_DRIVE_FOLDER_ID!,\n }),\n },\n ],\n },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n## Filesystem operations\n\n### Read and write\n\n```ts\n// Write a file (string or Uint8Array)\nawait agent.writeFile(\"/home/user/hello.txt\", \"Hello, world!\");\n\n// Read a file (returns Uint8Array)\nconst content = await agent.readFile(\"/home/user/hello.txt\");\nconsole.log(new TextDecoder().decode(content));\n```\n\n### Batch read and write\n\n```ts\n// Batch write (creates parent directories automatically)\nconst writeResults = await agent.writeFiles([\n { path: \"/home/user/src/index.ts\", content: \"console.log('hello');\" },\n { path: \"/home/user/src/utils.ts\", content: \"export function add(a: number, b: number) { return a + b; }\" },\n]);\n\n// Batch read\nconst readResults = await agent.readFiles([\n \"/home/user/src/index.ts\",\n \"/home/user/src/utils.ts\",\n]);\nfor (const result of readResults) {\n console.log(result.path, new TextDecoder().decode(result.content));\n}\n```\n\n### Directories\n\n```ts\n// Create a directory\nawait agent.mkdir(\"/home/user/projects\");\n\n// List directory contents\nconst entries = await agent.readdir(\"/home/user/projects\");\n\n// Recursive listing with metadata\nconst tree = await agent.readdirRecursive(\"/home/user\", {\n maxDepth: 3,\n exclude: [\"node_modules\"],\n});\nfor (const entry of tree) {\n console.log(entry.type, entry.path, entry.size);\n}\n```\n\n### File metadata\n\n```ts\n// Check if a path exists\nconst fileExists = await agent.exists(\"/home/user/hello.txt\");\n\n// Get file metadata\nconst info = await agent.stat(\"/home/user/hello.txt\");\nconsole.log(info.size, info.isDirectory, info.mtimeMs);\n```\n\n### Move and delete\n\n```ts\n// Move/rename\nawait agent.move(\"/home/user/old.txt\", \"/home/user/new.txt\");\n\n// Delete a file\nawait agent.delete(\"/home/user/new.txt\");\n\n// Delete a directory recursively\nawait agent.delete(\"/home/user/temp\", { recursive: true });\n```","src/content/docs/docs/filesystem.mdx","fdbebac3bb8126bc",{"id":9,"data":125,"body":130,"filePath":131,"digest":132,"deferredRender":16},{"title":126,"description":62,"editUrl":16,"head":127,"tableOfContents":20,"template":18,"sidebar":128,"pagefind":16,"draft":20},"Introduction",[],{"hidden":20,"attrs":129},{},"import DocsLanding from '@rivet-dev/docs-theme/components/DocsLanding.astro';\nimport AgentOSHeroLogo from '../../../components/AgentOSHeroLogo.astro';\n\n\u003CDocsLanding>\n\t\u003CAgentOSHeroLogo slot=\"hero\" />\n\u003C/DocsLanding>","src/content/docs/docs/index.mdx","352df9dbb91266b5","docs/limitations",{"id":133,"data":135,"body":141,"filePath":142,"digest":143,"deferredRender":16},{"title":136,"description":137,"editUrl":16,"head":138,"template":18,"sidebar":139,"pagefind":16,"draft":20},"Limitations","What the agentOS VM does not support, and how to work around it.",[],{"hidden":20,"attrs":140},{},"agentOS is a Linux-like environment with a POSIX-compliant virtual kernel. It handles most agent workloads (coding, scripting, file I/O, networking) with near-zero overhead.\n\n## Sandbox mounting\n\nWhen a workload needs a full Linux OS, agents can escalate to a full sandbox on demand without changing code. The [sandbox mounting](/docs/sandbox) extension mounts the sandbox as a filesystem and lets you execute commands on it, like mounting a hard drive on your own machine. Files written in the VM are available in the sandbox and vice versa.\n\nSee [agentOS vs Sandbox](/docs/versus-sandbox) for a detailed comparison.\n\n## Limitations\n\n### Software registry\n\nagentOS uses its own [software registry](/agent-os/registry) of popular tools cross-compiled for the runtime. You cannot download and install arbitrary binaries (for example via `curl` or `apt`), and standard Linux package managers (`apt`, `yum`) are not available since agentOS is not a full Linux OS. Native binaries that are not yet available in the registry (such as Go, Rust, or C++ toolchains) require a full [sandbox](/docs/sandbox).\n\nSee [Software](/docs/software) for how to install and configure available packages.\n\n### POSIX-compliant, not full Linux\n\nagentOS provides a POSIX-compliant virtual kernel with full filesystem operations, networking, and process management. It is not a full Linux kernel, so some Linux-specific features are not available:\n\n- Kernel modules and eBPF\n- Container runtimes (e.g. Docker)\n- File watching (`inotify`, `fs.watch`)\n\n### No hardware access\n\nThe VM has no access to GPUs, USB devices, or other hardware.","src/content/docs/docs/limitations.mdx","22dd054a560a7bea","docs/llm-credentials",{"id":144,"data":146,"body":152,"filePath":153,"digest":154,"deferredRender":16},{"title":147,"description":148,"editUrl":16,"head":149,"template":18,"sidebar":150,"pagefind":16,"draft":20},"LLM Credentials","Pass LLM API keys to agent sessions securely.",[],{"hidden":20,"attrs":151},{},"- **Keys stay on the server** and are injected at session creation\n- **Per-tenant isolation** for multi-tenant deployments\n\n## Passing API keys\n\nPass LLM provider keys via the `env` option on `createSession`. The VM does not inherit from the host `process.env`, so keys must be passed explicitly.\n\n```ts\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\n```\n\n## Per-tenant credentials\n\nLook up each tenant's API key from your database and pass it at session creation.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup, UserError } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\ninterface ConnState {\n userId: string;\n anthropicApiKey: string;\n}\n\nconst vm = agentOs({\n createConnState: async (c, params: { authToken: string }): Promise\u003CConnState> => {\n const user = await validateAndLookupUser(params.authToken);\n if (!user) {\n throw new UserError(\"Forbidden\", { code: \"forbidden\" });\n }\n return { userId: user.id, anthropicApiKey: user.anthropicApiKey };\n },\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\nThen use the connection state when creating sessions:\n\n```ts\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: c.conn.state.anthropicApiKey },\n});\n```\n\nSee [Sandbox Agent's LLM credentials documentation](https://sandboxagent.dev/docs/llm-credentials) for more details on per-tenant token patterns.\n\n## Embedded LLM Gateway\n\nThe [Embedded LLM Gateway](/docs/llm-gateway) (coming soon) will remove the need to manage API keys manually. It routes all agent LLM requests through a managed proxy built into agentOS, providing per-tenant usage metering, rate limiting, and cost controls without deploying a separate gateway service.","src/content/docs/docs/llm-credentials.mdx","fbc8cc10fbfd4b55","docs/llm-gateway",{"id":155,"data":157,"body":163,"filePath":164,"digest":165,"deferredRender":16},{"title":158,"description":159,"editUrl":16,"head":160,"template":18,"sidebar":161,"pagefind":16,"draft":20},"Embedded LLM Gateway","Route, meter, and manage LLM API calls from agents.",[],{"hidden":20,"attrs":162},{},"{/* TODO: This page is coming soon. */}\n\nThe Embedded LLM Gateway runs as part of the agentOS library, not as an external service. It intercepts and manages all LLM API calls made by agents inside the VM.\n\n- **Unified routing** for all agent LLM requests\n- **API keys stay on the server** so they are never exposed to agent code inside the VM\n- **Usage metering** with per-session and per-agent breakdowns\n- **Rate limiting** and cost controls\n\nCheck back soon for full documentation.","src/content/docs/docs/llm-gateway.mdx","cbe27275943ef64e","docs/multiplayer",{"id":166,"data":168,"body":174,"filePath":175,"digest":176,"deferredRender":16},{"title":169,"description":170,"editUrl":16,"head":171,"template":18,"sidebar":172,"pagefind":16,"draft":20},"Multiplayer","Connect multiple clients to the same agentOS actor for collaborative agent workflows.",[],{"hidden":20,"attrs":173},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n- **Multiple clients** connected to the same agent VM simultaneously\n- **Broadcast events** so all subscribers see session output, process logs, and shell data\n- **Collaborative patterns** where one user prompts and others observe\n- **Handoff** between human and agent control\n\n## Multiple clients observing a session\n\nAll clients connected to the same actor receive broadcasted events. This enables building collaborative UIs where multiple users watch an agent work.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\n// Client A: creates the session and sends prompts\nconst clientA = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agentA = clientA.vm.getOrCreate([\"shared-agent\"]);\n\nagentA.on(\"sessionEvent\", (data) => {\n console.log(\"[A]\", data.event.method);\n});\n\nconst session = await agentA.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait agentA.sendPrompt(session.sessionId, \"Build a REST API\");\n\n// Client B: observes the same session (in a separate process)\nconst clientB = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agentB = clientB.vm.getOrCreate([\"shared-agent\"]);\n\nagentB.on(\"sessionEvent\", (data) => {\n console.log(\"[B]\", data.event.method);\n});\n\n// Client B sees the same events as Client A\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Shared process output\n\nAll clients receive process output events from the same VM.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"shared-agent\"]);\n\n// All connected clients see process output\nagent.on(\"processOutput\", (data) => {\n const text = new TextDecoder().decode(data.data);\n console.log(`[pid ${data.pid}] ${data.stream}: ${text}`);\n});\n\n// All connected clients see shell data\nagent.on(\"shellData\", (data) => {\n const text = new TextDecoder().decode(data.data);\n process.stdout.write(text);\n});\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Collaborative prompt/observe pattern\n\nOne client acts as the driver (sending prompts), while others observe.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n onSessionEvent: async (c, sessionId, event) => {\n // Server-side hook runs once per event, even with multiple clients\n console.log(\"Session event:\", sessionId, event.method);\n },\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\n// Driver client\nconst driver = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst driverAgent = driver.vm.getOrCreate([\"shared-agent\"]);\n\nconst session = await driverAgent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\n\n// Observer client (different user, same actor)\nconst observer = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst observerAgent = observer.vm.getOrCreate([\"shared-agent\"]);\n\nobserverAgent.on(\"sessionEvent\", (data) => {\n console.log(\"[observer]\", data.event.method, data.event.params);\n});\n\n// Driver sends a prompt. Observer sees the streaming response.\nawait driverAgent.sendPrompt(session.sessionId, \"Refactor the auth module\");\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Reconnection with event replay\n\nWhen a client reconnects, use `getSequencedEvents` to replay missed events and catch up.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"shared-agent\"]);\n\n// On reconnect, replay events from the last known sequence number\nconst lastSeq = 42; // Track this on the client side\nconst missedEvents = await agent.getSequencedEvents(\"session-id\", {\n since: lastSeq,\n});\nfor (const event of missedEvents) {\n console.log(\"Replaying:\", event.sequenceNumber, event.notification.method);\n}\n\n// Resume live streaming\nagent.on(\"sessionEvent\", (data) => {\n console.log(\"Live:\", data.event.method);\n});\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Recommendations\n\n- Use the same actor key (e.g. `[\"shared-agent\"]`) for all clients that should share the same VM.\n- Events are broadcasted to all connected clients automatically. No additional setup needed.\n- For reconnection, track the last sequence number on the client and use `getSequencedEvents` to replay missed events.\n- Use the server-side `onSessionEvent` hook for logic that should run once per event regardless of connected clients.","src/content/docs/docs/multiplayer.mdx","a25670dee66aec00","docs/networking",{"id":177,"data":179,"body":185,"filePath":186,"digest":187,"deferredRender":16},{"title":180,"description":181,"editUrl":16,"head":182,"template":18,"sidebar":183,"pagefind":16,"draft":20},"Networking & Previews","Proxy HTTP requests into agentOS VMs and create shareable preview URLs.",[],{"hidden":20,"attrs":184},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n- **`vmFetch`** proxies HTTP requests to services running inside the VM\n- **Preview URLs** create time-limited, shareable public URLs to VM services\n- **Token-based access** with configurable expiration and revocation\n- **CORS enabled** for browser access to preview URLs\n\n## Fetch from a VM service\n\nUse `vmFetch` to send HTTP requests to a service running inside the VM.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Start a web server inside the VM\nawait agent.writeFile(\n \"/home/user/server.js\",\n 'require(\"http\").createServer((req, res) => res.end(\"Hello from VM\")).listen(3000);',\n);\nawait agent.spawn(\"node\", [\"/home/user/server.js\"]);\n\n// Fetch from the VM service\nconst response = await agent.vmFetch(3000, \"/\");\nconsole.log(\"Status:\", response.status);\nconsole.log(\"Body:\", new TextDecoder().decode(response.body));\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## vmFetch with options\n\nSend requests with custom methods, headers, and body.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst response = await agent.vmFetch(3000, \"/api/data\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ key: \"value\" }),\n});\n\nconsole.log(\"Status:\", response.status, response.statusText);\nconsole.log(\"Headers:\", response.headers);\nconsole.log(\"Body:\", new TextDecoder().decode(response.body));\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Create a preview URL\n\nPreview URLs are essentially port forwarding for VM services. They create a time-limited, publicly accessible URL that proxies HTTP requests to a specific port inside the VM. Use them to share web app previews with users, embed dev servers in iframes, or give external tools access to services running inside the agent's VM.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n preview: {\n defaultExpiresInSeconds: 3600, // 1 hour default\n maxExpiresInSeconds: 86400, // 24 hour maximum\n },\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Start a web app in the VM\nawait agent.spawn(\"node\", [\"/home/user/app.js\"]);\n\n// Create a preview URL (default 1 hour expiration)\nconst preview = await agent.createSignedPreviewUrl(3000);\nconsole.log(\"Preview path:\", preview.path);\nconsole.log(\"Token:\", preview.token);\nconsole.log(\"Expires at:\", new Date(preview.expiresAt));\n\n// Create a preview URL with custom expiration\nconst shortPreview = await agent.createSignedPreviewUrl(3000, 300); // 5 minutes\nconsole.log(\"Short-lived preview:\", shortPreview.path);\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Revoke a preview URL\n\nUse `expireSignedPreviewUrl` to immediately revoke a preview token.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst preview = await agent.createSignedPreviewUrl(3000);\n\n// Revoke the token immediately\nawait agent.expireSignedPreviewUrl(preview.token);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Recommendations\n\n- Preview tokens are stored in SQLite and survive sleep/wake cycles. Expired tokens are cleaned up automatically.\n- Default preview expiration is 1 hour. Configure `preview.maxExpiresInSeconds` to cap the maximum lifetime.\n- CORS is enabled on preview URLs, allowing browser access from any origin.\n- Use `vmFetch` for server-to-server access. Use preview URLs for browser or external access.\n- See [Security](/docs/security) for more on preview URL token security.","src/content/docs/docs/networking.mdx","4961c067c2010ae4","docs/permissions",{"id":188,"data":190,"body":196,"filePath":197,"digest":198,"deferredRender":16},{"title":191,"description":192,"editUrl":16,"head":193,"template":18,"sidebar":194,"pagefind":16,"draft":20},"Permissions","Approve or deny agent tool use with human-in-the-loop or auto-approve patterns.",[],{"hidden":20,"attrs":195},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n- **Human-in-the-loop** approval for agent tool use (file writes, command execution, etc.)\n- **Auto-approve** patterns for trusted workloads\n- **Server-side hooks** for programmatic permission decisions\n- **Client-side subscriptions** for building approval UIs\n\n## Permission request flow\n\nWhen an agent wants to use a tool (e.g. write a file, run a command), it emits a `permissionRequest` event. Your code responds with `respondPermission` to approve or deny.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Listen for permission requests\nagent.on(\"permissionRequest\", async (data) => {\n console.log(\"Permission requested:\", data.request);\n\n // Approve this single request\n await agent.respondPermission(\n data.sessionId,\n data.request.permissionId,\n \"once\",\n );\n});\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait agent.sendPrompt(session.sessionId, \"Create a new file at /home/user/output.txt\");\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Permission reply options\n\n| Reply | Behavior |\n|-------|----------|\n| `\"once\"` | Approve this single request |\n| `\"always\"` | Approve this and all future requests of the same type |\n| `\"reject\"` | Deny the request |\n\n## Server-side auto-approve\n\nUse the `onPermissionRequest` hook in the actor config to approve permissions server-side without client involvement. This is useful for fully automated pipelines.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n onPermissionRequest: async (c, sessionId, request) => {\n // Auto-approve all file operations\n await c.respondPermission(sessionId, request.permissionId, \"always\");\n },\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// No need to handle permissions on the client. The server auto-approves.\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait agent.sendPrompt(session.sessionId, \"Write files as needed\");\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Selective approval\n\nInspect the permission request to make approval decisions based on the tool or path.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n onPermissionRequest: async (c, sessionId, request) => {\n // `request.description` and `request.params` carry the raw ACP\n // permission details (the requested tool, paths, etc.).\n // Auto-approve reads, require manual approval for writes.\n const description = request.description ?? \"\";\n if (description.toLowerCase().includes(\"read\")) {\n await c.respondPermission(sessionId, request.permissionId, \"always\");\n }\n // Anything not handled here is forwarded to the client via the\n // permissionRequest event.\n },\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Only write permissions reach the client\nagent.on(\"permissionRequest\", async (data) => {\n const approved = confirm(`Allow write: ${JSON.stringify(data.request)}?`);\n await agent.respondPermission(\n data.sessionId,\n data.request.permissionId,\n approved ? \"once\" : \"reject\",\n );\n});\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait agent.sendPrompt(session.sessionId, \"Read config.json and update it\");\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Recommendations\n\n- Use `\"always\"` sparingly. It approves all future requests of that type for the session lifetime.\n- For automated CI/CD pipelines, use the server-side `onPermissionRequest` hook to auto-approve without client round-trips.\n- For interactive applications, subscribe to `permissionRequest` on the client and build an approval UI.\n- If neither the server hook nor the client responds, the agent blocks until a response is given or the action times out.","src/content/docs/docs/permissions.mdx","5585f46f524d390a","docs/persistence",{"id":199,"data":201,"body":207,"filePath":208,"digest":209,"deferredRender":16},{"title":202,"description":203,"editUrl":16,"head":204,"template":18,"sidebar":205,"pagefind":16,"draft":20},"Persistence & Sleep","How agentOS persists data and manages sleep/wake cycles.",[],{"hidden":20,"attrs":206},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n- **Persistent filesystem** backs `/home/user` automatically\n- **Session transcripts** persisted with sequence numbers for replay\n- **Configurable sleep** with a 15-minute grace period by default\n- **Automatic wake** when a client connects or a cron job triggers\n\n## What persists across sleep\n\n| Data | Storage | Persists? |\n|------|---------|-----------|\n| Files in `/home/user` | Persistent filesystem | Yes |\n| Session records | SQLite (`agent_os_sessions`) | Yes |\n| Session event history | SQLite (`agent_os_session_events`) | Yes |\n| Preview URL tokens | SQLite (`agent_os_preview_tokens`) | Yes |\n| Cron job definitions | Actor state | Yes |\n| Running processes | VM kernel | No |\n| Active shells | VM kernel | No |\n| In-memory mounts | VM memory | No |\n| VM kernel state | VM memory | No |\n\n## What prevents sleep\n\nThe actor stays awake as long as any of these are active:\n\n- **Active sessions** (created but not closed/destroyed)\n- **Running processes** (spawned but not exited)\n- **Active shells** (opened but not closed)\n- **Pending hooks** (server-side callbacks still executing)\n\nWhen all activity stops, the sleep grace period begins.\n\n## Sleep grace period\n\nAfter all activity stops, the actor waits 15 minutes before sleeping. This allows for brief pauses between interactions without restarting the VM.\n\n```\nActivity stops ──> 15 min grace period ──> Actor sleeps\n (VM shutdown, processes killed)\n\nNew client connects ──> Actor wakes ──> VM boots ──> Filesystem restored\n```\n\n## Sleep vs destroy\n\n| | Sleep | Destroy |\n|-|-------|---------|\n| Filesystem | Preserved | Deleted |\n| Session records | Preserved | Deleted |\n| Event history | Preserved | Deleted |\n| Preview tokens | Preserved | Deleted |\n| VM state | Lost | Lost |\n| Processes | Killed | Killed |\n\n## VM boot and shutdown events\n\nSubscribe to `vmBooted` and `vmShutdown` events to track VM lifecycle.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nagent.on(\"vmBooted\", () => {\n console.log(\"VM is ready\");\n});\n\nagent.on(\"vmShutdown\", (data) => {\n console.log(\"VM shutdown reason:\", data.reason);\n // reason: \"sleep\" | \"destroy\" | \"error\"\n});\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Resuming after sleep\n\nWhen the actor wakes up:\n\n1. The VM boots and the filesystem is restored from SQLite\n2. Session records and event history are available immediately\n3. Processes and shells from the previous session are gone\n4. Clients can reconnect and resume sessions using `resumeSession`\n5. Use `getSessionEvents` to replay missed events\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// List sessions from before sleep\nconst sessions = await agent.listPersistedSessions();\nconsole.log(\"Previous sessions:\", sessions.length);\n\n// Resume the most recent session\nif (sessions.length > 0) {\n const last = sessions[0];\n await agent.resumeSession(last.sessionId);\n\n // Replay events for transcript\n const events = await agent.getSessionEvents(last.sessionId);\n for (const e of events) {\n console.log(e.seq, e.event.method);\n }\n}\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Persisted tables schema\n\n### `agent_os_fs_entries`\n\nStores the virtual filesystem.\n\n| Column | Type | Description |\n|--------|------|-------------|\n| `path` | TEXT PRIMARY KEY | File or directory path |\n| `is_directory` | INTEGER | 1 for directory, 0 for file |\n| `content` | BLOB | File content |\n| `mode` | INTEGER | POSIX mode bits |\n| `size` | INTEGER | File size in bytes |\n| `atime_ms` | INTEGER | Access time (ms) |\n| `mtime_ms` | INTEGER | Modification time (ms) |\n| `ctime_ms` | INTEGER | Change time (ms) |\n| `birthtime_ms` | INTEGER | Birth time (ms) |\n\n### `agent_os_sessions`\n\nStores session metadata.\n\n| Column | Type | Description |\n|--------|------|-------------|\n| `session_id` | TEXT PRIMARY KEY | Unique session identifier |\n| `agent_type` | TEXT | Agent type (e.g. \"pi\") |\n| `capabilities` | TEXT (JSON) | Agent capabilities |\n| `agent_info` | TEXT (JSON) | Agent metadata |\n| `created_at` | INTEGER | Creation timestamp (ms) |\n\n### `agent_os_session_events`\n\nStores session event history.\n\n| Column | Type | Description |\n|--------|------|-------------|\n| `id` | INTEGER PRIMARY KEY | Auto-incrementing ID |\n| `session_id` | TEXT | Session reference |\n| `seq` | INTEGER | Sequence number within session |\n| `event` | TEXT (JSON) | JSON-RPC notification |\n| `created_at` | INTEGER | Timestamp (ms) |","src/content/docs/docs/persistence.mdx","cb2236feafb41fad","docs/processes",{"id":210,"data":212,"body":218,"filePath":219,"digest":220,"deferredRender":16},{"title":213,"description":214,"editUrl":16,"head":215,"template":18,"sidebar":216,"pagefind":16,"draft":20},"Processes & Shell","Execute commands, spawn long-running processes, and open interactive shells in agentOS VMs.",[],{"hidden":20,"attrs":217},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n- **One-shot execution** with `exec` for simple commands\n- **Long-running processes** with `spawn`, stdout/stderr streaming, and stdin writing\n- **Process lifecycle** management with stop, kill, wait, and inspect\n- **Interactive shells** with PTY support for terminal I/O\n- **Process tree** visibility across all VM runtimes\n\n## One-shot execution\n\nUse `exec` to run a command and wait for completion. Returns stdout, stderr, and exit code.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst result = await agent.exec(\"echo hello && ls /home/user\");\nconsole.log(\"stdout:\", result.stdout);\nconsole.log(\"stderr:\", result.stderr);\nconsole.log(\"exit code:\", result.exitCode);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Spawn a long-running process\n\nUse `spawn` for processes that run in the background. Output is streamed via `processOutput` and `processExit` events.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Subscribe to process output\nagent.on(\"processOutput\", (data) => {\n const text = new TextDecoder().decode(data.data);\n console.log(`[pid ${data.pid}] ${data.stream}: ${text}`);\n});\n\nagent.on(\"processExit\", (data) => {\n console.log(`[pid ${data.pid}] exited with code ${data.exitCode}`);\n});\n\n// Spawn a dev server\nconst { pid } = await agent.spawn(\"node\", [\"/home/user/server.js\"]);\nconsole.log(\"Started process:\", pid);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Write to stdin\n\nSend input to a running process.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst { pid } = await agent.spawn(\"cat\", []);\n\n// Write to stdin\nawait agent.writeProcessStdin(pid, \"hello from stdin\\n\");\n\n// Close stdin when done\nawait agent.closeProcessStdin(pid);\n\n// Wait for the process to exit\nconst exitCode = await agent.waitProcess(pid);\nconsole.log(\"exit code:\", exitCode);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Process lifecycle\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst { pid } = await agent.spawn(\"node\", [\"/home/user/server.js\"]);\n\n// List all spawned processes\nconst processes = await agent.listProcesses();\nconsole.log(processes);\n\n// Get info about a specific process\nconst info = await agent.getProcess(pid);\nconsole.log(info.running, info.exitCode);\n\n// Graceful stop (SIGTERM)\nawait agent.stopProcess(pid);\n\n// Force kill (SIGKILL)\nawait agent.killProcess(pid);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## System-wide process visibility\n\nView all processes across all VM runtimes, not just those started via `spawn`.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// All processes\nconst all = await agent.allProcesses();\nfor (const p of all) {\n console.log(p.pid, p.driver, p.command, p.status);\n}\n\n// Process tree (parent-child hierarchy)\nconst tree = await agent.processTree();\nfor (const node of tree) {\n console.log(node.pid, node.command, \"children:\", node.children.length);\n}\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Interactive shells\n\nOpen an interactive shell with PTY support. Shell data is streamed via `shellData` events.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Subscribe to shell output\nagent.on(\"shellData\", (data) => {\n const text = new TextDecoder().decode(data.data);\n process.stdout.write(text);\n});\n\n// Open a shell\nconst { shellId } = await agent.openShell();\n\n// Write commands to the shell\nawait agent.writeShell(shellId, \"ls -la /home/user\\n\");\n\n// Resize the terminal\nawait agent.resizeShell(shellId, 120, 40);\n\n// Close the shell when done\nawait agent.closeShell(shellId);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Recommendations\n\n- Use `exec` for short commands where you need the full output. Use `spawn` for long-running processes where you want streaming output.\n- Subscribe to `processOutput` and `processExit` **before** calling `spawn` to avoid missing events.\n- Active processes prevent the actor from sleeping. Stop or kill them when they are no longer needed.\n- Active shells also prevent sleep. Close shells when the user disconnects.\n- Use `allProcesses` and `processTree` for debugging. They show everything running in the VM, including agent processes.","src/content/docs/docs/processes.mdx","644e95aada2aa3a6","docs/queues",{"id":221,"data":223,"body":229,"filePath":230,"digest":231,"deferredRender":16},{"title":224,"description":225,"editUrl":16,"head":226,"template":18,"sidebar":227,"pagefind":16,"draft":20},"Queues","Serialize agent work with durable queues for backpressure and rate limiting.",[],{"hidden":20,"attrs":228},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n- **Serial execution** ensures agents process one task at a time\n- **Durable messages** survive sleep and restart\n- **Completable messages** for request/response patterns with agents\n- **Backpressure** absorbs bursts and prevents overload\n\n## Queue agent commands\n\nUse actor queues to serialize work that an agent processes one task at a time.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\nimport { actor, queue, setup } from \"rivetkit\";\n\nconst taskRunner = actor({\n queues: {\n tasks: queue\u003C{ prompt: string }>(),\n },\n run: async (c) => {\n const agentHandle = c.actors.vm.getOrCreate([\"task-agent\"]);\n\n for await (const message of c.queue.iter()) {\n // Process one task at a time\n const session = await agentHandle.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n await agentHandle.sendPrompt(session.sessionId, message.body.prompt);\n await agentHandle.closeSession(session.sessionId);\n }\n },\n});\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { taskRunner, vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst handle = client.taskRunner.getOrCreate([\"main\"]);\n\n// Queue up work. Tasks are processed one at a time.\nawait handle.send(\"tasks\", { prompt: \"Review PR #123\" });\nawait handle.send(\"tasks\", { prompt: \"Fix the flaky test in auth.test.ts\" });\nawait handle.send(\"tasks\", { prompt: \"Update the README\" });\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Request/response with completable messages\n\nUse completable messages when the caller needs to wait for the agent to finish.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\nimport { actor, queue, setup } from \"rivetkit\";\n\nconst reviewer = actor({\n queues: {\n review: queue\u003C{ file: string }, { summary: string }>(),\n },\n run: async (c) => {\n const agentHandle = c.actors.vm.getOrCreate([\"reviewer\"]);\n const session = await agentHandle.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n\n for await (const message of c.queue.iter({ completable: true })) {\n const content = await agentHandle.readFile(message.body.file);\n const text = new TextDecoder().decode(content);\n\n await agentHandle.sendPrompt(\n session.sessionId,\n `Review this code and write a summary to /home/user/review.txt:\\n\\n${text}`,\n );\n\n const review = await agentHandle.readFile(\"/home/user/review.txt\");\n await message.complete({\n summary: new TextDecoder().decode(review),\n });\n }\n },\n});\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { reviewer, vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst handle = client.reviewer.getOrCreate([\"main\"]);\n\n// Wait for the agent to complete the review\nconst result = await handle.send(\n \"review\",\n { file: \"/home/user/src/auth.ts\" },\n { wait: true, timeout: 120_000 },\n);\n\nif (result.status === \"completed\") {\n console.log(\"Review:\", result.response.summary);\n}\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Ingesting from external systems\n\nAccept tasks from webhooks, APIs, or other services and queue them for agent processing.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\nimport { actor, queue, setup } from \"rivetkit\";\n\nconst issueWorker = actor({\n queues: {\n issues: queue\u003C{ title: string; body: string }>(),\n },\n actions: {\n // HTTP endpoint to receive webhook payloads\n ingestIssue: async (c, title: string, body: string) => {\n await c.queue.push(\"issues\", { title, body });\n },\n },\n run: async (c) => {\n const agentHandle = c.actors.vm.getOrCreate([\"issue-worker\"]);\n\n for await (const message of c.queue.iter()) {\n const session = await agentHandle.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n await agentHandle.sendPrompt(\n session.sessionId,\n `Investigate and fix this issue:\\n\\nTitle: ${message.body.title}\\n\\n${message.body.body}`,\n );\n await agentHandle.closeSession(session.sessionId);\n }\n },\n});\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { issueWorker, vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst handle = client.issueWorker.getOrCreate([\"main\"]);\n\n// Ingest from a webhook or external system\nawait handle.ingestIssue(\n \"Login redirect broken\",\n \"Users are redirected to /undefined after login on mobile\",\n);\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Recommendations\n\n- Use queues when you need guaranteed serial execution. Agents process one message at a time, preventing race conditions.\n- Use completable messages when the caller needs the result. Set a generous timeout since agent work can take minutes.\n- Queues survive actor sleep. Messages are persisted and processed when the actor wakes up.\n- See [Queues & Run Loops](/docs/actors/queues) for the full queue API reference.","src/content/docs/docs/queues.mdx","53e20396e2fa6e69","docs/quickstart",{"id":232,"data":234,"body":240,"filePath":241,"digest":242,"deferredRender":16},{"title":235,"description":236,"editUrl":16,"head":237,"template":18,"sidebar":238,"pagefind":16,"draft":20},"Quickstart","Set up an agentOS actor, create a session, and run your first coding agent.",[],{"hidden":20,"attrs":239},{},"import { Aside, Steps, Tabs, TabItem } from '@astrojs/starlight/components';\n\n\u003CAside type=\"note\">\nagentOS is in preview and the API is subject to change. If you run into issues, please [report them on GitHub](https://github.com/rivet-dev/rivet/issues) or [join our Discord](https://rivet.dev/discord).\n\u003C/Aside>\n\n\u003CSteps>\n\n1. **Install**\n\n - **rivetkit** — Actor framework with built-in persistence and orchestration\n - **@rivet-dev/agent-os-common** — Standard VM software (curl, grep, git, and more)\n - **@rivet-dev/agent-os-pi** — [Pi](https://github.com/mariozechner/pi-coding-agent) coding agent (Claude Code, Amp, and OpenCode coming soon)\n\n ```bash\n npm install rivetkit @rivet-dev/agent-os-common @rivet-dev/agent-os-pi\n ```\n\n2. **Create the Server & Client**\n\n \u003CTabs>\n \u003CTabItem label=\"server.ts\">\n ```ts title=\"server.ts\"\n import { agentOs } from \"rivetkit/agent-os\";\n import { setup } from \"rivetkit\";\n import common from \"@rivet-dev/agent-os-common\";\n import pi from \"@rivet-dev/agent-os-pi\";\n\n const vm = agentOs({\n options: { software: [common, pi] },\n });\n\n export const registry = setup({ use: { vm } });\n registry.start();\n ```\n \u003C/TabItem>\n \u003CTabItem label=\"client.ts\">\n ```ts title=\"client.ts\"\n import { createClient } from \"rivetkit/client\";\n import type { registry } from \"./server\";\n\n const client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\n const agent = client.vm.getOrCreate([\"my-agent\"]);\n\n // Subscribe to streaming events\n agent.on(\"sessionEvent\", (data) => {\n console.log(data.event);\n });\n\n // Create a session and send a prompt\n const session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n await agent.sendPrompt(\n session.sessionId,\n \"Write a hello world script to /home/user/hello.js\",\n );\n\n // Read the file the agent created\n const content = await agent.readFile(\"/home/user/hello.js\");\n console.log(new TextDecoder().decode(content));\n ```\n \u003C/TabItem>\n \u003C/Tabs>\n\n3. **Run**\n\n Start the server:\n\n ```bash\n npx tsx server.ts\n ```\n\n Then in a separate terminal, run the client:\n\n ```bash\n npx tsx client.ts\n ```\n\n4. **Customize**\n\n Now that you have a working agent, customize it to fit your needs:\n\n - **[Software](/docs/software)** — Install software packages inside the VM\n - **[Tools](/docs/tools)** — Expose your JavaScript functions to agents as CLI commands\n - **[Filesystem](/docs/filesystem)** — Read, write, and manage files inside the VM\n\n\u003C/Steps>\n\n\n## agentOS Core\n\nThe quickstart above uses `rivetkit/agent-os`, which includes statefulness, multiplayer, and orchestration out of the box. If you only need direct VM control without those features, you can use the core package (`@rivet-dev/agent-os-core`) standalone.\n\nSee [agentOS core documentation](/docs/core) for reference.","src/content/docs/docs/quickstart.mdx","f1f89a2f454226d6","docs/security-model",{"id":243,"data":245,"body":251,"filePath":252,"digest":253,"deferredRender":16},{"title":246,"description":247,"editUrl":16,"head":248,"template":18,"sidebar":249,"pagefind":16,"draft":20},"Security Model","Trust boundaries, isolation guarantees, and the agentOS threat model.",[],{"hidden":20,"attrs":250},{},"import { Aside } from '@astrojs/starlight/components';\n\n\u003CAside type=\"caution\">\nagentOS is in beta and still undergoing security review. The security model described here is subject to change.\n\u003C/Aside>\n\n## Deny by default\n\nNo syscalls are bound to the system by default. Everything is denied until explicitly opted in. Network access, filesystem mounts, process spawning, and all other capabilities must be configured by the host before the VM can use them.\n\n## Trust boundaries\n\nagentOS has two trust boundaries:\n\n1. **Runtime boundary.** The VM isolate that runs agent code. All code inside the VM is untrusted. The isolate prevents access to the host process, host filesystem, and host network.\n2. **Host boundary.** Your application code that configures and manages the VM. You are responsible for hardening the host process, validating inputs, and managing secrets.\n\n## VM isolation\n\nEach agentOS actor runs in its own isolated VM:\n\n- **Sandboxed execution.** All agent code runs inside a V8 isolate with WebAssembly. No code escapes the isolate boundary.\n- **Virtual filesystem.** The VM has its own filesystem. Agents cannot access host files unless explicitly mounted.\n- **Virtual network.** The VM has no direct access to the host network. Outbound requests are proxied through the host with configurable controls.\n- **Process isolation.** No host process is visible or accessible from inside the VM.\n\n## What agentOS guarantees\n\n- Agent code cannot read or write host files outside configured mounts\n- Agent code cannot make network requests except through the host proxy\n- Agent code cannot access host environment variables or secrets\n- Each actor's filesystem, sessions, and state are isolated from other actors\n- Resource limits (CPU, memory) are enforced at the VM level\n\n## What you are responsible for\n\n- Hardening the host process and deployment environment\n- Validating authentication tokens in `onBeforeConnect`\n- Scoping [permissions](/docs/permissions) appropriately for your use case\n- Managing API keys and secrets on the host side (use the [LLM gateway](/docs/llm-gateway) to avoid passing keys into the VM)\n- Configuring [resource limits and network controls](/docs/security) to match your threat model\n\n## Further reading\n\n- [Security configuration](/docs/security) for resource limits, network control, and authentication setup\n- [Permissions](/docs/permissions) for agent tool-use approval patterns\n- [agentOS vs Sandbox](/docs/versus-sandbox) for when to escalate to a full sandbox","src/content/docs/docs/security-model.mdx","99a4ff3a2f6a0e2e","docs/sandbox",{"id":254,"data":256,"body":262,"filePath":263,"digest":264,"deferredRender":16},{"title":257,"description":258,"editUrl":16,"head":259,"template":18,"sidebar":260,"pagefind":16,"draft":20},"Sandbox Mounting","Extend agentOS with full sandboxes for heavy workloads like browsers, desktop automation, and compilation.",[],{"hidden":20,"attrs":261},{},"- **Hybrid architecture** pairs agentOS with full sandboxes on demand\n- **Pay-per-second billing** so sandboxes only cost money while they are running\n- **Filesystem mount** projects the sandbox into the VM as a native directory, like mounting a hard drive on your own machine\n- **Toolkit** exposes sandbox process management as [host tools](/docs/tools)\n- **Provider-agnostic** via [Sandbox Agent](https://sandboxagent.dev) under the hood\n\n## Why use agentOS with a sandbox?\n\nagentOS is not a replacement for sandboxes. It's designed to work alongside them. agentOS makes it easy to integrate agents into your backend with [host tools](/docs/tools), [permissions](/docs/permissions), the [LLM gateway](/docs/llm-gateway), and orchestration. Sandbox mounting lets you connect a full sandbox environment when the workload needs it.\n\nSee [agentOS vs Sandbox](/docs/versus-sandbox) for a detailed comparison.\n\n## When to use a sandbox\n\n- **Native binaries** not yet supported in the agentOS runtime.\n- **Browsers and desktop automation**: Playwright, Puppeteer, Selenium, or anything that needs a display server.\n- **Heavy compilation**: Large builds or native toolchains that require a full Linux environment.\n- **GUI applications**: Desktop apps, VNC sessions, or any workload that needs a graphical environment.\n- **Node.js packages with native extensions** (e.g. `sharp`, `bcrypt`, `better-sqlite3`) that require a full build toolchain.\n\n## Getting started\n\nThe `@rivet-dev/agent-os-sandbox` package integrates through two mechanisms:\n\n- **Filesystem mount**: Projects the sandbox into the VM as a native directory, like mounting a hard drive on your own machine. Read and write files through the mount directly.\n- **Toolkit**: Exposes sandbox process management as [host tools](/docs/tools). Execute commands on the sandbox from within the VM.\n\nBoth are powered by [Sandbox Agent](https://sandboxagent.dev), so you can swap providers without changing agent code.\n\n```bash\nnpm install @rivet-dev/agent-os-sandbox sandbox-agent\n```\n\n```ts\nimport { SandboxAgent } from \"sandbox-agent\";\nimport { docker } from \"sandbox-agent/docker\";\nimport { AgentOs } from \"@rivet-dev/agent-os-core\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport { createSandboxFs, createSandboxToolkit } from \"@rivet-dev/agent-os-sandbox\";\n\nconst sandbox = await SandboxAgent.start({\n sandbox: docker(),\n});\n\nconst vm = await AgentOs.create({\n software: [common],\n mounts: [\n {\n path: \"/sandbox\",\n plugin: createSandboxFs({ client: sandbox }),\n },\n ],\n toolKits: [createSandboxToolkit({ client: sandbox })],\n});\n\n// Write code via the filesystem. The /sandbox mount maps to the sandbox root.\nawait vm.writeFile(\"/sandbox/app/index.ts\", 'console.log(\"hello\")');\n\n// Run it via the toolkit. Commands execute inside the sandbox, so paths are\n// relative to the sandbox root (/app/index.ts), not the VM mount (/sandbox/app/index.ts).\nconst result = await vm.exec(\"agentos-sandbox run-command --command node --args /app/index.ts\");\n```\n\n## Tools reference\n\nThe toolkit exposes these commands inside the VM:\n\n```bash\n# Run a command synchronously\nagentos-sandbox run-command --command \"npm install\" --cwd \"/app\"\n\n# Start a background process\nagentos-sandbox create-process --command \"npm\" --args \"run\" --args \"dev\"\n\n# List running processes\nagentos-sandbox list-processes\n\n# Get process output\nagentos-sandbox get-process-logs --id \"proc_abc123\"\n\n# Stop or kill a process\nagentos-sandbox stop-process --id \"proc_abc123\"\nagentos-sandbox kill-process --id \"proc_abc123\"\n\n# Send input to an interactive process\nagentos-sandbox send-input --id \"proc_abc123\" --data \"yes\"\n```\n\n## Sandbox providers\n\nThe extension works with any [Sandbox Agent](https://sandboxagent.dev) provider. See the [Sandbox Agent documentation](https://sandboxagent.dev) for available providers and setup instructions.\n\n## Recommendations\n\n- Start with the default agentOS VM for all workloads. Only spin up a sandbox when you hit a task that genuinely requires one.\n- Sandboxes are billed per second of uptime. Spin them up on demand and tear them down when the task is done to minimize cost.\n- The hybrid model means your agent can handle both lightweight coding tasks and heavy system operations in the same session, using the right tool for each.\n- See [Tools](/docs/tools) for how host tools work and how the agent calls them as CLI commands.\n- See [Security Model](/docs/security-model) for details on the VM isolation model.","src/content/docs/docs/sandbox.mdx","349ce15abb6ad037","docs/security",{"id":265,"data":267,"body":273,"filePath":274,"digest":275,"deferredRender":16},{"title":268,"description":269,"editUrl":16,"head":270,"template":18,"sidebar":271,"pagefind":16,"draft":20},"Security & Auth","Configure resource limits, network control, authentication, and filesystem isolation for agentOS.",[],{"hidden":20,"attrs":272},{},"For the isolation model and trust boundaries, see [Security Model](/docs/security-model).\n\n## Resource limits\n\nCap kernel resources per VM to prevent runaway agents. Resource limits live under `limits.resources`. Every field is optional and unset fields fall back to built-in defaults.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: {\n software: [common, pi],\n limits: {\n resources: {\n cpuCount: 1,\n maxProcesses: 64,\n maxFilesystemBytes: 512 * 1024 * 1024, // 512 MB\n maxWasmMemoryBytes: 256 * 1024 * 1024, // 256 MB\n },\n },\n },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n## Network control\n\nVM network access is governed by kernel [Permissions](/docs/permissions). By default, the VM's outbound networking is also protected by SSRF checks that block requests to loopback addresses. `loopbackExemptPorts` exempts specific loopback ports from those checks — for example, to reach a host-side mock server during testing.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\n\nconst vm = agentOs({\n options: {\n software: [common],\n loopbackExemptPorts: [8080, 3000],\n },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n## Custom authentication\n\nUse the `onBeforeConnect` hook to validate clients before they access the agent.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup, UserError } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n onBeforeConnect: async (c, params: { authToken: string }) => {\n const isValid = await verifyToken(params.authToken);\n if (!isValid) {\n throw new UserError(\"Forbidden\", { code: \"forbidden\" });\n }\n },\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n\nasync function verifyToken(token: string): Promise\u003Cboolean> {\n // Your authentication logic\n return token === \"valid-token\";\n}\n```\n\nSee [Authentication](/docs/authentication) for `createConnState`, client usage, and more patterns.\n\n## Permission system\n\nAgents request permission before using tools. See [Permissions](/docs/permissions) for auto-approve, selective approval, and human-in-the-loop patterns.\n\n## Preview URL security\n\nPreview URLs use randomly generated 32-character lowercase alphanumeric (a-z0-9) tokens with configurable expiration. See [Networking & Previews](/docs/networking) for token management.\n\n- Tokens are stored in SQLite and survive sleep/wake\n- Expired tokens are automatically cleaned up\n- Use `expireSignedPreviewUrl` to immediately revoke a token\n\n## Filesystem isolation\n\nEach VM has its own virtual filesystem. Files are isolated per actor instance.\n\n- `/home/user` is persistent and survives sleep/wake\n- Mount boundaries prevent escape via symlinks or path traversal\n- Host directory mounts (if configured) prevent symlink escape beyond the mount point","src/content/docs/docs/security.mdx","2ba0bac0e9b1ae89","docs/sessions",{"id":276,"data":278,"body":284,"filePath":285,"digest":286,"deferredRender":16},{"title":279,"description":280,"editUrl":16,"head":281,"template":18,"sidebar":282,"pagefind":16,"draft":20},"Sessions","Create agent sessions, send prompts, stream responses, and replay event history.",[],{"hidden":20,"attrs":283},{},"import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';\n\n- **Create sessions** with any supported agent type\n- **Stream responses** in real time via `sessionEvent` subscriptions\n- **Replay events** with sequence numbers for reconnection and history\n- **Persist transcripts** automatically in SQLite across sleep/wake cycles\n- **Universal transcript format** using the Agent Communication Protocol (ACP)\n\n\u003CAside type=\"note\">\nCurrently only [Pi](https://github.com/mariozechner/pi-coding-agent) is supported as an agent. Amp, Claude Code, Codex, and OpenCode are coming soon.\n\u003C/Aside>\n\n## Create a session\n\nUse `createSession` to launch an agent inside the VM. Returns session metadata including capabilities and agent info.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nconsole.log(session.sessionId);\nconsole.log(session.capabilities);\nconsole.log(session.agentInfo);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n### `env`\n\nEnvironment variables to pass to the agent process. The VM does not inherit from the host `process.env`, so API keys must be passed explicitly.\n\n```ts\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\n```\n\n### `cwd`\n\nWorking directory for the agent session inside the VM. Defaults to `/home/user`.\n\n```ts\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n cwd: \"/home/user/project\",\n});\n```\n\n### `mcpServers`\n\nPass MCP servers to give the agent access to additional tools. MCP servers provide typed tool definitions that the agent's LLM can discover and call natively.\n\n#### Local MCP server\n\nRun an MCP server as a child process inside the VM.\n\n```ts\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n mcpServers: [\n {\n type: \"local\",\n command: \"npx\",\n args: [\"-y\", \"@modelcontextprotocol/server-filesystem\", \"/home/user\"],\n env: {},\n },\n ],\n});\n```\n\n#### Remote MCP server\n\nConnect to an MCP server running outside the VM.\n\n```ts\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n mcpServers: [\n {\n type: \"remote\",\n url: \"https://mcp.example.com/sse\",\n headers: {\n Authorization: \"Bearer my-token\",\n },\n },\n ],\n});\n```\n\n### `additionalInstructions`\n\nAppend custom instructions to the agent's system prompt.\n\n```ts\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n additionalInstructions: \"Always write tests before implementation.\",\n});\n```\n\n### `skipOsInstructions`\n\nSkip the base OS instructions injection. Tool documentation is still included even when this is `true`.\n\n```ts\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n skipOsInstructions: true,\n});\n```\n\n## Send a prompt\n\nUse `sendPrompt` to send a message to an active session. The response contains the agent's reply.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nconst response = await agent.sendPrompt(\n session.sessionId,\n \"Create a TypeScript function that checks if a number is prime\",\n);\nconsole.log(response);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Stream responses\n\nSubscribe to `sessionEvent` to receive real-time streaming output from the agent.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Subscribe to session events before sending the prompt\nagent.on(\"sessionEvent\", (data) => {\n console.log(`[${data.sessionId}]`, data.event.method, data.event.params);\n});\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait agent.sendPrompt(session.sessionId, \"Explain how async/await works\");\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Cancel a prompt\n\nUse `cancelPrompt` to stop an in-progress prompt.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\n\n// Start a long-running prompt\nconst promptPromise = agent.sendPrompt(\n session.sessionId,\n \"Refactor the entire codebase to use TypeScript strict mode\",\n);\n\n// Cancel after 10 seconds\nsetTimeout(async () => {\n await agent.cancelPrompt(session.sessionId);\n}, 10_000);\n\nconst response = await promptPromise;\nconsole.log(response);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Resume, close, and destroy sessions\n\n- `resumeSession` reconnects to a session that was suspended (e.g. after sleep)\n- `closeSession` gracefully closes a session\n- `destroySession` removes the session and all persisted data\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Resume a previously created session\nconst resumed = await agent.resumeSession(\"session-id-from-earlier\");\n\n// Close without destroying persisted data\nawait agent.closeSession(resumed.sessionId);\n\n// Destroy session and all persisted events\nawait agent.destroySession(resumed.sessionId);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Runtime configuration\n\nChange model, mode, and thought level on a live session.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\n\n// Change model\nawait agent.setModel(session.sessionId, \"claude-sonnet-4-6\");\n\n// Change mode (e.g. \"plan\", \"auto\")\nawait agent.setMode(session.sessionId, \"plan\");\n\n// Change thought level\nawait agent.setThoughtLevel(session.sessionId, \"high\");\n\n// Query available options\nconst modes = await agent.getModes(session.sessionId);\nconsole.log(modes);\n\nconst options = await agent.getConfigOptions(session.sessionId);\nconsole.log(options);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Replay events\n\nUse `getSequencedEvents` to replay in-memory session events (for live reconnection while the VM is running), or `getSessionEvents` to replay from persisted storage (for transcript history, including when the VM is not running). See [Events](/docs/events#event-replay) for details on the difference.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait agent.sendPrompt(session.sessionId, \"Hello\");\n\n// Get all events\nconst events = await agent.getEvents(session.sessionId);\nconsole.log(events);\n\n// Get events with sequence numbers (for pagination/reconnection)\nconst sequenced = await agent.getSequencedEvents(session.sessionId, {\n since: 0,\n});\nconsole.log(sequenced);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Persisted session history\n\nQuery session history from SQLite. Works even when the VM is not running.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// List all persisted sessions\nconst sessions = await agent.listPersistedSessions();\nfor (const s of sessions) {\n console.log(s.sessionId, s.agentType, s.createdAt);\n}\n\n// Get full event history for a session\nconst events = await agent.getSessionEvents(sessions[0].sessionId);\nfor (const e of events) {\n console.log(e.seq, e.event.method, e.createdAt);\n}\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Multiple sessions\n\nA single VM can run multiple sessions simultaneously. Each session has its own agent process but shares the same filesystem. Use different session IDs to manage them independently.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Create two sessions in the same VM\nconst coder = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nconst reviewer = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\n\n// Coder writes code\nawait agent.sendPrompt(coder.sessionId, \"Write a REST API at /home/user/api.ts\");\n\n// Reviewer reads and reviews the same file\nawait agent.sendPrompt(reviewer.sessionId, \"Review /home/user/api.ts for issues\");\n\n// Close each session independently\nawait agent.closeSession(coder.sessionId);\nawait agent.closeSession(reviewer.sessionId);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Recommendations\n\n- Subscribe to `sessionEvent` **before** calling `sendPrompt` to avoid missing early events.\n- Use `getSequencedEvents` with `since` for reconnection. Track the last sequence number you processed.\n- Use `listPersistedSessions` and `getSessionEvents` to build transcript history UIs without requiring a running VM.\n- Call `closeSession` when done to release resources. Use `destroySession` only when you want to permanently delete session data.","src/content/docs/docs/sessions.mdx","46369492f528fac5","docs/software",{"id":287,"data":289,"body":295,"filePath":296,"digest":297,"deferredRender":16},{"title":290,"description":291,"editUrl":16,"head":292,"template":18,"sidebar":293,"pagefind":16,"draft":20},"Software","Install software packages and configure the commands available inside agentOS.",[],{"hidden":20,"attrs":294},{},"agentOS starts with no commands installed. The `software` option lets you declare which packages to include. Each package provides one or more CLI commands.\n\n## Install\n\n```bash\nnpm install @rivet-dev/agent-os-core @rivet-dev/agent-os-common\n```\n\n`@rivet-dev/agent-os-common` is a meta-package that includes coreutils, sed, grep, gawk, findutils, diffutils, tar, and gzip. For a smaller footprint, install individual packages instead.\n\n## Usage\n\n```ts\nimport { AgentOs } from \"@rivet-dev/agent-os-core\";\nimport common from \"@rivet-dev/agent-os-common\";\n\nconst vm = await AgentOs.create({\n software: [common],\n});\n\nconst result = await vm.exec(\"echo hello | grep hello\");\nconsole.log(result.stdout); // \"hello\\n\"\n\nawait vm.dispose();\n```\n\nYou can mix individual packages and meta-packages:\n\n```ts\nimport { AgentOs } from \"@rivet-dev/agent-os-core\";\nimport coreutils from \"@rivet-dev/agent-os-coreutils\";\nimport grep from \"@rivet-dev/agent-os-grep\";\nimport jq from \"@rivet-dev/agent-os-jq\";\nimport ripgrep from \"@rivet-dev/agent-os-ripgrep\";\n\nconst vm = await AgentOs.create({\n software: [coreutils, grep, jq, ripgrep],\n});\n```\n\n## Available Packages\n\nBrowse all available software packages on the [Registry](/agent-os/registry).\n\n\n## Publishing Custom Packages\n\nSee the [agent-os-registry contributing guide](https://github.com/rivet-dev/agent-os/blob/main/registry/CONTRIBUTING.md) for how to add new software packages to the registry.","src/content/docs/docs/software.mdx","d71bae88617ecbfa","docs/sqlite",{"id":298,"data":300,"body":306,"filePath":307,"digest":308,"deferredRender":16},{"title":301,"description":302,"editUrl":16,"head":303,"template":18,"sidebar":304,"pagefind":16,"draft":20},"SQLite","Give agents access to a persistent SQLite database via host tools.",[],{"hidden":20,"attrs":305},{},"Each agentOS actor has a built-in SQLite database that persists across sessions and sleep/wake cycles. Expose it to agents as a host tool so they can run arbitrary SQL queries.\n\n## Example\n\nGive the agent a single `sql` tool that executes any SQL query against the actor's SQLite database.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\nimport { db } from \"rivetkit/db\";\nimport { toolKit, hostTool } from \"@rivet-dev/agent-os-core\";\nimport { z } from \"zod\";\n\nconst actorDb = db({});\n\nconst sqlToolkit = toolKit({\n name: \"db\",\n description: \"SQLite database\",\n tools: {\n sql: hostTool({\n description: \"Execute a SQL query against the actor's SQLite database. Use this for creating tables, inserting data, and querying data. Returns rows for SELECT queries.\",\n inputSchema: z.object({\n query: z.string().describe(\"SQL query to execute\"),\n }),\n execute: async (input) => {\n const result = await actorDb.execute(input.query);\n return result;\n },\n }),\n },\n});\n\nconst vm = agentOs({\n db: actorDb,\n options: { software: [common, pi], toolKits: [sqlToolkit] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\nThe agent calls it as a CLI command:\n\n```bash\nagentos-db sql --query \"CREATE TABLE notes (id INTEGER PRIMARY KEY, content TEXT, created_at INTEGER)\"\nagentos-db sql --query \"INSERT INTO notes (content, created_at) VALUES ('auth uses JWT with 24h expiry', 1711843200000)\"\nagentos-db sql --query \"SELECT * FROM notes WHERE content LIKE '%auth%'\"\n```\n\nThe database persists in the actor's storage across sessions and sleep/wake cycles. The agent can create whatever schema it needs and build up structured data over time.\n\n## Recommendations\n\n- See the [Crash Course](/docs/crash-course) for the `db()` API (`onMigrate`, parameterized `db.execute`).\n- See [Tools](/docs/tools) for how host tools work.","src/content/docs/docs/sqlite.mdx","5962368c765fc56f","docs/system-prompt",{"id":309,"data":311,"body":317,"filePath":318,"digest":319,"deferredRender":16},{"title":312,"description":313,"editUrl":16,"head":314,"template":18,"sidebar":315,"pagefind":16,"draft":20},"System Prompt","How agentOS injects context into agent sessions.",[],{"hidden":20,"attrs":316},{},"agentOS automatically injects a system prompt into every agent session that describes the VM environment and available tools. The prompt is additive and never replaces the agent's own instructions (CLAUDE.md, AGENTS.md, etc.).\n\nThe base prompt is embedded in the sidecar (not written to a file inside the VM). At session start the sidecar assembles the base prompt with your additional instructions and generated tool docs, then injects the result into the agent adapter's launch arguments (for example, `--append-system-prompt` for Pi).\n\n## Customization\n\n```ts\nconst session = await vm.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n // Append custom instructions\n additionalInstructions: \"Always write tests before implementation.\",\n // Suppress the base OS prompt (tool docs are still injected)\n skipOsInstructions: true,\n});\n```","src/content/docs/docs/system-prompt.mdx","8abea299e38517df","docs/tools",{"id":320,"data":322,"body":328,"filePath":329,"digest":330,"deferredRender":16},{"title":323,"description":324,"editUrl":16,"head":325,"template":18,"sidebar":326,"pagefind":16,"draft":20},"Tools","Expose custom tools to agents as CLI commands inside the VM.",[],{"hidden":20,"attrs":327},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\nExpose your JavaScript functions to agents as CLI commands inside the VM.\n\n- **Define tools on the host** with Zod input schemas\n- **Auto-generated CLI commands** installed at `/usr/local/bin/agentos-{toolkit}`\n- **Code mode compatible** for up to 80% token reduction since tool calls are just shell commands\n- **Tool list injected** into the agent's [system prompt](/docs/system-prompt) automatically\n\n## Getting started\n\nDefine a toolkit with Zod input schemas and pass it to `agentOs()`. Each tool becomes a CLI command inside the VM.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\nimport { toolKit, hostTool } from \"@rivet-dev/agent-os-core\";\nimport { z } from \"zod\";\n\nconst weatherToolkit = toolKit({\n name: \"weather\",\n description: \"Weather data tools\",\n tools: {\n forecast: hostTool({\n description: \"Get the weather forecast for a city\",\n inputSchema: z.object({\n city: z.string().describe(\"City name\"),\n days: z.number().optional().describe(\"Number of days\"),\n }),\n execute: async (input) => {\n const res = await fetch(`https://api.weather.example/forecast?city=${input.city}&days=${input.days ?? 3}`);\n return res.json();\n },\n examples: [\n { description: \"3-day forecast for Paris\", input: { city: \"Paris\", days: 3 } },\n ],\n }),\n },\n});\n\nconst vm = agentOs({\n options: { software: [common, pi],\n toolKits: [weatherToolkit],\n },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait agent.sendPrompt(session.sessionId, \"What's the weather in Paris?\");\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n### Zod to CLI mapping\n\nZod schema fields are converted to CLI flags automatically. Field names are converted from camelCase to kebab-case.\n\n| Zod type | CLI syntax | Example |\n|---|---|---|\n| `z.string()` | `--name value` | `--path /tmp/out.png` |\n| `z.number()` | `--name 42` | `--limit 5` |\n| `z.boolean()` | `--flag` / `--no-flag` | `--full-page` |\n| `z.enum([\"a\",\"b\"])` | `--name a` | `--format json` |\n| `z.array(z.string())` | `--name a --name b` | `--tags foo --tags bar` |\n\nOptional fields (via `.optional()`) become optional flags. Required fields are enforced at validation time.\n\n### What the agent sees\n\nWhen toolkits are registered, CLI shims are installed at `/usr/local/bin/agentos-{name}` inside the VM and the tool list is injected into the agent's [system prompt](/docs/system-prompt).\n\nThe agent interacts with tools as shell commands:\n\n```bash\n# List all available toolkits\nagentos list-tools\n\n# List tools in a specific toolkit\nagentos list-tools weather\n\n# Get help for a tool\nagentos-weather forecast --help\n\n# Call a tool with flags\nagentos-weather forecast --city Paris --days 3\n\n# Call a tool with inline JSON\nagentos-weather forecast --json '{\"city\":\"Paris\",\"days\":3}'\n\n# Call a tool with JSON from a file\nagentos-weather forecast --json-file /tmp/input.json\n```\n\nOn success, the tool exits `0` and writes a JSON envelope to stdout:\n\n```json\n{\"ok\":true,\"result\":{\"temperature\":22,\"condition\":\"sunny\"}}\n```\n\nOn failure (validation or execution error), the tool exits non-zero and writes the error message to stderr:\n\n```text\nMissing required flag: --city\n```\n\n## Tools vs MCP servers\n\nagentOS supports two ways to give agents access to external functionality: **host tools** and **MCP servers**. Both work, but they have different tradeoffs.\n\n| | Host Tools | MCP Servers |\n|---|---|---|\n| **How it works** | Call JavaScript functions on the host directly | Connect to a standard MCP server |\n| **Authentication** | None required. Direct binding to the agent's OS. | Requires custom auth configuration per server |\n| **Code mode** | Built-in. Tools are exposed as CLI commands, so agents can call them inside scripts for up to 80% token reduction. | Requires extra work to make code mode work out of the box |\n| **Latency** | Near-zero. Bound directly to the host process. | Extra network hop to reach the MCP server |\n| **Setup** | Define tools in your actor code with Zod schemas | Configure any standard MCP server |\n\nUse host tools when you want to expose your own JavaScript functions to agents. Use MCP servers when you want to connect to existing third-party services. See [Sessions](/docs/sessions#mcpservers) for MCP server configuration.\n\n## Security\n\nTool calls from the agent securely invoke your `execute()` functions on the host. Your functions run with full access to the host environment, so you can call databases, APIs, and services directly without proxying credentials into the VM. The agent never sees the credentials — it only sees the tool's input/output contract.\n\n## Recommendations\n\n- Keep tool descriptions concise. They are injected into the agent's system prompt and consume tokens.\n- Use `.describe()` on Zod fields to generate useful `--help` output.\n- Set an explicit `timeout` (in ms) on long-running tools. Tools run without a timeout unless one is set.\n- Tools execute on the host with full access to the host environment. Do not expose tools that could compromise the host without appropriate safeguards.","src/content/docs/docs/tools.mdx","afc3c670ab1ddd09","docs/versus-sandbox",{"id":331,"data":333,"body":339,"filePath":340,"digest":341,"deferredRender":16},{"title":334,"description":335,"editUrl":16,"head":336,"template":18,"sidebar":337,"pagefind":16,"draft":20},"agentOS vs Sandbox","When to use the lightweight agentOS VM, a full sandbox, or both together.",[],{"hidden":20,"attrs":338},{},"- **agentOS** is a lightweight VM that runs inside your process. Near-zero cold start, low memory, direct backend integration via [host tools](/docs/tools).\n- **Sandboxes** are full Linux environments with root access, system packages, and native binary support.\n- **You can use both.** agentOS works with sandboxes through [sandbox mounting](/docs/sandbox). Agents run in the lightweight VM by default and spin up a full sandbox on demand.\n\n## Comparison\n\n| | agentOS VM | Full Sandbox |\n|---|---|---|\n| **Cost** | Very low. Runs in your process. | Pay per second of uptime. |\n| **Startup** | Near-zero cold start (~6 ms). | Seconds to spin up. |\n| **Backend integration** | Direct. [Host tools](/docs/tools) call your functions with zero latency. | Indirect. Requires network calls back to your backend. |\n| **API keys** | Stay on the server via the [LLM gateway](/docs/llm-gateway). | Must be injected into the sandbox environment. |\n| **Permissions** | Granular, deny-by-default. | Coarse-grained (container-level). |\n| **Infrastructure** | `npm install` | Vendor account + API keys. |\n| **Best for** | Coding, file manipulation, scripting, API calls, orchestration. | Browsers, desktop automation, native compilation, dev servers. |\n\n## When to use each\n\n### agentOS VM\n\nUse the lightweight VM for most agent workloads:\n\n- Coding and file editing\n- Running scripts and CLI tools\n- Calling APIs and services via host tools\n- Multi-agent orchestration and workflows\n- Tasks where backend integration matters (permissions, tool access, LLM routing)\n\n### Full sandbox\n\nSpin up a sandbox when the workload needs a real Linux kernel:\n\n- Browsers and desktop automation (Playwright, Puppeteer, Selenium)\n- Heavy compilation and native toolchains\n- Dev servers with hot reload, databases, and system ports\n- GUI applications and VNC sessions\n\n### Both together\n\nUse agentOS with [sandbox mounting](/docs/sandbox) for workflows that need both:\n\n- Agent runs in the agentOS VM with full access to host tools and permissions\n- Sandbox spins up on demand for heavy tasks\n- Sandbox filesystem is mounted into the VM as a native directory\n- Agent reads and writes sandbox files the same way it reads local files","src/content/docs/docs/versus-sandbox.mdx","b2fcc9b2b2714d5a","docs/webhooks",{"id":342,"data":344,"body":350,"filePath":351,"digest":352,"deferredRender":16},{"title":345,"description":346,"editUrl":16,"head":347,"template":18,"sidebar":348,"pagefind":16,"draft":20},"Webhooks","Trigger agent workflows from external webhooks using Hono and queues.",[],{"hidden":20,"attrs":349},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\nUse a lightweight HTTP server to receive webhooks and queue them for agent processing. This example uses [Hono](https://hono.dev) to receive Slack webhooks and dispatch them to an agent via queues.\n\n## Example: Slack webhook to agent\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\nimport { actor, queue, setup } from \"rivetkit\";\nimport { Hono } from \"hono\";\nimport { createClient } from \"rivetkit/client\";\n\n// Actor that processes Slack messages via a queue\nconst slackWorker = actor({\n queues: {\n messages: queue\u003C{ channel: string; text: string; user: string }>(),\n },\n run: async (c) => {\n const agentHandle = c.actors.vm.getOrCreate([\"slack-agent\"]);\n\n for await (const message of c.queue.iter()) {\n const { channel, text, user } = message.body;\n\n const session = await agentHandle.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n const response = await agentHandle.sendPrompt(\n session.sessionId,\n `Slack message from ${user} in #${channel}:\\n\\n${text}\\n\\nRespond helpfully.`,\n );\n await agentHandle.closeSession(session.sessionId);\n\n // Post the response back to Slack\n await fetch(\"https://slack.com/api/chat.postMessage\", {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`,\n },\n body: JSON.stringify({ channel, text: response }),\n });\n }\n },\n});\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { slackWorker, vm } });\nregistry.start();\n\n// Hono server to receive Slack webhooks\nconst app = new Hono();\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\n\napp.post(\"/slack/events\", async (c) => {\n const body = await c.req.json();\n\n // Handle Slack URL verification\n if (body.type === \"url_verification\") {\n return c.json({ challenge: body.challenge });\n }\n\n // Queue the message for the agent\n if (body.event?.type === \"message\" && !body.event?.bot_id) {\n const worker = client.slackWorker.getOrCreate([\"main\"]);\n await worker.send(\"messages\", {\n channel: body.event.channel,\n text: body.event.text,\n user: body.event.user,\n });\n }\n\n return c.json({ ok: true });\n});\n\nexport default app;\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## How it works\n\n1. Slack sends an HTTP POST to `/slack/events`\n2. The Hono handler validates the event and pushes it to the actor's queue\n3. The queue processes messages one at a time, creating agent sessions for each\n4. The agent responds and the worker posts the reply back to Slack\n\nThe queue provides backpressure and durability. If the agent is busy, messages wait in the queue. If the server restarts, queued messages are replayed.\n\n## Recommendations\n\n- Use [Queues](/docs/queues) to decouple webhook ingestion from agent processing. This prevents slow agent responses from blocking the webhook endpoint.\n- Return `200` from the webhook handler immediately after queuing. External services like Slack have short timeout windows.\n- Store webhook secrets in environment variables, not in code.","src/content/docs/docs/webhooks.mdx","17a04621bf977afc","docs/workflows",{"id":353,"data":355,"body":361,"filePath":362,"digest":363,"deferredRender":16},{"title":356,"description":357,"editUrl":16,"head":358,"template":18,"sidebar":359,"pagefind":16,"draft":20},"Workflow Automation","Orchestrate multi-step agent tasks with durable workflows.",[],{"hidden":20,"attrs":360},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n- **Durable workflows** that survive crashes and restarts\n- **Multi-step orchestration** with sessions, file operations, and process execution\n- **Error handling and retry** via `ctx.step()` for each operation\n- **Agent chaining** where output of one session feeds into the next\n\n## Basic workflow\n\nUse the actor `workflow()` primitive to orchestrate a multi-step agent task. Each step is durable and will resume from where it left off after a restart.\n\nSession creation and prompting must happen within the same step because sessions are ephemeral and won't survive a replay.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\nimport { actor, setup, workflow } from \"rivetkit\";\n\nconst automator = actor({\n workflows: {\n fixBug: workflow\u003C{ repo: string; issue: string }>(),\n },\n run: async (c) => {\n for await (const message of c.workflow.iter(\"fixBug\")) {\n const { repo, issue } = message.body;\n const agentHandle = c.actors.vm.getOrCreate([`fix-${issue}`]);\n\n // Step 1: Clone the repo\n await c.step(\"clone-repo\", async (c) => {\n return agentHandle.exec(`git clone ${repo} /home/user/repo`);\n });\n\n // Step 2: Agent fixes the bug (session lives within this step)\n await c.step(\"fix-bug\", async (c) => {\n const session = await agentHandle.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n await agentHandle.sendPrompt(\n session.sessionId,\n `Fix the bug described in issue: ${issue}`,\n );\n await agentHandle.closeSession(session.sessionId);\n });\n\n // Step 3: Run tests\n const tests = await c.step(\"run-tests\", async (c) => {\n return agentHandle.exec(\"cd /home/user/repo && npm test\");\n });\n\n console.log(\"Tests exit code:\", tests.exitCode);\n await message.complete();\n }\n },\n});\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { automator, vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst handle = client.automator.getOrCreate([\"main\"]);\n\n// Trigger the workflow\nawait handle.send(\"fixBug\", {\n repo: \"https://github.com/example/repo.git\",\n issue: \"Fix the login redirect bug\",\n});\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Agent chaining\n\nOutput of one agent session feeds into the next. Each session is created and completed within its own step.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\nimport { actor, setup, workflow } from \"rivetkit\";\n\nconst pipeline = actor({\n workflows: {\n codeReview: workflow\u003C{ filePath: string }>(),\n },\n run: async (c) => {\n for await (const message of c.workflow.iter(\"codeReview\")) {\n const agentHandle = c.actors.vm.getOrCreate([`review-${Date.now()}`]);\n\n // Step 1: Agent reviews code and writes findings to a file\n await c.step(\"review\", async (c) => {\n const session = await agentHandle.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n await agentHandle.sendPrompt(\n session.sessionId,\n `Review the code at ${message.body.filePath} and write your findings to /home/user/review.md`,\n );\n await agentHandle.closeSession(session.sessionId);\n });\n\n // Step 2: Read the review from the filesystem\n const review = await c.step(\"read-review\", async (c) => {\n const content = await agentHandle.readFile(\"/home/user/review.md\");\n return new TextDecoder().decode(content);\n });\n\n // Step 3: Second session applies fixes based on the review\n await c.step(\"fix\", async (c) => {\n const session = await agentHandle.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n await agentHandle.sendPrompt(\n session.sessionId,\n `Apply the following review feedback:\\n\\n${review}`,\n );\n await agentHandle.closeSession(session.sessionId);\n });\n\n await message.complete();\n }\n },\n});\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { pipeline, vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst handle = client.pipeline.getOrCreate([\"main\"]);\n\nawait handle.send(\"codeReview\", { filePath: \"/home/user/src/auth.ts\" });\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Recommendations\n\n- Create and close sessions within the same step. Sessions are ephemeral and won't exist after a workflow replays.\n- Pass data between steps via the filesystem or step return values, not session state.\n- Keep step names stable across code changes. Renaming a step breaks replay for in-progress workflows.\n- Use separate actors for the workflow orchestrator and the agentOS VM.\n- See [Workflows](/docs/actors/workflows) for the full workflow API reference including timers, joins, and races.","src/content/docs/docs/workflows.mdx","54b9103733c58b29","docs/agents/amp",{"id":364,"data":366,"body":372,"filePath":373,"digest":374,"deferredRender":16},{"title":367,"description":368,"editUrl":16,"head":369,"template":18,"sidebar":370,"pagefind":16,"draft":20},"Amp","Run the Amp coding agent inside a VM.",[],{"hidden":20,"attrs":371},{},"import { Aside } from '@astrojs/starlight/components';\n\n\u003CAside type=\"note\">\nAmp agent documentation is coming soon.\n\u003C/Aside>","src/content/docs/docs/agents/amp.mdx","6f68d8dfd7680d81","docs/agents/claude",{"id":375,"data":377,"body":383,"filePath":384,"digest":385,"deferredRender":16},{"title":378,"description":379,"editUrl":16,"head":380,"template":18,"sidebar":381,"pagefind":16,"draft":20},"Claude","Run the Claude coding agent inside a VM.",[],{"hidden":20,"attrs":382},{},"import { Aside } from '@astrojs/starlight/components';\n\n\u003CAside type=\"note\">\nClaude agent documentation is coming soon.\n\u003C/Aside>\n\n```ts\nimport claude from \"@rivet-dev/agent-os-claude\";\n\nconst vm = await AgentOs.create({ software: [common, claude] });\nconst { sessionId } = await vm.createSession(\"claude\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY },\n});\n```","src/content/docs/docs/agents/claude.mdx","bbd303b74db0eaaf","docs/agents/codex",{"id":386,"data":388,"body":394,"filePath":395,"digest":396,"deferredRender":16},{"title":389,"description":390,"editUrl":16,"head":391,"template":18,"sidebar":392,"pagefind":16,"draft":20},"Codex","Run the Codex coding agent inside a VM.",[],{"hidden":20,"attrs":393},{},"import { Aside } from '@astrojs/starlight/components';\n\n\u003CAside type=\"note\">\nCodex agent documentation is coming soon.\n\u003C/Aside>\n\n```ts\nimport codex from \"@rivet-dev/agent-os-codex-agent\";\n\nconst vm = await AgentOs.create({ software: [common, codex] });\nconst { sessionId } = await vm.createSession(\"codex\", {\n env: { OPENAI_API_KEY: process.env.OPENAI_API_KEY },\n});\n```","src/content/docs/docs/agents/codex.mdx","6fa0a2fe6d4cd6a2","docs/agents/opencode",{"id":397,"data":399,"body":405,"filePath":406,"digest":407,"deferredRender":16},{"title":400,"description":401,"editUrl":16,"head":402,"template":18,"sidebar":403,"pagefind":16,"draft":20},"OpenCode","Run the OpenCode coding agent inside a VM.",[],{"hidden":20,"attrs":404},{},"import { Aside } from '@astrojs/starlight/components';\n\n\u003CAside type=\"note\">\nOpenCode agent documentation is coming soon.\n\u003C/Aside>\n\n```ts\nimport opencode from \"@rivet-dev/agent-os-opencode\";\n\nconst vm = await AgentOs.create({ software: [common, opencode] });\nconst { sessionId } = await vm.createSession(\"opencode\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY },\n});\n```","src/content/docs/docs/agents/opencode.mdx","5d0805d2e53e8c8e","docs/agents/pi",{"id":408,"data":410,"body":416,"filePath":417,"digest":418,"deferredRender":16},{"title":411,"description":412,"editUrl":16,"head":413,"template":18,"sidebar":414,"pagefind":16,"draft":20},"Pi","Run the Pi coding agent inside a VM with extensions and custom configuration.",[],{"hidden":20,"attrs":415},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n## Quick start\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agent-os-common\";\nimport pi from \"@rivet-dev/agent-os-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\n\nconst { text } = await agent.sendPrompt(\n session.sessionId,\n \"What files are in the current directory?\",\n);\nconsole.log(text);\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\nRead [Sessions](/docs/sessions) first for session options, streaming events, prompts, and lifecycle management.\n\n## Extensions\n\nPi supports [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent/examples/extensions) that let you register custom tools, modify the system prompt, and hook into agent lifecycle events. Write a `.js` file into the VM's extensions directory before creating a session and Pi discovers it automatically.\n\nPi scans two directories for `.js` extension files:\n\n| Directory | Scope |\n|-----------|-------|\n| `~/.pi/agent/extensions/` | Global — applies to all Pi sessions |\n| `\u003Ccwd>/.pi/extensions/` | Project — applies only when cwd matches |\n\n```ts\nconst extensionCode = `\nexport default function(pi) {\n // Modify the system prompt before each agent turn\n pi.on(\"before_agent_start\", async (event) => {\n return {\n systemPrompt: event.systemPrompt +\n \"\\\\n\\\\nAlways respond in formal English.\"\n };\n });\n}\n`;\n\n// Write the extension before creating the session\nawait agent.mkdir(\"/home/user/.pi/agent/extensions\", { recursive: true });\nawait agent.writeFile(\"/home/user/.pi/agent/extensions/formal.js\", extensionCode);\n\n// Pi discovers the extension automatically\nconst { sessionId } = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY },\n});\n```\n\nSee the [Pi extension documentation](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent/examples/extensions) for the full extension API.","src/content/docs/docs/agents/pi.mdx","9b0562c4e174096e"] \ No newline at end of file +[["Map",1,2,9,10],"meta::meta",["Map",3,4,5,6,7,8],"astro-version","5.18.2","content-config-digest","3edc204469c72622","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"site\":\"https://agentos-sdk.dev\",\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"where\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":false,\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[null,null,null],\"rehypePlugins\":[null,[null,{\"experimentalHeadingIdCompat\":false}],null,[null,{\"themes\":[\"github-dark-default\"],\"defaultLocale\":\"en\",\"cascadeLayer\":\"starlight.components\",\"styleOverrides\":{\"borderRadius\":\"0.75rem\",\"borderWidth\":\"1px\",\"codePaddingBlock\":\"0.75rem\",\"codePaddingInline\":\"1rem\",\"codeFontFamily\":\"\\\"JetBrains Mono\\\", ui-monospace, SFMono-Regular, Menlo, monospace\",\"codeFontSize\":\"var(--sl-text-code)\",\"codeLineHeight\":\"var(--sl-line-height)\",\"uiFontFamily\":\"var(--__sl-font)\",\"textMarkers\":{\"lineDiffIndicatorMarginLeft\":\"0.25rem\",\"defaultChroma\":\"45\",\"backgroundOpacity\":\"60%\"},\"borderColor\":\"rgba(244, 241, 231, 0.1)\",\"codeBackground\":\"#0a0a0a\",\"frames\":{\"editorTabBarBackground\":\"#111110\",\"editorTabBarBorderBottomColor\":\"rgba(244, 241, 231, 0.1)\",\"editorActiveTabBackground\":\"#0a0a0a\",\"editorActiveTabIndicatorBottomColor\":\"#cb5a33\",\"terminalTitlebarBackground\":\"#111110\",\"terminalBackground\":\"#0a0a0a\",\"frameBoxShadowCssValue\":\"none\"}},\"plugins\":[{\"name\":\"Starlight Plugin\",\"hooks\":{}},{\"name\":\"astro-expressive-code\",\"hooks\":{}}]}]],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true,\"allowedDomains\":[],\"actionBodySizeLimit\":1048576},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false,\"svgo\":false},\"legacy\":{\"collections\":false},\"prefetch\":{\"prefetchAll\":true},\"i18n\":{\"defaultLocale\":\"en\",\"locales\":[\"en\"],\"routing\":{\"prefixDefaultLocale\":false,\"redirectToDefaultLocale\":false,\"fallbackType\":\"redirect\"}}}","docs",["Map",11,12,25,26,36,37,47,48,58,59,69,70,80,81,91,92,102,103,113,114,9,124,133,134,144,145,155,156,166,167,177,178,188,189,199,200,210,211,221,222,232,233,243,244,254,255,265,266,276,277,287,288,298,299,309,310,320,321,331,332,342,343,353,354,364,365,375,376,386,387,397,398,408,409],"docs/authentication",{"id":11,"data":13,"body":22,"filePath":23,"digest":24,"deferredRender":16},{"title":14,"description":15,"editUrl":16,"head":17,"template":18,"sidebar":19,"pagefind":16,"draft":20},"Authentication","Authenticate connections to agentOS actors using hooks.",true,[],"doc",{"hidden":20,"attrs":21},false,{},"agentOS uses the same authentication system as Rivet Actors. Validate credentials in `onBeforeConnect` or extract user data with `createConnState`.\n\nFor full documentation including JWT examples, role-based access control, rate limiting, and token caching, see [Actor Authentication](/docs/actors/authentication).\n\n## `onBeforeConnect`\n\nValidate credentials before allowing a connection. Throw an error to reject.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup, UserError } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n onBeforeConnect: async (c, params: { authToken: string }) => {\n const isValid = await validateToken(params.authToken);\n if (!isValid) {\n throw new UserError(\"Forbidden\", { code: \"forbidden\" });\n }\n },\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n## `createConnState`\n\nExtract user data from credentials and store it in connection state. Accessible in actions via `c.conn.state`.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup, UserError } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\ninterface ConnState {\n userId: string;\n role: string;\n}\n\nconst vm = agentOs({\n createConnState: async (c, params: { authToken: string }): Promise\u003CConnState> => {\n const payload = await validateToken(params.authToken);\n if (!payload) {\n throw new UserError(\"Forbidden\", { code: \"forbidden\" });\n }\n return { userId: payload.sub, role: payload.role };\n },\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n## Client usage\n\nPass credentials when connecting:\n\n```ts\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"], {\n params: { authToken: \"my-jwt-token\" },\n});\n```\n\nSee [Actor Authentication](/docs/actors/authentication) for more patterns including external auth providers, role-based access control, and token caching.","src/content/docs/docs/authentication.mdx","c6aeb6d387f2bd32","docs/benchmarks",{"id":25,"data":27,"body":33,"filePath":34,"digest":35,"deferredRender":16},{"title":28,"description":29,"editUrl":16,"head":30,"template":18,"sidebar":31,"pagefind":16,"draft":20},"Benchmarks","Performance benchmarks comparing agentOS to traditional sandbox providers.",[],{"hidden":20,"attrs":32},{},"These are the benchmark figures shown on the agentOS marketing page. All numbers are computed from the same data source used by the marketing page. For independent sandbox comparison data, see the [ComputeSDK benchmarks](https://www.computesdk.com/benchmarks/).\n\n## Cold start\n\nTime from requesting an execution to first code running. Measured using the sleep workload (a minimal VM running an idle Node.js process). Sandbox baseline: **E2B**, the fastest mainstream sandbox provider as of March 30, 2026. See [ComputeSDK benchmarks](https://www.computesdk.com/benchmarks/) for independent sandbox comparison data.\n\n| Metric | agentOS | Fastest sandbox (E2B) |\n|---|--:|--:|\n| Cold start p50 | 4.8 ms | 440 ms |\n| Cold start p95 | 5.6 ms | 950 ms |\n| Cold start p99 | 6.1 ms | 3,150 ms |\n\n## Memory per instance\n\nMeasured via staircase benchmarking:\n\n1. **Warmup.** A throwaway VM is created, started, and destroyed before measurement begins. This pays one-time costs (module cache, JIT compilation) that are amortized away in any real deployment where the host process is long-lived.\n2. **Baseline.** GC is forced twice (`--expose-gc`), then RSS is sampled across the entire process tree by reading `/proc/[pid]/statm` for the host process and all descendants. This captures child processes (e.g. V8 isolates running as separate processes) that `process.memoryUsage().rss` would miss.\n3. **Staircase.** VMs are added one at a time. After each VM starts and settles, GC is forced and RSS is sampled again. The delta from the previous sample is the incremental cost of that VM.\n4. **Average.** The per-VM cost is the mean of all step deltas.\n5. **Teardown.** All VMs are disposed and the reclaimed RSS is recorded.\n\nRSS is a process-wide metric that includes thread stacks and OS-mapped pages beyond the VM itself, so the reported figure is an upper bound on the true per-VM cost.\n\nSandbox baseline: **Daytona**, the cheapest mainstream sandbox provider as of March 30, 2026. Default sandbox: 1 vCPU + 1 GiB RAM.\n\n### Full coding agent\n\nPi coding agent session with MCP servers and mounted file systems.\n\n| Metric | agentOS | Cheapest sandbox (Daytona) |\n|---|--:|--:|\n| Memory per instance | ~131 MB | ~1024 MB |\n\n### Simple shell command\n\nMinimal shell workload running simple commands.\n\n| Metric | agentOS | Cheapest sandbox (Daytona) |\n|---|--:|--:|\n| Memory per instance | ~22 MB | ~1024 MB |\n\n## Cost per execution-second\n\nAssumes one agent per sandbox (needed for isolation) and 70% host utilization for self-hosted hardware (the industry-standard HPA scaling threshold). Cost formula: `server cost per second / concurrent executions per server`, where concurrent executions = `floor(server RAM / agent memory) × 0.7`.\n\nSandbox baseline: **Daytona** at $0.0504/vCPU-h + $0.0162/GiB-h with a 1 vCPU + 1 GiB minimum. Source: [daytona.io/pricing](https://www.daytona.io/pricing).\n\n### Full coding agent\n\n| Host tier | agentOS | Cheapest sandbox | Difference |\n|---|--:|--:|--:|\n| AWS ARM | $0.00000058/s | $0.000018/s | 32x cheaper |\n| AWS x86 | $0.00000072/s | $0.000018/s | 26x cheaper |\n| Hetzner ARM | $0.000000066/s | $0.000018/s | 281x cheaper |\n| Hetzner x86 | $0.00000011/s | $0.000018/s | 171x cheaper |\n\n### Simple shell command\n\n| Host tier | agentOS | Cheapest sandbox | Difference |\n|---|--:|--:|--:|\n| AWS ARM | $0.000000073/s | $0.000018/s | 254x cheaper |\n| AWS x86 | $0.000000090/s | $0.000018/s | 205x cheaper |\n| Hetzner ARM | $0.000000011/s | $0.000018/s | 1738x cheaper |\n| Hetzner x86 | $0.000000017/s | $0.000018/s | 1061x cheaper |\n\n## Test environment\n\n| Component | Details |\n|---|---|\n| CPU | 12th Gen Intel i7-12700KF, 12 cores / 20 threads @ 3.7 GHz, 25 MB cache |\n| RAM | 2× 32 GB DDR4 @ 2400 MT/s |\n| Node.js | v24.13.0 |\n| OS | Linux 6.1.0 (Debian), x86_64 |\n\n## Sandbox baselines\n\n| Comparison | Provider | Why this provider |\n|---|---|---|\n| Cold start | E2B | Fastest mainstream sandbox provider on [ComputeSDK](https://www.computesdk.com/benchmarks/) as of March 30, 2026 |\n| Memory and cost | Daytona | Cheapest mainstream sandbox provider as of March 30, 2026 ($0.0504/vCPU-h + $0.0162/GiB-h) |\n\nSelf-hosted hardware tiers: AWS t4g.micro (ARM, $0.0084/h, 1 GiB), AWS t3.micro (x86, $0.0104/h, 1 GiB), Hetzner CAX11 (ARM, €3.29/mo, 4 GiB), Hetzner CX22 (x86, €5.39/mo, 4 GiB). All on-demand pricing.\n\n## Reproducing\n\nagentOS benchmarks live in the [agent-os repository](https://github.com/rivet-dev/agent-os) under `scripts/benchmarks/`.","src/content/docs/docs/benchmarks.mdx","b6a087cd9516c3ec","docs/configuration",{"id":36,"data":38,"body":44,"filePath":45,"digest":46,"deferredRender":16},{"title":39,"description":40,"editUrl":16,"head":41,"template":18,"sidebar":42,"pagefind":16,"draft":20},"Configuration","Configure the agentOS VM options, preview settings, and lifecycle hooks.",[],{"hidden":20,"attrs":43},{},"`agentOs()` accepts the following configuration object.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport { nodeModulesMount } from \"@rivet-dev/agentos-core\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: {\n // Filesystems to mount at boot. Use nodeModulesMount() to expose a host\n // node_modules tree at /root/node_modules.\n mounts: [nodeModulesMount(\"/path/to/project/node_modules\")],\n // Software packages to install in the VM (see /docs/software)\n software: [common, pi],\n // Ports exempt from SSRF checks\n loopbackExemptPorts: [3000],\n // Extra instructions appended to agent system prompts\n additionalInstructions: \"Always write tests first.\",\n },\n\n // Preview URL token lifetimes\n preview: {\n defaultExpiresInSeconds: 3600, // 1 hour (default)\n maxExpiresInSeconds: 86400, // 24 hours (default)\n },\n\n // Called when a client connects. Throw to reject. See /docs/authentication\n onBeforeConnect: async (c, params) => {\n const user = await verifyToken(params.token);\n if (!user) throw new Error(\"Unauthorized\");\n },\n // Called for every session event, server-side. Runs once per event.\n onSessionEvent: async (c, sessionId, event) => {\n console.log(\"Session event:\", sessionId, event.method);\n },\n // Called when an agent requests permission. See /docs/permissions\n onPermissionRequest: async (c, sessionId, request) => {\n await c.respondPermission(sessionId, request.permissionId, \"always\");\n },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n## Session options\n\nOptions passed to `createSession`. See [Sessions](/docs/sessions) for full documentation.\n\n## Timeouts\n\n| Setting | Default | Description |\n|---------|---------|-------------|\n| Action timeout | 15 minutes | Maximum time for any single action |\n| Sleep grace period | 15 minutes | Time before sleeping after all activity stops |\n\nThese are set internally by the `agentOs()` factory and cannot be overridden per-call. See [Persistence & Sleep](/docs/persistence) for details on the sleep lifecycle.","src/content/docs/docs/configuration.mdx","4b1075756e522ef7","docs/agent-to-agent",{"id":47,"data":49,"body":55,"filePath":56,"digest":57,"deferredRender":16},{"title":50,"description":51,"editUrl":16,"head":52,"template":18,"sidebar":53,"pagefind":16,"draft":20},"Agent-to-Agent Communication","Use host tools to let agents communicate with each other.",[],{"hidden":20,"attrs":54},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\nAgents communicate through [host tools](/docs/tools). You define a toolkit that lets one agent send work to another, and the agent calls it like any other CLI command.\n\n## Example: code writer + reviewer\n\nThis example creates a writer agent with a `review` tool. When the writer calls the tool, it reads the file from the writer's VM, writes it to a separate reviewer VM, and sends a review prompt.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\nimport { toolKit, hostTool } from \"@rivet-dev/agentos-core\";\nimport { createClient } from \"rivetkit/client\";\nimport { z } from \"zod\";\n\n// Tool that bridges the writer to the reviewer\nconst reviewToolkit = toolKit({\n name: \"review\",\n description: \"Send code to the reviewer agent\",\n tools: {\n submit: hostTool({\n description: \"Submit a file for code review\",\n inputSchema: z.object({\n path: z.string().describe(\"Path to the file to review\"),\n }),\n execute: async (input) => {\n const client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\n const writerHandle = client.writer.getOrCreate([\"my-project\"]);\n const reviewerHandle = client.reviewer.getOrCreate([\"my-project\"]);\n\n // Read file from writer, write to reviewer\n const content = await writerHandle.readFile(input.path);\n await reviewerHandle.writeFile(input.path, content);\n\n // Ask the reviewer to review\n const session = await reviewerHandle.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n const response = await reviewerHandle.sendPrompt(\n session.sessionId,\n `Review the code at ${input.path} and list any issues.`,\n );\n await reviewerHandle.closeSession(session.sessionId);\n\n return { review: response };\n },\n }),\n },\n});\n\n// Writer has the review toolkit, reviewer is plain\nconst writer = agentOs({\n options: { software: [common, pi], toolKits: [reviewToolkit] },\n});\nconst reviewer = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { writer, reviewer } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst writerAgent = client.writer.getOrCreate([\"my-project\"]);\n\nconst session = await writerAgent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\n\n// The writer will call `agentos-review submit --path /home/user/api.ts`\n// when it's ready for a review\nawait writerAgent.sendPrompt(\n session.sessionId,\n \"Write a REST API at /home/user/api.ts, then submit it for review.\",\n);\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\nThe writer agent sees the review tool as a CLI command:\n\n```bash\nagentos-review submit --path /home/user/api.ts\n```\n\nWhen the writer calls this, the host tool reads the file from the writer's VM, writes it to the reviewer's VM, and sends a prompt to the reviewer. The review result is returned to the writer as JSON.\n\n## Why host tools?\n\nHost tools are the natural communication layer between agents because:\n\n- **The agent doesn't need to know about other agents.** It just calls a tool. You can swap the implementation without changing the agent's behavior.\n- **No credentials in the VM.** The host tool executes on the server, so it can access other agents directly without exposing connection details.\n- **Composable.** Chain any number of agents by adding more tools. Each tool is a self-contained bridge to another agent.\n\n## Recommendations\n\n- Each agent has its own isolated VM and filesystem. Use `readFile`/`writeFile` in host tools to pass files between them.\n- Use [Queues](/docs/queues) when agents need to process work asynchronously.\n- Use [Workflows](/docs/workflows) to make multi-agent pipelines durable across restarts.","src/content/docs/docs/agent-to-agent.mdx","7ec4903d4382dd7d","docs/crash-course",{"id":58,"data":60,"body":66,"filePath":67,"digest":68,"deferredRender":16},{"title":61,"description":62,"editUrl":16,"head":63,"template":18,"sidebar":64,"pagefind":16,"draft":20},"Crash Course","Run coding agents inside isolated VMs with full filesystem, process, and network control.",[],{"hidden":20,"attrs":65},{},"import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';\n\n\u003CAside type=\"note\">\nagentOS is in preview and the API is subject to change. If you run into issues, please [report them on GitHub](https://github.com/rivet-dev/rivet/issues) or [join our Discord](https://rivet.dev/discord).\n\u003C/Aside>\n\n{/* SKILL_OVERVIEW_START */}\n\n## Features\n\n- **Isolated VMs**: Each agent gets its own filesystem, processes, and networking. No shared state, no cross-contamination.\n- **Multi-Agent Support**: Run Amp, Claude Code, Codex, OpenCode, and PI with a unified API. Swap agents without changing your code.\n- **Host Tools**: Expose your JavaScript functions to agents as CLI commands. Direct binding with near-zero latency and automatic code mode for up to 80% token reduction.\n- **Persistent State**: Filesystem and transcripts survive sleep/wake cycles automatically. No external database needed.\n- **Orchestration**: Workflows, queues, cron jobs, and multi-agent coordination built on Rivet Actors.\n- **Hybrid Sandboxes**: Run agents in the lightweight VM by default. Spin up a full sandbox on demand for browsers, compilation, and desktop automation.\n\n## When to Use agentOS\n\n- **Coding agents**: Run any coding agent with full OS access, file editing, shell execution, and tool use.\n- **Automated pipelines**: CI-like workflows where agents clone repos, fix bugs, run tests, and open PRs.\n- **Multi-agent systems**: Coordinators dispatching to specialized agents, review pipelines, planning chains.\n- **Scheduled maintenance**: Cron-based agents that audit code, update dependencies, or generate reports.\n- **Collaborative workspaces**: Multiple users observing and interacting with the same agent session in realtime.\n\n## Minimal Project\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Subscribe to streaming events\nagent.on(\"sessionEvent\", (data) => {\n console.log(data.event);\n});\n\n// Create a session and send a prompt\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nconst response = await agent.sendPrompt(\n session.sessionId,\n \"Write a hello world script to /home/user/hello.js\",\n);\nconsole.log(response);\n\n// Read the file the agent created\nconst content = await agent.readFile(\"/home/user/hello.js\");\nconsole.log(new TextDecoder().decode(content));\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\nAfter the quickstart, customize your agent with the [Registry](/agent-os/registry).\n\n## Quick Reference\n\n### Sessions & Transcripts\n\nCreate agent sessions, send prompts, and stream responses in realtime. Transcripts are persisted automatically across sleep/wake cycles.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Stream events as they arrive\nagent.on(\"sessionEvent\", (data) => {\n console.log(data.event.method, data.event);\n});\n\n// Create a session with MCP servers\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n mcpServers: [\n {\n type: \"local\",\n command: \"npx\",\n args: [\"-y\", \"@modelcontextprotocol/server-filesystem\", \"/home/user\"],\n env: {},\n },\n ],\n});\n\n// Send a prompt and wait for the response\nconst response = await agent.sendPrompt(\n session.sessionId,\n \"List all files in the home directory\",\n);\nconsole.log(response);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n[Documentation](/docs/sessions)\n\n### Permissions\n\nApprove or deny agent tool use with human-in-the-loop patterns or auto-approve for trusted workloads.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\n// Auto-approve all permissions server-side\nconst vm = agentOs({\n onPermissionRequest: async (c, sessionId, request) => {\n await c.respondPermission(sessionId, request.permissionId, \"always\");\n },\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Or handle permissions client-side for human-in-the-loop\nagent.on(\"permissionRequest\", async (data) => {\n console.log(\"Permission requested:\", data.request);\n // \"once\" | \"always\" | \"reject\"\n await agent.respondPermission(data.sessionId, data.request.permissionId, \"once\");\n});\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n[Documentation](/docs/permissions)\n\n### Tools\n\nExpose your JavaScript functions to agents as CLI commands inside the VM. Agents call them as shell commands with auto-generated flags from Zod schemas.\n\n```ts\nimport { toolKit, hostTool } from \"@rivet-dev/agentos-core\";\nimport { z } from \"zod\";\n\nconst myTools = toolKit({\n name: \"myapp\",\n description: \"Application tools\",\n tools: {\n createTicket: hostTool({\n description: \"Create a ticket in the issue tracker\",\n inputSchema: z.object({\n title: z.string().describe(\"Ticket title\"),\n priority: z.enum([\"low\", \"medium\", \"high\"]).describe(\"Priority level\"),\n }),\n execute: async (input) => {\n const ticket = await db.tickets.create(input);\n return { id: ticket.id, url: ticket.url };\n },\n }),\n },\n});\n\n// Agent calls: agentos-myapp createTicket --title \"Fix login\" --priority high\n```\n\n[Documentation](/docs/tools)\n\n### Filesystem\n\nRead, write, and manage files inside the VM. The `/home/user` directory is persisted automatically across sleep/wake cycles.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Write a file\nawait agent.writeFile(\"/home/user/config.json\", JSON.stringify({ key: \"value\" }));\n\n// Read a file\nconst content = await agent.readFile(\"/home/user/config.json\");\nconsole.log(new TextDecoder().decode(content));\n\n// List directory contents recursively\nconst files = await agent.readdirRecursive(\"/home/user\", { maxDepth: 2 });\nconsole.log(files);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n[Documentation](/docs/filesystem)\n\n### Processes & Shell\n\nExecute commands, spawn long-running processes, and open interactive shells.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// One-shot execution\nconst result = await agent.exec(\"echo hello && ls /home/user\");\nconsole.log(\"stdout:\", result.stdout);\nconsole.log(\"exit code:\", result.exitCode);\n\n// Spawn a long-running process\nagent.on(\"processOutput\", (data) => {\n console.log(`[pid ${data.pid}]`, new TextDecoder().decode(data.data));\n});\n\nconst { pid } = await agent.spawn(\"node\", [\"server.js\"]);\nconsole.log(\"Process ID:\", pid);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n[Documentation](/docs/processes)\n\n### Networking & Previews\n\nProxy HTTP requests into VMs with `vmFetch`. Create preview URLs for port forwarding VM services to shareable public URLs.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Fetch from a service running inside the VM\nconst response = await agent.vmFetch(3000, \"/api/health\");\nconsole.log(\"Status:\", response.status);\n\n// Create a preview URL (port forwarding to a public URL)\nconst preview = await agent.createSignedPreviewUrl(3000);\nconsole.log(\"Public URL:\", preview.path);\nconsole.log(\"Expires at:\", new Date(preview.expiresAt));\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n[Documentation](/docs/networking)\n\n### Cron Jobs\n\nSchedule recurring commands and agent sessions with cron expressions.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Schedule a command every hour\nawait agent.scheduleCron({\n schedule: \"0 * * * *\",\n action: { type: \"exec\", command: \"rm\", args: [\"-rf\", \"/tmp/cache/*\"] },\n});\n\n// Schedule an agent session daily at 9 AM\nawait agent.scheduleCron({\n schedule: \"0 9 * * *\",\n action: {\n type: \"session\",\n agentType: \"pi\",\n prompt: \"Review the codebase for security issues and write a report to /home/user/audit.md\",\n },\n});\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n[Documentation](/docs/cron)\n\n### Sandbox Mounting\n\nagentOS uses a hybrid model: agents run in a lightweight VM by default and mount a full sandbox on demand for heavy workloads like browsers, compilation, and desktop automation. Sandboxes are powered by [Sandbox Agent](https://sandboxagent.dev), so you can swap providers without changing agent code. Mount the sandbox as a filesystem and expose its process management as host tools.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport { SandboxAgent } from \"sandbox-agent\";\nimport { DockerProvider } from \"sandbox-agent/docker\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\nimport { createSandboxFs, createSandboxToolkit } from \"@rivet-dev/agentos-sandbox\";\n\nconst sandbox = await SandboxAgent.start({\n sandbox: new DockerProvider(),\n});\n\nconst vm = agentOs({\n options: {\n software: [common, pi],\n mounts: [\n {\n path: \"/sandbox\",\n driver: createSandboxFs({ client: sandbox }),\n },\n ],\n toolKits: [createSandboxToolkit({ client: sandbox })],\n },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n[Documentation](/docs/sandbox)\n\n### Multiplayer & Realtime\n\nConnect multiple clients to the same agent VM. All subscribers see session output, process logs, and shell data in realtime.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\n// Client A: creates the session and sends prompts\nconst clientA = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agentA = clientA.vm.getOrCreate([\"shared-agent\"]);\nagentA.on(\"sessionEvent\", (data) => console.log(\"[A]\", data.event.method));\n\nconst session = await agentA.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait agentA.sendPrompt(session.sessionId, \"Build a REST API\");\n\n// Client B: observes the same session (separate process)\nconst clientB = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agentB = clientB.vm.getOrCreate([\"shared-agent\"]);\nagentB.on(\"sessionEvent\", (data) => console.log(\"[B]\", data.event.method));\n// Client B sees the same events as Client A\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n[Documentation](/docs/multiplayer)\n\n### Agent-to-Agent\n\nCompose specialized agents into pipelines. Each agent gets its own isolated VM and filesystem.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst coder = agentOs({\n options: { software: [common, pi] },\n});\nconst reviewer = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { coder, reviewer } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\n\n// Coder writes the feature\nconst coderAgent = client.coder.getOrCreate([\"feature-auth\"]);\nconst coderSession = await coderAgent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait coderAgent.sendPrompt(coderSession.sessionId, \"Implement the login feature\");\n\n// Pass files to the reviewer\nconst src = await coderAgent.readFile(\"/home/user/src/auth.ts\");\nconst reviewerAgent = client.reviewer.getOrCreate([\"feature-auth\"]);\nawait reviewerAgent.writeFile(\"/home/user/src/auth.ts\", src);\n\n// Reviewer checks the code\nconst reviewSession = await reviewerAgent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait reviewerAgent.sendPrompt(\n reviewSession.sessionId,\n \"Review auth.ts for security issues\",\n);\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n[Documentation](/docs/agent-to-agent)\n\n### Workflows\n\nOrchestrate multi-step agent tasks with durable workflows that survive crashes and restarts.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\nimport { actor, setup, workflow } from \"rivetkit\";\n\nconst automator = actor({\n workflows: {\n fixBug: workflow\u003C{ repo: string; issue: string }>(),\n },\n run: async (c) => {\n for await (const message of c.workflow.iter(\"fixBug\")) {\n const { repo, issue } = message.body;\n const agentHandle = c.actors.vm.getOrCreate([`fix-${issue}`]);\n\n await c.step(\"clone-repo\", async (c) => {\n return agentHandle.exec(`git clone ${repo} /home/user/repo`);\n });\n\n await c.step(\"fix-bug\", async (c) => {\n const session = await agentHandle.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n const response = await agentHandle.sendPrompt(\n session.sessionId,\n `Fix the bug described in issue: ${issue}`,\n );\n await agentHandle.closeSession(session.sessionId);\n return response;\n });\n\n await c.step(\"run-tests\", async (c) => {\n return agentHandle.exec(\"cd /home/user/repo && npm test\");\n });\n\n await message.complete();\n }\n },\n});\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { automator, vm } });\nregistry.start();\n```\n\n[Documentation](/docs/workflows)\n\n### SQLite\n\nUse actor-local SQLite as structured long-term memory that persists across sessions and sleep/wake cycles.\n\n```ts\nimport { actor, setup } from \"rivetkit\";\nimport { db } from \"rivetkit/db\";\n\nconst memoryAgent = actor({\n db: db({\n onMigrate: async (db) => {\n await db.execute(`\n CREATE TABLE IF NOT EXISTS memories (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n session_id TEXT NOT NULL,\n category TEXT NOT NULL,\n content TEXT NOT NULL,\n created_at INTEGER NOT NULL\n );\n `);\n },\n }),\n actions: {\n store: async (c, sessionId: string, category: string, content: string) => {\n await c.db.execute(\n \"INSERT INTO memories (session_id, category, content, created_at) VALUES (?, ?, ?, ?)\",\n sessionId, category, content, Date.now(),\n );\n },\n search: async (c, query: string) => {\n return c.db.execute(\n \"SELECT category, content FROM memories WHERE content LIKE ? ORDER BY created_at DESC LIMIT 20\",\n `%${query}%`,\n );\n },\n },\n});\n```\n\n[Documentation](/docs/sqlite)\n\n{/* SKILL_OVERVIEW_END */}","src/content/docs/docs/crash-course.mdx","228d33a355ee2e94","docs/cron",{"id":69,"data":71,"body":77,"filePath":78,"digest":79,"deferredRender":16},{"title":72,"description":73,"editUrl":16,"head":74,"template":18,"sidebar":75,"pagefind":16,"draft":20},"Cron Jobs","Schedule recurring commands and agent sessions in agentOS VMs.",[],{"hidden":20,"attrs":76},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n- **Cron expressions** for flexible scheduling (e.g. `\"0 9 * * *\"` for 9 AM daily)\n- **Two action types**: `exec` for commands, `session` for agent sessions\n- **Overlap modes**: `allow`, `skip`, or `queue` concurrent executions\n- **Event streaming** via `cronEvent` for monitoring job execution\n\n## Schedule a command\n\nRun a shell command on a recurring schedule.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Schedule a cleanup script every hour\nconst { id } = await agent.scheduleCron({\n schedule: \"0 * * * *\",\n action: {\n type: \"exec\",\n command: \"rm\",\n args: [\"-rf\", \"/tmp/cache/*\"],\n },\n});\nconsole.log(\"Cron job ID:\", id);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Schedule an agent session\n\nCreate a recurring agent session that runs a prompt on a schedule.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Run an agent every day at 9 AM to check for issues\nawait agent.scheduleCron({\n schedule: \"0 9 * * *\",\n action: {\n type: \"session\",\n agentType: \"pi\",\n prompt: \"Review the logs in /home/user/logs/ and summarize any errors\",\n options: { cwd: \"/home/user\" },\n },\n});\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Overlap modes\n\nControl what happens when a cron job triggers while a previous execution is still running.\n\n| Mode | Behavior |\n|------|----------|\n| `\"skip\"` | Skip this trigger if the previous run is still active |\n| `\"allow\"` | Allow concurrent executions (default) |\n| `\"queue\"` | Queue this trigger and run it after the previous one finishes |\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Queue overlapping executions\nawait agent.scheduleCron({\n schedule: \"*/5 * * * *\",\n overlap: \"queue\",\n action: {\n type: \"session\",\n agentType: \"pi\",\n prompt: \"Process the next batch of tasks\",\n },\n});\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Monitor cron events\n\nSubscribe to `cronEvent` to track job execution.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nagent.on(\"cronEvent\", (data) => {\n console.log(\"Cron event:\", data.event);\n});\n\nawait agent.scheduleCron({\n schedule: \"*/1 * * * *\",\n action: { type: \"exec\", command: \"echo\", args: [\"heartbeat\"] },\n});\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## List and cancel cron jobs\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// List all cron jobs\nconst jobs = await agent.listCronJobs();\nfor (const job of jobs) {\n console.log(job.id, job.schedule);\n}\n\n// Cancel a specific job\nawait agent.cancelCronJob(jobs[0].id);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Example: Heartbeat pattern\n\nSchedule a recurring agent session to periodically check on a task. This is the core pattern behind [OpenClaw](https://openclaw.org), where an agent wakes up on a schedule to review progress, take action, and go back to sleep.\n\n```ts\nawait agent.scheduleCron({\n schedule: \"*/30 * * * *\",\n overlap: \"skip\",\n action: {\n type: \"session\",\n agentType: \"pi\",\n prompt: \"Check the status of open issues and take any necessary action\",\n },\n});\n```\n\nThe agent sleeps between executions and only consumes resources when the cron job fires.\n\n## Recommendations\n\n- Use `\"skip\"` overlap mode for most jobs. This prevents unbounded concurrency if a job takes longer than the interval. The default is `\"allow\"`.\n- Use `\"queue\"` when every trigger must execute, even if they back up.\n- Cron jobs keep the actor alive while executing. The actor can sleep between executions.\n- Provide a custom `id` when scheduling to make it easier to manage and cancel jobs later.","src/content/docs/docs/cron.mdx","22419df3f27e683b","docs/core",{"id":80,"data":82,"body":88,"filePath":89,"digest":90,"deferredRender":16},{"title":83,"description":84,"editUrl":16,"head":85,"template":18,"sidebar":86,"pagefind":16,"draft":20},"Core Package","Use @rivet-dev/agentos-core standalone for direct VM control without the Rivet Actor runtime.",[],{"hidden":20,"attrs":87},{},"## agentOS vs agentOS Core\n\nThe `agentOs()` actor (from `rivetkit/agent-os`) wraps the core package and adds:\n\n| | Core (`@rivet-dev/agentos-core`) | Actor (`rivetkit/agent-os`) |\n|-|---|---|\n| Persistence | In-memory by default (pluggable via [mounts](#mounts)) | Persistent filesystem and sessions |\n| Distributed state | Manage yourself | Built-in distributed statefulness |\n| Stateful VMs | Complex to run yourself | Built into Rivet |\n| Sleep/wake | Manual `dispose()` / `create()` | Automatic |\n| Events | Direct callbacks | Broadcasted to all connected clients |\n| Preview URLs | None | Built-in signed URL server |\n| Multiplayer | N/A | Multiple clients on same actor |\n| Orchestration | N/A | Workflows, queues, cron |\n| Agent-to-agent communication | Custom | Built into [Rivet Actors](/docs/agent-to-agent) |\n| Authentication | Set up yourself | [Documentation](/docs/authentication) |\n\nWe recommend using [Rivet Actors](/docs/actors) because they provide a portable way to run agentOS on any infrastructure with built-in persistence, networking, and orchestration. Use the core package if you need the most bare-bones implementation possible.\n\n## Install\n\n```bash\nnpm install @rivet-dev/agentos-core\n```\n\n## Boot a VM\n\n```ts\nimport { AgentOs } from \"@rivet-dev/agentos-core\";\nimport common from \"@agent-os-pkgs/common\";\n\nconst vm = await AgentOs.create({\n software: [common],\n});\n\n// Run a command\nconst result = await vm.exec(\"echo hello\");\nconsole.log(result.stdout); // \"hello\\n\"\n\nawait vm.dispose();\n```\n\n## Filesystem\n\n```ts\nimport { AgentOs } from \"@rivet-dev/agentos-core\";\nimport common from \"@agent-os-pkgs/common\";\n\nconst vm = await AgentOs.create({ software: [common] });\n\nawait vm.writeFile(\"/home/user/hello.txt\", \"Hello, world!\");\nconst content = await vm.readFile(\"/home/user/hello.txt\");\nconsole.log(new TextDecoder().decode(content));\n\nawait vm.mkdir(\"/home/user/src\");\nawait vm.writeFiles([\n { path: \"/home/user/src/index.ts\", content: \"console.log('hi');\" },\n { path: \"/home/user/src/utils.ts\", content: \"export const add = (a: number, b: number) => a + b;\" },\n]);\n\nconst entries = await vm.readdirRecursive(\"/home/user\");\nfor (const entry of entries) {\n console.log(entry.type, entry.path);\n}\n\nawait vm.dispose();\n```\n\n## Processes\n\n```ts\nimport { AgentOs } from \"@rivet-dev/agentos-core\";\nimport common from \"@agent-os-pkgs/common\";\n\nconst vm = await AgentOs.create({ software: [common] });\n\n// One-shot execution\nconst result = await vm.exec(\"ls -la /home/user\");\nconsole.log(result.stdout);\n\n// Long-running process with streaming output\nawait vm.writeFile(\"/tmp/server.mjs\", 'import http from \"http\"; http.createServer((req, res) => res.end(\"ok\")).listen(3000); console.log(\"listening\");');\nconst proc = vm.spawn(\"node\", [\"/tmp/server.mjs\"]);\nvm.onProcessStdout(proc.pid, (data) => {\n console.log(\"stdout:\", new TextDecoder().decode(data));\n});\nvm.onProcessExit(proc.pid, (code) => {\n console.log(\"exited:\", code);\n});\n\n// Write to stdin\nvm.writeProcessStdin(proc.pid, \"some input\\n\");\n\n// Stop or kill\nvm.stopProcess(proc.pid);\n\nawait vm.dispose();\n```\n\n## Agent sessions\n\nThe core package returns a `sessionId` string. All session operations are called on the `vm` instance with the session ID.\n\n```ts\nimport { AgentOs } from \"@rivet-dev/agentos-core\";\nimport common from \"@agent-os-pkgs/common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = await AgentOs.create({ software: [common, pi] });\n\nconst { sessionId } = await vm.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\n\n// Stream events (each event is a JSON-RPC notification)\nvm.onSessionEvent(sessionId, (event) => {\n console.log(event.method, event.params);\n});\n\n// Handle permissions\nvm.onPermissionRequest(sessionId, (request) => {\n console.log(\"Permission:\", request.description);\n // Reply with \"once\", \"always\", or \"reject\"\n vm.respondPermission(sessionId, request.permissionId, \"once\");\n});\n\n// Send a prompt. prompt() resolves to { response, text }, where `text` is the\n// accumulated agent message text and `response` is the raw JSON-RPC response.\nconst { text } = await vm.prompt(sessionId, \"Write a hello world script\");\nconsole.log(text);\n\n// Configure the session\nawait vm.setSessionModel(sessionId, \"claude-sonnet-4-6\");\nawait vm.setSessionMode(sessionId, \"plan\");\n\nvm.closeSession(sessionId);\nawait vm.dispose();\n```\n\nSession events are live-only: subscribe with `onSessionEvent()` before sending a\nprompt. There is no replay buffer or event history to read back after the fact.\n\n## Interactive shell\n\n```ts\nimport { AgentOs } from \"@rivet-dev/agentos-core\";\nimport common from \"@agent-os-pkgs/common\";\n\nconst vm = await AgentOs.create({ software: [common] });\n\nconst { shellId } = vm.openShell();\n\nvm.onShellData(shellId, (data) => {\n process.stdout.write(new TextDecoder().decode(data));\n});\n\nvm.writeShell(shellId, \"echo hello from shell\\n\");\n\n// Resize terminal\nvm.resizeShell(shellId, 120, 40);\n\nvm.closeShell(shellId);\nawait vm.dispose();\n```\n\n## Networking\n\n```ts\nimport { AgentOs } from \"@rivet-dev/agentos-core\";\nimport common from \"@agent-os-pkgs/common\";\n\nconst vm = await AgentOs.create({ software: [common] });\n\n// Start a server inside the VM\nawait vm.writeFile(\"/tmp/app.mjs\", 'import http from \"http\"; http.createServer((req, res) => res.end(\"hello\")).listen(3000);');\nvm.spawn(\"node\", [\"/tmp/app.mjs\"]);\n\n// Fetch from it\nconst response = await vm.fetch(3000, new Request(\"http://localhost/\"));\nconsole.log(await response.text());\n\nawait vm.dispose();\n```\n\n## Cron jobs\n\nThe core package supports a `\"callback\"` action type in addition to `\"exec\"` and `\"session\"`.\n\n```ts\nimport { AgentOs } from \"@rivet-dev/agentos-core\";\nimport common from \"@agent-os-pkgs/common\";\n\nconst vm = await AgentOs.create({ software: [common] });\n\nconst job = vm.scheduleCron({\n id: \"cleanup\",\n schedule: \"0 * * * *\",\n action: { type: \"exec\", command: \"rm\", args: [\"-rf\", \"/tmp/cache\"] },\n});\n\n// Or use a callback (not available in the actor wrapper)\nvm.scheduleCron({\n schedule: \"*/5 * * * *\",\n action: {\n type: \"callback\",\n fn: async () => {\n console.log(\"Custom logic every 5 minutes\");\n },\n },\n});\n\nvm.onCronEvent((event) => {\n if (event.type === \"cron:fire\") console.log(\"Job fired:\", event.jobId);\n if (event.type === \"cron:complete\") console.log(\"Job done:\", event.jobId, event.durationMs, \"ms\");\n if (event.type === \"cron:error\") console.error(\"Job error:\", event.error);\n});\n\nconsole.log(vm.listCronJobs());\njob.cancel();\n\nawait vm.dispose();\n```\n\n## Mounts\n\nConfigure filesystem backends at boot time.\n\nNative mount plugins (host directories, S3, etc.) are passed via `plugin`, while\nJavaScript filesystem backends are passed via `driver`.\n\n```ts\nimport { AgentOs, createHostDirBackend, createInMemoryFileSystem } from \"@rivet-dev/agentos-core\";\nimport { createS3Backend } from \"@secure-exec/s3\";\nimport common from \"@agent-os-pkgs/common\";\n\nconst vm = await AgentOs.create({\n software: [common],\n mounts: [\n // Host directory (read-only)\n { path: \"/mnt/code\", plugin: createHostDirBackend({ hostPath: \"/path/to/repo\", readOnly: true }) },\n // S3 bucket\n { path: \"/mnt/data\", plugin: createS3Backend({ bucket: \"my-bucket\", prefix: \"agent/\" }) },\n // In-memory scratch space\n { path: \"/mnt/scratch\", driver: createInMemoryFileSystem() },\n ],\n});\n\nconst files = await vm.readdir(\"/mnt/code\");\nconsole.log(files);\n\nawait vm.dispose();\n```\n\n## What you give up without the actor\n\n- **No built-in persistence.** The default filesystem is in-memory and lost on `dispose()`. You can configure your own [mounts](#mounts) (S3, host directories, etc.) for persistence.\n- **No sleep/wake.** You manage the full VM lifecycle yourself.\n- **No event broadcasting.** Events are local callbacks, not distributed to remote clients.\n- **No preview URLs.** No built-in HTTP server for sharing VM services.\n- **No multiplayer.** Single-process, single-client only.\n- **No orchestration.** No workflows, queues, or scheduling integration.\n- **No session persistence.** Session history is lost on dispose.\n\nIf you need any of these, use the [`agentOs()` actor](/docs/quickstart) instead.","src/content/docs/docs/core.mdx","e3cd4d1f9f9b1684","docs/deployment",{"id":91,"data":93,"body":99,"filePath":100,"digest":101,"deferredRender":16},{"title":94,"description":95,"editUrl":16,"head":96,"template":18,"sidebar":97,"pagefind":16,"draft":20},"Deployment","Choose the right deployment option for agentOS.",[],{"hidden":20,"attrs":98},{},"agentOS supports multiple deployment models depending on your needs.\n\n| Option | Description | Best for |\n|--------|-------------|----------|\n| **Local Dev** | Run locally with `npx rivetkit dev`. No infrastructure needed. | Development and testing |\n| **[Rivet Cloud](/cloud)** | Fully managed hosting on Rivet Compute or bring your own cloud (BYOC). | Teams that want zero-ops deployment |\n| **[Rivet Self-Hosted](/docs/self-hosting)** | Run the full Rivet platform on your own infrastructure. | Organizations that need full control |\n| **[Rivet Enterprise](/sales)** | Self-hosted with dedicated support, custom SLAs, and compliance reviews. | Regulated industries and large-scale deployments |\n| **agentOS Core** | Use `@rivet-dev/agentos-core` directly without the Rivet platform. Embed the runtime in any Node.js backend. | Custom integrations and existing infrastructure |","src/content/docs/docs/deployment.mdx","a02615a45f9b750c","docs/events",{"id":102,"data":104,"body":110,"filePath":111,"digest":112,"deferredRender":16},{"title":105,"description":106,"editUrl":16,"head":107,"template":18,"sidebar":108,"pagefind":16,"draft":20},"Events","Full event catalog with payload shapes for agentOS.",[],{"hidden":20,"attrs":109},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n## Event types\n\n### sessionEvent\n\nEmitted for every agent session event (streaming output, errors, status changes).\n\n```ts\nagent.on(\"sessionEvent\", (data) => {\n // data.sessionId: string\n // data.event: JsonRpcNotification (method, params)\n console.log(data.sessionId, data.event.method, data.event.params);\n});\n```\n\nEvents are also persisted to SQLite for replay via `getSessionEvents`.\n\n### permissionRequest\n\nEmitted when an agent requests permission to use a tool.\n\n```ts\nagent.on(\"permissionRequest\", async (data) => {\n // data.sessionId: string\n // data.request: PermissionRequest (permissionId, description, params)\n console.log(\"Permission requested:\", data.request);\n\n await agent.respondPermission(data.sessionId, data.request.permissionId, \"once\");\n});\n```\n\nSee [Permissions](/docs/permissions) for approval patterns.\n\n### processOutput\n\nEmitted when a spawned process writes to stdout or stderr.\n\n```ts\nagent.on(\"processOutput\", (data) => {\n // data.pid: number\n // data.stream: \"stdout\" | \"stderr\"\n // data.data: Uint8Array\n const text = new TextDecoder().decode(data.data);\n console.log(`[${data.pid}] ${data.stream}: ${text}`);\n});\n```\n\n### processExit\n\nEmitted when a spawned process exits.\n\n```ts\nagent.on(\"processExit\", (data) => {\n // data.pid: number\n // data.exitCode: number\n console.log(`Process ${data.pid} exited with code ${data.exitCode}`);\n});\n```\n\n### shellData\n\nEmitted when an interactive shell produces output.\n\n```ts\nagent.on(\"shellData\", (data) => {\n // data.shellId: string\n // data.data: Uint8Array\n const text = new TextDecoder().decode(data.data);\n process.stdout.write(text);\n});\n```\n\n### cronEvent\n\nEmitted when a cron job runs.\n\n```ts\nagent.on(\"cronEvent\", (data) => {\n // data.event: CronEvent\n console.log(\"Cron event:\", data.event);\n});\n```\n\n### vmBooted\n\nEmitted when the VM finishes booting. No payload.\n\n```ts\nagent.on(\"vmBooted\", () => {\n console.log(\"VM is ready\");\n});\n```\n\n### vmShutdown\n\nEmitted when the VM is shutting down.\n\n```ts\nagent.on(\"vmShutdown\", (data) => {\n // data.reason: \"sleep\" | \"destroy\" | \"error\"\n console.log(\"VM shutting down:\", data.reason);\n});\n```\n\n## Client subscription pattern\n\nSubscribe to events before triggering actions to avoid missing early events.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Subscribe to all relevant events first\nagent.on(\"sessionEvent\", (data) => {\n console.log(\"Session:\", data.event.method);\n});\nagent.on(\"processOutput\", (data) => {\n console.log(\"Process:\", new TextDecoder().decode(data.data));\n});\nagent.on(\"processExit\", (data) => {\n console.log(\"Exit:\", data.pid, data.exitCode);\n});\n\n// Then trigger actions\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait agent.sendPrompt(session.sessionId, \"Run the test suite\");\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Event replay\n\nThere are two ways to replay session events:\n\n- **`getSequencedEvents`** returns events from the in-memory session. Each event has a `sequenceNumber` and a `notification` (the raw JSON-RPC notification). Use this for live reconnection while the VM is running.\n- **`getSessionEvents`** returns events from persisted storage (SQLite). Each event has a `seq`, `event`, and `createdAt`. Use this for transcript history, including when the VM is not running.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Replay events from sequence 0\nconst events = await agent.getSequencedEvents(\"session-id\", { since: 0 });\nfor (const e of events) {\n console.log(e.sequenceNumber, e.notification.method);\n}\n\n// Replay from persisted storage (works without running VM)\nconst persisted = await agent.getSessionEvents(\"session-id\");\nfor (const e of persisted) {\n console.log(e.seq, e.event.method, e.createdAt);\n}\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>","src/content/docs/docs/events.mdx","17dd9e05d4d11190","docs/filesystem",{"id":113,"data":115,"body":121,"filePath":122,"digest":123,"deferredRender":16},{"title":116,"description":117,"editUrl":16,"head":118,"template":18,"sidebar":119,"pagefind":16,"draft":20},"Filesystem","Read, write, mount, and manage files inside agentOS.",[],{"hidden":20,"attrs":120},{},"Every VM comes with a persistent filesystem out of the box. Files written anywhere in the VM are automatically saved to the Rivet Actor's built-in storage and restored on wake. No configuration needed.\n\n- **Persistent by default** backed by Rivet Actor storage, up to 10 GB\n- **Full POSIX filesystem** with read, write, mkdir, stat, move, delete\n- **Batch operations** for reading and writing multiple files at once\n- **Mount backends** for additional storage like S3, host directories, and overlays\n\n## Mounting filesystems\n\nThe default filesystem persists automatically across sleep/wake cycles with no setup required (up to 10 GB). For larger storage or external data, mount additional filesystem drivers via the `options.mounts` config.\n\nEach mount takes a `path` (where to mount inside the VM) and an optional `readOnly` flag. JavaScript filesystem backends like `createInMemoryFileSystem()` are passed as `driver`, while native plugin backends like `createHostDirBackend()`, `createS3Backend()`, and `createGoogleDriveBackend()` are passed as `plugin`.\n\n### In-memory\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport { createInMemoryFileSystem } from \"@rivet-dev/agentos-core\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: {\n software: [common, pi],\n mounts: [\n { path: \"/mnt/scratch\", driver: createInMemoryFileSystem() },\n ],\n },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n### Host directory\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport { createHostDirBackend } from \"@rivet-dev/agentos-core\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: {\n software: [common, pi],\n mounts: [\n { path: \"/mnt/code\", plugin: createHostDirBackend({ hostPath: \"/path/to/repo\" }), readOnly: true },\n ],\n },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n### S3\n\nInstall `@rivet-dev/agentos-s3` for S3-compatible storage.\n\nUse `createS3Backend` to mount a bucket. Pass an optional `prefix` to scope storage to a key path within the bucket — useful for sharing one bucket across multiple agents.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport { createS3Backend } from \"@rivet-dev/agentos-s3\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: {\n software: [common, pi],\n mounts: [\n {\n path: \"/mnt/data\",\n plugin: createS3Backend({\n bucket: \"my-bucket\",\n prefix: \"agent-data/\",\n region: \"us-east-1\",\n }),\n },\n ],\n },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n`createS3Backend` also accepts `credentials` (`{ accessKeyId, secretAccessKey }`) and a custom `endpoint` for S3-compatible providers.\n\n### Google Drive\n\nInstall `@rivet-dev/agentos-google-drive` for Google Drive storage.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport { createGoogleDriveBackend } from \"@rivet-dev/agentos-google-drive\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: {\n software: [common, pi],\n mounts: [\n {\n path: \"/mnt/drive\",\n plugin: createGoogleDriveBackend({\n credentials: {\n clientEmail: process.env.GOOGLE_DRIVE_CLIENT_EMAIL!,\n privateKey: process.env.GOOGLE_DRIVE_PRIVATE_KEY!,\n },\n folderId: process.env.GOOGLE_DRIVE_FOLDER_ID!,\n }),\n },\n ],\n },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n## Filesystem operations\n\n### Read and write\n\n```ts\n// Write a file (string or Uint8Array)\nawait agent.writeFile(\"/home/user/hello.txt\", \"Hello, world!\");\n\n// Read a file (returns Uint8Array)\nconst content = await agent.readFile(\"/home/user/hello.txt\");\nconsole.log(new TextDecoder().decode(content));\n```\n\n### Batch read and write\n\n```ts\n// Batch write (creates parent directories automatically)\nconst writeResults = await agent.writeFiles([\n { path: \"/home/user/src/index.ts\", content: \"console.log('hello');\" },\n { path: \"/home/user/src/utils.ts\", content: \"export function add(a: number, b: number) { return a + b; }\" },\n]);\n\n// Batch read\nconst readResults = await agent.readFiles([\n \"/home/user/src/index.ts\",\n \"/home/user/src/utils.ts\",\n]);\nfor (const result of readResults) {\n console.log(result.path, new TextDecoder().decode(result.content));\n}\n```\n\n### Directories\n\n```ts\n// Create a directory\nawait agent.mkdir(\"/home/user/projects\");\n\n// List directory contents\nconst entries = await agent.readdir(\"/home/user/projects\");\n\n// Recursive listing with metadata\nconst tree = await agent.readdirRecursive(\"/home/user\", {\n maxDepth: 3,\n exclude: [\"node_modules\"],\n});\nfor (const entry of tree) {\n console.log(entry.type, entry.path, entry.size);\n}\n```\n\n### File metadata\n\n```ts\n// Check if a path exists\nconst fileExists = await agent.exists(\"/home/user/hello.txt\");\n\n// Get file metadata\nconst info = await agent.stat(\"/home/user/hello.txt\");\nconsole.log(info.size, info.isDirectory, info.mtimeMs);\n```\n\n### Move and delete\n\n```ts\n// Move/rename\nawait agent.move(\"/home/user/old.txt\", \"/home/user/new.txt\");\n\n// Delete a file\nawait agent.delete(\"/home/user/new.txt\");\n\n// Delete a directory recursively\nawait agent.delete(\"/home/user/temp\", { recursive: true });\n```","src/content/docs/docs/filesystem.mdx","fdbebac3bb8126bc",{"id":9,"data":125,"body":130,"filePath":131,"digest":132,"deferredRender":16},{"title":126,"description":62,"editUrl":16,"head":127,"tableOfContents":20,"template":18,"sidebar":128,"pagefind":16,"draft":20},"Introduction",[],{"hidden":20,"attrs":129},{},"import DocsLanding from '@rivet-dev/docs-theme/components/DocsLanding.astro';\nimport AgentOSHeroLogo from '../../../components/AgentOSHeroLogo.astro';\n\n\u003CDocsLanding>\n\t\u003CAgentOSHeroLogo slot=\"hero\" />\n\u003C/DocsLanding>","src/content/docs/docs/index.mdx","352df9dbb91266b5","docs/limitations",{"id":133,"data":135,"body":141,"filePath":142,"digest":143,"deferredRender":16},{"title":136,"description":137,"editUrl":16,"head":138,"template":18,"sidebar":139,"pagefind":16,"draft":20},"Limitations","What the agentOS VM does not support, and how to work around it.",[],{"hidden":20,"attrs":140},{},"agentOS is a Linux-like environment with a POSIX-compliant virtual kernel. It handles most agent workloads (coding, scripting, file I/O, networking) with near-zero overhead.\n\n## Sandbox mounting\n\nWhen a workload needs a full Linux OS, agents can escalate to a full sandbox on demand without changing code. The [sandbox mounting](/docs/sandbox) extension mounts the sandbox as a filesystem and lets you execute commands on it, like mounting a hard drive on your own machine. Files written in the VM are available in the sandbox and vice versa.\n\nSee [agentOS vs Sandbox](/docs/versus-sandbox) for a detailed comparison.\n\n## Limitations\n\n### Software registry\n\nagentOS uses its own [software registry](/agent-os/registry) of popular tools cross-compiled for the runtime. You cannot download and install arbitrary binaries (for example via `curl` or `apt`), and standard Linux package managers (`apt`, `yum`) are not available since agentOS is not a full Linux OS. Native binaries that are not yet available in the registry (such as Go, Rust, or C++ toolchains) require a full [sandbox](/docs/sandbox).\n\nSee [Software](/docs/software) for how to install and configure available packages.\n\n### POSIX-compliant, not full Linux\n\nagentOS provides a POSIX-compliant virtual kernel with full filesystem operations, networking, and process management. It is not a full Linux kernel, so some Linux-specific features are not available:\n\n- Kernel modules and eBPF\n- Container runtimes (e.g. Docker)\n- File watching (`inotify`, `fs.watch`)\n\n### No hardware access\n\nThe VM has no access to GPUs, USB devices, or other hardware.","src/content/docs/docs/limitations.mdx","22dd054a560a7bea","docs/llm-credentials",{"id":144,"data":146,"body":152,"filePath":153,"digest":154,"deferredRender":16},{"title":147,"description":148,"editUrl":16,"head":149,"template":18,"sidebar":150,"pagefind":16,"draft":20},"LLM Credentials","Pass LLM API keys to agent sessions securely.",[],{"hidden":20,"attrs":151},{},"- **Keys stay on the server** and are injected at session creation\n- **Per-tenant isolation** for multi-tenant deployments\n\n## Passing API keys\n\nPass LLM provider keys via the `env` option on `createSession`. The VM does not inherit from the host `process.env`, so keys must be passed explicitly.\n\n```ts\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\n```\n\n## Per-tenant credentials\n\nLook up each tenant's API key from your database and pass it at session creation.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup, UserError } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\ninterface ConnState {\n userId: string;\n anthropicApiKey: string;\n}\n\nconst vm = agentOs({\n createConnState: async (c, params: { authToken: string }): Promise\u003CConnState> => {\n const user = await validateAndLookupUser(params.authToken);\n if (!user) {\n throw new UserError(\"Forbidden\", { code: \"forbidden\" });\n }\n return { userId: user.id, anthropicApiKey: user.anthropicApiKey };\n },\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\nThen use the connection state when creating sessions:\n\n```ts\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: c.conn.state.anthropicApiKey },\n});\n```\n\nSee [Sandbox Agent's LLM credentials documentation](https://sandboxagent.dev/docs/llm-credentials) for more details on per-tenant token patterns.\n\n## Embedded LLM Gateway\n\nThe [Embedded LLM Gateway](/docs/llm-gateway) (coming soon) will remove the need to manage API keys manually. It routes all agent LLM requests through a managed proxy built into agentOS, providing per-tenant usage metering, rate limiting, and cost controls without deploying a separate gateway service.","src/content/docs/docs/llm-credentials.mdx","fbc8cc10fbfd4b55","docs/llm-gateway",{"id":155,"data":157,"body":163,"filePath":164,"digest":165,"deferredRender":16},{"title":158,"description":159,"editUrl":16,"head":160,"template":18,"sidebar":161,"pagefind":16,"draft":20},"Embedded LLM Gateway","Route, meter, and manage LLM API calls from agents.",[],{"hidden":20,"attrs":162},{},"{/* TODO: This page is coming soon. */}\n\nThe Embedded LLM Gateway runs as part of the agentOS library, not as an external service. It intercepts and manages all LLM API calls made by agents inside the VM.\n\n- **Unified routing** for all agent LLM requests\n- **API keys stay on the server** so they are never exposed to agent code inside the VM\n- **Usage metering** with per-session and per-agent breakdowns\n- **Rate limiting** and cost controls\n\nCheck back soon for full documentation.","src/content/docs/docs/llm-gateway.mdx","cbe27275943ef64e","docs/multiplayer",{"id":166,"data":168,"body":174,"filePath":175,"digest":176,"deferredRender":16},{"title":169,"description":170,"editUrl":16,"head":171,"template":18,"sidebar":172,"pagefind":16,"draft":20},"Multiplayer","Connect multiple clients to the same agentOS actor for collaborative agent workflows.",[],{"hidden":20,"attrs":173},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n- **Multiple clients** connected to the same agent VM simultaneously\n- **Broadcast events** so all subscribers see session output, process logs, and shell data\n- **Collaborative patterns** where one user prompts and others observe\n- **Handoff** between human and agent control\n\n## Multiple clients observing a session\n\nAll clients connected to the same actor receive broadcasted events. This enables building collaborative UIs where multiple users watch an agent work.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\n// Client A: creates the session and sends prompts\nconst clientA = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agentA = clientA.vm.getOrCreate([\"shared-agent\"]);\n\nagentA.on(\"sessionEvent\", (data) => {\n console.log(\"[A]\", data.event.method);\n});\n\nconst session = await agentA.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait agentA.sendPrompt(session.sessionId, \"Build a REST API\");\n\n// Client B: observes the same session (in a separate process)\nconst clientB = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agentB = clientB.vm.getOrCreate([\"shared-agent\"]);\n\nagentB.on(\"sessionEvent\", (data) => {\n console.log(\"[B]\", data.event.method);\n});\n\n// Client B sees the same events as Client A\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Shared process output\n\nAll clients receive process output events from the same VM.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"shared-agent\"]);\n\n// All connected clients see process output\nagent.on(\"processOutput\", (data) => {\n const text = new TextDecoder().decode(data.data);\n console.log(`[pid ${data.pid}] ${data.stream}: ${text}`);\n});\n\n// All connected clients see shell data\nagent.on(\"shellData\", (data) => {\n const text = new TextDecoder().decode(data.data);\n process.stdout.write(text);\n});\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Collaborative prompt/observe pattern\n\nOne client acts as the driver (sending prompts), while others observe.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n onSessionEvent: async (c, sessionId, event) => {\n // Server-side hook runs once per event, even with multiple clients\n console.log(\"Session event:\", sessionId, event.method);\n },\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\n// Driver client\nconst driver = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst driverAgent = driver.vm.getOrCreate([\"shared-agent\"]);\n\nconst session = await driverAgent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\n\n// Observer client (different user, same actor)\nconst observer = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst observerAgent = observer.vm.getOrCreate([\"shared-agent\"]);\n\nobserverAgent.on(\"sessionEvent\", (data) => {\n console.log(\"[observer]\", data.event.method, data.event.params);\n});\n\n// Driver sends a prompt. Observer sees the streaming response.\nawait driverAgent.sendPrompt(session.sessionId, \"Refactor the auth module\");\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Reconnection with event replay\n\nWhen a client reconnects, use `getSequencedEvents` to replay missed events and catch up.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"shared-agent\"]);\n\n// On reconnect, replay events from the last known sequence number\nconst lastSeq = 42; // Track this on the client side\nconst missedEvents = await agent.getSequencedEvents(\"session-id\", {\n since: lastSeq,\n});\nfor (const event of missedEvents) {\n console.log(\"Replaying:\", event.sequenceNumber, event.notification.method);\n}\n\n// Resume live streaming\nagent.on(\"sessionEvent\", (data) => {\n console.log(\"Live:\", data.event.method);\n});\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Recommendations\n\n- Use the same actor key (e.g. `[\"shared-agent\"]`) for all clients that should share the same VM.\n- Events are broadcasted to all connected clients automatically. No additional setup needed.\n- For reconnection, track the last sequence number on the client and use `getSequencedEvents` to replay missed events.\n- Use the server-side `onSessionEvent` hook for logic that should run once per event regardless of connected clients.","src/content/docs/docs/multiplayer.mdx","a25670dee66aec00","docs/networking",{"id":177,"data":179,"body":185,"filePath":186,"digest":187,"deferredRender":16},{"title":180,"description":181,"editUrl":16,"head":182,"template":18,"sidebar":183,"pagefind":16,"draft":20},"Networking & Previews","Proxy HTTP requests into agentOS VMs and create shareable preview URLs.",[],{"hidden":20,"attrs":184},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n- **`vmFetch`** proxies HTTP requests to services running inside the VM\n- **Preview URLs** create time-limited, shareable public URLs to VM services\n- **Token-based access** with configurable expiration and revocation\n- **CORS enabled** for browser access to preview URLs\n\n## Fetch from a VM service\n\nUse `vmFetch` to send HTTP requests to a service running inside the VM.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Start a web server inside the VM\nawait agent.writeFile(\n \"/home/user/server.js\",\n 'require(\"http\").createServer((req, res) => res.end(\"Hello from VM\")).listen(3000);',\n);\nawait agent.spawn(\"node\", [\"/home/user/server.js\"]);\n\n// Fetch from the VM service\nconst response = await agent.vmFetch(3000, \"/\");\nconsole.log(\"Status:\", response.status);\nconsole.log(\"Body:\", new TextDecoder().decode(response.body));\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## vmFetch with options\n\nSend requests with custom methods, headers, and body.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst response = await agent.vmFetch(3000, \"/api/data\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ key: \"value\" }),\n});\n\nconsole.log(\"Status:\", response.status, response.statusText);\nconsole.log(\"Headers:\", response.headers);\nconsole.log(\"Body:\", new TextDecoder().decode(response.body));\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Create a preview URL\n\nPreview URLs are essentially port forwarding for VM services. They create a time-limited, publicly accessible URL that proxies HTTP requests to a specific port inside the VM. Use them to share web app previews with users, embed dev servers in iframes, or give external tools access to services running inside the agent's VM.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n preview: {\n defaultExpiresInSeconds: 3600, // 1 hour default\n maxExpiresInSeconds: 86400, // 24 hour maximum\n },\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Start a web app in the VM\nawait agent.spawn(\"node\", [\"/home/user/app.js\"]);\n\n// Create a preview URL (default 1 hour expiration)\nconst preview = await agent.createSignedPreviewUrl(3000);\nconsole.log(\"Preview path:\", preview.path);\nconsole.log(\"Token:\", preview.token);\nconsole.log(\"Expires at:\", new Date(preview.expiresAt));\n\n// Create a preview URL with custom expiration\nconst shortPreview = await agent.createSignedPreviewUrl(3000, 300); // 5 minutes\nconsole.log(\"Short-lived preview:\", shortPreview.path);\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Revoke a preview URL\n\nUse `expireSignedPreviewUrl` to immediately revoke a preview token.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst preview = await agent.createSignedPreviewUrl(3000);\n\n// Revoke the token immediately\nawait agent.expireSignedPreviewUrl(preview.token);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Recommendations\n\n- Preview tokens are stored in SQLite and survive sleep/wake cycles. Expired tokens are cleaned up automatically.\n- Default preview expiration is 1 hour. Configure `preview.maxExpiresInSeconds` to cap the maximum lifetime.\n- CORS is enabled on preview URLs, allowing browser access from any origin.\n- Use `vmFetch` for server-to-server access. Use preview URLs for browser or external access.\n- See [Security](/docs/security) for more on preview URL token security.","src/content/docs/docs/networking.mdx","4961c067c2010ae4","docs/permissions",{"id":188,"data":190,"body":196,"filePath":197,"digest":198,"deferredRender":16},{"title":191,"description":192,"editUrl":16,"head":193,"template":18,"sidebar":194,"pagefind":16,"draft":20},"Permissions","Approve or deny agent tool use with human-in-the-loop or auto-approve patterns.",[],{"hidden":20,"attrs":195},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n- **Human-in-the-loop** approval for agent tool use (file writes, command execution, etc.)\n- **Auto-approve** patterns for trusted workloads\n- **Server-side hooks** for programmatic permission decisions\n- **Client-side subscriptions** for building approval UIs\n\n## Permission request flow\n\nWhen an agent wants to use a tool (e.g. write a file, run a command), it emits a `permissionRequest` event. Your code responds with `respondPermission` to approve or deny.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Listen for permission requests\nagent.on(\"permissionRequest\", async (data) => {\n console.log(\"Permission requested:\", data.request);\n\n // Approve this single request\n await agent.respondPermission(\n data.sessionId,\n data.request.permissionId,\n \"once\",\n );\n});\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait agent.sendPrompt(session.sessionId, \"Create a new file at /home/user/output.txt\");\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Permission reply options\n\n| Reply | Behavior |\n|-------|----------|\n| `\"once\"` | Approve this single request |\n| `\"always\"` | Approve this and all future requests of the same type |\n| `\"reject\"` | Deny the request |\n\n## Server-side auto-approve\n\nUse the `onPermissionRequest` hook in the actor config to approve permissions server-side without client involvement. This is useful for fully automated pipelines.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n onPermissionRequest: async (c, sessionId, request) => {\n // Auto-approve all file operations\n await c.respondPermission(sessionId, request.permissionId, \"always\");\n },\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// No need to handle permissions on the client. The server auto-approves.\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait agent.sendPrompt(session.sessionId, \"Write files as needed\");\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Selective approval\n\nInspect the permission request to make approval decisions based on the tool or path.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n onPermissionRequest: async (c, sessionId, request) => {\n // `request.description` and `request.params` carry the raw ACP\n // permission details (the requested tool, paths, etc.).\n // Auto-approve reads, require manual approval for writes.\n const description = request.description ?? \"\";\n if (description.toLowerCase().includes(\"read\")) {\n await c.respondPermission(sessionId, request.permissionId, \"always\");\n }\n // Anything not handled here is forwarded to the client via the\n // permissionRequest event.\n },\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Only write permissions reach the client\nagent.on(\"permissionRequest\", async (data) => {\n const approved = confirm(`Allow write: ${JSON.stringify(data.request)}?`);\n await agent.respondPermission(\n data.sessionId,\n data.request.permissionId,\n approved ? \"once\" : \"reject\",\n );\n});\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait agent.sendPrompt(session.sessionId, \"Read config.json and update it\");\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Recommendations\n\n- Use `\"always\"` sparingly. It approves all future requests of that type for the session lifetime.\n- For automated CI/CD pipelines, use the server-side `onPermissionRequest` hook to auto-approve without client round-trips.\n- For interactive applications, subscribe to `permissionRequest` on the client and build an approval UI.\n- If neither the server hook nor the client responds, the agent blocks until a response is given or the action times out.","src/content/docs/docs/permissions.mdx","5585f46f524d390a","docs/persistence",{"id":199,"data":201,"body":207,"filePath":208,"digest":209,"deferredRender":16},{"title":202,"description":203,"editUrl":16,"head":204,"template":18,"sidebar":205,"pagefind":16,"draft":20},"Persistence & Sleep","How agentOS persists data and manages sleep/wake cycles.",[],{"hidden":20,"attrs":206},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n- **Persistent filesystem** backs `/home/user` automatically\n- **Session transcripts** persisted with sequence numbers for replay\n- **Configurable sleep** with a 15-minute grace period by default\n- **Automatic wake** when a client connects or a cron job triggers\n\n## What persists across sleep\n\n| Data | Storage | Persists? |\n|------|---------|-----------|\n| Files in `/home/user` | Persistent filesystem | Yes |\n| Session records | SQLite (`agent_os_sessions`) | Yes |\n| Session event history | SQLite (`agent_os_session_events`) | Yes |\n| Preview URL tokens | SQLite (`agent_os_preview_tokens`) | Yes |\n| Cron job definitions | Actor state | Yes |\n| Running processes | VM kernel | No |\n| Active shells | VM kernel | No |\n| In-memory mounts | VM memory | No |\n| VM kernel state | VM memory | No |\n\n## What prevents sleep\n\nThe actor stays awake as long as any of these are active:\n\n- **Active sessions** (created but not closed/destroyed)\n- **Running processes** (spawned but not exited)\n- **Active shells** (opened but not closed)\n- **Pending hooks** (server-side callbacks still executing)\n\nWhen all activity stops, the sleep grace period begins.\n\n## Sleep grace period\n\nAfter all activity stops, the actor waits 15 minutes before sleeping. This allows for brief pauses between interactions without restarting the VM.\n\n```\nActivity stops ──> 15 min grace period ──> Actor sleeps\n (VM shutdown, processes killed)\n\nNew client connects ──> Actor wakes ──> VM boots ──> Filesystem restored\n```\n\n## Sleep vs destroy\n\n| | Sleep | Destroy |\n|-|-------|---------|\n| Filesystem | Preserved | Deleted |\n| Session records | Preserved | Deleted |\n| Event history | Preserved | Deleted |\n| Preview tokens | Preserved | Deleted |\n| VM state | Lost | Lost |\n| Processes | Killed | Killed |\n\n## VM boot and shutdown events\n\nSubscribe to `vmBooted` and `vmShutdown` events to track VM lifecycle.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nagent.on(\"vmBooted\", () => {\n console.log(\"VM is ready\");\n});\n\nagent.on(\"vmShutdown\", (data) => {\n console.log(\"VM shutdown reason:\", data.reason);\n // reason: \"sleep\" | \"destroy\" | \"error\"\n});\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Resuming after sleep\n\nWhen the actor wakes up:\n\n1. The VM boots and the filesystem is restored from SQLite\n2. Session records and event history are available immediately\n3. Processes and shells from the previous session are gone\n4. Clients can reconnect and resume sessions using `resumeSession`\n5. Use `getSessionEvents` to replay missed events\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// List sessions from before sleep\nconst sessions = await agent.listPersistedSessions();\nconsole.log(\"Previous sessions:\", sessions.length);\n\n// Resume the most recent session\nif (sessions.length > 0) {\n const last = sessions[0];\n await agent.resumeSession(last.sessionId);\n\n // Replay events for transcript\n const events = await agent.getSessionEvents(last.sessionId);\n for (const e of events) {\n console.log(e.seq, e.event.method);\n }\n}\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Persisted tables schema\n\n### `agent_os_fs_entries`\n\nStores the virtual filesystem.\n\n| Column | Type | Description |\n|--------|------|-------------|\n| `path` | TEXT PRIMARY KEY | File or directory path |\n| `is_directory` | INTEGER | 1 for directory, 0 for file |\n| `content` | BLOB | File content |\n| `mode` | INTEGER | POSIX mode bits |\n| `size` | INTEGER | File size in bytes |\n| `atime_ms` | INTEGER | Access time (ms) |\n| `mtime_ms` | INTEGER | Modification time (ms) |\n| `ctime_ms` | INTEGER | Change time (ms) |\n| `birthtime_ms` | INTEGER | Birth time (ms) |\n\n### `agent_os_sessions`\n\nStores session metadata.\n\n| Column | Type | Description |\n|--------|------|-------------|\n| `session_id` | TEXT PRIMARY KEY | Unique session identifier |\n| `agent_type` | TEXT | Agent type (e.g. \"pi\") |\n| `capabilities` | TEXT (JSON) | Agent capabilities |\n| `agent_info` | TEXT (JSON) | Agent metadata |\n| `created_at` | INTEGER | Creation timestamp (ms) |\n\n### `agent_os_session_events`\n\nStores session event history.\n\n| Column | Type | Description |\n|--------|------|-------------|\n| `id` | INTEGER PRIMARY KEY | Auto-incrementing ID |\n| `session_id` | TEXT | Session reference |\n| `seq` | INTEGER | Sequence number within session |\n| `event` | TEXT (JSON) | JSON-RPC notification |\n| `created_at` | INTEGER | Timestamp (ms) |","src/content/docs/docs/persistence.mdx","cb2236feafb41fad","docs/processes",{"id":210,"data":212,"body":218,"filePath":219,"digest":220,"deferredRender":16},{"title":213,"description":214,"editUrl":16,"head":215,"template":18,"sidebar":216,"pagefind":16,"draft":20},"Processes & Shell","Execute commands, spawn long-running processes, and open interactive shells in agentOS VMs.",[],{"hidden":20,"attrs":217},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n- **One-shot execution** with `exec` for simple commands\n- **Long-running processes** with `spawn`, stdout/stderr streaming, and stdin writing\n- **Process lifecycle** management with stop, kill, wait, and inspect\n- **Interactive shells** with PTY support for terminal I/O\n- **Process tree** visibility across all VM runtimes\n\n## One-shot execution\n\nUse `exec` to run a command and wait for completion. Returns stdout, stderr, and exit code.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst result = await agent.exec(\"echo hello && ls /home/user\");\nconsole.log(\"stdout:\", result.stdout);\nconsole.log(\"stderr:\", result.stderr);\nconsole.log(\"exit code:\", result.exitCode);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Spawn a long-running process\n\nUse `spawn` for processes that run in the background. Output is streamed via `processOutput` and `processExit` events.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Subscribe to process output\nagent.on(\"processOutput\", (data) => {\n const text = new TextDecoder().decode(data.data);\n console.log(`[pid ${data.pid}] ${data.stream}: ${text}`);\n});\n\nagent.on(\"processExit\", (data) => {\n console.log(`[pid ${data.pid}] exited with code ${data.exitCode}`);\n});\n\n// Spawn a dev server\nconst { pid } = await agent.spawn(\"node\", [\"/home/user/server.js\"]);\nconsole.log(\"Started process:\", pid);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Write to stdin\n\nSend input to a running process.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst { pid } = await agent.spawn(\"cat\", []);\n\n// Write to stdin\nawait agent.writeProcessStdin(pid, \"hello from stdin\\n\");\n\n// Close stdin when done\nawait agent.closeProcessStdin(pid);\n\n// Wait for the process to exit\nconst exitCode = await agent.waitProcess(pid);\nconsole.log(\"exit code:\", exitCode);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Process lifecycle\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst { pid } = await agent.spawn(\"node\", [\"/home/user/server.js\"]);\n\n// List all spawned processes\nconst processes = await agent.listProcesses();\nconsole.log(processes);\n\n// Get info about a specific process\nconst info = await agent.getProcess(pid);\nconsole.log(info.running, info.exitCode);\n\n// Graceful stop (SIGTERM)\nawait agent.stopProcess(pid);\n\n// Force kill (SIGKILL)\nawait agent.killProcess(pid);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## System-wide process visibility\n\nView all processes across all VM runtimes, not just those started via `spawn`.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// All processes\nconst all = await agent.allProcesses();\nfor (const p of all) {\n console.log(p.pid, p.driver, p.command, p.status);\n}\n\n// Process tree (parent-child hierarchy)\nconst tree = await agent.processTree();\nfor (const node of tree) {\n console.log(node.pid, node.command, \"children:\", node.children.length);\n}\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Interactive shells\n\nOpen an interactive shell with PTY support. Shell data is streamed via `shellData` events.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Subscribe to shell output\nagent.on(\"shellData\", (data) => {\n const text = new TextDecoder().decode(data.data);\n process.stdout.write(text);\n});\n\n// Open a shell\nconst { shellId } = await agent.openShell();\n\n// Write commands to the shell\nawait agent.writeShell(shellId, \"ls -la /home/user\\n\");\n\n// Resize the terminal\nawait agent.resizeShell(shellId, 120, 40);\n\n// Close the shell when done\nawait agent.closeShell(shellId);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Recommendations\n\n- Use `exec` for short commands where you need the full output. Use `spawn` for long-running processes where you want streaming output.\n- Subscribe to `processOutput` and `processExit` **before** calling `spawn` to avoid missing events.\n- Active processes prevent the actor from sleeping. Stop or kill them when they are no longer needed.\n- Active shells also prevent sleep. Close shells when the user disconnects.\n- Use `allProcesses` and `processTree` for debugging. They show everything running in the VM, including agent processes.","src/content/docs/docs/processes.mdx","644e95aada2aa3a6","docs/queues",{"id":221,"data":223,"body":229,"filePath":230,"digest":231,"deferredRender":16},{"title":224,"description":225,"editUrl":16,"head":226,"template":18,"sidebar":227,"pagefind":16,"draft":20},"Queues","Serialize agent work with durable queues for backpressure and rate limiting.",[],{"hidden":20,"attrs":228},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n- **Serial execution** ensures agents process one task at a time\n- **Durable messages** survive sleep and restart\n- **Completable messages** for request/response patterns with agents\n- **Backpressure** absorbs bursts and prevents overload\n\n## Queue agent commands\n\nUse actor queues to serialize work that an agent processes one task at a time.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\nimport { actor, queue, setup } from \"rivetkit\";\n\nconst taskRunner = actor({\n queues: {\n tasks: queue\u003C{ prompt: string }>(),\n },\n run: async (c) => {\n const agentHandle = c.actors.vm.getOrCreate([\"task-agent\"]);\n\n for await (const message of c.queue.iter()) {\n // Process one task at a time\n const session = await agentHandle.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n await agentHandle.sendPrompt(session.sessionId, message.body.prompt);\n await agentHandle.closeSession(session.sessionId);\n }\n },\n});\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { taskRunner, vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst handle = client.taskRunner.getOrCreate([\"main\"]);\n\n// Queue up work. Tasks are processed one at a time.\nawait handle.send(\"tasks\", { prompt: \"Review PR #123\" });\nawait handle.send(\"tasks\", { prompt: \"Fix the flaky test in auth.test.ts\" });\nawait handle.send(\"tasks\", { prompt: \"Update the README\" });\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Request/response with completable messages\n\nUse completable messages when the caller needs to wait for the agent to finish.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\nimport { actor, queue, setup } from \"rivetkit\";\n\nconst reviewer = actor({\n queues: {\n review: queue\u003C{ file: string }, { summary: string }>(),\n },\n run: async (c) => {\n const agentHandle = c.actors.vm.getOrCreate([\"reviewer\"]);\n const session = await agentHandle.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n\n for await (const message of c.queue.iter({ completable: true })) {\n const content = await agentHandle.readFile(message.body.file);\n const text = new TextDecoder().decode(content);\n\n await agentHandle.sendPrompt(\n session.sessionId,\n `Review this code and write a summary to /home/user/review.txt:\\n\\n${text}`,\n );\n\n const review = await agentHandle.readFile(\"/home/user/review.txt\");\n await message.complete({\n summary: new TextDecoder().decode(review),\n });\n }\n },\n});\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { reviewer, vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst handle = client.reviewer.getOrCreate([\"main\"]);\n\n// Wait for the agent to complete the review\nconst result = await handle.send(\n \"review\",\n { file: \"/home/user/src/auth.ts\" },\n { wait: true, timeout: 120_000 },\n);\n\nif (result.status === \"completed\") {\n console.log(\"Review:\", result.response.summary);\n}\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Ingesting from external systems\n\nAccept tasks from webhooks, APIs, or other services and queue them for agent processing.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\nimport { actor, queue, setup } from \"rivetkit\";\n\nconst issueWorker = actor({\n queues: {\n issues: queue\u003C{ title: string; body: string }>(),\n },\n actions: {\n // HTTP endpoint to receive webhook payloads\n ingestIssue: async (c, title: string, body: string) => {\n await c.queue.push(\"issues\", { title, body });\n },\n },\n run: async (c) => {\n const agentHandle = c.actors.vm.getOrCreate([\"issue-worker\"]);\n\n for await (const message of c.queue.iter()) {\n const session = await agentHandle.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n await agentHandle.sendPrompt(\n session.sessionId,\n `Investigate and fix this issue:\\n\\nTitle: ${message.body.title}\\n\\n${message.body.body}`,\n );\n await agentHandle.closeSession(session.sessionId);\n }\n },\n});\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { issueWorker, vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst handle = client.issueWorker.getOrCreate([\"main\"]);\n\n// Ingest from a webhook or external system\nawait handle.ingestIssue(\n \"Login redirect broken\",\n \"Users are redirected to /undefined after login on mobile\",\n);\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Recommendations\n\n- Use queues when you need guaranteed serial execution. Agents process one message at a time, preventing race conditions.\n- Use completable messages when the caller needs the result. Set a generous timeout since agent work can take minutes.\n- Queues survive actor sleep. Messages are persisted and processed when the actor wakes up.\n- See [Queues & Run Loops](/docs/actors/queues) for the full queue API reference.","src/content/docs/docs/queues.mdx","53e20396e2fa6e69","docs/quickstart",{"id":232,"data":234,"body":240,"filePath":241,"digest":242,"deferredRender":16},{"title":235,"description":236,"editUrl":16,"head":237,"template":18,"sidebar":238,"pagefind":16,"draft":20},"Quickstart","Set up an agentOS actor, create a session, and run your first coding agent.",[],{"hidden":20,"attrs":239},{},"import { Aside, Steps, Tabs, TabItem } from '@astrojs/starlight/components';\n\n\u003CAside type=\"note\">\nagentOS is in preview and the API is subject to change. If you run into issues, please [report them on GitHub](https://github.com/rivet-dev/rivet/issues) or [join our Discord](https://rivet.dev/discord).\n\u003C/Aside>\n\n\u003CSteps>\n\n1. **Install**\n\n - **rivetkit** — Actor framework with built-in persistence and orchestration\n - **@rivet-dev/agentos-common** — Standard VM software (curl, grep, git, and more)\n - **@rivet-dev/agentos-pi** — [Pi](https://github.com/mariozechner/pi-coding-agent) coding agent (Claude Code, Amp, and OpenCode coming soon)\n\n ```bash\n npm install rivetkit @rivet-dev/agentos-common @rivet-dev/agentos-pi\n ```\n\n2. **Create the Server & Client**\n\n \u003CTabs>\n \u003CTabItem label=\"server.ts\">\n ```ts title=\"server.ts\"\n import { agentOs } from \"rivetkit/agent-os\";\n import { setup } from \"rivetkit\";\n import common from \"@rivet-dev/agentos-common\";\n import pi from \"@rivet-dev/agentos-pi\";\n\n const vm = agentOs({\n options: { software: [common, pi] },\n });\n\n export const registry = setup({ use: { vm } });\n registry.start();\n ```\n \u003C/TabItem>\n \u003CTabItem label=\"client.ts\">\n ```ts title=\"client.ts\"\n import { createClient } from \"rivetkit/client\";\n import type { registry } from \"./server\";\n\n const client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\n const agent = client.vm.getOrCreate([\"my-agent\"]);\n\n // Subscribe to streaming events\n agent.on(\"sessionEvent\", (data) => {\n console.log(data.event);\n });\n\n // Create a session and send a prompt\n const session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n await agent.sendPrompt(\n session.sessionId,\n \"Write a hello world script to /home/user/hello.js\",\n );\n\n // Read the file the agent created\n const content = await agent.readFile(\"/home/user/hello.js\");\n console.log(new TextDecoder().decode(content));\n ```\n \u003C/TabItem>\n \u003C/Tabs>\n\n3. **Run**\n\n Start the server:\n\n ```bash\n npx tsx server.ts\n ```\n\n Then in a separate terminal, run the client:\n\n ```bash\n npx tsx client.ts\n ```\n\n4. **Customize**\n\n Now that you have a working agent, customize it to fit your needs:\n\n - **[Software](/docs/software)** — Install software packages inside the VM\n - **[Tools](/docs/tools)** — Expose your JavaScript functions to agents as CLI commands\n - **[Filesystem](/docs/filesystem)** — Read, write, and manage files inside the VM\n\n\u003C/Steps>\n\n\n## agentOS Core\n\nThe quickstart above uses `rivetkit/agent-os`, which includes statefulness, multiplayer, and orchestration out of the box. If you only need direct VM control without those features, you can use the core package (`@rivet-dev/agentos-core`) standalone.\n\nSee [agentOS core documentation](/docs/core) for reference.","src/content/docs/docs/quickstart.mdx","f1f89a2f454226d6","docs/security-model",{"id":243,"data":245,"body":251,"filePath":252,"digest":253,"deferredRender":16},{"title":246,"description":247,"editUrl":16,"head":248,"template":18,"sidebar":249,"pagefind":16,"draft":20},"Security Model","Trust boundaries, isolation guarantees, and the agentOS threat model.",[],{"hidden":20,"attrs":250},{},"import { Aside } from '@astrojs/starlight/components';\n\n\u003CAside type=\"caution\">\nagentOS is in beta and still undergoing security review. The security model described here is subject to change.\n\u003C/Aside>\n\n## Deny by default\n\nNo syscalls are bound to the system by default. Everything is denied until explicitly opted in. Network access, filesystem mounts, process spawning, and all other capabilities must be configured by the host before the VM can use them.\n\n## Trust boundaries\n\nagentOS has two trust boundaries:\n\n1. **Runtime boundary.** The VM isolate that runs agent code. All code inside the VM is untrusted. The isolate prevents access to the host process, host filesystem, and host network.\n2. **Host boundary.** Your application code that configures and manages the VM. You are responsible for hardening the host process, validating inputs, and managing secrets.\n\n## VM isolation\n\nEach agentOS actor runs in its own isolated VM:\n\n- **Sandboxed execution.** All agent code runs inside a V8 isolate with WebAssembly. No code escapes the isolate boundary.\n- **Virtual filesystem.** The VM has its own filesystem. Agents cannot access host files unless explicitly mounted.\n- **Virtual network.** The VM has no direct access to the host network. Outbound requests are proxied through the host with configurable controls.\n- **Process isolation.** No host process is visible or accessible from inside the VM.\n\n## What agentOS guarantees\n\n- Agent code cannot read or write host files outside configured mounts\n- Agent code cannot make network requests except through the host proxy\n- Agent code cannot access host environment variables or secrets\n- Each actor's filesystem, sessions, and state are isolated from other actors\n- Resource limits (CPU, memory) are enforced at the VM level\n\n## What you are responsible for\n\n- Hardening the host process and deployment environment\n- Validating authentication tokens in `onBeforeConnect`\n- Scoping [permissions](/docs/permissions) appropriately for your use case\n- Managing API keys and secrets on the host side (use the [LLM gateway](/docs/llm-gateway) to avoid passing keys into the VM)\n- Configuring [resource limits and network controls](/docs/security) to match your threat model\n\n## Further reading\n\n- [Security configuration](/docs/security) for resource limits, network control, and authentication setup\n- [Permissions](/docs/permissions) for agent tool-use approval patterns\n- [agentOS vs Sandbox](/docs/versus-sandbox) for when to escalate to a full sandbox","src/content/docs/docs/security-model.mdx","99a4ff3a2f6a0e2e","docs/sandbox",{"id":254,"data":256,"body":262,"filePath":263,"digest":264,"deferredRender":16},{"title":257,"description":258,"editUrl":16,"head":259,"template":18,"sidebar":260,"pagefind":16,"draft":20},"Sandbox Mounting","Extend agentOS with full sandboxes for heavy workloads like browsers, desktop automation, and compilation.",[],{"hidden":20,"attrs":261},{},"- **Hybrid architecture** pairs agentOS with full sandboxes on demand\n- **Pay-per-second billing** so sandboxes only cost money while they are running\n- **Filesystem mount** projects the sandbox into the VM as a native directory, like mounting a hard drive on your own machine\n- **Toolkit** exposes sandbox process management as [host tools](/docs/tools)\n- **Provider-agnostic** via [Sandbox Agent](https://sandboxagent.dev) under the hood\n\n## Why use agentOS with a sandbox?\n\nagentOS is not a replacement for sandboxes. It's designed to work alongside them. agentOS makes it easy to integrate agents into your backend with [host tools](/docs/tools), [permissions](/docs/permissions), the [LLM gateway](/docs/llm-gateway), and orchestration. Sandbox mounting lets you connect a full sandbox environment when the workload needs it.\n\nSee [agentOS vs Sandbox](/docs/versus-sandbox) for a detailed comparison.\n\n## When to use a sandbox\n\n- **Native binaries** not yet supported in the agentOS runtime.\n- **Browsers and desktop automation**: Playwright, Puppeteer, Selenium, or anything that needs a display server.\n- **Heavy compilation**: Large builds or native toolchains that require a full Linux environment.\n- **GUI applications**: Desktop apps, VNC sessions, or any workload that needs a graphical environment.\n- **Node.js packages with native extensions** (e.g. `sharp`, `bcrypt`, `better-sqlite3`) that require a full build toolchain.\n\n## Getting started\n\nThe `@rivet-dev/agentos-sandbox` package integrates through two mechanisms:\n\n- **Filesystem mount**: Projects the sandbox into the VM as a native directory, like mounting a hard drive on your own machine. Read and write files through the mount directly.\n- **Toolkit**: Exposes sandbox process management as [host tools](/docs/tools). Execute commands on the sandbox from within the VM.\n\nBoth are powered by [Sandbox Agent](https://sandboxagent.dev), so you can swap providers without changing agent code.\n\n```bash\nnpm install @rivet-dev/agentos-sandbox sandbox-agent\n```\n\n```ts\nimport { SandboxAgent } from \"sandbox-agent\";\nimport { docker } from \"sandbox-agent/docker\";\nimport { AgentOs } from \"@rivet-dev/agentos-core\";\nimport common from \"@rivet-dev/agentos-common\";\nimport { createSandboxFs, createSandboxToolkit } from \"@rivet-dev/agentos-sandbox\";\n\nconst sandbox = await SandboxAgent.start({\n sandbox: docker(),\n});\n\nconst vm = await AgentOs.create({\n software: [common],\n mounts: [\n {\n path: \"/sandbox\",\n plugin: createSandboxFs({ client: sandbox }),\n },\n ],\n toolKits: [createSandboxToolkit({ client: sandbox })],\n});\n\n// Write code via the filesystem. The /sandbox mount maps to the sandbox root.\nawait vm.writeFile(\"/sandbox/app/index.ts\", 'console.log(\"hello\")');\n\n// Run it via the toolkit. Commands execute inside the sandbox, so paths are\n// relative to the sandbox root (/app/index.ts), not the VM mount (/sandbox/app/index.ts).\nconst result = await vm.exec(\"agentos-sandbox run-command --command node --args /app/index.ts\");\n```\n\n## Tools reference\n\nThe toolkit exposes these commands inside the VM:\n\n```bash\n# Run a command synchronously\nagentos-sandbox run-command --command \"npm install\" --cwd \"/app\"\n\n# Start a background process\nagentos-sandbox create-process --command \"npm\" --args \"run\" --args \"dev\"\n\n# List running processes\nagentos-sandbox list-processes\n\n# Get process output\nagentos-sandbox get-process-logs --id \"proc_abc123\"\n\n# Stop or kill a process\nagentos-sandbox stop-process --id \"proc_abc123\"\nagentos-sandbox kill-process --id \"proc_abc123\"\n\n# Send input to an interactive process\nagentos-sandbox send-input --id \"proc_abc123\" --data \"yes\"\n```\n\n## Sandbox providers\n\nThe extension works with any [Sandbox Agent](https://sandboxagent.dev) provider. See the [Sandbox Agent documentation](https://sandboxagent.dev) for available providers and setup instructions.\n\n## Recommendations\n\n- Start with the default agentOS VM for all workloads. Only spin up a sandbox when you hit a task that genuinely requires one.\n- Sandboxes are billed per second of uptime. Spin them up on demand and tear them down when the task is done to minimize cost.\n- The hybrid model means your agent can handle both lightweight coding tasks and heavy system operations in the same session, using the right tool for each.\n- See [Tools](/docs/tools) for how host tools work and how the agent calls them as CLI commands.\n- See [Security Model](/docs/security-model) for details on the VM isolation model.","src/content/docs/docs/sandbox.mdx","349ce15abb6ad037","docs/security",{"id":265,"data":267,"body":273,"filePath":274,"digest":275,"deferredRender":16},{"title":268,"description":269,"editUrl":16,"head":270,"template":18,"sidebar":271,"pagefind":16,"draft":20},"Security & Auth","Configure resource limits, network control, authentication, and filesystem isolation for agentOS.",[],{"hidden":20,"attrs":272},{},"For the isolation model and trust boundaries, see [Security Model](/docs/security-model).\n\n## Resource limits\n\nCap kernel resources per VM to prevent runaway agents. Resource limits live under `limits.resources`. Every field is optional and unset fields fall back to built-in defaults.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: {\n software: [common, pi],\n limits: {\n resources: {\n cpuCount: 1,\n maxProcesses: 64,\n maxFilesystemBytes: 512 * 1024 * 1024, // 512 MB\n maxWasmMemoryBytes: 256 * 1024 * 1024, // 256 MB\n },\n },\n },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n## Network control\n\nVM network access is governed by kernel [Permissions](/docs/permissions). By default, the VM's outbound networking is also protected by SSRF checks that block requests to loopback addresses. `loopbackExemptPorts` exempts specific loopback ports from those checks — for example, to reach a host-side mock server during testing.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\n\nconst vm = agentOs({\n options: {\n software: [common],\n loopbackExemptPorts: [8080, 3000],\n },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\n## Custom authentication\n\nUse the `onBeforeConnect` hook to validate clients before they access the agent.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup, UserError } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n onBeforeConnect: async (c, params: { authToken: string }) => {\n const isValid = await verifyToken(params.authToken);\n if (!isValid) {\n throw new UserError(\"Forbidden\", { code: \"forbidden\" });\n }\n },\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n\nasync function verifyToken(token: string): Promise\u003Cboolean> {\n // Your authentication logic\n return token === \"valid-token\";\n}\n```\n\nSee [Authentication](/docs/authentication) for `createConnState`, client usage, and more patterns.\n\n## Permission system\n\nAgents request permission before using tools. See [Permissions](/docs/permissions) for auto-approve, selective approval, and human-in-the-loop patterns.\n\n## Preview URL security\n\nPreview URLs use randomly generated 32-character lowercase alphanumeric (a-z0-9) tokens with configurable expiration. See [Networking & Previews](/docs/networking) for token management.\n\n- Tokens are stored in SQLite and survive sleep/wake\n- Expired tokens are automatically cleaned up\n- Use `expireSignedPreviewUrl` to immediately revoke a token\n\n## Filesystem isolation\n\nEach VM has its own virtual filesystem. Files are isolated per actor instance.\n\n- `/home/user` is persistent and survives sleep/wake\n- Mount boundaries prevent escape via symlinks or path traversal\n- Host directory mounts (if configured) prevent symlink escape beyond the mount point","src/content/docs/docs/security.mdx","2ba0bac0e9b1ae89","docs/sessions",{"id":276,"data":278,"body":284,"filePath":285,"digest":286,"deferredRender":16},{"title":279,"description":280,"editUrl":16,"head":281,"template":18,"sidebar":282,"pagefind":16,"draft":20},"Sessions","Create agent sessions, send prompts, stream responses, and replay event history.",[],{"hidden":20,"attrs":283},{},"import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';\n\n- **Create sessions** with any supported agent type\n- **Stream responses** in real time via `sessionEvent` subscriptions\n- **Replay events** with sequence numbers for reconnection and history\n- **Persist transcripts** automatically in SQLite across sleep/wake cycles\n- **Universal transcript format** using the Agent Communication Protocol (ACP)\n\n\u003CAside type=\"note\">\nCurrently only [Pi](https://github.com/mariozechner/pi-coding-agent) is supported as an agent. Amp, Claude Code, Codex, and OpenCode are coming soon.\n\u003C/Aside>\n\n## Create a session\n\nUse `createSession` to launch an agent inside the VM. Returns session metadata including capabilities and agent info.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nconsole.log(session.sessionId);\nconsole.log(session.capabilities);\nconsole.log(session.agentInfo);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n### `env`\n\nEnvironment variables to pass to the agent process. The VM does not inherit from the host `process.env`, so API keys must be passed explicitly.\n\n```ts\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\n```\n\n### `cwd`\n\nWorking directory for the agent session inside the VM. Defaults to `/home/user`.\n\n```ts\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n cwd: \"/home/user/project\",\n});\n```\n\n### `mcpServers`\n\nPass MCP servers to give the agent access to additional tools. MCP servers provide typed tool definitions that the agent's LLM can discover and call natively.\n\n#### Local MCP server\n\nRun an MCP server as a child process inside the VM.\n\n```ts\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n mcpServers: [\n {\n type: \"local\",\n command: \"npx\",\n args: [\"-y\", \"@modelcontextprotocol/server-filesystem\", \"/home/user\"],\n env: {},\n },\n ],\n});\n```\n\n#### Remote MCP server\n\nConnect to an MCP server running outside the VM.\n\n```ts\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n mcpServers: [\n {\n type: \"remote\",\n url: \"https://mcp.example.com/sse\",\n headers: {\n Authorization: \"Bearer my-token\",\n },\n },\n ],\n});\n```\n\n### `additionalInstructions`\n\nAppend custom instructions to the agent's system prompt.\n\n```ts\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n additionalInstructions: \"Always write tests before implementation.\",\n});\n```\n\n### `skipOsInstructions`\n\nSkip the base OS instructions injection. Tool documentation is still included even when this is `true`.\n\n```ts\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n skipOsInstructions: true,\n});\n```\n\n## Send a prompt\n\nUse `sendPrompt` to send a message to an active session. The response contains the agent's reply.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nconst response = await agent.sendPrompt(\n session.sessionId,\n \"Create a TypeScript function that checks if a number is prime\",\n);\nconsole.log(response);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Stream responses\n\nSubscribe to `sessionEvent` to receive real-time streaming output from the agent.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Subscribe to session events before sending the prompt\nagent.on(\"sessionEvent\", (data) => {\n console.log(`[${data.sessionId}]`, data.event.method, data.event.params);\n});\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait agent.sendPrompt(session.sessionId, \"Explain how async/await works\");\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Cancel a prompt\n\nUse `cancelPrompt` to stop an in-progress prompt.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\n\n// Start a long-running prompt\nconst promptPromise = agent.sendPrompt(\n session.sessionId,\n \"Refactor the entire codebase to use TypeScript strict mode\",\n);\n\n// Cancel after 10 seconds\nsetTimeout(async () => {\n await agent.cancelPrompt(session.sessionId);\n}, 10_000);\n\nconst response = await promptPromise;\nconsole.log(response);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Resume, close, and destroy sessions\n\n- `resumeSession` reconnects to a session that was suspended (e.g. after sleep)\n- `closeSession` gracefully closes a session\n- `destroySession` removes the session and all persisted data\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Resume a previously created session\nconst resumed = await agent.resumeSession(\"session-id-from-earlier\");\n\n// Close without destroying persisted data\nawait agent.closeSession(resumed.sessionId);\n\n// Destroy session and all persisted events\nawait agent.destroySession(resumed.sessionId);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Runtime configuration\n\nChange model, mode, and thought level on a live session.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\n\n// Change model\nawait agent.setModel(session.sessionId, \"claude-sonnet-4-6\");\n\n// Change mode (e.g. \"plan\", \"auto\")\nawait agent.setMode(session.sessionId, \"plan\");\n\n// Change thought level\nawait agent.setThoughtLevel(session.sessionId, \"high\");\n\n// Query available options\nconst modes = await agent.getModes(session.sessionId);\nconsole.log(modes);\n\nconst options = await agent.getConfigOptions(session.sessionId);\nconsole.log(options);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Replay events\n\nUse `getSequencedEvents` to replay in-memory session events (for live reconnection while the VM is running), or `getSessionEvents` to replay from persisted storage (for transcript history, including when the VM is not running). See [Events](/docs/events#event-replay) for details on the difference.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait agent.sendPrompt(session.sessionId, \"Hello\");\n\n// Get all events\nconst events = await agent.getEvents(session.sessionId);\nconsole.log(events);\n\n// Get events with sequence numbers (for pagination/reconnection)\nconst sequenced = await agent.getSequencedEvents(session.sessionId, {\n since: 0,\n});\nconsole.log(sequenced);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Persisted session history\n\nQuery session history from SQLite. Works even when the VM is not running.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// List all persisted sessions\nconst sessions = await agent.listPersistedSessions();\nfor (const s of sessions) {\n console.log(s.sessionId, s.agentType, s.createdAt);\n}\n\n// Get full event history for a session\nconst events = await agent.getSessionEvents(sessions[0].sessionId);\nfor (const e of events) {\n console.log(e.seq, e.event.method, e.createdAt);\n}\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Multiple sessions\n\nA single VM can run multiple sessions simultaneously. Each session has its own agent process but shares the same filesystem. Use different session IDs to manage them independently.\n\n\u003CTabs>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\n// Create two sessions in the same VM\nconst coder = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nconst reviewer = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\n\n// Coder writes code\nawait agent.sendPrompt(coder.sessionId, \"Write a REST API at /home/user/api.ts\");\n\n// Reviewer reads and reviews the same file\nawait agent.sendPrompt(reviewer.sessionId, \"Review /home/user/api.ts for issues\");\n\n// Close each session independently\nawait agent.closeSession(coder.sessionId);\nawait agent.closeSession(reviewer.sessionId);\n```\n\u003C/TabItem>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Recommendations\n\n- Subscribe to `sessionEvent` **before** calling `sendPrompt` to avoid missing early events.\n- Use `getSequencedEvents` with `since` for reconnection. Track the last sequence number you processed.\n- Use `listPersistedSessions` and `getSessionEvents` to build transcript history UIs without requiring a running VM.\n- Call `closeSession` when done to release resources. Use `destroySession` only when you want to permanently delete session data.","src/content/docs/docs/sessions.mdx","46369492f528fac5","docs/software",{"id":287,"data":289,"body":295,"filePath":296,"digest":297,"deferredRender":16},{"title":290,"description":291,"editUrl":16,"head":292,"template":18,"sidebar":293,"pagefind":16,"draft":20},"Software","Install software packages and configure the commands available inside agentOS.",[],{"hidden":20,"attrs":294},{},"agentOS starts with no commands installed. The `software` option lets you declare which packages to include. Each package provides one or more CLI commands.\n\n## Install\n\n```bash\nnpm install @rivet-dev/agentos-core @rivet-dev/agentos-common\n```\n\n`@rivet-dev/agentos-common` is a meta-package that includes coreutils, sed, grep, gawk, findutils, diffutils, tar, and gzip. For a smaller footprint, install individual packages instead.\n\n## Usage\n\n```ts\nimport { AgentOs } from \"@rivet-dev/agentos-core\";\nimport common from \"@rivet-dev/agentos-common\";\n\nconst vm = await AgentOs.create({\n software: [common],\n});\n\nconst result = await vm.exec(\"echo hello | grep hello\");\nconsole.log(result.stdout); // \"hello\\n\"\n\nawait vm.dispose();\n```\n\nYou can mix individual packages and meta-packages:\n\n```ts\nimport { AgentOs } from \"@rivet-dev/agentos-core\";\nimport coreutils from \"@rivet-dev/agentos-coreutils\";\nimport grep from \"@rivet-dev/agentos-grep\";\nimport jq from \"@rivet-dev/agentos-jq\";\nimport ripgrep from \"@rivet-dev/agentos-ripgrep\";\n\nconst vm = await AgentOs.create({\n software: [coreutils, grep, jq, ripgrep],\n});\n```\n\n## Available Packages\n\nBrowse all available software packages on the [Registry](/agent-os/registry).\n\n\n## Publishing Custom Packages\n\nSee the [agent-os-registry contributing guide](https://github.com/rivet-dev/agent-os/blob/main/registry/CONTRIBUTING.md) for how to add new software packages to the registry.","src/content/docs/docs/software.mdx","d71bae88617ecbfa","docs/sqlite",{"id":298,"data":300,"body":306,"filePath":307,"digest":308,"deferredRender":16},{"title":301,"description":302,"editUrl":16,"head":303,"template":18,"sidebar":304,"pagefind":16,"draft":20},"SQLite","Give agents access to a persistent SQLite database via host tools.",[],{"hidden":20,"attrs":305},{},"Each agentOS actor has a built-in SQLite database that persists across sessions and sleep/wake cycles. Expose it to agents as a host tool so they can run arbitrary SQL queries.\n\n## Example\n\nGive the agent a single `sql` tool that executes any SQL query against the actor's SQLite database.\n\n```ts\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\nimport { db } from \"rivetkit/db\";\nimport { toolKit, hostTool } from \"@rivet-dev/agentos-core\";\nimport { z } from \"zod\";\n\nconst actorDb = db({});\n\nconst sqlToolkit = toolKit({\n name: \"db\",\n description: \"SQLite database\",\n tools: {\n sql: hostTool({\n description: \"Execute a SQL query against the actor's SQLite database. Use this for creating tables, inserting data, and querying data. Returns rows for SELECT queries.\",\n inputSchema: z.object({\n query: z.string().describe(\"SQL query to execute\"),\n }),\n execute: async (input) => {\n const result = await actorDb.execute(input.query);\n return result;\n },\n }),\n },\n});\n\nconst vm = agentOs({\n db: actorDb,\n options: { software: [common, pi], toolKits: [sqlToolkit] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\nThe agent calls it as a CLI command:\n\n```bash\nagentos-db sql --query \"CREATE TABLE notes (id INTEGER PRIMARY KEY, content TEXT, created_at INTEGER)\"\nagentos-db sql --query \"INSERT INTO notes (content, created_at) VALUES ('auth uses JWT with 24h expiry', 1711843200000)\"\nagentos-db sql --query \"SELECT * FROM notes WHERE content LIKE '%auth%'\"\n```\n\nThe database persists in the actor's storage across sessions and sleep/wake cycles. The agent can create whatever schema it needs and build up structured data over time.\n\n## Recommendations\n\n- See the [Crash Course](/docs/crash-course) for the `db()` API (`onMigrate`, parameterized `db.execute`).\n- See [Tools](/docs/tools) for how host tools work.","src/content/docs/docs/sqlite.mdx","5962368c765fc56f","docs/system-prompt",{"id":309,"data":311,"body":317,"filePath":318,"digest":319,"deferredRender":16},{"title":312,"description":313,"editUrl":16,"head":314,"template":18,"sidebar":315,"pagefind":16,"draft":20},"System Prompt","How agentOS injects context into agent sessions.",[],{"hidden":20,"attrs":316},{},"agentOS automatically injects a system prompt into every agent session that describes the VM environment and available tools. The prompt is additive and never replaces the agent's own instructions (CLAUDE.md, AGENTS.md, etc.).\n\nThe base prompt is embedded in the sidecar (not written to a file inside the VM). At session start the sidecar assembles the base prompt with your additional instructions and generated tool docs, then injects the result into the agent adapter's launch arguments (for example, `--append-system-prompt` for Pi).\n\n## Customization\n\n```ts\nconst session = await vm.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n // Append custom instructions\n additionalInstructions: \"Always write tests before implementation.\",\n // Suppress the base OS prompt (tool docs are still injected)\n skipOsInstructions: true,\n});\n```","src/content/docs/docs/system-prompt.mdx","8abea299e38517df","docs/tools",{"id":320,"data":322,"body":328,"filePath":329,"digest":330,"deferredRender":16},{"title":323,"description":324,"editUrl":16,"head":325,"template":18,"sidebar":326,"pagefind":16,"draft":20},"Tools","Expose custom tools to agents as CLI commands inside the VM.",[],{"hidden":20,"attrs":327},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\nExpose your JavaScript functions to agents as CLI commands inside the VM.\n\n- **Define tools on the host** with Zod input schemas\n- **Auto-generated CLI commands** installed at `/usr/local/bin/agentos-{toolkit}`\n- **Code mode compatible** for up to 80% token reduction since tool calls are just shell commands\n- **Tool list injected** into the agent's [system prompt](/docs/system-prompt) automatically\n\n## Getting started\n\nDefine a toolkit with Zod input schemas and pass it to `agentOs()`. Each tool becomes a CLI command inside the VM.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\nimport { toolKit, hostTool } from \"@rivet-dev/agentos-core\";\nimport { z } from \"zod\";\n\nconst weatherToolkit = toolKit({\n name: \"weather\",\n description: \"Weather data tools\",\n tools: {\n forecast: hostTool({\n description: \"Get the weather forecast for a city\",\n inputSchema: z.object({\n city: z.string().describe(\"City name\"),\n days: z.number().optional().describe(\"Number of days\"),\n }),\n execute: async (input) => {\n const res = await fetch(`https://api.weather.example/forecast?city=${input.city}&days=${input.days ?? 3}`);\n return res.json();\n },\n examples: [\n { description: \"3-day forecast for Paris\", input: { city: \"Paris\", days: 3 } },\n ],\n }),\n },\n});\n\nconst vm = agentOs({\n options: { software: [common, pi],\n toolKits: [weatherToolkit],\n },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\nawait agent.sendPrompt(session.sessionId, \"What's the weather in Paris?\");\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n### Zod to CLI mapping\n\nZod schema fields are converted to CLI flags automatically. Field names are converted from camelCase to kebab-case.\n\n| Zod type | CLI syntax | Example |\n|---|---|---|\n| `z.string()` | `--name value` | `--path /tmp/out.png` |\n| `z.number()` | `--name 42` | `--limit 5` |\n| `z.boolean()` | `--flag` / `--no-flag` | `--full-page` |\n| `z.enum([\"a\",\"b\"])` | `--name a` | `--format json` |\n| `z.array(z.string())` | `--name a --name b` | `--tags foo --tags bar` |\n\nOptional fields (via `.optional()`) become optional flags. Required fields are enforced at validation time.\n\n### What the agent sees\n\nWhen toolkits are registered, CLI shims are installed at `/usr/local/bin/agentos-{name}` inside the VM and the tool list is injected into the agent's [system prompt](/docs/system-prompt).\n\nThe agent interacts with tools as shell commands:\n\n```bash\n# List all available toolkits\nagentos list-tools\n\n# List tools in a specific toolkit\nagentos list-tools weather\n\n# Get help for a tool\nagentos-weather forecast --help\n\n# Call a tool with flags\nagentos-weather forecast --city Paris --days 3\n\n# Call a tool with inline JSON\nagentos-weather forecast --json '{\"city\":\"Paris\",\"days\":3}'\n\n# Call a tool with JSON from a file\nagentos-weather forecast --json-file /tmp/input.json\n```\n\nOn success, the tool exits `0` and writes a JSON envelope to stdout:\n\n```json\n{\"ok\":true,\"result\":{\"temperature\":22,\"condition\":\"sunny\"}}\n```\n\nOn failure (validation or execution error), the tool exits non-zero and writes the error message to stderr:\n\n```text\nMissing required flag: --city\n```\n\n## Tools vs MCP servers\n\nagentOS supports two ways to give agents access to external functionality: **host tools** and **MCP servers**. Both work, but they have different tradeoffs.\n\n| | Host Tools | MCP Servers |\n|---|---|---|\n| **How it works** | Call JavaScript functions on the host directly | Connect to a standard MCP server |\n| **Authentication** | None required. Direct binding to the agent's OS. | Requires custom auth configuration per server |\n| **Code mode** | Built-in. Tools are exposed as CLI commands, so agents can call them inside scripts for up to 80% token reduction. | Requires extra work to make code mode work out of the box |\n| **Latency** | Near-zero. Bound directly to the host process. | Extra network hop to reach the MCP server |\n| **Setup** | Define tools in your actor code with Zod schemas | Configure any standard MCP server |\n\nUse host tools when you want to expose your own JavaScript functions to agents. Use MCP servers when you want to connect to existing third-party services. See [Sessions](/docs/sessions#mcpservers) for MCP server configuration.\n\n## Security\n\nTool calls from the agent securely invoke your `execute()` functions on the host. Your functions run with full access to the host environment, so you can call databases, APIs, and services directly without proxying credentials into the VM. The agent never sees the credentials — it only sees the tool's input/output contract.\n\n## Recommendations\n\n- Keep tool descriptions concise. They are injected into the agent's system prompt and consume tokens.\n- Use `.describe()` on Zod fields to generate useful `--help` output.\n- Set an explicit `timeout` (in ms) on long-running tools. Tools run without a timeout unless one is set.\n- Tools execute on the host with full access to the host environment. Do not expose tools that could compromise the host without appropriate safeguards.","src/content/docs/docs/tools.mdx","afc3c670ab1ddd09","docs/versus-sandbox",{"id":331,"data":333,"body":339,"filePath":340,"digest":341,"deferredRender":16},{"title":334,"description":335,"editUrl":16,"head":336,"template":18,"sidebar":337,"pagefind":16,"draft":20},"agentOS vs Sandbox","When to use the lightweight agentOS VM, a full sandbox, or both together.",[],{"hidden":20,"attrs":338},{},"- **agentOS** is a lightweight VM that runs inside your process. Near-zero cold start, low memory, direct backend integration via [host tools](/docs/tools).\n- **Sandboxes** are full Linux environments with root access, system packages, and native binary support.\n- **You can use both.** agentOS works with sandboxes through [sandbox mounting](/docs/sandbox). Agents run in the lightweight VM by default and spin up a full sandbox on demand.\n\n## Comparison\n\n| | agentOS VM | Full Sandbox |\n|---|---|---|\n| **Cost** | Very low. Runs in your process. | Pay per second of uptime. |\n| **Startup** | Near-zero cold start (~6 ms). | Seconds to spin up. |\n| **Backend integration** | Direct. [Host tools](/docs/tools) call your functions with zero latency. | Indirect. Requires network calls back to your backend. |\n| **API keys** | Stay on the server via the [LLM gateway](/docs/llm-gateway). | Must be injected into the sandbox environment. |\n| **Permissions** | Granular, deny-by-default. | Coarse-grained (container-level). |\n| **Infrastructure** | `npm install` | Vendor account + API keys. |\n| **Best for** | Coding, file manipulation, scripting, API calls, orchestration. | Browsers, desktop automation, native compilation, dev servers. |\n\n## When to use each\n\n### agentOS VM\n\nUse the lightweight VM for most agent workloads:\n\n- Coding and file editing\n- Running scripts and CLI tools\n- Calling APIs and services via host tools\n- Multi-agent orchestration and workflows\n- Tasks where backend integration matters (permissions, tool access, LLM routing)\n\n### Full sandbox\n\nSpin up a sandbox when the workload needs a real Linux kernel:\n\n- Browsers and desktop automation (Playwright, Puppeteer, Selenium)\n- Heavy compilation and native toolchains\n- Dev servers with hot reload, databases, and system ports\n- GUI applications and VNC sessions\n\n### Both together\n\nUse agentOS with [sandbox mounting](/docs/sandbox) for workflows that need both:\n\n- Agent runs in the agentOS VM with full access to host tools and permissions\n- Sandbox spins up on demand for heavy tasks\n- Sandbox filesystem is mounted into the VM as a native directory\n- Agent reads and writes sandbox files the same way it reads local files","src/content/docs/docs/versus-sandbox.mdx","b2fcc9b2b2714d5a","docs/webhooks",{"id":342,"data":344,"body":350,"filePath":351,"digest":352,"deferredRender":16},{"title":345,"description":346,"editUrl":16,"head":347,"template":18,"sidebar":348,"pagefind":16,"draft":20},"Webhooks","Trigger agent workflows from external webhooks using Hono and queues.",[],{"hidden":20,"attrs":349},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\nUse a lightweight HTTP server to receive webhooks and queue them for agent processing. This example uses [Hono](https://hono.dev) to receive Slack webhooks and dispatch them to an agent via queues.\n\n## Example: Slack webhook to agent\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\nimport { actor, queue, setup } from \"rivetkit\";\nimport { Hono } from \"hono\";\nimport { createClient } from \"rivetkit/client\";\n\n// Actor that processes Slack messages via a queue\nconst slackWorker = actor({\n queues: {\n messages: queue\u003C{ channel: string; text: string; user: string }>(),\n },\n run: async (c) => {\n const agentHandle = c.actors.vm.getOrCreate([\"slack-agent\"]);\n\n for await (const message of c.queue.iter()) {\n const { channel, text, user } = message.body;\n\n const session = await agentHandle.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n const response = await agentHandle.sendPrompt(\n session.sessionId,\n `Slack message from ${user} in #${channel}:\\n\\n${text}\\n\\nRespond helpfully.`,\n );\n await agentHandle.closeSession(session.sessionId);\n\n // Post the response back to Slack\n await fetch(\"https://slack.com/api/chat.postMessage\", {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`,\n },\n body: JSON.stringify({ channel, text: response }),\n });\n }\n },\n});\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { slackWorker, vm } });\nregistry.start();\n\n// Hono server to receive Slack webhooks\nconst app = new Hono();\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\n\napp.post(\"/slack/events\", async (c) => {\n const body = await c.req.json();\n\n // Handle Slack URL verification\n if (body.type === \"url_verification\") {\n return c.json({ challenge: body.challenge });\n }\n\n // Queue the message for the agent\n if (body.event?.type === \"message\" && !body.event?.bot_id) {\n const worker = client.slackWorker.getOrCreate([\"main\"]);\n await worker.send(\"messages\", {\n channel: body.event.channel,\n text: body.event.text,\n user: body.event.user,\n });\n }\n\n return c.json({ ok: true });\n});\n\nexport default app;\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## How it works\n\n1. Slack sends an HTTP POST to `/slack/events`\n2. The Hono handler validates the event and pushes it to the actor's queue\n3. The queue processes messages one at a time, creating agent sessions for each\n4. The agent responds and the worker posts the reply back to Slack\n\nThe queue provides backpressure and durability. If the agent is busy, messages wait in the queue. If the server restarts, queued messages are replayed.\n\n## Recommendations\n\n- Use [Queues](/docs/queues) to decouple webhook ingestion from agent processing. This prevents slow agent responses from blocking the webhook endpoint.\n- Return `200` from the webhook handler immediately after queuing. External services like Slack have short timeout windows.\n- Store webhook secrets in environment variables, not in code.","src/content/docs/docs/webhooks.mdx","17a04621bf977afc","docs/workflows",{"id":353,"data":355,"body":361,"filePath":362,"digest":363,"deferredRender":16},{"title":356,"description":357,"editUrl":16,"head":358,"template":18,"sidebar":359,"pagefind":16,"draft":20},"Workflow Automation","Orchestrate multi-step agent tasks with durable workflows.",[],{"hidden":20,"attrs":360},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n- **Durable workflows** that survive crashes and restarts\n- **Multi-step orchestration** with sessions, file operations, and process execution\n- **Error handling and retry** via `ctx.step()` for each operation\n- **Agent chaining** where output of one session feeds into the next\n\n## Basic workflow\n\nUse the actor `workflow()` primitive to orchestrate a multi-step agent task. Each step is durable and will resume from where it left off after a restart.\n\nSession creation and prompting must happen within the same step because sessions are ephemeral and won't survive a replay.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\nimport { actor, setup, workflow } from \"rivetkit\";\n\nconst automator = actor({\n workflows: {\n fixBug: workflow\u003C{ repo: string; issue: string }>(),\n },\n run: async (c) => {\n for await (const message of c.workflow.iter(\"fixBug\")) {\n const { repo, issue } = message.body;\n const agentHandle = c.actors.vm.getOrCreate([`fix-${issue}`]);\n\n // Step 1: Clone the repo\n await c.step(\"clone-repo\", async (c) => {\n return agentHandle.exec(`git clone ${repo} /home/user/repo`);\n });\n\n // Step 2: Agent fixes the bug (session lives within this step)\n await c.step(\"fix-bug\", async (c) => {\n const session = await agentHandle.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n await agentHandle.sendPrompt(\n session.sessionId,\n `Fix the bug described in issue: ${issue}`,\n );\n await agentHandle.closeSession(session.sessionId);\n });\n\n // Step 3: Run tests\n const tests = await c.step(\"run-tests\", async (c) => {\n return agentHandle.exec(\"cd /home/user/repo && npm test\");\n });\n\n console.log(\"Tests exit code:\", tests.exitCode);\n await message.complete();\n }\n },\n});\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { automator, vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst handle = client.automator.getOrCreate([\"main\"]);\n\n// Trigger the workflow\nawait handle.send(\"fixBug\", {\n repo: \"https://github.com/example/repo.git\",\n issue: \"Fix the login redirect bug\",\n});\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Agent chaining\n\nOutput of one agent session feeds into the next. Each session is created and completed within its own step.\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\nimport { actor, setup, workflow } from \"rivetkit\";\n\nconst pipeline = actor({\n workflows: {\n codeReview: workflow\u003C{ filePath: string }>(),\n },\n run: async (c) => {\n for await (const message of c.workflow.iter(\"codeReview\")) {\n const agentHandle = c.actors.vm.getOrCreate([`review-${Date.now()}`]);\n\n // Step 1: Agent reviews code and writes findings to a file\n await c.step(\"review\", async (c) => {\n const session = await agentHandle.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n await agentHandle.sendPrompt(\n session.sessionId,\n `Review the code at ${message.body.filePath} and write your findings to /home/user/review.md`,\n );\n await agentHandle.closeSession(session.sessionId);\n });\n\n // Step 2: Read the review from the filesystem\n const review = await c.step(\"read-review\", async (c) => {\n const content = await agentHandle.readFile(\"/home/user/review.md\");\n return new TextDecoder().decode(content);\n });\n\n // Step 3: Second session applies fixes based on the review\n await c.step(\"fix\", async (c) => {\n const session = await agentHandle.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n });\n await agentHandle.sendPrompt(\n session.sessionId,\n `Apply the following review feedback:\\n\\n${review}`,\n );\n await agentHandle.closeSession(session.sessionId);\n });\n\n await message.complete();\n }\n },\n});\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { pipeline, vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst handle = client.pipeline.getOrCreate([\"main\"]);\n\nawait handle.send(\"codeReview\", { filePath: \"/home/user/src/auth.ts\" });\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\n## Recommendations\n\n- Create and close sessions within the same step. Sessions are ephemeral and won't exist after a workflow replays.\n- Pass data between steps via the filesystem or step return values, not session state.\n- Keep step names stable across code changes. Renaming a step breaks replay for in-progress workflows.\n- Use separate actors for the workflow orchestrator and the agentOS VM.\n- See [Workflows](/docs/actors/workflows) for the full workflow API reference including timers, joins, and races.","src/content/docs/docs/workflows.mdx","54b9103733c58b29","docs/agents/amp",{"id":364,"data":366,"body":372,"filePath":373,"digest":374,"deferredRender":16},{"title":367,"description":368,"editUrl":16,"head":369,"template":18,"sidebar":370,"pagefind":16,"draft":20},"Amp","Run the Amp coding agent inside a VM.",[],{"hidden":20,"attrs":371},{},"import { Aside } from '@astrojs/starlight/components';\n\n\u003CAside type=\"note\">\nAmp agent documentation is coming soon.\n\u003C/Aside>","src/content/docs/docs/agents/amp.mdx","6f68d8dfd7680d81","docs/agents/claude",{"id":375,"data":377,"body":383,"filePath":384,"digest":385,"deferredRender":16},{"title":378,"description":379,"editUrl":16,"head":380,"template":18,"sidebar":381,"pagefind":16,"draft":20},"Claude","Run the Claude coding agent inside a VM.",[],{"hidden":20,"attrs":382},{},"import { Aside } from '@astrojs/starlight/components';\n\n\u003CAside type=\"note\">\nClaude agent documentation is coming soon.\n\u003C/Aside>\n\n```ts\nimport claude from \"@rivet-dev/agentos-claude\";\n\nconst vm = await AgentOs.create({ software: [common, claude] });\nconst { sessionId } = await vm.createSession(\"claude\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY },\n});\n```","src/content/docs/docs/agents/claude.mdx","bbd303b74db0eaaf","docs/agents/codex",{"id":386,"data":388,"body":394,"filePath":395,"digest":396,"deferredRender":16},{"title":389,"description":390,"editUrl":16,"head":391,"template":18,"sidebar":392,"pagefind":16,"draft":20},"Codex","Run the Codex coding agent inside a VM.",[],{"hidden":20,"attrs":393},{},"import { Aside } from '@astrojs/starlight/components';\n\n\u003CAside type=\"note\">\nCodex agent documentation is coming soon.\n\u003C/Aside>\n\n```ts\nimport codex from \"@rivet-dev/agentos-codex-agent\";\n\nconst vm = await AgentOs.create({ software: [common, codex] });\nconst { sessionId } = await vm.createSession(\"codex\", {\n env: { OPENAI_API_KEY: process.env.OPENAI_API_KEY },\n});\n```","src/content/docs/docs/agents/codex.mdx","6fa0a2fe6d4cd6a2","docs/agents/opencode",{"id":397,"data":399,"body":405,"filePath":406,"digest":407,"deferredRender":16},{"title":400,"description":401,"editUrl":16,"head":402,"template":18,"sidebar":403,"pagefind":16,"draft":20},"OpenCode","Run the OpenCode coding agent inside a VM.",[],{"hidden":20,"attrs":404},{},"import { Aside } from '@astrojs/starlight/components';\n\n\u003CAside type=\"note\">\nOpenCode agent documentation is coming soon.\n\u003C/Aside>\n\n```ts\nimport opencode from \"@rivet-dev/agentos-opencode\";\n\nconst vm = await AgentOs.create({ software: [common, opencode] });\nconst { sessionId } = await vm.createSession(\"opencode\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY },\n});\n```","src/content/docs/docs/agents/opencode.mdx","5d0805d2e53e8c8e","docs/agents/pi",{"id":408,"data":410,"body":416,"filePath":417,"digest":418,"deferredRender":16},{"title":411,"description":412,"editUrl":16,"head":413,"template":18,"sidebar":414,"pagefind":16,"draft":20},"Pi","Run the Pi coding agent inside a VM with extensions and custom configuration.",[],{"hidden":20,"attrs":415},{},"import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n## Quick start\n\n\u003CTabs>\n\u003CTabItem label=\"server.ts\">\n```ts title=\"server.ts\"\nimport { agentOs } from \"rivetkit/agent-os\";\nimport { setup } from \"rivetkit\";\nimport common from \"@rivet-dev/agentos-common\";\nimport pi from \"@rivet-dev/agentos-pi\";\n\nconst vm = agentOs({\n options: { software: [common, pi] },\n});\n\nexport const registry = setup({ use: { vm } });\nregistry.start();\n```\n\u003C/TabItem>\n\u003CTabItem label=\"client.ts\">\n```ts title=\"client.ts\"\nimport { createClient } from \"rivetkit/client\";\nimport type { registry } from \"./server\";\n\nconst client = createClient\u003Ctypeof registry>(\"http://localhost:6420\");\nconst agent = client.vm.getOrCreate([\"my-agent\"]);\n\nconst session = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },\n});\n\nconst { text } = await agent.sendPrompt(\n session.sessionId,\n \"What files are in the current directory?\",\n);\nconsole.log(text);\n```\n\u003C/TabItem>\n\u003C/Tabs>\n\nRead [Sessions](/docs/sessions) first for session options, streaming events, prompts, and lifecycle management.\n\n## Extensions\n\nPi supports [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent/examples/extensions) that let you register custom tools, modify the system prompt, and hook into agent lifecycle events. Write a `.js` file into the VM's extensions directory before creating a session and Pi discovers it automatically.\n\nPi scans two directories for `.js` extension files:\n\n| Directory | Scope |\n|-----------|-------|\n| `~/.pi/agent/extensions/` | Global — applies to all Pi sessions |\n| `\u003Ccwd>/.pi/extensions/` | Project — applies only when cwd matches |\n\n```ts\nconst extensionCode = `\nexport default function(pi) {\n // Modify the system prompt before each agent turn\n pi.on(\"before_agent_start\", async (event) => {\n return {\n systemPrompt: event.systemPrompt +\n \"\\\\n\\\\nAlways respond in formal English.\"\n };\n });\n}\n`;\n\n// Write the extension before creating the session\nawait agent.mkdir(\"/home/user/.pi/agent/extensions\", { recursive: true });\nawait agent.writeFile(\"/home/user/.pi/agent/extensions/formal.js\", extensionCode);\n\n// Pi discovers the extension automatically\nconst { sessionId } = await agent.createSession(\"pi\", {\n env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY },\n});\n```\n\nSee the [Pi extension documentation](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent/examples/extensions) for the full extension API.","src/content/docs/docs/agents/pi.mdx","9b0562c4e174096e"] \ No newline at end of file diff --git a/website/docs.config.mjs b/website/docs.config.mjs index a65753cc9..9605a9935 100644 --- a/website/docs.config.mjs +++ b/website/docs.config.mjs @@ -104,8 +104,18 @@ export const siteConfig = { { slug: "docs/deployment", attrs: { "data-icon": "cloud" } }, { slug: "docs/limitations", attrs: { "data-icon": "shield" } }, { - label: "Internals", + label: "Advanced", items: [ + { + label: "Architecture", + items: [ + { + slug: "docs/architecture/sessions-persistence", + label: "Sessions & Persistence", + attrs: { "data-icon": "hardDrive" }, + }, + ], + }, { slug: "docs/security-model", label: "Security Model", attrs: { "data-icon": "lock" } }, { slug: "docs/persistence", label: "Persistence & Sleep", attrs: { "data-icon": "hardDrive" } }, { slug: "docs/system-prompt", label: "System Prompt", attrs: { "data-icon": "fileCode" } }, diff --git a/website/package.json b/website/package.json index 1674b4c6b..25d747a25 100644 --- a/website/package.json +++ b/website/package.json @@ -1,5 +1,5 @@ { - "name": "@agent-os/website", + "name": "@agentos/website", "private": true, "version": "0.0.1", "license": "Apache-2.0", diff --git a/website/src/content/docs/docs/architecture/sessions-persistence.mdx b/website/src/content/docs/docs/architecture/sessions-persistence.mdx new file mode 100644 index 000000000..8804e7a1b --- /dev/null +++ b/website/src/content/docs/docs/architecture/sessions-persistence.mdx @@ -0,0 +1,189 @@ +--- +title: "Sessions & Persistence" +description: "How Agent OS, ACP, RivetKit actors, and durable session persistence fit together." +--- + +Agent OS runs coding agents inside VMs and talks to them through the Agent +Communication Protocol (ACP). RivetKit wraps those VMs in durable actors, so a +session can survive actor sleep/wake even though the live VM and agent process +do not. + +## Layers + +Agent OS session architecture has four layers: + +| Layer | Responsibility | +| --- | --- | +| RivetKit actor | Owns the public API and durable actor-local SQLite state. | +| Agent OS client | Thin facade used by the actor to create sessions, prompt agents, and call the sidecar. | +| Agent OS sidecar ACP extension | Launches ACP adapters inside the VM, speaks JSON-RPC, handles permissions, and owns resume orchestration. | +| ACP adapter / agent | Runs inside the VM and speaks ACP over stdio. | + +The actor is durable. The VM is disposable. The ACP agent process is live state +inside the VM. + +## API Shape + +The actor-facing session API is: + +- `createSession(agentType, options)` +- `sendPrompt(sessionId, text)` +- `closeSession(sessionId)` +- `listPersistedSessions()` +- `getSessionEvents(sessionId)` + +`sessionId` is the stable, client-facing id. If fallback resume creates a new +live ACP session id after wake, the actor keeps an internal +`externalSessionId -> liveSessionId` remap. Clients keep using the original +`sessionId`. + +## Create Flow + +1. The actor calls Agent OS `createSession`. +2. The sidecar starts the ACP adapter process inside the VM. +3. The sidecar sends ACP `initialize`. +4. The sidecar sends ACP `session/new`. +5. The actor persists session metadata in `agent_os_sessions`. +6. The actor starts capturing ACP `session/update` events for the session. + +Persisted session metadata includes: + +- `session_id` +- `agent_type` +- agent capabilities and agent info +- create-time `cwd` +- create-time `env` + +The create-time `cwd` and `env` are used later so resumed sessions start with +the same working directory and environment they were created with. + +## Prompt Flow + +1. The actor receives `sendPrompt(sessionId, text)`. +2. If the session is persisted but not live in the current VM, the actor lazily + resumes it first. +3. The actor writes a synthetic `user_prompt` event before forwarding the + prompt. +4. The actor forwards the prompt to the live ACP session id. +5. The sidecar sends ACP `session/prompt`. +6. Inbound ACP `session/update` events are captured into + `agent_os_session_events`. + +`agent_os_session_events` is ordered per session. Sequence numbers are allocated +inside the SQLite insert so concurrent prompt and stream captures cannot reuse +the same sequence number. + +## Sleep And Wake + +When a RivetKit actor sleeps: + +- the VM is destroyed +- ACP adapter processes exit +- the actor's in-memory `live_sessions` remap is lost +- actor SQLite survives + +When the actor wakes: + +- a fresh VM boots +- stable session ids still exist in `agent_os_sessions` +- no ACP session is live yet +- resume happens lazily on the next prompt + +## Resume Flow + +On the first post-wake prompt for a persisted session: + +1. The actor reads `agent_os_sessions`. +2. The actor reconstructs a Markdown transcript from + `agent_os_session_events`. +3. The actor writes the transcript to + `/root/.agentos/threads/.md`. +4. The actor calls sidecar `resumeSession` with: + - stable external `sessionId` + - agent type + - transcript path + - persisted create-time `cwd` + - persisted create-time `env` + +The sidecar then chooses one of two resume paths. + +### Native Resume + +If the ACP agent advertises `loadSession` or `resume`, the sidecar sends +`session/load` or `session/resume`. + +When native resume succeeds: + +- the live ACP id is the stable external `sessionId` +- the agent restores its own context +- no transcript preamble is injected + +OpenCode uses this path when its own session store is still available in the +durable VM filesystem. + +### Transcript Fallback + +If native resume is unsupported, or if native resume reports a normalized +`unknown_session`, the sidecar falls back to a fresh session: + +1. The sidecar sends ACP `session/new`. +2. The sidecar returns the new live ACP id to the actor. +3. The actor stores `externalSessionId -> liveSessionId`. +4. The sidecar prepends a one-shot preamble to the next prompt pointing at the + transcript path. + +The fallback is universal because it only requires the agent to read a file with +its normal tools. It is lower fidelity than native resume because the transcript +is pointed to, not automatically loaded into the agent's context window. + +## Unknown Session Normalization + +Adapters report missing sessions differently. The sidecar normalizes known +missing-session shapes into: + +```json +{ "error": { "data": { "kind": "unknown_session" } } } +``` + +For example, OpenCode currently reports a missing native session as: + +```json +{ "code": -32603, "data": { "details": "NotFoundError" } } +``` + +That shape is captured before normalization in tests, then normalized so the +resume state machine can safely choose transcript fallback. Other internal +errors still propagate as failures. + +## Persistence + +Durable session state lives in actor SQLite: + +| Table | Purpose | +| --- | --- | +| `agent_os_sessions` | Stable session registry, agent type, capabilities, agent info, create-time `cwd`, and create-time `env`. | +| `agent_os_session_events` | Append-only prompt and ACP event log keyed by the stable external `sessionId`. | + +The transcript file is not canonical state. It is a disposable render of +`agent_os_session_events`, rebuilt on demand during fallback resume. + +## What Is Durable + +| Data | Survives sleep/wake? | Notes | +| --- | --- | --- | +| Actor SQLite | Yes | Stores session registry, events, preview tokens, and other actor data. | +| VM filesystem | Yes, when backed by the actor sqlite_vfs root | Used by agents and resume transcripts. | +| Live ACP process | No | Recreated on wake. | +| Actor in-memory vars | No | Includes the live ACP id remap. | +| Client-facing `sessionId` | Yes | Stored in `agent_os_sessions`. | + +## Where To Look In Code + +- Sidecar ACP orchestration: + `crates/agent-os-sidecar/src/acp_extension.rs` +- Agent OS TypeScript client surface: + `packages/core/src/agent-os.ts` +- RivetKit actor session actions: + `rivetkit-rust/packages/rivetkit-agent-os/src/actions/session.rs` +- RivetKit persistence helpers: + `rivetkit-rust/packages/rivetkit-agent-os/src/persistence.rs` diff --git a/website/src/content/docs/docs/quickstart.mdx b/website/src/content/docs/docs/quickstart.mdx index 72bd088bd..adf746e37 100644 --- a/website/src/content/docs/docs/quickstart.mdx +++ b/website/src/content/docs/docs/quickstart.mdx @@ -15,11 +15,11 @@ agentOS is in preview and the API is subject to change. If you run into issues, 1. **Install** - **rivetkit** — Actor framework with built-in persistence and orchestration - - **@rivet-dev/agent-os-common** — Standard VM software (curl, grep, git, and more) - - **@rivet-dev/agent-os-pi** — [Pi](https://github.com/mariozechner/pi-coding-agent) coding agent (Claude Code, Amp, and OpenCode coming soon) + - **@rivet-dev/agentos-common** — Standard VM software (curl, grep, git, and more) + - **@rivet-dev/agentos-pi** — [Pi](https://github.com/mariozechner/pi-coding-agent) coding agent (Claude Code, Amp, and OpenCode coming soon) ```bash - npm install rivetkit @rivet-dev/agent-os-common @rivet-dev/agent-os-pi + npm install rivetkit @rivet-dev/agentos-common @rivet-dev/agentos-pi ``` 2. **Create the Server & Client** @@ -29,8 +29,8 @@ agentOS is in preview and the API is subject to change. If you run into issues, ```ts title="server.ts" import { agentOs } from "rivetkit/agent-os"; import { setup } from "rivetkit"; - import common from "@rivet-dev/agent-os-common"; - import pi from "@rivet-dev/agent-os-pi"; + import common from "@rivet-dev/agentos-common"; + import pi from "@rivet-dev/agentos-pi"; const vm = agentOs({ options: { software: [common, pi] }, @@ -96,6 +96,6 @@ agentOS is in preview and the API is subject to change. If you run into issues, ## agentOS Core -The quickstart above uses `rivetkit/agent-os`, which includes statefulness, multiplayer, and orchestration out of the box. If you only need direct VM control without those features, you can use the core package (`@rivet-dev/agent-os-core`) standalone. +The quickstart above uses `rivetkit/agent-os`, which includes statefulness, multiplayer, and orchestration out of the box. If you only need direct VM control without those features, you can use the core package (`@rivet-dev/agentos-core`) standalone. See [agentOS core documentation](/docs/core) for reference.