diff --git a/.gitignore b/.gitignore
index 486a0d99c..ab2af40af 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,3 +74,7 @@ packages/browser/.cache/
# Vendored V8 bridge bundles staged at release time for crates.io publishing
crates/execution/assets/generated/
crates/v8-runtime/assets/generated/
+
+# Transient repro scratch files and Vite/Vitest config timestamp artifacts
+.tmp-*
+*.timestamp-*.mjs
diff --git a/CLAUDE.md b/CLAUDE.md
index c3a2067cb..44d4329ee 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -39,6 +39,7 @@ Agent OS is a **fully virtualized operating system**. The kernel, written as a R
- **Base filesystem rebuild flow:** `pnpm --dir packages/core snapshot:alpine-defaults` writes `alpine-defaults.json`, then `pnpm --dir packages/core build:base-filesystem` rewrites AgentOs-specific values and emits `base-filesystem.json`.
- **The default VM filesystem model should be Docker-like.** Layered overlay view with one writable upper layer on top of one or more immutable lower snapshot layers.
- **Everything runs inside the VM.** Agent processes, servers, network requests -- all spawned inside the Agent OS kernel, never on the host. This is a hard rule with no exceptions.
+- **Present normal Linux semantics to tools.** Never bend agent SDKs, shell tools, or adapters around Agent OS quirks when the correct fix is implementing standard Linux/Node/POSIX behavior in the runtime. Agent-specific patches are acceptable only for explicit product policy, configuration, or upstream SDK bugs.
## Native Binary Distribution
@@ -145,11 +146,17 @@ When the user asks to track something in a note, store it in `~/.agents/notes/`
## Error Handling
- Always return anyhow errors from failable Rust functions. Do not glob-import from anyhow. Prefer `.context()` over the `anyhow!` macro.
+- A failing fallback path must rethrow the original error with the fallback's failure attached as context. Never let the fallback's error replace the original.
+
+## Runtime Limits
+
+- **Every new limit-shaped constant must be classified.** Any `MAX_*` / `*_LIMIT` / `*_CAPACITY` / retention / sizing constant added under the scanned roots must get an entry in `crates/sidecar/tests/fixtures/limits-inventory.json`: either `policy` (wired through `VmLimits` with a `wired` field naming its config field) or `invariant`/`policy-deferred` with a one-line rationale. The `cargo test -p agent-os-sidecar --test limits_audit` audit fails when a qualifying constant is unclassified.
## Fail-By-Default Runtime
- Avoid silent no-ops for required runtime behavior. If a capability is required, validate it and throw an explicit error with actionable context instead of returning early.
- Do not use optional chaining for required lifecycle and bridge operations. Optional chaining is acceptable only for best-effort diagnostics and cleanup paths (logging hooks, dispose/release cleanup).
+- Never land a public callback, stream, or event API without a wired delivery source. If the source is not wired yet, the doc comment must say so explicitly so callers do not wait on a stream that never yields.
## Async Rust Locks
@@ -167,6 +174,9 @@ When the user asks to track something in a note, store it in `~/.agents/notes/`
- Reserve `tokio::time::sleep` for per-call timeouts, retry/reconnect backoff, deliberate debounce windows, or `sleep_until(deadline)` arms in an event-select loop. A `loop { check; sleep }` body is polling and should be event-driven instead.
- `scc` async methods do not hold locks across `.await` points. Use `entry_async` for atomic read-then-write.
- Never add unexplained wall-clock defers like `sleep(1ms)` to decouple a spawn from its caller. Use `tokio::task::yield_now().await` or rely on the spawn itself.
+- Polling is forbidden in every language and layer, not just Rust. Never wait for a state change by re-checking in a loop in TypeScript, tests, or shell. Wait on an event: a Notify/watch channel, promise, callback, process exit, or stream EOF. If an external system genuinely offers no event signal, bound the poll with a deadline and justify it in a comment.
+- Never block while holding a lock. No bounded-channel sends, thread joins, or IO under any lock guard. Remove or copy the needed state under the lock, release it, then do the blocking work.
+- Code that registers a waiter or pending entry in a shared queue must remove it on every exit path: success, early drain, timeout, and error.
## Memory Leaks
@@ -176,6 +186,11 @@ When the user asks to track something in a note, store it in `~/.agents/notes/`
- `std::mem::forget` is only acceptable when an FFI handle cannot be dropped in the current context; document the constraint inline, prove the leak is bounded, and prefer routing cleanup through an Env-bearing owner.
- Spawned futures that capture JS callbacks or other heavy resources must have a guaranteed completion path (e.g. a `CancellationToken` whose clones are guaranteed to drop). A `spawn_local(async move { token.cancelled().await; ... })` only drains if every clone of the token is dropped or cancelled.
+## Untrusted Input
+
+- Write parser bounds checks in subtraction form after an explicit minimum-length guard (`len >= off && len - off >= n`), never `off + n > len`, which wraps on 32-bit targets.
+- Cap any allocation whose size derives from untrusted input before allocating.
+
## Testing
- **Never use `vi.mock`, `jest.mock`, or module-level mocking.** Write tests against real infrastructure (real kernel, real filesystems, real processes). For LLM calls, use `@copilotkit/llmock` to run a mock LLM server. For protocol-level test doubles (e.g., ACP adapters), write hand-written scripts that run as real processes. `vi.fn()` for simple callback tracking is acceptable.
@@ -186,10 +201,15 @@ When the user asks to track something in a note, store it in `~/.agents/notes/`
- This repo uses jj (Jujutsu) on top of git. **jj's workflow is inverted from git:** the working copy is itself a revision that auto-tracks edits, so you create a new revision *before* making changes (with `jj new`) rather than committing *after* (`git commit`). The description is set separately via `jj describe`. There is no staging step.
- Before making changes, check whether jj is initialized by running `jj status`. If it fails (e.g. "There is no jj repo in '.'"), run `jj git init --colocate` from the repo root so jj lives alongside the existing `.git` directory. Do NOT run `jj git init` without `--colocate` — that creates a standalone jj repo and breaks the git workflow.
-- **MUST run `jj new` before making any file edits for a new task.** This is the first step of any task that touches files. Run it before reading, before planning, before editing. The only exception is when you are directly fixing or finishing the change at `@` that you just made in this same session. In that case use `jj squash --into ` or `jj edit `. If you already started editing without running `jj new`, stop and split the changes with `JJ_EDITOR=true jj split ` before continuing. Each revision must be one self-contained change reviewable on its own. Never mix unrelated work into one revision.
+- **One revision = one self-contained change. MUST run `jj new` before starting each change**, before reading, planning, or editing. The unit is the *change*, not the *task*, *request*, or *session*. A single user request routinely contains several unrelated changes (a fix here, a refactor there, a test update); each one is its own revision, so run `jj new` again the moment you move on to the next change. Do not let edits pile up in one revision just because they came from one prompt or one work session.
+- **Heuristic for "is this one revision or several?"** If a single `jj describe` line cannot honestly describe the whole diff without the word "and", or the diff spans unrelated subsystems/concerns (e.g. a test fix plus a build change plus an adapter tweak), it is more than one revision. Err toward more, smaller revisions. A revision touching a dozen files across many subsystems under a vague message like "triage failed tests" is the anti-pattern, not the goal.
+- Run it before reading, before planning, before editing. The only exception is when you are directly fixing or finishing the change at `@` that you just made in this same session. In that case use `jj squash --into ` or `jj edit `. If you already started editing and find the working copy now mixes unrelated changes, stop immediately and split them apart with `JJ_EDITOR=true jj split ` before continuing. Never mix unrelated work into one revision.
- Set the revision description with `jj describe -m "[SLOP({full-model-id}-{reasoning})] {conventional commit message}"`. Use conventional commits (`feat`, `fix`, `chore`, `docs`, `refactor`, etc.) with a single-line message. `{full-model-id}` is the canonical model ID (e.g. `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-haiku-4-5`). `{reasoning}` is the reasoning effort (`high`, `medium`, `low`, `off`) — include it only if the runtime exposes it; otherwise omit the `-{reasoning}` suffix entirely.
- Examples: `[SLOP(claude-opus-4-7-high)] feat(metrics): record depot sqlite phase timings` or, when reasoning is not known, `[SLOP(claude-opus-4-7)] fix(pegboard): handle empty ack batch`.
- **Never add a co-author trailer** (no `Co-Authored-By: ...` line). Descriptions are single-line only.
+- **A revision description must describe its actual diff.** Check the message against `jj diff -r --stat` before running `jj describe`.
+- Abandon stray empty undescribed revisions before ending a session. Do not leave `jj new` artifacts in the branch.
+- Never commit fetched or vendored source trees. Add the ignore entry before fetching.
- **Never push to `main` unless explicitly specified by the user.**
- **Safety:** Never run destructive jj or git commands (`jj git push`, `jj abandon`, `jj squash` into a non-current revision, `jj op restore`, `jj op undo` past your own work, `jj rebase -d main`, `git push --force`, `git reset --hard`) unless the user explicitly requests it.
@@ -207,3 +227,4 @@ pnpm lint # biome check
- CI and release automation must install the pnpm workspace with `--frozen-lockfile` before Cargo builds that generate V8 bridge assets into `OUT_DIR`. Fork pull requests should run the same `pnpm test` command without `AGENTOS_E2E_NETWORK=1`.
- When changing V8 bridge registration or snapshot bootstrap code under `crates/v8-runtime/`, rebuild `agent-os-v8-runtime` before rerunning sidecar V8 integration tests. `cargo test -p agent-os-sidecar` can otherwise reuse stale embedded-runtime objects from `target/`.
- The `crates/v8-runtime` snapshot test (`snapshot::tests::snapshot_consolidated_tests`) currently has to run in isolation: use `cargo test -p agent-os-v8-runtime -- --test-threads=1` for the main suite and `cargo test -p agent-os-v8-runtime snapshot::tests::snapshot_consolidated_tests -- --exact --ignored` separately until the shared test binary teardown SIGSEGV is fixed.
+- Biome honors `.gitignore` (`vcs.useIgnoreFile`), and the core-dump patterns (`**/core`) match `packages/core`, so `pnpm lint` silently skips that entire package. Do not treat a green lint as proof those files were checked. Fixing the pattern requires first cleaning up the package's accumulated lint debt (tracked in `~/.agents/todo/`).
diff --git a/Cargo.lock b/Cargo.lock
index ab9ed6146..5a7231e09 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -62,6 +62,7 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
+ "tracing",
"wat",
]
@@ -114,6 +115,8 @@ dependencies = [
"socket2 0.6.3",
"tokio",
"tokio-rustls 0.26.4",
+ "tracing",
+ "tracing-subscriber",
"ureq",
"url",
"v8",
@@ -137,6 +140,7 @@ dependencies = [
"crossbeam-channel",
"libc",
"openssl",
+ "serde",
"signal-hook",
"v8",
]
@@ -2033,6 +2037,12 @@ dependencies = [
"simple_asn1",
]
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -2217,6 +2227,15 @@ dependencies = [
"minimal-lexical",
]
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "num-bigint"
version = "0.4.6"
@@ -2952,6 +2971,15 @@ dependencies = [
"digest",
]
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
[[package]]
name = "shlex"
version = "1.3.0"
@@ -3170,6 +3198,15 @@ dependencies = [
"syn",
]
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "time"
version = "0.3.47"
@@ -3338,6 +3375,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "nu-ansi-term",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing-core",
+ "tracing-log",
]
[[package]]
@@ -3452,6 +3515,12 @@ dependencies = [
"which",
]
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
[[package]]
name = "vcpkg"
version = "0.2.15"
diff --git a/README.md b/README.md
index b1f4a1b95..f2878fac5 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
- A portable open-source operating system for AI agents. Near-zero cold starts (~6 ms), up to 32x cheaper than sandboxes. Powered by WebAssembly and V8 isolates.
Supports Pi, Claude Code*, Codex*, Amp*, and OpenCode* * coming soon
+ A portable open-source operating system for AI agents. Near-zero cold starts (~6 ms), up to 32x cheaper than sandboxes. Built-in ACP agents: Pi, Claude Code, and OpenCode
@@ -28,11 +28,11 @@ You don't have to choose: agentOS works with sandboxes through the [sandbox exte
## Quick start
```bash
-npm install @rivet-dev/agent-os @rivet-dev/agent-os-common @rivet-dev/agent-os-pi
+npm install @rivet-dev/agent-os-core @rivet-dev/agent-os-common @rivet-dev/agent-os-pi
```
```ts
-import { AgentOs } from "@rivet-dev/agent-os";
+import { AgentOs } from "@rivet-dev/agent-os-core";
import common from "@rivet-dev/agent-os-common";
import pi from "@rivet-dev/agent-os-pi";
@@ -107,13 +107,13 @@ All benchmarks compare agentOS against the fastest/cheapest mainstream sandbox p
## Features
### Agents
-- **Multi-agent support**: Run Claude Code, Codex, OpenCode, Amp, Pi, and more with a unified API
+- **Multi-agent support**: Run built-in Pi, Claude Code, and OpenCode agents with a unified API, plus install registry command packages such as Codex as VM software
- **[Sessions via ACP](https://rivet.dev/docs/agent-os/sessions)**: Create, manage, and resume agent sessions over the [Agent Communication Protocol](https://agentclientprotocol.com)
- **Universal transcript format**: One transcript format across all agents for debugging, auditing, and comparison
- **[Automatic persistence](https://rivet.dev/docs/agent-os/persistence)**: Every conversation is saved and replayable without extra code
### Infrastructure
-- **[Mount anything as a filesystem](https://rivet.dev/docs/agent-os/filesystem)**: S3, Google Drive, SQLite, host directories, or custom backends
+- **[Mount external storage as a filesystem](https://rivet.dev/docs/agent-os/filesystem)**: S3-compatible storage, Google Drive, host directories, overlay filesystems, or custom backends
- **[Host tools](https://rivet.dev/docs/agent-os/tools)**: Define JavaScript functions that agents call as CLI commands inside the VM
- **[Cron](https://rivet.dev/docs/agent-os/cron), [webhooks](https://rivet.dev/docs/agent-os/webhooks), and [queues](https://rivet.dev/docs/agent-os/queues)**: Schedule tasks, receive external events, and serialize work with built-in primitives
- **[Sandbox extension](https://rivet.dev/docs/agent-os/sandbox)**: Pair with full sandboxes (E2B, Daytona, etc.) for heavy workloads like browsers or native compilation
@@ -128,16 +128,11 @@ All benchmarks compare agentOS against the fastest/cheapest mainstream sandbox p
- **[Deny-by-default permissions](https://rivet.dev/docs/agent-os/security)**: Granular control over filesystem, network, process, and environment access
- **[Programmatic network control](https://rivet.dev/docs/agent-os/networking)**: Allow, deny, or proxy any outbound connection
- **[Resource limits](https://rivet.dev/docs/agent-os/security)**: Set precise CPU and memory limits per agent
-- **[V8 + WebAssembly isolation](https://rivet.dev/docs/agent-os/architecture)**: Each agent runs in its own isolate with no shared state
+- **[VM isolation](https://rivet.dev/docs/agent-os/architecture)**: Each agent runs in its own VM with no shared state
## Architecture
-agentOS is built on an in-process operating system kernel written in JavaScript. Three runtimes mount into the kernel:
-
-- **WebAssembly**: POSIX utilities (coreutils, grep, sed, etc.) compiled to WASM
-- **V8 isolates**: JavaScript/TypeScript agent code runs in sandboxed V8 contexts
-
-The kernel manages a virtual filesystem, process table, pipes, PTYs, and a virtual network stack. Everything runs inside the kernel -- nothing executes on the host.
+agentOS is built on an in-process operating system kernel. The kernel manages a virtual filesystem, process table, pipes, PTYs, and a virtual network stack. Everything runs inside the kernel -- nothing executes on the host.
See the [Architecture docs](https://rivet.dev/docs/agent-os/architecture) for details.
@@ -146,11 +141,11 @@ See the [Architecture docs](https://rivet.dev/docs/agent-os/architecture) for de
Browse pre-built agents, tools, filesystems, and software packages at the [agentOS Registry](https://rivet.dev/agent-os/registry).
-### WASM Command Packages
+### VM Command Packages
| Package | apt Equivalent | Description | Source | Combined Size | Gzipped |
|---------|---------------|-------------|--------|---------------|---------|
-| `@rivet-dev/agent-os-codex` | codex | OpenAI Codex integration (codex, codex-exec) | rust | - | - |
+| `@rivet-dev/agent-os-codex` | codex | OpenAI Codex command package (codex, codex-exec) | rust | - | - |
| `@rivet-dev/agent-os-coreutils` | coreutils | GNU coreutils: sh, cat, ls, cp, sort, and 80+ commands | rust | - | - |
| `@rivet-dev/agent-os-curl` | curl | curl HTTP client | c | - | - |
| `@rivet-dev/agent-os-diffutils` | diffutils | GNU diffutils (diff) | rust | - | - |
@@ -177,8 +172,8 @@ Browse pre-built agents, tools, filesystems, and software packages at the [agent
| Package | Description | Includes |
|---------|-------------|----------|
-| `@rivet-dev/agent-os-build-essential` | Build-essential WASM command set (standard + make + git + curl) | standard, make, git, curl |
-| `@rivet-dev/agent-os-common` | Common WASM command set (coreutils + sed + grep + gawk + findutils + diffutils + tar + gzip) | coreutils, sed, grep, gawk, findutils, diffutils, tar, gzip |
+| `@rivet-dev/agent-os-build-essential` | Build-essential VM command set (standard + make + git + curl) | standard, make, git, curl |
+| `@rivet-dev/agent-os-common` | Common VM command set (coreutils + sed + grep + gawk + findutils + diffutils + tar + gzip) | coreutils, sed, grep, gawk, findutils, diffutils, tar, gzip |
## License
diff --git a/crates/CLAUDE.md b/crates/CLAUDE.md
index 191636566..a6002ef4a 100644
--- a/crates/CLAUDE.md
+++ b/crates/CLAUDE.md
@@ -112,6 +112,7 @@ These are hard rules with no exceptions:
- **Control-plane stop/continue for shared V8 runtime processes must update kernel state as well as the host runtime PID.** In `crates/sidecar/src/execution.rs`, when `kill_process_internal(...)` handles `SIGSTOP`/`SIGCONT` for `uses_shared_v8_runtime()` executions, route the signal through `vm.kernel.kill_process(...)` after signaling the shared runtime so `GetProcessSnapshot` and wait semantics reflect the requested stopped/running transition.
- **Shared-runtime Wasm executions do not have a per-guest host PID to reap.** In `crates/sidecar/src/execution.rs`, `kill_process_internal(...)` should terminate embedded Wasm sessions directly for `SIGTERM`/`SIGKILL`-style control-plane kills and queue a synthetic `ActiveExecutionEvent::Exited(...)` when the event channel closes without a process-specific host exit notification; waiting on the shared runtime PID will hang because the embedded runtime process stays alive for other sessions.
- **Nested `child_process` shell handling must preserve shell builtins without forcing every command through `sh -c`.** In `crates/sidecar/src/execution.rs`, `shell: true` requests still need a real shell for builtins like `exit`, but plain commands such as `cat /tmp/file` should stay on the direct resolved-command path or sandbox/host path behavior can diverge again.
+- **Shell grammar (redirects, pipelines, globbing, quoting) belongs to the VM shell.** The kernel exposes process, fd, and VFS primitives; the bridge routes shell-mode commands to `/bin/sh -c` and never parses shell syntax itself.
- **Resolve symlinked JavaScript entrypoints before launching the guest Node runtime.** In `crates/sidecar/src/execution.rs`, follow `node_modules/.bin/*` symlinks (and existing shell shims) before treating a file as a JavaScript main module, or relative imports like `require("../src/defaults")` resolve against the `.bin` alias instead of the real package bin.
- **The npm/npx display shim should suppress libnpmexec's synthetic `> npx` banner.** In `crates/sidecar/src/execution.rs` `build_host_node_cli_eval(...)`, the display stub forwards `proc-log` output directly, so filter the run-script banner emitted for the fake `npx` lifecycle event or `npx -y ` no longer matches native stdout.
- **Process-event handlers must fail closed on stale VM/process state.** In `crates/sidecar/src/execution.rs` and `crates/sidecar/src/service.rs`, any VM/process lookup reached from queued execution events or JS sync-RPC dispatch should log through `log_stale_process_event(...)` and return cleanly instead of using `expect(...)`, because teardown can win after an event is queued but before it is drained.
@@ -120,6 +121,7 @@ These are hard rules with no exceptions:
- **When extracting large sidecar test modules out of `src/`, keep the tests in `crates/sidecar/tests/*.rs` by wrapping `include!("../src/...")` in a same-named module and nesting the moved assertions under `mod tests`.** This preserves the original `use super::*` access to private helpers, and service-style harnesses also need crate-root re-exports for items that sibling modules import via `crate::{DispatchResult, NativeSidecar, SidecarError}`.
- **If a shared `src/` module carries helper code that only those included test harnesses use, gate the helper types/functions and their imports with `#[cfg(test)]`.** That keeps focused library builds like `cargo test -p agent-os-sidecar --test protocol` warning-free without moving the test scaffold back into production code paths.
- **Sidecar integration tests surface handler failures as `Rejected(...)` responses, not transport-level `Err`s.** When `dispatch_blocking(...)` reaches a real request handler and that handler returns `SidecarError`, assert on `ResponsePayload::Rejected { code, message }`; reserve `expect_err(...)` for transport or framing failures that prevent a response frame from being produced.
+- **Operator-tunable VM limits live on `VmLimits` (`crates/sidecar/src/limits.rs`), parsed from `CreateVmRequest.metadata` `limits..` keys; kernel `ResourceLimits` keeps its `resource.*` keys.** Every new `MAX_*`/`*_LIMIT`/capacity/retention/sizing constant must be classified in `crates/sidecar/tests/fixtures/limits-inventory.json` (`policy` wired through `VmLimits`, or `invariant`/`policy-deferred` with a rationale); `cargo test -p agent-os-sidecar --test limits_audit` enforces it.
## Testing
diff --git a/crates/bridge/bridge-contract.json b/crates/bridge/bridge-contract.json
index 7adf30529..fc3bf7c0c 100644
--- a/crates/bridge/bridge-contract.json
+++ b/crates/bridge/bridge-contract.json
@@ -17,7 +17,19 @@
"convention": "syncPromise",
"argumentTypes": ["specifier: string", "fromDir: string", "mode?: \"require\" | \"import\""],
"returnType": "string | null",
- "names": ["_resolveModule", "_loadFile", "_resolveModuleSync", "_loadFileSync"]
+ "names": ["_resolveModule", "_resolveModuleSync"]
+ },
+ {
+ "convention": "syncPromise",
+ "argumentTypes": ["path: string"],
+ "returnType": "string | null",
+ "names": ["_loadFile", "_loadFileSync"]
+ },
+ {
+ "convention": "syncPromise",
+ "argumentTypes": ["filename: string"],
+ "returnType": "\"module\" | \"commonjs\" | \"json\" | null",
+ "names": ["_moduleFormat"]
},
{
"convention": "syncPromise",
@@ -162,6 +174,7 @@
"_upgradeSocketWriteRaw",
"_upgradeSocketEndRaw",
"_upgradeSocketDestroyRaw",
+ "_networkDnsLookupSyncRaw",
"_netSocketConnectRaw",
"_netSocketPollRaw",
"_netSocketReadRaw",
@@ -174,6 +187,8 @@
"_netSocketGetTlsClientHelloRaw",
"_netSocketTlsQueryRaw",
"_tlsGetCiphersRaw",
+ "_netReserveTcpPortRaw",
+ "_netReleaseTcpPortRaw",
"_netServerListenRaw",
"_netServerAcceptRaw",
"_dgramSocketCreateRaw",
diff --git a/crates/bridge/src/lib.rs b/crates/bridge/src/lib.rs
index 37ad1db4b..c4e02d0df 100644
--- a/crates/bridge/src/lib.rs
+++ b/crates/bridge/src/lib.rs
@@ -522,7 +522,7 @@ pub fn bridge_contract() -> &'static BridgeContract {
#[cfg(test)]
mod tests {
- use super::{bridge_contract, BridgeCallConvention};
+ use super::{BridgeCallConvention, bridge_contract};
#[test]
fn bridge_contract_has_version_and_unique_method_names() {
@@ -564,4 +564,46 @@ mod tests {
);
}
}
+
+ #[test]
+ fn bridge_contract_module_loading_signatures_match_runtime_calls() {
+ let contract = bridge_contract();
+
+ let find_group = |method: &str| {
+ contract
+ .groups
+ .iter()
+ .find(|group| group.names.iter().any(|name| name == method))
+ .unwrap_or_else(|| panic!("missing bridge contract method {method}"))
+ };
+
+ let resolve_group = find_group("_resolveModule");
+ assert_eq!(resolve_group.convention, BridgeCallConvention::SyncPromise);
+ assert_eq!(
+ resolve_group.argument_types,
+ vec![
+ "specifier: string",
+ "fromDir: string",
+ "mode?: \"require\" | \"import\""
+ ]
+ );
+ assert_eq!(
+ resolve_group.names,
+ vec!["_resolveModule", "_resolveModuleSync"]
+ );
+
+ let load_group = find_group("_loadFile");
+ assert_eq!(load_group.convention, BridgeCallConvention::SyncPromise);
+ assert_eq!(load_group.argument_types, vec!["path: string"]);
+ assert_eq!(load_group.names, vec!["_loadFile", "_loadFileSync"]);
+
+ let format_group = find_group("_moduleFormat");
+ assert_eq!(format_group.convention, BridgeCallConvention::SyncPromise);
+ assert_eq!(format_group.argument_types, vec!["filename: string"]);
+ assert_eq!(
+ format_group.return_type,
+ "\"module\" | \"commonjs\" | \"json\" | null"
+ );
+ assert_eq!(format_group.names, vec!["_moduleFormat"]);
+ }
}
diff --git a/crates/bridge/tests/bridge.rs b/crates/bridge/tests/bridge.rs
index bb13bdbbc..2e59c6a92 100644
--- a/crates/bridge/tests/bridge.rs
+++ b/crates/bridge/tests/bridge.rs
@@ -37,12 +37,14 @@ where
contents: b"world".to_vec(),
})
.expect("write file");
- assert!(bridge
- .exists(PathRequest {
- vm_id: String::from("vm-1"),
- path: String::from("/workspace/output.txt"),
- })
- .expect("exists after write"));
+ assert!(
+ bridge
+ .exists(PathRequest {
+ vm_id: String::from("vm-1"),
+ path: String::from("/workspace/output.txt"),
+ })
+ .expect("exists after write")
+ );
let directory = bridge
.read_dir(ReadDirRequest {
diff --git a/crates/bridge/tests/support.rs b/crates/bridge/tests/support.rs
index 6c31016fc..6e8b2b3e1 100644
--- a/crates/bridge/tests/support.rs
+++ b/crates/bridge/tests/support.rs
@@ -11,7 +11,7 @@ use agent_os_bridge::{
StructuredEventRecord, SymlinkRequest, TruncateRequest, WriteExecutionStdinRequest,
WriteFileRequest,
};
-use std::collections::{BTreeMap, VecDeque};
+use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -20,11 +20,23 @@ pub struct StubError {
}
impl StubError {
+ pub fn new(message: impl Into) -> Self {
+ Self {
+ message: message.into(),
+ }
+ }
+
fn missing(kind: &'static str, key: &str) -> Self {
Self {
message: format!("missing {kind}: {key}"),
}
}
+
+ fn invalid(kind: &'static str, key: &str) -> Self {
+ Self {
+ message: format!("invalid {kind}: {key}"),
+ }
+ }
}
#[derive(Debug)]
@@ -37,6 +49,9 @@ pub struct RecordingBridge {
symlinks: BTreeMap,
snapshots: BTreeMap,
execution_events: VecDeque,
+ permission_responses: VecDeque>,
+ worker_create_errors: VecDeque,
+ execution_start_errors: VecDeque,
pub filesystem_permission_requests: Vec,
pub permission_checks: Vec,
pub log_events: Vec,
@@ -47,6 +62,8 @@ pub struct RecordingBridge {
pub stdin_writes: Vec,
pub closed_executions: Vec,
pub killed_executions: Vec,
+ #[allow(dead_code)]
+ pub terminated_workers: Vec<(String, String, String)>,
}
impl Default for RecordingBridge {
@@ -63,6 +80,9 @@ impl Default for RecordingBridge {
symlinks: BTreeMap::new(),
snapshots: BTreeMap::new(),
execution_events: VecDeque::new(),
+ permission_responses: VecDeque::new(),
+ worker_create_errors: VecDeque::new(),
+ execution_start_errors: VecDeque::new(),
filesystem_permission_requests: Vec::new(),
permission_checks: Vec::new(),
log_events: Vec::new(),
@@ -73,6 +93,7 @@ impl Default for RecordingBridge {
stdin_writes: Vec::new(),
closed_executions: Vec::new(),
killed_executions: Vec::new(),
+ terminated_workers: Vec::new(),
}
}
}
@@ -95,12 +116,46 @@ impl RecordingBridge {
self.execution_events.push_back(event);
}
+ pub fn push_permission_decision(&mut self, decision: PermissionDecision) {
+ self.permission_responses.push_back(Ok(decision));
+ }
+
+ pub fn push_permission_error(&mut self, message: impl Into) {
+ self.permission_responses
+ .push_back(Err(StubError::new(message)));
+ }
+
+ pub fn push_worker_create_error(&mut self, message: impl Into) {
+ self.worker_create_errors.push_back(StubError::new(message));
+ }
+
+ pub fn push_execution_start_error(&mut self, message: impl Into) {
+ self.execution_start_errors
+ .push_back(StubError::new(message));
+ }
+
+ pub fn next_worker_create_error(&mut self) -> Option {
+ self.worker_create_errors.pop_front()
+ }
+
+ fn next_permission_response(&mut self) -> Result {
+ self.permission_responses
+ .pop_front()
+ .unwrap_or_else(|| Ok(PermissionDecision::allow()))
+ }
+
fn metadata_for_path(&self, path: &str, follow_links: bool) -> Result {
+ let mut current_path = path.to_owned();
+ let mut seen_links = BTreeSet::new();
+
if follow_links {
- if let Some(target) = self.symlinks.get(path) {
- return self.metadata_for_path(target, true);
+ while let Some(target) = self.symlinks.get(¤t_path) {
+ if !seen_links.insert(current_path.clone()) {
+ return Err(StubError::invalid("symlink cycle", ¤t_path));
+ }
+ current_path = target.clone();
}
- } else if self.symlinks.contains_key(path) {
+ } else if self.symlinks.contains_key(¤t_path) {
return Ok(FileMetadata {
mode: 0o777,
size: 0,
@@ -108,7 +163,7 @@ impl RecordingBridge {
});
}
- if let Some(bytes) = self.files.get(path) {
+ if let Some(bytes) = self.files.get(¤t_path) {
return Ok(FileMetadata {
mode: 0o644,
size: bytes.len() as u64,
@@ -116,7 +171,7 @@ impl RecordingBridge {
});
}
- if let Some(entries) = self.directories.get(path) {
+ if let Some(entries) = self.directories.get(¤t_path) {
return Ok(FileMetadata {
mode: 0o755,
size: entries.len() as u64,
@@ -124,7 +179,7 @@ impl RecordingBridge {
});
}
- Err(StubError::missing("path", path))
+ Err(StubError::missing("path", ¤t_path))
}
}
@@ -235,7 +290,7 @@ impl PermissionBridge for RecordingBridge {
self.filesystem_permission_requests.push(request.clone());
self.permission_checks
.push(format!("fs:{}:{}", request.vm_id, request.path));
- Ok(PermissionDecision::allow())
+ self.next_permission_response()
}
fn check_network_access(
@@ -244,7 +299,7 @@ impl PermissionBridge for RecordingBridge {
) -> Result {
self.permission_checks
.push(format!("net:{}:{}", request.vm_id, request.resource));
- Ok(PermissionDecision::allow())
+ self.next_permission_response()
}
fn check_command_execution(
@@ -253,7 +308,7 @@ impl PermissionBridge for RecordingBridge {
) -> Result {
self.permission_checks
.push(format!("cmd:{}:{}", request.vm_id, request.command));
- Ok(PermissionDecision::allow())
+ self.next_permission_response()
}
fn check_environment_access(
@@ -262,7 +317,7 @@ impl PermissionBridge for RecordingBridge {
) -> Result {
self.permission_checks
.push(format!("env:{}:{}", request.vm_id, request.key));
- Ok(PermissionDecision::allow())
+ self.next_permission_response()
}
}
@@ -365,6 +420,10 @@ impl ExecutionBridge for RecordingBridge {
&mut self,
_request: StartExecutionRequest,
) -> Result {
+ if let Some(error) = self.execution_start_errors.pop_front() {
+ return Err(error);
+ }
+
let execution = StartedExecution {
execution_id: format!("exec-{}", self.next_execution_id),
};
@@ -394,3 +453,38 @@ impl ExecutionBridge for RecordingBridge {
Ok(self.execution_events.pop_front())
}
}
+
+#[test]
+fn recording_bridge_rejects_symlink_cycles_when_following_metadata() {
+ let mut bridge = RecordingBridge::default();
+ bridge
+ .symlink(SymlinkRequest {
+ vm_id: String::from("vm-1"),
+ target_path: String::from("/b"),
+ link_path: String::from("/a"),
+ })
+ .expect("create first symlink");
+ bridge
+ .symlink(SymlinkRequest {
+ vm_id: String::from("vm-1"),
+ target_path: String::from("/a"),
+ link_path: String::from("/b"),
+ })
+ .expect("create second symlink");
+
+ let error = bridge
+ .stat(PathRequest {
+ vm_id: String::from("vm-1"),
+ path: String::from("/a"),
+ })
+ .expect_err("cycle should be rejected");
+ assert!(error.message.contains("symlink cycle"));
+
+ let metadata = bridge
+ .lstat(PathRequest {
+ vm_id: String::from("vm-1"),
+ path: String::from("/a"),
+ })
+ .expect("lstat should not follow symlink");
+ assert_eq!(metadata.kind, FileKind::SymbolicLink);
+}
diff --git a/crates/build-support/v8_bridge_build.rs b/crates/build-support/v8_bridge_build.rs
new file mode 100644
index 000000000..ea85ac88e
--- /dev/null
+++ b/crates/build-support/v8_bridge_build.rs
@@ -0,0 +1,228 @@
+use std::env;
+use std::fs;
+use std::io;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+const ENV_NODE: &str = "AGENT_OS_NODE";
+const ENV_BUILD_SCRIPT: &str = "AGENT_OS_V8_BRIDGE_BUILD_SCRIPT";
+const ENV_DEBUG: &str = "AGENT_OS_GENERATED_ASSET_DEBUG";
+
+pub fn build_v8_bridge(crate_manifest_dir: &Path, out_dir: &Path) {
+ let repo_root = crate_manifest_dir
+ .parent()
+ .and_then(Path::parent)
+ .unwrap_or_else(|| {
+ panic!(
+ "failed to resolve repo root from CARGO_MANIFEST_DIR={}",
+ crate_manifest_dir.display()
+ )
+ });
+ let script_path = resolve_build_script(repo_root);
+ let package_root = script_path
+ .parent()
+ .and_then(Path::parent)
+ .unwrap_or_else(|| {
+ panic!(
+ "failed to resolve package root from V8 bridge build script path {}",
+ script_path.display()
+ )
+ });
+ let node_modules = package_root.join("node_modules");
+ let node = env::var_os(ENV_NODE).unwrap_or_else(|| "node".into());
+ let node_path = PathBuf::from(node);
+ let debug = env::var_os(ENV_DEBUG).is_some();
+
+ emit_rerun_inputs(repo_root, &script_path);
+ println!("cargo:rerun-if-env-changed={ENV_NODE}");
+ println!("cargo:rerun-if-env-changed={ENV_BUILD_SCRIPT}");
+ println!("cargo:rerun-if-env-changed={ENV_DEBUG}");
+
+ if !node_modules.exists() {
+ panic!(
+ "missing Node dependencies at {}. Run `pnpm install` from {} before building V8 bridge assets.",
+ node_modules.display(),
+ repo_root.display()
+ );
+ }
+
+ require_pnpm(repo_root, debug);
+
+ if debug {
+ println!(
+ "cargo:warning=building V8 bridge with node={} script={} out_dir={}",
+ node_path.display(),
+ script_path.display(),
+ out_dir.display()
+ );
+ }
+
+ let output = Command::new(&node_path)
+ .arg(&script_path)
+ .arg("--out-dir")
+ .arg(out_dir)
+ .current_dir(repo_root)
+ .output()
+ .unwrap_or_else(|error| match error.kind() {
+ io::ErrorKind::NotFound => panic!(
+ "failed to build V8 bridge assets because `{}` was not found. Install Node.js or set {ENV_NODE} to the Node binary.",
+ node_path.display()
+ ),
+ _ => panic!(
+ "failed to spawn V8 bridge build with `{}`: {}",
+ node_path.display(),
+ error
+ ),
+ });
+
+ if !output.status.success() {
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ let dependency_hint = if stderr.contains("ERR_MODULE_NOT_FOUND")
+ || stderr.contains("Cannot find package")
+ || stderr.contains("Cannot find module")
+ {
+ "\nNode dependencies appear to be missing or incomplete. Run `pnpm install` from the repo root."
+ } else {
+ ""
+ };
+
+ panic!(
+ "failed to build V8 bridge assets with `{}` (status: {}).{}\nstdout:\n{}\nstderr:\n{}",
+ node_path.display(),
+ output.status,
+ dependency_hint,
+ stdout.trim(),
+ stderr.trim()
+ );
+ }
+
+ let bridge_output = out_dir.join("v8-bridge.js");
+ let zlib_output = out_dir.join("v8-bridge-zlib.js");
+ if !bridge_output.exists() || !zlib_output.exists() {
+ panic!(
+ "V8 bridge build completed but expected outputs are missing: {}, {}",
+ bridge_output.display(),
+ zlib_output.display()
+ );
+ }
+}
+
+fn resolve_build_script(repo_root: &Path) -> PathBuf {
+ match env::var_os(ENV_BUILD_SCRIPT) {
+ Some(path) => {
+ let path = PathBuf::from(path);
+ if path.is_absolute() {
+ path
+ } else {
+ repo_root.join(path)
+ }
+ }
+ None => repo_root.join("packages/core/scripts/build-v8-bridge.mjs"),
+ }
+}
+
+fn require_pnpm(repo_root: &Path, debug: bool) {
+ let output = Command::new("pnpm")
+ .arg("--version")
+ .current_dir(repo_root)
+ .output()
+ .unwrap_or_else(|error| match error.kind() {
+ io::ErrorKind::NotFound => {
+ panic!(
+ "failed to build V8 bridge assets because `pnpm` was not found. Install pnpm and run `pnpm install` from {}.",
+ repo_root.display()
+ )
+ }
+ _ => panic!("failed to check pnpm availability: {}", error),
+ });
+
+ if !output.status.success() {
+ panic!(
+ "failed to build V8 bridge assets because `pnpm --version` failed with status {}. Run `pnpm install` from {} after fixing pnpm.",
+ output.status,
+ repo_root.display()
+ );
+ }
+
+ if debug {
+ println!(
+ "cargo:warning=pnpm version {}",
+ String::from_utf8_lossy(&output.stdout).trim()
+ );
+ }
+}
+
+fn emit_rerun_inputs(repo_root: &Path, script_path: &Path) {
+ let inputs = [
+ repo_root.join("crates/build-support/v8_bridge_build.rs"),
+ script_path.to_path_buf(),
+ repo_root.join("crates/execution/assets/v8-bridge.source.js"),
+ repo_root.join("packages/core/package.json"),
+ repo_root.join("pnpm-lock.yaml"),
+ ];
+
+ for input in inputs {
+ println!("cargo:rerun-if-changed={}", input.display());
+ }
+
+ let shim_dir = repo_root.join("crates/execution/assets/undici-shims");
+ emit_rerun_dir(&shim_dir).unwrap_or_else(|error| {
+ panic!(
+ "failed to enumerate V8 bridge shim inputs under {}: {}",
+ shim_dir.display(),
+ error
+ )
+ });
+}
+
+fn emit_rerun_dir(dir: &Path) -> io::Result<()> {
+ let mut entries = fs::read_dir(dir)?.collect::, _>>()?;
+ entries.sort_by_key(|entry| entry.path());
+
+ for entry in entries {
+ let path = entry.path();
+ let file_type = entry.file_type()?;
+ if file_type.is_dir() {
+ emit_rerun_dir(&path)?;
+ } else {
+ println!("cargo:rerun-if-changed={}", path.display());
+ }
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::emit_rerun_dir;
+ use std::fs;
+ use std::io;
+ use std::path::PathBuf;
+
+ fn temp_test_dir(name: &str) -> io::Result {
+ let mut path = std::env::temp_dir();
+ path.push(format!(
+ "agent-os-v8-bridge-build-{name}-{}",
+ std::process::id()
+ ));
+ let _ = fs::remove_dir_all(&path);
+ fs::create_dir(&path)?;
+ Ok(path)
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn emit_rerun_dir_does_not_follow_directory_symlinks() -> io::Result<()> {
+ let dir = temp_test_dir("symlink-cycle")?;
+ fs::write(dir.join("shim.js"), b"export {};")?;
+ std::os::unix::fs::symlink(&dir, dir.join("self"))?;
+
+ let result = emit_rerun_dir(&dir);
+ let cleanup = fs::remove_dir_all(&dir);
+
+ result?;
+ cleanup?;
+ Ok(())
+ }
+}
diff --git a/crates/client/src/agent_os.rs b/crates/client/src/agent_os.rs
index f4fe0d402..6551612f4 100644
--- a/crates/client/src/agent_os.rs
+++ b/crates/client/src/agent_os.rs
@@ -7,7 +7,7 @@
use std::collections::{BTreeMap, VecDeque};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, AtomicUsize, Ordering};
-use std::sync::Arc;
+use std::sync::{Arc, Weak};
use std::time::Duration;
use scc::{HashMap as SccHashMap, HashSet as SccHashSet};
@@ -16,14 +16,19 @@ use tokio::task::JoinHandle;
use agent_os_sidecar::protocol::{
ConfigureVmRequest, CreateVmRequest, DisposeReason, DisposeVmRequest, EventPayload,
- GuestRuntimeKind, MountDescriptor, MountPluginDescriptor, OpenSessionRequest, OwnershipScope,
- PermissionsPolicy, RegisterToolkitRequest, RegisteredToolDefinition, RequestPayload,
- ResponsePayload, RootFilesystemDescriptor, SidecarPlacement, SidecarRequestPayload,
- SidecarResponsePayload, SoftwareDescriptor, ToolInvocationRequest, ToolInvocationResultResponse,
- VmLifecycleState,
+ FsPermissionRule as WireFsPermissionRule, FsPermissionRuleSet as WireFsPermissionRuleSet,
+ FsPermissionScope, GuestRuntimeKind, KillProcessRequest, MountDescriptor,
+ MountPluginDescriptor, OpenSessionRequest, OwnershipScope,
+ PatternPermissionRule as WirePatternPermissionRule,
+ PatternPermissionRuleSet as WirePatternPermissionRuleSet, PatternPermissionScope,
+ PermissionMode as WirePermissionMode, PermissionsPolicy, RegisterToolkitRequest,
+ RegisteredToolDefinition, RequestPayload, ResponsePayload, RootFilesystemDescriptor,
+ SidecarPermissionResultResponse, SidecarPlacement, SidecarRequestPayload,
+ SidecarResponsePayload, SoftwareDescriptor, ToolInvocationRequest,
+ ToolInvocationResultResponse, VmLifecycleState,
};
-use crate::config::{AgentOsConfig, HostTool, SoftwareKind, TimerScheduleDriver};
+use crate::config::{AgentOsConfig, HostTool, MountConfig, SoftwareKind, TimerScheduleDriver};
use crate::cron::CronManager;
use crate::error::ClientError;
use crate::json_rpc::SequencedEvent;
@@ -75,6 +80,11 @@ pub(crate) struct ShellEntry {
pub spawned_tx: watch::Sender,
}
+/// A connected ACP terminal process and its output fan-out task.
+pub(crate) struct AcpTerminalEntry {
+ pub exit_task: JoinHandle<()>,
+}
+
/// An ACP session (TS `_sessions` value). Keyed by ACP session id.
pub(crate) struct SessionEntry {
pub agent_type: String,
@@ -91,6 +101,7 @@ pub(crate) struct SessionEntry {
pub event_tx: broadcast::Sender,
pub permission_tx: broadcast::Sender,
pub pending_permission_replies: SccHashMap>,
+ pub pending_session_request_lock: parking_lot::Mutex<()>,
/// Pending prompt resolvers, for cancel prompt-fallback + abort-on-close.
///
/// The resolver carries the intended [`JsonRpcResponse`], mirroring the TS resolver shape
@@ -98,7 +109,8 @@ pub(crate) struct SessionEntry {
/// the abort/cancel site: abort-on-close resolves with the `-32000` `Session closed: ` error,
/// while prompt-cancel resolves with `{ result: { stopReason: "cancelled" } }`. The shape is NOT
/// re-derived from the method downstream.
- pub pending_prompt_resolvers: SccHashMap>,
+ pub pending_prompt_resolvers:
+ SccHashMap>,
}
// ---------------------------------------------------------------------------
@@ -118,10 +130,9 @@ pub(crate) struct AgentOsInner {
pub(crate) session_id: String,
pub(crate) vm_id: String,
pub(crate) request_counter: AtomicI64,
- pub(crate) sidecar_request_counter: AtomicI64,
- pub(crate) max_frame_bytes: AtomicUsize,
// Process registries.
+ pub(crate) process_registry_lock: parking_lot::Mutex<()>,
pub(crate) processes: SccHashMap,
/// Wire `process_id` allocator for `exec` (the kernel-process view). Distinct from the
/// spawn synthetic-pid space so an `exec` call never perturbs the observable `spawn` pid sequence
@@ -130,6 +141,7 @@ pub(crate) struct AgentOsInner {
/// Synthetic display-pid allocator for `spawn` (TS `nextSyntheticPid`, seeded at
/// [`crate::process::SYNTHETIC_PID_BASE`]). The first spawned process gets `SYNTHETIC_PID_BASE`.
pub(crate) synthetic_pid_counter: AtomicU64,
+ pub(crate) observed_process_time_lock: parking_lot::Mutex<()>,
/// First-observed start time (epoch ms) per `":"`, mirroring TS
/// `observedProcessStartTimes`. A process keeps the timestamp first seen in `all_processes` across
/// later calls instead of advancing on every snapshot.
@@ -142,7 +154,9 @@ pub(crate) struct AgentOsInner {
pub(crate) shells: SccHashMap,
pub(crate) shell_counter: AtomicU64,
pub(crate) pending_shell_exits: SccHashMap>,
- pub(crate) acp_terminal_pids: SccHashSet,
+ pub(crate) acp_terminals: SccHashMap,
+ pub(crate) acp_terminal_count: AtomicUsize,
+ pub(crate) acp_terminal_lifecycle_lock: tokio::sync::Mutex<()>,
// Session registries.
pub(crate) sessions: SccHashMap,
@@ -184,7 +198,7 @@ impl AgentOs {
AgentOs::get_shared_sidecar(None, config.sidecar_binary_path.clone()).await?
}
};
- let (transport, connection_id, max_frame_bytes) = sidecar.ensure_connection().await?;
+ let (transport, connection_id, _) = sidecar.ensure_connection().await?;
// 2. Open a session for this VM (connection scope) on the shared connection.
let session = match transport
@@ -199,12 +213,17 @@ impl AgentOs {
{
ResponsePayload::SessionOpened(opened) => opened,
ResponsePayload::Rejected(rejected) => return Err(rejected_to_error(rejected)),
- _ => return Err(ClientError::Sidecar("unexpected open_session response".to_string())),
+ _ => {
+ return Err(ClientError::Sidecar(
+ "unexpected open_session response".to_string(),
+ ));
+ }
};
let session_id = session.session_id;
// 3. Subscribe to events BEFORE CreateVm so the `ready` lifecycle event cannot be missed.
let mut events = transport.subscribe_events();
+ let permissions = permissions_policy(&config);
// 4. Create the VM (session scope). Default root filesystem keeps the bundled base layer.
let vm = match transport
@@ -214,14 +233,18 @@ impl AgentOs {
runtime: GuestRuntimeKind::JavaScript,
metadata: BTreeMap::new(),
root_filesystem: RootFilesystemDescriptor::default(),
- permissions: Some(PermissionsPolicy::allow_all()),
+ permissions: Some(permissions.clone()),
}),
)
.await?
{
ResponsePayload::VmCreated(created) => created,
ResponsePayload::Rejected(rejected) => return Err(rejected_to_error(rejected)),
- _ => return Err(ClientError::Sidecar("unexpected create_vm response".to_string())),
+ _ => {
+ return Err(ClientError::Sidecar(
+ "unexpected create_vm response".to_string(),
+ ));
+ }
};
let vm_id = vm.vm_id;
@@ -240,20 +263,20 @@ impl AgentOs {
.map(|entry| entry.descriptor)
.collect();
+ // Native plugin mounts configured on the client, combined with the wasm command-dir mounts.
+ let mut mounts = serialize_mounts(&config)?;
+ mounts.extend(command_mounts);
+
// 6. Configure the VM (vm scope).
match transport
.request(
OwnershipScope::vm(&connection_id, &session_id, &vm_id),
RequestPayload::ConfigureVm(ConfigureVmRequest {
- mounts: command_mounts,
+ mounts,
software,
- permissions: Some(PermissionsPolicy::allow_all()),
+ permissions: Some(permissions),
module_access_cwd: config.module_access_cwd.clone(),
- instructions: config
- .additional_instructions
- .clone()
- .into_iter()
- .collect(),
+ instructions: config.additional_instructions.clone().into_iter().collect(),
projected_modules: Vec::new(),
command_permissions: BTreeMap::new(),
allowed_node_builtins: config.allowed_node_builtins.clone().unwrap_or_default(),
@@ -264,7 +287,11 @@ impl AgentOs {
{
ResponsePayload::VmConfigured(_) => {}
ResponsePayload::Rejected(rejected) => return Err(rejected_to_error(rejected)),
- _ => return Err(ClientError::Sidecar("unexpected configure_vm response".to_string())),
+ _ => {
+ return Err(ClientError::Sidecar(
+ "unexpected configure_vm response".to_string(),
+ ));
+ }
}
// 6b. Register host tool kits (if any): forward each tool definition via `register_toolkit`,
@@ -303,7 +330,7 @@ impl AgentOs {
_ => {
return Err(ClientError::Sidecar(
"unexpected register_toolkit response".to_string(),
- ))
+ ));
}
}
}
@@ -314,7 +341,6 @@ impl AgentOs {
// 7. Lease this VM on the (possibly shared) sidecar, build cron, and assemble the client.
sidecar.active_vm_count.fetch_add(1, Ordering::SeqCst);
let lease = AgentOsSidecarVmLease {
- vm_id: vm_id.clone(),
sidecar: sidecar.clone(),
};
@@ -330,17 +356,19 @@ impl AgentOs {
session_id,
vm_id,
request_counter: AtomicI64::new(1),
- sidecar_request_counter: AtomicI64::new(-1),
- max_frame_bytes: AtomicUsize::new(max_frame_bytes),
+ process_registry_lock: parking_lot::Mutex::new(()),
processes: SccHashMap::new(),
process_counter: AtomicU64::new(1),
synthetic_pid_counter: AtomicU64::new(SYNTHETIC_PID_BASE),
+ observed_process_time_lock: parking_lot::Mutex::new(()),
observed_process_start_times: SccHashMap::new(),
observed_process_exit_times: SccHashMap::new(),
shells: SccHashMap::new(),
shell_counter: AtomicU64::new(0),
pending_shell_exits: SccHashMap::new(),
- acp_terminal_pids: SccHashSet::new(),
+ acp_terminals: SccHashMap::new(),
+ acp_terminal_count: AtomicUsize::new(0),
+ acp_terminal_lifecycle_lock: tokio::sync::Mutex::new(()),
sessions: SccHashMap::new(),
closed_session_ids: parking_lot::Mutex::new(VecDeque::new()),
closing_session_ids: SccHashSet::new(),
@@ -352,9 +380,20 @@ impl AgentOs {
disposed: AtomicBool::new(false),
};
- Ok(AgentOs {
+ let client = AgentOs {
inner: Arc::new(inner),
- })
+ };
+ // Register the permission router and callback unconditionally (unlike `tool_invocation`,
+ // which is gated on configured tool kits): any agent session can raise a permission
+ // request. Re-registering on a shared transport replaces an identical stateless callback,
+ // same as the `tool_invocation` pattern.
+ let _ = vm_permission_routers()
+ .insert(client.inner.vm_id.clone(), Arc::downgrade(&client.inner));
+ client
+ .inner
+ .transport
+ .register_callback("permission_request", permission_request_callback());
+ Ok(client)
}
/// Dispose the VM (= TS `dispose`). Teardown order:
@@ -377,25 +416,62 @@ impl AgentOs {
// 1. Cron dispose (cancel armed timers + tear down the driver).
self.inner.cron.dispose();
- // 2-5. Best-effort kill every tracked shell and drain its pending exit task (two-phase
- // teardown, bounded by SHELL_DISPOSE_TIMEOUT_MS) so late shell output cannot race a
- // closed transport.
+ // 2-5. Best-effort drain tracked shell and terminal tasks before the VM is disposed, bounded
+ // by SHELL_DISPOSE_TIMEOUT_MS so late output cannot race a closed transport.
let mut exit_tasks = Vec::new();
self.inner.pending_shell_exits.retain(|_, task| {
exit_tasks.push(std::mem::replace(task, tokio::spawn(async {})));
false
});
+
+ {
+ let _terminal_lifecycle_guard = self.inner.acp_terminal_lifecycle_lock.lock().await;
+ let mut terminal_entries = Vec::new();
+ self.inner.acp_terminals.retain(|process_id, entry| {
+ terminal_entries.push((
+ process_id.clone(),
+ std::mem::replace(&mut entry.exit_task, tokio::spawn(async {})),
+ ));
+ false
+ });
+ self.inner.acp_terminal_count.store(0, Ordering::SeqCst);
+ for (process_id, _) in &terminal_entries {
+ let transport = self.transport().clone();
+ let ownership = OwnershipScope::vm(
+ self.inner.connection_id.clone(),
+ self.inner.session_id.clone(),
+ self.inner.vm_id.clone(),
+ );
+ let process_id = process_id.clone();
+ exit_tasks.push(tokio::spawn(async move {
+ let _ = transport
+ .request(
+ ownership,
+ RequestPayload::KillProcess(KillProcessRequest {
+ process_id,
+ signal: String::from("SIGTERM"),
+ }),
+ )
+ .await;
+ }));
+ }
+ for (_, task) in terminal_entries {
+ exit_tasks.push(task);
+ }
+ }
if !exit_tasks.is_empty() {
- let drain = async {
- for task in exit_tasks {
- let _ = task.await;
- }
- };
- let _ = tokio::time::timeout(
+ let mut drain_tasks = exit_tasks;
+ if tokio::time::timeout(
Duration::from_millis(crate::SHELL_DISPOSE_TIMEOUT_MS),
- drain,
+ futures::future::join_all(drain_tasks.iter_mut()),
)
- .await;
+ .await
+ .is_err()
+ {
+ for task in drain_tasks {
+ task.abort();
+ }
+ }
}
// 6-7. Release this VM (DisposeVm best-effort) and its lease. The transport is shared across
@@ -416,6 +492,7 @@ impl AgentOs {
)
.await;
let _ = vm_tools().remove(&self.inner.vm_id);
+ let _ = vm_permission_routers().remove(&self.inner.vm_id);
let sidecar = self.inner.sidecar.clone();
if let Some(lease) = lease {
lease.dispose().await?;
@@ -521,6 +598,57 @@ fn vm_tools() -> &'static SccHashMap client inner, so the shared `permission_request` transport
+/// callback can route a sidecar permission request to the owning client. `Weak` so the registry
+/// never extends a client's lifetime; entries are removed in `shutdown`.
+static VM_PERMISSION_ROUTERS: OnceCell>> = OnceCell::new();
+
+fn vm_permission_routers() -> &'static SccHashMap> {
+ VM_PERMISSION_ROUTERS.get_or_init(SccHashMap::new)
+}
+
+/// The transport callback that answers sidecar permission requests by routing them to the owning
+/// client's `on_permission_request` subscribers. Mirrors TS `_handlePermissionSidecarRequest`.
+fn permission_request_callback() -> SidecarCallback {
+ Arc::new(|payload, ownership| {
+ Box::pin(async move {
+ let request = match payload {
+ SidecarRequestPayload::PermissionRequest(request) => request,
+ SidecarRequestPayload::ToolInvocation(_)
+ | SidecarRequestPayload::AcpRequest(_)
+ | SidecarRequestPayload::JsBridgeCall(_) => {
+ return Ok(SidecarResponsePayload::PermissionRequestResult(
+ SidecarPermissionResultResponse {
+ permission_id: "unknown".to_string(),
+ reply: None,
+ error: Some(
+ "permission callback received a non-permission request".to_string(),
+ ),
+ },
+ ));
+ }
+ };
+ let vm_id = ownership_vm_id(&ownership).unwrap_or("");
+ let inner = vm_permission_routers()
+ .read(vm_id, |_, weak| weak.clone())
+ .and_then(|weak| weak.upgrade());
+ let Some(inner) = inner else {
+ return Ok(SidecarResponsePayload::PermissionRequestResult(
+ SidecarPermissionResultResponse {
+ permission_id: request.permission_id,
+ reply: None,
+ error: Some(format!("no client registered for vm: {vm_id}")),
+ },
+ ));
+ };
+ let client = AgentOs { inner };
+ Ok(SidecarResponsePayload::PermissionRequestResult(
+ client.deliver_sidecar_permission_request(request).await,
+ ))
+ })
+ })
+}
+
/// The transport callback that answers guest tool invocations by running the matching host tool.
fn tool_invocation_callback() -> SidecarCallback {
Arc::new(|payload, ownership| {
@@ -657,6 +785,148 @@ fn build_command_mounts(resolved: &[ResolvedSoftware]) -> Vec {
mounts
}
+fn serialize_mounts(config: &AgentOsConfig) -> Result, ClientError> {
+ config
+ .mounts
+ .iter()
+ .map(|mount| match mount {
+ MountConfig::Native {
+ path,
+ plugin,
+ read_only,
+ } => Ok(MountDescriptor {
+ guest_path: path.clone(),
+ read_only: *read_only,
+ plugin: MountPluginDescriptor {
+ id: plugin.id.clone(),
+ config: plugin
+ .config
+ .clone()
+ .unwrap_or_else(|| serde_json::Value::Object(Default::default())),
+ },
+ }),
+ MountConfig::Plain { .. } => Err(ClientError::Sidecar(
+ "plain mounts cannot be configured during Rust client VM creation".to_string(),
+ )),
+ MountConfig::Overlay { .. } => Err(ClientError::Sidecar(
+ "overlay mounts cannot be configured during Rust client VM creation".to_string(),
+ )),
+ })
+ .collect()
+}
+
+fn permissions_policy(config: &AgentOsConfig) -> PermissionsPolicy {
+ let Some(permissions) = config.permissions.as_ref() else {
+ return PermissionsPolicy::allow_all();
+ };
+
+ PermissionsPolicy {
+ fs: Some(
+ permissions
+ .fs
+ .as_ref()
+ .map(serialize_fs_permissions)
+ .unwrap_or(FsPermissionScope::Mode(WirePermissionMode::Allow)),
+ ),
+ network: Some(
+ permissions
+ .network
+ .as_ref()
+ .map(serialize_pattern_permissions)
+ .unwrap_or(PatternPermissionScope::Mode(WirePermissionMode::Allow)),
+ ),
+ child_process: Some(
+ permissions
+ .child_process
+ .as_ref()
+ .map(serialize_pattern_permissions)
+ .unwrap_or(PatternPermissionScope::Mode(WirePermissionMode::Allow)),
+ ),
+ process: Some(
+ permissions
+ .process
+ .as_ref()
+ .map(serialize_pattern_permissions)
+ .unwrap_or(PatternPermissionScope::Mode(WirePermissionMode::Allow)),
+ ),
+ env: Some(
+ permissions
+ .env
+ .as_ref()
+ .map(serialize_pattern_permissions)
+ .unwrap_or(PatternPermissionScope::Mode(WirePermissionMode::Allow)),
+ ),
+ tool: Some(
+ permissions
+ .tool
+ .as_ref()
+ .map(serialize_pattern_permissions)
+ .unwrap_or(PatternPermissionScope::Mode(WirePermissionMode::Allow)),
+ ),
+ }
+}
+
+fn serialize_fs_permissions(permissions: &crate::config::FsPermissions) -> FsPermissionScope {
+ match permissions {
+ crate::config::FsPermissions::Mode(mode) => {
+ FsPermissionScope::Mode(serialize_permission_mode(*mode))
+ }
+ crate::config::FsPermissions::Rules(rules) => {
+ FsPermissionScope::Rules(WireFsPermissionRuleSet {
+ default: rules.default.map(serialize_permission_mode),
+ rules: rules
+ .rules
+ .iter()
+ .map(|rule| WireFsPermissionRule {
+ mode: serialize_permission_mode(rule.mode),
+ operations: operation_wildcard_if_omitted(&rule.operations),
+ paths: resource_wildcard_if_omitted(&rule.paths),
+ })
+ .collect(),
+ })
+ }
+ }
+}
+
+fn serialize_pattern_permissions(
+ permissions: &crate::config::PatternPermissions,
+) -> PatternPermissionScope {
+ match permissions {
+ crate::config::PatternPermissions::Mode(mode) => {
+ PatternPermissionScope::Mode(serialize_permission_mode(*mode))
+ }
+ crate::config::PatternPermissions::Rules(rules) => {
+ PatternPermissionScope::Rules(WirePatternPermissionRuleSet {
+ default: rules.default.map(serialize_permission_mode),
+ rules: rules
+ .rules
+ .iter()
+ .map(|rule| WirePatternPermissionRule {
+ mode: serialize_permission_mode(rule.mode),
+ operations: operation_wildcard_if_omitted(&rule.operations),
+ patterns: resource_wildcard_if_omitted(&rule.patterns),
+ })
+ .collect(),
+ })
+ }
+ }
+}
+
+fn serialize_permission_mode(mode: crate::config::PermissionMode) -> WirePermissionMode {
+ match mode {
+ crate::config::PermissionMode::Allow => WirePermissionMode::Allow,
+ crate::config::PermissionMode::Deny => WirePermissionMode::Deny,
+ }
+}
+
+fn operation_wildcard_if_omitted(values: &Option>) -> Vec {
+ values.clone().unwrap_or_else(|| vec!["*".to_string()])
+}
+
+fn resource_wildcard_if_omitted(values: &Option>) -> Vec {
+ values.clone().unwrap_or_else(|| vec!["**".to_string()])
+}
+
/// Extract the `vm_id` from an ownership scope, if it is VM-scoped.
fn ownership_vm_id(ownership: &OwnershipScope) -> Option<&str> {
match ownership {
@@ -672,3 +942,87 @@ fn rejected_to_error(rejected: agent_os_sidecar::protocol::RejectedResponse) ->
message: rejected.message,
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::{PatternPermissionScope, WirePermissionMode, permissions_policy};
+ use crate::config::{
+ AgentOsConfig, FsPermissionRule, FsPermissions, PatternPermissions, PermissionMode,
+ Permissions, RulePermissions,
+ };
+
+ #[test]
+ fn permissions_policy_defaults_to_allow_all_when_unset() {
+ assert_eq!(
+ permissions_policy(&AgentOsConfig::default()),
+ agent_os_sidecar::protocol::PermissionsPolicy::allow_all()
+ );
+ }
+
+ #[test]
+ fn permissions_policy_preserves_configured_denies_and_allows_omitted_domains() {
+ let policy = permissions_policy(&AgentOsConfig {
+ permissions: Some(Permissions {
+ network: Some(PatternPermissions::Mode(PermissionMode::Deny)),
+ ..Default::default()
+ }),
+ ..Default::default()
+ });
+
+ assert_eq!(
+ policy.network,
+ Some(PatternPermissionScope::Mode(WirePermissionMode::Deny))
+ );
+ assert_eq!(
+ policy.child_process,
+ Some(PatternPermissionScope::Mode(WirePermissionMode::Allow))
+ );
+ }
+
+ #[test]
+ fn permissions_policy_expands_omitted_rule_fields_to_domain_wildcards() {
+ let policy = permissions_policy(&AgentOsConfig {
+ permissions: Some(Permissions {
+ fs: Some(FsPermissions::Rules(RulePermissions {
+ default: Some(PermissionMode::Deny),
+ rules: vec![FsPermissionRule {
+ mode: PermissionMode::Allow,
+ operations: None,
+ paths: Some(vec!["/workspace/**".to_string()]),
+ }],
+ })),
+ ..Default::default()
+ }),
+ ..Default::default()
+ });
+
+ let Some(agent_os_sidecar::protocol::FsPermissionScope::Rules(rules)) = policy.fs else {
+ panic!("expected fs rule set");
+ };
+ assert_eq!(rules.default, Some(WirePermissionMode::Deny));
+ assert_eq!(rules.rules[0].operations, vec!["*"]);
+ assert_eq!(rules.rules[0].paths, vec!["/workspace/**"]);
+
+ let policy = permissions_policy(&AgentOsConfig {
+ permissions: Some(Permissions {
+ network: Some(PatternPermissions::Rules(RulePermissions {
+ default: Some(PermissionMode::Allow),
+ rules: vec![crate::config::PatternPermissionRule {
+ mode: PermissionMode::Deny,
+ operations: None,
+ patterns: None,
+ }],
+ })),
+ ..Default::default()
+ }),
+ ..Default::default()
+ });
+
+ let Some(PatternPermissionScope::Rules(rules)) = policy.network else {
+ panic!("expected network rule set");
+ };
+ assert_eq!(rules.default, Some(WirePermissionMode::Allow));
+ assert_eq!(rules.rules[0].operations, vec!["*"]);
+ assert_eq!(rules.rules[0].patterns, vec!["**"]);
+ }
+}
diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs
index 5fb6fe4e6..1006330b3 100644
--- a/crates/client/src/config.rs
+++ b/crates/client/src/config.rs
@@ -8,7 +8,6 @@
//! only and become `Arc` trait objects; they cannot cross the wire and are gated exactly as
//! the actor layer gates them.
-use std::collections::BTreeMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
@@ -153,7 +152,9 @@ pub struct SoftwareInput {
/// error string. Stays host-side (never crosses to the guest); the guest invokes it by name via the
/// sidecar tool-invocation callback channel.
pub type ToolCallback = Arc<
- dyn Fn(serde_json::Value) -> futures::future::BoxFuture<'static, Result>
+ dyn Fn(
+ serde_json::Value,
+ ) -> futures::future::BoxFuture<'static, Result>
+ Send
+ Sync,
>;
@@ -191,7 +192,11 @@ pub struct Permissions {
pub fs: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network: Option,
- #[serde(default, rename = "childProcess", skip_serializing_if = "Option::is_none")]
+ #[serde(
+ default,
+ rename = "childProcess",
+ skip_serializing_if = "Option::is_none"
+ )]
pub child_process: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub process: Option,
@@ -375,8 +380,7 @@ pub enum AgentOsSidecarConfig {
/// Mirrors the TS `ScheduleEntry.callback: () => void | Promise`. The cron manager passes a
/// closure that runs one job execution; the driver awaits it (and, for the default driver, reschedules
/// the next cron fire afterwards).
-pub type ScheduleCallback =
- Arc futures::future::BoxFuture<'static, ()> + Send + Sync>;
+pub type ScheduleCallback = Arc futures::future::BoxFuture<'static, ()> + Send + Sync>;
/// A schedule entry handed to a [`ScheduleDriver`]. Mirrors TS `ScheduleEntry`
/// (`cron/schedule-driver.ts`).
@@ -458,9 +462,7 @@ impl TimerScheduleDriver {
}
};
- let delay = (next - now)
- .to_std()
- .unwrap_or(std::time::Duration::ZERO);
+ let delay = (next - now).to_std().unwrap_or(std::time::Duration::ZERO);
tokio::spawn(async move {
tokio::select! {
@@ -510,8 +512,3 @@ impl ScheduleDriver for TimerScheduleDriver {
self.timers.clear();
}
}
-
-/// Metadata helpers reused when building sidecar requests.
-pub(crate) fn empty_metadata() -> BTreeMap {
- BTreeMap::new()
-}
diff --git a/crates/client/src/cron.rs b/crates/client/src/cron.rs
index df8cf89b0..150cf497f 100644
--- a/crates/client/src/cron.rs
+++ b/crates/client/src/cron.rs
@@ -12,8 +12,8 @@
//!
//! Cron fields are interpreted in the host LOCAL timezone, matching croner's default behavior.
-use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
use chrono::{DateTime, Datelike, Duration as ChronoDuration, Local, Timelike, Utc, Weekday};
use scc::HashMap as SccHashMap;
@@ -159,6 +159,7 @@ pub(crate) struct CronJobState {
/// Owns scheduled jobs, the schedule driver, and the cron event broadcast.
pub struct CronManager {
pub(crate) jobs: SccHashMap,
+ pub(crate) schedule_lock: parking_lot::Mutex<()>,
pub(crate) driver: Arc,
pub(crate) event_tx: broadcast::Sender,
}
@@ -169,6 +170,7 @@ impl CronManager {
let (event_tx, _rx) = broadcast::channel(256);
Self {
jobs: SccHashMap::new(),
+ schedule_lock: parking_lot::Mutex::new(()),
driver,
event_tx,
}
@@ -179,6 +181,7 @@ impl CronManager {
/// Mirrors TS `CronManager.cancel`: cancel the driver-armed timer (`this.driver.cancel(handle)`)
/// and remove the job from the registry.
pub(crate) fn cancel_job(&self, id: &str) {
+ let _guard = self.schedule_lock.lock();
if let Some((_, state)) = self.jobs.remove(id) {
self.driver.cancel(&state.handle);
}
@@ -189,6 +192,7 @@ impl CronManager {
/// Mirrors TS `CronManager.dispose`: cancel every armed timer through the driver, clear the
/// registry, then call `this.driver.dispose()` to tear down all driver-held timer state.
pub(crate) fn dispose(&self) {
+ let _guard = self.schedule_lock.lock();
self.jobs.scan(|_, state| {
self.driver.cancel(&state.handle);
});
@@ -505,7 +509,11 @@ fn parse_one_shot(schedule: &str) -> Option> {
let normalized = schedule.replacen(' ', "T", 1);
// Date + time without a timezone: ECMAScript treats this as LOCAL time.
- for fmt in ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"] {
+ for fmt in [
+ "%Y-%m-%dT%H:%M:%S%.f",
+ "%Y-%m-%dT%H:%M:%S",
+ "%Y-%m-%dT%H:%M",
+ ] {
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(&normalized, fmt) {
return match Local.from_local_datetime(&naive) {
chrono::LocalResult::Single(dt) => Some(dt.with_timezone(&Utc)),
@@ -626,7 +634,9 @@ impl CronExpr {
&str,
Option<&str>,
) = match fields.len() {
- 5 => ("0", fields[0], fields[1], fields[2], fields[3], fields[4], None),
+ 5 => (
+ "0", fields[0], fields[1], fields[2], fields[3], fields[4], None,
+ ),
6 => (
fields[0], fields[1], fields[2], fields[3], fields[4], fields[5], None,
),
@@ -1089,7 +1099,9 @@ impl AgentOs {
// Validate before any state mutation, matching TS `validateScheduleForRegistration`.
let next_run = validate_schedule(&options.schedule, now)?;
- let id = options.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
+ let id = options
+ .id
+ .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let overlap = options.overlap.unwrap_or_default();
// Build the driver callback that runs one job execution, mirroring TS
@@ -1106,36 +1118,15 @@ impl AgentOs {
})
});
- // Ask the driver to arm the timer.
- let handle = cron.driver.schedule(ScheduleEntry {
- id: id.clone(),
- schedule: options.schedule.clone(),
- callback,
- });
-
- let state = CronJobState {
- schedule: options.schedule.clone(),
- action: options.action,
- overlap,
- last_run: parking_lot::Mutex::new(None),
- next_run: parking_lot::Mutex::new(next_run),
- run_count: std::sync::atomic::AtomicU64::new(0),
- running: AtomicBool::new(false),
- queued: AtomicBool::new(false),
- handle,
- };
-
- // Insert; if the id already exists, cancel its driver-armed timer first, mirroring the TS
- // `Map.set` overwrite over a freshly-armed handle.
- if let Some((_, old)) = cron.jobs.remove(&id) {
- cron.driver.cancel(&old.handle);
- }
- let _ = cron.jobs.insert(id.clone(), state);
-
- Ok(CronJobHandle {
+ register_cron_job(
+ cron,
id,
- manager: Arc::clone(cron),
- })
+ options.schedule,
+ options.action,
+ overlap,
+ next_run,
+ callback,
+ )
}
/// Snapshot all cron jobs. Mirrors TS `CronManager.list`.
@@ -1167,3 +1158,159 @@ impl AgentOs {
self.cron().event_tx.subscribe()
}
}
+
+fn ensure_cron_capacity(cron: &CronManager, id: &str) -> std::result::Result<(), ClientError> {
+ if cron.jobs.contains(id) || cron.jobs.len() < crate::CRON_JOB_LIMIT {
+ return Ok(());
+ }
+
+ Err(ClientError::Sidecar(format!(
+ "cron job limit exceeded: at most {} jobs can be scheduled per VM",
+ crate::CRON_JOB_LIMIT
+ )))
+}
+
+fn register_cron_job(
+ cron: &Arc,
+ id: String,
+ schedule: String,
+ action: CronAction,
+ overlap: CronOverlap,
+ next_run: Option>,
+ callback: crate::config::ScheduleCallback,
+) -> std::result::Result {
+ let _guard = cron.schedule_lock.lock();
+ ensure_cron_capacity(cron, &id)?;
+
+ // If replacing an existing id, cancel the old driver-armed timer before scheduling the new one.
+ // The default timer driver's handles are id-based, so cancelling after the new schedule would
+ // cancel the replacement.
+ if let Some((_, old)) = cron.jobs.remove(&id) {
+ cron.driver.cancel(&old.handle);
+ }
+
+ let handle = cron.driver.schedule(ScheduleEntry {
+ id: id.clone(),
+ schedule: schedule.clone(),
+ callback,
+ });
+
+ let state = CronJobState {
+ schedule,
+ action,
+ overlap,
+ last_run: parking_lot::Mutex::new(None),
+ next_run: parking_lot::Mutex::new(next_run),
+ run_count: std::sync::atomic::AtomicU64::new(0),
+ running: AtomicBool::new(false),
+ queued: AtomicBool::new(false),
+ handle,
+ };
+
+ let _ = cron.jobs.insert(id.clone(), state);
+
+ Ok(CronJobHandle {
+ id,
+ manager: Arc::clone(cron),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ CronAction, CronJobState, CronManager, CronOverlap, ScheduleDriver, ScheduleEntry,
+ ScheduleHandle, ensure_cron_capacity, register_cron_job,
+ };
+ use crate::CRON_JOB_LIMIT;
+ use std::sync::Arc;
+ use std::sync::atomic::AtomicBool;
+
+ #[derive(Default)]
+ struct RecordingScheduleDriver {
+ calls: parking_lot::Mutex>,
+ }
+
+ impl ScheduleDriver for RecordingScheduleDriver {
+ fn schedule(&self, entry: ScheduleEntry) -> ScheduleHandle {
+ self.calls.lock().push(format!("schedule:{}", entry.id));
+ ScheduleHandle { id: entry.id }
+ }
+
+ fn cancel(&self, handle: &ScheduleHandle) {
+ self.calls.lock().push(format!("cancel:{}", handle.id));
+ }
+
+ fn dispose(&self) {}
+ }
+
+ fn dummy_state(id: String) -> CronJobState {
+ CronJobState {
+ schedule: "0 0 * * *".to_string(),
+ action: CronAction::Callback {
+ callback: Arc::new(|| Box::pin(async {})),
+ },
+ overlap: CronOverlap::Allow,
+ last_run: parking_lot::Mutex::new(None),
+ next_run: parking_lot::Mutex::new(None),
+ run_count: std::sync::atomic::AtomicU64::new(0),
+ running: AtomicBool::new(false),
+ queued: AtomicBool::new(false),
+ handle: ScheduleHandle { id },
+ }
+ }
+
+ #[test]
+ fn cron_capacity_rejects_new_jobs_at_limit_but_allows_replacements() {
+ let manager = CronManager::new(Arc::new(RecordingScheduleDriver::default()));
+ for index in 0..CRON_JOB_LIMIT {
+ let id = format!("job-{index}");
+ assert!(
+ manager.jobs.insert(id.clone(), dummy_state(id)).is_ok(),
+ "seed cron job"
+ );
+ }
+
+ let error = ensure_cron_capacity(&manager, "overflow").expect_err("limit should reject");
+ assert!(
+ error.to_string().contains("cron job limit exceeded"),
+ "unexpected limit error: {error}"
+ );
+ ensure_cron_capacity(&manager, "job-0").expect("replacement should be allowed");
+ }
+
+ #[test]
+ fn cron_replacement_cancels_old_timer_before_scheduling_new_timer() {
+ let driver = Arc::new(RecordingScheduleDriver::default());
+ let manager = Arc::new(CronManager::new(driver.clone()));
+ let callback: crate::config::ScheduleCallback = Arc::new(|| Box::pin(async {}));
+
+ register_cron_job(
+ &manager,
+ "same-id".to_string(),
+ "0 0 * * *".to_string(),
+ CronAction::Callback {
+ callback: callback.clone(),
+ },
+ CronOverlap::Allow,
+ None,
+ callback.clone(),
+ )
+ .expect("initial schedule");
+ register_cron_job(
+ &manager,
+ "same-id".to_string(),
+ "0 1 * * *".to_string(),
+ CronAction::Callback { callback },
+ CronOverlap::Allow,
+ None,
+ Arc::new(|| Box::pin(async {})),
+ )
+ .expect("replacement schedule");
+
+ assert_eq!(
+ *driver.calls.lock(),
+ vec!["schedule:same-id", "cancel:same-id", "schedule:same-id"]
+ );
+ assert_eq!(manager.jobs.len(), 1);
+ }
+}
diff --git a/crates/client/src/fs.rs b/crates/client/src/fs.rs
index 18e7120da..7536cc54e 100644
--- a/crates/client/src/fs.rs
+++ b/crates/client/src/fs.rs
@@ -337,10 +337,16 @@ impl AgentOs {
Ok(())
}
- /// Runs the safe guard, then rejects writes to read-only paths (`/proc`, `/proc/*`).
- pub(crate) fn assert_writable_absolute_path(path: &str) -> std::result::Result<(), ClientError> {
+ /// Runs the safe guard, then rejects writes to read-only paths.
+ pub(crate) fn assert_writable_absolute_path(
+ path: &str,
+ ) -> std::result::Result<(), ClientError> {
Self::assert_safe_absolute_path(path)?;
- if path == "/proc" || path.starts_with("/proc/") {
+ if path == "/proc"
+ || path.starts_with("/proc/")
+ || path == "/etc/agentos"
+ || path.starts_with("/etc/agentos/")
+ {
return Err(ClientError::PathReadOnly(path.to_string()));
}
Ok(())
@@ -434,6 +440,7 @@ impl AgentOs {
atime_ms: None,
mtime_ms: None,
len: None,
+ offset: None,
}
}
@@ -468,9 +475,9 @@ impl AgentOs {
let result = self
.guest_fs_call(Self::fs_request(GuestFilesystemOperation::ReadFile, path))
.await?;
- let content = result.content.with_context(|| {
- format!("sidecar returned no file content for {path}")
- })?;
+ let content = result
+ .content
+ .with_context(|| format!("sidecar returned no file content for {path}"))?;
match result.encoding {
Some(RootFilesystemEntryEncoding::Base64) => BASE64
.decode(content.as_bytes())
@@ -485,9 +492,10 @@ impl AgentOs {
async fn kernel_write_file(&self, path: &str, content: &FileContent) -> Result<()> {
let (encoded, encoding) = match content {
FileContent::Text(text) => (text.clone(), None),
- FileContent::Bytes(bytes) => {
- (BASE64.encode(bytes), Some(RootFilesystemEntryEncoding::Base64))
- }
+ FileContent::Bytes(bytes) => (
+ BASE64.encode(bytes),
+ Some(RootFilesystemEntryEncoding::Base64),
+ ),
};
let mut request = Self::fs_request(GuestFilesystemOperation::WriteFile, path);
request.content = Some(encoded);
@@ -525,9 +533,7 @@ impl AgentOs {
let result = self
.guest_fs_call(Self::fs_request(GuestFilesystemOperation::Stat, path))
.await?;
- let stat = result
- .stat
- .context("stat response missing stat payload")?;
+ let stat = result.stat.context("stat response missing stat payload")?;
Ok(Self::virtual_stat_from(stat))
}
@@ -535,9 +541,7 @@ impl AgentOs {
let result = self
.guest_fs_call(Self::fs_request(GuestFilesystemOperation::Lstat, path))
.await?;
- let stat = result
- .stat
- .context("lstat response missing stat payload")?;
+ let stat = result.stat.context("lstat response missing stat payload")?;
Ok(Self::virtual_stat_from(stat))
}
@@ -612,6 +616,7 @@ impl AgentOs {
to: &'a str,
) -> futures::future::BoxFuture<'a, Result<()>> {
Box::pin(async move {
+ Self::assert_writable_absolute_path(to)?;
let stat = self.kernel_lstat(from).await?;
if stat.is_symbolic_link {
let target = self.kernel_readlink(from).await?;
@@ -651,7 +656,7 @@ impl AgentOs {
recursive: bool,
) -> futures::future::BoxFuture<'a, Result<()>> {
Box::pin(async move {
- let stat = self.kernel_stat(path).await?;
+ let stat = self.kernel_lstat(path).await?;
if stat.is_directory {
if recursive {
let entries = self.kernel_readdir(path).await?;
@@ -756,7 +761,7 @@ impl AgentOs {
if options.recursive {
return self.mkdirp(path).await;
}
- Self::assert_safe_absolute_path(path)?;
+ Self::assert_writable_absolute_path(path)?;
self.kernel_mkdir(path).await
}
@@ -793,7 +798,7 @@ impl AgentOs {
continue;
}
let full_path = Self::join_child(&dir_path, &name);
- let s = self.kernel_stat(&full_path).await?;
+ let s = self.kernel_lstat(&full_path).await?;
if s.is_symbolic_link {
results.push(DirEntry {
path: full_path,
@@ -903,8 +908,8 @@ impl AgentOs {
/// Move a path. `lstat(from)` no-follow; symlink/non-dir -> rename; real dir -> recursive copy
/// (preserve mode/uid/gid/symlinks) + recursive delete. (TS `move`.)
pub async fn move_path(&self, from: &str, to: &str) -> Result<()> {
- Self::assert_safe_absolute_path(from)?;
- Self::assert_safe_absolute_path(to)?;
+ Self::assert_writable_absolute_path(from)?;
+ Self::assert_writable_absolute_path(to)?;
let source_stat = self.kernel_lstat(from).await?;
if !source_stat.is_directory || source_stat.is_symbolic_link {
return self.kernel_rename(from, to).await;
@@ -913,10 +918,10 @@ impl AgentOs {
self.delete(from, DeleteOptions { recursive: true }).await
}
- /// Delete a path. `stat` to discriminate; recursive manually recurses children then `remove_dir`;
+ /// Delete a path. `lstat` to discriminate; recursive manually recurses children then `remove_dir`;
/// non-recursive dir -> `remove_dir` (ENOTEMPTY if non-empty).
pub async fn delete(&self, path: &str, options: DeleteOptions) -> Result<()> {
- Self::assert_safe_absolute_path(path)?;
+ Self::assert_writable_absolute_path(path)?;
self.delete_inner(path, options.recursive).await
}
diff --git a/crates/client/src/json_rpc.rs b/crates/client/src/json_rpc.rs
index 3962ea4f8..a2a39fece 100644
--- a/crates/client/src/json_rpc.rs
+++ b/crates/client/src/json_rpc.rs
@@ -49,7 +49,11 @@ pub struct AcpTimeoutErrorData {
pub exit_code: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub killed: Option,
- #[serde(default, rename = "transportState", skip_serializing_if = "Option::is_none")]
+ #[serde(
+ default,
+ rename = "transportState",
+ skip_serializing_if = "Option::is_none"
+ )]
pub transport_state: Option,
#[serde(rename = "recentActivity")]
pub recent_activity: Vec,
diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs
index 282972ff5..52b79bbd0 100644
--- a/crates/client/src/lib.rs
+++ b/crates/client/src/lib.rs
@@ -49,6 +49,9 @@ pub const SHELL_DISPOSE_TIMEOUT_MS: u64 = 5_000;
/// VM lifecycle ready timeout during `create` (milliseconds).
pub const VM_READY_TIMEOUT_MS: u64 = 10_000;
+/// Maximum scheduled cron jobs per VM.
+pub const CRON_JOB_LIMIT: usize = 1024;
+
// ---------------------------------------------------------------------------
// Public re-exports
// ---------------------------------------------------------------------------
@@ -85,9 +88,9 @@ pub use shell::{ConnectTerminalOptions, OpenShellOptions, ShellHandle};
pub use session::{
AgentCapabilities, AgentInfo, AgentRegistryEntry, ConfigAllowedValue, CreateSessionOptions,
- GetEventsOptions, McpServerConfig, PermissionDelivery, PermissionReply, PermissionRequest,
- PromptCapabilities, PromptResult, SessionConfigOption, SessionId, SessionInfo, SessionInitData,
- SessionMode, SessionModeState,
+ GetEventsOptions, McpServerConfig, PermissionReply, PermissionRequest, PromptCapabilities,
+ PromptResult, SessionConfigOption, SessionId, SessionInfo, SessionInitData, SessionMode,
+ SessionModeState,
};
pub use json_rpc::{
diff --git a/crates/client/src/net.rs b/crates/client/src/net.rs
index e96266370..188d06f33 100644
--- a/crates/client/src/net.rs
+++ b/crates/client/src/net.rs
@@ -6,10 +6,11 @@
//! Fully buffered both directions. Wire path is the existing `VmFetch` request/response.
use std::collections::BTreeMap;
+use std::sync::atomic::Ordering;
use anyhow::{Context, Result};
-use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
+use base64::engine::general_purpose::STANDARD as BASE64;
use bytes::Bytes;
use serde::Deserialize;
@@ -20,6 +21,11 @@ use agent_os_sidecar::protocol::{
use crate::agent_os::AgentOs;
use crate::error::ClientError;
+/// Maximum fully buffered fetch component size. `VmFetch` is a single request/response frame, so
+/// keeping this at the default frame size prevents fetch-specific buffers from growing just because
+/// a sidecar was configured with a larger transport frame limit for another API.
+const VM_FETCH_BUFFER_LIMIT_BYTES: usize = agent_os_sidecar::protocol::DEFAULT_MAX_FRAME_BYTES;
+
/// The shape of the JSON string returned in [`VmFetchResponse::response_json`], mirroring the TS
/// `{ status, statusText?, headers?: [k,v][], body?: base64 }` payload.
#[derive(Debug, Deserialize)]
@@ -44,12 +50,20 @@ impl AgentOs {
port: u16,
request: http::Request,
) -> Result> {
+ let buffer_limit = self.fetch_buffer_limit();
let (parts, body) = request.into_parts();
// Only `pathname`+`search` are carried on the wire; the host/authority is discarded, matching
// the TS `${url.pathname}${url.search}`. A missing path defaults to "/".
let path = match parts.uri.path_and_query() {
- Some(pq) => pq.as_str().to_owned(),
+ Some(pq) => {
+ ensure_fetch_component_within_limit(
+ "fetch request path",
+ pq.as_str().len(),
+ buffer_limit,
+ )?;
+ pq.as_str().to_owned()
+ }
None => "/".to_owned(),
};
@@ -58,25 +72,53 @@ impl AgentOs {
// Headers serialized as a JSON object (TS `Object.fromEntries(headers.entries())`). A repeated
// header name keeps the last value, matching JS object semantics where later keys overwrite.
let mut header_map: BTreeMap = BTreeMap::new();
+ let mut raw_header_bytes = 0usize;
for (name, value) in parts.headers.iter() {
+ raw_header_bytes = raw_header_bytes
+ .saturating_add(name.as_str().len())
+ .saturating_add(value.as_bytes().len());
header_map.insert(
name.as_str().to_owned(),
String::from_utf8_lossy(value.as_bytes()).into_owned(),
);
}
+ ensure_fetch_component_within_limit(
+ "fetch request headers",
+ raw_header_bytes,
+ buffer_limit,
+ )?;
let headers_json =
serde_json::to_string(&header_map).context("serializing fetch request headers")?;
+ ensure_fetch_component_within_limit(
+ "fetch request headers json",
+ headers_json.len(),
+ buffer_limit,
+ )?;
// Body is only attached for methods other than GET/HEAD (TS `request.method !== "GET" && ...`).
let wire_body = if method == "GET" || method == "HEAD" {
None
} else {
- Some(String::from_utf8_lossy(&body).into_owned())
+ ensure_fetch_component_within_limit("fetch request body", body.len(), buffer_limit)?;
+ let body = String::from_utf8_lossy(&body).into_owned();
+ ensure_fetch_component_within_limit(
+ "fetch request body text",
+ body.len(),
+ buffer_limit,
+ )?;
+ Some(body)
};
+ ensure_fetch_request_payload_within_limit(
+ &method,
+ &path,
+ &headers_json,
+ wire_body.as_deref(),
+ buffer_limit,
+ )?;
let response = self
.transport()
- .request(
+ .request_bounded(
self.vm_fetch_ownership(),
RequestPayload::VmFetch(VmFetchRequest {
port,
@@ -85,6 +127,7 @@ impl AgentOs {
headers_json,
body: wire_body,
}),
+ buffer_limit,
)
.await?;
@@ -94,12 +137,16 @@ impl AgentOs {
return Err(ClientError::Kernel { code, message }.into());
}
other => {
- return Err(ClientError::Sidecar(format!(
- "fetch: unexpected response {other:?}"
- ))
- .into());
+ return Err(
+ ClientError::Sidecar(format!("fetch: unexpected response {other:?}")).into(),
+ );
}
};
+ ensure_fetch_component_within_limit(
+ "fetch response json",
+ response_json.len(),
+ buffer_limit,
+ )?;
let payload: VmFetchResponsePayload =
serde_json::from_str(&response_json).context("parsing vm_fetch response json")?;
@@ -107,11 +154,14 @@ impl AgentOs {
// Base64-decode the response body (TS `Buffer.from(body ?? "", "base64")`). An absent body is
// an empty body.
let decoded_body = match payload.body {
- Some(encoded) => Bytes::from(
- BASE64
- .decode(encoded.as_bytes())
- .context("decoding base64 fetch response body")?,
- ),
+ Some(encoded) => {
+ ensure_fetch_base64_body_within_limit(&encoded, buffer_limit)?;
+ Bytes::from(
+ BASE64
+ .decode(encoded.as_bytes())
+ .context("decoding base64 fetch response body")?,
+ )
+ }
None => Bytes::new(),
};
@@ -130,7 +180,9 @@ impl AgentOs {
// `statusText` has no slot in `http::Response`; carry it on the extensions so a caller can
// recover it, matching the TS `Response.statusText`.
if let Some(status_text) = payload.status_text {
- http_response.extensions_mut().insert(FetchStatusText(status_text));
+ http_response
+ .extensions_mut()
+ .insert(FetchStatusText(status_text));
}
Ok(http_response)
@@ -140,9 +192,129 @@ impl AgentOs {
fn vm_fetch_ownership(&self) -> OwnershipScope {
OwnershipScope::vm(self.connection_id(), self.wire_session_id(), self.vm_id())
}
+
+ fn fetch_buffer_limit(&self) -> usize {
+ self.transport()
+ .max_frame_bytes
+ .load(Ordering::Relaxed)
+ .min(VM_FETCH_BUFFER_LIMIT_BYTES)
+ }
}
/// The wire `statusText`, stashed in [`http::Response`] extensions so callers can recover the TS
/// `Response.statusText` value (the `http` crate has no dedicated status-text field).
#[derive(Debug, Clone)]
pub struct FetchStatusText(pub String);
+
+fn ensure_fetch_component_within_limit(
+ component: &str,
+ size: usize,
+ limit: usize,
+) -> Result<(), ClientError> {
+ if size > limit {
+ return Err(ClientError::Sidecar(format!(
+ "{component} is {size} bytes, limit is {limit}"
+ )));
+ }
+ Ok(())
+}
+
+fn ensure_fetch_base64_body_within_limit(encoded: &str, limit: usize) -> Result<(), ClientError> {
+ ensure_fetch_component_within_limit("fetch response body base64", encoded.len(), limit)?;
+ ensure_fetch_component_within_limit(
+ "fetch response body",
+ base64_decoded_upper_bound(encoded.len()),
+ limit,
+ )
+}
+
+fn ensure_fetch_request_payload_within_limit(
+ method: &str,
+ path: &str,
+ headers_json: &str,
+ body: Option<&str>,
+ limit: usize,
+) -> Result<(), ClientError> {
+ let size = method
+ .len()
+ .saturating_add(path.len())
+ .saturating_add(headers_json.len())
+ .saturating_add(body.map(str::len).unwrap_or_default());
+ ensure_fetch_component_within_limit("fetch request payload", size, limit)
+}
+
+fn base64_decoded_upper_bound(encoded_len: usize) -> usize {
+ encoded_len.saturating_add(3) / 4 * 3
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ VM_FETCH_BUFFER_LIMIT_BYTES, base64_decoded_upper_bound,
+ ensure_fetch_base64_body_within_limit, ensure_fetch_component_within_limit,
+ ensure_fetch_request_payload_within_limit,
+ };
+
+ #[test]
+ fn fetch_component_limit_rejects_oversized_buffers() {
+ assert!(ensure_fetch_component_within_limit("component", 8, 8).is_ok());
+
+ let error =
+ ensure_fetch_component_within_limit("component", 9, 8).expect_err("limit violation");
+ assert!(
+ error.to_string().contains("component is 9 bytes"),
+ "unexpected error: {error}"
+ );
+ }
+
+ #[test]
+ fn fetch_component_limit_rejects_expanded_request_text() {
+ let replacement = String::from_utf8_lossy(&[0xff]).into_owned();
+ assert_eq!(replacement.len(), 3);
+
+ let error = ensure_fetch_component_within_limit("fetch request body text", 3, 2)
+ .expect_err("expanded body text should exceed limit");
+ assert!(
+ error
+ .to_string()
+ .contains("fetch request body text is 3 bytes"),
+ "unexpected error: {error}"
+ );
+ }
+
+ #[test]
+ fn fetch_request_payload_limit_rejects_aggregate_oversize() {
+ let error =
+ ensure_fetch_request_payload_within_limit("POST", "/abc", "{}", Some("body"), 8)
+ .expect_err("aggregate request payload should exceed limit");
+ assert!(
+ error
+ .to_string()
+ .contains("fetch request payload is 14 bytes"),
+ "unexpected error: {error}"
+ );
+ }
+
+ #[test]
+ fn fetch_base64_guard_bounds_decoded_response_size() {
+ assert_eq!(base64_decoded_upper_bound(4), 3);
+ assert!(ensure_fetch_base64_body_within_limit("AAAA", 4).is_ok());
+
+ let error = ensure_fetch_base64_body_within_limit("AAAA", 2)
+ .expect_err("encoded body should exceed limit");
+ assert!(
+ error
+ .to_string()
+ .contains("fetch response body base64 is 4 bytes"),
+ "unexpected error: {error}"
+ );
+ }
+
+ #[test]
+ fn fetch_buffer_limit_is_fixed_to_default_frame_size() {
+ assert_eq!(
+ VM_FETCH_BUFFER_LIMIT_BYTES,
+ agent_os_sidecar::protocol::DEFAULT_MAX_FRAME_BYTES
+ );
+ }
+}
diff --git a/crates/client/src/process.rs b/crates/client/src/process.rs
index b3f8e7853..f17a43d1b 100644
--- a/crates/client/src/process.rs
+++ b/crates/client/src/process.rs
@@ -11,6 +11,7 @@ use std::collections::BTreeMap;
use std::sync::atomic::Ordering;
use anyhow::{Context, Result};
+use scc::HashMap as SccHashMap;
use serde::{Deserialize, Serialize};
use tokio::sync::{broadcast, watch};
@@ -28,6 +29,15 @@ use crate::stream::{ByteStream, Subscription};
/// Broadcast channel capacity for a spawned process's stdout/stderr fan-out.
const PROCESS_STREAM_CAPACITY: usize = 1024;
+/// Maximum SDK-spawned process entries retained per VM.
+const PROCESS_REGISTRY_LIMIT: usize = 1024;
+
+/// Maximum first-observed process timestamp entries retained per VM.
+const OBSERVED_PROCESS_TIME_LIMIT: usize = 4096;
+
+/// Maximum bytes captured by `exec` across stdout and stderr.
+const EXEC_OUTPUT_CAPTURE_LIMIT_BYTES: usize = 16 * 1024 * 1024;
+
/// Base value for the synthetic display-pid sequence used by `spawn` (TS `SYNTHETIC_PID_BASE`). The
/// first spawned process is assigned exactly this value.
pub(crate) const SYNTHETIC_PID_BASE: u64 = 1_000_000;
@@ -226,11 +236,16 @@ impl AgentOs {
let timeout_deadline = options
.timeout
.filter(|ms| ms.is_finite() && *ms >= 0.0)
- .map(|ms| tokio::time::Instant::now() + std::time::Duration::from_secs_f64(ms / 1000.0));
+ .map(|ms| {
+ tokio::time::Instant::now() + std::time::Duration::from_secs_f64(ms / 1000.0)
+ });
let mut killed_for_timeout = false;
+ let capture_stdio = options.capture_stdio.unwrap_or(true);
let mut stdout = Vec::::new();
let mut stderr = Vec::::new();
+ let mut captured_output_bytes = 0usize;
+ let mut capture_error: Option = None;
let exit_code = loop {
let recv = events.recv();
let frame = match timeout_deadline {
@@ -263,13 +278,39 @@ impl AgentOs {
if let Some(cb) = on_stdout.as_mut() {
cb(&output.chunk);
}
- stdout.extend_from_slice(&output.chunk);
+ if capture_stdio && capture_error.is_none() {
+ match append_exec_output(
+ &mut stdout,
+ &output.chunk,
+ &mut captured_output_bytes,
+ "stdout",
+ ) {
+ Ok(()) => {}
+ Err(error) => {
+ self.kill_wire_process(&process_id, "SIGKILL");
+ capture_error = Some(error);
+ }
+ }
+ }
}
StreamChannel::Stderr => {
if let Some(cb) = on_stderr.as_mut() {
cb(&output.chunk);
}
- stderr.extend_from_slice(&output.chunk);
+ if capture_stdio && capture_error.is_none() {
+ match append_exec_output(
+ &mut stderr,
+ &output.chunk,
+ &mut captured_output_bytes,
+ "stderr",
+ ) {
+ Ok(()) => {}
+ Err(error) => {
+ self.kill_wire_process(&process_id, "SIGKILL");
+ capture_error = Some(error);
+ }
+ }
+ }
}
}
}
@@ -283,6 +324,10 @@ impl AgentOs {
}
};
+ if let Some(error) = capture_error {
+ return Err(error.into());
+ }
+
Ok(ExecResult {
exit_code,
stdout: String::from_utf8_lossy(&stdout).into_owned(),
@@ -299,6 +344,15 @@ impl AgentOs {
args: Vec,
mut options: SpawnOptions,
) -> Result {
+ let registry_guard = self.inner().process_registry_lock.lock();
+ self.prune_exited_processes_locked(1);
+ if self.process_registry_len_locked() >= PROCESS_REGISTRY_LIMIT {
+ return Err(ClientError::Sidecar(format!(
+ "process registry limit exceeded: at most {PROCESS_REGISTRY_LIMIT} processes can be tracked per VM"
+ ))
+ .into());
+ }
+
// Draw the public pid from the dedicated synthetic-pid space (TS `nextSyntheticPid`), seeded
// at `SYNTHETIC_PID_BASE`. `exec` uses a separate counter so it never perturbs this sequence.
let pid = self
@@ -337,6 +391,7 @@ impl AgentOs {
// `spawn` is documented as overwriting any prior entry for a freshly allocated pid; the pid
// is monotonic so a collision is not expected.
let _ = self.inner().processes.insert(pid, entry);
+ drop(registry_guard);
// Subscribe to events before issuing the request so the pump sees everything.
let events = self.transport().subscribe_events();
@@ -524,11 +579,10 @@ impl AgentOs {
}
};
- // Snapshot the SDK process registry, keyed by wire `process_id`, capturing the resolved
- // kernel pid (if landed), the display pid, exit code, command, and args. This mirrors the TS
- // `trackedProcessesById` lookup used to build `displayPidByKernelPid` and override fields.
+ // Snapshot the SDK process registry, keyed by wire `process_id`, capturing exit code,
+ // command, and args. This mirrors the TS `trackedProcessesById` lookup used to build
+ // `displayPidByKernelPid` and override fields.
struct Tracked {
- display_pid: u32,
exit_code: Option,
command: String,
args: Vec,
@@ -543,7 +597,6 @@ impl AgentOs {
tracked_by_process_id.insert(
entry.process_id.clone(),
Tracked {
- display_pid: *display_pid,
exit_code,
command: entry.command.clone(),
args: entry.args.clone(),
@@ -552,7 +605,8 @@ impl AgentOs {
});
let now_ms = epoch_ms_now();
- let mut seen_display_pids: std::collections::BTreeSet = std::collections::BTreeSet::new();
+ let mut seen_display_pids: std::collections::BTreeSet =
+ std::collections::BTreeSet::new();
let mut out: Vec = Vec::new();
for entry in snapshot.processes {
@@ -665,6 +719,7 @@ impl AgentOs {
/// Return the first-observed start time for a process key, recording `now` the first time it is
/// seen so later snapshots report a stable timestamp (TS `observedProcessStartTimes`).
fn observed_start_time(&self, process_key: &str, now_ms: f64) -> f64 {
+ let _guard = self.inner().observed_process_time_lock.lock();
if let Some(existing) = self
.inner()
.observed_process_start_times
@@ -676,6 +731,10 @@ impl AgentOs {
.inner()
.observed_process_start_times
.insert(process_key.to_owned(), now_ms);
+ prune_string_f64_map(
+ &self.inner().observed_process_start_times,
+ OBSERVED_PROCESS_TIME_LIMIT,
+ );
// Re-read to honor a racing insert that may have won; either value is a valid first-observed
// timestamp.
self.inner()
@@ -686,6 +745,7 @@ impl AgentOs {
/// Return the first-observed exit time for an SDK process id, recording `now` on first sight.
fn observed_exit_time(&self, process_id: &str, now_ms: f64) -> f64 {
+ let _guard = self.inner().observed_process_time_lock.lock();
if let Some(existing) = self
.inner()
.observed_process_exit_times
@@ -697,6 +757,10 @@ impl AgentOs {
.inner()
.observed_process_exit_times
.insert(process_id.to_owned(), now_ms);
+ prune_string_f64_map(
+ &self.inner().observed_process_exit_times,
+ OBSERVED_PROCESS_TIME_LIMIT,
+ );
self.inner()
.observed_process_exit_times
.read(process_id, |_, value| *value)
@@ -842,10 +906,52 @@ impl AgentOs {
Ok(())
}
+ fn process_registry_len_locked(&self) -> usize {
+ let mut count = 0usize;
+ self.inner().processes.scan(|_, _| {
+ count += 1;
+ });
+ count
+ }
+
+ fn prune_exited_processes_locked(&self, reserve_slots: usize) {
+ let mut entries = Vec::new();
+ self.inner().processes.scan(|pid, entry| {
+ entries.push((*pid, entry.exit_tx.borrow().is_some()));
+ });
+ let target_len = PROCESS_REGISTRY_LIMIT.saturating_sub(reserve_slots);
+ if entries.len() <= target_len {
+ return;
+ }
+
+ for pid in exited_pids_to_prune(entries, target_len) {
+ self.remove_process_tracking_locked(pid);
+ }
+ }
+
+ fn remove_process_tracking_locked(&self, pid: u32) {
+ if let Some((_, entry)) = self.inner().processes.remove(&pid) {
+ let _time_guard = self.inner().observed_process_time_lock.lock();
+ let _ = self
+ .inner()
+ .observed_process_exit_times
+ .remove(&entry.process_id);
+ let fallback_start_key = format!("{}:{pid}", entry.process_id);
+ let _ = self
+ .inner()
+ .observed_process_start_times
+ .remove(&fallback_start_key);
+ if let Some(kernel_pid) = *entry.kernel_pid.borrow() {
+ let start_key = format!("{}:{kernel_pid}", entry.process_id);
+ let _ = self.inner().observed_process_start_times.remove(&start_key);
+ }
+ }
+ }
+
/// Background pump for a spawned process: issue the `Execute` request, then fan kernel
/// `ProcessOutput`/`ProcessExited` events for this process id into the per-process broadcast and
- /// watch channels. Removes the SDK map entry once the process exits, matching the TS
- /// `proc.wait().then` cleanup.
+ /// watch channels. Exited entries are retained for post-exit inspection, then pruned oldest-first
+ /// under registry pressure.
#[allow(clippy::too_many_arguments)]
async fn run_spawn(
self,
@@ -885,6 +991,8 @@ impl AgentOs {
let _ = stderr_tx.send(message.into_bytes());
tracing::error!(?error, pid, %process_id, "spawn: Execute request failed");
let _ = exit_tx.send(Some(1));
+ let _guard = self.inner().process_registry_lock.lock();
+ self.prune_exited_processes_locked(0);
return;
}
}
@@ -923,6 +1031,8 @@ impl AgentOs {
| EventPayload::Structured(_) => {}
}
}
+ let _guard = self.inner().process_registry_lock.lock();
+ self.prune_exited_processes_locked(0);
}
}
@@ -988,10 +1098,71 @@ fn stdin_to_bytes(input: StdinInput) -> Vec {
}
}
+fn append_exec_output(
+ buffer: &mut Vec,
+ chunk: &[u8],
+ captured_output_bytes: &mut usize,
+ channel: &str,
+) -> std::result::Result<(), ClientError> {
+ let next_total = captured_output_bytes
+ .checked_add(chunk.len())
+ .ok_or_else(|| exec_output_limit_error(channel, usize::MAX))?;
+ if next_total > EXEC_OUTPUT_CAPTURE_LIMIT_BYTES {
+ return Err(exec_output_limit_error(channel, next_total));
+ }
+ buffer.extend_from_slice(chunk);
+ *captured_output_bytes = next_total;
+ Ok(())
+}
+
+fn exec_output_limit_error(channel: &str, size: usize) -> ClientError {
+ ClientError::Sidecar(format!(
+ "exec {channel} capture is {size} bytes, limit is {EXEC_OUTPUT_CAPTURE_LIMIT_BYTES}"
+ ))
+}
+
+fn exited_pids_to_prune(mut entries: Vec<(u32, bool)>, target_len: usize) -> Vec {
+ if entries.len() <= target_len {
+ return Vec::new();
+ }
+ let mut remove_count = entries.len() - target_len;
+ entries.sort_by_key(|(pid, _)| *pid);
+ let mut out = Vec::new();
+ for (pid, exited) in entries {
+ if remove_count == 0 {
+ break;
+ }
+ if !exited {
+ continue;
+ }
+ out.push(pid);
+ remove_count -= 1;
+ }
+ out
+}
+
+fn prune_string_f64_map(map: &SccHashMap, limit: usize) {
+ let mut keys = Vec::new();
+ map.scan(|key, _| {
+ keys.push(key.clone());
+ });
+ if keys.len() <= limit {
+ return;
+ }
+ let remove_count = keys.len() - limit;
+ keys.sort();
+ for key in keys.into_iter().take(remove_count) {
+ let _ = map.remove(&key);
+ }
+}
+
/// Drive a caller-supplied output callback from a fresh subscription on the given broadcast channel.
/// Each chunk delivered to the channel is forwarded to `callback` as raw bytes. The task ends when
/// the channel closes (process exit), matching the TS handler-set lifetime.
-pub(crate) fn install_output_callback(tx: broadcast::Sender>, mut callback: OutputCallback) {
+pub(crate) fn install_output_callback(
+ tx: broadcast::Sender>,
+ mut callback: OutputCallback,
+) {
let mut rx = tx.subscribe();
tokio::spawn(async move {
loop {
@@ -1012,3 +1183,51 @@ fn epoch_ms_now() -> f64 {
.map(|d| d.as_secs_f64() * 1000.0)
.unwrap_or(0.0)
}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ EXEC_OUTPUT_CAPTURE_LIMIT_BYTES, append_exec_output, exited_pids_to_prune,
+ prune_string_f64_map,
+ };
+ use scc::HashMap as SccHashMap;
+
+ #[test]
+ fn append_exec_output_rejects_capture_over_limit() {
+ let mut buffer = vec![0u8; EXEC_OUTPUT_CAPTURE_LIMIT_BYTES - 1];
+ let mut captured = buffer.len();
+
+ append_exec_output(&mut buffer, &[1], &mut captured, "stdout")
+ .expect("chunk at limit should fit");
+ assert_eq!(captured, EXEC_OUTPUT_CAPTURE_LIMIT_BYTES);
+
+ let error = append_exec_output(&mut buffer, &[2], &mut captured, "stdout")
+ .expect_err("chunk over limit should fail");
+ assert!(
+ error.to_string().contains("exec stdout capture is"),
+ "unexpected error: {error}"
+ );
+ assert_eq!(captured, EXEC_OUTPUT_CAPTURE_LIMIT_BYTES);
+ assert_eq!(buffer.len(), EXEC_OUTPUT_CAPTURE_LIMIT_BYTES);
+ }
+
+ #[test]
+ fn exited_pid_pruning_keeps_live_entries_and_removes_oldest_exited() {
+ let pids = exited_pids_to_prune(vec![(3, true), (1, false), (2, true), (4, true)], 2);
+ assert_eq!(pids, vec![2, 3]);
+ }
+
+ #[test]
+ fn observed_time_pruning_enforces_limit() {
+ let map = SccHashMap::new();
+ let _ = map.insert("b".to_string(), 2.0);
+ let _ = map.insert("a".to_string(), 1.0);
+ let _ = map.insert("c".to_string(), 3.0);
+
+ prune_string_f64_map(&map, 2);
+
+ assert!(map.read("a", |_, _| ()).is_none());
+ assert!(map.read("b", |_, _| ()).is_some());
+ assert!(map.read("c", |_, _| ()).is_some());
+ }
+}
diff --git a/crates/client/src/session.rs b/crates/client/src/session.rs
index d00821253..13e82204a 100644
--- a/crates/client/src/session.rs
+++ b/crates/client/src/session.rs
@@ -7,7 +7,7 @@
//! data only. JSON-RPC errors are NOT Rust `Err`; methods that issue requests return a
//! [`JsonRpcResponse`] whose `error` field may be set.
-use std::collections::{BTreeMap, VecDeque};
+use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::pin::Pin;
use std::sync::atomic::Ordering;
@@ -15,24 +15,40 @@ use std::sync::atomic::Ordering;
use anyhow::Result;
use futures::Stream;
use serde::{Deserialize, Serialize};
-use serde_json::{json, Value};
+use serde_json::{Value, json};
use agent_os_sidecar::protocol::{
CloseAgentSessionRequest, CreateSessionRequest, GetSessionStateRequest, GuestRuntimeKind,
OwnershipScope, RequestPayload, ResponsePayload, SessionCreatedResponse, SessionRequest,
- SessionStateResponse,
+ SessionStateResponse, SidecarPermissionRequest, SidecarPermissionResultResponse,
};
use crate::agent_os::{AgentOs, SessionEntry};
use crate::error::ClientError;
-use crate::json_rpc::{JsonRpcError, JsonRpcId, JsonRpcNotification, JsonRpcResponse, SequencedEvent};
-use crate::stream::{subscribe_with_replay, Subscription};
-use crate::{ACP_SESSION_EVENT_RETENTION_LIMIT, CLOSED_SESSION_ID_RETENTION_LIMIT, PERMISSION_TIMEOUT_MS};
+use crate::json_rpc::{
+ JsonRpcError, JsonRpcId, JsonRpcNotification, JsonRpcResponse, SequencedEvent,
+};
+use crate::stream::{Subscription, subscribe_with_replay};
+use crate::{
+ ACP_SESSION_EVENT_RETENTION_LIMIT, CLOSED_SESSION_ID_RETENTION_LIMIT, PERMISSION_TIMEOUT_MS,
+};
/// ACP method name for legacy permission requests/responses.
const LEGACY_PERMISSION_METHOD: &str = "request/permission";
-/// ACP method name for `session/request_permission` (newer ACP).
-const ACP_PERMISSION_METHOD: &str = "session/request_permission";
+
+/// Maximum in-flight session RPC requests per session.
+const SESSION_PENDING_REQUEST_LIMIT: usize = 1024;
+
+/// Maximum bytes accumulated into `PromptResult.text`.
+const PROMPT_TEXT_CAPTURE_LIMIT_BYTES: usize = 16 * 1024 * 1024;
+
+/// Maximum distinct agent-message chunk sequence numbers tracked per prompt call.
+const PROMPT_DELIVERED_CHUNK_SEQUENCE_LIMIT: usize = 262_144;
+
+pub type SessionEventStream = Pin + Send>>;
+pub type SessionEventSubscription = (SessionEventStream, Subscription);
+pub type PermissionRequestStream = Pin + Send>>;
+pub type PermissionRequestSubscription = (PermissionRequestStream, Subscription);
// ---------------------------------------------------------------------------
// Supporting types
@@ -61,24 +77,8 @@ pub struct AgentRegistryEntry {
/// Built-in agent ids (mirrors the keys of TS `AGENT_CONFIGS`).
const BUILTIN_AGENT_IDS: [&str; 5] = ["pi", "pi-cli", "opencode", "claude", "codex"];
-/// opencode context-file paths injected via `OPENCODE_CONTEXTPATHS` (port of TS `OPENCODE_CONTEXT_PATHS`).
-const OPENCODE_CONTEXT_PATHS: [&str; 12] = [
- ".github/copilot-instructions.md",
- ".cursorrules",
- ".cursor/rules/",
- "CLAUDE.md",
- "CLAUDE.local.md",
- "opencode.md",
- "opencode.local.md",
- "OpenCode.md",
- "OpenCode.local.md",
- "OPENCODE.md",
- "OPENCODE.local.md",
- "/etc/agentos/instructions.md",
-];
-
-/// A built-in agent configuration (port of a TS `AGENT_CONFIGS` entry). `prepareInstructions` is a
-/// documented nuance not yet ported.
+/// A built-in agent configuration (port of a TS `AGENT_CONFIGS` entry). System-prompt assembly and
+/// injection are owned by the sidecar.
struct AgentConfigDef {
acp_adapter: &'static str,
agent_package: &'static str,
@@ -141,47 +141,15 @@ 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//`.
-/// Find a package's real host directory under `/node_modules`, supporting both
-/// flat (npm-hoisted, `node_modules/`) and nested (pnpm, `node_modules/.pnpm//node_modules/`)
-/// layouts. pnpm does not hoist transitive packages to the top level, so an agent adapter such as
-/// `@rivet-dev/agent-os-pi` only exists deep in the `.pnpm` store. Returns the package directory whose
-/// `package.json` exists, preferring the hoisted location.
-fn find_package_dir(module_access_cwd: &str, package_name: &str) -> Option {
- let node_modules = std::path::Path::new(module_access_cwd).join("node_modules");
- let hoisted = node_modules.join(package_name);
- if hoisted.join("package.json").is_file() {
- return Some(hoisted);
- }
- let pnpm_store = node_modules.join(".pnpm");
- for entry in std::fs::read_dir(&pnpm_store).ok()?.flatten() {
- let candidate = entry.path().join("node_modules").join(package_name);
- if candidate.join("package.json").is_file() {
- return Some(candidate);
- }
- }
- None
-}
-
-/// Map a host path under `/node_modules` to its guest path under
-/// `/root/node_modules`, since module access projects that tree there. Returns `None` if `host_path`
-/// is not within the projected `node_modules`.
-fn host_node_modules_path_to_guest(module_access_cwd: &str, host_path: &std::path::Path) -> Option {
- let node_modules = std::path::Path::new(module_access_cwd).join("node_modules");
- let relative = host_path.strip_prefix(&node_modules).ok()?;
- Some(format!("/root/node_modules/{}", relative.to_string_lossy()))
-}
-
fn resolve_package_bin(
module_access_cwd: &str,
package_name: &str,
bin_name: Option<&str>,
) -> std::result::Result {
- let package_dir = find_package_dir(module_access_cwd, package_name).ok_or_else(|| {
- ClientError::Sidecar(format!(
- "package not found: {package_name} (looked under {module_access_cwd}/node_modules and its .pnpm store)"
- ))
- })?;
- let pkg_json_path = package_dir.join("package.json");
+ 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()))
})?;
@@ -201,99 +169,7 @@ fn resolve_package_bin(
let bin_entry = bin_entry.ok_or_else(|| {
ClientError::Sidecar(format!("No bin entry found in {package_name}/package.json"))
})?;
- let bin_host_path = package_dir.join(bin_entry.trim_start_matches("./"));
- host_node_modules_path_to_guest(module_access_cwd, &bin_host_path).ok_or_else(|| {
- ClientError::Sidecar(format!(
- "resolved bin for {package_name} is outside module access node_modules: {}",
- bin_host_path.display()
- ))
- })
-}
-
-#[cfg(test)]
-mod resolve_package_bin_tests {
- use super::resolve_package_bin;
- use std::fs;
- use std::path::{Path, PathBuf};
-
- /// Build a throwaway host fixture dir, returning its path. Cleaned by the caller.
- fn fixture(label: &str) -> PathBuf {
- let dir = std::env::temp_dir().join(format!(
- "agentos-resolve-bin-{}-{}",
- std::process::id(),
- label
- ));
- let _ = fs::remove_dir_all(&dir);
- dir
- }
-
- fn write_pkg(root: &Path, rel_pkg_dir: &str, bin_json: &str) {
- let pkg_dir = root.join(rel_pkg_dir);
- fs::create_dir_all(&pkg_dir).expect("mkdir pkg");
- fs::write(
- pkg_dir.join("package.json"),
- format!(r#"{{"name":"x","bin":{bin_json}}}"#),
- )
- .expect("write package.json");
- }
-
- #[test]
- fn resolves_hoisted_package_to_top_level_guest_path() {
- let root = fixture("hoisted");
- write_pkg(
- &root,
- "node_modules/@scope/pkg",
- r#"{"the-bin":"./dist/adapter.js"}"#,
- );
- let result = resolve_package_bin(root.to_str().unwrap(), "@scope/pkg", Some("the-bin"));
- let _ = fs::remove_dir_all(&root);
- assert_eq!(
- result.unwrap(),
- "/root/node_modules/@scope/pkg/dist/adapter.js"
- );
- }
-
- #[test]
- fn resolves_pnpm_nested_package_to_its_real_deep_guest_path() {
- // pnpm does not hoist transitive packages; the adapter only exists deep in the .pnpm store,
- // and it must be launched from there so its relative dependency symlinks resolve.
- let root = fixture("pnpm");
- let key = "@scope+pkg@1.0.0_peer";
- write_pkg(
- &root,
- &format!("node_modules/.pnpm/{key}/node_modules/@scope/pkg"),
- r#"{"the-bin":"./dist/adapter.js"}"#,
- );
- let result = resolve_package_bin(root.to_str().unwrap(), "@scope/pkg", Some("the-bin"));
- let _ = fs::remove_dir_all(&root);
- assert_eq!(
- result.unwrap(),
- format!("/root/node_modules/.pnpm/{key}/node_modules/@scope/pkg/dist/adapter.js")
- );
- }
-
- #[test]
- fn prefers_hoisted_over_pnpm_when_both_exist() {
- let root = fixture("both");
- write_pkg(&root, "node_modules/pkg", r#""./hoisted.js""#);
- write_pkg(
- &root,
- "node_modules/.pnpm/pkg@1/node_modules/pkg",
- r#""./nested.js""#,
- );
- let result = resolve_package_bin(root.to_str().unwrap(), "pkg", None);
- let _ = fs::remove_dir_all(&root);
- assert_eq!(result.unwrap(), "/root/node_modules/pkg/hoisted.js");
- }
-
- #[test]
- fn missing_package_is_an_error() {
- let root = fixture("missing");
- fs::create_dir_all(root.join("node_modules")).expect("mkdir");
- let result = resolve_package_bin(root.to_str().unwrap(), "nope", None);
- let _ = fs::remove_dir_all(&root);
- assert!(result.is_err());
- }
+ Ok(format!("/root/node_modules/{package_name}/{bin_entry}"))
}
/// MCP server config used by `create_session`.
@@ -315,7 +191,7 @@ pub enum McpServerConfig {
}
/// Options for `create_session`.
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CreateSessionOptions {
/// Default `"/home/user"`.
pub cwd: Option,
@@ -327,18 +203,6 @@ pub struct CreateSessionOptions {
pub additional_instructions: Option,
}
-impl Default for CreateSessionOptions {
- fn default() -> Self {
- Self {
- cwd: None,
- env: BTreeMap::new(),
- mcp_servers: Vec::new(),
- skip_os_instructions: false,
- additional_instructions: None,
- }
- }
-}
-
/// The id returned by `create_session` / `resume_session`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionId {
@@ -411,9 +275,17 @@ pub struct SessionConfigOption {
pub label: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option,
- #[serde(default, rename = "currentValue", skip_serializing_if = "Option::is_none")]
+ #[serde(
+ default,
+ rename = "currentValue",
+ skip_serializing_if = "Option::is_none"
+ )]
pub current_value: Option,
- #[serde(default, rename = "allowedValues", skip_serializing_if = "Option::is_none")]
+ #[serde(
+ default,
+ rename = "allowedValues",
+ skip_serializing_if = "Option::is_none"
+ )]
pub allowed_values: Option>,
#[serde(default, rename = "readOnly", skip_serializing_if = "Option::is_none")]
pub read_only: Option,
@@ -424,7 +296,11 @@ pub struct SessionConfigOption {
pub struct PromptCapabilities {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audio: Option,
- #[serde(default, rename = "embeddedContext", skip_serializing_if = "Option::is_none")]
+ #[serde(
+ default,
+ rename = "embeddedContext",
+ skip_serializing_if = "Option::is_none"
+ )]
pub embedded_context: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option,
@@ -441,27 +317,55 @@ pub struct AgentCapabilities {
pub plan_mode: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub questions: Option,
- #[serde(default, rename = "tool_calls", skip_serializing_if = "Option::is_none")]
+ #[serde(
+ default,
+ rename = "tool_calls",
+ skip_serializing_if = "Option::is_none"
+ )]
pub tool_calls: Option,
- #[serde(default, rename = "text_messages", skip_serializing_if = "Option::is_none")]
+ #[serde(
+ default,
+ rename = "text_messages",
+ skip_serializing_if = "Option::is_none"
+ )]
pub text_messages: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub images: Option,
- #[serde(default, rename = "file_attachments", skip_serializing_if = "Option::is_none")]
+ #[serde(
+ default,
+ rename = "file_attachments",
+ skip_serializing_if = "Option::is_none"
+ )]
pub file_attachments: Option,
- #[serde(default, rename = "session_lifecycle", skip_serializing_if = "Option::is_none")]
+ #[serde(
+ default,
+ rename = "session_lifecycle",
+ skip_serializing_if = "Option::is_none"
+ )]
pub session_lifecycle: Option,
- #[serde(default, rename = "error_events", skip_serializing_if = "Option::is_none")]
+ #[serde(
+ default,
+ rename = "error_events",
+ skip_serializing_if = "Option::is_none"
+ )]
pub error_events: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option,
- #[serde(default, rename = "streaming_deltas", skip_serializing_if = "Option::is_none")]
+ #[serde(
+ default,
+ rename = "streaming_deltas",
+ skip_serializing_if = "Option::is_none"
+ )]
pub streaming_deltas: Option,
#[serde(default, rename = "mcp_tools", skip_serializing_if = "Option::is_none")]
pub mcp_tools: Option,
- #[serde(default, rename = "promptCapabilities", skip_serializing_if = "Option::is_none")]
+ #[serde(
+ default,
+ rename = "promptCapabilities",
+ skip_serializing_if = "Option::is_none"
+ )]
pub prompt_capabilities: Option,
#[serde(flatten)]
pub extra: BTreeMap,
@@ -484,7 +388,11 @@ pub struct AgentInfo {
pub struct SessionInitData {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modes: Option,
- #[serde(default, rename = "configOptions", skip_serializing_if = "Option::is_none")]
+ #[serde(
+ default,
+ rename = "configOptions",
+ skip_serializing_if = "Option::is_none"
+ )]
pub config_options: Option>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capabilities: Option,
@@ -500,7 +408,8 @@ pub struct SessionInitData {
/// and resolves it. Subsequent calls (or other broadcast clones) are no-ops.
#[derive(Clone)]
pub struct PermissionResponder {
- inner: std::sync::Arc>>>,
+ inner:
+ std::sync::Arc>>>,
}
impl PermissionResponder {
@@ -525,9 +434,10 @@ impl PermissionResponder {
/// A permission request delivered to a subscriber. Carries a Clone-able one-shot responder.
///
-/// The TS handler is `(request) => void`; in Rust this is the request/responder pattern: the
-/// subscriber resolves the request by calling [`PermissionResponder::respond`], or the 120s timeout
-/// / no-subscriber path auto-rejects.
+/// Requests are delivered by the sidecar permission-request path
+/// ([`AgentOs::deliver_sidecar_permission_request`]). The subscriber resolves the request via
+/// [`PermissionResponder::respond`] or [`AgentOs::respond_permission`]; the
+/// [`crate::PERMISSION_TIMEOUT_MS`] timeout and the no-subscriber path auto-reject.
#[derive(Clone)]
pub struct PermissionRequest {
pub permission_id: String,
@@ -555,63 +465,14 @@ pub enum PermissionReply {
Reject,
}
-/// Resolve the ACP `optionId` for a permission `reply`, scanning the agent-provided `options`
-/// (`params.options[]`) for a matching `optionId`/`kind`, then falling back to the canonical id.
-/// Mirrors `_normalizeAcpPermissionOptionId`. Always returns a `Some` (the TS `null` branch is never
-/// reachable since each reply has a non-empty fallback).
-fn normalize_acp_permission_option_id(
- options: Option<&Vec>,
- reply: PermissionReply,
-) -> String {
- let (option_ids, kinds): (&[&str], &[&str]) = match reply {
- PermissionReply::Always => (&["always", "allow_always"], &["allow_always"]),
- PermissionReply::Once => (&["once", "allow_once"], &["allow_once"]),
- PermissionReply::Reject => (&["reject", "reject_once"], &["reject_once"]),
- };
-
- let matched = options.and_then(|options| {
- options.iter().find_map(|option| {
- let option_id = option.get("optionId").and_then(Value::as_str);
- let kind = option.get("kind").and_then(Value::as_str);
- let hit = option_id.map(|id| option_ids.contains(&id)).unwrap_or(false)
- || kind.map(|kind| kinds.contains(&kind)).unwrap_or(false);
- if hit {
- option_id.map(str::to_string)
- } else {
- None
- }
- })
- });
-
- matched.unwrap_or_else(|| {
- match reply {
- PermissionReply::Always => "allow_always",
- PermissionReply::Once => "allow_once",
- PermissionReply::Reject => "reject_once",
- }
- .to_string()
- })
-}
-
-/// Build the ACP permission result (`{ outcome: { outcome: "selected", optionId } }`) for a `reply`,
-/// reading agent-provided options from `params.options[]`. Mirrors `_buildAcpPermissionResult`.
-/// Because `normalize_acp_permission_option_id` always yields an id, the `cancelled` outcome branch
-/// is never taken (matching the TS fallbacks).
-fn build_acp_permission_result(reply: PermissionReply, params: &Value) -> Value {
- let options: Option> = params.get("options").and_then(Value::as_array).map(|array| {
- array
- .iter()
- .filter(|option| option.is_object())
- .cloned()
- .collect()
- });
- let option_id = normalize_acp_permission_option_id(options.as_ref(), reply);
- json!({
- "outcome": {
- "outcome": "selected",
- "optionId": option_id,
- }
- })
+/// The wire string for a [`PermissionReply`] (`"once"` / `"always"` / `"reject"`), matching the
+/// serde `lowercase` rename and the TS `PermissionReply` union.
+fn permission_reply_wire(reply: PermissionReply) -> &'static str {
+ match reply {
+ PermissionReply::Once => "once",
+ PermissionReply::Always => "always",
+ PermissionReply::Reject => "reject",
+ }
}
// ---------------------------------------------------------------------------
@@ -664,7 +525,10 @@ fn merge_sequenced_events(ring: &mut VecDeque, incoming: Vec, ring: &VecDeque) -> Option {
+fn next_highest_sequence_number(
+ current: Option,
+ ring: &VecDeque,
+) -> Option {
let Some(latest) = ring.back().map(|event| event.sequence_number) else {
return current;
};
@@ -674,51 +538,229 @@ fn next_highest_sequence_number(current: Option, ring: &VecDeque) -> i64 {
- let min = ring
- .iter()
- .map(|event| event.sequence_number)
- .fold(0i64, i64::min);
- min - 1
+fn accumulate_agent_message_chunk(
+ event: &SequencedEvent,
+ start_after: i64,
+ delivered_chunk_sequences: &mut BTreeSet,
+ agent_text: &mut String,
+) -> std::result::Result<(), ClientError> {
+ if event.sequence_number <= start_after {
+ return Ok(());
+ }
+ let params = event.notification.params.clone().unwrap_or(Value::Null);
+ let update = params.get("update").cloned().unwrap_or(Value::Null);
+ if update.get("sessionUpdate").and_then(Value::as_str) != Some("agent_message_chunk") {
+ return Ok(());
+ }
+ if delivered_chunk_sequences.contains(&event.sequence_number) {
+ return Ok(());
+ }
+ if let Some(chunk) = update
+ .get("content")
+ .and_then(|content| content.get("text"))
+ .and_then(Value::as_str)
+ {
+ if delivered_chunk_sequences.len() >= PROMPT_DELIVERED_CHUNK_SEQUENCE_LIMIT {
+ return Err(prompt_chunk_sequence_limit_error());
+ }
+ let next_len = agent_text
+ .len()
+ .checked_add(chunk.len())
+ .ok_or_else(|| prompt_text_limit_error(usize::MAX))?;
+ if next_len > PROMPT_TEXT_CAPTURE_LIMIT_BYTES {
+ return Err(prompt_text_limit_error(next_len));
+ }
+ agent_text.push_str(chunk);
+ delivered_chunk_sequences.insert(event.sequence_number);
+ }
+ Ok(())
}
-/// Apply a `session/update` notification's local cache side effects (`current_mode_update`,
-/// `config_option(s)_update`). Mirrors `_applySessionUpdate`. Holds the entry's per-field guards
-/// briefly.
-fn apply_session_update(entry: &SessionEntry, notification: &JsonRpcNotification) {
- if notification.method != "session/update" {
- return;
+fn pending_session_request_count(entry: &SessionEntry) -> usize {
+ let mut count = 0;
+ entry.pending_prompt_resolvers.scan(|_, _| {
+ count += 1;
+ });
+ count
+}
+
+fn prompt_text_limit_error(size: usize) -> ClientError {
+ ClientError::Sidecar(format!(
+ "prompt text capture is {size} bytes, limit is {PROMPT_TEXT_CAPTURE_LIMIT_BYTES}"
+ ))
+}
+
+fn prompt_chunk_sequence_limit_error() -> ClientError {
+ ClientError::Sidecar(format!(
+ "prompt chunk sequence tracking limit exceeded: at most {PROMPT_DELIVERED_CHUNK_SEQUENCE_LIMIT} chunks can be captured per prompt"
+ ))
+}
+
+struct PendingSessionRequestGuard<'a> {
+ os: &'a AgentOs,
+ session_id: &'a str,
+ resolver_id: i64,
+ active: bool,
+}
+
+impl<'a> PendingSessionRequestGuard<'a> {
+ fn new(os: &'a AgentOs, session_id: &'a str, resolver_id: i64) -> Self {
+ Self {
+ os,
+ session_id,
+ resolver_id,
+ active: true,
+ }
}
- let params = notification.params.clone().unwrap_or(Value::Null);
- let update = params
- .get("update")
- .cloned()
- .unwrap_or_else(|| params.clone());
- let session_update = update.get("sessionUpdate").and_then(Value::as_str);
-
- if session_update == Some("current_mode_update") {
- if let Some(current_mode_id) = update.get("currentModeId").and_then(Value::as_str) {
- let mut modes = entry.modes.lock();
- if let Some(modes) = modes.as_mut() {
- modes.current_mode_id = current_mode_id.to_string();
- }
+
+ fn cleanup(&mut self) {
+ if self.active {
+ self.os
+ .cleanup_pending_resolver(self.session_id, self.resolver_id);
+ self.active = false;
}
}
+}
- if matches!(
- session_update,
- Some("config_option_update") | Some("config_options_update")
- ) {
- if let Some(config_options) = update.get("configOptions").and_then(Value::as_array) {
- if let Ok(parsed) =
- serde_json::from_value::>(Value::Array(config_options.clone()))
- {
- *entry.config_options.lock() = parsed;
- }
+impl Drop for PendingSessionRequestGuard<'_> {
+ fn drop(&mut self) {
+ self.cleanup();
+ }
+}
+
+// Private accumulator coverage stays inline because integration tests cannot construct the missed
+// broadcast plus hydrated-ring ordering without exposing client internals.
+#[cfg(test)]
+mod prompt_accumulation_tests {
+ use super::*;
+
+ fn event(sequence_number: i64, update: Value) -> SequencedEvent {
+ SequencedEvent {
+ sequence_number,
+ notification: JsonRpcNotification {
+ jsonrpc: "2.0".to_string(),
+ method: "session/update".to_string(),
+ params: Some(json!({ "update": update })),
+ },
}
}
+
+ #[test]
+ fn hydrated_chunk_is_not_hidden_by_later_non_chunk_event() {
+ let start_after = 9;
+ let chunk = event(
+ 10,
+ json!({
+ "sessionUpdate": "agent_message_chunk",
+ "content": { "text": "hello" },
+ }),
+ );
+ let later_non_chunk = event(
+ 11,
+ json!({
+ "sessionUpdate": "current_mode_update",
+ "currentModeId": "default",
+ }),
+ );
+
+ let mut delivered_chunk_sequences = BTreeSet::new();
+ let mut text = String::new();
+ accumulate_agent_message_chunk(
+ &later_non_chunk,
+ start_after,
+ &mut delivered_chunk_sequences,
+ &mut text,
+ )
+ .expect("later non-chunk");
+ accumulate_agent_message_chunk(
+ &chunk,
+ start_after,
+ &mut delivered_chunk_sequences,
+ &mut text,
+ )
+ .expect("chunk");
+ accumulate_agent_message_chunk(
+ &chunk,
+ start_after,
+ &mut delivered_chunk_sequences,
+ &mut text,
+ )
+ .expect("duplicate chunk");
+
+ assert_eq!(text, "hello");
+ }
+
+ #[test]
+ fn prompt_text_capture_limit_rejects_overflowing_chunk() {
+ let chunk = event(
+ 10,
+ json!({
+ "sessionUpdate": "agent_message_chunk",
+ "content": { "text": "abcd" },
+ }),
+ );
+ let mut delivered_chunk_sequences = BTreeSet::new();
+ let mut text = "x".repeat(PROMPT_TEXT_CAPTURE_LIMIT_BYTES - 3);
+ let error =
+ accumulate_agent_message_chunk(&chunk, 9, &mut delivered_chunk_sequences, &mut text)
+ .expect_err("chunk should exceed prompt text cap");
+ assert!(
+ error.to_string().contains("prompt text capture is"),
+ "unexpected error: {error}"
+ );
+ assert_eq!(text.len(), PROMPT_TEXT_CAPTURE_LIMIT_BYTES - 3);
+ }
+
+ #[test]
+ fn prompt_chunk_sequence_limit_rejects_more_tracked_chunks() {
+ let chunk = event(
+ PROMPT_DELIVERED_CHUNK_SEQUENCE_LIMIT as i64 + 10,
+ json!({
+ "sessionUpdate": "agent_message_chunk",
+ "content": { "text": "x" },
+ }),
+ );
+ let mut delivered_chunk_sequences =
+ BTreeSet::from_iter(0..PROMPT_DELIVERED_CHUNK_SEQUENCE_LIMIT as i64);
+ let mut text = String::new();
+ let error =
+ accumulate_agent_message_chunk(&chunk, -1, &mut delivered_chunk_sequences, &mut text)
+ .expect_err("chunk should exceed sequence tracking cap");
+ assert!(
+ error
+ .to_string()
+ .contains("prompt chunk sequence tracking limit exceeded"),
+ "unexpected error: {error}"
+ );
+ assert!(text.is_empty());
+ }
+
+ #[test]
+ fn pending_session_request_count_tracks_registered_resolvers() {
+ let (event_tx, _) = tokio::sync::broadcast::channel(1);
+ let (permission_tx, _) = tokio::sync::broadcast::channel(1);
+ let entry = SessionEntry {
+ agent_type: "pi".to_string(),
+ modes: parking_lot::Mutex::new(None),
+ config_options: parking_lot::Mutex::new(Vec::new()),
+ capabilities: parking_lot::Mutex::new(None),
+ agent_info: parking_lot::Mutex::new(None),
+ config_overrides: parking_lot::Mutex::new(BTreeMap::new()),
+ event_ring: parking_lot::Mutex::new(VecDeque::new()),
+ highest_sequence_number: std::sync::atomic::AtomicI64::new(-1),
+ event_tx,
+ permission_tx,
+ pending_permission_replies: scc::HashMap::new(),
+ pending_session_request_lock: parking_lot::Mutex::new(()),
+ pending_prompt_resolvers: scc::HashMap::new(),
+ };
+ let (first_tx, _first_rx) = tokio::sync::oneshot::channel();
+ let (second_tx, _second_rx) = tokio::sync::oneshot::channel();
+ let _ = entry.pending_prompt_resolvers.insert(1, first_tx);
+ let _ = entry.pending_prompt_resolvers.insert(2, second_tx);
+
+ assert_eq!(pending_session_request_count(&entry), 2);
+ }
}
/// Re-apply synthetic config overrides onto the cached config options. Mirrors
@@ -752,128 +794,6 @@ fn apply_synthetic_config_overrides(entry: &SessionEntry) {
/// distinguish `session/prompt` resolvers without an extra `SessionEntry` field).
const PENDING_METHOD_PREFIX: &str = "__pending_method::";
-/// Record a sequenced notification into the session ring and run the cache/permission side effects.
-/// Mirrors `_recordSessionNotification` (without the host-side event-handler microtask dispatch,
-/// which the broadcast channel covers).
-fn record_session_notification(
- entry: &SessionEntry,
- sequence_number: i64,
- notification: JsonRpcNotification,
-) {
- {
- let mut ring = entry.event_ring.lock();
- merge_sequenced_events(
- &mut ring,
- vec![SequencedEvent {
- sequence_number,
- notification: notification.clone(),
- }],
- );
- let next = next_highest_sequence_number(
- Some(entry.highest_sequence_number.load(Ordering::SeqCst)),
- &ring,
- );
- if let Some(next) = next {
- entry.highest_sequence_number.store(next, Ordering::SeqCst);
- }
- }
- apply_session_update(entry, ¬ification);
-
- if should_dispatch_to_session_event_handlers(¬ification) {
- let _ = entry.event_tx.send(SequencedEvent {
- sequence_number,
- notification: notification.clone(),
- });
- }
-
- // Permission-from-notification delivery (mirrors the permission branch of
- // `_recordSessionNotification`). When a recorded notification is a legacy `request/permission`
- // or ACP `session/request_permission` with a string/number `permissionId`, deliver a
- // [`PermissionRequest`] to subscribers. This is the notification path: it broadcasts the request
- // (params verbatim, as TS does here) WITHOUT registering a `pending_permission_replies` slot or
- // arming the 120s timeout. The request/responder reply slot + timeout wiring is the separate
- // ACP/sidecar request path handled by [`AgentOs::deliver_permission_request`].
- if notification.method == LEGACY_PERMISSION_METHOD
- || notification.method == ACP_PERMISSION_METHOD
- {
- let params = notification.params.clone().unwrap_or(Value::Null);
- let permission_id = match params.get("permissionId") {
- Some(Value::String(id)) => Some(id.clone()),
- Some(Value::Number(num)) => Some(num.to_string()),
- _ => None,
- };
- if let Some(permission_id) = permission_id {
- let description = params
- .get("description")
- .and_then(Value::as_str)
- .map(str::to_string);
- // The notification path has no reply slot, so the responder resolves to nothing.
- let (responder, _receiver) = PermissionResponder::new();
- let request = PermissionRequest {
- permission_id,
- description,
- params,
- responder,
- };
- let _ = entry.permission_tx.send(request);
- }
- }
-}
-
-/// Build a [`PermissionRequest`] from a legacy/ACP permission notification for the request/responder
-/// (sidecar-request / ACP-request) path. Mirrors the request construction in
-/// `_handleAcpPermissionRequest` / `_handlePermissionSidecarRequest`.
-///
-/// For the ACP path (`session/request_permission`) the delivered params are enriched with
-/// `permissionId` and `_acpMethod` (matching `permissionParams = { ...params, permissionId,
-/// _acpMethod: request.method }`). The enriched params are also returned so the caller can build the
-/// ACP outcome result via [`build_acp_permission_result`]. The legacy path delivers params verbatim.
-fn build_permission_request(
- notification: &JsonRpcNotification,
-) -> Option<(
- String,
- PermissionRequest,
- Value,
- tokio::sync::oneshot::Receiver,
-)> {
- let raw_params = notification.params.clone().unwrap_or(Value::Null);
- let permission_id = match raw_params.get("permissionId") {
- Some(Value::String(id)) => id.clone(),
- Some(Value::Number(num)) => num.to_string(),
- _ => return None,
- };
-
- // ACP path enriches params with `permissionId` and `_acpMethod`; legacy path uses params as-is.
- let delivered_params = if notification.method == ACP_PERMISSION_METHOD {
- let mut object = match raw_params {
- Value::Object(existing) => existing,
- _ => serde_json::Map::new(),
- };
- object.insert("permissionId".to_string(), Value::String(permission_id.clone()));
- object.insert(
- "_acpMethod".to_string(),
- Value::String(notification.method.clone()),
- );
- Value::Object(object)
- } else {
- raw_params
- };
-
- let description = delivered_params
- .get("description")
- .and_then(Value::as_str)
- .map(str::to_string);
-
- let (responder, receiver) = PermissionResponder::new();
- let request = PermissionRequest {
- permission_id: permission_id.clone(),
- description,
- params: delivered_params.clone(),
- responder,
- };
- Some((permission_id, request, delivered_params, receiver))
-}
-
/// Apply the local cache mutations of `_syncSessionState`: modes, config options, capabilities,
/// agent info, and merged events from a sidecar [`SessionStateResponse`].
fn sync_session_state(entry: &SessionEntry, state: &SessionStateResponse) {
@@ -916,15 +836,27 @@ fn sync_session_state(entry: &SessionEntry, state: &SessionStateResponse) {
})
.collect();
+ let previous_highest = entry.highest_sequence_number.load(Ordering::SeqCst);
+ let dispatchable_new_events = incoming
+ .iter()
+ .filter(|event| {
+ event.sequence_number > previous_highest
+ && should_dispatch_to_session_event_handlers(&event.notification)
+ })
+ .cloned()
+ .collect::>();
+
let mut ring = entry.event_ring.lock();
merge_sequenced_events(&mut ring, incoming);
- let next = next_highest_sequence_number(
- Some(entry.highest_sequence_number.load(Ordering::SeqCst)),
- &ring,
- );
+ let next = next_highest_sequence_number(Some(previous_highest), &ring);
if let Some(next) = next {
entry.highest_sequence_number.store(next, Ordering::SeqCst);
}
+ drop(ring);
+
+ for event in dispatchable_new_events {
+ let _ = entry.event_tx.send(event);
+ }
}
/// Synthesize the unsupported-config JSON-RPC error response (`-32601`). Mirrors
@@ -947,103 +879,6 @@ fn unsupported_config_response(agent_type: &str, category: &str) -> JsonRpcRespo
}
}
-/// Apply the codex config fallback: record overrides, re-apply, synthesize a negative-seq
-/// `config_option_update`, and return a `via: "codex-config-fallback"` response. Mirrors
-/// `_applyCodexConfigFallback`.
-fn apply_codex_config_fallback(entry: &SessionEntry, category: &str, value: &str) -> JsonRpcResponse {
- {
- let options = entry.config_options.lock();
- let matching_id = options
- .iter()
- .find(|option| option.category.as_deref() == Some(category))
- .map(|option| option.id.clone());
- drop(options);
- let mut overrides = entry.config_overrides.lock();
- if let Some(id) = matching_id {
- overrides.insert(id, value.to_string());
- }
- overrides.insert(category.to_string(), value.to_string());
- }
- apply_synthetic_config_overrides(entry);
-
- let config_options = entry.config_options.lock().clone();
- let synthetic_seq = {
- let ring = entry.event_ring.lock();
- next_synthetic_sequence_number(&ring)
- };
- record_session_notification(
- entry,
- synthetic_seq,
- JsonRpcNotification {
- jsonrpc: "2.0".to_string(),
- method: "session/update".to_string(),
- params: Some(json!({
- "update": {
- "sessionUpdate": "config_option_update",
- "configOptions": config_options,
- }
- })),
- },
- );
-
- let config_options = entry.config_options.lock().clone();
- JsonRpcResponse {
- jsonrpc: "2.0".to_string(),
- id: Some(JsonRpcId::Null),
- result: Some(json!({
- "configOptions": config_options,
- "via": "codex-config-fallback",
- })),
- error: None,
- }
-}
-
-/// Augment `session/prompt` params for codex sessions with the cached model/thought-level overrides
-/// under `_meta.agentOsCodexConfig`. Mirrors `_augmentPromptParams`.
-fn augment_prompt_params(entry: &SessionEntry, params: Option) -> Option {
- if entry.agent_type != "codex" {
- return params;
- }
- let (model, thought_level) = {
- let options = entry.config_options.lock();
- let model = options
- .iter()
- .find(|option| option.category.as_deref() == Some("model"))
- .and_then(|option| option.current_value.clone());
- let thought_level = options
- .iter()
- .find(|option| option.category.as_deref() == Some("thought_level"))
- .and_then(|option| option.current_value.clone());
- (model, thought_level)
- };
- if model.is_none() && thought_level.is_none() {
- return params;
- }
-
- let mut meta = match params.as_ref().and_then(|p| p.get("_meta")) {
- Some(Value::Object(existing)) => existing.clone(),
- _ => serde_json::Map::new(),
- };
- let mut codex_config = serde_json::Map::new();
- if let Some(model) = model {
- codex_config.insert("model".to_string(), Value::String(model));
- }
- if let Some(thought_level) = thought_level {
- codex_config.insert("thought_level".to_string(), Value::String(thought_level));
- }
- meta.insert(
- "agentOsCodexConfig".to_string(),
- Value::Object(codex_config),
- );
-
- let mut object = match params {
- Some(Value::Object(existing)) => existing,
- _ => serde_json::Map::new(),
- };
- object.insert("_meta".to_string(), Value::Object(meta));
- Some(Value::Object(object))
-}
-
/// Build the closed-session abort response (`-32000`). Mirrors `_abortPendingSessionRequests`.
fn session_closed_response(session_id: &str) -> JsonRpcResponse {
JsonRpcResponse {
@@ -1086,7 +921,10 @@ impl AgentOs {
/// Re-hydrate cached session state from the sidecar `GetSessionState` snapshot, acknowledging the
/// highest seen sequence number. Mirrors `_hydrateSessionState`.
- async fn hydrate_session_state(&self, session_id: &str) -> std::result::Result<(), ClientError> {
+ async fn hydrate_session_state(
+ &self,
+ session_id: &str,
+ ) -> std::result::Result<(), ClientError> {
let acknowledged = self.require_session(session_id, |entry| {
let highest = entry.highest_sequence_number.load(Ordering::SeqCst);
if highest >= 0 {
@@ -1127,37 +965,42 @@ impl AgentOs {
}
/// Core request helper: every session request routes through this. Tracks pending resolvers per
- /// session (cancel prompt-fallback + abort-on-close), augments `session/prompt` for codex, calls
- /// the sidecar, re-hydrates state, and applies local cache updates for `set_mode` /
- /// `set_config_option`.
+ /// session (cancel prompt-fallback + abort-on-close), calls the sidecar, re-hydrates state, and
+ /// applies local cache updates for `set_mode` / `set_config_option`.
pub(crate) async fn send_session_request(
&self,
session_id: &str,
method: &str,
params: Option,
) -> std::result::Result {
- let request_params = if method == "session/prompt" {
- self.require_session(session_id, |entry| augment_prompt_params(entry, params.clone()))?
- } else {
- params
- };
+ let request_params = params;
// Register a pending-resolver slot so cancel/close can resolve this request locally. The
// resolver carries the intended [`JsonRpcResponse`] (close -> `-32000 Session closed`,
// cancel -> `{stopReason: cancelled}`); whichever completes first wins. Mirrors the TS
// resolver `{ method, resolve: (response) => void }`.
let resolver_id = self.inner().request_counter.fetch_add(1, Ordering::SeqCst);
- let (resolve_tx, resolve_rx) =
- tokio::sync::oneshot::channel::();
+ let (resolve_tx, resolve_rx) = tokio::sync::oneshot::channel::();
self.require_session(session_id, |entry| {
- let _ = entry.pending_prompt_resolvers.insert(resolver_id, resolve_tx);
+ let _guard = entry.pending_session_request_lock.lock();
+ if pending_session_request_count(entry) >= SESSION_PENDING_REQUEST_LIMIT {
+ return Err(ClientError::Sidecar(format!(
+ "session pending request limit exceeded: at most {SESSION_PENDING_REQUEST_LIMIT} requests can be in flight per session"
+ )));
+ }
+ let _ = entry
+ .pending_prompt_resolvers
+ .insert(resolver_id, resolve_tx);
// Track the method so prompt-fallback can target only `session/prompt` resolvers.
entry
.config_overrides
.lock()
.entry(format!("{PENDING_METHOD_PREFIX}{resolver_id}"))
.or_insert_with(|| method.to_string());
- })?;
+ Ok(())
+ })??;
+ let mut pending_request_guard =
+ PendingSessionRequestGuard::new(self, session_id, resolver_id);
let transport = self.transport();
let ownership = self.session_ownership();
@@ -1176,14 +1019,14 @@ impl AgentOs {
// A cancel/close resolved this request locally before the sidecar replied. The
// resolver carries the intended response (cancel vs close), set at the abort/cancel
// site, so it is returned verbatim rather than re-derived from the method.
- self.cleanup_pending_resolver(session_id, resolver_id);
+ pending_request_guard.cleanup();
match resolved {
Ok(response) => return Ok(response),
Err(_) => return Ok(session_closed_response(session_id)),
}
}
result = &mut rpc => {
- self.cleanup_pending_resolver(session_id, resolver_id);
+ pending_request_guard.cleanup();
result?
}
};
@@ -1237,7 +1080,8 @@ impl AgentOs {
) -> std::result::Result<(), ClientError> {
self.require_session(session_id, |entry| {
if method == "session/set_mode" {
- if let Some(mode_id) = params.and_then(|p| p.get("modeId")).and_then(Value::as_str) {
+ if let Some(mode_id) = params.and_then(|p| p.get("modeId")).and_then(Value::as_str)
+ {
let mut modes = entry.modes.lock();
if let Some(modes) = modes.as_mut() {
modes.current_mode_id = mode_id.to_string();
@@ -1245,7 +1089,9 @@ impl AgentOs {
}
}
if method == "session/set_config_option" {
- let config_id = params.and_then(|p| p.get("configId")).and_then(Value::as_str);
+ let config_id = params
+ .and_then(|p| p.get("configId"))
+ .and_then(Value::as_str);
let value = params.and_then(|p| p.get("value")).and_then(Value::as_str);
if let (Some(config_id), Some(value)) = (config_id, value) {
let mut options = entry.config_options.lock();
@@ -1260,7 +1106,7 @@ impl AgentOs {
}
/// Set a config option by its category (model/thought_level). Mirrors
- /// `_setSessionConfigByCategory`: readonly -> error response, codex `-32601` fallback.
+ /// `_setSessionConfigByCategory`: readonly -> error response.
async fn set_session_config_by_category(
&self,
session_id: &str,
@@ -1292,27 +1138,6 @@ impl AgentOs {
)
.await?;
- let is_codex_method_not_found = agent_type == "codex"
- && response
- .error
- .as_ref()
- .map(|error| {
- error.code == -32601
- && error
- .data
- .as_ref()
- .and_then(|data| data.get("method"))
- .and_then(Value::as_str)
- == Some("session/set_config_option")
- })
- .unwrap_or(false);
-
- if is_codex_method_not_found {
- return self.require_session(session_id, |entry| {
- apply_codex_config_fallback(entry, category, value)
- });
- }
-
Ok(response)
}
@@ -1357,16 +1182,11 @@ impl AgentOs {
.collect()
}
- /// Create an ACP session. Resolves the agent config, prepares instructions, merges env (user
- /// wins), creates the session via the sidecar (`runtime: java_script`, protocol v1, default
- /// client caps), and hydrates state. On hydration failure the session is removed and the error
- /// rethrown. Returns the session id only.
- ///
- /// PARITY GAP: agent-config resolution + adapter-bin resolution + `prepareInstructions` live in
- /// shared modules (`AgentConfig`/`AGENT_CONFIGS`/software roots) that are not present in the
- /// scaffold and out of scope to edit. The local registration + hydration flow is implemented
- /// against `register_session`, which the create path must call once that infra exists. See
- /// `todosLeft`.
+ /// Create an ACP session. Resolves the agent config, merges env (user wins), creates the session
+ /// via the sidecar (`runtime: java_script`, protocol v1, default client caps), and hydrates
+ /// state. System-prompt assembly and injection are owned by the sidecar; the client only
+ /// forwards `additional_instructions` / `skip_os_instructions`. On hydration failure the session
+ /// is removed and the error rethrown. Returns the session id only.
pub async fn create_session(
&self,
agent_type: &str,
@@ -1385,19 +1205,13 @@ impl AgentOs {
let adapter_entrypoint =
resolve_package_bin(&module_access_cwd, config.acp_adapter, None)?;
- // prepareInstructions (per-agent OS-instruction injection): appended-prompt launch args for
- // pi/pi-cli/claude/codex, OPENCODE_CONTEXTPATHS env for opencode.
- let (args, prepared_env) = self.prepare_instructions(agent_type, &options).await?;
-
- // Merge env: agent default_env (lowest) -> prepareInstructions env -> user env (wins).
+ // Merge env: agent default_env (lowest) -> user env (wins). System-prompt assembly and
+ // injection (launch args / OPENCODE_CONTEXTPATHS) are owned by the sidecar at CreateSession.
let mut env: BTreeMap = config
.default_env
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
- for (key, value) in prepared_env {
- env.insert(key, value);
- }
for (key, value) in &options.env {
env.insert(key.clone(), value.clone());
}
@@ -1430,12 +1244,14 @@ impl AgentOs {
agent_type: agent_type.to_string(),
runtime: GuestRuntimeKind::JavaScript,
adapter_entrypoint,
- args,
+ args: Vec::new(),
env,
cwd,
mcp_servers,
protocol_version: crate::ACP_PROTOCOL_VERSION,
client_capabilities,
+ additional_instructions: options.additional_instructions.clone(),
+ skip_os_instructions: options.skip_os_instructions,
}),
)
.await?;
@@ -1492,7 +1308,8 @@ impl AgentOs {
closed.retain(|id| id != session_id);
}
- let (event_tx, _) = tokio::sync::broadcast::channel(ACP_SESSION_EVENT_RETENTION_LIMIT.max(1));
+ let (event_tx, _) =
+ tokio::sync::broadcast::channel(ACP_SESSION_EVENT_RETENTION_LIMIT.max(1));
let (permission_tx, _) = tokio::sync::broadcast::channel(64);
let entry = SessionEntry {
agent_type: agent_type.to_string(),
@@ -1506,13 +1323,11 @@ impl AgentOs {
event_tx,
permission_tx,
pending_permission_replies: scc::HashMap::new(),
+ pending_session_request_lock: parking_lot::Mutex::new(()),
pending_prompt_resolvers: scc::HashMap::new(),
};
sync_session_state(&entry, state);
- let _ = self
- .inner()
- .sessions
- .insert(session_id.to_string(), entry);
+ let _ = self.inner().sessions.insert(session_id.to_string(), entry);
match self.hydrate_session_state(session_id).await {
Ok(()) => Ok(()),
@@ -1523,100 +1338,6 @@ impl AgentOs {
}
}
- /// Read OS instructions from `/etc/agentos/instructions.md` inside the VM, optionally appending
- /// session-level additional instructions. Port of TS `readVmInstructions` (tool-reference
- /// injection is a noted nuance not yet wired).
- async fn read_vm_instructions(
- &self,
- additional: Option<&str>,
- skip_base: bool,
- ) -> Result {
- let mut parts: Vec = Vec::new();
- if !skip_base {
- // OS instructions are best-effort: a VM whose base layer predates the
- // baked `/etc/agentos/instructions.md` (older sidecar) must not crash
- // session creation. Treat a missing file as "no base instructions",
- // matching the non-destructive, skip-able prompt-injection contract.
- match self.read_file("/etc/agentos/instructions.md").await {
- Ok(data) => parts.push(String::from_utf8_lossy(&data).into_owned()),
- Err(error) => {
- tracing::warn!(
- ?error,
- "skipping OS instructions: /etc/agentos/instructions.md not readable"
- );
- }
- }
- }
- if let Some(additional) = additional {
- if !additional.is_empty() {
- parts.push(additional.to_string());
- }
- }
- if parts.is_empty() {
- return Ok(String::new());
- }
- // Horizontal rule so agents can distinguish the injected prompt from host-appended content.
- parts.push("---".to_string());
- Ok(parts.join("\n\n"))
- }
-
- /// Per-agent `prepareInstructions` (port of TS `AGENT_CONFIGS[*].prepareInstructions`). Returns
- /// the launch args and env additions to apply. pi/pi-cli/claude/codex append the OS+session
- /// instructions as a prompt arg; opencode injects them as `OPENCODE_CONTEXTPATHS`.
- async fn prepare_instructions(
- &self,
- agent_type: &str,
- options: &CreateSessionOptions,
- ) -> Result<(Vec, BTreeMap)> {
- let skip_base = options.skip_os_instructions;
- match agent_type {
- "pi" | "pi-cli" | "claude" | "codex" => {
- let flag = if agent_type == "codex" {
- "--append-developer-instructions"
- } else {
- "--append-system-prompt"
- };
- if !skip_base || options.additional_instructions.is_some() {
- let instructions = self
- .read_vm_instructions(options.additional_instructions.as_deref(), skip_base)
- .await?;
- if !instructions.is_empty() {
- return Ok((vec![flag.to_string(), instructions], BTreeMap::new()));
- }
- }
- Ok((Vec::new(), BTreeMap::new()))
- }
- "opencode" => {
- let mut context_paths: Vec = if skip_base {
- Vec::new()
- } else {
- OPENCODE_CONTEXT_PATHS
- .iter()
- .map(|path| (*path).to_string())
- .collect()
- };
- if let Some(additional) = options.additional_instructions.as_deref() {
- if !additional.is_empty() {
- let path = "/tmp/agentos-additional-instructions.md";
- self.write_file(path, crate::fs::FileContent::Text(additional.to_string()))
- .await?;
- context_paths.push(path.to_string());
- }
- }
- if context_paths.is_empty() {
- return Ok((Vec::new(), BTreeMap::new()));
- }
- let mut env = BTreeMap::new();
- env.insert(
- "OPENCODE_CONTEXTPATHS".to_string(),
- serde_json::to_string(&context_paths).unwrap_or_default(),
- );
- Ok((Vec::new(), env))
- }
- _ => Ok((Vec::new(), BTreeMap::new())),
- }
- }
-
/// Resume an existing session. SYNC. Existence check + echo; no sidecar call.
pub fn resume_session(&self, session_id: &str) -> std::result::Result {
self.require_session(session_id, |_| ())?;
@@ -1650,28 +1371,9 @@ impl AgentOs {
(entry.event_tx.subscribe(), latest)
})?;
- // Collect `agent_message_chunk` text keyed by sequence number. A map (not a running string)
- // dedups chunks seen on both the live broadcast and the final ring reconciliation, and keeps
- // them in sequence order regardless of delivery order. Reconciling from the ring at the end is
- // required because a fast/short reply can land entirely in one chunk that is recorded during
- // the request's final hydration without ever reaching this no-replay broadcast subscription.
- let mut chunks: BTreeMap = BTreeMap::new();
- let accumulate = |event: &SequencedEvent, chunks: &mut BTreeMap| {
- if event.sequence_number <= start_after {
- return;
- }
- let params = event.notification.params.clone().unwrap_or(Value::Null);
- let update = params.get("update").cloned().unwrap_or(Value::Null);
- if update.get("sessionUpdate").and_then(Value::as_str) == Some("agent_message_chunk") {
- if let Some(chunk) = update
- .get("content")
- .and_then(|content| content.get("text"))
- .and_then(Value::as_str)
- {
- chunks.insert(event.sequence_number, chunk.to_string());
- }
- }
- };
+ let mut agent_text = String::new();
+ let mut delivered_chunk_sequences = BTreeSet::new();
+ let mut prompt_text_error: Option = None;
let request = self.send_session_request(
session_id,
@@ -1688,7 +1390,15 @@ impl AgentOs {
result = &mut request => break result,
event = rx.recv() => {
match event {
- Ok(event) => accumulate(&event, &mut chunks),
+ Ok(event) => accumulate_agent_message_chunk(
+ &event,
+ start_after,
+ &mut delivered_chunk_sequences,
+ &mut agent_text,
+ )
+ .unwrap_or_else(|error| {
+ prompt_text_error.get_or_insert(error);
+ }),
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
// Channel closed; finish the request without further chunks.
@@ -1705,7 +1415,15 @@ impl AgentOs {
// late (during the final hydrate) but not yet received are not dropped.
loop {
match rx.try_recv() {
- Ok(event) => accumulate(&event, &mut chunks),
+ Ok(event) => accumulate_agent_message_chunk(
+ &event,
+ start_after,
+ &mut delivered_chunk_sequences,
+ &mut agent_text,
+ )
+ .unwrap_or_else(|error| {
+ prompt_text_error.get_or_insert(error);
+ }),
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::TryRecvError::Empty)
| Err(tokio::sync::broadcast::error::TryRecvError::Closed) => break,
@@ -1713,23 +1431,28 @@ impl AgentOs {
}
drop(rx);
+ let response = response?;
// Reconcile from the authoritative event ring: a short reply can be recorded entirely during
- // the request's final hydration, after the broadcast subscription stopped delivering. The map
- // dedups by sequence, so chunks already seen on the broadcast are not double-counted.
- if let Ok(ring_events) = self.get_session_events(
- session_id,
- GetEventsOptions {
- since: Some(start_after),
- method: None,
- },
- ) {
- for event in &ring_events {
- accumulate(event, &mut chunks);
- }
+ // the request's final hydration, after the broadcast subscription stopped delivering. The
+ // accumulator dedups by sequence, so chunks already seen on the broadcast are not double-counted.
+ let hydrated_events = self.require_session(session_id, |entry| {
+ entry.event_ring.lock().iter().cloned().collect::>()
+ })?;
+ for event in &hydrated_events {
+ accumulate_agent_message_chunk(
+ event,
+ start_after,
+ &mut delivered_chunk_sequences,
+ &mut agent_text,
+ )
+ .unwrap_or_else(|error| {
+ prompt_text_error.get_or_insert(error);
+ });
+ }
+ if let Some(error) = prompt_text_error {
+ return Err(error.into());
}
- let response = response?;
- let agent_text: String = chunks.into_values().collect();
Ok(PromptResult {
response,
text: agent_text,
@@ -1815,9 +1538,7 @@ impl AgentOs {
fn abort_pending_session_requests(&self, session_id: &str) {
let _ = self.require_session(session_id, |entry| {
let mut ids = Vec::new();
- entry
- .pending_prompt_resolvers
- .scan(|id, _| ids.push(*id));
+ entry.pending_prompt_resolvers.scan(|id, _| ids.push(*id));
for id in ids {
if let Some((_, resolver)) = entry.pending_prompt_resolvers.remove(&id) {
// Mirrors `_abortPendingSessionRequests`: resolve EVERY pending resolver
@@ -2036,7 +1757,7 @@ impl AgentOs {
}
/// Set the session model. Uses `set_config_option` with category `model`; readonly -> error
- /// response; codex `-32601` fallback synthesizes a negative-seq update.
+ /// response.
pub async fn set_session_model(
&self,
session_id: &str,
@@ -2088,7 +1809,9 @@ impl AgentOs {
method: &str,
params: Option,
) -> Result {
- Ok(self.send_session_request(session_id, method, params).await?)
+ Ok(self
+ .send_session_request(session_id, method, params)
+ .await?)
}
/// Thin alias for `raw_session_send`.
@@ -2107,10 +1830,7 @@ impl AgentOs {
pub fn on_session_event(
&self,
session_id: &str,
- ) -> std::result::Result<
- (Pin + Send>>, Subscription),
- ClientError,
- > {
+ ) -> std::result::Result {
let (buffered, rx) = self.require_session(session_id, |entry| {
let ring = entry.event_ring.lock();
let buffered: VecDeque = ring
@@ -2126,26 +1846,21 @@ impl AgentOs {
Ok((Box::pin(mapped), Subscription::noop()))
}
- /// Subscribe to a session's permission requests (request/responder). No subscribers -> auto
- /// reject; 120s timeout; both `request/permission` (legacy) and `session/request_permission`
- /// (ACP) method names are handled; the host answers via `respond_permission`.
- ///
- /// Each emitted [`PermissionRequest`] carries a `responder` oneshot. The matching
- /// `pending_permission_replies` slot is registered with a 120s timeout that auto-removes the
- /// entry on expiry. The constant is [`PERMISSION_TIMEOUT_MS`].
+ /// Subscribe to permission requests raised by the session's guest agent. Requests originate
+ /// from the sidecar `permission_request` callback (the sidecar normalizes both the legacy
+ /// `request/permission` and ACP `session/request_permission` method names before invoking the
+ /// host). With no subscribers a request auto-rejects; subscribers reply via the carried
+ /// [`PermissionResponder`] or [`AgentOs::respond_permission`], bounded by the
+ /// [`crate::PERMISSION_TIMEOUT_MS`] timeout.
pub fn on_permission_request(
&self,
session_id: &str,
- ) -> std::result::Result<
- (Pin + Send>>, Subscription),
- ClientError,
- > {
+ ) -> std::result::Result {
let rx = self.require_session(session_id, |entry| entry.permission_tx.subscribe())?;
- // Pass broadcast items straight through. Each item carries a Clone-able
- // [`PermissionResponder`]; the reply slot + 120s timeout are armed by
- // [`AgentOs::deliver_permission_request`] at ingestion time, and `respond_permission`
- // resolves the same slot.
+ // Pass broadcast items straight through. Each item carries a cloneable
+ // [`PermissionResponder`] that resolves the pending reply slot registered by
+ // `deliver_sidecar_permission_request`.
let stream = futures::stream::unfold(rx, move |mut rx| async move {
loop {
match rx.recv().await {
@@ -2159,102 +1874,105 @@ impl AgentOs {
Ok((Box::pin(stream), Subscription::noop()))
}
- /// Deliver an inbound permission request to a session's subscribers, registering its reply slot
- /// into `pending_permission_replies` with a 120s ([`PERMISSION_TIMEOUT_MS`]) timeout that
- /// auto-rejects on expiry. When there are no subscribers the request auto-rejects immediately.
- ///
- /// Invoked by the sidecar event/request handler for both `request/permission` (legacy) and
- /// `session/request_permission` (ACP). The returned [`PermissionDelivery`] carries the settled
- /// [`PermissionReply`] and the path-appropriate handler `result`:
- /// - ACP path (`session/request_permission`) returns `_buildAcpPermissionResult(reply, params)`
- /// = `{ outcome: { outcome: "selected", optionId } }`, mirroring `_handleAcpPermissionRequest`.
- /// - legacy path (`request/permission`) returns the bare `{ reply }`, mirroring
- /// `_handlePermissionSidecarRequest`'s `{ reply }`.
- ///
- /// On the no-subscriber / timeout path the reply is `Reject`, and the ACP result is built from
- /// `reject` (mirroring `_buildAcpPermissionResult("reject", ...)`).
- pub(crate) async fn deliver_permission_request(
+ /// Answer a sidecar-initiated permission request (`SidecarRequestPayload::PermissionRequest`)
+ /// by fanning a [`PermissionRequest`] out to `on_permission_request` subscribers and waiting for
+ /// the reply. Mirrors TS `_handlePermissionSidecarRequest`:
+ /// - unknown session -> `error: "Session not found: "`
+ /// - no subscribers -> `reply: "reject"`
+ /// - otherwise registers the `pending_permission_replies` slot, delivers the request, and waits
+ /// up to [`crate::PERMISSION_TIMEOUT_MS`] for `respond_permission` / the responder; timeout
+ /// removes the slot and returns `error: "Timed out waiting for permission reply: "`.
+ pub(crate) async fn deliver_sidecar_permission_request(
&self,
- session_id: &str,
- notification: &JsonRpcNotification,
- ) -> PermissionDelivery {
- let is_acp = notification.method == ACP_PERMISSION_METHOD;
- let Some((permission_id, request, delivered_params, responder_rx)) =
- build_permission_request(notification)
- else {
- return PermissionDelivery::new(PermissionReply::Reject, is_acp, &Value::Null);
- };
+ request: SidecarPermissionRequest,
+ ) -> SidecarPermissionResultResponse {
+ let SidecarPermissionRequest {
+ session_id,
+ permission_id,
+ params,
+ } = request;
- // Register the reply slot so `respond_permission` can resolve it directly.
let (slot_tx, slot_rx) = tokio::sync::oneshot::channel::();
- let registered = self.require_session(session_id, |entry| {
- // No subscribers -> auto-reject (mirrors `permissionHandlers.size === 0`).
+ let (responder, responder_rx) = PermissionResponder::new();
+ let description = params
+ .get("description")
+ .and_then(Value::as_str)
+ .map(str::to_string);
+ let delivered = PermissionRequest {
+ permission_id: permission_id.clone(),
+ description,
+ params,
+ responder,
+ };
+
+ // Register the reply slot and broadcast under the same session lookup. No subscribers ->
+ // auto-reject (mirrors `permissionHandlers.size === 0`).
+ let registered = self.require_session(&session_id, |entry| {
if entry.permission_tx.receiver_count() == 0 {
return false;
}
let _ = entry
.pending_permission_replies
.insert(permission_id.clone(), slot_tx);
- let _ = entry.permission_tx.send(request);
+ let _ = entry.permission_tx.send(delivered);
true
});
-
match registered {
Ok(true) => {}
- Ok(false) | Err(_) => {
- return PermissionDelivery::new(PermissionReply::Reject, is_acp, &delivered_params)
+ Ok(false) => {
+ return SidecarPermissionResultResponse {
+ permission_id,
+ reply: Some(permission_reply_wire(PermissionReply::Reject).to_string()),
+ error: None,
+ };
+ }
+ Err(_) => {
+ return SidecarPermissionResultResponse {
+ permission_id: permission_id.clone(),
+ reply: None,
+ error: Some(format!("Session not found: {session_id}")),
+ };
}
}
// Bridge the subscriber's `responder.respond(..)` into the same reply slot.
let this = self.clone();
- let session_owned = session_id.to_string();
- let permission_owned = permission_id.clone();
+ let bridge_session_id = session_id.clone();
+ let bridge_permission_id = permission_id.clone();
tokio::spawn(async move {
if let Ok(reply) = responder_rx.await {
let _ = this
- .respond_permission(&session_owned, &permission_owned, reply)
+ .respond_permission(&bridge_session_id, &bridge_permission_id, reply)
.await;
}
});
- // Await the host reply, the subscriber responder (via the bridge above), or the 120s
- // timeout, whichever fires first.
- let timeout =
- tokio::time::sleep(std::time::Duration::from_millis(PERMISSION_TIMEOUT_MS));
+ let timeout = tokio::time::sleep(std::time::Duration::from_millis(PERMISSION_TIMEOUT_MS));
tokio::pin!(timeout);
- let reply = tokio::select! {
- reply = slot_rx => reply.unwrap_or(PermissionReply::Reject),
+ tokio::select! {
+ reply = slot_rx => match reply {
+ Ok(reply) => SidecarPermissionResultResponse {
+ permission_id,
+ reply: Some(permission_reply_wire(reply).to_string()),
+ error: None,
+ },
+ // The slot sender dropped without a reply (session closed / replies rejected).
+ Err(_) => SidecarPermissionResultResponse {
+ permission_id,
+ reply: Some(permission_reply_wire(PermissionReply::Reject).to_string()),
+ error: None,
+ },
+ },
_ = &mut timeout => {
- let _ = self.require_session(session_id, |entry| {
+ let _ = self.require_session(&session_id, |entry| {
let _ = entry.pending_permission_replies.remove(&permission_id);
});
- PermissionReply::Reject
+ SidecarPermissionResultResponse {
+ permission_id: permission_id.clone(),
+ reply: None,
+ error: Some(format!("Timed out waiting for permission reply: {permission_id}")),
+ }
}
- };
- PermissionDelivery::new(reply, is_acp, &delivered_params)
- }
-}
-
-/// The settled outcome of [`AgentOs::deliver_permission_request`], carrying both the resolved
-/// [`PermissionReply`] and the path-appropriate JSON-RPC handler `result` (the ACP `outcome` object
-/// for the ACP path, or the bare `{ reply }` for the legacy sidecar path).
-#[derive(Debug, Clone, PartialEq)]
-pub struct PermissionDelivery {
- /// The settled reply (host answer, or `Reject` on no-subscriber / timeout).
- pub reply: PermissionReply,
- /// The handler result to return on the wire (ACP outcome vs bare `{ reply }`).
- pub result: Value,
-}
-
-impl PermissionDelivery {
- fn new(reply: PermissionReply, is_acp: bool, params: &Value) -> Self {
- let result = if is_acp {
- build_acp_permission_result(reply, params)
- } else {
- json!({ "reply": reply })
- };
- Self { reply, result }
+ }
}
}
-
diff --git a/crates/client/src/shell.rs b/crates/client/src/shell.rs
index 644dc9f61..cae9d65da 100644
--- a/crates/client/src/shell.rs
+++ b/crates/client/src/shell.rs
@@ -19,9 +19,9 @@
//! does not implement.
use std::collections::BTreeMap;
-use std::sync::atomic::Ordering;
+use std::sync::atomic::{AtomicUsize, Ordering};
-use anyhow::{Context, Result};
+use anyhow::Result;
use uuid::Uuid;
use agent_os_sidecar::protocol::{
@@ -29,14 +29,17 @@ use agent_os_sidecar::protocol::{
RejectedResponse, RequestPayload, ResponsePayload, StreamChannel, WriteStdinRequest,
};
-use crate::agent_os::{AgentOs, ShellEntry};
+use crate::agent_os::{AcpTerminalEntry, AgentOs, ShellEntry};
use crate::error::ClientError;
-use crate::process::{install_output_callback, OutputCallback, StdinInput};
+use crate::process::{OutputCallback, ProcessStatus, StdinInput, install_output_callback};
use crate::stream::ByteStream;
/// Channel capacity for a shell's data / stderr broadcasts.
const SHELL_DATA_CHANNEL_CAPACITY: usize = 1024;
+/// Maximum active or spawning terminals created by `connect_terminal` per VM.
+const ACP_TERMINAL_LIMIT: usize = 1024;
+
/// Default shell command used when [`OpenShellOptions::command`] is omitted (matches the kernel's
/// PTY-backed `sh`).
const DEFAULT_SHELL_COMMAND: &str = "sh";
@@ -100,6 +103,51 @@ fn stdin_chunk(data: StdinInput) -> Vec {
}
}
+fn try_reserve_counter(counter: &AtomicUsize, limit: usize) -> bool {
+ counter
+ .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
+ (count < limit).then_some(count + 1)
+ })
+ .is_ok()
+}
+
+fn release_counter(counter: &AtomicUsize) {
+ let _ = counter.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
+ Some(count.saturating_sub(1))
+ });
+}
+
+struct AcpTerminalReservation<'a> {
+ agent: &'a AgentOs,
+ active: bool,
+}
+
+impl<'a> AcpTerminalReservation<'a> {
+ fn new(agent: &'a AgentOs) -> std::result::Result {
+ if !try_reserve_counter(&agent.inner().acp_terminal_count, ACP_TERMINAL_LIMIT) {
+ return Err(ClientError::Sidecar(format!(
+ "acp terminal limit exceeded: at most {ACP_TERMINAL_LIMIT} terminals can be active per VM"
+ )));
+ }
+ Ok(Self {
+ agent,
+ active: true,
+ })
+ }
+
+ fn disarm(&mut self) {
+ self.active = false;
+ }
+}
+
+impl Drop for AcpTerminalReservation<'_> {
+ fn drop(&mut self) {
+ if self.active {
+ release_counter(&self.agent.inner().acp_terminal_count);
+ }
+ }
+}
+
impl AgentOs {
/// The VM-scoped ownership scope used for every shell/fetch wire request.
fn vm_ownership(&self) -> OwnershipScope {
@@ -109,6 +157,62 @@ impl AgentOs {
self.vm_id().to_string(),
)
}
+
+ pub(crate) fn finish_acp_terminal(&self, process_id: &str) {
+ if self.inner().acp_terminals.remove(process_id).is_some() {
+ release_counter(&self.inner().acp_terminal_count);
+ }
+ }
+
+ async fn start_acp_terminal(
+ &self,
+ execute: ExecuteRequest,
+ ownership: OwnershipScope,
+ pid_tx: tokio::sync::oneshot::Sender>,
+ process_id: &str,
+ ) -> Option {
+ {
+ let _terminal_lifecycle_guard = self.inner().acp_terminal_lifecycle_lock.lock().await;
+ if self.inner().disposed.load(Ordering::SeqCst) {
+ let error = ClientError::Sidecar(
+ "cannot connect terminal after VM shutdown has started".to_string(),
+ );
+ let _ = pid_tx.send(Err(error));
+ self.finish_acp_terminal(process_id);
+ return None;
+ }
+ }
+
+ let result = match self
+ .transport()
+ .request(ownership, RequestPayload::Execute(execute))
+ .await
+ {
+ Ok(ResponsePayload::ProcessStarted(ProcessStartedResponse { pid, .. })) => pid
+ .ok_or_else(|| {
+ ClientError::Sidecar(
+ "connect_terminal: sidecar did not return a pid".to_string(),
+ )
+ }),
+ Ok(ResponsePayload::Rejected(rejected)) => Err(rejected_to_error(rejected)),
+ Ok(other) => Err(ClientError::Sidecar(format!(
+ "unexpected response to connect_terminal: {other:?}"
+ ))),
+ Err(error) => Err(error),
+ };
+
+ match result {
+ Ok(pid) => {
+ let _ = pid_tx.send(Ok(pid));
+ Some(pid)
+ }
+ Err(error) => {
+ let _ = pid_tx.send(Err(error));
+ self.finish_acp_terminal(process_id);
+ None
+ }
+ }
+ }
}
// ---------------------------------------------------------------------------
@@ -203,13 +307,14 @@ impl AgentOs {
// Record the real kernel pid on the entry (TS `ShellHandle.pid`) and release the write
// gate so any queued `write_shell`/`close_shell` proceed against the live spawn.
- if let ResponsePayload::ProcessStarted(ProcessStartedResponse { pid, .. }) = response {
- if let Some(pid) = pid {
- agent
- .inner()
- .shells
- .update(&exit_shell_id, |_, existing| existing.pid = pid);
- }
+ if let ResponsePayload::ProcessStarted(ProcessStartedResponse {
+ pid: Some(pid), ..
+ }) = response
+ {
+ agent
+ .inner()
+ .shells
+ .update(&exit_shell_id, |_, existing| existing.pid = pid);
}
let _ = spawned_tx.send(true);
@@ -246,12 +351,9 @@ impl AgentOs {
// The `.finally` equivalent: remove from both the tracking set and the shells map (only
// if it is still our entry, matching the TS identity check).
agent.inner().pending_shell_exits.remove(&exit_key);
- agent
- .inner()
- .shells
- .remove_if(&exit_shell_id, |existing| {
- existing.process_id == route_process_id
- });
+ agent.inner().shells.remove_if(&exit_shell_id, |existing| {
+ existing.process_id == route_process_id
+ });
// remove_if takes `&mut V`; the comparison only reads, which is fine.
});
@@ -261,7 +363,7 @@ impl AgentOs {
}
/// 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 pid set.
+ /// be addressed by other shell methods. Killed during dispose via the ACP-terminal registry.
///
/// Mirrors the TS `connectTerminal`, which routes its `onData`/`onStderr` callbacks through
/// `openShell`. The Rust port opens a shell, wires the caller's `on_data` to the shell's data
@@ -302,28 +404,33 @@ impl AgentOs {
};
// Subscribe before issuing the spawn so no output is missed.
- let mut events = self.transport().subscribe_events();
- let response = self
- .transport()
- .request(self.vm_ownership(), RequestPayload::Execute(execute))
- .await
- .context("connect_terminal spawn failed")?;
-
- let pid = match response {
- ResponsePayload::ProcessStarted(ProcessStartedResponse { pid, .. }) => {
- pid.context("connect_terminal: sidecar did not return a pid")?
+ let events = self.transport().subscribe_events();
+ let ownership = self.vm_ownership();
+ let (pid_tx, pid_rx) = tokio::sync::oneshot::channel();
+ let (start_tx, start_rx) = tokio::sync::oneshot::channel::<()>();
+ let agent = self.clone();
+ let route_process_id = process_id.clone();
+ let exit_task = tokio::spawn(async move {
+ if start_rx.await.is_err() {
+ return;
}
- ResponsePayload::Rejected(rejected) => return Err(rejected_to_error(rejected).into()),
- _ => anyhow::bail!("unexpected response to connect_terminal"),
- };
-
- // Fan terminal output to the caller's onData/onStderr sinks until the process exits.
- let route_process_id = process_id;
- tokio::spawn(async move {
+ let terminal_pid = match agent
+ .start_acp_terminal(execute, ownership, pid_tx, &route_process_id)
+ .await
+ {
+ Some(pid) => pid,
+ None => return,
+ };
+ let mut events = events;
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::Lagged(_)) => {
+ if terminal_process_finished(&agent, terminal_pid).await {
+ break;
+ }
+ continue;
+ }
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
};
match payload {
@@ -348,12 +455,51 @@ impl AgentOs {
EventPayload::VmLifecycle(_) | EventPayload::Structured(_) => {}
}
}
+ agent.finish_acp_terminal(&route_process_id);
});
- // NOT tracked in `_shells`; recorded for dispose-time terminal teardown only.
- let _ = self.inner().acp_terminal_pids.insert(pid);
+ {
+ let _terminal_lifecycle_guard = self.inner().acp_terminal_lifecycle_lock.lock().await;
+ if self.inner().disposed.load(Ordering::SeqCst) {
+ exit_task.abort();
+ return Err(ClientError::Sidecar(
+ "cannot connect terminal after VM shutdown has started".to_string(),
+ )
+ .into());
+ }
+ let mut terminal_reservation = AcpTerminalReservation::new(self)?;
+ match self
+ .inner()
+ .acp_terminals
+ .insert(process_id.clone(), AcpTerminalEntry { exit_task })
+ {
+ Ok(()) => {}
+ Err((_, entry)) => {
+ entry.exit_task.abort();
+ return Err(ClientError::Sidecar(format!(
+ "terminal process id collision while tracking ACP terminal: {process_id}"
+ ))
+ .into());
+ }
+ }
+ terminal_reservation.disarm();
+ if start_tx.send(()).is_err() {
+ self.finish_acp_terminal(&process_id);
+ return Err(ClientError::Sidecar(
+ "terminal startup task ended before registration completed".to_string(),
+ )
+ .into());
+ }
+ }
- Ok(pid)
+ pid_rx
+ .await
+ .map_err(|_| {
+ ClientError::Sidecar(
+ "terminal startup task ended before returning a pid".to_string(),
+ )
+ })?
+ .map_err(Into::into)
}
/// Write to a shell. SYNC fire-and-forget. Errors with [`ClientError::ShellNotFound`].
@@ -385,10 +531,7 @@ impl AgentOs {
/// Subscribe to a shell's stdout data. SYNC register; multi-handler; dropping the returned stream
/// is the unsubscribe. Carries stdout ONLY (stderr is on `on_shell_stderr`). Errors with
/// [`ClientError::ShellNotFound`].
- pub fn on_shell_data(
- &self,
- shell_id: &str,
- ) -> std::result::Result {
+ pub fn on_shell_data(&self, shell_id: &str) -> std::result::Result {
self.inner()
.shells
.read(shell_id, |_, entry| entry.data_tx.subscribe())
@@ -399,10 +542,7 @@ impl AgentOs {
/// Subscribe to a shell's stderr. SYNC register; multi-handler; dropping the returned stream is
/// the unsubscribe. This is the dedicated stderr channel backing the TS `onStderr` option; stderr
/// is never fanned into `on_shell_data`. Errors with [`ClientError::ShellNotFound`].
- pub fn on_shell_stderr(
- &self,
- shell_id: &str,
- ) -> std::result::Result {
+ pub fn on_shell_stderr(&self, shell_id: &str) -> std::result::Result {
self.inner()
.shells
.read(shell_id, |_, entry| entry.stderr_tx.subscribe())
@@ -485,3 +625,32 @@ async fn wait_for_spawn(mut spawned_rx: tokio::sync::watch::Receiver) {
}
}
}
+
+async fn terminal_process_finished(agent: &AgentOs, pid: u32) -> bool {
+ match agent.all_processes().await {
+ Ok(processes) => match processes.into_iter().find(|process| process.pid == pid) {
+ Some(process) => process.status != ProcessStatus::Running,
+ None => true,
+ },
+ Err(error) => {
+ tracing::warn!(?error, pid, "terminal process snapshot failed");
+ false
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn reserve_counter_enforces_limit_and_release_reopens_slot() {
+ let counter = AtomicUsize::new(0);
+
+ assert!(try_reserve_counter(&counter, 2));
+ assert!(try_reserve_counter(&counter, 2));
+ assert!(!try_reserve_counter(&counter, 2));
+ release_counter(&counter);
+ assert!(try_reserve_counter(&counter, 2));
+ }
+}
diff --git a/crates/client/src/sidecar.rs b/crates/client/src/sidecar.rs
index c57cb97e8..dfabda25c 100644
--- a/crates/client/src/sidecar.rs
+++ b/crates/client/src/sidecar.rs
@@ -1,12 +1,11 @@
//! `AgentOsSidecar` (public transport handle) + placement/description + the process-global shared
-//! pool + internal lease/vm-admin.
+//! pool + internal lease accounting.
//!
-//! Ported from `packages/core/src/agent-os.ts` (`AgentOsSidecar`) and the internal vm-admin layer.
-//! The shared-sidecar pool is a process-global map (default pool `"default"`); `create_vm` /
-//! `get_vm_admin` / `dispose_vm` are internal and never public on `AgentOs`.
+//! Ported from `packages/core/src/agent-os.ts` (`AgentOsSidecar`). The shared-sidecar pool is a
+//! process-global map (default pool `"default"`).
-use std::sync::atomic::{AtomicU32, AtomicU8, Ordering};
use std::sync::Arc;
+use std::sync::atomic::{AtomicU8, AtomicU32, Ordering};
use once_cell::sync::OnceCell;
use scc::HashMap as SccHashMap;
@@ -21,6 +20,9 @@ use crate::agent_os::AgentOs;
use crate::error::ClientError;
use crate::transport::SidecarTransport;
+/// Maximum shared sidecar pool entries retained process-wide.
+const SHARED_SIDECAR_POOL_LIMIT: usize = 1024;
+
/// 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 {
@@ -174,7 +176,11 @@ impl AgentOsSidecar {
message: rejected.message,
});
}
- _ => return Err(ClientError::Sidecar("unexpected authenticate response".to_string())),
+ _ => {
+ return Err(ClientError::Sidecar(
+ "unexpected authenticate response".to_string(),
+ ));
+ }
};
let max_frame = authed.max_frame_bytes as usize;
transport.max_frame_bytes.store(max_frame, Ordering::SeqCst);
@@ -232,19 +238,17 @@ impl AgentOsSidecar {
let errors: Vec = Vec::new();
// Parity note: TypeScript iterates `state.activeLeases` here and aggregates per-lease
- // disposal errors. Active leases are owned by `AgentOs` (via
- // `AgentOsInner.sidecar_lease`) and are released through `AgentOsSidecarVmLease::dispose`
- // during `AgentOs::shutdown`. The shared active-lease registry is part of the
- // create_vm / vm-admin transport layer, which is not yet wired (see the `SidecarVmAdmin`
- // TODO above). Once that lands, drain it here and push any disposal errors into `errors`.
+ // disposal errors. Active leases are owned by `AgentOs` and are released through
+ // `AgentOsSidecarVmLease::dispose` during `AgentOs::shutdown`.
self.active_vm_count.store(0, Ordering::SeqCst);
self.state
.store(SidecarState::Disposed.as_u8(), Ordering::SeqCst);
if let Some(pool) = self.shared_pool.as_deref() {
// Only remove the cached entry if it still points at this exact sidecar instance.
- let self_id = self.sidecar_id.as_str();
- let _ = shared_sidecars().remove_if(pool, |cached| cached.sidecar_id == self_id);
+ let self_ptr = self as *const AgentOsSidecar;
+ let _ = shared_sidecars()
+ .remove_if(pool, |cached| std::ptr::eq(Arc::as_ptr(cached), self_ptr));
}
if errors.is_empty() {
@@ -265,16 +269,9 @@ impl AgentOsSidecar {
}
}
-/// Internal VM admin held behind a lease. Not public.
-pub(crate) trait SidecarVmAdmin: Send + Sync {
- // TODO(parity: model the vm-admin surface: kernel/rootView/mounts/sidecar session, etc.).
-}
-
-/// A lease over a VM admin; released on `AgentOs` dispose.
+/// A lease over a VM; released on `AgentOs` dispose.
pub(crate) struct AgentOsSidecarVmLease {
- pub(crate) vm_id: String,
pub(crate) sidecar: Arc,
- // TODO(parity: hold the admin + release wiring).
}
impl AgentOsSidecarVmLease {
@@ -286,9 +283,6 @@ impl AgentOsSidecarVmLease {
/// cannot be disposed twice). The active-vm count is decremented (saturating at 0) to mirror
/// `state.description.activeVmCount = state.activeLeases.size`.
///
- /// Parity note: the underlying session/transport `client.dispose()` is part of the create_vm /
- /// vm-admin transport layer, which is not yet wired (see the `SidecarVmAdmin` TODO above). Once
- /// that lands, dispose the held admin/client here and surface any error.
pub(crate) async fn dispose(self) -> Result<(), ClientError> {
let sidecar = self.sidecar;
// Mirror `activeVmCount = activeLeases.size` by decrementing, never underflowing past 0.
@@ -311,12 +305,55 @@ impl AgentOsSidecarVmLease {
/// Process-global shared-sidecar pool, keyed by pool name (default `"default"`).
static SHARED_SIDECARS: OnceCell>> = OnceCell::new();
+static SHARED_SIDECAR_POOL_LOCK: OnceCell> = OnceCell::new();
/// Access (initializing on first use) the process-global shared-sidecar pool.
pub(crate) fn shared_sidecars() -> &'static SccHashMap> {
SHARED_SIDECARS.get_or_init(SccHashMap::new)
}
+fn shared_sidecar_pool_lock() -> &'static parking_lot::Mutex<()> {
+ SHARED_SIDECAR_POOL_LOCK.get_or_init(parking_lot::Mutex::default)
+}
+
+fn shared_sidecar_pool_len(cache: &SccHashMap>) -> usize {
+ let mut len = 0;
+ cache.scan(|_, _| {
+ len += 1;
+ });
+ len
+}
+
+fn prune_disposed_shared_sidecars(cache: &SccHashMap>) {
+ let mut disposed_pools = Vec::new();
+ cache.scan(|pool, sidecar| {
+ if sidecar.describe().state == SidecarState::Disposed {
+ disposed_pools.push(pool.clone());
+ }
+ });
+ for pool in disposed_pools {
+ let _ = cache.remove_if(&pool, |sidecar| {
+ sidecar.describe().state == SidecarState::Disposed
+ });
+ }
+}
+
+#[cfg(test)]
+fn ensure_shared_sidecar_pool_capacity(
+ cache: &SccHashMap>,
+) -> Result<(), ClientError> {
+ if shared_sidecar_pool_len(cache) >= SHARED_SIDECAR_POOL_LIMIT {
+ return Err(shared_sidecar_pool_limit_error());
+ }
+ Ok(())
+}
+
+fn shared_sidecar_pool_limit_error() -> ClientError {
+ ClientError::Sidecar(format!(
+ "shared sidecar pool limit exceeded: at most {SHARED_SIDECAR_POOL_LIMIT} pools can be cached"
+ ))
+}
+
impl AgentOs {
/// Create an explicit sidecar handle. `sidecar_id` defaults to `agent-os-sidecar-`.
///
@@ -325,7 +362,8 @@ impl AgentOs {
pub async fn create_sidecar(
sidecar_id: Option,
) -> Result, ClientError> {
- let sidecar_id = sidecar_id.unwrap_or_else(|| format!("agent-os-sidecar-{}", Uuid::new_v4()));
+ let sidecar_id =
+ sidecar_id.unwrap_or_else(|| format!("agent-os-sidecar-{}", Uuid::new_v4()));
let placement = AgentOsSidecarPlacement::Explicit {
sidecar_id: sidecar_id.clone(),
};
@@ -346,6 +384,7 @@ impl AgentOs {
) -> Result, ClientError> {
let pool = pool.unwrap_or_else(|| "default".to_string());
let cache = shared_sidecars();
+ let _guard = shared_sidecar_pool_lock().lock();
// Fast path: reuse a cached, non-disposed sidecar for this pool.
if let Some(existing) = cache.read(&pool, |_, sidecar| sidecar.clone()) {
@@ -353,6 +392,7 @@ impl AgentOs {
return Ok(existing);
}
}
+ prune_disposed_shared_sidecars(cache);
// Parity: TypeScript builds placement `{ kind: "shared", ...(pool ? { pool } : {}) }`, so an
// empty-string pool (a non-nullish value that survives `?? "default"`) is OMITTED from the
@@ -373,6 +413,7 @@ impl AgentOs {
// Insert atomically, replacing a stale (disposed) entry but yielding to a live one that a
// concurrent caller may have just installed.
+ let cache_len = shared_sidecar_pool_len(cache);
match cache.entry(pool) {
scc::hash_map::Entry::Occupied(mut occupied) => {
if occupied.get().describe().state == SidecarState::Disposed {
@@ -383,9 +424,127 @@ impl AgentOs {
}
}
scc::hash_map::Entry::Vacant(vacant) => {
+ if cache_len >= SHARED_SIDECAR_POOL_LIMIT {
+ return Err(shared_sidecar_pool_limit_error());
+ }
vacant.insert_entry(sidecar.clone());
Ok(sidecar)
}
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn shared(pool: &str, state: SidecarState) -> Arc {
+ let sidecar = Arc::new(AgentOsSidecar::new(
+ format!("agent-os-shared-sidecar:{pool}"),
+ AgentOsSidecarPlacement::Shared {
+ pool: Some(pool.to_string()),
+ },
+ Some(pool.to_string()),
+ None,
+ ));
+ sidecar.state.store(state.as_u8(), Ordering::SeqCst);
+ sidecar
+ }
+
+ #[test]
+ fn prune_disposed_shared_sidecars_keeps_live_entries() {
+ let cache = SccHashMap::new();
+ let _ = cache.insert("live".to_string(), shared("live", SidecarState::Ready));
+ let _ = cache.insert(
+ "disposed".to_string(),
+ shared("disposed", SidecarState::Disposed),
+ );
+
+ prune_disposed_shared_sidecars(&cache);
+
+ assert_eq!(shared_sidecar_pool_len(&cache), 1);
+ assert!(cache.read("live", |_, _| ()).is_some());
+ assert!(cache.read("disposed", |_, _| ()).is_none());
+ }
+
+ #[test]
+ fn shared_sidecar_pool_capacity_rejects_full_live_cache() {
+ let cache = SccHashMap::new();
+ for index in 0..SHARED_SIDECAR_POOL_LIMIT {
+ let pool = format!("pool-{index}");
+ let _ = cache.insert(pool.clone(), shared(&pool, SidecarState::Ready));
+ }
+
+ let error =
+ ensure_shared_sidecar_pool_capacity(&cache).expect_err("full cache should reject");
+
+ assert!(
+ error
+ .to_string()
+ .contains("shared sidecar pool limit exceeded"),
+ "unexpected error: {error}"
+ );
+ }
+
+ #[test]
+ fn shared_sidecar_pool_capacity_allows_after_pruning_disposed_entries() {
+ let cache = SccHashMap::new();
+ for index in 0..SHARED_SIDECAR_POOL_LIMIT {
+ let pool = format!("pool-{index}");
+ let state = if index == 0 {
+ SidecarState::Disposed
+ } else {
+ SidecarState::Ready
+ };
+ let _ = cache.insert(pool.clone(), shared(&pool, state));
+ }
+
+ prune_disposed_shared_sidecars(&cache);
+
+ ensure_shared_sidecar_pool_capacity(&cache).expect("pruned cache should admit one entry");
+ assert_eq!(
+ shared_sidecar_pool_len(&cache),
+ SHARED_SIDECAR_POOL_LIMIT - 1
+ );
+ }
+
+ #[tokio::test]
+ async fn get_shared_sidecar_inserts_vacant_pool_without_reentrant_scan() {
+ let pool = format!("unit-{}", Uuid::new_v4());
+ let sidecar = AgentOs::get_shared_sidecar(Some(pool.clone()), None)
+ .await
+ .expect("shared sidecar");
+
+ assert_eq!(sidecar.shared_pool.as_deref(), Some(pool.as_str()));
+
+ sidecar.dispose().await.expect("dispose shared sidecar");
+ }
+
+ #[test]
+ fn dispose_removes_only_same_shared_sidecar_instance() {
+ let pool = format!("dispose-race-{}", Uuid::new_v4());
+ let old = shared(&pool, SidecarState::Ready);
+ let replacement = shared(&pool, SidecarState::Ready);
+ let cache = shared_sidecars();
+ let _guard = shared_sidecar_pool_lock().lock();
+
+ let _ = cache.insert(pool.clone(), replacement.clone());
+ old.state
+ .store(SidecarState::Disposing.as_u8(), Ordering::SeqCst);
+ old.active_vm_count.store(0, Ordering::SeqCst);
+ old.state
+ .store(SidecarState::Disposed.as_u8(), Ordering::SeqCst);
+ let old_ptr = Arc::as_ptr(&old);
+ let _ = cache.remove_if(&pool, |cached| std::ptr::eq(Arc::as_ptr(cached), old_ptr));
+
+ let cached = cache
+ .read(&pool, |_, cached| cached.clone())
+ .expect("replacement should remain cached");
+ assert!(Arc::ptr_eq(&cached, &replacement));
+
+ let replacement_ptr = Arc::as_ptr(&replacement);
+ let _ = cache.remove_if(&pool, |cached| {
+ std::ptr::eq(Arc::as_ptr(cached), replacement_ptr)
+ });
+ }
+}
diff --git a/crates/client/src/stream.rs b/crates/client/src/stream.rs
index 1fee791c2..76ef76284 100644
--- a/crates/client/src/stream.rs
+++ b/crates/client/src/stream.rs
@@ -23,6 +23,9 @@ use tokio_util::sync::ReusableBoxFuture;
use crate::json_rpc::SequencedEvent;
+type ByteRecvResult = Result, broadcast::error::RecvError>;
+type ByteRecvState = (ByteRecvResult, broadcast::Receiver>);
+
/// RAII guard returned by `on_*` register methods. Dropping it deregisters the subscription.
///
/// For broadcast/watch-backed subscriptions, dropping the returned stream/receiver is itself the
@@ -72,7 +75,7 @@ impl Drop for Subscription {
///
/// Lagged messages are skipped. Closing the sender ends the stream.
pub struct ByteStream {
- inner: ReusableBoxFuture<'static, (Result, broadcast::error::RecvError>, broadcast::Receiver>)>,
+ inner: ReusableBoxFuture<'static, ByteRecvState>,
}
impl ByteStream {
@@ -84,9 +87,7 @@ impl ByteStream {
}
}
-async fn recv_bytes(
- mut rx: broadcast::Receiver>,
-) -> (Result, broadcast::error::RecvError>, broadcast::Receiver>) {
+async fn recv_bytes(mut rx: broadcast::Receiver>) -> ByteRecvState {
let result = rx.recv().await;
(result, rx)
}
diff --git a/crates/client/src/transport.rs b/crates/client/src/transport.rs
index 42d16e3b4..cc45dafc5 100644
--- a/crates/client/src/transport.rs
+++ b/crates/client/src/transport.rs
@@ -5,23 +5,23 @@
//! and defines NO wire types. Framing: 4-byte big-endian length prefix via
//! [`protocol::NativeFrameCodec`], payload codec pinned to [`protocol::NativePayloadCodec::Bare`].
//!
-//! Request-id direction is load-bearing: host-initiated `Request`/`Response` frames use POSITIVE ids
-//! (counter starts at 1, increments); sidecar-initiated `SidecarRequest`/`SidecarResponse` callbacks
-//! use NEGATIVE ids (counter starts at -1, decrements).
+//! Request-id direction is load-bearing: host-initiated `Request`/`Response` frames use positive ids
+//! allocated by this transport, while sidecar-initiated `SidecarRequest`/`SidecarResponse` callbacks
+//! echo the id allocated by the sidecar.
use std::process::Stdio;
use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
use std::sync::{Arc, Weak};
use scc::HashMap as SccHashMap;
-use tokio::io::{AsyncReadExt, AsyncWriteExt};
-use tokio::process::{Child, ChildStdin, ChildStdout, Command};
+use tokio::io::{AsyncReadExt, AsyncWrite, AsyncWriteExt};
+use tokio::process::{Child, ChildStdout, Command};
use tokio::sync::{broadcast, mpsc, oneshot};
use agent_os_sidecar::protocol::{
- self, EventPayload, NativeFrameCodec, NativePayloadCodec, OwnershipScope, ProtocolFrame,
- RequestFrame, RequestPayload, ResponsePayload, SidecarRequestFrame, SidecarRequestPayload,
- SidecarResponseFrame, SidecarResponsePayload, DEFAULT_MAX_FRAME_BYTES,
+ self, DEFAULT_MAX_FRAME_BYTES, EventPayload, NativeFrameCodec, NativePayloadCodec,
+ OwnershipScope, ProtocolFrame, RequestFrame, RequestPayload, ResponsePayload,
+ SidecarRequestFrame, SidecarRequestPayload, SidecarResponseFrame, SidecarResponsePayload,
};
use crate::error::ClientError;
@@ -29,6 +29,15 @@ use crate::error::ClientError;
/// Broadcast capacity for the structured/lifecycle/process event fan-out.
const EVENT_CHANNEL_CAPACITY: usize = 4096;
+/// Maximum outbound frames buffered while the writer task drains to sidecar stdin.
+const REQUEST_FRAME_QUEUE_CAPACITY: usize = 4096;
+
+/// Maximum callback/control response frames buffered ahead of regular host requests.
+const CONTROL_FRAME_QUEUE_CAPACITY: usize = 1024;
+
+/// Maximum in-flight host-initiated sidecar requests per transport.
+const PENDING_REQUEST_LIMIT: usize = 4096;
+
/// Env var that overrides the sidecar binary path. Defaults to `agent-os-sidecar` on `PATH`. Tests
/// point this at the freshly built binary.
const SIDECAR_BIN_ENV: &str = "AGENT_OS_SIDECAR_BIN";
@@ -38,7 +47,8 @@ pub(crate) type SidecarCallback = Arc<
dyn Fn(
SidecarRequestPayload,
OwnershipScope,
- ) -> futures::future::BoxFuture<'static, Result>
+ )
+ -> futures::future::BoxFuture<'static, Result>
+ Send
+ Sync,
>;
@@ -50,18 +60,19 @@ pub struct SidecarTransport {
pub(crate) child: parking_lot::Mutex