diff --git a/docs/adr-docker-sandboxes.md b/docs/adr-docker-sandboxes.md index 71dd2c0..90657eb 100644 --- a/docs/adr-docker-sandboxes.md +++ b/docs/adr-docker-sandboxes.md @@ -82,7 +82,7 @@ already say what each argv means. | `timeoutMs` | **Honoured** — a runner-side timer plus `sbx rm --force`, the same shape as `DockerRunner`'s. | | `hostPorts` | **Honoured** — one sandbox-scoped policy rule (§5). | | `pidsLimit`, `readOnlyRootfs`, `user`, `tmpfs`, `extraHosts` | **Superseded** by the hypervisor boundary and the sandbox's own network path — every one of them exists to narrow a *shared-kernel* container. Named, never dropped silently: `describeSupersededSpecFields` reports the ones a spec actually carries and the runner logs them per run. | -| `memory` | **Superseded**, and this one deserves its argument. `turn.ts` floors it at `4g` because "a turn must never run with unbounded memory" — an obligation about the *host*, which a container shares. A microVM is created with a fixed RAM allocation it cannot exceed, so the obligation is discharged by construction. Sizing that allocation per sandbox is not documented; `run.sandbox.createArgs` is the escape hatch until it is. **This is a deliberate deviation from the plan**, which said to refuse a spec carrying `memory` if no mapping existed; refusing would make the runtime unusable, since `turn.ts` and `check-runner.ts` both always set it. | +| `memory` | **Honoured** — `--memory`. The existing `4g` floor from `turn.ts` and `check-runner.ts` therefore remains the per-turn/check VM memory bound under the substituted runner. | | `network` | **Refused**, loudly, in both `config.ts` and `sbxCreateArgs`. A sandbox has no docker network to join, and an operator who set one is expressing an intent this runtime cannot honour. | | `onOutput` | **Honoured** — the exec is streamed and captured under the same bound as `DockerRunner`'s, so turn diagnostics and secret redaction (`modelEnvSecrets`) behave identically. | @@ -156,7 +156,7 @@ discipline bounds their disk use. - **Refused combinations**: `sandbox` × `turnTransport: 'unix'`, `sandbox` × `run.network`, `sandbox` × `run.codexAuth` (managed Codex auth mounts a scratch home and an `/etc/passwd` entry keyed to the daemon's host uid, which passthrough workspaces cannot express). -- **Startup fail-fast** under the sandbox runtime: `sandboxPreflight` runs `sbx --version` and +- **Startup fail-fast** under the sandbox runtime: `sandboxPreflight` runs `sbx version` and `sbx ls`, and a daemon that cannot dispatch says so at startup rather than at the first claim it wins. `sbx login` is interactive and must have happened out of band — the same posture forge authentication already takes. @@ -180,8 +180,6 @@ closes each: 4. **`sbx ls` output shape.** `parseSandboxList` skips a header row and takes the first column, tolerant of added columns by construction, and is the single place the CLI's human output leaks into Radial. *Closed by:* the smoke test's `listByLabel` assertions. -5. **Per-sandbox memory sizing.** Undocumented; superseded by the fixed VM allocation (§4), with - `run.sandbox.createArgs` as the escape hatch. ### Manual verification checklist diff --git a/docs/radial-json.md b/docs/radial-json.md index 408270c..3ad3760 100644 --- a/docs/radial-json.md +++ b/docs/radial-json.md @@ -153,7 +153,7 @@ sbx login # interactive; must happen | `template` | `run.image` | Template image the sandbox is created from. It must already be in the sandbox image store (`sbx template load`), not merely in Docker's. | | `agent` | `claude` | The agent positional `sbx create` takes beside `--template`. Radial never starts it — the harness runs through `sbx exec` — so this only names the sandbox's own default entrypoint. It is a name `sbx` accepts, unrelated to Radial's `harness`. | | `kits` | `[]` | `--kit` entries, stacked in order: install steps, files and network rules layered on the template. A path, a `git+https://…` URL or an OCI reference. | -| `createArgs` | `[]` | Extra `sbx create` flags, passed verbatim. The escape hatch for a CLI Docker documents as subject to change — and, today, the only way to size a sandbox's RAM. | +| `createArgs` | `[]` | Extra `sbx create` flags, passed verbatim. The escape hatch for a CLI Docker documents as subject to change. (`run.memory` is mapped directly to `sbx create --memory`.) | | `binary` | `sbx` | The executable, for a machine that installed it elsewhere. | The turn socket needs one egress rule, and the daemon writes it per turn rather than asking you to: diff --git a/packages/daemon/README.md b/packages/daemon/README.md index ca816ef..2fba40b 100644 --- a/packages/daemon/README.md +++ b/packages/daemon/README.md @@ -119,7 +119,7 @@ orphan reconcilers. | | `docker` (default) | `sandbox` | | --- | --- | --- | -| boundary | shared kernel, `--cap-drop=ALL`, non-root, read-only rootfs, pids and memory caps | a microVM: its own kernel, its own filesystem, a fixed RAM allocation | +| boundary | shared kernel, `--cap-drop=ALL`, non-root, read-only rootfs, pids and memory caps | a microVM: its own kernel and filesystem, with the same requested memory cap | | Docker inside the turn | impossible | yes — a full inner Docker daemon | | turn transport | `unix` on Linux, `tcp` on Docker Desktop | always `tcp`, bound to `127.0.0.1` | | egress | the docker network (`run.network`) | the host policy proxy — `sbx policy`, and `run.network` is refused | diff --git a/packages/daemon/src/sandbox.ts b/packages/daemon/src/sandbox.ts index 6e5a117..5bcc051 100644 --- a/packages/daemon/src/sandbox.ts +++ b/packages/daemon/src/sandbox.ts @@ -48,8 +48,9 @@ export interface SandboxOptions { agent?: string /** `--kit` entries, stacked in the given order. */ kits?: string[] - /** Extra `sbx create` flags, passed verbatim after the kits. The escape hatch for a CLI whose - * surface is explicitly "subject to change" — an operator can add a flag Radial does not know. */ + /** Extra `sbx create` flags, passed verbatim after Radial's template and memory flags. The escape + * hatch for a CLI whose surface is explicitly "subject to change" — an operator can add a flag + * Radial does not know. */ createArgs?: string[] } @@ -58,12 +59,6 @@ export interface SandboxOptions { * SUPERSEDED by the hypervisor boundary and the sandbox's own network policy, not silently * dropped: `describeSupersededSpecFields` names the ones actually present so the daemon can say so * once at startup, and the ADR carries the argument field by field. - * - * `memory` is here for the same reason: a microVM is created with a fixed RAM allocation, so a turn - * inside one cannot grow into the host's memory the way a container sharing the host kernel can. - * That is the obligation `turn.ts`'s `4g` floor exists to discharge ("a turn must never run with - * unbounded memory"), and it is discharged by construction here. Sizing that allocation per sandbox - * is not documented by `sbx`; `run.sandbox.createArgs` is the escape hatch until it is. */ export const SUPERSEDED_SPEC_FIELDS = [ 'pidsLimit', @@ -71,7 +66,6 @@ export const SUPERSEDED_SPEC_FIELDS = [ 'user', 'tmpfs', 'extraHosts', - 'memory', ] as const /** The superseded fields this spec actually carries, in declaration order. Pure. */ @@ -102,8 +96,10 @@ function requireMappableMounts(spec: ContainerSpec): void { } /** - * Pure `sbx create` argv builder. The sandbox is created detached and left stopped-of-work: the - * harness runs in the `sbx exec` below, not as the agent positional. + * Pure `sbx create` argv builder. `ContainerSpec.memory` maps to the documented `--memory` flag so + * the caller's per-turn/check resource bound survives the runner substitution. The sandbox is + * created before work starts; the harness runs in the `sbx exec` below, not as the agent + * positional. * * No `-e` flags: the turn's environment — a model key, the turn token, a forge grant — is set by * the exec script, which reaches `sbx` over stdin. That keeps every secret off both the host's and @@ -123,6 +119,7 @@ export function sbxCreateArgs(spec: ContainerSpec, options: SandboxOptions = {}) const args = ['create', '--name', spec.label] for (const kit of options.kits ?? []) args.push('--kit', kit) args.push('--template', options.template ?? spec.image) + if (spec.memory !== undefined) args.push('--memory', spec.memory) args.push(...(options.createArgs ?? [])) args.push(options.agent ?? DEFAULT_SBX_AGENT) for (const mount of spec.mounts) { @@ -243,7 +240,7 @@ export async function sandboxPreflight( const problems: string[] = [] let version: SbxResult try { - version = await exec(['--version']) + version = await exec(['version']) } catch (error) { return [ `run.containerRuntime is "sandbox" but ${binary} could not be run (${error instanceof Error ? error.message : String(error)}); ` + @@ -251,7 +248,7 @@ export async function sandboxPreflight( ] } if (version.code !== 0) { - problems.push(`${binary} --version failed (exit ${version.code}): ${version.stderr.trim() || version.stdout.trim()}`) + problems.push(`${binary} version failed (exit ${version.code}): ${version.stderr.trim() || version.stdout.trim()}`) return problems } const list = await exec(['ls']).catch((error: unknown) => ({ diff --git a/packages/daemon/test/sandbox.test.mjs b/packages/daemon/test/sandbox.test.mjs index e93bbe8..f472a05 100644 --- a/packages/daemon/test/sandbox.test.mjs +++ b/packages/daemon/test/sandbox.test.mjs @@ -45,6 +45,8 @@ it('sbxCreateArgs names the sandbox, the template and every workspace, marking r 'radial.turn.abc123', '--template', 'radial-turn:latest', + '--memory', + '4g', DEFAULT_SBX_AGENT, '/var/lib/radiald/runs/abc/checkout', '/var/lib/radiald/runs/abc/bundle:ro', @@ -56,10 +58,10 @@ it('sbxCreateArgs carries an operator template, kits, agent and extra flags in a template: 'my-org/radial-turn:v1', agent: 'codex', kits: ['./kits/radial', 'ghcr.io/acme/kit:1.0'], - createArgs: ['--memory', '8g'], + createArgs: ['--cpus', '6'], }) - assert.deepEqual(args.slice(0, 11), [ + assert.deepEqual(args.slice(0, 13), [ 'create', '--name', 'radial.turn.abc123', @@ -70,9 +72,11 @@ it('sbxCreateArgs carries an operator template, kits, agent and extra flags in a '--template', 'my-org/radial-turn:v1', '--memory', - '8g', + '4g', + '--cpus', + '6', ]) - assert.equal(args[11], 'codex') + assert.equal(args[13], 'codex') }) it('sbxCreateArgs never puts an environment value on the command line', () => { @@ -161,7 +165,7 @@ it('sbxRemoveArgs always forces: a sandbox holds the turn credentials until it i }) it('describeSupersededSpecFields names the docker hardening the microVM boundary replaces', () => { - assert.deepEqual(describeSupersededSpecFields(baseSpec), ['readOnlyRootfs', 'user', 'tmpfs', 'memory']) + assert.deepEqual(describeSupersededSpecFields(baseSpec), ['readOnlyRootfs', 'user', 'tmpfs']) assert.deepEqual(describeSupersededSpecFields({ ...baseSpec, memory: undefined, tmpfs: undefined }), [ 'readOnlyRootfs', 'user', @@ -184,7 +188,7 @@ it('sandboxPreflight reports a missing sbx and an unauthenticated one, each nami assert.match(missing[0], /run\.containerRuntime to "docker"/) const unauthenticated = await sandboxPreflight({}, async (args) => - args[0] === '--version' + args[0] === 'version' ? { code: 0, stdout: 'sbx 0.4.0', stderr: '' } : { code: 1, stdout: '', stderr: 'not logged in' }, )