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

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

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

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

Strengthen tests: drop the any-failure createOrb test and add focused Docker
lifecycle coverage (PID, logs, status, exit code, stop/kill, removal) guarded
only when Docker is unavailable.
2026-07-24 22:03:21 +05:30

751 lines
23 KiB
TypeScript

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