diff --git a/bun.lock b/bun.lock index 8b519d1..dd0a19a 100644 --- a/bun.lock +++ b/bun.lock @@ -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:*", diff --git a/package.json b/package.json index 0975630..bbafa16 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/agents/package.json b/packages/agents/package.json index a1e5390..121c974 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -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:*", diff --git a/packages/agents/src/orb/README.md b/packages/agents/src/orb/README.md new file mode 100644 index 0000000..2c99592 --- /dev/null +++ b/packages/agents/src/orb/README.md @@ -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. diff --git a/packages/agents/src/orb/docker-sandbox.test.ts b/packages/agents/src/orb/docker-sandbox.test.ts new file mode 100644 index 0000000..43233ca --- /dev/null +++ b/packages/agents/src/orb/docker-sandbox.test.ts @@ -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 => { + 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 => { + 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 => + // 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 => { + try { + const code = await runCliExit([ + "docker", + "version", + "--format", + "{{.Server.Version}}", + ]); + return code === 0; + } catch { + return false; + } +}; + +const containerExists = async (name: string): Promise => { + 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 => { + const list = await client.listProcesses(); + return list.processes.find((p) => p.id === id)?.status; +}; + +const waitForStatus = async ( + check: () => Promise, + target: string, + timeoutMs = 8000 +): Promise => { + 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." + ); +} diff --git a/packages/agents/src/orb/docker-sandbox.ts b/packages/agents/src/orb/docker-sandbox.ts new file mode 100644 index 0000000..eb615f9 --- /dev/null +++ b/packages/agents/src/orb/docker-sandbox.ts @@ -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 => { + 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 => { + 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 => + // 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 => { + 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 => { + // -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 => { + 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 => { + 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 `-` reaches every member of the group. + */ +const signalProcessGroup = async ( + container: string, + pid: number, + signal: "TERM" | "KILL" | "0" +): Promise => { + 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 => { + 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>, + id: string +): Promise => { + 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 | 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + + 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>; + readonly timeoutMs?: number; + }): Promise { + 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>; + }): Promise { + 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 { + return await this.signalProcess(id, "TERM"); + } + + async killProcess(id: string): Promise { + return await this.signalProcess(id, "KILL"); + } + + private async signalProcess( + id: string, + requested: "TERM" | "KILL" + ): Promise { + 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 { + 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 { + throw new OrbSandboxError({ + message: + "Interactive process input is not supported by the Docker sandbox", + reason: "CommandFailed", + }); + } + + async dispose(): Promise { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/packages/agents/src/orb/domain.test.ts b/packages/agents/src/orb/domain.test.ts new file mode 100644 index 0000000..0d2a2b1 --- /dev/null +++ b/packages/agents/src/orb/domain.test.ts @@ -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"); + }); +}); diff --git a/packages/agents/src/orb/domain.ts b/packages/agents/src/orb/domain.ts new file mode 100644 index 0000000..c08cdb1 --- /dev/null +++ b/packages/agents/src/orb/domain.ts @@ -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> = { + 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> = { + 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", + { + 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", + { + 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", + { + 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", + { + 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; + } +); diff --git a/packages/agents/src/orb/events.test.ts b/packages/agents/src/orb/events.test.ts new file mode 100644 index 0000000..e73ddaa --- /dev/null +++ b/packages/agents/src/orb/events.test.ts @@ -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"); + }); +}); diff --git a/packages/agents/src/orb/events.ts b/packages/agents/src/orb/events.ts new file mode 100644 index 0000000..7b6cf6d --- /dev/null +++ b/packages/agents/src/orb/events.ts @@ -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> +): 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:=]+(?[^\s"'},]+)/giu, + /sk-(?[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; +}; diff --git a/packages/agents/src/orb/index.ts b/packages/agents/src/orb/index.ts new file mode 100644 index 0000000..046a564 --- /dev/null +++ b/packages/agents/src/orb/index.ts @@ -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"; diff --git a/packages/agents/src/orb/opencode-config.test.ts b/packages/agents/src/orb/opencode-config.test.ts new file mode 100644 index 0000000..60c8cc3 --- /dev/null +++ b/packages/agents/src/orb/opencode-config.test.ts @@ -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"); + }); +}); diff --git a/packages/agents/src/orb/opencode-config.ts b/packages/agents/src/orb/opencode-config.ts new file mode 100644 index 0000000..4e3cf1a --- /dev/null +++ b/packages/agents/src/orb/opencode-config.ts @@ -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 => + 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; diff --git a/packages/agents/src/orb/permission-policy.test.ts b/packages/agents/src/orb/permission-policy.test.ts new file mode 100644 index 0000000..3d14bb6 --- /dev/null +++ b/packages/agents/src/orb/permission-policy.test.ts @@ -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(); + }); +}); diff --git a/packages/agents/src/orb/permission-policy.ts b/packages/agents/src/orb/permission-policy.ts new file mode 100644 index 0000000..bd11051 --- /dev/null +++ b/packages/agents/src/orb/permission-policy.ts @@ -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+(?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 }; +}; diff --git a/packages/agents/src/orb/runtime.test.ts b/packages/agents/src/orb/runtime.test.ts new file mode 100644 index 0000000..3624894 --- /dev/null +++ b/packages/agents/src/orb/runtime.test.ts @@ -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"); + }); +}); diff --git a/packages/agents/src/orb/runtime.ts b/packages/agents/src/orb/runtime.ts new file mode 100644 index 0000000..25713bc --- /dev/null +++ b/packages/agents/src/orb/runtime.ts @@ -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; + 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 => + transitionOrbState({ from: this.orbState, to }).pipe( + Effect.tap((next) => + Effect.sync(() => { + this.orbState = next; + }) + ), + Effect.asVoid + ); + + readonly setRunState = (to: RunState): Effect.Effect => + 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 { + 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>; + 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(); + 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 => { + 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("'", `'"'"'`)}'`; diff --git a/scripts/orb-proof.ts b/scripts/orb-proof.ts new file mode 100644 index 0000000..7ee6b4a --- /dev/null +++ b/scripts/orb-proof.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + const present = await containerExists(name); + console.log( + ` [verify] container ${name} ${present ? "STILL PRESENT" : "removed"}` + ); + return !present; +}; + +const TINY_PROJECT: Record = { + "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 => { + 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 => { + 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 => { + 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 => { + 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); + } +})();