Skip to content

Commit 475ca7e

Browse files
bgrgicakclaude
andcommitted
Fix production readiness issues for multi-engine sandbox
- Merge embedded Dockerfiles into single build (fixes nerdctl/buildkit FROM reference failure where local images aren't visible to buildkit) - Remove @flout/sandbox from container image (host-side only package) - Validate Colima mount paths are under ~ (VM only mounts home dir) - Add Colima crash recovery with clear error messages in build and start - Fix engine priority: Colima always checked first even with --engine flag (prevents standalone nerdctl without buildkit from being preferred over Colima which bundles buildkit) - Add per-engine E2E tests: full lifecycle (build, start, exec, status, stop) verified for Docker, Podman, and containerd via Colima - Use ~/.cache/flout/ for build context when via Colima (VM can't see /tmp) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fc10268 commit 475ca7e

7 files changed

Lines changed: 203 additions & 29 deletions

File tree

package-lock.json

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,8 @@
2323
"typescript-eslint": "^8.58.1"
2424
},
2525
"name": "flout",
26-
"version": "0.1.3"
26+
"version": "0.1.3",
27+
"dependencies": {
28+
"@flout/cli": "^0.1.3"
29+
}
2730
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* Per-engine E2E tests.
3+
* Runs the full sandbox lifecycle (start → exec → status → stop)
4+
* through each available container engine with the mock Claude API.
5+
*/
6+
import { describe, it, before, after } from 'node:test';
7+
import assert from 'node:assert';
8+
import { spawnSync } from 'child_process';
9+
import os from 'os';
10+
import path from 'path';
11+
import { fileURLToPath } from 'url';
12+
import { startMockServer, installMockCredentials, restoreCredentials } from '@flout/claude-mock-api';
13+
14+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
15+
const cli = path.resolve(__dirname, '..', '..', 'src', 'cli.ts');
16+
const pkgRoot = path.resolve(__dirname, '..', '..');
17+
18+
let mockPort: number;
19+
let mockServer: { server: import('http').Server; port: number };
20+
let credentialsBackup: string | null = null;
21+
const credentialsPath = path.join(os.homedir(), '.claude', '.credentials.json');
22+
23+
function flout(...args: string[]): { stdout: string; stderr: string; exitCode: number | null } {
24+
const result = spawnSync('npx', ['tsx', cli, ...args], {
25+
encoding: 'utf8',
26+
timeout: 300000,
27+
cwd: pkgRoot,
28+
env: {
29+
...process.env,
30+
CLAUDE_CODE_API_BASE_URL: `http://127.0.0.1:${mockPort}`,
31+
},
32+
});
33+
return { stdout: result.stdout || '', stderr: result.stderr || '', exitCode: result.status };
34+
}
35+
36+
function extractContainerName(output: string): string | null {
37+
const match = output.match(/Container '(flout-\d{4}-\d{6}-[a-z0-9-]+)'/);
38+
return match ? match[1] : null;
39+
}
40+
41+
interface EngineTestConfig {
42+
name: string;
43+
flag: string; // value for --engine
44+
skip: string | undefined;
45+
cleanupBinary: string; // binary to use for cleanup (rm -f)
46+
cleanupPrefix: string[]; // prefix args for cleanup
47+
}
48+
49+
function isAvailable(binary: string): boolean {
50+
if (binary === 'colima-nerdctl') {
51+
return spawnSync('colima', ['status'], { stdio: 'ignore' }).status === 0;
52+
}
53+
const which = spawnSync('which', [binary], { stdio: 'ignore' });
54+
if (which.status !== 0) return false;
55+
if (binary === 'docker' || binary === 'podman') {
56+
return spawnSync(binary, ['info'], { stdio: 'ignore', timeout: 5000 }).status === 0;
57+
}
58+
return true;
59+
}
60+
61+
const engines: EngineTestConfig[] = [
62+
{
63+
name: 'docker',
64+
flag: 'docker',
65+
skip: !isAvailable('docker') ? 'docker not available' : undefined,
66+
cleanupBinary: 'docker',
67+
cleanupPrefix: [],
68+
},
69+
{
70+
name: 'podman',
71+
flag: 'podman',
72+
skip: !isAvailable('podman') ? 'podman not available' : undefined,
73+
cleanupBinary: 'podman',
74+
cleanupPrefix: [],
75+
},
76+
{
77+
name: 'containerd via colima',
78+
flag: 'containerd',
79+
skip: !isAvailable('colima-nerdctl') ? 'colima not running' : undefined,
80+
cleanupBinary: 'colima',
81+
cleanupPrefix: ['nerdctl', '--'],
82+
},
83+
];
84+
85+
for (const engine of engines) {
86+
describe(`e2e: sandbox with ${engine.name}`, { skip: engine.skip }, () => {
87+
const containers: string[] = [];
88+
89+
before(async () => {
90+
mockServer = await startMockServer();
91+
mockPort = mockServer.port;
92+
credentialsBackup = installMockCredentials(credentialsPath);
93+
});
94+
95+
after(() => {
96+
mockServer.server.close();
97+
restoreCredentials(credentialsPath, credentialsBackup);
98+
for (const c of containers) {
99+
spawnSync(engine.cleanupBinary, [...engine.cleanupPrefix, 'rm', '-f', c], { stdio: 'ignore' });
100+
}
101+
});
102+
103+
it('starts a container', () => {
104+
const result = flout('sandbox', 'start', '--name', `e2e-${engine.flag}`, '--engine', engine.flag);
105+
assert.strictEqual(result.exitCode, 0,
106+
`start failed: stdout=${result.stdout} stderr=${result.stderr}`);
107+
const name = extractContainerName(result.stdout);
108+
assert.ok(name, `should output container name, got: ${result.stdout}`);
109+
containers.push(name!);
110+
});
111+
112+
it('shows the container in status', () => {
113+
const result = flout('sandbox', 'status', '--engine', engine.flag);
114+
assert.strictEqual(result.exitCode, 0);
115+
assert.ok(result.stdout.includes(containers[0]),
116+
`status should list ${containers[0]}`);
117+
});
118+
119+
it('can exec into the container', () => {
120+
const name = containers[0];
121+
const result = spawnSync(engine.cleanupBinary,
122+
[...engine.cleanupPrefix, 'exec', name, 'echo', 'hello-from-sandbox'],
123+
{ encoding: 'utf8' });
124+
assert.strictEqual(result.status, 0,
125+
`exec failed: ${result.stderr}`);
126+
assert.ok(result.stdout.includes('hello-from-sandbox'));
127+
});
128+
129+
it('stops the container', () => {
130+
const name = containers[0];
131+
const result = flout('sandbox', 'stop', name, '--engine', engine.flag);
132+
assert.strictEqual(result.exitCode, 0,
133+
`stop failed: stdout=${result.stdout} stderr=${result.stderr}`);
134+
assert.ok(result.stdout.includes('removed'));
135+
});
136+
137+
it('status shows no containers after cleanup', () => {
138+
const result = flout('sandbox', 'status', '--engine', engine.flag);
139+
assert.strictEqual(result.exitCode, 0);
140+
// Container was removed, should not appear
141+
if (containers[0]) {
142+
assert.ok(!result.stdout.includes(containers[0]),
143+
`should not list removed container ${containers[0]}`);
144+
}
145+
});
146+
});
147+
}

packages/docker/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ RUN cd /tmp/flout && npm ci && npm run build \
1717
&& cd docker && npm pack && mv flout-docker-*.tgz /tmp/ && cd .. \
1818
&& cd sandbox && npm pack && mv flout-sandbox-*.tgz /tmp/ && cd .. \
1919
&& cd cli && npm pack && mv flout-cli-*.tgz /tmp/ && cd .. \
20-
&& npm install -g /tmp/flout-claude-*.tgz /tmp/flout-docker-*.tgz /tmp/flout-sandbox-*.tgz /tmp/flout-cli-*.tgz \
20+
&& npm install -g /tmp/flout-cli-*.tgz /tmp/flout-claude-*.tgz /tmp/flout-docker-*.tgz /tmp/flout-sandbox-*.tgz \
2121
&& rm -rf /tmp/flout /tmp/flout-*.tgz
2222
USER dev
2323

packages/sandbox/src/engine.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,16 +110,16 @@ function startColimaForEngine(preferred?: EngineType): Engine | null {
110110
* need a VM anyway, so running Colima is preferred.
111111
*/
112112
function detect(preferred?: EngineType): Engine | null {
113-
// If a specific engine is requested, try it directly first
113+
// 1. Colima (already running) — always checked first, even with --engine
114+
const fromColima = engineFromColima(preferred);
115+
if (fromColima) return fromColima;
116+
117+
// If a specific engine is requested, try it natively
114118
if (preferred) {
115119
const binary = preferred === 'nerdctl' ? 'nerdctl' : preferred;
116120
if (isEngineAvailable(binary)) return makeEngine(binary, false);
117121
}
118122

119-
// 1. Colima (already running)
120-
const fromColima = engineFromColima(preferred);
121-
if (fromColima) return fromColima;
122-
123123
if (!preferred) {
124124
// 2. Podman
125125
if (isEngineAvailable('podman')) return makeEngine('podman', false);

packages/sandbox/src/index.ts

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from 'fs';
33
import os from 'os';
44
import path from 'path';
55
import { detectEngine } from './engine.js';
6+
import { isColimaRunning } from './colima.js';
67
import type { Engine, EngineType } from './engine.js';
78

89
export type { Engine, EngineType, EngineInput } from './engine.js';
@@ -14,11 +15,10 @@ export interface SandboxAgent {
1415
encodePath(dir: string): string;
1516
}
1617

17-
const BASE_IMAGE_NAME = 'flout';
1818
const IMAGE_NAME = 'flout-claude';
1919
const CONTAINER_PREFIX = 'flout-';
2020

21-
const EMBEDDED_BASE_DOCKERFILE = `FROM node:20-slim
21+
const EMBEDDED_DOCKERFILE = `FROM node:20-slim
2222
2323
RUN apt-get update && apt-get install -y \\
2424
bash curl git sudo tmux \\
@@ -28,23 +28,17 @@ RUN useradd -m -s /bin/bash dev \\
2828
&& adduser dev sudo \\
2929
&& echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
3030
31-
RUN npm install -g @flout/cli @flout/claude @flout/sandbox
31+
RUN npm install -g @flout/cli @flout/claude
3232
3333
USER dev
3434
WORKDIR /home/dev
3535
RUN echo 'echo ""; flout status; echo ""' >> /home/dev/.bashrc
3636
37-
CMD ["sleep", "infinity"]
38-
`;
39-
40-
const EMBEDDED_CLAUDE_DOCKERFILE = `FROM ${BASE_IMAGE_NAME}
41-
42-
USER dev
4337
RUN curl -fsSL https://claude.ai/install.sh | bash
44-
4538
RUN echo 'export PATH="$HOME/.local/bin:$PATH"' >> /home/dev/.bashrc
46-
4739
ENV PATH="/home/dev/.local/bin:\${PATH}"
40+
41+
CMD ["sleep", "infinity"]
4842
`;
4943

5044
function timestamp(): string {
@@ -74,6 +68,7 @@ function engineArgs(engine: Engine, args: string[]): string[] {
7468
return [...engine.prefix, ...args];
7569
}
7670

71+
7772
function imageExists(engine: Engine, name: string): boolean {
7873
const result = spawnSync(engine.binary, engineArgs(engine, ['image', 'inspect', name]), { stdio: 'ignore' });
7974
return result.status === 0;
@@ -99,8 +94,9 @@ function buildTmpDir(engine: Engine): string {
9994
}
10095

10196
function checkBuildSupport(engine: Engine): void {
97+
// Colima bundles buildkit inside the VM — no host-side check needed.
98+
// Only standalone nerdctl (not via Colima) needs buildkit on the host.
10299
if (engine.type === 'nerdctl' && !engine.viaColima) {
103-
// Standalone nerdctl needs buildkit for image builds
104100
const buildctl = spawnSync('which', ['buildctl'], { stdio: 'ignore' });
105101
if (buildctl.status !== 0) {
106102
console.error('nerdctl build requires buildkit (buildctl + buildkitd).');
@@ -115,14 +111,15 @@ function buildImage(engine: Engine): void {
115111
checkBuildSupport(engine);
116112
const tmpDir = buildTmpDir(engine);
117113
try {
118-
if (!imageExists(engine, BASE_IMAGE_NAME)) {
119-
console.log(`Building flout base image with ${engine.binary}...`);
120-
fs.writeFileSync(path.join(tmpDir, 'Dockerfile'), EMBEDDED_BASE_DOCKERFILE);
121-
execFileSync(engine.binary, engineArgs(engine, ['build', '-t', BASE_IMAGE_NAME, tmpDir]), { stdio: 'inherit' });
122-
}
123-
console.log(`Building flout-claude image with ${engine.binary}...`);
124-
fs.writeFileSync(path.join(tmpDir, 'Dockerfile'), EMBEDDED_CLAUDE_DOCKERFILE);
114+
console.log(`Building flout-claude image with ${engine.type}...`);
115+
fs.writeFileSync(path.join(tmpDir, 'Dockerfile'), EMBEDDED_DOCKERFILE);
125116
execFileSync(engine.binary, engineArgs(engine, ['build', '-t', IMAGE_NAME, tmpDir]), { stdio: 'inherit' });
117+
} catch {
118+
if (engine.viaColima && !isColimaRunning()) {
119+
console.error('Colima VM is not running. It may have crashed or been stopped.');
120+
console.error('Restart it with: colima start');
121+
}
122+
process.exit(1);
126123
} finally {
127124
fs.rmSync(tmpDir, { recursive: true, force: true });
128125
}
@@ -168,9 +165,23 @@ export interface SandboxStartOptions {
168165
engine?: EngineType;
169166
}
170167

168+
function validateMountPath(engine: Engine, hostPath: string): void {
169+
if (!engine.viaColima) return;
170+
const home = os.homedir();
171+
const resolved = path.resolve(hostPath);
172+
if (!resolved.startsWith(home + '/') && resolved !== home) {
173+
console.error(`Error: '${resolved}' is outside your home directory.`);
174+
console.error(`Colima can only mount paths under ${home}.`);
175+
console.error('Move your project under ~ or use a native engine (--engine docker).');
176+
process.exit(1);
177+
}
178+
}
179+
171180
export function start({ name, cwd, extraArgs, agent, engine: preferredEngine }: SandboxStartOptions): string {
172181
const e = getEngine(preferredEngine);
173182

183+
validateMountPath(e, cwd);
184+
174185
if (!imageExists(e, IMAGE_NAME)) {
175186
buildImage(e);
176187
}
@@ -198,13 +209,21 @@ export function start({ name, cwd, extraArgs, agent, engine: preferredEngine }:
198209
...extraArgs,
199210
IMAGE_NAME,
200211
]);
201-
execFileSync(e.binary, runArgs, { stdio: 'inherit' });
212+
try {
213+
execFileSync(e.binary, runArgs, { stdio: 'inherit' });
214+
} catch {
215+
if (e.viaColima && !isColimaRunning()) {
216+
console.error('Colima VM is not running. It may have crashed or been stopped.');
217+
console.error('Restart it with: colima start');
218+
}
219+
process.exit(1);
220+
}
202221

203222
const encoded = agent.encodePath(mountTarget);
204223
const trustDir = `/home/dev/.claude/projects/${encoded}`;
205224
spawnSync(e.binary, engineArgs(e, ['exec', full, 'mkdir', '-p', trustDir]));
206225

207-
console.log(`Container '${full}' created and running (engine: ${e.binary}).`);
226+
console.log(`Container '${full}' created and running (engine: ${e.type}${e.viaColima ? ' via colima' : ''}).`);
208227
return full;
209228
}
210229

packages/sandbox/test/engine-types.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ describe('engine type: nerdctl (containerd)', () => {
8787
it('detects nerdctl when available', { skip: !hasNerdctl ? 'nerdctl not installed' : undefined }, () => {
8888
const engine = detectEngine('nerdctl');
8989
assert.strictEqual(engine.type, 'nerdctl');
90-
assert.strictEqual(engine.binary, 'nerdctl');
90+
// Binary is 'colima' when Colima provides containerd, 'nerdctl' standalone
91+
assert.ok(engine.binary === 'nerdctl' || engine.binary === 'colima',
92+
`expected nerdctl or colima binary, got '${engine.binary}'`);
9193
});
9294

9395
it('nerdctl binary supports expected commands', { skip: !hasNerdctl ? 'nerdctl not installed' : undefined }, () => {

0 commit comments

Comments
 (0)