feat(agents/orb): harden Docker sandbox lifecycle, proof staging, and tests

Fix detached process startup in docker-sandbox.ts: capture the real process
group PID via setsid, redirect stdout/stderr to durable log files, refresh
status and exit code after natural completion, make getProcessLogs return real
logs, and implement bounded TERM/KILL cleanup with no leftover containers.
Switch the adapter to node:child_process (supported by Bun) so tests execute
under vitest, add -i to docker exec so staged files reach cat, and assert the
launcher is nonempty before launch.

Ensure Orb creation disposes the VM before its sandbox on partial failure
(AgentOS creation or OpenCode linking), and dispose the VM before its sandbox
in OrbHandle.dispose. Fix the Effect state callbacks (tapError/sync/asVoid) and
prepareRepository branch/base shell quoting.

Split normalizeSessionEvent to stay under the complexity limit without
suppressing the rule. Raw outbound prompts stay unchanged; only persisted and
logged event copies are redacted.

Rewrite scripts/orb-proof.ts to emit explicit stable stage markers for Docker,
AgentOS/OpenCode, and the model turn; ORB_GATEWAY_* fall back safely to
AGENT_MODEL_* without printing secrets; an unreachable gateway is BLOCKED not
passed; container removal is verified after disposal.

Strengthen tests: drop the any-failure createOrb test and add focused Docker
lifecycle coverage (PID, logs, status, exit code, stop/kill, removal) guarded
only when Docker is unavailable.
This commit is contained in:
-Puter
2026-07-24 22:03:21 +05:30
parent b5c40755cf
commit 22ebb89ff0
18 changed files with 3573 additions and 2 deletions

View File

@@ -154,6 +154,7 @@
"name": "@code/agents",
"version": "0.0.0",
"dependencies": {
"@agentos-software/opencode": "0.2.7",
"@code/backend": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",

View File

@@ -59,7 +59,8 @@
"build:agents": "vp run --filter @code/agents build",
"docs:update": "bun run scripts/update-docs.ts",
"subtree": "bun run scripts/subtree.ts",
"fix": "ultracite fix"
"fix": "ultracite fix",
"orb:proof": "bun run scripts/orb-proof.ts"
},
"dependencies": {},
"devDependencies": {

View File

@@ -20,7 +20,8 @@
"convex": "catalog:",
"effect": "catalog:",
"hono": "4.12.31",
"valibot": "^1.4.2"
"valibot": "^1.4.2",
"@agentos-software/opencode": "0.2.7"
},
"devDependencies": {
"@code/config": "workspace:*",

View File

@@ -0,0 +1,107 @@
# Orb Runtime
One logical execution workspace for one ProjectIssue run. An Orb bundles an AgentOS actor, an OpenCode harness session, a Docker sandbox, a mounted repository workspace, project context, model-gateway configuration, process/log handling, normalized execution events, and a cleanup lifecycle.
## Architecture
```
┌──────────────────────────────────────────────────┐
│ OrbRuntime │
│ creates OrbHandle instances │
├──────────────────────────────────────────────────┤
│ OrbHandle │
│ ┌─────────────┐ ┌────────────────────────┐ │
│ │ AgentOS VM │ │ Docker Sandbox │ │
│ │ (OpenCode │◄──►│ (bun, tests, builds, │ │
│ │ ACP agent) │ │ repo checkout) │ │
│ └─────────────┘ └────────────────────────┘ │
│ │ │ │
│ session events runProcess / │
│ → normalized createProcess │
│ OrbEvent │
└──────────────────────────────────────────────────┘
```
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`.
## Domain model
| Type | Description |
| --- | --- |
| `OrbId` | Branded identifier for one Orb |
| `OrbRunId` | Branded identifier for one run attempt |
| `OrbSessionId` | Branded identifier for one OpenCode session |
| `OrbIdentity` | `{ projectId, runId, workUnitId }` — derives the actor key |
| `OrbState` | `creating → prepared → running → needs-input → completed/failed/cancelled → disposed` |
| `RunState` | `queued → provisioning → preparing → running → verifying → succeeded/failed/cancelled` |
| `OrbEvent` | Normalized, secret-free execution event |
Orb state and run state are separate state machines. The VM/sandbox lease state is never conflated with the product work-unit state.
## Tagged errors
| Error | Reasons |
| --- | --- |
| `OrbStateError` | `InvalidTransition`, `AlreadyDisposed`, `NotRunning` |
| `OrbSandboxError` | `DockerUnavailable`, `ContainerStart`, `CommandFailed`, `ContainerCleanup` |
| `OrbSessionError` | `OpenSession`, `PromptFailed`, `AgentNotInstalled`, `SessionNotFound` |
| `OrbConfigurationError` | `MissingGateway`, `MissingIdentity`, `InvalidModelConfig` |
## Environment variables
| Variable | Required | Description |
| --- | --- | --- |
| `ORB_PROOF` | Proof only | Set to `1` to run the proof fixture |
| `ORB_GATEWAY_API_KEY` | Proof only | Model gateway API key |
| `ORB_GATEWAY_BASE_URL` | Proof only | Model gateway base URL (OpenAI-compatible `/v1`) |
| `ORB_GATEWAY_MODEL` | Proof only | Model name |
| `ORB_GATEWAY_PROVIDER` | Proof only | Provider identifier |
| `ORB_DOCKER_IMAGE` | Optional | Docker image (default: `oven/bun:1.3-debian`) |
| `ORB_DOCKER_WORKSPACE` | Optional | Host workspace root (default: `/tmp/orb-workspaces`) |
| `RIVET_ENDPOINT` | Optional | AgentOS/RivetKit sidecar endpoint |
No permanent provider credentials are stored in project files. API keys are injected at runtime via the OpenCode config written to the VM filesystem, and all event output is passed through `redactSecrets`.
## 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
## Local startup
```bash
# Run the proof fixture
ORB_PROOF=1 \
ORB_GATEWAY_API_KEY=your-key \
ORB_GATEWAY_BASE_URL=https://ai.example.com/v1 \
ORB_GATEWAY_MODEL=glm-5.2 \
ORB_GATEWAY_PROVIDER=cheaptricks \
bun run scripts/orb-proof.ts
```
Stable markers: `ORB_PROOF_PASSED` (exit 0), `ORB_PROOF_BLOCKED` (exit 2), `ORB_PROOF_FAILED` (exit 1).
## Filesystem layout
```
Container (/workspace = host bind mount)
/workspace/repository/ — project checkout
/workspace/control/ — issue + context files
/tmp/orb-pids/ — process PID tracking
AgentOS VM
/mnt/sandbox/ — sandbox mount point
/mnt/sandbox/repository/ — repo (via sandbox)
/root/.config/opencode/ — OpenCode config
```
## 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.

View File

@@ -0,0 +1,287 @@
/* eslint-disable no-console -- diagnostic skip messages in a Docker-guarded suite */
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 { 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"));
});
});
};
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) => {
const [command = "docker", ...rest] = args;
const proc = spawn(command, rest, {
stdio: ["ignore", "pipe", "pipe"],
});
proc.on("error", () => resolve(null));
proc.on("close", (code) => resolve(code));
});
const dockerAvailable = async (): Promise<boolean> => {
try {
const code = await runCliExit([
"docker",
"version",
"--format",
"{{.Server.Version}}",
]);
return code === 0;
} catch {
return false;
}
};
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.
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 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;
};
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;
};
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);
const version = await client.runProcess({ command: "bun --version" });
expect(version.exitCode).toBe(0);
expect((version.stdout ?? "").trim()).toMatch(/\d/u);
expect(await containerExists(name)).toBe(true);
await provider.dispose();
expect(await containerExists(name)).toBe(false);
expect(provider.isStarted).toBe(false);
});
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();
}
});
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'",
});
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(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();
}
});
});
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",
})
)
);
expect(error).toBeInstanceOf(OrbSandboxError);
expect(error.reason).toBe("DockerUnavailable");
});
});
if (!HAVE_DOCKER) {
console.warn(
"[docker-sandbox.test.ts] Docker daemon unavailable; Docker lifecycle tests skipped."
);
}

View File

@@ -0,0 +1,791 @@
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";
import { OrbSandboxError } from "./domain";
// ---------------------------------------------------------------------------
// Docker sandbox — AgentOsSandboxProvider backed by the `docker` CLI
// ---------------------------------------------------------------------------
const DEFAULT_IMAGE = "oven/bun:1.3-debian";
const CONTAINER_WORKSPACE = "/workspace";
const PID_DIR = "/tmp/orb-pids";
// ---------------------------------------------------------------------------
// 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)
// ---------------------------------------------------------------------------
export interface DockerSandboxOptions {
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;
constructor(options: DockerSandboxOptions) {
this.containerName = options.containerName;
this.hostWorkspace = options.hostWorkspacePath;
this.image = options.image ?? DEFAULT_IMAGE;
this.workDir = options.workDir ?? CONTAINER_WORKSPACE;
}
static readonly create = Effect.fn("DockerSandboxProvider.create")(
function* createProvider(options: DockerSandboxOptions) {
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;
}
}
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);
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;
this.client = null;
if (client) {
await client.dispose().catch(swallow);
}
if (this.containerOwned) {
await this.removeContainer();
}
}
get containerId(): string {
return this.containerName;
}
get isStarted(): boolean {
return this.client !== null;
}
}
// ---------------------------------------------------------------------------
// DockerSandboxClient — real process lifecycle via process-group tracking
// ---------------------------------------------------------------------------
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",
});
}
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

@@ -0,0 +1,132 @@
import { Effect } from "effect";
import { describe, expect, it } from "vitest";
import {
orbActorKey,
OrbStateError,
transitionOrbState,
transitionRunState,
} from "./domain";
const runTransition = (from: string, to: string) =>
Effect.runSync(
Effect.flip(transitionOrbState({ from: from as never, to: to as never }))
);
const runTransitionOk = (from: string, to: string) =>
Effect.runSync(transitionOrbState({ from: from as never, to: to as never }));
const runRunState = (from: string, to: string) =>
Effect.runSync(
Effect.flip(transitionRunState({ from: from as never, to: to as never }))
);
const runRunStateOk = (from: string, to: string) =>
Effect.runSync(transitionRunState({ from: from as never, to: to as never }));
describe("orbActorKey", () => {
it("produces a stable key from the identity triple", () => {
const key = orbActorKey({
projectId: "prj-1",
runId: "run-1",
workUnitId: "wrk-1",
});
expect(key).toBe("project:prj-1:work:wrk-1:run:run-1");
});
it("preserves order across different identities", () => {
expect(orbActorKey({ projectId: "a", runId: "b", workUnitId: "c" })).toBe(
"project:a:work:c:run:b"
);
});
});
describe("transitionOrbState", () => {
it("allows creating -> prepared", () => {
expect(runTransitionOk("creating", "prepared")).toBe("prepared");
});
it("allows prepared -> running", () => {
expect(runTransitionOk("prepared", "running")).toBe("running");
});
it("allows running -> needs-input", () => {
expect(runTransitionOk("running", "needs-input")).toBe("needs-input");
});
it("allows running -> completed", () => {
expect(runTransitionOk("running", "completed")).toBe("completed");
});
it("allows completed -> disposed", () => {
expect(runTransitionOk("completed", "disposed")).toBe("disposed");
});
it("allows needs-input -> running (resume)", () => {
expect(runTransitionOk("needs-input", "running")).toBe("running");
});
it("rejects disposed -> running", () => {
const error = runTransition("disposed", "running");
expect(error).toBeInstanceOf(OrbStateError);
expect(error.reason).toBe("AlreadyDisposed");
});
it("rejects completed -> creating", () => {
const error = runTransition("completed", "creating");
expect(error).toBeInstanceOf(OrbStateError);
expect(error.reason).toBe("InvalidTransition");
});
it("rejects prepared -> needs-input (skipping running)", () => {
const error = runTransition("prepared", "needs-input");
expect(error).toBeInstanceOf(OrbStateError);
expect(error.reason).toBe("InvalidTransition");
});
it("rejects cancelled -> running", () => {
const error = runTransition("cancelled", "running");
expect(error).toBeInstanceOf(OrbStateError);
expect(error.reason).toBe("InvalidTransition");
});
});
describe("transitionRunState", () => {
it("allows queued -> provisioning", () => {
expect(runRunStateOk("queued", "provisioning")).toBe("provisioning");
});
it("allows provisioning -> preparing", () => {
expect(runRunStateOk("provisioning", "preparing")).toBe("preparing");
});
it("allows running -> verifying", () => {
expect(runRunStateOk("running", "verifying")).toBe("verifying");
});
it("allows verifying -> succeeded", () => {
expect(runRunStateOk("verifying", "succeeded")).toBe("succeeded");
});
it("allows running -> cancelled", () => {
expect(runRunStateOk("running", "cancelled")).toBe("cancelled");
});
it("rejects succeeded -> running (terminal)", () => {
const error = runRunState("succeeded", "running");
expect(error).toBeInstanceOf(OrbStateError);
expect(error.reason).toBe("InvalidTransition");
});
it("rejects failed -> running (terminal)", () => {
const error = runRunState("failed", "running");
expect(error).toBeInstanceOf(OrbStateError);
expect(error.reason).toBe("InvalidTransition");
});
it("rejects queued -> succeeded (skipping steps)", () => {
const error = runRunState("queued", "succeeded");
expect(error).toBeInstanceOf(OrbStateError);
expect(error.reason).toBe("InvalidTransition");
});
});

View File

@@ -0,0 +1,259 @@
/* eslint-disable max-classes-per-file -- each domain failure has a distinct tagged reason. */
import { Effect, Schema } from "effect";
const MeaningfulString = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
expected: "a non-empty string",
})
);
// ---------------------------------------------------------------------------
// Branded identifiers
// ---------------------------------------------------------------------------
export const OrbId = MeaningfulString.pipe(Schema.brand("OrbId"));
export type OrbId = typeof OrbId.Type;
export const OrbRunId = MeaningfulString.pipe(Schema.brand("OrbRunId"));
export type OrbRunId = typeof OrbRunId.Type;
export const OrbSessionId = MeaningfulString.pipe(Schema.brand("OrbSessionId"));
export type OrbSessionId = typeof OrbSessionId.Type;
// ---------------------------------------------------------------------------
// Actor identity — one Orb per project/issue/run triple
// ---------------------------------------------------------------------------
export const OrbIdentity = Schema.Struct({
projectId: MeaningfulString,
runId: MeaningfulString,
workUnitId: MeaningfulString,
});
export type OrbIdentity = typeof OrbIdentity.Type;
/** Stable RivetKit actor key derived from the identity triple. */
export const orbActorKey = (identity: OrbIdentity): string =>
`project:${identity.projectId}:work:${identity.workUnitId}:run:${identity.runId}`;
// ---------------------------------------------------------------------------
// State machines — product state stays separate from run state
// ---------------------------------------------------------------------------
export const OrbState = Schema.Literals([
"creating",
"prepared",
"running",
"needs-input",
"completed",
"failed",
"cancelled",
"disposed",
]);
export type OrbState = typeof OrbState.Type;
export const RunState = Schema.Literals([
"queued",
"provisioning",
"preparing",
"running",
"verifying",
"succeeded",
"failed",
"cancelled",
]);
export type RunState = typeof RunState.Type;
const ORB_TRANSITIONS: Readonly<Record<OrbState, readonly OrbState[]>> = {
cancelled: ["disposed"],
completed: ["disposed"],
creating: ["prepared", "running", "failed", "cancelled", "disposed"],
disposed: [],
failed: ["disposed"],
"needs-input": ["running", "completed", "failed", "cancelled", "disposed"],
prepared: ["running", "failed", "cancelled", "disposed"],
running: ["needs-input", "completed", "failed", "cancelled", "disposed"],
};
const RUN_TRANSITIONS: Readonly<Record<RunState, readonly RunState[]>> = {
cancelled: [],
failed: [],
preparing: ["running", "verifying", "failed", "cancelled"],
provisioning: ["preparing", "running", "failed", "cancelled"],
queued: ["provisioning", "preparing", "running", "failed", "cancelled"],
running: ["verifying", "succeeded", "failed", "cancelled"],
succeeded: [],
verifying: ["succeeded", "failed", "cancelled"],
};
// ---------------------------------------------------------------------------
// Tagged errors
// ---------------------------------------------------------------------------
export const OrbStateErrorReason = Schema.Literals([
"InvalidTransition",
"AlreadyDisposed",
"NotRunning",
]);
export type OrbStateErrorReason = typeof OrbStateErrorReason.Type;
export class OrbStateError extends Schema.TaggedErrorClass<OrbStateError>()(
"OrbStateError",
{
from: Schema.String,
message: Schema.String,
reason: OrbStateErrorReason,
to: Schema.String,
}
) {}
export const OrbSandboxErrorReason = Schema.Literals([
"DockerUnavailable",
"ContainerStart",
"CommandFailed",
"ContainerCleanup",
]);
export type OrbSandboxErrorReason = typeof OrbSandboxErrorReason.Type;
export class OrbSandboxError extends Schema.TaggedErrorClass<OrbSandboxError>()(
"OrbSandboxError",
{
message: Schema.String,
reason: OrbSandboxErrorReason,
}
) {}
export const OrbSessionErrorReason = Schema.Literals([
"OpenSession",
"PromptFailed",
"AgentNotInstalled",
"SessionNotFound",
"PermissionDenied",
]);
export type OrbSessionErrorReason = typeof OrbSessionErrorReason.Type;
export class OrbSessionError extends Schema.TaggedErrorClass<OrbSessionError>()(
"OrbSessionError",
{
message: Schema.String,
reason: OrbSessionErrorReason,
}
) {}
export const OrbConfigurationErrorReason = Schema.Literals([
"MissingGateway",
"MissingIdentity",
"InvalidModelConfig",
]);
export type OrbConfigurationErrorReason =
typeof OrbConfigurationErrorReason.Type;
export class OrbConfigurationError extends Schema.TaggedErrorClass<OrbConfigurationError>()(
"OrbConfigurationError",
{
message: Schema.String,
reason: OrbConfigurationErrorReason,
}
) {}
// ---------------------------------------------------------------------------
// Configuration types
// ---------------------------------------------------------------------------
export const OrbModelGatewayConfig = Schema.Struct({
apiKey: MeaningfulString,
baseUrl: MeaningfulString,
model: MeaningfulString,
provider: MeaningfulString,
});
export type OrbModelGatewayConfig = typeof OrbModelGatewayConfig.Type;
export const OrbProjectContext = Schema.Struct({
artifacts: Schema.Array(
Schema.Struct({
content: Schema.String,
path: MeaningfulString,
})
),
contextFiles: Schema.Array(
Schema.Struct({
content: Schema.String,
path: MeaningfulString,
})
),
issueBody: MeaningfulString,
issueTitle: MeaningfulString,
repositoryUrl: Schema.UndefinedOr(MeaningfulString),
});
export type OrbProjectContext = typeof OrbProjectContext.Type;
// ---------------------------------------------------------------------------
// Transition helpers
// ---------------------------------------------------------------------------
export const transitionOrbState = Effect.fn("Orb.transitionState")(
function* transitionOrbState(input: {
readonly from: OrbState;
readonly to: OrbState;
}) {
if (input.from === input.to) {
return input.to;
}
if (input.from === "disposed") {
return yield* Effect.fail(
new OrbStateError({
from: input.from,
message: "Orb is already disposed",
reason: "AlreadyDisposed",
to: input.to,
})
);
}
if (!ORB_TRANSITIONS[input.from].includes(input.to)) {
return yield* Effect.fail(
new OrbStateError({
from: input.from,
message: `Cannot transition orb from ${input.from} to ${input.to}`,
reason: "InvalidTransition",
to: input.to,
})
);
}
return input.to;
}
);
export const transitionRunState = Effect.fn("Orb.transitionRunState")(
function* transitionRunState(input: {
readonly from: RunState;
readonly to: RunState;
}) {
if (input.from === input.to) {
return input.to;
}
if (
input.from === "succeeded" ||
input.from === "failed" ||
input.from === "cancelled"
) {
return yield* Effect.fail(
new OrbStateError({
from: input.from,
message: `Run is already in terminal state ${input.from}`,
reason: "InvalidTransition",
to: input.to,
})
);
}
if (!RUN_TRANSITIONS[input.from].includes(input.to)) {
return yield* Effect.fail(
new OrbStateError({
from: input.from,
message: `Cannot transition run from ${input.from} to ${input.to}`,
reason: "InvalidTransition",
to: input.to,
})
);
}
return input.to;
}
);

View File

@@ -0,0 +1,134 @@
/* eslint-disable no-non-null-assertion -- test assertions on defined events */
import { describe, expect, it } from "vitest";
import { makeOrbEvent, normalizeSessionEvent, redactSecrets } from "./events";
describe("redactSecrets", () => {
it("redacts api_key patterns", () => {
const input = "Using api_key=sk-abc123def456ghi789jkl012mno345";
const result = redactSecrets(input);
expect(result).not.toContain("sk-abc123");
expect(result).toContain("[REDACTED]");
});
it("redacts Bearer tokens", () => {
const input = "Authorization: Bearer eyJhbGciOiJIUzI1";
const result = redactSecrets(input);
expect(result).toContain("Bearer [REDACTED]");
expect(result).not.toContain("eyJhbGciOiJIUzI1");
});
it("redacts token= patterns", () => {
const input = 'token: "my-secret-token-value"';
const result = redactSecrets(input);
expect(result).not.toContain("my-secret-token-value");
});
it("preserves non-secret text", () => {
expect(redactSecrets("just regular text")).toBe("just regular text");
});
});
describe("makeOrbEvent", () => {
it("creates an event with sequence and timestamp", () => {
const event = makeOrbEvent(5, "session_opened", { text: "opened" });
expect(event.sequence).toBe(5);
expect(event.type).toBe("session_opened");
expect(event.text).toBe("opened");
expect(event.timestamp).toBeDefined();
});
it("creates an event with command and exitCode", () => {
const event = makeOrbEvent(3, "command_executed", {
command: "bun test",
exitCode: 0,
});
expect(event.command).toBe("bun test");
expect(event.exitCode).toBe(0);
});
});
describe("normalizeSessionEvent", () => {
it("normalizes agent_message_chunk", () => {
const event = normalizeSessionEvent({
rawText: "Working on the fix",
sequence: 5,
sessionId: "sess-1",
timestamp: "2026-07-24T12:00:00Z",
type: "agent_message_chunk",
});
expect(event).toBeDefined();
expect(event!.type).toBe("agent_message_chunk");
expect(event!.text).toBe("Working on the fix");
expect(event!.sequence).toBe(5);
});
it("normalizes agent_message to agent_message_completed", () => {
const event = normalizeSessionEvent({
content: [{ text: "Done", type: "text" }],
sequence: 10,
sessionId: "sess-1",
timestamp: "2026-07-24T12:00:05Z",
type: "agent_message",
});
expect(event).toBeDefined();
expect(event!.type).toBe("agent_message_completed");
expect(event!.text).toBe("Done");
});
it("normalizes tool_call", () => {
const event = normalizeSessionEvent({
sequence: 3,
sessionId: "sess-1",
timestamp: "2026-07-24T12:00:01Z",
title: "bash",
toolCallId: "tc-1",
type: "tool_call",
});
expect(event).toBeDefined();
expect(event!.type).toBe("tool_call_started");
expect(event!.toolName).toBe("bash");
expect(event!.toolCallId).toBe("tc-1");
});
it("normalizes permission_request", () => {
const event = normalizeSessionEvent({
sequence: 7,
sessionId: "sess-1",
timestamp: "2026-07-24T12:00:03Z",
type: "permission_request",
});
expect(event).toBeDefined();
expect(event!.type).toBe("permission_requested");
});
it("returns undefined for unmapped types", () => {
const event = normalizeSessionEvent({
sequence: 1,
sessionId: "sess-1",
timestamp: "2026-07-24T12:00:00Z",
type: "session_config",
});
expect(event).toBeUndefined();
});
it("returns undefined when type is missing", () => {
const event = normalizeSessionEvent({
sequence: 1,
sessionId: "sess-1",
});
expect(event).toBeUndefined();
});
it("redacts secrets in persisted event copies", () => {
const event = normalizeSessionEvent({
rawText: "Using api_key=sk-secret123456789012345678",
sequence: 2,
sessionId: "sess-1",
timestamp: "2026-07-24T12:00:00Z",
type: "agent_message_chunk",
});
expect(event).toBeDefined();
expect(event!.text).not.toContain("sk-secret");
});
});

View File

@@ -0,0 +1,216 @@
import { Schema } from "effect";
// ---------------------------------------------------------------------------
// Normalized Orb events — domain-meaningful, secret-free
// ---------------------------------------------------------------------------
export const OrbEventVariant = Schema.Literals([
"session_opened",
"agent_message_chunk",
"agent_message_completed",
"agent_thought_chunk",
"tool_call_started",
"tool_call_completed",
"permission_requested",
"permission_denied",
"command_executed",
"session_failed",
"session_closed",
"vm_booted",
"vm_shutdown",
]);
export type OrbEventVariant = typeof OrbEventVariant.Type;
export const OrbEvent = Schema.Struct({
command: Schema.UndefinedOr(Schema.String),
exitCode: Schema.UndefinedOr(Schema.Int),
sequence: Schema.Number,
text: Schema.UndefinedOr(Schema.String),
timestamp: Schema.String,
toolCallId: Schema.UndefinedOr(Schema.String),
toolName: Schema.UndefinedOr(Schema.String),
type: OrbEventVariant,
});
export type OrbEvent = typeof OrbEvent.Type;
/** Construct an OrbEvent with auto-incrementing sequence and timestamp. */
export const makeOrbEvent = (
sequence: number,
type: OrbEventVariant,
fields?: Partial<Omit<OrbEvent, "sequence" | "timestamp" | "type">>
): OrbEvent =>
({
command: fields?.command ?? undefined,
exitCode: fields?.exitCode ?? undefined,
sequence,
text: fields?.text ?? undefined,
timestamp: new Date().toISOString(),
toolCallId: fields?.toolCallId ?? undefined,
toolName: fields?.toolName ?? undefined,
type,
}) as unknown as OrbEvent;
// ---------------------------------------------------------------------------
// Secret redaction — applied only to persisted/logged event copies,
// never to prompts sent to the model.
// ---------------------------------------------------------------------------
const REDACT_PATTERNS = [
/(?:api[_-]?key|token|secret|password|credential)["'\s:=]+(?<value>[^\s"'},]+)/giu,
/sk-(?<key>[a-zA-Z0-9]{20,})/gu,
/Bearer\s+[a-zA-Z0-9._-]+/gu,
];
export const redactSecrets = (text: string): string => {
let result = text;
for (const pattern of REDACT_PATTERNS) {
result = result.replaceAll(pattern, (match) =>
match.toLowerCase().includes("bearer")
? "Bearer [REDACTED]"
: "[REDACTED]"
);
}
return result;
};
// ---------------------------------------------------------------------------
// Translation from AgentOS session stream entries to normalized OrbEvent
// ---------------------------------------------------------------------------
interface AcpSessionUpdateLike {
readonly type?: string;
readonly sessionUpdate?: string;
readonly rawText?: string;
readonly text?: string;
readonly content?: unknown;
readonly toolCallId?: string | null;
readonly toolCallStatus?: string;
readonly title?: string | null;
readonly sessionId?: string;
}
interface SessionStreamEntryLike {
readonly afterSequence?: number;
readonly content?: unknown;
readonly durability?: string;
readonly rawText?: string;
readonly sequence?: number;
readonly sessionId?: string;
readonly sessionUpdate?: string;
readonly text?: string;
readonly timestamp?: string;
readonly title?: string | null;
readonly toolCallId?: string | null;
readonly type?: string;
}
const IGNORED_TYPES = new Set([
"session_config",
"agent_description",
"agent_capability",
]);
const MESSAGE_TYPES = new Set([
"agent_message_chunk",
"agent_message",
"agent_thought_chunk",
]);
const TOOL_CALL_TYPES = new Set([
"tool_call",
"tool_call_status",
"tool_call_update",
]);
const extractText = (entry: AcpSessionUpdateLike): string | undefined => {
if (entry.rawText !== undefined) {
return entry.rawText;
}
if (entry.text !== undefined) {
return entry.text;
}
if (typeof entry.content === "string") {
return entry.content;
}
if (Array.isArray(entry.content)) {
const texts = entry.content
.filter(
(block): block is { readonly type: string; readonly text?: unknown } =>
typeof block === "object" && block !== null && "type" in block
)
.map((block) => (typeof block.text === "string" ? block.text : undefined))
.filter((text): text is string => text !== undefined);
return texts.length > 0 ? texts.join("") : undefined;
}
return undefined;
};
const optionalToolFields = (
raw: SessionStreamEntryLike
): { toolCallId?: string; toolName?: string } => ({
...(raw.toolCallId === null || raw.toolCallId === undefined
? {}
: { toolCallId: raw.toolCallId }),
...(raw.title === null || raw.title === undefined
? {}
: { toolName: raw.title }),
});
const fromMessage = (
type: string,
sequence: number,
raw: SessionStreamEntryLike
): OrbEvent | undefined => {
const text = extractText(raw);
if (text === undefined) {
// agent_message completes even without text; chunk/thought do not.
return type === "agent_message"
? makeOrbEvent(sequence, "agent_message_completed", {})
: undefined;
}
const variant = (
type === "agent_message" ? "agent_message_completed" : type
) as OrbEventVariant;
return makeOrbEvent(sequence, variant, { text: redactSecrets(text) });
};
const fromToolCall = (
type: string,
sequence: number,
raw: SessionStreamEntryLike
): OrbEvent =>
makeOrbEvent(
sequence,
type === "tool_call" ? "tool_call_started" : "tool_call_completed",
optionalToolFields(raw)
);
/**
* Translate one AgentOS SessionStreamEntry into zero or one normalized OrbEvent.
* Returns undefined for event types that have no domain-meaningful mapping yet.
* Secret redaction is applied so persisted event copies never leak credentials.
*/
export const normalizeSessionEvent = (
raw: SessionStreamEntryLike
): OrbEvent | undefined => {
const type = raw.type ?? raw.sessionUpdate;
if (type === undefined) {
return undefined;
}
const sequence = raw.sequence ?? 0;
if (IGNORED_TYPES.has(type)) {
return undefined;
}
if (type === "permission_request") {
return makeOrbEvent(sequence, "permission_requested", {
text: "Permission requested",
});
}
if (MESSAGE_TYPES.has(type)) {
return fromMessage(type, sequence, raw);
}
if (TOOL_CALL_TYPES.has(type)) {
return fromToolCall(type, sequence, raw);
}
return undefined;
};

View File

@@ -0,0 +1,7 @@
// oxlint-disable-next-line no-barrel-file -- The Orb module exposes its public surface here.
export * from "./domain";
export * from "./events";
export * from "./docker-sandbox";
export * from "./opencode-config";
export * from "./permission-policy";
export * from "./runtime";

View File

@@ -0,0 +1,82 @@
/* eslint-disable no-non-null-assertion -- test assertions on defined objects */
import { Effect } from "effect";
import { describe, expect, it } from "vitest";
import { OrbConfigurationError } from "./domain";
import {
opencodeSetupCommands,
prepareOpenCodeConfig,
} from "./opencode-config";
const validGateway = {
apiKey: "test-key-123",
baseUrl: "https://gateway.example.com/v1",
model: "test-model",
provider: "test-provider",
};
const validContext = {
artifacts: [],
contextFiles: [],
issueBody: "Fix the bug",
issueTitle: "Bug fix",
repositoryUrl: undefined,
};
describe("prepareOpenCodeConfig", () => {
it("produces valid config JSON with provider and model", () => {
const result = Effect.runSync(
prepareOpenCodeConfig({
context: validContext,
gateway: validGateway,
})
);
const parsed = JSON.parse(result.configJson);
expect(parsed.model).toBe("test-provider/test-model");
expect(parsed.provider["test-provider"].baseUrl).toBe(
"https://gateway.example.com/v1"
);
expect(parsed.provider["test-provider"].apiKey).toBe("test-key-123");
});
it("sets config path under opencode config directory", () => {
const result = Effect.runSync(
prepareOpenCodeConfig({
context: validContext,
gateway: validGateway,
})
);
expect(result.configPath).toContain("opencode");
expect(result.configPath).toContain("config.json");
expect(result.instructionsPath).toBe("/workspace/control/issue.md");
});
it("rejects empty base URL", () => {
const error = Effect.runSync(
Effect.flip(
prepareOpenCodeConfig({
context: validContext,
gateway: { ...validGateway, baseUrl: " " },
})
)
);
expect(error).toBeInstanceOf(OrbConfigurationError);
expect(error.reason).toBe("InvalidModelConfig");
});
});
describe("opencodeSetupCommands", () => {
it("produces mkdir, write, and chmod commands", () => {
const config = Effect.runSync(
prepareOpenCodeConfig({
context: validContext,
gateway: validGateway,
})
);
const commands = opencodeSetupCommands(config);
expect(commands.length).toBe(3);
expect(commands[0]).toContain("mkdir");
expect(commands[1]).toContain("cat >");
expect(commands[2]).toContain("chmod 600");
});
});

View File

@@ -0,0 +1,76 @@
import { Effect } from "effect";
import { OrbConfigurationError } from "./domain";
import type { OrbModelGatewayConfig, OrbProjectContext } from "./domain";
// ---------------------------------------------------------------------------
// OpenCode configuration — prepared inside the AgentOS VM filesystem
// ---------------------------------------------------------------------------
const OPENCODE_CONFIG_DIR = "/root/.config/opencode";
const OPENCODE_CONFIG_PATH = `${OPENCODE_CONFIG_DIR}/config.json`;
const AGENT_INSTRUCTIONS_PATH = "/workspace/control/issue.md";
export interface PreparedOpenCodeConfig {
readonly configJson: string;
readonly configPath: string;
readonly instructionsPath: string;
}
const validateGateway = (
gateway: OrbModelGatewayConfig
): Effect.Effect<void, OrbConfigurationError> =>
gateway.baseUrl.trim().length === 0
? Effect.fail(
new OrbConfigurationError({
message: "Model gateway base URL must not be empty",
reason: "InvalidModelConfig",
})
)
: Effect.void;
/**
* Build the OpenCode configuration JSON and file layout for one Orb run.
* The configuration points OpenCode at the model gateway with run-scoped
* credentials injected at runtime — never committed to project files.
*/
export const prepareOpenCodeConfig = Effect.fn("Orb.prepareOpenCodeConfig")(
function* prepareOpenCodeConfig(input: {
readonly context: OrbProjectContext;
readonly gateway: OrbModelGatewayConfig;
}) {
yield* validateGateway(input.gateway);
const config = {
$schema: "https://opencode.ai/config.json",
model: `${input.gateway.provider}/${input.gateway.model}`,
provider: {
[input.gateway.provider]: {
apiKey: input.gateway.apiKey,
baseUrl: input.gateway.baseUrl,
models: {
[input.gateway.model]: {
name: input.gateway.model,
},
},
},
},
};
const configJson = JSON.stringify(config, null, 2);
return {
configJson,
configPath: OPENCODE_CONFIG_PATH,
instructionsPath: AGENT_INSTRUCTIONS_PATH,
} satisfies PreparedOpenCodeConfig;
}
);
/** Shell commands that stage the OpenCode config directory inside a VM. */
export const opencodeSetupCommands = (config: PreparedOpenCodeConfig) =>
[
`mkdir -p ${OPENCODE_CONFIG_DIR}`,
`cat > ${config.configPath} << 'ORB_EOF'\n${config.configJson}\nORB_EOF`,
`chmod 600 ${config.configPath}`,
] as const;

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { evaluatePermission, isDangerousPermission } from "./permission-policy";
const allowOption = { id: "allow", title: "Allow" };
const denyOption = { id: "deny", title: "Deny" };
describe("isDangerousPermission", () => {
it("flags merge operations", () => {
expect(
isDangerousPermission({
requestId: "r1",
toolCall: { title: "git merge main" },
})
).toBe(true);
});
it("flags production deployment", () => {
expect(
isDangerousPermission({
requestId: "r2",
toolCall: { title: "deploy to production" },
})
).toBe(true);
});
it("flags secret access", () => {
expect(
isDangerousPermission({
requestId: "r3",
toolCall: { title: "read secrets" },
})
).toBe(true);
});
it("flags credential access", () => {
expect(
isDangerousPermission({
requestId: "r4",
toolCall: { title: "access credentials" },
})
).toBe(true);
});
it("does not flag safe operations", () => {
expect(
isDangerousPermission({
requestId: "r5",
toolCall: { title: "run tests" },
})
).toBe(false);
});
it("does not flag file edits", () => {
expect(
isDangerousPermission({
requestId: "r6",
toolCall: { title: "edit src/index.ts" },
})
).toBe(false);
});
});
describe("evaluatePermission", () => {
it("allows safe operations and picks allow option", () => {
const decision = evaluatePermission({
options: [allowOption, denyOption],
requestId: "r1",
toolCall: { title: "run bun test" },
});
expect(decision.allow).toBe(true);
expect(decision.optionId).toBe("allow");
});
it("denies dangerous operations and picks deny option", () => {
const decision = evaluatePermission({
options: [allowOption, denyOption],
requestId: "r2",
toolCall: { title: "git merge main" },
});
expect(decision.allow).toBe(false);
expect(decision.optionId).toBe("deny");
});
it("falls back to last option when no explicit deny exists", () => {
const decision = evaluatePermission({
options: [
{ id: "ok", title: "OK" },
{ id: "cancel", title: "Cancel" },
],
requestId: "r3",
toolCall: { title: "deploy to production" },
});
expect(decision.allow).toBe(false);
expect(decision.optionId).toBe("cancel");
});
it("handles empty options", () => {
const decision = evaluatePermission({
options: [],
requestId: "r4",
toolCall: { title: "run tests" },
});
expect(decision.allow).toBe(true);
expect(decision.optionId).toBeUndefined();
});
});

View File

@@ -0,0 +1,90 @@
/**
* Narrow permission policy for Orb sessions.
*
* Denies merge, production deployment, secret access, and external
* communications. Allows all other operations. Used as the callback for
* ACP permission_request events with permissionPolicy: "ask".
*/
interface PermissionOption {
readonly id: string;
readonly title?: string;
readonly description?: string;
}
interface PermissionRequestLike {
readonly requestId: string;
readonly options?: readonly PermissionOption[];
readonly toolCall?: {
readonly title?: string;
readonly kind?: string;
readonly name?: string;
};
}
const DENY_PATTERNS = [
/\bmerge\b/iu,
/\bdeploy\b.*\bprod/iu,
/\bproduction\b/iu,
/\bsecret/iu,
/\bcredential/iu,
/\bpassword\b/iu,
/\bapi[_-]?key\b/iu,
/\bpush\s+to\s+(?<branch>main|master)\b/iu,
];
/** Evaluate whether a permission request is dangerous. */
export const isDangerousPermission = (
request: PermissionRequestLike
): boolean => {
const text = [
request.toolCall?.title,
request.toolCall?.kind,
request.toolCall?.name,
]
.filter((s): s is string => typeof s === "string")
.join(" ");
return DENY_PATTERNS.some((pattern) => pattern.test(text));
};
/**
* Pick the option ID that matches the desired decision. Falls back to the
* last option (typically deny) for safety when no explicit deny option exists,
* or the first option (typically allow) when no explicit allow option exists.
*/
const pickOption = (
options: readonly PermissionOption[],
allow: boolean
): string | undefined => {
if (options.length === 0) {
return undefined;
}
if (allow) {
const match = options.find(
(o) =>
/allow|accept|yes|permit/iu.test(o.title ?? "") ||
/allow|accept|yes|permit/iu.test(o.description ?? "")
);
return match?.id ?? options[0]?.id;
}
const match = options.find(
(o) =>
/deny|reject|no|cancel/iu.test(o.title ?? "") ||
/deny|reject|no|cancel/iu.test(o.description ?? "")
);
return match?.id ?? options.at(-1)?.id;
};
export interface PermissionDecision {
readonly allow: boolean;
readonly optionId: string | undefined;
}
/** Evaluate a permission request and return the decision. */
export const evaluatePermission = (
request: PermissionRequestLike
): PermissionDecision => {
const dangerous = isDangerousPermission(request);
const optionId = pickOption(request.options ?? [], !dangerous);
return { allow: !dangerous, optionId };
};

View File

@@ -0,0 +1,125 @@
/* eslint-disable no-non-null-assertion -- test assertions on defined objects */
import { Effect } from "effect";
import { describe, expect, it } from "vitest";
import { OrbConfigurationError } from "./domain";
import { makeOrbEvent } from "./events";
import type { OrbEvent } from "./events";
import { OrbHandle, OrbRuntime } from "./runtime";
const validGateway = {
apiKey: "test-key",
baseUrl: "https://gw.example.com/v1",
model: "m1",
provider: "p1",
};
const validContext = {
artifacts: [],
contextFiles: [],
issueBody: "Do the thing",
issueTitle: "Thing",
repositoryUrl: undefined,
};
const validIdentity = {
projectId: "prj-1",
runId: "run-1",
workUnitId: "wrk-1",
};
describe("OrbRuntime.createOrb validation", () => {
it("rejects missing gateway API key before touching Docker", () => {
const runtime = new OrbRuntime();
// This must fail at validation, not at Docker — if Docker is the failure,
// that indicates the validation ordering is wrong.
const error = Effect.runSync(
Effect.flip(
runtime.createOrb({
context: validContext,
docker: {
containerName: "orb-test",
hostWorkspacePath: "/tmp/orb-test",
},
gateway: { ...validGateway, apiKey: " " },
identity: validIdentity,
})
)
);
expect(error).toBeInstanceOf(OrbConfigurationError);
expect(error.reason).toBe("MissingGateway");
});
});
describe("OrbHandle event lifecycle", () => {
it("emits events to listeners", () => {
const handle = new OrbHandle(
"orb-x" as never,
"run-x" as never,
validIdentity
);
const events: OrbEvent[] = [];
handle.onEvent((e) => events.push(e));
handle.emitEvent(makeOrbEvent(1, "vm_booted", { text: "booted" }));
handle.emitEvent(
makeOrbEvent(2, "command_executed", { command: "ls", exitCode: 0 })
);
expect(events.length).toBe(2);
expect(events[0]!.type).toBe("vm_booted");
expect(events[1]!.type).toBe("command_executed");
expect(events[1]!.command).toBe("ls");
});
it("unsubscribes listeners correctly", () => {
const handle = new OrbHandle(
"orb-y" as never,
"run-y" as never,
validIdentity
);
const events: OrbEvent[] = [];
const unsub = handle.onEvent((e) => events.push(e));
handle.emitEvent(makeOrbEvent(1, "session_opened"));
unsub();
handle.emitEvent(makeOrbEvent(2, "session_closed"));
expect(events.length).toBe(1);
});
});
describe("OrbHandle state transitions", () => {
it("starts in creating state", () => {
const handle = new OrbHandle(
"orb-z" as never,
"run-z" as never,
validIdentity
);
expect(handle.state).toBe("creating");
});
it("transitions to prepared then running", () => {
const handle = new OrbHandle(
"orb-a" as never,
"run-a" as never,
validIdentity
);
Effect.runSync(handle.setOrbState("prepared"));
expect(handle.state).toBe("prepared");
Effect.runSync(handle.setOrbState("running"));
expect(handle.state).toBe("running");
});
it("rejects invalid transition", () => {
const handle = new OrbHandle(
"orb-b" as never,
"run-b" as never,
validIdentity
);
const error = Effect.runSync(
Effect.flip(handle.setOrbState("needs-input"))
);
expect(error.reason).toBe("InvalidTransition");
});
});

View File

@@ -0,0 +1,750 @@
/* eslint-disable prefer-destructuring -- field captures before mutation are intentional */
/* eslint-disable max-classes-per-file -- runtime and handle form one service. */
import opencodePkg from "@agentos-software/opencode";
import { AgentOs } from "@rivet-dev/agentos-core";
import type { SessionStreamEntry } from "@rivet-dev/agentos-core";
import { Effect } from "effect";
import type { DockerSandboxOptions } from "./docker-sandbox";
import { DockerSandboxProvider } from "./docker-sandbox";
import {
OrbConfigurationError,
OrbSandboxError,
OrbSessionError,
orbActorKey,
transitionOrbState,
transitionRunState,
} from "./domain";
import type {
OrbIdentity,
OrbId,
OrbModelGatewayConfig,
OrbProjectContext,
OrbRunId,
OrbSessionId,
OrbState,
OrbStateError,
RunState,
} from "./domain";
import type { OrbEvent } from "./events";
import { makeOrbEvent, normalizeSessionEvent, redactSecrets } from "./events";
import { prepareOpenCodeConfig } from "./opencode-config";
import { evaluatePermission } from "./permission-policy";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface OrbEnv {
readonly dockerImage?: string;
readonly dockerWorkspace?: string;
readonly rivetEndpoint?: string;
}
export interface OrbCreateInput {
readonly context: OrbProjectContext;
readonly docker: Omit<DockerSandboxOptions, "image">;
readonly gateway: OrbModelGatewayConfig;
readonly identity: OrbIdentity;
}
// ---------------------------------------------------------------------------
// OrbHandle
// ---------------------------------------------------------------------------
export class OrbHandle {
readonly id: OrbId;
readonly runId: OrbRunId;
readonly identity: OrbIdentity;
readonly actorKey: string;
private vm: AgentOs | null = null;
private dockerProvider: DockerSandboxProvider | null = null;
private sessionId: string | undefined;
private orbState: OrbState = "creating";
private runState: RunState = "queued";
private eventSequence = 0;
private readonly eventListeners = new Set<(event: OrbEvent) => void>();
private unsubscribeSession: (() => void) | null = null;
private context: OrbProjectContext | undefined;
private gateway: OrbModelGatewayConfig | undefined;
constructor(id: OrbId, runId: OrbRunId, identity: OrbIdentity) {
this.id = id;
this.runId = runId;
this.identity = identity;
this.actorKey = orbActorKey(identity);
}
get state(): OrbState {
return this.orbState;
}
get currentSessionId(): string | undefined {
return this.sessionId;
}
get docker(): DockerSandboxProvider | null {
return this.dockerProvider;
}
get vmInstance(): AgentOs | null {
return this.vm;
}
// -----------------------------------------------------------------------
// Event API
// -----------------------------------------------------------------------
onEvent(listener: (event: OrbEvent) => void): () => void {
this.eventListeners.add(listener);
return () => {
this.eventListeners.delete(listener);
};
}
emitEvent(event: OrbEvent): void {
for (const listener of this.eventListeners) {
listener(event);
}
}
private nextSequence(): number {
this.eventSequence += 1;
return this.eventSequence;
}
// -----------------------------------------------------------------------
// State transitions
// -----------------------------------------------------------------------
readonly setOrbState = (to: OrbState): Effect.Effect<void, OrbStateError> =>
transitionOrbState({ from: this.orbState, to }).pipe(
Effect.tap((next) =>
Effect.sync(() => {
this.orbState = next;
})
),
Effect.asVoid
);
readonly setRunState = (to: RunState): Effect.Effect<void, OrbStateError> =>
transitionRunState({ from: this.runState, to }).pipe(
Effect.tap((next) =>
Effect.sync(() => {
this.runState = next;
})
),
Effect.asVoid
);
// -----------------------------------------------------------------------
// Internal attachment
// -----------------------------------------------------------------------
_attachVm(vm: AgentOs): void {
this.vm = vm;
this.unsubscribeSession = vm.onSessionEvent((entry: SessionStreamEntry) => {
const normalized = normalizeSessionEvent(entry);
if (normalized) {
this.emitEvent({ ...normalized, sequence: this.nextSequence() });
}
if (
typeof entry === "object" &&
entry !== null &&
"type" in entry &&
entry.type === "permission_request"
) {
void this.handlePermissionRequest(
entry as unknown as {
requestId: string;
options: {
description?: string;
id: string;
title?: string;
}[];
toolCall?: { kind?: string; name?: string; title?: string };
}
);
}
});
this.emitEvent(
makeOrbEvent(this.nextSequence(), "vm_booted", { text: "VM booted" })
);
}
_attachDocker(provider: DockerSandboxProvider): void {
this.dockerProvider = provider;
}
_configure(input: {
readonly context: OrbProjectContext;
readonly gateway: OrbModelGatewayConfig;
}): void {
this.context = input.context;
this.gateway = input.gateway;
}
// -----------------------------------------------------------------------
// Permission handler
// -----------------------------------------------------------------------
private async handlePermissionRequest(request: {
readonly requestId: string;
readonly options: {
description?: string;
id: string;
title?: string;
}[];
readonly toolCall?: { kind?: string; name?: string; title?: string };
}): Promise<void> {
const vm = this.vm;
const sessionId = this.sessionId;
if (!vm || !sessionId) {
return;
}
const decision = evaluatePermission(request);
if (!decision.allow) {
this.emitEvent(
makeOrbEvent(this.nextSequence(), "permission_denied", {
text: `Permission denied for: ${request.toolCall?.title ?? "unknown"}`,
})
);
}
if (decision.optionId) {
await vm
.respondPermission({
optionId: decision.optionId,
requestId: request.requestId,
sessionId,
})
.catch(
// eslint-disable-next-line no-empty-function -- best-effort permission response
() => {}
);
}
}
// -----------------------------------------------------------------------
// Repository preparation
// -----------------------------------------------------------------------
readonly prepareRepository = Effect.fn("Orb.prepareRepository")(
function* prepareRepository(
this: OrbHandle,
input: { readonly baseBranch?: string; readonly branchName?: string }
) {
if (!this.dockerProvider) {
return yield* Effect.fail(
new OrbSandboxError({
message: "Docker sandbox is not attached",
reason: "ContainerStart",
})
);
}
yield* this.setOrbState("prepared");
yield* this.setRunState("provisioning");
const client = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
message: `Failed to start Docker sandbox: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "ContainerStart",
}),
try: () => {
const dp = this.dockerProvider;
if (!dp) {
throw new Error("Docker provider detached");
}
return dp.start();
},
});
const repoUrl = this.context?.repositoryUrl;
if (repoUrl) {
const branch = input.branchName ?? "main";
const base = input.baseBranch ?? "main";
// 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`;
// 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 result = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
message: `Repository checkout failed: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "CommandFailed",
}),
try: () =>
client.runProcess({
command: `${cloneCmd} && ${checkoutCmd}`,
cwd: "/workspace",
timeoutMs: 300_000,
}),
});
if (result.exitCode !== 0) {
return yield* Effect.fail(
new OrbSandboxError({
message: `Repository clone failed: ${result.stderr}`,
reason: "CommandFailed",
})
);
}
} else {
yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
message: `Failed to create workspace: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "CommandFailed",
}),
try: () =>
client.runProcess({
command: "mkdir -p /workspace/repository",
cwd: "/workspace",
}),
});
}
const ctx = this.context;
const vm = this.vm;
if (ctx && vm) {
yield* Effect.tryPromise({
catch: () => null, // eslint-disable-next-line no-empty-function -- best-effort context staging
try: () =>
vm
.mkdir("/mnt/sandbox/control", { recursive: true })
.then(() =>
vm.writeFile(
"/mnt/sandbox/control/issue.md",
`# ${ctx.issueTitle}\n\n${ctx.issueBody}\n`
)
),
});
for (const file of ctx.contextFiles) {
yield* Effect.tryPromise({
catch: () => null,
try: () =>
vm.writeFile(`/mnt/sandbox/control/${file.path}`, file.content),
});
}
}
yield* this.setRunState("preparing");
}
);
// -----------------------------------------------------------------------
// OpenCode session
// -----------------------------------------------------------------------
readonly openSession = Effect.fn("Orb.openSession")(
function* openSession(this: OrbHandle) {
if (!this.vm) {
return yield* Effect.fail(
new OrbSessionError({
message: "AgentOS VM is not attached",
reason: "OpenSession",
})
);
}
if (!this.gateway || !this.context) {
return yield* Effect.fail(
new OrbConfigurationError({
message: "Orb is not configured with gateway and context",
reason: "MissingGateway",
})
);
}
const config = yield* prepareOpenCodeConfig({
context: this.context,
gateway: this.gateway,
});
const vm = this.vm;
yield* Effect.tryPromise({
catch: (cause) =>
new OrbSessionError({
message: `Failed to create config directory: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "OpenSession",
}),
try: () => vm.mkdir("/root/.config/opencode", { recursive: true }),
});
yield* Effect.tryPromise({
catch: (cause) =>
new OrbSessionError({
message: `Failed to write OpenCode config: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "OpenSession",
}),
try: () => vm.writeFile(config.configPath, config.configJson),
});
const agents = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSessionError({
message: `Failed to list agents: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "AgentNotInstalled",
}),
try: () => vm.listAgents(),
});
const hasOpencode = agents.some(
(a) => a.id === "opencode" && a.installed
);
if (!hasOpencode) {
return yield* Effect.fail(
new OrbSessionError({
message: "OpenCode agent is not installed in the VM",
reason: "AgentNotInstalled",
})
);
}
yield* Effect.tryPromise({
catch: (cause) =>
new OrbSessionError({
message: `Failed to open OpenCode session: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "OpenSession",
}),
try: () =>
vm.openSession({
agent: "opencode",
cwd: "/mnt/sandbox/repository",
permissionPolicy: "ask",
skipOsInstructions: false,
}),
});
const sessionInfo = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSessionError({
message: `Failed to read session info: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "OpenSession",
}),
try: () => vm.getSession(),
});
this.sessionId = sessionInfo.sessionId;
this.emitEvent(
makeOrbEvent(this.nextSequence(), "session_opened", {
text: `Session ${sessionInfo.sessionId} opened`,
})
);
yield* this.setOrbState("running");
yield* this.setRunState("running");
return sessionInfo.sessionId as OrbSessionId;
}
);
// -----------------------------------------------------------------------
// Send task — raw prompt to model, redacted copy in events only
// -----------------------------------------------------------------------
readonly sendTask = Effect.fn("Orb.sendTask")(function* sendTask(
this: OrbHandle,
prompt: string
) {
if (!this.vm || !this.sessionId) {
return yield* Effect.fail(
new OrbSessionError({
message: "No active OpenCode session",
reason: "SessionNotFound",
})
);
}
// eslint-disable-next-line prefer-destructuring -- captured before mutation
const vm = this.vm;
// eslint-disable-next-line prefer-destructuring -- captured before mutation
const sessionId = this.sessionId;
// Send raw prompt — no redaction of outgoing content.
const result = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSessionError({
message: `Prompt failed: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "PromptFailed",
}),
try: () =>
vm.prompt({
content: [{ text: prompt, type: "text" }],
sessionId,
}),
});
return result;
});
// -----------------------------------------------------------------------
// Execute command via Docker
// -----------------------------------------------------------------------
readonly executeCommand = Effect.fn("Orb.executeCommand")(
function* executeCommand(
this: OrbHandle,
input: {
readonly command: string;
readonly cwd?: string;
readonly env?: Readonly<Record<string, string>>;
readonly timeoutMs?: number;
}
) {
if (!this.dockerProvider) {
return yield* Effect.fail(
new OrbSandboxError({
message: "Docker sandbox is not attached",
reason: "ContainerStart",
})
);
}
const client = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
message: `Failed to get sandbox client: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "ContainerStart",
}),
try: () => {
const dp = this.dockerProvider;
if (!dp) {
throw new Error("Docker provider detached");
}
return dp.start();
},
});
const result = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
message: `Command failed: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "CommandFailed",
}),
try: () =>
client.runProcess({
command: input.command,
...(input.cwd === undefined ? {} : { cwd: input.cwd }),
...(input.env === undefined ? {} : { env: input.env }),
...(input.timeoutMs === undefined
? {}
: { timeoutMs: input.timeoutMs }),
}),
});
// Emit redacted copy for logs/UI.
this.emitEvent(
makeOrbEvent(this.nextSequence(), "command_executed", {
command: redactSecrets(input.command),
exitCode: result.exitCode ?? undefined,
})
);
return result;
}
);
// -----------------------------------------------------------------------
// Cancel / dispose
// -----------------------------------------------------------------------
readonly cancel = Effect.fn("Orb.cancel")(function* cancel(this: OrbHandle) {
// eslint-disable-next-line prefer-destructuring -- captured before mutation
const vm = this.vm;
// eslint-disable-next-line prefer-destructuring -- captured before mutation
const sessionId = this.sessionId;
if (vm && sessionId) {
yield* Effect.tryPromise({
catch: () => null,
try: () => vm.cancelPrompt({ sessionId }),
}).pipe(Effect.ignore);
}
yield* this.setOrbState("cancelled");
yield* this.setRunState("cancelled");
this.emitEvent(
makeOrbEvent(this.nextSequence(), "session_closed", {
text: "Orb cancelled",
})
);
});
readonly dispose = Effect.fn("Orb.dispose")(
function* dispose(this: OrbHandle) {
// eslint-disable-next-line prefer-destructuring -- captured before mutation
const vm = this.vm;
// eslint-disable-next-line prefer-destructuring -- captured before mutation
const sessionId = this.sessionId;
// eslint-disable-next-line prefer-destructuring -- captured before mutation
const docker = this.dockerProvider;
if (vm && sessionId) {
yield* Effect.tryPromise({
catch: () => null,
try: () => vm.cancelPrompt({ sessionId }),
}).pipe(Effect.ignore);
}
// Dispose the VM before its sandbox so the mount unbinds cleanly.
if (vm) {
if (this.unsubscribeSession) {
this.unsubscribeSession();
this.unsubscribeSession = null;
}
yield* Effect.tryPromise({
catch: () => null,
try: () => vm.dispose(),
}).pipe(Effect.ignore);
}
if (docker) {
yield* Effect.tryPromise({
catch: () => null,
try: () => docker.dispose(),
}).pipe(Effect.ignore);
}
yield* this.setOrbState("disposed");
this.eventListeners.clear();
this.vm = null;
this.dockerProvider = null;
this.sessionId = undefined;
}
);
}
// ---------------------------------------------------------------------------
// OrbRuntime
// ---------------------------------------------------------------------------
export class OrbRuntime {
private readonly orbs = new Map<string, OrbHandle>();
private readonly env: OrbEnv;
constructor(env: OrbEnv = {}) {
this.env = env;
}
readonly createOrb = Effect.fn("OrbRuntime.createOrb")(function* createOrb(
this: OrbRuntime,
input: OrbCreateInput
) {
if (!input.gateway.apiKey.trim()) {
return yield* Effect.fail(
new OrbConfigurationError({
message: "Model gateway API key is required",
reason: "MissingGateway",
})
);
}
const orbId =
`orb-${input.identity.projectId}-${input.identity.runId}` as OrbId;
const runId = `run-${input.identity.runId}` as OrbRunId;
const handle = new OrbHandle(orbId, runId, input.identity);
handle._configure({
context: input.context,
gateway: input.gateway,
});
// 1. Create and start Docker sandbox (the provider owns its container).
const dockerOptions: DockerSandboxOptions = {
...input.docker,
...(this.env.dockerImage === undefined
? {}
: { image: this.env.dockerImage }),
};
const provider = yield* DockerSandboxProvider.create(dockerOptions);
handle._attachDocker(provider);
// 2-3. Start the sandbox, create the AgentOS VM, and link OpenCode. Any
// failure here disposes the VM before its sandbox so neither leaks.
let createdVm: AgentOs | null = null;
const vm = yield* Effect.gen(function* vm() {
const sandboxClient = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
message: `Failed to start Docker sandbox: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "ContainerStart",
}),
try: () => provider.start(),
});
const created = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSessionError({
message: `Failed to create AgentOS VM: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "OpenSession",
}),
try: () =>
AgentOs.create({
sandbox: {
client: sandboxClient,
dispose: false,
mountPath: "/mnt/sandbox",
readOnly: false,
sandboxRoot: "/workspace",
},
software: [opencodePkg],
}),
});
createdVm = created;
yield* Effect.tryPromise({
catch: (cause) =>
new OrbSessionError({
message: `Failed to link OpenCode: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "AgentNotInstalled",
}),
try: () => created.linkSoftware({ path: opencodePkg.packagePath }),
});
return created;
}).pipe(
Effect.tapError(() =>
Effect.tryPromise({
catch: () => null,
// eslint-disable-next-line no-use-before-define -- module-level cleanup helper
try: () => disposeVmBeforeSandbox(createdVm, provider),
}).pipe(Effect.ignore)
)
);
// 4. Attach VM to handle.
handle._attachVm(vm);
this.orbs.set(orbId, handle);
return handle;
});
getOrb(id: string): OrbHandle | undefined {
return this.orbs.get(id);
}
listOrbs(): readonly OrbHandle[] {
return [...this.orbs.values()];
}
}
// ---------------------------------------------------------------------------
// Partial-failure cleanup — dispose the VM before its sandbox
// ---------------------------------------------------------------------------
const disposeVmBeforeSandbox = async (
vm: AgentOs | null,
docker: DockerSandboxProvider
): Promise<void> => {
if (vm) {
try {
await vm.dispose();
} catch {
// best-effort; container removal below is the hard guarantee
}
}
await docker.dispose();
};
// ---------------------------------------------------------------------------
// Shell quoting
// ---------------------------------------------------------------------------
// eslint-disable-next-line no-use-before-define -- module-level helper
const shellQuote = (value: string): string =>
`'${value.replaceAll("'", `'"'"'`)}'`;

405
scripts/orb-proof.ts Normal file
View File

@@ -0,0 +1,405 @@
/**
* Orb Runtime Proof Fixture
*
* Opt-in integration proof that exercises the Orb lifecycle in distinct,
* independently reported stages, each with a stable marker:
*
* STAGE 1 Docker sandbox lifecycle ORB_STAGE_DOCKER_OK / _BLOCKED
* STAGE 2 AgentOS VM + OpenCode session ORB_STAGE_AGENTOS_OK / _BLOCKED
* STAGE 3 Model turn via the gateway ORB_STAGE_MODEL_TURN_OK / _BLOCKED
*
* A missing or unreachable model gateway is BLOCKED, never passed. The proof
* only prints ORB_PROOF_PASSED when the model turn completes. It never prints
* gateway secrets; only provider/model identifiers and reachability labels.
*
* Gateway credentials resolve from ORB_GATEWAY_* with safe fallbacks to the
* shared AGENT_MODEL_* variables. Container removal is always verified after
* disposal.
*
* Usage:
* ORB_PROOF=1 \
* ORB_GATEWAY_API_KEY=... \
* ORB_GATEWAY_BASE_URL=https://ai.example.com/v1 \
* ORB_GATEWAY_MODEL=glm-5.2 \
* ORB_GATEWAY_PROVIDER=cheaptricks \
* bun run scripts/orb-proof.ts
*/
/* eslint-disable no-console -- proof fixture is a CLI tool */
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { Effect } from "effect";
import { DockerSandboxProvider } from "../packages/agents/src/orb/docker-sandbox";
import type { DockerSandboxOptions } from "../packages/agents/src/orb/docker-sandbox";
import type { OrbEvent } from "../packages/agents/src/orb/events";
import { OrbRuntime } from "../packages/agents/src/orb/runtime";
import type { OrbHandle } from "../packages/agents/src/orb/runtime";
const MARKER = {
blocked: "ORB_PROOF_BLOCKED",
failed: "ORB_PROOF_FAILED",
passed: "ORB_PROOF_PASSED",
} as const;
const STAGE = {
agentosBlocked: "ORB_STAGE_AGENTOS_BLOCKED",
agentosOk: "ORB_STAGE_AGENTOS_OK",
dockerBlocked: "ORB_STAGE_DOCKER_BLOCKED",
dockerOk: "ORB_STAGE_DOCKER_OK",
modelBlocked: "ORB_STAGE_MODEL_TURN_BLOCKED",
modelOk: "ORB_STAGE_MODEL_TURN_OK",
} as const;
interface Gateway {
apiKey: string;
baseUrl: string;
model: string;
provider: string;
}
const envValue = (key: string): string | undefined => process.env[key];
const resolveGateway = (): Gateway | null => {
const apiKey =
envValue("ORB_GATEWAY_API_KEY") ?? envValue("AGENT_MODEL_API_KEY");
const baseUrl =
envValue("ORB_GATEWAY_BASE_URL") ?? envValue("AGENT_MODEL_BASE_URL");
const model = envValue("ORB_GATEWAY_MODEL") ?? envValue("AGENT_MODEL_NAME");
const provider =
envValue("ORB_GATEWAY_PROVIDER") ?? envValue("AGENT_MODEL_PROVIDER");
if (
!apiKey?.trim() ||
!baseUrl?.trim() ||
!model?.trim() ||
!provider?.trim()
) {
return null;
}
return { apiKey, baseUrl, model, provider };
};
const runCli = async (args: readonly string[]): Promise<string> => {
try {
const proc = Bun.spawn([...args], { stderr: "pipe", stdout: "pipe" });
const stdout = await new Response(proc.stdout).text();
await proc.exited;
return stdout;
} catch {
return "";
}
};
const dockerVersion = async (): Promise<string | null> => {
const out = await runCli([
"docker",
"version",
"--format",
"{{.Server.Version}}",
]);
const trimmed = out.trim();
return trimmed.length > 0 ? trimmed : null;
};
const containerExists = async (name: string): Promise<boolean> => {
const listing = await runCli([
"docker",
"ps",
"-a",
"--filter",
`name=^${name}$`,
"--format",
"{{.ID}}",
]);
return listing.trim().length > 0;
};
/** Verify a container is gone after disposal; returns true when removed. */
const verifyRemoved = async (name: string): Promise<boolean> => {
const present = await containerExists(name);
console.log(
` [verify] container ${name} ${present ? "STILL PRESENT" : "removed"}`
);
return !present;
};
const TINY_PROJECT: Record<string, string> = {
"index.test.ts": `import { add } from "./index";\nimport { expect, test } from "bun:test";\n\ntest("add", () => {\n expect(add(1, 2)).toBe(3);\n});\n`,
"index.ts": `export function add(a: number, b: number): number {\n return a + b;\n}\n`,
"package.json": `{"name":"tiny","scripts":{"test":"bun test"}}\n`,
};
const prepareProject = async (hostWorkspace: string): Promise<void> => {
await mkdir(path.join(hostWorkspace, "repository"), { recursive: true });
for (const [file, content] of Object.entries(TINY_PROJECT)) {
// eslint-disable-next-line no-await-in-loop -- sequential file writes are intentional
await writeFile(path.join(hostWorkspace, "repository", file), content);
}
};
// ---------------------------------------------------------------------------
// STAGE 1 — Docker sandbox lifecycle (standalone, no sidecar required)
// ---------------------------------------------------------------------------
const stageDocker = async (image: string | undefined): Promise<boolean> => {
const version = await dockerVersion();
if (!version) {
console.log("[docker] Docker daemon not available");
console.log(STAGE.dockerBlocked);
return false;
}
console.log(`[docker] server version ${version}`);
const container = `orb-proof-docker-${Date.now()}`;
const hostWorkspace = `/tmp/orb-proof-docker-${Date.now()}`;
const options: DockerSandboxOptions =
image === undefined
? { containerName: container, hostWorkspacePath: hostWorkspace }
: { containerName: container, hostWorkspacePath: hostWorkspace, image };
try {
await prepareProject(hostWorkspace);
const provider = await Effect.runPromise(
DockerSandboxProvider.create(options)
);
const client = await provider.start();
console.log("[docker] running bun install in sandbox...");
const install = await client.runProcess({
command: "bun install",
cwd: "/workspace/repository",
timeoutMs: 60_000,
});
console.log(` bun install exit: ${install.exitCode}`);
console.log("[docker] running bun test in sandbox...");
const test = await client.runProcess({
command: "bun test",
cwd: "/workspace/repository",
timeoutMs: 60_000,
});
console.log(` bun test exit: ${test.exitCode}`);
await provider.dispose();
const removed = await verifyRemoved(container);
if (install.exitCode !== 0 || test.exitCode !== 0 || !removed) {
console.log("[docker] sandbox lifecycle did not complete cleanly");
console.log(STAGE.dockerBlocked);
return false;
}
console.log(STAGE.dockerOk);
return true;
} catch (error) {
console.log(
`[docker] stage failed: ${error instanceof Error ? error.message : String(error)}`
);
await runCli(["docker", "rm", "-f", container]);
await verifyRemoved(container);
console.log(STAGE.dockerBlocked);
return false;
}
};
// ---------------------------------------------------------------------------
// STAGE 2 — AgentOS VM + OpenCode session
// ---------------------------------------------------------------------------
const stageAgentOs = async (
gateway: Gateway,
image: string | undefined,
events: OrbEvent[]
): Promise<{ container: string; handle: OrbHandle } | null> => {
const container = `orb-proof-${Date.now()}`;
const hostWorkspace = `/tmp/orb-proof-${Date.now()}`;
const runtime = new OrbRuntime(
image === undefined ? {} : { dockerImage: image }
);
const createResult = await Effect.runPromise(
Effect.match(
runtime.createOrb({
context: {
artifacts: [],
contextFiles: [],
issueBody: "Run bun test and report the result.",
issueTitle: "Proof: run tests",
},
docker: { containerName: container, hostWorkspacePath: hostWorkspace },
gateway,
identity: {
projectId: "proof",
runId: `proof-${Date.now()}`,
workUnitId: "proof-wu",
},
}),
{
onFailure: (error) => ({ error, ok: false as const }),
onSuccess: (handle) => ({ handle, ok: true as const }),
}
)
);
if (!createResult.ok) {
console.log(
`[agentos] creation failed (${createResult.error.reason}): ${createResult.error.message}`
);
await verifyRemoved(container);
console.log(STAGE.agentosBlocked);
return null;
}
const { handle } = createResult;
handle.onEvent((event) => {
events.push(event);
const summary = (event.text ?? event.command ?? event.type).slice(0, 160);
console.log(` [event] ${event.type}: ${summary}`);
});
try {
const vm = handle.vmInstance;
if (!vm) {
throw new Error("AgentOS VM is not attached");
}
const agents = await vm.listAgents();
const opencode = agents.find((a) => a.id === "opencode" && a.installed);
if (!opencode) {
throw new Error("OpenCode agent is not installed in the VM");
}
console.log("[agentos] OpenCode agent available");
await Effect.runPromise(handle.openSession());
console.log(`[agentos] session opened: ${handle.currentSessionId ?? "?"}`);
console.log(STAGE.agentosOk);
return { container, handle };
} catch (error) {
console.log(
`[agentos] stage failed: ${error instanceof Error ? error.message : String(error)}`
);
await Effect.runPromise(handle.dispose().pipe(Effect.ignore)).catch(() => {
// best-effort cleanup
});
await verifyRemoved(container);
console.log(STAGE.agentosBlocked);
return null;
}
};
// ---------------------------------------------------------------------------
// STAGE 3 — Model turn through the gateway
// ---------------------------------------------------------------------------
const stageModelTurn = async (handle: OrbHandle): Promise<boolean> => {
const vm = handle.vmInstance;
const sessionId = handle.currentSessionId;
if (!vm || !sessionId) {
console.log("[model] no active session to drive a model turn");
console.log(STAGE.modelBlocked);
return false;
}
try {
console.log("[model] sending bounded turn to gateway...");
const result = await vm.prompt({
content: [
{ text: "Reply with exactly: ORB_PROOF_MODEL_ECHO", type: "text" },
],
sessionId,
});
console.log(
`[model] turn completed (stopReason=${result.stopReason ?? "?"})`
);
console.log(STAGE.modelOk);
return true;
} catch (error) {
// An unreachable/missing gateway surfaces here as a prompt failure.
console.log(
`[model] gateway turn failed: ${error instanceof Error ? error.message : String(error)}`
);
console.log(STAGE.modelBlocked);
return false;
}
};
// ---------------------------------------------------------------------------
// Orchestration
// ---------------------------------------------------------------------------
const runProof = async (): Promise<number> => {
const image = envValue("ORB_DOCKER_IMAGE");
const events: OrbEvent[] = [];
// STAGE 1 — Docker sandbox.
console.log("[stage 1] Docker sandbox lifecycle...");
const dockerOk = await stageDocker(image);
if (!dockerOk) {
console.log(`\n${MARKER.blocked}`);
return 2;
}
// STAGE 2 + 3 — AgentOS/OpenCode + model turn.
const gateway = resolveGateway();
if (!gateway) {
console.error(
"[gateway] missing config: set ORB_GATEWAY_* (or AGENT_MODEL_* fallbacks)."
);
console.log(STAGE.agentosBlocked);
console.log(`\n${MARKER.blocked}`);
return 2;
}
// Identifiers only — never the key or base URL.
console.log(`[gateway] provider=${gateway.provider} model=${gateway.model}`);
console.log("[stage 2] AgentOS VM + OpenCode session...");
const orb = await stageAgentOs(gateway, image, events);
if (!orb) {
console.log(`\n${MARKER.blocked}`);
return 2;
}
console.log("[stage 3] model turn...");
let modelOk = false;
try {
modelOk = await stageModelTurn(orb.handle);
} finally {
console.log("[dispose] disposing Orb...");
await Effect.runPromise(orb.handle.dispose().pipe(Effect.ignore)).catch(
() => {
// best-effort cleanup
}
);
const removed = await verifyRemoved(orb.container);
if (!removed) {
console.log("[dispose] container removal could not be verified");
}
}
console.log(
`\nStreamed ${events.length} normalized events (${[...new Set(events.map((e) => e.type))].join(", ")}).`
);
if (modelOk) {
console.log(`\n${MARKER.passed}`);
return 0;
}
console.log(`\n${MARKER.blocked}`);
return 2;
};
// Entry point
if (envValue("ORB_PROOF") !== "1") {
console.error(
"Set ORB_PROOF=1 to run the proof fixture (requires Docker + model gateway)."
);
console.error(MARKER.blocked);
process.exit(2);
}
(async () => {
try {
const code = await runProof();
process.exit(code);
} catch (error) {
console.error("[proof] unexpected failure:", error);
console.error(MARKER.failed);
process.exit(1);
}
})();