The in-process DockerSandboxClient could never satisfy AgentOS 0.2.10's sandbox serialization contract: AgentOS serializes sandbox mounts through getSerializableClientConfig, which reads client.baseUrl and passes it to the sidecar. An in-process object has no network endpoint. Replace the custom adapter with the supported boundary: SandboxAgent.start with sandbox-agent/docker, which starts a sandbox-agent server inside a Docker container with a dynamically mapped host port and returns a SandboxAgent client whose baseUrl both the main process and the sidecar subprocess reach over 127.0.0.1. Dispose calls destroySandbox so no containers are left behind. Remove 700+ lines of dead DockerSandboxClient infrastructure (PID tracking, log files, signal handling) now handled natively by the sandbox-agent server. Add a sqlite_file database descriptor to AgentOs.create so session storage works. Wrap command execution in sh -c for the SandboxAgent API. Apply chmod 600 to the real OpenCode config path containing the gateway key. The real proof with the main .env now passes all three stages: ORB_STAGE_DOCKER_OK, ORB_STAGE_AGENTOS_OK, ORB_STAGE_MODEL_TURN_OK, ending ORB_PROOF_PASSED with zero leftover containers. Update README to describe the real SandboxAgent + Docker topology and remove the misleading 'missing sidecar' limitation.
371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
/**
|
|
* Orb Runtime Proof Fixture
|
|
*
|
|
* Opt-in integration proof that exercises the Orb lifecycle in distinct,
|
|
* independently reported stages, each with a stable marker:
|
|
*
|
|
* STAGE 1 Docker sandbox lifecycle ORB_STAGE_DOCKER_OK / _BLOCKED
|
|
* STAGE 2 AgentOS VM + OpenCode session ORB_STAGE_AGENTOS_OK / _BLOCKED
|
|
* STAGE 3 Model turn via the gateway ORB_STAGE_MODEL_TURN_OK / _BLOCKED
|
|
*
|
|
* A missing or unreachable model gateway is BLOCKED, never passed. The proof
|
|
* only prints ORB_PROOF_PASSED when the model turn completes. It never prints
|
|
* gateway secrets; only provider/model identifiers and reachability labels.
|
|
*
|
|
* Gateway credentials resolve from ORB_GATEWAY_* with safe fallbacks to the
|
|
* shared AGENT_MODEL_* variables. Container removal is always verified after
|
|
* disposal.
|
|
*
|
|
* Usage:
|
|
* ORB_PROOF=1 \
|
|
* ORB_GATEWAY_API_KEY=... \
|
|
* ORB_GATEWAY_BASE_URL=https://ai.example.com/v1 \
|
|
* ORB_GATEWAY_MODEL=glm-5.2 \
|
|
* ORB_GATEWAY_PROVIDER=cheaptricks \
|
|
* bun run scripts/orb-proof.ts
|
|
*/
|
|
/* eslint-disable no-console -- proof fixture is a CLI tool */
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import { Effect } from "effect";
|
|
|
|
import { DockerSandboxProvider } from "../packages/agents/src/orb/docker-sandbox";
|
|
import type { DockerSandboxOptions } from "../packages/agents/src/orb/docker-sandbox";
|
|
import type { OrbEvent } from "../packages/agents/src/orb/events";
|
|
import { OrbRuntime } from "../packages/agents/src/orb/runtime";
|
|
import type { OrbHandle } from "../packages/agents/src/orb/runtime";
|
|
|
|
const MARKER = {
|
|
blocked: "ORB_PROOF_BLOCKED",
|
|
failed: "ORB_PROOF_FAILED",
|
|
passed: "ORB_PROOF_PASSED",
|
|
} as const;
|
|
|
|
const STAGE = {
|
|
agentosBlocked: "ORB_STAGE_AGENTOS_BLOCKED",
|
|
agentosOk: "ORB_STAGE_AGENTOS_OK",
|
|
dockerBlocked: "ORB_STAGE_DOCKER_BLOCKED",
|
|
dockerOk: "ORB_STAGE_DOCKER_OK",
|
|
modelBlocked: "ORB_STAGE_MODEL_TURN_BLOCKED",
|
|
modelOk: "ORB_STAGE_MODEL_TURN_OK",
|
|
} as const;
|
|
|
|
interface Gateway {
|
|
apiKey: string;
|
|
baseUrl: string;
|
|
model: string;
|
|
provider: string;
|
|
}
|
|
|
|
const envValue = (key: string): string | undefined => process.env[key];
|
|
|
|
const resolveGateway = (): Gateway | null => {
|
|
const apiKey =
|
|
envValue("ORB_GATEWAY_API_KEY") ?? envValue("AGENT_MODEL_API_KEY");
|
|
const baseUrl =
|
|
envValue("ORB_GATEWAY_BASE_URL") ?? envValue("AGENT_MODEL_BASE_URL");
|
|
const model = envValue("ORB_GATEWAY_MODEL") ?? envValue("AGENT_MODEL_NAME");
|
|
const provider =
|
|
envValue("ORB_GATEWAY_PROVIDER") ?? envValue("AGENT_MODEL_PROVIDER");
|
|
if (
|
|
!apiKey?.trim() ||
|
|
!baseUrl?.trim() ||
|
|
!model?.trim() ||
|
|
!provider?.trim()
|
|
) {
|
|
return null;
|
|
}
|
|
return { apiKey, baseUrl, model, provider };
|
|
};
|
|
|
|
const runCli = async (args: readonly string[]): Promise<string> => {
|
|
try {
|
|
const proc = Bun.spawn([...args], { stderr: "pipe", stdout: "pipe" });
|
|
const stdout = await new Response(proc.stdout).text();
|
|
await proc.exited;
|
|
return stdout;
|
|
} catch {
|
|
return "";
|
|
}
|
|
};
|
|
|
|
const dockerVersion = async (): Promise<string | null> => {
|
|
const out = await runCli([
|
|
"docker",
|
|
"version",
|
|
"--format",
|
|
"{{.Server.Version}}",
|
|
]);
|
|
const trimmed = out.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
};
|
|
|
|
const TINY_PROJECT: Record<string, string> = {
|
|
"index.test.ts": `import { test } from "node:test";\nimport assert from "node:assert/strict";\nimport { add } from "./index.ts";\n\ntest("add", () => {\n assert.equal(add(1, 2), 3);\n});\n`,
|
|
"index.ts": `export function add(a: number, b: number): number {\n return a + b;\n}\n`,
|
|
"package.json": `{"name":"tiny","type":"module","scripts":{"test":"node --test"}}\n`,
|
|
};
|
|
|
|
const prepareProject = async (hostWorkspace: string): Promise<void> => {
|
|
await mkdir(path.join(hostWorkspace, "repository"), { recursive: true });
|
|
for (const [file, content] of Object.entries(TINY_PROJECT)) {
|
|
// eslint-disable-next-line no-await-in-loop -- sequential file writes are intentional
|
|
await writeFile(path.join(hostWorkspace, "repository", file), content);
|
|
}
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// STAGE 1 — Docker sandbox via SandboxAgent (standalone, no sidecar required)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const stageDocker = async (image: string | undefined): Promise<boolean> => {
|
|
const version = await dockerVersion();
|
|
if (!version) {
|
|
console.log("[docker] Docker daemon not available");
|
|
console.log(STAGE.dockerBlocked);
|
|
return false;
|
|
}
|
|
console.log(`[docker] server version ${version}`);
|
|
|
|
const hostWorkspace = `/tmp/orb-proof-docker-${Date.now()}`;
|
|
const options: DockerSandboxOptions =
|
|
image === undefined
|
|
? { hostWorkspacePath: hostWorkspace }
|
|
: { hostWorkspacePath: hostWorkspace, image };
|
|
|
|
try {
|
|
await prepareProject(hostWorkspace);
|
|
const provider = await Effect.runPromise(
|
|
DockerSandboxProvider.create(options)
|
|
);
|
|
const client = await provider.start();
|
|
|
|
const { baseUrl } = client as unknown as { baseUrl: string };
|
|
console.log(`[docker] SandboxAgent baseUrl: ${baseUrl}`);
|
|
|
|
console.log("[docker] running npm install in sandbox...");
|
|
const install = await client.runProcess({
|
|
args: ["-c", "npm install"],
|
|
command: "sh",
|
|
cwd: "/home/sandbox/repository",
|
|
timeoutMs: 60_000,
|
|
});
|
|
console.log(` npm install exit: ${install.exitCode}`);
|
|
|
|
await provider.dispose();
|
|
if (install.exitCode !== 0) {
|
|
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)}`
|
|
);
|
|
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 npm test and report the result.",
|
|
issueTitle: "Proof: run tests",
|
|
},
|
|
docker: { 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}`
|
|
);
|
|
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
|
|
});
|
|
console.log(STAGE.agentosBlocked);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// STAGE 3 — Model turn through the gateway
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const stageModelTurn = async (handle: OrbHandle): Promise<boolean> => {
|
|
const vm = handle.vmInstance;
|
|
const sessionId = handle.currentSessionId;
|
|
if (!vm || !sessionId) {
|
|
console.log("[model] no active session to drive a model turn");
|
|
console.log(STAGE.modelBlocked);
|
|
return false;
|
|
}
|
|
try {
|
|
console.log("[model] sending bounded turn to gateway...");
|
|
const result = await vm.prompt({
|
|
content: [
|
|
{ text: "Reply with exactly: ORB_PROOF_MODEL_ECHO", type: "text" },
|
|
],
|
|
sessionId,
|
|
});
|
|
console.log(
|
|
`[model] turn completed (stopReason=${result.stopReason ?? "?"})`
|
|
);
|
|
console.log(STAGE.modelOk);
|
|
return true;
|
|
} catch (error) {
|
|
// An unreachable/missing gateway surfaces here as a prompt failure.
|
|
console.log(
|
|
`[model] gateway turn failed: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
console.log(STAGE.modelBlocked);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Orchestration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const runProof = async (): Promise<number> => {
|
|
const image = envValue("ORB_DOCKER_IMAGE");
|
|
const events: OrbEvent[] = [];
|
|
|
|
// STAGE 1 — Docker sandbox.
|
|
console.log("[stage 1] Docker sandbox lifecycle...");
|
|
const dockerOk = await stageDocker(image);
|
|
if (!dockerOk) {
|
|
console.log(`\n${MARKER.blocked}`);
|
|
return 2;
|
|
}
|
|
|
|
// STAGE 2 + 3 — AgentOS/OpenCode + model turn.
|
|
const gateway = resolveGateway();
|
|
if (!gateway) {
|
|
console.error(
|
|
"[gateway] missing config: set ORB_GATEWAY_* (or AGENT_MODEL_* fallbacks)."
|
|
);
|
|
console.log(STAGE.agentosBlocked);
|
|
console.log(`\n${MARKER.blocked}`);
|
|
return 2;
|
|
}
|
|
// Identifiers only — never the key or base URL.
|
|
console.log(`[gateway] provider=${gateway.provider} model=${gateway.model}`);
|
|
|
|
console.log("[stage 2] AgentOS VM + OpenCode session...");
|
|
const orb = await stageAgentOs(gateway, image, events);
|
|
if (!orb) {
|
|
console.log(`\n${MARKER.blocked}`);
|
|
return 2;
|
|
}
|
|
|
|
console.log("[stage 3] model turn...");
|
|
let modelOk = false;
|
|
try {
|
|
modelOk = await stageModelTurn(orb.handle);
|
|
} finally {
|
|
console.log("[dispose] disposing Orb...");
|
|
await Effect.runPromise(orb.handle.dispose().pipe(Effect.ignore)).catch(
|
|
() => {
|
|
// best-effort cleanup
|
|
}
|
|
);
|
|
console.log("[dispose] Orb disposed (SandboxAgent container auto-removed)");
|
|
}
|
|
|
|
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);
|
|
}
|
|
})();
|