Files
nanoclaw/src/container-runner.test.ts
T
Omri Maya 1d6bba4d3f feat(container): per-container CPU/memory limits (opt-in)
Pass CONTAINER_CPU_LIMIT / CONTAINER_MEMORY_LIMIT through to `docker run`
as --cpus / --memory in buildContainerArgs. Both default to empty, so spawn
args are byte-identical to today unless an operator opts in — no risk of
OOM-ing existing workloads. Caps an agent container's CPU/memory so one agent
can't monopolize the host. Swap is a deployment concern (--memory is a hard
cap on a swapless host); not managed here.

Structural tests assert each flag is pushed and guarded by its env knob,
matching the existing buildContainerArgs structural-test convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:39:16 +03:00

94 lines
4.6 KiB
TypeScript

import fs from 'fs';
import path from 'path';
import { describe, expect, it } from 'vitest';
import { resolveProviderName } from './container-runner.js';
describe('resolveProviderName', () => {
it('prefers session over container config', () => {
expect(resolveProviderName('codex', 'claude')).toBe('codex');
});
it('falls back to container config when session is null', () => {
expect(resolveProviderName(null, 'opencode')).toBe('opencode');
});
it('defaults to claude when nothing is set', () => {
expect(resolveProviderName(null, undefined)).toBe('claude');
});
it('lowercases the resolved name', () => {
expect(resolveProviderName('CODEX', null)).toBe('codex');
expect(resolveProviderName(null, 'Claude')).toBe('claude');
});
it('treats empty string as unset (falls through)', () => {
expect(resolveProviderName('', 'opencode')).toBe('opencode');
expect(resolveProviderName(null, '')).toBe('claude');
});
});
describe('buildContainerArgs ordering invariant (structural)', () => {
// The OneCLI gateway apply (SDK applyContainerConfig) appends credential-stub
// mounts — e.g. the codex auth.json sentinel nested INSIDE our RW
// /home/node/.codex mount. Docker applies binds in argument order, so the
// stub must land AFTER its parent mount or the parent shadows it and the
// agent silently degrades to loginless auth. Driving the real
// buildContainerArgs needs a live gateway + container runtime, so this
// guards the invariant structurally: the gateway apply must appear after
// the volume-mounts loop in the source.
it('applies the OneCLI gateway after the volume mounts', () => {
const src = fs.readFileSync(path.join(process.cwd(), 'src', 'container-runner.ts'), 'utf-8');
const mountsLoop = src.indexOf('for (const mount of mounts)');
const gatewayApply = src.indexOf('onecli.applyContainerConfig');
expect(mountsLoop).toBeGreaterThan(-1);
expect(gatewayApply).toBeGreaterThan(-1);
expect(gatewayApply).toBeGreaterThan(mountsLoop);
});
});
describe('per-container resource limits (structural)', () => {
// CONTAINER_CPU_LIMIT / CONTAINER_MEMORY_LIMIT pass through to `docker run` as
// --cpus / --memory, but only when set. The default is empty string → no flag →
// today's unbounded behavior (don't OOM existing OSS workloads). Swap is not
// managed here (a swapless host makes --memory a hard cap). buildContainerArgs
// needs a live gateway to drive, so guard the wiring structurally: the flags
// must be pushed, and each must be guarded by its env knob so empty emits nothing.
it('reads both limit knobs from config', () => {
const src = fs.readFileSync(path.join(process.cwd(), 'src', 'container-runner.ts'), 'utf-8');
expect(src).toContain('CONTAINER_CPU_LIMIT');
expect(src).toContain('CONTAINER_MEMORY_LIMIT');
});
it('guards --cpus behind a truthy CONTAINER_CPU_LIMIT', () => {
const src = fs.readFileSync(path.join(process.cwd(), 'src', 'container-runner.ts'), 'utf-8');
expect(src).toMatch(/if \(CONTAINER_CPU_LIMIT\)[\s\S]*?args\.push\('--cpus', CONTAINER_CPU_LIMIT\)/);
});
it('guards --memory behind a truthy CONTAINER_MEMORY_LIMIT (and sets no swap flag)', () => {
const src = fs.readFileSync(path.join(process.cwd(), 'src', 'container-runner.ts'), 'utf-8');
expect(src).toMatch(/if \(CONTAINER_MEMORY_LIMIT\) args\.push\('--memory', CONTAINER_MEMORY_LIMIT\)/);
expect(src).not.toContain('--memory-swap');
});
it('defaults both knobs to empty string in config (no flag = unbounded)', () => {
const cfg = fs.readFileSync(path.join(process.cwd(), 'src', 'config.ts'), 'utf-8');
expect(cfg).toContain("CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || ''");
expect(cfg).toContain("CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || ''");
});
});
describe('container boot-failure tripwire (structural)', () => {
// A container that dies at boot (unknown provider, missing CLI binary, bad
// config) explains itself only on stderr — which logs at debug, below the
// default level. The spawn handler must keep a stderr tail and surface it
// at warn on a non-zero exit, or the operator sees only "exited code 1" on
// repeat. Driving a real failing spawn needs a container runtime, so this
// guards the wiring structurally, matching the invariant test above.
it('surfaces the stderr tail when the container exits non-zero', () => {
const src = fs.readFileSync(path.join(process.cwd(), 'src', 'container-runner.ts'), 'utf-8');
expect(src).toContain('stderrTail.push(line)');
expect(src).toMatch(/Container exited non-zero.*stderrTail/s);
});
});