diff --git a/.github/refs/secure-exec b/.github/refs/secure-exec index 4b3bfb36f..00c7a6bfb 100644 --- a/.github/refs/secure-exec +++ b/.github/refs/secure-exec @@ -1 +1 @@ -94f540b301988026549ea3e5044c18c61c598e59 +abf0d0172b05e59dfc8bbb698ae73904b2a076b7 diff --git a/crates/client/src/agent_os.rs b/crates/client/src/agent_os.rs index 6d86ef8b2..ff7380778 100644 --- a/crates/client/src/agent_os.rs +++ b/crates/client/src/agent_os.rs @@ -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; @@ -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, @@ -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() @@ -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)); } } diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs index b7b049de0..46cefb4a0 100644 --- a/crates/client/src/config.rs +++ b/crates/client/src/config.rs @@ -481,6 +481,30 @@ pub struct JsRuntimeLimits { skip_serializing_if = "Option::is_none" )] pub v8_heap_limit_mb: Option, + #[serde( + default, + rename = "syncRpcWaitTimeoutMs", + skip_serializing_if = "Option::is_none" + )] + pub sync_rpc_wait_timeout_ms: Option, + #[serde( + default, + rename = "cpuTimeLimitMs", + skip_serializing_if = "Option::is_none" + )] + pub cpu_time_limit_ms: Option, + #[serde( + default, + rename = "wallClockLimitMs", + skip_serializing_if = "Option::is_none" + )] + pub wall_clock_limit_ms: Option, + #[serde( + default, + rename = "importCacheMaterializeTimeoutMs", + skip_serializing_if = "Option::is_none" + )] + pub import_cache_materialize_timeout_ms: Option, #[serde( default, rename = "capturedOutputLimitBytes", @@ -521,6 +545,12 @@ pub struct PythonLimits { skip_serializing_if = "Option::is_none" )] pub execution_timeout_ms: Option, + #[serde( + default, + rename = "maxOldSpaceMb", + skip_serializing_if = "Option::is_none" + )] + pub max_old_space_mb: Option, #[serde( default, rename = "vfsRpcTimeoutMs", @@ -549,6 +579,18 @@ pub struct WasmLimits { skip_serializing_if = "Option::is_none" )] pub sync_read_limit_bytes: Option, + #[serde( + default, + rename = "prewarmTimeoutMs", + skip_serializing_if = "Option::is_none" + )] + pub prewarm_timeout_ms: Option, + #[serde( + default, + rename = "runnerHeapLimitMb", + skip_serializing_if = "Option::is_none" + )] + pub runner_heap_limit_mb: Option, } // --------------------------------------------------------------------------- diff --git a/examples/resource-limits/README.md b/examples/resource-limits/README.md index fd499c90d..488f45e34 100644 --- a/examples/resource-limits/README.md +++ b/examples/resource-limits/README.md @@ -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 @@ -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 diff --git a/examples/resource-limits/server.ts b/examples/resource-limits/server.ts index 876f11bb1..eba1a2af8 100644 --- a/examples/resource-limits/server.ts +++ b/examples/resource-limits/server.ts @@ -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], @@ -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 + }, }, }); diff --git a/packages/core/src/agent-os.ts b/packages/core/src/agent-os.ts index 62fda2414..efca7888d 100644 --- a/packages/core/src/agent-os.ts +++ b/packages/core/src/agent-os.ts @@ -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; @@ -478,6 +482,7 @@ export interface AgentOsLimits { python?: { outputBufferMaxBytes?: number; executionTimeoutMs?: number; + maxOldSpaceMb?: number; vfsRpcTimeoutMs?: number; }; /** Guest WASM runtime limits. */ @@ -485,6 +490,8 @@ export interface AgentOsLimits { maxModuleFileBytes?: number; capturedOutputLimitBytes?: number; syncReadLimitBytes?: number; + prewarmTimeoutMs?: number; + runnerHeapLimitMb?: number; }; } diff --git a/packages/core/src/options-schema.ts b/packages/core/src/options-schema.ts index 0bdd4a3f6..52bff77f0 100644 --- a/packages/core/src/options-schema.ts +++ b/packages/core/src/options-schema.ts @@ -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(), diff --git a/packages/core/tests/vim-interactive.test.ts b/packages/core/tests/vim-interactive.test.ts index 8383b0805..e48830d5b 100644 --- a/packages/core/tests/vim-interactive.test.ts +++ b/packages/core/tests/vim-interactive.test.ts @@ -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 () => { diff --git a/website/src/content/docs/docs/resource-limits.mdx b/website/src/content/docs/docs/resource-limits.mdx index 74497ca84..d65146258 100644 --- a/website/src/content/docs/docs/resource-limits.mdx +++ b/website/src/content/docs/docs/resource-limits.mdx @@ -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. @@ -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`.