mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
1d6bba4d3f
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>
94 lines
4.6 KiB
TypeScript
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);
|
|
});
|
|
});
|