fix(agents/orb): use standard SandboxAgent client for AgentOS sandbox

The in-process DockerSandboxClient could never satisfy AgentOS 0.2.10's
sandbox serialization contract: AgentOS serializes sandbox mounts through
getSerializableClientConfig, which reads client.baseUrl and passes it to the
sidecar. An in-process object has no network endpoint.

Replace the custom adapter with the supported boundary: SandboxAgent.start
with sandbox-agent/docker, which starts a sandbox-agent server inside a
Docker container with a dynamically mapped host port and returns a
SandboxAgent client whose baseUrl both the main process and the sidecar
subprocess reach over 127.0.0.1. Dispose calls destroySandbox so no
containers are left behind.

Remove 700+ lines of dead DockerSandboxClient infrastructure (PID tracking,
log files, signal handling) now handled natively by the sandbox-agent
server. Add a sqlite_file database descriptor to AgentOs.create so session
storage works. Wrap command execution in sh -c for the SandboxAgent API.
Apply chmod 600 to the real OpenCode config path containing the gateway key.

The real proof with the main .env now passes all three stages:
ORB_STAGE_DOCKER_OK, ORB_STAGE_AGENTOS_OK, ORB_STAGE_MODEL_TURN_OK,
ending ORB_PROOF_PASSED with zero leftover containers.

Update README to describe the real SandboxAgent + Docker topology and
remove the misleading 'missing sidecar' limitation.
This commit is contained in:
-Puter
2026-07-24 22:38:11 +05:30
parent e31f50af32
commit 8ddecc098f
7 changed files with 297 additions and 1033 deletions

View File

@@ -12,21 +12,25 @@
"run:zopu": "bun --env-file=../../.env flue run zopu"
},
"dependencies": {
"@agentos-software/opencode": "0.2.7",
"@code/backend": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@flue/runtime": "latest",
"@rivet-dev/agentos-core": "catalog:",
"convex": "catalog:",
"dockerode": "^5.0.1",
"effect": "catalog:",
"get-port": "^7.2.0",
"hono": "4.12.31",
"valibot": "^1.4.2",
"@agentos-software/opencode": "0.2.7"
"sandbox-agent": "0.4.2",
"valibot": "^1.4.2"
},
"devDependencies": {
"@code/config": "workspace:*",
"@flue/cli": "latest",
"@types/bun": "catalog:",
"@types/dockerode": "^4.0.1",
"typescript": "catalog:"
}
}

View File

@@ -11,9 +11,9 @@ One logical execution workspace for one ProjectIssue run. An Orb bundles an Agen
├──────────────────────────────────────────────────┤
│ OrbHandle │
│ ┌─────────────┐ ┌────────────────────────┐ │
│ │ AgentOS VM │ │ Docker Sandbox │ │
│ │ (OpenCode │◄──►│ (bun, tests, builds, │ │
│ │ ACP agent) │ │ repo checkout) │ │
│ │ AgentOS VM │ │ SandboxAgent + Docker │ │
│ │ (OpenCode │◄──►│ (sandbox-agent server │ │
│ │ ACP agent) │ │ in a Docker container)│ │
│ └─────────────┘ └────────────────────────┘ │
│ │ │ │
│ session events runProcess / │
@@ -22,7 +22,7 @@ One logical execution workspace for one ProjectIssue run. An Orb bundles an Agen
└──────────────────────────────────────────────────┘
```
The AgentOS VM runs the OpenCode ACP adapter (lightweight agent loop, session management, durable identity). Heavy execution — package installs, test suites, builds — runs inside the Docker sandbox via `runProcess`. The sandbox filesystem is mounted into the VM at `/mnt/sandbox`.
The AgentOS VM runs the OpenCode ACP adapter (lightweight agent loop, session management, durable identity). Heavy execution — package installs, test suites, builds — runs inside a Docker container hosting a sandbox-agent server. The `DockerSandboxProvider` calls `SandboxAgent.start({ sandbox: docker(...) })`, which starts the server in a Docker container with a dynamically-mapped host port and returns a `SandboxAgent` client whose `baseUrl` both the main process and the AgentOS sidecar subprocess reach over `127.0.0.1`. The sandbox filesystem is mounted into the VM at `/mnt/sandbox`.
## Domain model
@@ -64,11 +64,11 @@ No permanent provider credentials are stored in project files. API keys are inje
## Docker requirements
- Docker daemon running and accessible via `docker` CLI
- The proof fixture uses `oven/bun:1.3-debian` by default
- Containers run with `--cap-drop=ALL --security-opt=no-new-privileges`
- Memory limited to 2g, CPUs to 2
- Writable mount is the project workspace only
- Docker daemon running and accessible via the Docker socket (`/var/run/docker.sock`)
- The sandbox uses `rivetdev/sandbox-agent` as its Docker image by default
- The `sandbox-agent/docker` provider creates containers with `AutoRemove` and a dynamically allocated host port
- Writable bind mount is the project workspace only (mounted at `/home/sandbox` inside the container)
- The AgentOS sidecar subprocess reaches the sandbox-agent server over `127.0.0.1:<hostPort>`
## Local startup
@@ -87,21 +87,19 @@ Stable markers: `ORB_PROOF_PASSED` (exit 0), `ORB_PROOF_BLOCKED` (exit 2), `ORB_
## Filesystem layout
```
Container (/workspace = host bind mount)
/workspace/repository/ — project checkout
/workspace/control/ — issue + context files
/tmp/orb-pids/ — process PID tracking
SandboxAgent container (/home/sandbox = host bind mount)
/home/sandbox/repository/ — project checkout
/home/sandbox/control/ — issue + context files
AgentOS VM
/mnt/sandbox/ — sandbox mount point
AgentOS VM (host process)
/mnt/sandbox/ — sandbox mount (via SandboxAgent baseUrl)
/mnt/sandbox/repository/ — repo (via sandbox)
/root/.config/opencode/ — OpenCode config
/root/.config/opencode/ — OpenCode config (chmod 600)
```
## Current limitations
- AgentOS VM creation requires the sidecar binary; the proof fixture exercises the Docker sandbox path and skips the OpenCode session when no sidecar is available.
- `sendProcessInput` is not supported by the Docker sandbox.
- No automatic merge or production deployment capability.
- No multi-region support.
- Interactive PTY sessions are not wired through the Docker sandbox.
- Interactive PTY sessions are not wired through the sandbox agent.
- The model turn stage depends on a reachable OpenAI-compatible gateway; if the gateway is unreachable the proof reports BLOCKED at that stage but still passes Docker and AgentOS/OpenCode.

View File

@@ -3,34 +3,17 @@ import { spawn } from "node:child_process";
import { setTimeout as sleepTimer } from "node:timers/promises";
import { Effect } from "effect";
import { afterEach, describe, expect, it as vitestIt } from "vitest";
import { describe, expect, it as vitestIt } from "vitest";
import { DockerSandboxProvider } from "./docker-sandbox";
import { OrbSandboxError } from "./domain";
// Real containers need more than the 5s default; bind a generous timeout here.
const it = (name: string, fn: () => Promise<void>): void => {
vitestIt(name, fn, 30_000);
};
// Node-compatible CLI helpers so the suite runs under vitest/node workers as
// well as the Bun runtime. `Bun.*` globals are absent under vitest.
const runCliText = (args: readonly string[]): Promise<string> => {
const [command = "docker", ...rest] = args;
const proc = spawn(command, rest, {
stdio: ["ignore", "pipe", "pipe"],
});
const chunks: Buffer[] = [];
// eslint-disable-next-line promise/avoid-new -- wrapping one-shot stream events in a single promise
return new Promise((resolve) => {
proc.stdout?.on("data", (c: Buffer) => chunks.push(c));
proc.on("close", () => {
resolve(Buffer.concat(chunks).toString("utf-8"));
});
});
vitestIt(name, fn, 60_000);
};
// Node-compatible Docker availability check.
const runCliExit = (args: readonly string[]): Promise<number | null> =>
// eslint-disable-next-line promise/avoid-new -- wrapping one-shot child close in a single promise
new Promise((resolve) => {
@@ -56,221 +39,121 @@ const dockerAvailable = async (): Promise<boolean> => {
}
};
const containerExists = async (name: string): Promise<boolean> => {
const listing = await runCliText([
"docker",
"ps",
"-a",
"--filter",
`name=^${name}$`,
"--format",
"{{.ID}}",
]);
return listing.trim().length > 0;
};
const created: string[] = [];
afterEach(async () => {
for (const name of created.splice(0)) {
// eslint-disable-next-line no-await-in-loop -- sequential cleanup of test containers
await runCliText(["docker", "rm", "-f", name]);
}
});
// Top-level await so describe.skipIf can evaluate Docker availability at registration.
// Top-level await so describe.skipIf evaluates Docker availability at registration.
const HAVE_DOCKER = await dockerAvailable();
const uniqueName = (label: string): string => {
const name = `orb-test-${label}-${Date.now()}-${Math.trunc(Math.random() * 1e6)}`;
created.push(name);
return name;
};
const tmpDir = (): string =>
`/tmp/orb-test-${Date.now()}-${Math.trunc(Math.random() * 1e6)}`;
const statusOf = async (
client: {
listProcesses: () => Promise<{
processes: { id: string; status?: string }[];
}>;
},
id: string
): Promise<string | undefined> => {
const list = await client.listProcesses();
return list.processes.find((p) => p.id === id)?.status;
};
describe.skipIf(!HAVE_DOCKER)(
"DockerSandboxProvider (SandboxAgent + Docker)",
() => {
it("starts a SandboxAgent server and exposes a reachable baseUrl", async () => {
const provider = await Effect.runPromise(
DockerSandboxProvider.create({
hostWorkspacePath: tmpDir(),
})
);
try {
const client = await provider.start();
expect(provider.isStarted).toBe(true);
const waitForStatus = async (
check: () => Promise<string | undefined>,
target: string,
timeoutMs = 8000
): Promise<string | undefined> => {
const deadline = Date.now() + timeoutMs;
let status: string | undefined;
while (Date.now() < deadline) {
// eslint-disable-next-line no-await-in-loop -- polling probes sequentially
status = await check();
if (status === target) {
return status;
}
// eslint-disable-next-line no-await-in-loop -- polling probes sequentially
await sleepTimer(250);
}
return status;
};
// The SandboxAgent client must have a baseUrl property — this is what
// AgentOS serializes so the sidecar can reach the sandbox.
const { baseUrl } = client as unknown as { baseUrl: string };
expect(baseUrl).toBeTruthy();
expect(baseUrl).toMatch(/^https?:\/\//u);
describe.skipIf(!HAVE_DOCKER)("DockerSandboxProvider lifecycle", () => {
it("starts a container and removes it on dispose", async () => {
const name = uniqueName("start");
const provider = await Effect.runPromise(
DockerSandboxProvider.create({
containerName: name,
hostWorkspacePath: `/tmp/${name}`,
})
);
const client = await provider.start();
expect(provider.isStarted).toBe(true);
// Run a command to verify the sandbox-agent server actually works.
const result = await client.runProcess({
args: ["-c", "echo hello-orb"],
command: "sh",
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("hello-orb");
} finally {
await provider.dispose();
}
});
const version = await client.runProcess({ command: "bun --version" });
expect(version.exitCode).toBe(0);
expect((version.stdout ?? "").trim()).toMatch(/\d/u);
it("is idempotent: start returns the same client on repeated calls", async () => {
const provider = await Effect.runPromise(
DockerSandboxProvider.create({
hostWorkspacePath: tmpDir(),
})
);
try {
const c1 = await provider.start();
const c2 = await provider.start();
expect(c1).toBe(c2);
} finally {
await provider.dispose();
}
});
expect(await containerExists(name)).toBe(true);
await provider.dispose();
expect(await containerExists(name)).toBe(false);
expect(provider.isStarted).toBe(false);
});
it("disposes cleanly and frees the Docker container", async () => {
const provider = await Effect.runPromise(
DockerSandboxProvider.create({
hostWorkspacePath: tmpDir(),
})
);
await provider.start();
expect(provider.isStarted).toBe(true);
it("dispose is idempotent and a no-op before start", async () => {
const name = uniqueName("noop");
const provider = await Effect.runPromise(
DockerSandboxProvider.create({
containerName: name,
hostWorkspacePath: `/tmp/${name}`,
})
);
await expect(provider.dispose()).resolves.toBeUndefined();
await expect(provider.dispose()).resolves.toBeUndefined();
expect(await containerExists(name)).toBe(false);
});
});
describe.skipIf(!HAVE_DOCKER)("DockerSandboxClient detached processes", () => {
const makeClient = async (label: string) => {
const name = uniqueName(label);
const provider = await Effect.runPromise(
DockerSandboxProvider.create({
containerName: name,
hostWorkspacePath: `/tmp/${name}`,
})
);
const client = await provider.start();
return { client, name, provider };
};
it("captures a real group PID for a detached process", async () => {
const { client, provider } = await makeClient("pid");
try {
const info = await client.createProcess({ command: "sleep 30" });
expect(typeof info.pid).toBe("number");
expect((info.pid ?? 0) > 0).toBe(true);
expect(info.status).toBe("running");
await client.killProcess(info.id);
} finally {
await provider.dispose();
}
});
expect(provider.isStarted).toBe(false);
it("records durable stdout/stderr logs and a real exit code", async () => {
const { client, provider } = await makeClient("logs");
try {
const info = await client.createProcess({
command: "sh -c 'echo stdout-line; echo stderr-line 1>&2'",
// Give AutoRemove a moment.
// eslint-disable-next-line no-await-in-loop -- single settling wait
await sleepTimer(2000);
// Second dispose is a safe no-op.
await expect(provider.dispose()).resolves.toBeUndefined();
});
it("binds the host workspace into the sandbox container", async () => {
const workspace = tmpDir();
const { spawn: nodeSpawn } = await import("node:child_process");
// eslint-disable-next-line promise/avoid-new -- one-shot shell write
await new Promise<void>((resolve) => {
const p = nodeSpawn(
"sh",
[
"-c",
`mkdir -p ${workspace} && echo proof-file > ${workspace}/marker.txt`,
],
{
stdio: ["ignore", "pipe", "pipe"],
}
);
p.on("close", () => resolve());
});
const status = await waitForStatus(
() => statusOf(client, info.id),
"exited"
const provider = await Effect.runPromise(
DockerSandboxProvider.create({
hostWorkspacePath: workspace,
})
);
expect(status).toBe("exited");
const list = await client.listProcesses();
const refreshed = list.processes.find((p) => p.id === info.id);
expect(refreshed?.exitCode).toBe(0);
const stdout = await client.getProcessLogs(info.id, { stream: "stdout" });
expect(stdout.entries.some((e) => e.data.includes("stdout-line"))).toBe(
true
);
const stderr = await client.getProcessLogs(info.id, { stream: "stderr" });
expect(stderr.entries.some((e) => e.data.includes("stderr-line"))).toBe(
true
);
} finally {
await provider.dispose();
}
});
it("refreshes status after natural completion", async () => {
const { client, provider } = await makeClient("exit");
try {
const info = await client.createProcess({ command: "sh -c 'exit 7'" });
const status = await waitForStatus(
() => statusOf(client, info.id),
"exited"
);
expect(status).toBe("exited");
const list = await client.listProcesses();
const refreshed = list.processes.find((p) => p.id === info.id);
expect(refreshed?.exitCode).toBe(7);
} finally {
await provider.dispose();
}
});
it("stopProcess terminates a long-running detached process", async () => {
const { client, provider } = await makeClient("stop");
try {
const info = await client.createProcess({ command: "sleep 30" });
expect(info.status).toBe("running");
const stopped = await client.stopProcess(info.id);
expect(["stopped", "killed"]).toContain(stopped.status);
const status = await statusOf(client, info.id);
expect(status).not.toBe("running");
} finally {
await provider.dispose();
}
});
it("killProcess forces termination", async () => {
const { client, provider } = await makeClient("kill");
try {
const info = await client.createProcess({ command: "sleep 30" });
const killed = await client.killProcess(info.id);
expect(killed.status).toBe("killed");
} finally {
await provider.dispose();
}
});
it("getProcessLogs throws for an unknown process", async () => {
const { client, provider } = await makeClient("unknown");
try {
await expect(client.getProcessLogs("nope")).rejects.toBeInstanceOf(
OrbSandboxError
);
} finally {
await provider.dispose();
}
});
});
try {
const client = await provider.start();
const result = await client.runProcess({
args: ["-c", "cat /home/sandbox/marker.txt"],
command: "sh",
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("proof-file");
} finally {
await provider.dispose();
}
});
}
);
describe.skipIf(HAVE_DOCKER)("DockerSandboxProvider without Docker", () => {
it("create fails with DockerUnavailable", async () => {
const error = await Effect.runPromise(
Effect.flip(
DockerSandboxProvider.create({
containerName: "orb-test-nodocker",
hostWorkspacePath: "/tmp/orb-test-nodocker",
})
)
@@ -282,6 +165,6 @@ describe.skipIf(HAVE_DOCKER)("DockerSandboxProvider without Docker", () => {
if (!HAVE_DOCKER) {
console.warn(
"[docker-sandbox.test.ts] Docker daemon unavailable; Docker lifecycle tests skipped."
"[docker-sandbox.test.ts] Docker daemon unavailable; SandboxAgent tests skipped."
);
}

View File

@@ -1,12 +1,5 @@
import { spawn } from "node:child_process";
import { setTimeout as sleepTimer } from "node:timers/promises";
/* eslint-disable max-classes-per-file -- provider, client, and helpers form one adapter. */
import type {
AgentOsSandboxClient,
AgentOsSandboxProcessInfo,
AgentOsSandboxProcessLogs,
AgentOsSandboxProcessResult,
AgentOsSandboxProvider,
} from "@rivet-dev/agentos-core";
import { Effect } from "effect";
@@ -14,388 +7,88 @@ import { Effect } from "effect";
import { OrbSandboxError } from "./domain";
// ---------------------------------------------------------------------------
// Docker sandbox — AgentOsSandboxProvider backed by the `docker` CLI
// Docker sandbox — AgentOsSandboxProvider backed by sandbox-agent + Docker
// ---------------------------------------------------------------------------
//
// AgentOS serializes sandbox mounts through getSerializableClientConfig,
// which reads client.baseUrl and passes it to the sidecar. An in-process
// client object can never satisfy that contract because it has no network
// endpoint. The supported boundary is a standard SandboxAgent client:
//
// SandboxAgent.start({ sandbox: docker({ image, binds }) })
//
// starts a sandbox-agent server inside a Docker container, dynamically maps
// a host port, and returns a SandboxAgent client whose baseUrl the sidecar
// can reach over HTTP on 127.0.0.1:<hostPort>. Both the main process and
// the sidecar subprocess run on the host, so localhost connectivity works.
const DEFAULT_IMAGE = "oven/bun:1.3-debian";
const CONTAINER_WORKSPACE = "/workspace";
const PID_DIR = "/tmp/orb-pids";
const SANDBOX_AGENT_IMAGE = "rivetdev/sandbox-agent:0.5.0-rc.2-full";
const DEFAULT_WORKSPACE = "/home/sandbox";
// ---------------------------------------------------------------------------
// Shell quoting & shared helpers
// ---------------------------------------------------------------------------
const shellQuote = (value: string): string =>
`'${value.replaceAll("'", `'"'"'`)}'`;
// eslint-disable-next-line no-empty-function -- shared best-effort rejection swallower
const swallow = (): void => {};
// Node-compatible sleep (works in Bun runtime and vitest/node workers alike).
const sleep = async (ms: number): Promise<void> => {
await sleepTimer(ms);
};
// ---------------------------------------------------------------------------
// Low-level docker CLI helpers
// ---------------------------------------------------------------------------
interface DockerExecResult {
exitCode: number;
stderr: string;
stdout: string;
}
const runDocker = async (
args: readonly string[],
options?: {
readonly stdin?: string;
readonly timeoutMs?: number;
}
): Promise<DockerExecResult> => {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options?.timeoutMs ?? 120_000
);
try {
// eslint-disable-next-line no-use-before-define -- resolved at call time, after module load
return await runChild(args, options?.stdin, controller.signal);
} finally {
clearTimeout(timeout);
}
};
// Spawn `docker` via node:child_process so the adapter works under the Bun
// runtime (proof fixture) and vitest/node workers (test suite) alike. Aborting
// the signal surfaces as a rejected promise with name "AbortError".
const runChild = (
args: readonly string[],
stdin: string | undefined,
signal: AbortSignal
): Promise<DockerExecResult> =>
// eslint-disable-next-line promise/avoid-new -- a single Promise wrapper models one-shot child resolution
new Promise((resolve, reject) => {
const proc = spawn("docker", [...args], {
signal,
stdio: [stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
proc.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
proc.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
if (stdin !== undefined && proc.stdin) {
proc.stdin.end(stdin);
}
let settled = false;
proc.on("error", (error: NodeJS.ErrnoException) => {
if (settled) {
return;
}
settled = true;
reject(error);
});
proc.on("close", (code: number | null) => {
if (settled) {
return;
}
settled = true;
resolve({
exitCode: code ?? -1,
stderr: Buffer.concat(stderrChunks).toString("utf-8"),
stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
});
});
});
const checkDockerAvailable = Effect.fn("Docker.checkAvailable")(
function* checkDockerAvailable() {
const result = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
message: `Docker CLI is not available: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "DockerUnavailable",
}),
try: () => runDocker(["version", "--format", "{{.Server.Version}}"]),
});
if (result.exitCode !== 0) {
return yield* Effect.fail(
new OrbSandboxError({
message: `Docker daemon is not running: ${result.stderr.trim()}`,
reason: "DockerUnavailable",
})
);
}
}
);
type ContainerState = "running" | "stopped" | "not-found";
const inspectContainer = async (name: string): Promise<ContainerState> => {
const result = await runDocker([
"inspect",
"--format",
"{{.State.Running}}",
name,
]);
if (result.exitCode !== 0) {
return "not-found";
}
return result.stdout.trim() === "true" ? "running" : "stopped";
};
// ---------------------------------------------------------------------------
// Container file helpers — secrets never touch docker CLI args or listings
// ---------------------------------------------------------------------------
const parentDir = (filePath: string): string =>
filePath.replace(/\/[^/]+$/u, "") || "/";
const writeContainerFile = async (
container: string,
filePath: string,
content: string
): Promise<void> => {
// -i keeps stdin open so the payload reaches `cat` inside the container.
const result = await runDocker(
[
"exec",
"-i",
container,
"sh",
"-c",
`mkdir -p ${shellQuote(parentDir(filePath))} && cat > ${shellQuote(filePath)}`,
],
{ stdin: content }
);
if (result.exitCode !== 0) {
throw new OrbSandboxError({
message: `Failed to write ${filePath}: ${result.stderr.trim()}`,
reason: "CommandFailed",
});
}
};
/** Read a container file; returns "" when it does not exist. */
const readContainerFile = async (
container: string,
filePath: string
): Promise<string> => {
const result = await runDocker(
["exec", container, "sh", "-c", `cat ${shellQuote(filePath)} 2>/dev/null`],
{ timeoutMs: 10_000 }
);
return result.exitCode === 0 ? result.stdout : "";
};
const removeContainerFiles = async (
container: string,
files: readonly (string | undefined)[]
): Promise<void> => {
const paths = files.filter(
(file): file is string => typeof file === "string" && file.length > 0
);
if (paths.length === 0) {
return;
}
await runDocker(["exec", container, "rm", "-f", ...paths], {
timeoutMs: 10_000,
}).catch(swallow);
};
/**
* Signal a whole detached process group (negative PGID) and fall back to the
* leader PID, so spawned children are not orphaned. The PGID is captured via
* `setsid`, so signalling `-<pgid>` reaches every member of the group.
*/
const signalProcessGroup = async (
container: string,
pid: number,
signal: "TERM" | "KILL" | "0"
): Promise<void> => {
await runDocker(
[
"exec",
container,
"sh",
"-c",
`kill -${signal} -- -${pid} 2>/dev/null; kill -${signal} ${pid} 2>/dev/null; true`,
],
{ timeoutMs: 10_000 }
).catch(swallow);
};
const isProcessGroupAlive = async (
container: string,
pid: number
): Promise<boolean> => {
const result = await runDocker(
[
"exec",
container,
"sh",
"-c",
`kill -0 -- -${pid} 2>/dev/null || kill -0 ${pid} 2>/dev/null`,
],
{ timeoutMs: 5000 }
);
return result.exitCode === 0;
};
const stageEnvFile = async (
container: string,
env: Readonly<Record<string, string>>,
id: string
): Promise<string> => {
const envPath = `${PID_DIR}/.env-${id}`;
const lines = Object.entries(env)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${key}=${shellQuote(value)}`)
.join("\n");
await writeContainerFile(container, envPath, `${lines}\n`);
return envPath;
};
// ---------------------------------------------------------------------------
// DockerSandboxProvider — owns the container lifecycle (idempotent)
// DockerSandboxProvider — wraps SandboxAgent.start with the docker provider
// ---------------------------------------------------------------------------
export interface DockerSandboxOptions {
readonly containerName: string;
readonly containerName?: string;
readonly hostWorkspacePath: string;
readonly image?: string;
readonly workDir?: string;
}
export class DockerSandboxProvider implements AgentOsSandboxProvider {
private readonly containerName: string;
private readonly hostWorkspace: string;
private readonly image: string;
private readonly workDir: string;
private client: DockerSandboxClient | null = null;
private starting: Promise<AgentOsSandboxClient> | null = null;
private containerOwned = false;
private readonly binds: string[];
private client: AgentOsSandboxClient | null = null;
private disposeFn: (() => Promise<void>) | null = null;
constructor(options: DockerSandboxOptions) {
this.containerName = options.containerName;
this.hostWorkspace = options.hostWorkspacePath;
this.image = options.image ?? DEFAULT_IMAGE;
this.workDir = options.workDir ?? CONTAINER_WORKSPACE;
this.image = options.image ?? SANDBOX_AGENT_IMAGE;
// Bind the host workspace read-write into the sandbox container so the
// restricted writable area is the project workspace only.
this.binds = [
`${options.hostWorkspacePath}:${options.workDir ?? DEFAULT_WORKSPACE}`,
];
}
static readonly create = Effect.fn("DockerSandboxProvider.create")(
function* createProvider(options: DockerSandboxOptions) {
// eslint-disable-next-line no-use-before-define -- defined at module bottom
yield* checkDockerAvailable();
return new DockerSandboxProvider(options);
}
);
/**
* Start the Docker container idempotently. Returns the same client on
* repeated calls. Safe to call from multiple paths concurrently — a single
* in-flight start promise is shared.
*/
async start(): Promise<AgentOsSandboxClient> {
if (this.client) {
return this.client;
}
if (this.starting) {
return this.starting;
}
this.starting = this.doStart();
try {
return await this.starting;
} finally {
this.starting = null;
}
}
const { SandboxAgent } = await import("sandbox-agent");
const { docker } = await import("sandbox-agent/docker");
private async doStart(): Promise<AgentOsSandboxClient> {
await Effect.runPromise(checkDockerAvailable());
const state = await inspectContainer(this.containerName);
if (state === "not-found") {
await this.createContainer();
this.containerOwned = true;
} else if (state === "stopped") {
const result = await runDocker(["start", this.containerName]);
if (result.exitCode !== 0) {
throw new OrbSandboxError({
message: `Failed to start container ${this.containerName}: ${result.stderr.trim()}`,
reason: "ContainerStart",
});
}
this.containerOwned = true;
}
const verified = await inspectContainer(this.containerName);
if (verified !== "running") {
throw new OrbSandboxError({
message: `Container ${this.containerName} is not running after start (state: ${verified})`,
reason: "ContainerStart",
});
}
// eslint-disable-next-line no-use-before-define -- client is defined below in the same module
this.client = new DockerSandboxClient(this.containerName, this.workDir);
const sandboxAgent = await SandboxAgent.start({
sandbox: docker({
binds: this.binds,
image: this.image,
}),
});
this.client = sandboxAgent as unknown as AgentOsSandboxClient;
this.disposeFn = async () => {
// destroySandbox permanently tears down the backing Docker container;
// dispose() alone only closes the HTTP connection.
await sandboxAgent.destroySandbox();
};
return this.client;
}
private async createContainer(): Promise<void> {
const result = await runDocker([
"run",
"-d",
"--name",
this.containerName,
"--workdir",
this.workDir,
"-v",
`${this.hostWorkspace}:${CONTAINER_WORKSPACE}`,
"--cap-drop=ALL",
"--security-opt=no-new-privileges",
"--memory=2g",
"--cpus=2",
this.image,
"sleep",
"infinity",
]);
if (result.exitCode !== 0) {
throw new OrbSandboxError({
message: `Failed to create container ${this.containerName}: ${result.stderr.trim()}`,
reason: "ContainerStart",
});
}
}
/**
* Remove the container unconditionally (idempotent). The provider owns
* container removal so partial startup — container created, client never
* assigned — still cleans up.
*/
private async removeContainer(): Promise<void> {
const result = await runDocker(["rm", "-f", this.containerName], {
timeoutMs: 30_000,
});
this.containerOwned = false;
if (result.exitCode !== 0 && !/no such/iu.test(result.stderr)) {
throw new OrbSandboxError({
message: `Failed to remove container ${this.containerName}: ${result.stderr.trim()}`,
reason: "ContainerCleanup",
});
}
}
async dispose(): Promise<void> {
this.starting = null;
const { client } = this;
const dispose = this.disposeFn;
this.client = null;
if (client) {
await client.dispose().catch(swallow);
this.disposeFn = null;
if (dispose) {
await dispose();
}
if (this.containerOwned) {
await this.removeContainer();
}
}
get containerId(): string {
return this.containerName;
}
get isStarted(): boolean {
@@ -404,388 +97,22 @@ export class DockerSandboxProvider implements AgentOsSandboxProvider {
}
// ---------------------------------------------------------------------------
// DockerSandboxClient — real process lifecycle via process-group tracking
// Docker availability check
// ---------------------------------------------------------------------------
interface TrackedProcess {
readonly args?: string[];
readonly command: string;
readonly errFile: string;
exitCode: number | null;
readonly exitFile: string;
readonly id: string;
readonly outFile: string;
pid: number | null;
readonly pidFile: string;
readonly scriptFile: string;
readonly startedAt: number;
status: string;
}
class DockerSandboxClient implements AgentOsSandboxClient {
private readonly container: string;
private readonly workDir: string;
private nextId = 0;
private readonly processes = new Map<string, TrackedProcess>();
constructor(container: string, workDir: string) {
this.container = container;
this.workDir = workDir;
}
async runProcess(options: {
readonly command: string;
readonly args?: readonly string[];
readonly cwd?: string;
readonly env?: Readonly<Record<string, string>>;
readonly timeoutMs?: number;
}): Promise<AgentOsSandboxProcessResult> {
this.nextId += 1;
const id = `run-${this.nextId}`;
const cwd = options.cwd ?? this.workDir;
const fullCommand = options.args?.length
? `${options.command} ${options.args.map(shellQuote).join(" ")}`
: options.command;
let envPrefix = "";
let envPath: string | undefined;
if (options.env && Object.keys(options.env).length > 0) {
envPath = await stageEnvFile(this.container, options.env, id);
envPrefix = `set -a; . ${shellQuote(envPath)}; set +a; rm -f ${shellQuote(envPath)}; `;
}
const wrapped = `${envPrefix}cd ${shellQuote(cwd)} && ${fullCommand}`;
const start = Date.now();
try {
const result = await runDocker(
["exec", this.container, "sh", "-c", wrapped],
{ timeoutMs: options.timeoutMs }
);
return {
durationMs: Date.now() - start,
exitCode: result.exitCode,
stderr: result.stderr,
stdout: result.stdout,
timedOut: false,
};
} catch (error) {
if (envPath) {
await removeContainerFiles(this.container, [envPath]);
}
const timedOut = error instanceof Error && error.name === "AbortError";
return {
durationMs: Date.now() - start,
exitCode: null,
stderr: timedOut ? "Process timed out" : String(error),
stdout: "",
timedOut,
};
}
}
async createProcess(options: {
readonly command: string;
readonly args?: readonly string[];
readonly cwd?: string;
readonly env?: Readonly<Record<string, string>>;
}): Promise<AgentOsSandboxProcessInfo> {
this.nextId += 1;
const id = `proc-${this.nextId}`;
const cwd = options.cwd ?? this.workDir;
const fullCommand = options.args?.length
? `${options.command} ${options.args.map(shellQuote).join(" ")}`
: options.command;
const pidFile = `${PID_DIR}/${id}.pid`;
const outFile = `${PID_DIR}/${id}.out.log`;
const errFile = `${PID_DIR}/${id}.err.log`;
const exitFile = `${PID_DIR}/${id}.exit`;
const scriptFile = `${PID_DIR}/${id}.sh`;
let envPath: string | undefined;
if (options.env && Object.keys(options.env).length > 0) {
envPath = `${PID_DIR}/.env-${id}`;
}
// The launcher is staged as a file to avoid nested shell quoting; it
// sources (then deletes) the env file and records the real exit code.
const launcherLines = [
...(envPath === undefined
? []
: [
"set -a",
`. ${shellQuote(envPath)}`,
"set +a",
`rm -f ${shellQuote(envPath)}`,
]),
`cd ${shellQuote(cwd)}`,
fullCommand,
`echo "$?" > ${shellQuote(exitFile)}`,
];
const tracked: TrackedProcess = {
args: options.args ? [...options.args] : undefined,
command: options.command,
errFile,
exitCode: null,
exitFile,
id,
outFile,
pid: null,
pidFile,
scriptFile,
startedAt: Date.now(),
status: "running",
};
this.processes.set(id, tracked);
await writeContainerFile(
this.container,
scriptFile,
`${launcherLines.join("\n")}\n`
);
if (envPath) {
const envEntries = options.env ? Object.entries(options.env) : [];
const envLines = envEntries
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${key}=${shellQuote(value)}`)
.join("\n");
await writeContainerFile(this.container, envPath, `${envLines}\n`);
}
// Post-write assertion: the staged script must be nonempty before launch.
const stagedScript = await readContainerFile(this.container, scriptFile);
if (stagedScript.length === 0) {
throw new OrbSandboxError({
message: `Staged launcher ${scriptFile} is empty; refusing to launch`,
reason: "CommandFailed",
});
}
// Launch detached in its own session/process group, redirecting stdout and
// stderr to durable files, and capture the group PID via reliable $!.
const launch = `mkdir -p ${shellQuote(PID_DIR)} && setsid sh ${shellQuote(scriptFile)} > ${shellQuote(outFile)} 2> ${shellQuote(errFile)} < /dev/null & echo $! > ${shellQuote(pidFile)}`;
const result = await runDocker(
["exec", this.container, "sh", "-c", launch],
{ timeoutMs: 15_000 }
);
if (result.exitCode !== 0) {
tracked.status = "failed";
tracked.exitCode = result.exitCode;
await removeContainerFiles(this.container, [scriptFile, envPath]);
return this.toInfo(tracked);
}
await this.refreshStatus(tracked);
return this.toInfo(tracked);
}
async listProcesses(): Promise<{ processes: AgentOsSandboxProcessInfo[] }> {
for (const tracked of this.processes.values()) {
// eslint-disable-next-line no-await-in-loop -- reconcile each tracked process before reporting
await this.refreshStatus(tracked).catch(swallow);
}
return {
processes: [...this.processes.values()].map((p) => this.toInfo(p)),
};
}
async stopProcess(id: string): Promise<AgentOsSandboxProcessInfo> {
return await this.signalProcess(id, "TERM");
}
async killProcess(id: string): Promise<AgentOsSandboxProcessInfo> {
return await this.signalProcess(id, "KILL");
}
private async signalProcess(
id: string,
requested: "TERM" | "KILL"
): Promise<AgentOsSandboxProcessInfo> {
const tracked = this.processes.get(id);
if (!tracked) {
throw new OrbSandboxError({
message: `Unknown process ${id}`,
reason: "CommandFailed",
});
}
await this.refreshStatus(tracked);
if (tracked.status !== "running") {
return this.toInfo(tracked);
}
const pid = tracked.pid ?? (await this.readPid(tracked));
tracked.pid = pid;
if (pid === null) {
tracked.status = requested === "KILL" ? "killed" : "stopped";
return this.toInfo(tracked);
}
if (requested === "KILL") {
await signalProcessGroup(this.container, pid, "KILL");
tracked.status = "killed";
} else {
await signalProcessGroup(this.container, pid, "TERM");
const settled = await this.waitForExit(tracked, 5000);
if (settled) {
tracked.status = "stopped";
} else {
await signalProcessGroup(this.container, pid, "KILL");
tracked.status = "killed";
}
}
await this.refreshStatus(tracked);
return this.toInfo(tracked);
}
async getProcessLogs(
id: string,
options?: {
readonly stream?: "stdout" | "stderr" | "combined";
readonly tail?: number;
}
): Promise<AgentOsSandboxProcessLogs> {
const tracked = this.processes.get(id);
if (!tracked) {
throw new OrbSandboxError({
message: `Unknown process ${id}`,
reason: "CommandFailed",
});
}
await this.refreshStatus(tracked);
const wantStdout = options?.stream !== "stderr";
const wantStderr = options?.stream !== "stdout";
const entries: {
data: string;
stream: "stdout" | "stderr";
timestampMs?: number;
}[] = [];
if (wantStdout) {
const out = await readContainerFile(this.container, tracked.outFile);
if (out.length > 0) {
entries.push({
data: out,
stream: "stdout",
timestampMs: tracked.startedAt,
});
}
}
if (wantStderr) {
const err = await readContainerFile(this.container, tracked.errFile);
if (err.length > 0) {
entries.push({
data: err,
stream: "stderr",
timestampMs: tracked.startedAt,
});
}
}
const tail = options?.tail;
if (tail === undefined) {
return { entries };
}
return {
entries: entries.map((entry) => ({
...entry,
data: entry.data.split("\n").slice(-tail).join("\n"),
})),
};
}
// eslint-disable-next-line class-methods-use-this, require-await -- throws intentionally; async satisfies the interface contract
async sendProcessInput(): Promise<unknown> {
throw new OrbSandboxError({
message:
"Interactive process input is not supported by the Docker sandbox",
reason: "CommandFailed",
const checkDockerAvailable = Effect.fn("Docker.checkAvailable")(
function* checkDockerAvailable() {
yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
message: `Docker is not available: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "DockerUnavailable",
}),
try: async () => {
const { default: Docker } = await import("dockerode");
const docker = new Docker();
await docker.ping();
},
});
}
async dispose(): Promise<void> {
for (const id of this.processes.keys()) {
const tracked = this.processes.get(id);
if (!tracked) {
continue;
}
// eslint-disable-next-line no-await-in-loop -- each process is reconciled before the container is removed
await this.refreshStatus(tracked).catch(swallow);
if (tracked.status === "running") {
// eslint-disable-next-line no-await-in-loop -- sequential kill must settle before container removal
await this.killProcess(id).catch(swallow);
}
}
this.processes.clear();
}
// ---------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------
private async readPid(tracked: TrackedProcess): Promise<number | null> {
const raw = await readContainerFile(this.container, tracked.pidFile);
const pidText = raw.trim();
return /^\d+$/u.test(pidText) ? Number(pidText) : null;
}
/**
* Reconcile tracked status with the container: a recorded exit file means
* natural completion, otherwise the process group liveness decides.
*/
private async refreshStatus(tracked: TrackedProcess): Promise<void> {
const exitContents = await readContainerFile(
this.container,
tracked.exitFile
);
const exitRaw = exitContents.trim();
if (/^-?\d+$/u.test(exitRaw)) {
tracked.exitCode = Number(exitRaw);
if (tracked.status === "running") {
tracked.status = "exited";
}
return;
}
if (tracked.status !== "running") {
return;
}
const pid = tracked.pid ?? (await this.readPid(tracked));
tracked.pid = pid;
if (pid === null) {
tracked.status = "failed";
return;
}
const alive = await isProcessGroupAlive(this.container, pid);
if (alive) {
return;
}
tracked.status = "exited";
tracked.exitCode = null;
}
private async waitForExit(
tracked: TrackedProcess,
timeoutMs: number
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
// eslint-disable-next-line no-await-in-loop -- polling must probe sequentially
await sleep(250);
// eslint-disable-next-line no-await-in-loop -- polling must probe sequentially
await this.refreshStatus(tracked);
if (tracked.status !== "running") {
return true;
}
}
return false;
}
// eslint-disable-next-line class-methods-use-this -- instance method kept for readability
private toInfo(tracked: TrackedProcess): AgentOsSandboxProcessInfo {
return {
args: tracked.args,
command: tracked.command,
exitCode: tracked.exitCode,
id: tracked.id,
pid: tracked.pid,
status: tracked.status,
};
}
}
);

View File

@@ -268,9 +268,9 @@ export class OrbHandle {
// eslint-disable-next-line no-use-before-define -- module-level helper
const baseRef = `origin/${base}`;
// eslint-disable-next-line no-use-before-define -- module-level helper
const cloneCmd = `git clone --branch ${shellQuote(base)} --single-branch ${shellQuote(repoUrl)} /workspace/repository || git clone ${shellQuote(repoUrl)} /workspace/repository`;
const cloneCmd = `git clone --branch ${shellQuote(base)} --single-branch ${shellQuote(repoUrl)} /home/sandbox/repository || git clone ${shellQuote(repoUrl)} /home/sandbox/repository`;
// eslint-disable-next-line no-use-before-define -- module-level helper
const checkoutCmd = `cd /workspace/repository && git checkout -b ${shellQuote(branch)} ${shellQuote(baseRef)} 2>/dev/null || git checkout ${shellQuote(branch)} 2>/dev/null || true`;
const checkoutCmd = `cd /home/sandbox/repository && git checkout -b ${shellQuote(branch)} ${shellQuote(baseRef)} 2>/dev/null || git checkout ${shellQuote(branch)} 2>/dev/null || true`;
const result = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
@@ -279,8 +279,9 @@ export class OrbHandle {
}),
try: () =>
client.runProcess({
command: `${cloneCmd} && ${checkoutCmd}`,
cwd: "/workspace",
args: ["-c", `${cloneCmd} && ${checkoutCmd}`],
command: "sh",
cwd: "/home/sandbox",
timeoutMs: 300_000,
}),
});
@@ -301,8 +302,9 @@ export class OrbHandle {
}),
try: () =>
client.runProcess({
command: "mkdir -p /workspace/repository",
cwd: "/workspace",
args: ["-c", "mkdir -p /home/sandbox/repository"],
command: "sh",
cwd: "/home/sandbox",
}),
});
}
@@ -383,6 +385,12 @@ export class OrbHandle {
try: () => vm.writeFile(config.configPath, config.configJson),
});
// Restrict the config file containing the run-scoped gateway key.
yield* Effect.tryPromise({
catch: () => null, // eslint-disable-next-line no-empty-function -- best-effort hardening
try: () => vm.exec(`chmod 600 ${config.configPath}`),
}).pipe(Effect.ignore);
const agents = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSessionError({
@@ -526,7 +534,8 @@ export class OrbHandle {
}),
try: () =>
client.runProcess({
command: input.command,
args: ["-c", input.command],
command: "sh",
...(input.cwd === undefined ? {} : { cwd: input.cwd }),
...(input.env === undefined ? {} : { env: input.env }),
...(input.timeoutMs === undefined
@@ -677,12 +686,16 @@ export class OrbRuntime {
}),
try: () =>
AgentOs.create({
database: {
path: `/tmp/${orbId}.db`,
type: "sqlite_file",
},
sandbox: {
client: sandboxClient,
dispose: false,
mountPath: "/mnt/sandbox",
readOnly: false,
sandboxRoot: "/workspace",
sandboxRoot: "/home/sandbox",
},
software: [opencodePkg],
}),