Skip to content

Commit 097b28e

Browse files
committed
fix: fall back to sandbox-agent CLI in tests
1 parent 2a309d3 commit 097b28e

File tree

3 files changed

+200
-11
lines changed

3 files changed

+200
-11
lines changed

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ Each agent type needs:
266266
- **Cache the expensive layers explicitly.** Reuse pnpm store, workspace `node_modules`, and Rust `target/`/cargo caches in CI; otherwise this repo's mixed Rust/TS pipeline gets too slow to iterate on.
267267
- **CI runners also need the `just` binary.** `packages/dev-shell/test/dev-shell-cli.integration.test.ts` shells out to the repo `justfile`, so GitHub runners must install `just` explicitly or the package test fails with `spawn just ENOENT`.
268268
- **CI runners also need Playwright's Chromium bundle for `packages/browser`.** The browser package's `test:browser` script launches Playwright against Chromium, so the workflow must install and ideally cache `~/.cache/ms-playwright` or every browser spec fails with `browserType.launch: Executable doesn't exist`.
269+
- **Sandbox toolkit tests should not assume a prebuilt Docker image exists.** `packages/core/src/test/docker.ts` now falls back to the bundled `sandbox-agent` CLI when `sandbox-agent-test:dev` is unavailable, so CI does not need a bespoke Docker image just to exercise `registry/tool/sandbox`.
269270
- **Cross-crate Rust test helpers must use repo-relative paths, never machine-local absolute paths.** `#[path = "..."]` includes under `crates/*` are compiled on CI runners with different checkout roots, so absolute developer paths like `/home/nathan/...` will break `cargo test --workspace`.
270271
- **Local CI reproduction:** `git lfs pull && cargo test --workspace --no-fail-fast && cargo test -p agent-os-sidecar -- --ignored --test-threads=1 && cargo build -p agent-os-sidecar && AGENTOS_E2E_NETWORK=1 pnpm test`
271272
- **Framework**: vitest

packages/core/src/test/docker.ts

Lines changed: 198 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@
66
* removes the container.
77
*/
88

9-
import { execFile as execFileCb } from "node:child_process";
9+
import { execFile as execFileCb, spawn } from "node:child_process";
1010
import { promisify } from "node:util";
1111
import { randomUUID } from "node:crypto";
12+
import { createServer } from "node:net";
13+
import { constants as fsConstants } from "node:fs";
14+
import { access } from "node:fs/promises";
15+
import { dirname, resolve } from "node:path";
16+
import { fileURLToPath } from "node:url";
1217

1318
const execFile = promisify(execFileCb);
1419

@@ -385,6 +390,175 @@ export interface SandboxAgentContainerHandle extends ContainerHandle {
385390
client: import("sandbox-agent").SandboxAgent;
386391
}
387392

393+
async function isDockerImageAvailable(image: string): Promise<boolean> {
394+
try {
395+
await execFile("docker", ["image", "inspect", image]);
396+
return true;
397+
} catch (err) {
398+
if (err instanceof Error) {
399+
const message = err.message;
400+
if (
401+
message.includes("ENOENT") ||
402+
message.includes("Cannot connect to the Docker daemon") ||
403+
message.includes("No such image")
404+
) {
405+
return false;
406+
}
407+
}
408+
return false;
409+
}
410+
}
411+
412+
async function resolveSandboxAgentCli(): Promise<string> {
413+
const packageDir = dirname(
414+
fileURLToPath(new URL("../../package.json", import.meta.url)),
415+
);
416+
const candidates = [
417+
resolve(
418+
packageDir,
419+
"node_modules",
420+
"sandbox-agent",
421+
"node_modules",
422+
".bin",
423+
"sandbox-agent",
424+
),
425+
resolve(
426+
packageDir,
427+
"..",
428+
"..",
429+
"node_modules",
430+
".bin",
431+
"sandbox-agent",
432+
),
433+
];
434+
435+
for (const candidate of candidates) {
436+
try {
437+
await access(candidate, fsConstants.X_OK);
438+
return candidate;
439+
} catch {
440+
// Try the next candidate.
441+
}
442+
}
443+
444+
throw new Error(
445+
"Sandbox Agent CLI not found. Expected a bundled sandbox-agent binary in node_modules.",
446+
);
447+
}
448+
449+
async function allocateLocalPort(): Promise<number> {
450+
return await new Promise((resolvePort, reject) => {
451+
const server = createServer();
452+
server.once("error", reject);
453+
server.listen(0, "127.0.0.1", () => {
454+
const address = server.address();
455+
if (!address || typeof address === "string") {
456+
server.close(() =>
457+
reject(new Error("Failed to allocate a local port for Sandbox Agent")),
458+
);
459+
return;
460+
}
461+
server.close((closeErr) => {
462+
if (closeErr) {
463+
reject(closeErr);
464+
return;
465+
}
466+
resolvePort(address.port);
467+
});
468+
});
469+
});
470+
}
471+
472+
async function startLocalSandboxAgent(
473+
port: number,
474+
timeout: number,
475+
): Promise<ContainerHandle> {
476+
const cliPath = await resolveSandboxAgentCli();
477+
const name = `sandbox-agent-local-${randomUUID().slice(0, 8)}`;
478+
const stdoutChunks: string[] = [];
479+
const stderrChunks: string[] = [];
480+
const child = spawn(
481+
cliPath,
482+
[
483+
"server",
484+
"--host",
485+
"127.0.0.1",
486+
"--port",
487+
String(port),
488+
"--no-token",
489+
],
490+
{
491+
stdio: ["ignore", "pipe", "pipe"],
492+
},
493+
);
494+
495+
child.stdout?.setEncoding("utf8");
496+
child.stderr?.setEncoding("utf8");
497+
child.stdout?.on("data", (chunk: string) => {
498+
stdoutChunks.push(chunk);
499+
if (stdoutChunks.join("").length > 8_192) {
500+
stdoutChunks.splice(0, stdoutChunks.length - 16);
501+
}
502+
});
503+
child.stderr?.on("data", (chunk: string) => {
504+
stderrChunks.push(chunk);
505+
if (stderrChunks.join("").length > 8_192) {
506+
stderrChunks.splice(0, stderrChunks.length - 16);
507+
}
508+
});
509+
510+
const stop = async () => {
511+
if (child.exitCode !== null || child.killed) return;
512+
513+
child.kill("SIGTERM");
514+
await Promise.race([
515+
new Promise<void>((resolve) => {
516+
child.once("exit", () => resolve());
517+
}),
518+
sleep(5_000).then(() => {
519+
if (child.exitCode === null && !child.killed) {
520+
child.kill("SIGKILL");
521+
}
522+
}),
523+
]);
524+
};
525+
526+
const deadline = Date.now() + timeout;
527+
while (Date.now() < deadline) {
528+
if (child.exitCode !== null) {
529+
const output = `${stdoutChunks.join("")}${stderrChunks.join("")}`.trim();
530+
await stop();
531+
throw new Error(
532+
`Local Sandbox Agent exited before becoming healthy (exit code: ${child.exitCode ?? "unknown"}).` +
533+
(output ? `\nLast logs:\n${output}` : ""),
534+
);
535+
}
536+
537+
try {
538+
const resp = await fetch(`http://127.0.0.1:${port}/`);
539+
if (resp.ok) {
540+
return {
541+
id: `local-${child.pid ?? "sandbox-agent"}`,
542+
name,
543+
ports: { [`${port}/tcp`]: port },
544+
stop,
545+
};
546+
}
547+
} catch {
548+
// Server not ready yet.
549+
}
550+
551+
await sleep(500);
552+
}
553+
554+
await stop();
555+
const output = `${stdoutChunks.join("")}${stderrChunks.join("")}`.trim();
556+
throw new Error(
557+
`Local Sandbox Agent at http://127.0.0.1:${port} did not become healthy within ${timeout}ms` +
558+
(output ? `\nLast logs:\n${output}` : ""),
559+
);
560+
}
561+
388562
/**
389563
* Start a Sandbox Agent container and return a connected SandboxAgent client.
390564
*/
@@ -393,19 +567,32 @@ export async function startSandboxAgentContainer(
393567
): Promise<SandboxAgentContainerHandle> {
394568
const image =
395569
options?.image ?? "sandbox-agent-test:dev";
396-
const port = options?.port ?? 2468;
397-
398-
const container = await startContainer({
399-
image,
400-
ports: [{ host: 0, container: port }],
401-
command: ["server", "--host", "0.0.0.0", "--port", String(port), "--no-token"],
402-
});
403-
404-
const hostPort = container.ports[`${port}/tcp`];
570+
const timeout = options?.healthTimeout ?? 60_000;
571+
const requestedPort = options?.port;
572+
const localPort = requestedPort ?? (await allocateLocalPort());
573+
const useDocker = await isDockerImageAvailable(image);
574+
575+
const container = useDocker
576+
? await startContainer({
577+
image,
578+
ports: [{ host: 0, container: requestedPort ?? 2468 }],
579+
command: [
580+
"server",
581+
"--host",
582+
"0.0.0.0",
583+
"--port",
584+
String(requestedPort ?? 2468),
585+
"--no-token",
586+
],
587+
})
588+
: await startLocalSandboxAgent(localPort, timeout);
589+
590+
const hostPort = container.ports[
591+
`${useDocker ? (requestedPort ?? 2468) : localPort}/tcp`
592+
];
405593
const baseUrl = `http://127.0.0.1:${hostPort}`;
406594

407595
// Poll health from the host since the container may not have curl.
408-
const timeout = options?.healthTimeout ?? 60_000;
409596
const deadline = Date.now() + timeout;
410597
while (Date.now() < deadline) {
411598
try {

scripts/ralph/progress.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- Guest Node sync-RPC bootstrap FDs should be left for process teardown; closing inherited raw FDs with `fs.closeSync()` emits host warnings on newer Node builds.
1717
- Full crate test suites (`cargo test -p <crate>`) must pass, not just focused individual test cases.
1818
- After package renames or workspace dependency changes, validate from the repo root with `pnpm install --frozen-lockfile`; package-local checks can miss broken `workspace:*` resolution.
19+
- Sandbox toolkit integration tests should use the bundled `sandbox-agent` CLI as a fallback when `sandbox-agent-test:dev` is not present; CI runners should not depend on a locally prebuilt Docker image for that package.
1920
- SSRF filters must cover all RFC special-purpose ranges: 0.0.0.0/8, 10/8, 127/8, 169.254/16, 172.16/12, 192.168/16, 224/4, 255.255.255.255, plus IPv6 equivalents.
2021
- S3-compatible loopback endpoints for local Minio or mock servers must opt in explicitly with `allowLoopbackEndpoint`; production defaults should keep loopback/private endpoint SSRF checks enabled.
2122
- Native-sidecar Node launches must share the same host shadow root between `createVm` metadata `cwd` and `NativeSidecarKernelProxy.shadowRoot`; otherwise sidecar execute rejects the Node cwd as escaping the VM sandbox root.

0 commit comments

Comments
 (0)