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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/refs/secure-exec
Original file line number Diff line number Diff line change
@@ -1 +1 @@
94f540b301988026549ea3e5044c18c61c598e59
abf0d0172b05e59dfc8bbb698ae73904b2a076b7
33 changes: 29 additions & 4 deletions crates/client/src/agent_os.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ use secure_exec_vm_config as vm_config;
use crate::config::{
AgentOsConfig, AgentOsLimits, HostTool, MountConfig, PermissionMode, Permissions,
RootFilesystemConfig, RootFilesystemKind, RootFilesystemMode as ConfigRootFilesystemMode,
RootLowerInput, SidecarJsBridgeCall, SidecarJsBridgeCallback,
TimerScheduleDriver, ToolKit,
RootLowerInput, SidecarJsBridgeCall, SidecarJsBridgeCallback, TimerScheduleDriver, ToolKit,
};
use crate::cron::CronManager;
use crate::error::ClientError;
Expand Down Expand Up @@ -3871,9 +3870,9 @@ mod tests {
};
use crate::config::{
AgentOsConfig, AgentOsLimits, FsPermissionRule, FsPermissions, HttpLimits, JsRuntimeLimits,
MountPlugin, PatternPermissions, PermissionMode, Permissions, ResourceLimits,
MountPlugin, PatternPermissions, PermissionMode, Permissions, PythonLimits, ResourceLimits,
RootFilesystemConfig, RootFilesystemKind, RootFilesystemMode, RootLowerInput,
RulePermissions, ToolLimits,
RulePermissions, ToolLimits, WasmLimits,
};
use crate::fs::{
DirEntryType, FilesystemEntry, FilesystemEntryEncoding, FilesystemSnapshotEntries,
Expand Down Expand Up @@ -4158,6 +4157,19 @@ mod tests {
}),
js_runtime: Some(JsRuntimeLimits {
v8_heap_limit_mb: Some(64),
sync_rpc_wait_timeout_ms: Some(2_000),
cpu_time_limit_ms: Some(30_000),
wall_clock_limit_ms: Some(0),
import_cache_materialize_timeout_ms: Some(30_000),
..Default::default()
}),
python: Some(PythonLimits {
max_old_space_mb: Some(256),
..Default::default()
}),
wasm: Some(WasmLimits {
prewarm_timeout_ms: Some(30_000),
runner_heap_limit_mb: Some(2_048),
..Default::default()
}),
..Default::default()
Expand Down Expand Up @@ -4192,9 +4204,22 @@ mod tests {
assert_eq!(
limits
.js_runtime
.as_ref()
.expect("js runtime limits")
.v8_heap_limit_mb,
Some(64)
);
let js_runtime = limits.js_runtime.expect("js runtime limits");
assert_eq!(js_runtime.sync_rpc_wait_timeout_ms, Some(2_000));
assert_eq!(js_runtime.cpu_time_limit_ms, Some(30_000));
assert_eq!(js_runtime.wall_clock_limit_ms, Some(0));
assert_eq!(js_runtime.import_cache_materialize_timeout_ms, Some(30_000));
assert_eq!(
limits.python.expect("python limits").max_old_space_mb,
Some(256)
);
let wasm = limits.wasm.expect("wasm limits");
assert_eq!(wasm.prewarm_timeout_ms, Some(30_000));
assert_eq!(wasm.runner_heap_limit_mb, Some(2_048));
}
}
42 changes: 42 additions & 0 deletions crates/client/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,30 @@ pub struct JsRuntimeLimits {
skip_serializing_if = "Option::is_none"
)]
pub v8_heap_limit_mb: Option<u64>,
#[serde(
default,
rename = "syncRpcWaitTimeoutMs",
skip_serializing_if = "Option::is_none"
)]
pub sync_rpc_wait_timeout_ms: Option<u64>,
#[serde(
default,
rename = "cpuTimeLimitMs",
skip_serializing_if = "Option::is_none"
)]
pub cpu_time_limit_ms: Option<u64>,
#[serde(
default,
rename = "wallClockLimitMs",
skip_serializing_if = "Option::is_none"
)]
pub wall_clock_limit_ms: Option<u64>,
#[serde(
default,
rename = "importCacheMaterializeTimeoutMs",
skip_serializing_if = "Option::is_none"
)]
pub import_cache_materialize_timeout_ms: Option<u64>,
#[serde(
default,
rename = "capturedOutputLimitBytes",
Expand Down Expand Up @@ -521,6 +545,12 @@ pub struct PythonLimits {
skip_serializing_if = "Option::is_none"
)]
pub execution_timeout_ms: Option<u64>,
#[serde(
default,
rename = "maxOldSpaceMb",
skip_serializing_if = "Option::is_none"
)]
pub max_old_space_mb: Option<u64>,
#[serde(
default,
rename = "vfsRpcTimeoutMs",
Expand Down Expand Up @@ -549,6 +579,18 @@ pub struct WasmLimits {
skip_serializing_if = "Option::is_none"
)]
pub sync_read_limit_bytes: Option<u64>,
#[serde(
default,
rename = "prewarmTimeoutMs",
skip_serializing_if = "Option::is_none"
)]
pub prewarm_timeout_ms: Option<u64>,
#[serde(
default,
rename = "runnerHeapLimitMb",
skip_serializing_if = "Option::is_none"
)]
pub runner_heap_limit_mb: Option<u64>,
}

// ---------------------------------------------------------------------------
Expand Down
8 changes: 4 additions & 4 deletions examples/resource-limits/README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
---
title: "Resource Limits"
description: "Configure VM resource limits: processes, file descriptors, sockets, filesystem bytes, and WASM stack."
description: "Configure VM resource limits, JavaScript CPU/wall-clock budgets, Python caps, and WASM runtime limits."
category: "Reference"
order: 4
---

Cap how much of the host a VM can consume. Reach for this when you run untrusted or agent-generated code and need hard ceilings on processes, file descriptors, sockets, filesystem storage, and WASM stack depth.
Cap how much of the host a VM can consume. Reach for this when you run untrusted or agent-generated code and need hard ceilings on processes, file descriptors, sockets, filesystem storage, JavaScript CPU time, Python execution, and WASM runtime work.

## How it works

The VM accepts a `limits.resources` block when you call `agentOS({ ... })`. Each field bounds one kind of resource the guest can hold open at once: `maxProcesses`, `maxOpenFds`, `maxSockets`, a `maxFilesystemBytes` storage budget for the VFS, and a `maxWasmStackBytes` ceiling on the WASM call stack. The sidecar enforces these against the executor, so a guest that tries to exceed a cap is denied rather than allowed to exhaust the shared process. Defaults are bounded already; these values raise or lower them to fit your workload.
The VM accepts a typed `limits` block when you call `agentOS({ ... })`. Kernel resources live under `limits.resources`; JavaScript, Python, and WASM runtime limits live under `limits.jsRuntime`, `limits.python`, and `limits.wasm`. The sidecar forwards these over the VM creation wire, so guest env vars cannot raise or override its own caps.

## Run it

Expand All @@ -22,4 +22,4 @@ This starts a registry whose VM is provisioned with the configured resource caps

## Source

View the source on GitHub: https://github.com/rivet-dev/agent-os/tree/main/examples/resource-limits
View the source on GitHub: https://github.com/rivet-dev/agentos/tree/main/examples/resource-limits
19 changes: 18 additions & 1 deletion examples/resource-limits/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { agentOS, setup } from "@rivet-dev/agentos";
import pi from "@agentos-software/pi";
import { agentOS, setup } from "@rivet-dev/agentos";

const vm = agentOS({
software: [pi],
Expand All @@ -9,8 +9,25 @@ const vm = agentOS({
maxOpenFds: 256, // open file descriptors
maxSockets: 128, // open sockets
maxFilesystemBytes: 256 * 1024 * 1024, // VFS storage budget
maxWasmFuel: 30_000, // WASM execution budget
maxWasmMemoryBytes: 128 * 1024 * 1024, // WASM linear memory
maxWasmStackBytes: 4 * 1024 * 1024, // WASM call-stack ceiling
},
jsRuntime: {
v8HeapLimitMb: 128, // JS isolate heap
cpuTimeLimitMs: 30_000, // active JS CPU time
wallClockLimitMs: 0, // 0 disables elapsed wall-clock cutoff
importCacheMaterializeTimeoutMs: 30_000, // Node import-cache setup
syncRpcWaitTimeoutMs: 30_000, // host sync-RPC wait
},
python: {
executionTimeoutMs: 300_000, // Python wall-clock execution
maxOldSpaceMb: 0, // 0 keeps the Pyodide runner default
},
wasm: {
prewarmTimeoutMs: 30_000, // WASM compile-cache warmup
runnerHeapLimitMb: 2048, // trusted WASI runner V8 heap
},
},
});

Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/agent-os.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,10 @@ export interface AgentOsLimits {
/** Guest JavaScript runtime buffering limits. */
jsRuntime?: {
v8HeapLimitMb?: number;
syncRpcWaitTimeoutMs?: number;
cpuTimeLimitMs?: number;
wallClockLimitMs?: number;
importCacheMaterializeTimeoutMs?: number;
capturedOutputLimitBytes?: number;
stdinBufferLimitBytes?: number;
eventPayloadLimitBytes?: number;
Expand All @@ -478,13 +482,16 @@ export interface AgentOsLimits {
python?: {
outputBufferMaxBytes?: number;
executionTimeoutMs?: number;
maxOldSpaceMb?: number;
vfsRpcTimeoutMs?: number;
};
/** Guest WASM runtime limits. */
wasm?: {
maxModuleFileBytes?: number;
capturedOutputLimitBytes?: number;
syncReadLimitBytes?: number;
prewarmTimeoutMs?: number;
runnerHeapLimitMb?: number;
};
}

Expand Down
47 changes: 27 additions & 20 deletions packages/core/src/options-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,59 +93,66 @@ export const agentOsLimitsSchema = z
.strict()
.optional(),
http: z
.object({ maxFetchResponseBytes: nonNegativeInteger.optional() })
.object({ maxFetchResponseBytes: positiveInteger.optional() })
.strict()
.optional(),
tools: z
.object({
defaultToolTimeoutMs: nonNegativeInteger.optional(),
maxToolTimeoutMs: nonNegativeInteger.optional(),
maxRegisteredToolkits: nonNegativeInteger.optional(),
maxRegisteredToolsPerVm: nonNegativeInteger.optional(),
maxToolsPerToolkit: nonNegativeInteger.optional(),
maxToolSchemaBytes: nonNegativeInteger.optional(),
maxRegisteredToolkits: positiveInteger.optional(),
maxRegisteredToolsPerVm: positiveInteger.optional(),
maxToolsPerToolkit: positiveInteger.optional(),
maxToolSchemaBytes: positiveInteger.optional(),
maxToolExamplesPerTool: nonNegativeInteger.optional(),
maxToolExampleInputBytes: nonNegativeInteger.optional(),
maxToolExampleInputBytes: positiveInteger.optional(),
})
.strict()
.optional(),
plugins: z
.object({
maxPersistedManifestBytes: nonNegativeInteger.optional(),
maxPersistedManifestBytes: positiveInteger.optional(),
maxPersistedManifestFileBytes: nonNegativeInteger.optional(),
})
.strict()
.optional(),
acp: z
.object({
maxReadLineBytes: nonNegativeInteger.optional(),
stdoutBufferByteLimit: nonNegativeInteger.optional(),
maxReadLineBytes: positiveInteger.optional(),
stdoutBufferByteLimit: positiveInteger.optional(),
})
.strict()
.optional(),
jsRuntime: z
.object({
v8HeapLimitMb: nonNegativeInteger.optional(),
capturedOutputLimitBytes: nonNegativeInteger.optional(),
stdinBufferLimitBytes: nonNegativeInteger.optional(),
eventPayloadLimitBytes: nonNegativeInteger.optional(),
v8IpcMaxFrameBytes: nonNegativeInteger.optional(),
v8HeapLimitMb: positiveInteger.optional(),
syncRpcWaitTimeoutMs: nonNegativeInteger.optional(),
cpuTimeLimitMs: nonNegativeInteger.optional(),
wallClockLimitMs: nonNegativeInteger.optional(),
importCacheMaterializeTimeoutMs: positiveInteger.optional(),
capturedOutputLimitBytes: positiveInteger.optional(),
stdinBufferLimitBytes: positiveInteger.optional(),
eventPayloadLimitBytes: positiveInteger.optional(),
v8IpcMaxFrameBytes: positiveInteger.optional(),
})
.strict()
.optional(),
python: z
.object({
outputBufferMaxBytes: nonNegativeInteger.optional(),
executionTimeoutMs: nonNegativeInteger.optional(),
vfsRpcTimeoutMs: nonNegativeInteger.optional(),
outputBufferMaxBytes: positiveInteger.optional(),
executionTimeoutMs: positiveInteger.optional(),
maxOldSpaceMb: nonNegativeInteger.optional(),
vfsRpcTimeoutMs: positiveInteger.optional(),
})
.strict()
.optional(),
wasm: z
.object({
maxModuleFileBytes: nonNegativeInteger.optional(),
capturedOutputLimitBytes: nonNegativeInteger.optional(),
syncReadLimitBytes: nonNegativeInteger.optional(),
maxModuleFileBytes: positiveInteger.optional(),
capturedOutputLimitBytes: positiveInteger.optional(),
syncReadLimitBytes: positiveInteger.optional(),
prewarmTimeoutMs: positiveInteger.optional(),
runnerHeapLimitMb: positiveInteger.optional(),
})
.strict()
.optional(),
Expand Down
9 changes: 4 additions & 5 deletions packages/core/tests/vim-interactive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,11 +264,10 @@ describe.skipIf(!existsSync(VIM_BINARY))("interactive vim over VM PTY", () => {
);

// Regression guard for the idle full-screen raw-mode case: the guest must
// survive a long idle stretch on the DEFAULT CPU watchdog (no
// AGENTOS_V8_CPU_TIME_LIMIT_MS override) and still consume a keystroke
// afterwards. Guards both the event-driven kernel wait servicing (idle must
// accrue ~zero active CPU) and the poll/read wake path (the keystroke must
// land promptly, not after a polling slice).
// survive a long idle stretch on the default typed CPU watchdog and still
// consume a keystroke afterwards. Guards both the event-driven kernel wait
// servicing (idle must accrue ~zero active CPU) and the poll/read wake path
// (the keystroke must land promptly, not after a polling slice).
it(
"idles 60s+ in raw mode on the default CPU watchdog, then consumes a keystroke",
async () => {
Expand Down
21 changes: 17 additions & 4 deletions website/src/content/docs/docs/resource-limits.mdx
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
---
title: "Resource Limits"
description: "Cap per-VM processes, file descriptors, sockets, and filesystem bytes so guest code can never exhaust the host."
description: "Cap per-VM resources, JavaScript CPU/wall-clock time, Python execution, and WASM runtime work so guest code can never exhaust the host."
skill: true
---

Every agentOS VM runs with **per-VM resource caps**. Runaway or malicious guest code can exhaust its own VM, but it can never starve the host or any sibling VM.
Every agentOS VM runs with **per-VM resource and runtime caps**. Runaway or malicious guest code can exhaust its own VM, but it can never starve the host or any sibling VM.

- **Bounded by default**: each VM ships with conservative caps. Unset fields fall back to built-in defaults that match the runtime's historical constants.
- **Per-VM**: every VM gets its own budget. Limits are not shared across VMs.
- **Enforced by the kernel**: a guest that exceeds a cap fails inside the VM (out-of-memory, `EMFILE`, `EAGAIN`, etc.). The host is never affected.
- **Enforced by the sidecar/runtime**: a guest that exceeds a cap fails inside the VM (out-of-memory, `EMFILE`, `EAGAIN`, runtime timeout, etc.). The host is never affected.
- **Operator-raisable**: the operator (the trusted process that creates the VM) may raise any cap for trusted workloads. Guest code can never raise its own caps.

## Setting limits

Set caps on the `limits` object in the `agentOS` config. Limits are grouped by subsystem (`resources` and more). Omitted limits keep their secure default.
Set caps on the `limits` object in the `agentOS` config. Limits are grouped by subsystem (`resources`, `jsRuntime`, `python`, `wasm`, and more). Omitted limits keep their secure default.

<CodeSnippet file="examples/resource-limits/server.ts" />

Expand All @@ -25,11 +25,24 @@ Set caps on the `limits` object in the `agentOS` config. Limits are grouped by s
| `resources.maxOpenFds` | Open file descriptors | Exhausting the table fails with `EMFILE` / `ENFILE`. |
| `resources.maxSockets` | Open sockets in the socket table | Bounds concurrent connections; excess `connect`/`accept` fail. |
| `resources.maxFilesystemBytes` | Total bytes stored in the virtual filesystem | Bounds VFS storage; writes past the budget fail with a no-space error. |
| `resources.maxWasmFuel` | WASM execution budget | Bounds WASM execution work; unset means no explicit fuel budget. |
| `resources.maxWasmMemoryBytes` | WASM linear memory, in bytes | Default is `128 MiB`. |
| `resources.maxWasmStackBytes` | Maximum WASM call-stack size, in bytes | Deep recursion fails with a stack overflow instead of crashing the VM. |
| `jsRuntime.v8HeapLimitMb` | Guest JavaScript V8 heap, in MiB | Default is `128`. |
| `jsRuntime.cpuTimeLimitMs` | Active JavaScript CPU time | Default is `30000`; `0` disables the CPU watchdog. |
| `jsRuntime.wallClockLimitMs` | JavaScript elapsed wall-clock backstop | Default is `0`, disabled. Use this for finite commands, not long-lived adapters. |
| `jsRuntime.importCacheMaterializeTimeoutMs` | Node import-cache materialization timeout | Default is `30000`. |
| `jsRuntime.syncRpcWaitTimeoutMs` | JavaScript sync host-RPC wait | Unset keeps the engine default, currently `30000`. |
| `python.executionTimeoutMs` | Python execution wall-clock timeout | Default is `300000`. |
| `python.maxOldSpaceMb` | Pyodide runner V8 old-space heap, in MiB | Default is `0`, which keeps the engine default. |
| `wasm.prewarmTimeoutMs` | WASM compile-cache warmup timeout | Default is `30000`. |
| `wasm.runnerHeapLimitMb` | Trusted WASI/WASM runner V8 heap, in MiB | Default is `2048`; this is not guest linear memory. |

## Behavior at the limit

- **WASM stack**: deep recursion throws a stack-overflow error in the guest, never a host crash.
- **JavaScript CPU time**: CPU-bound loops terminate with a CPU-budget error once active JS CPU exceeds `jsRuntime.cpuTimeLimitMs`.
- **JavaScript wall time**: awaiting or blocked JS terminates only when you set `jsRuntime.wallClockLimitMs`; the default is disabled for long-lived adapters.
- **Filesystem bytes**: writing past the VFS budget fails with a no-space error to the guest.
- **Counts (fds / processes / sockets)**: hitting a table cap returns the standard POSIX errno (`EMFILE`, `EAGAIN`, etc.), exactly as a real Linux kernel would under `ulimit`.

Expand Down
Loading