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:
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user