Add persistent Boat computer environments and runner ownership

Introduce company-scoped computer attachments, bounded runner and viewer ownership, persistent files and worktrees, Boat ingress, and native and legacy runtime integration behind the experimental Boat flag. Keep desktop UI and extended acceptance cases in the stacked feature branch.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-10 10:07:51 -05:00
1 parent 19e9facc5b
commit f6aade3bae
99 files changed
+63903 -207

No files matched your search

@@ -0,0 +1,9 @@
import type { AdapterCommandBackedExecutionTarget, AdapterExecutionTarget } from "./execution-target.js";
/** Command transport capability; sandbox allocation ownership is unchanged. */
export function adapterExecutionTargetIsCommandBacked(
target: AdapterExecutionTarget | null | undefined,
): target is AdapterCommandBackedExecutionTarget {
return target?.kind === "remote" && (target.transport === "sandbox" || target.transport === "computer");
}
+44 -23
View File
@@ -1,3 +1,5 @@
import { adapterExecutionTargetIsCommandBacked } from "./execution-target-kind.js";
export { adapterExecutionTargetIsCommandBacked } from "./execution-target-kind.js";
import fs from "node:fs/promises";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
@@ -233,10 +235,30 @@ export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWor
duplexObservabilityRecorder?: DuplexObservabilityRecorder | null;
}
/** A durable attached computer owns a process, never the physical machine. */
export interface AdapterComputerExecutionTarget extends Omit<AdapterSandboxExecutionTarget, "transport" | "sandboxLeaseAcquisition"> {
transport: "computer";
listenerPort: number;
/** Native process control survives attempt handoff only for the same process claim. */
processRunner: CommandManagedRuntimeRunner;
resourceAuthority: { kind: "computer-owner"; computerId: string; ownerId: string; generation: number };
fileAuthority: { kind: "remote-persistent"; placementId: string; root: string; agentHome: string };
launch(input: { command: string; args?: string[]; cwd?: string; env?: Record<string, string> }): Promise<unknown>;
inspectProcess(): Promise<{ running: boolean; claim: unknown }>;
retainWarm(idleTimeoutMs: number): Promise<void>;
/** False means admission superseded this owner; callers must not close its process. */
retire(): Promise<boolean>;
computerTool: { command: string; args: string[] };
}
export type AdapterCommandBackedExecutionTarget = AdapterSandboxExecutionTarget | AdapterComputerExecutionTarget;
export type AdapterExecutionTarget =
| AdapterLocalExecutionTarget
| AdapterSshExecutionTarget
| AdapterSandboxExecutionTarget;
| AdapterSandboxExecutionTarget
| AdapterComputerExecutionTarget;
export type AdapterRemoteExecutionSpec = SshRemoteExecutionSpec;
@@ -443,7 +465,7 @@ function isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecu
if (parsed.kind === "local") return true;
if (parsed.kind !== "remote") return false;
if (parsed.transport === "ssh") return parseSshRemoteExecutionSpec(parseObject(parsed.spec)) !== null;
if (parsed.transport !== "sandbox") return false;
if (parsed.transport !== "sandbox" && parsed.transport !== "computer") return false;
return readStringMeta(parsed, "remoteCwd") !== null;
}
@@ -462,7 +484,7 @@ export function adapterExecutionTargetIsRemote(
export function adapterExecutionTargetUsesManagedHome(
target: AdapterExecutionTarget | null | undefined,
): boolean {
return target?.kind === "remote" && target.transport === "sandbox";
return adapterExecutionTargetIsCommandBacked(target);
}
/**
@@ -475,8 +497,7 @@ export function adapterExecutionTargetEnablesSandboxDuplexBridge(
target: AdapterExecutionTarget | null | undefined,
): boolean {
return (
target?.kind === "remote" &&
target.transport === "sandbox" &&
adapterExecutionTargetIsCommandBacked(target) &&
target.enableSandboxDuplexBridge === true
);
}
@@ -489,7 +510,7 @@ export function adapterExecutionTargetEnablesSandboxDuplexBridge(
export function adapterExecutionTargetDuplexObservabilityRecorder(
target: AdapterExecutionTarget | null | undefined,
): DuplexObservabilityRecorder | null {
return target?.kind === "remote" && target.transport === "sandbox"
return adapterExecutionTargetIsCommandBacked(target)
? target.duplexObservabilityRecorder ?? null
: null;
}
@@ -553,7 +574,7 @@ export function describeAdapterExecutionTarget(
if (target.transport === "ssh") {
return `SSH environment ${target.spec.username}@${target.spec.host}:${target.spec.port}`;
}
return `sandbox environment${target.providerKey ? ` (${target.providerKey})` : ""}`;
return `${target.transport} environment${target.providerKey ? ` (${target.providerKey})` : ""}`;
}
export type AdapterExecutionTargetTimeoutSource =
@@ -593,7 +614,7 @@ export function resolveAdapterExecutionTargetTimeout(
// that usually apply their own shorter command defaults, so request an
// explicit longer timeout for full adapter runs when the adapter leaves
// timeoutSec unset.
if (target?.kind === "remote" && target.transport === "sandbox") {
if (adapterExecutionTargetIsCommandBacked(target)) {
return { timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, source: "sandbox_default" };
}
return { timeoutSec: 0, source: "unlimited" };
@@ -659,18 +680,18 @@ export function formatAdapterExecutionTimeoutStartLogLine(
);
}
function requireSandboxRunner(target: AdapterSandboxExecutionTarget): CommandManagedRuntimeRunner {
function requireSandboxRunner(target: AdapterCommandBackedExecutionTarget): CommandManagedRuntimeRunner {
if (target.runner) return target.runner;
throw new Error(
"Sandbox execution target is missing its provider runtime runner. Sandbox commands must execute through the environment runtime.",
);
}
function preferredSandboxShell(target: AdapterSandboxExecutionTarget): "bash" | "sh" {
function preferredSandboxShell(target: AdapterCommandBackedExecutionTarget): "bash" | "sh" {
return preferredShellForSandbox(target.shellCommand);
}
type AdapterCommandCapableExecutionTarget = AdapterSshExecutionTarget | AdapterSandboxExecutionTarget;
type AdapterCommandCapableExecutionTarget = AdapterSshExecutionTarget | AdapterCommandBackedExecutionTarget;
// The Secure Shell command runner's own output buffer. This value used to
// derive from the bridge body limit (`DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES
@@ -697,7 +718,7 @@ function adapterExecutionTargetShellCommand(target: AdapterCommandCapableExecuti
function adapterExecutionTargetTimeoutMs(
target: AdapterCommandCapableExecutionTarget,
): number | null | undefined {
return target.transport === "sandbox" ? target.timeoutMs : undefined;
return adapterExecutionTargetIsCommandBacked(target) ? target.timeoutMs : undefined;
}
export async function ensureAdapterExecutionTargetCommandResolvable(
@@ -707,7 +728,7 @@ export async function ensureAdapterExecutionTargetCommandResolvable(
env: NodeJS.ProcessEnv,
options: { installCommand?: string | null; timeoutSec?: number | null } = {},
) {
if (target?.kind === "remote" && target.transport === "sandbox") {
if (adapterExecutionTargetIsCommandBacked(target)) {
await ensureSandboxCommandResolvable(
command,
target,
@@ -726,7 +747,7 @@ export async function ensureAdapterExecutionTargetCommandResolvable(
async function probeSandboxCommandResolvable(
command: string,
target: AdapterSandboxExecutionTarget,
target: AdapterCommandBackedExecutionTarget,
env: Record<string, string>,
): Promise<{ resolved: boolean; timedOut: boolean; stderr: string }> {
const runner = requireSandboxRunner(target);
@@ -747,7 +768,7 @@ async function probeSandboxCommandResolvable(
async function ensureSandboxCommandResolvable(
command: string,
target: AdapterSandboxExecutionTarget,
target: AdapterCommandBackedExecutionTarget,
env: Record<string, string>,
installCommand: string | null,
timeoutSec?: number | null,
@@ -815,7 +836,7 @@ export async function resolveAdapterExecutionTargetCommandForLogs(
cwd: string,
env: NodeJS.ProcessEnv,
): Promise<string> {
if (target?.kind === "remote" && target.transport === "sandbox") {
if (adapterExecutionTargetIsCommandBacked(target)) {
return `sandbox://${target.providerKey ?? "provider"}/${target.leaseId ?? "lease"}/${target.remoteCwd} :: ${command}`;
}
return await resolveCommandForLogs(command, cwd, env, {
@@ -859,7 +880,7 @@ export async function runAdapterExecutionTargetProcess(
args: string[],
options: AdapterExecutionTargetProcessOptions,
): Promise<RunProcessResult> {
if (target?.kind === "remote" && target.transport === "sandbox") {
if (adapterExecutionTargetIsCommandBacked(target)) {
const runner = requireSandboxRunner(target);
const env = sanitizeRemoteExecutionEnv(options.env);
await options.onRuntimeProgress?.({
@@ -1055,7 +1076,7 @@ export async function maybeRunSandboxInstallCommand(input: {
timeoutSec?: number;
}): Promise<AdapterSandboxInstallCommandCheck | null> {
const { target, adapterKey, installCommand } = input;
if (!target || target.kind !== "remote" || target.transport !== "sandbox") {
if (!adapterExecutionTargetIsCommandBacked(target)) {
return null;
}
const trimmed = installCommand.trim();
@@ -1162,7 +1183,7 @@ export async function ensureAdapterExecutionTargetRuntimeCommandInstalled(input:
onLog?: AdapterExecutionTargetShellOptions["onLog"];
}): Promise<void> {
const installCommand = input.installCommand?.trim();
if (!installCommand || input.target?.kind !== "remote" || input.target.transport !== "sandbox") {
if (!installCommand || !adapterExecutionTargetIsCommandBacked(input.target)) {
return;
}
@@ -1562,7 +1583,7 @@ export function githubOperationLauncherDirectory(input: GitHubLauncherLocation):
if (!/^[a-zA-Z0-9_-]+$/.test(input.runId)) throw new Error("Invalid GitHub launcher run ID");
return input.target?.kind === "remote"
? path.posix.join(input.target.remoteCwd, ".paperclip-runtime",
...(input.target.transport === "sandbox" ? ["paperclip-runner"] : []), "github", input.runId)
...(adapterExecutionTargetIsCommandBacked(input.target) ? ["paperclip-runner"] : []), "github", input.runId)
: path.join(os.tmpdir(), "paperclip-github-runtime", input.runId);
}
@@ -1986,7 +2007,7 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
// it does not start the 100 ms poll. Default OFF: the bridge keeps the poll.
streamOutputViaSession?: boolean;
}): Promise<AdapterExecutionTargetProcessSessionBridgeHandle | null> {
if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") {
if (!adapterExecutionTargetIsCommandBacked(input.target)) {
return null;
}
@@ -4838,7 +4859,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
// log line as the file path. The http2 path starts no file-bridge
// worker, so create the log directory before the tail starts.
let duplexRunLogTail: SandboxRunLogTailFactory | null = null;
if (target.transport === "sandbox" && target.streamRunLogs !== false) {
if (adapterExecutionTargetIsCommandBacked(target) && target.streamRunLogs !== false) {
const duplexLogsDir = sandboxCallbackBridgeDirectories(queueDir).logsDir;
await ensureSandboxRunLogDirectory({
runner,
@@ -4930,7 +4951,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
}
let runLogTail: SandboxRunLogTailFactory | null = null;
if (target.transport === "sandbox" && target.streamRunLogs !== false) {
if (adapterExecutionTargetIsCommandBacked(target) && target.streamRunLogs !== false) {
runLogTail = createSandboxRunLogTailFactory({
runner,
remoteCwd: target.remoteCwd,
@@ -210,3 +210,29 @@ describe("paperclip runner transport routing", () => {
).toThrow();
});
});
it("uses each computer owner's allocated port without changing the sandbox default", async () => {
const { adapterExecutionTargetIsCommandBacked } = await import("./execution-target.js");
const endpoint = ingress();
const getRunnerIngressEndpoint = vi.fn(async () => endpoint);
const base = {
kind: "remote" as const, transport: "computer" as const, providerKey: "boat", remoteCwd: "/home/user/project",
leaseId: "attempt-lease", listenerPort: 45101, effectiveCapabilities: capabilities, getRunnerIngressEndpoint,
processRunner: { execute: async () => ({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: new Date().toISOString() }) },
resourceAuthority: { kind: "computer-owner" as const, computerId: "machine", ownerId: "owner-a", generation: 2 },
fileAuthority: { kind: "remote-persistent" as const, placementId: "placement", root: "/home/user/project", agentHome: "/home/user/agent-a" },
launch: async () => ({}), inspectProcess: async () => ({ running: true, claim: {} }),
retainWarm: async () => {}, retire: async () => true, computerTool: { command: "cua-driver", args: ["mcp"] },
};
expect(adapterExecutionTargetIsCommandBacked(base)).toBe(true);
for (const port of [45101, 45102]) {
const resolved = await resolvePaperclipRunnerTransport({ target: { ...base, listenerPort: port }, runId: "run-1",
localConnectUrl: "ws://localhost/unused", runnerIngressAuthorized: true });
expect(resolved).toMatchObject({ mode: "provider_ingress", listenPort: port });
expect(getRunnerIngressEndpoint).toHaveBeenLastCalledWith({ leaseId: "attempt-lease", port, path: "/api/runner/v1/connect/run-1" });
}
const { listenerPort, processRunner, resourceAuthority, fileAuthority, launch, inspectProcess, retainWarm, retire, computerTool, ...common } = base;
const resolved = await resolvePaperclipRunnerTransport({ target: { ...common, transport: "sandbox" }, runId: "sandbox-run",
localConnectUrl: "ws://localhost/unused", runnerIngressAuthorized: true });
expect(resolved).toMatchObject({ mode: "provider_ingress", listenPort: 43127 });
});
@@ -1,4 +1,5 @@
import type { AdapterExecutionTarget } from "./execution-target.js";
import { adapterExecutionTargetIsCommandBacked } from "./execution-target-kind.js";
export const PAPERCLIP_RUNNER_INGRESS_PORT = 43_127;
export const PAPERCLIP_RUNNER_CONNECT_PATH_PREFIX = "/api/runner/v1/connect";
@@ -133,7 +134,7 @@ export async function resolvePaperclipRunnerTransport(input: {
}
if (
input.target.transport === "sandbox" &&
adapterExecutionTargetIsCommandBacked(input.target) &&
input.target.effectiveCapabilities?.runnerWebSocketIngress === true
) {
const ingressAuthorized =
@@ -152,16 +153,18 @@ export async function resolvePaperclipRunnerTransport(input: {
"The sandbox runner ingress provider is unavailable for this lease.",
);
}
const port = input.target.transport === "computer" ? input.target.listenerPort : PAPERCLIP_RUNNER_INGRESS_PORT;
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new PaperclipRunnerTransportError("runner_ingress_unavailable", "Invalid allocated runner port.");
const path = connectPath(input.runId);
const ingress = await getRunnerIngressEndpoint({
leaseId: input.target.leaseId,
port: PAPERCLIP_RUNNER_INGRESS_PORT,
port,
path,
});
return {
mode: "provider_ingress",
listenAddress: "0.0.0.0",
listenPort: PAPERCLIP_RUNNER_INGRESS_PORT,
listenPort: port,
listenPath: path,
ingress,
};
@@ -6,6 +6,7 @@ import path from "node:path";
import { afterAll, afterEach, describe, expect, it } from "vitest";
import {
buildSshSpawnTarget,
createSshCommandManagedRuntimeRunner,
buildSshEnvLabFixtureConfig,
getSshEnvLabSupport,
prepareWorkspaceForSshExecution,
@@ -181,6 +182,28 @@ describe("ssh env-lab fixture", () => {
// this drains it too instead of stranding a listener until the process exits.
afterAll(drainFixtureTeardowns);
it.each([0, 7])("handles a closed SSH input pipe without losing exit diagnostics (exit %i)", async (exitCode) => {
const rootDir = await createFixtureRootDir();
const previousPath = process.env.PATH;
await writeFile(path.join(rootDir, "ssh"), `#!/bin/sh
exec 0<&-
printf 'remote rejected upload' >&2
sleep 0.05
exit ${exitCode}
`, { mode: 0o700 });
process.env.PATH = `${rootDir}${path.delimiter}${previousPath ?? ""}`;
try {
const runner = createSshCommandManagedRuntimeRunner({ spec: UNREACHABLE_SSH_SPEC });
const result = await runner.execute({ command: "cat", stdin: "x".repeat(8 * 1024 * 1024), timeoutMs: 5000 });
expect(result.exitCode).toBe(exitCode === 0 ? null : exitCode);
expect(result.stderr).toBe("remote rejected upload");
expect(result.timedOut).toBe(false);
} finally {
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
}
});
it("starts an isolated sshd fixture and executes commands through it", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
+17 -5
View File
@@ -239,6 +239,7 @@ async function spawnText(
let stderr = "";
let settled = false;
let timedOut = false;
let stdinError: Error | null = null;
const finishReject = (error: Error & { stdout?: string; stderr?: string; code?: number | null; killed?: boolean }) => {
if (settled) return;
@@ -308,21 +309,32 @@ async function spawnText(
clearTimers();
if (settled) return;
settled = true;
if (code === 0) {
if (code === 0 && !stdinError) {
resolve({ stdout, stderr });
return;
}
reject(Object.assign(new Error(stderr.trim() || stdout.trim() || `Process exited with code ${code ?? -1}`), {
const inputFailure = stdinError ? "Process closed stdin before command input completed." : "";
reject(Object.assign(new Error(stderr.trim() || inputFailure || stdout.trim() || `Process exited with code ${code ?? -1}`), {
stdout,
stderr,
code,
stderr: stderr || inputFailure,
code: code === 0 && stdinError ? null : code,
signal,
killed: timedOut,
}));
});
if (options.stdin != null && child.stdin) {
child.stdin.end(options.stdin);
// SSH may exit or close its pipe while a multi-megabyte upload is still
// queued. A stream error is independent of the ChildProcess error event.
// Observe it before writing, then preserve the remote exit diagnostics
// when close arrives instead of letting EPIPE crash the controller.
child.stdin.on("error", (error) => { stdinError = error; });
try {
child.stdin.end(options.stdin);
} catch (error) {
stdinError = error instanceof Error ? error : new Error(String(error));
child.stdin.destroy();
}
}
});
}
@@ -166,6 +166,51 @@ describe("prepareSandboxClaudeProbeRuntime managed-config diagnostics", () => {
}
});
it("materializes config without syncing the persistent in-place workspace", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-in-place-probe-"));
cleanupDirs.push(root);
const remoteRoot = "/home/user/paperclip/company/agents/agent";
const target: AdapterExecutionTarget = {
...sandboxTarget,
workspaceRealization: {
mode: "in_place",
authoritativeRoot: remoteRoot,
pathAliases: [],
outboundRestorePaths: [],
},
};
const restoreWorkspace = vi.fn(async () => {});
prepareAdapterExecutionTargetRuntime.mockImplementationOnce(async (input) => {
// Reproduce the production boundary: importing a host workspace would
// overwrite remote authority. Only the selected config asset may stage.
expect(input.syncWorkspace).toBe(false);
expect(input.workspaceRemoteDir).toBe(remoteRoot);
expect(input.assets).toEqual([{ key: "config-seed", localDir: root, followSymlinks: true }]);
return {
runtimeRootDir: `${remoteRoot}/.paperclip-runtime/claude`,
assetDirs: { "config-seed": `${remoteRoot}/.paperclip-runtime/claude/config-seed` },
restoreWorkspace,
};
});
const env = { CLAUDE_CONFIG_DIR: root };
const checks = await prepareSandboxClaudeProbeRuntime({
managedAiConnection: true,
runId: "run-in-place",
target,
cwd: remoteRoot,
companyId: "company-1",
env,
installCommand: "install-claude",
detectCommand: "claude",
targetIsRemote: true,
targetIsSandbox: true,
helloProbeTimeoutSec: 30,
});
expect(checks).toEqual([expect.objectContaining({ code: "claude_managed_config_dir", level: "info" })]);
expect(env.CLAUDE_CONFIG_DIR).toBe(`${remoteRoot}/.paperclip-runtime/claude/config`);
expect(restoreWorkspace).toHaveBeenCalledOnce();
});
it("keeps a thrown config-materialization error out of every check and the log", async () => {
// The runtime preparation throws an error that carries two untrusted values:
// an opaque credential marker and a proxy marker. Neither may reach a check
@@ -302,8 +302,11 @@ export async function prepareSandboxClaudeProbeRuntime(input: {
let preparedRuntime: Awaited<ReturnType<typeof prepareAdapterExecutionTargetRuntime>> | null = null;
try {
const seedDir = input.managedAiConnection ? input.env.CLAUDE_CONFIG_DIR : await prepareClaudeConfigSeed(process.env, async () => {}, input.companyId);
const managedRemoteCwd =
input.target?.kind === "remote" ? input.target.remoteCwd : input.cwd;
const workspaceRealization = input.target?.kind === "remote"
? input.target.workspaceRealization : undefined;
const managedRemoteCwd = workspaceRealization?.mode === "in_place"
? workspaceRealization.authoritativeRoot
: input.target?.kind === "remote" ? input.target.remoteCwd : input.cwd;
tempWorkspaceDir = await fs.mkdtemp(
path.join(os.tmpdir(), "paperclip-claude-envtest-workspace-"),
);
@@ -313,6 +316,9 @@ export async function prepareSandboxClaudeProbeRuntime(input: {
adapterKey: "claude",
workspaceLocalDir: tempWorkspaceDir,
workspaceRemoteDir: managedRemoteCwd,
// A readiness probe only stages config assets. In-place workspaces are
// persistent authority and must never receive the empty probe workspace.
syncWorkspace: workspaceRealization?.mode !== "in_place",
timeoutSec: Math.max(1, input.helloProbeTimeoutSec),
assets: [
{
@@ -16,6 +16,7 @@ function cacheKeyForTarget(command: string, target: AdapterExecutionTarget | nul
if (target.kind === "local") {
return `local:${target.environmentId ?? ""}:${target.leaseId ?? ""}:${command}`;
}
if (target.transport === "computer") return `computer:${target.environmentId}:${target.resourceAuthority.computerId}:${command}`;
if (target.transport === "sandbox") {
return [
"sandbox",
@@ -231,6 +231,31 @@ describe("claude remote execution", () => {
}));
});
it("keeps an authoritative remote workspace in place while delivering managed Claude assets", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-in-place-"));
cleanupDirs.push(rootDir);
await execute({
runId: "run-in-place",
agent: { id: "agent-1", companyId: "company-1", name: "Claude", adapterType: "claude_local", adapterConfig: {} },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: { engine: "cli", command: "claude", cwd: "/stale-host-cwd" },
context: { paperclipWorkspace: { cwd: "/remote-only-agent-home", source: "agent_home" } },
executionTarget: {
kind: "remote", transport: "ssh", remoteCwd: "/old-copied-workspace",
workspaceRealization: { mode: "in_place", authoritativeRoot: "/persistent-project", pathAliases: [], outboundRestorePaths: [] },
spec: { host: "127.0.0.1", port: 2222, username: "fixture", remoteWorkspacePath: "/persistent-project", remoteCwd: "/persistent-project",
privateKey: "PRIVATE KEY", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", strictHostKeyChecking: true },
},
onLog: async () => {},
});
expect(prepareWorkspaceForSshExecution).not.toHaveBeenCalled();
expect(restoreWorkspaceFromSshExecution).not.toHaveBeenCalled();
expect(syncDirectoryToSsh).toHaveBeenCalledTimes(2);
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ remoteDir: "/persistent-project/.paperclip-runtime/claude/skills" }));
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[], { remoteExecution: { remoteCwd: string } }];
expect(call[3].remoteExecution.remoteCwd).toBe("/persistent-project");
});
it("does not resume saved Claude sessions for remote SSH execution without a matching remote identity", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-resume-"));
cleanupDirs.push(rootDir);
@@ -1,3 +1,4 @@
import { adapterExecutionTargetIsCommandBacked } from "@paperclipai/adapter-utils/execution-target";
import { createProviderStoppedBoundary } from "@paperclipai/adapter-utils/provider-stopped-boundary";
import { createUsageCheckpointLog } from "@paperclipai/adapter-utils/usage-checkpoint";
import fs from "node:fs/promises";
@@ -205,7 +206,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const effectiveWorkspaceCwd = executionTarget?.workspaceRealization?.mode === "in_place"
? executionTarget.workspaceRealization.authoritativeRoot
: useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
@@ -216,7 +219,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
if (!executionTargetIsRemote) {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
}
const envConfig = parseObject(config.env);
const env: Record<string, string> = { ...buildPaperclipEnv(agent, input.agentIdentity) };
@@ -426,7 +431,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
legacyRemoteExecution: ctx.executionTransport?.remoteExecution,
});
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
const executionTargetIsSandbox = executionTarget?.kind === "remote" && executionTarget.transport === "sandbox";
const executionTargetIsCommandBacked = adapterExecutionTargetIsCommandBacked(executionTarget);
const promptTemplate = asString(
config.promptTemplate,
@@ -453,7 +458,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
: [];
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const effectiveWorkspaceCwd = executionTarget?.workspaceRealization?.mode === "in_place"
? executionTarget.workspaceRealization.authoritativeRoot
: useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const hasExplicitClaudeConfigDir =
typeof configEnv.CLAUDE_CONFIG_DIR === "string" && configEnv.CLAUDE_CONFIG_DIR.trim().length > 0;
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
@@ -482,7 +489,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
graceSec,
extraArgs,
} = runtimeConfig;
Object.assign(env, claudeSandboxPermissionEnv({ dangerouslySkipPermissions, targetIsSandbox: executionTargetIsSandbox }));
Object.assign(env, claudeSandboxPermissionEnv({ dangerouslySkipPermissions, targetIsSandbox: executionTargetIsCommandBacked }));
let loggedEnv = initialLoggedEnv;
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
const terminalResultCleanupGraceMs = Math.max(
@@ -612,11 +619,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const claudeConfigSeedDir = useManagedRemoteClaudeConfig
? config.managedAiConnection ? sharedClaudeConfigDir : await prepareClaudeConfigSeed(process.env, onLog, agent.companyId)
: null;
const targetWorkspaceRealization = executionTarget?.workspaceRealization ?? null;
const preparedExecutionTargetRuntime = executionTargetIsRemote
? await (async () => {
await onLog(
"stdout",
`[paperclip] Syncing workspace and Claude runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
`[paperclip] Syncing ${targetWorkspaceRealization?.mode === "in_place" ? "Claude runtime assets" : "workspace and Claude runtime assets"} to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
);
return await prepareAdapterExecutionTargetRuntime({
runId,
@@ -624,6 +632,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
adapterKey: "claude",
timeoutSec,
workspaceLocalDir: cwd,
workspaceRemoteDir: targetWorkspaceRealization?.mode === "in_place"
? targetWorkspaceRealization.authoritativeRoot : undefined,
syncWorkspace: targetWorkspaceRealization?.mode !== "in_place",
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
onProgress: (line) => onLog("stdout", line),
@@ -748,7 +759,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
}
let effectiveEffort = effort;
if (executionTargetIsSandbox && effort) {
if (executionTargetIsCommandBacked && effort) {
const supportsEffort = await claudeCommandSupportsEffortFlag({
runId,
command,
@@ -12,6 +12,7 @@ import {
ensurePathInEnv,
} from "@paperclipai/adapter-utils/server-utils";
import {
adapterExecutionTargetIsCommandBacked,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
@@ -90,7 +91,7 @@ export async function testEnvironment(
const command = asString(config.command, "claude");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox";
const targetIsSandbox = adapterExecutionTargetIsCommandBacked(target);
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const runId = `claude-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
@@ -1,3 +1,4 @@
import { adapterExecutionTargetIsCommandBacked } from "@paperclipai/adapter-utils/execution-target";
import { createProviderStoppedBoundary } from "@paperclipai/adapter-utils/provider-stopped-boundary";
import { createUsageCheckpointLog } from "@paperclipai/adapter-utils/usage-checkpoint";
import fs from "node:fs/promises";
@@ -874,8 +875,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir;
}
const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd);
const executionTargetIsSandbox =
runtimeExecutionTarget?.kind === "remote" && runtimeExecutionTarget.transport === "sandbox";
const executionTargetIsCommandBacked =
adapterExecutionTargetIsCommandBacked(runtimeExecutionTarget);
const restoreRemoteWorkspace = preparedExecutionTargetRuntime
? () => preparedExecutionTargetRuntime.restoreWorkspace((line) => onLog("stdout", line))
: null;
@@ -1191,7 +1192,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
return notes;
})();
if (executionTargetIsSandbox) {
if (executionTargetIsCommandBacked) {
commandNotes.push(
"Added --skip-git-repo-check for sandbox execution because Codex requires an explicit trust bypass in headless remote workspaces.",
);
@@ -1226,7 +1227,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
forceSaferInvocation ? { ...config, fastMode: false } : config,
{
resumeSessionId,
skipGitRepoCheck: executionTargetIsSandbox,
skipGitRepoCheck: executionTargetIsCommandBacked,
networkAccess: env.PAPERCLIP_RUNNER_NETWORK_ACCESS !== "disabled",
},
);
@@ -9,6 +9,7 @@ import {
ensurePathInEnv,
} from "@paperclipai/adapter-utils/server-utils";
import {
adapterExecutionTargetIsCommandBacked,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
maybeRunSandboxInstallCommand,
@@ -277,7 +278,7 @@ export async function testEnvironment(
const command = asString(config.command, "codex");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox";
const targetIsSandbox = adapterExecutionTargetIsCommandBacked(target);
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
@@ -0,0 +1,16 @@
CREATE TABLE "computers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"environment_id" uuid NOT NULL,
"provider" text DEFAULT 'boat' NOT NULL,
"provider_id" text NOT NULL,
"ledger" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "computers" ADD CONSTRAINT "computers_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "computers" ADD CONSTRAINT "computers_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "computers_physical_uq" ON "computers" USING btree ("provider","provider_id");--> statement-breakpoint
CREATE UNIQUE INDEX "computers_environment_uq" ON "computers" USING btree ("environment_id");--> statement-breakpoint
CREATE INDEX "computers_company_idx" ON "computers" USING btree ("company_id");
File diff suppressed because it is too large. Load diff
+8 -1
View File
@@ -2304,6 +2304,13 @@
"when": 1791578748375,
"tag": "0330_spooky_white_tiger",
"breakpoints": true
},
{
"idx": 331,
"version": "7",
"when": 1791637689264,
"tag": "0331_next_emma_frost",
"breakpoints": true
}
]
}
}
+44
View File
@@ -0,0 +1,44 @@
import {
index,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { environments } from "./environments.js";
/** One row is the serialization boundary for a shared, externally owned computer. */
export const computers = pgTable(
"computers",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "restrict" }),
environmentId: uuid("environment_id")
.notNull()
.references(() => environments.id, { onDelete: "restrict" }),
provider: text("provider").notNull().default("boat"),
providerId: text("provider_id").notNull(),
ledger: jsonb("ledger").$type<Record<string, unknown>>().notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
physical: uniqueIndex("computers_physical_uq").on(
table.provider,
table.providerId,
),
environment: uniqueIndex("computers_environment_uq").on(
table.environmentId,
),
company: index("computers_company_idx").on(table.companyId),
}),
);
+1
View File
@@ -244,3 +244,4 @@ export { chatVoicePhoneLines, chatVoiceInboundCalls } from "./chat_voice_inbound
export * from "./decision_models.js";
export { userCompanyPreferences } from "./user_company_preferences.js";
export { aiSubscriptions, aiSubscriptionPrices, aiSubscriptionConnections } from "./ai_subscriptions.js";
export { computers } from "./computers.js";
@@ -1,3 +1,4 @@
import { createRunnerdCodexAppServerArgs } from "../live/runnerd-codex-transport.js";
import {
chmod,
lstat,
@@ -27,6 +28,7 @@ import { nativeMcpLaunchBinding } from "./native-mcp.js";
import {
materializeNativeRuntimeSkills,
prepareIsolatedCodexHome,
releaseMaterializedNativeRuntimeSkills,
} from "./runtime-context-materializer.js";
const roots: string[] = [];
@@ -93,6 +95,19 @@ function context(
}
describe("runtime context materialization", () => {
it("installs only the trusted environment computer binding and removes it on refresh without that capability", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-computer-context-"));
roots.push(root);
const codexHome = join(root, "codex-home");
await prepareIsolatedCodexHome({ context: null, codexHome,
computerTool: { command: "/opt/ascii/cua-driver/cua-driver", args: ["mcp", "--socket", "/run/ascii-cua/driver.sock"] } });
const config = await readFile(join(codexHome, "config.toml"), "utf8");
expect(config).toContain("[mcp_servers.paperclip_computer]");
expect(config).toContain('command = "/opt/ascii/cua-driver/cua-driver"');
await releaseMaterializedNativeRuntimeSkills(join(codexHome, "skills"));
await prepareIsolatedCodexHome({ context: null, codexHome });
expect(await readFile(join(codexHome, "config.toml"), "utf8")).not.toContain("paperclip_computer");
});
it("restores the legacy working-copy contract without changing its digest or adding new fields", () => {
const old = context("/skills", "/instructions");
old.instructions.workingCopy = { rootPath: "/old-run/copy", entryPath: "AGENTS.md" };
@@ -503,3 +518,12 @@ command = "untrusted-command"
await expect(stat(join(symlinkCodexHome, "auth.json"))).rejects.toThrow();
});
});
it("exposes a trusted persistent personal directory without instruction files", () => {
const options = { environment: { AGENT_HOME: "/untrusted", HOME: "/ambient" }, codexHome: "/private-provider" };
const supplied = createRunnerdCodexAppServerArgs({ ...options, persistentAgentHome: "/personal/agent" }).join("\n");
expect(supplied).toContain('AGENT_HOME="/personal/agent"');
expect(supplied).toContain('"/private-provider"="none"');
expect(supplied).not.toContain('AGENT_HOME="/untrusted"');
expect(createRunnerdCodexAppServerArgs(options).join("\n")).not.toContain("AGENT_HOME");
});
@@ -335,6 +335,8 @@ export async function prepareIsolatedCodexHome(input: {
codexHome: string;
sourceCodexHome?: string | null;
nativeMcp?: NativeMcpLaunchBinding | null;
/** Trusted execution-environment capability, never inherited from host config. */
computerTool?: { command: string; args: readonly string[] } | null;
apiKey?: string | null;
}): Promise<void> {
await materializeNativeRuntimeSkills(
@@ -365,6 +367,13 @@ export async function prepareIsolatedCodexHome(input: {
"",
]
: []),
...(input.computerTool ? [
"[mcp_servers.paperclip_computer]",
`command = ${JSON.stringify(input.computerTool.command)}`,
`args = ${JSON.stringify(input.computerTool.args)}`,
'default_tools_approval_mode = "approve"',
"",
] : []),
].join("\n"), { mode: 0o600 });
const targetAuth = join(input.codexHome, "auth.json");
@@ -1205,6 +1205,10 @@ export interface CapabilityRunnerdProcessEvidence {
}
export interface CapabilityRunnerdCodexTransportOptions {
/** Server-authorized local computer MCP on the selected execution environment. */
computerTool?: { command: string; args: readonly string[] } | null;
/** Controller-authorized durable personal directory, independent of instructions. */
persistentAgentHome?: string;
provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx";
opencodePermissionMode?: NativeOpenCodePermissionMode;
acpxAgent?: QualifiedAcpxAgent;
@@ -3445,6 +3449,7 @@ export function createRunnerdCodexAppServerArgs(input: {
codexCommand?: string;
readOnlyRoots?: string[];
instructionWorkingCopyRoot?: string;
persistentAgentHome?: string;
}): string[] {
// The filesystem policy denies HOME and CODEX_HOME to keep credentials and
// runner state outside provider reach. Always bind those names to the actual
@@ -3455,9 +3460,10 @@ export function createRunnerdCodexAppServerArgs(input: {
...input.environment,
HOME: input.codexHome,
CODEX_HOME: input.codexHome,
...(input.persistentAgentHome ? { AGENT_HOME: input.persistentAgentHome } : {}),
},
[...(input.readOnlyRoots ?? []), ...codexExecutableReadOnlyRoots(input.environment ?? {}, input.codexCommand)],
input.instructionWorkingCopyRoot,
input.instructionWorkingCopyRoot ?? input.persistentAgentHome,
);
}
@@ -4731,6 +4737,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
this.options.environment?.CODEX_API_KEY ??
this.options.environment?.OPENAI_API_KEY,
nativeMcp: nativeMcpLaunchBinding(this.options.environment),
computerTool: this.options.computerTool,
});
}
const opencodeProxyPath =
@@ -4966,6 +4973,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
codexHome,
codexCommand: effectiveCodexCommand,
instructionWorkingCopyRoot: runtimeContext?.instructions.workingCopy?.rootPath,
persistentAgentHome: this.options.persistentAgentHome,
readOnlyRoots: [
...trustedRuntimeReadOnlyRoots(
this.options.environment,
@@ -5450,6 +5458,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
codexHome,
codexCommand: typeof recordedCommand === "string" ? recordedCommand : this.options.codexCommand,
instructionWorkingCopyRoot: runtimeContext?.instructions.workingCopy?.rootPath,
persistentAgentHome: this.options.persistentAgentHome,
readOnlyRoots: [
...trustedRuntimeReadOnlyRoots(this.options.environment),
...(runtimeContext
@@ -5632,6 +5641,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
this.options.environment?.CODEX_API_KEY ??
this.options.environment?.OPENAI_API_KEY,
nativeMcp: nativeMcpLaunchBinding(this.options.environment),
computerTool: this.options.computerTool,
});
}
const adoptedRunner = this.options.adoptExistingRunner;
+1 -1
View File
@@ -574,7 +574,7 @@ export const PROJECT_STATUSES = [
] as const;
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
export const ENVIRONMENT_DRIVERS = ["local", "ssh", "sandbox", "plugin"] as const;
export const ENVIRONMENT_DRIVERS = ["local", "ssh", "sandbox", "plugin", "computer"] as const;
export type EnvironmentDriver = (typeof ENVIRONMENT_DRIVERS)[number];
export const ENVIRONMENT_STATUSES = ["active", "archived"] as const;
@@ -13,9 +13,28 @@ describe("isSandboxProviderSupportedForAdapter", () => {
"local",
"ssh",
"sandbox",
"computer",
]);
});
it.each(["claude_local", "codex_local", "paperclip_runner"] as const)(
"exposes computer environments for %s",
(adapterType) => {
const capabilities = getEnvironmentCapabilities([adapterType]);
expect(supportedEnvironmentDriversForAdapter(adapterType)).toContain("computer");
expect(capabilities.adapters[0].drivers.computer).toBe("supported");
},
);
it.each(["cursor", "gemini_local", "grok_local", "kimi_local", "openclaw"] as const)(
"does not advertise unqualified computer support for %s",
(adapterType) => {
const capabilities = getEnvironmentCapabilities([adapterType]);
expect(supportedEnvironmentDriversForAdapter(adapterType)).not.toContain("computer");
expect(capabilities.adapters[0].drivers.computer).toBe("unsupported");
},
);
it("accepts additional sandbox providers for remote-managed adapters", () => {
expect(
isSandboxProviderSupportedForAdapter("codex_local", "fake-plugin", ["fake-plugin"]),
+3 -1
View File
@@ -82,7 +82,7 @@ export function adapterSupportsRemoteManagedEnvironments(adapterType: string): b
export function supportedEnvironmentDriversForAdapter(adapterType: string): EnvironmentDriver[] {
return adapterSupportsRemoteManagedEnvironments(adapterType)
? ["local", "ssh", "sandbox"]
? ["local", "ssh", "sandbox", ...(["claude_local", "codex_local", "paperclip_runner"].includes(adapterType) ? ["computer" as const] : [])]
: ["local"];
}
@@ -134,6 +134,7 @@ export function getAdapterEnvironmentSupport(
ssh: supportedDrivers.has("ssh") ? "supported" : "unsupported",
sandbox: supportedDrivers.has("sandbox") ? "supported" : "unsupported",
plugin: supportedDrivers.has("plugin") ? "supported" : "unsupported",
computer: supportedDrivers.has("computer") ? "supported" : "unsupported",
},
sandboxProviders,
};
@@ -195,6 +196,7 @@ export function getEnvironmentCapabilities(
ssh: "supported",
sandbox: "supported",
plugin: "unsupported",
computer: "supported",
},
sandboxProviders,
};
+5
View File
@@ -42,6 +42,11 @@ export interface FeatureCatalogEntry {
}
export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalogEntry> = {
enableBoatEnvironments: {
title: "Boat environments",
description: "Attach persistent Boat computers for agent execution and a shared desktop.",
tier: "managed", cloudDefault: false, selfHostedDefault: false,
},
enableEnvironments: {
title: "Environments",
description:
+3
View File
@@ -797,6 +797,7 @@ export type {
FakeSandboxEnvironmentConfig,
LocalEnvironmentConfig,
PluginSandboxEnvironmentConfig,
BoatEnvironmentConfig,
PluginEnvironmentConfig,
SandboxEnvironmentConfig,
SandboxEnvironmentProvider,
@@ -2869,3 +2870,5 @@ export { EXTERNAL_AGENT_TOOL_GUIDANCE, DOT_AGENT_TOOL_GUIDANCE } from "./externa
export { AGENT_LIFECYCLE_STATES, type AgentLifecycleState, type AgentLifecycleOperation } from "./types/agent-lifecycle.js";
export type { AgentLifecycleRequest, AgentLifecycleResult } from "./types/agent-lifecycle.js";
export type { ComputerViewer, TaskComputer } from "./types/computer-view.js";
@@ -0,0 +1,11 @@
/** Browser credentials are ephemeral: never persist this object in task state. */
export interface ComputerViewer {
viewerUrl: string;
expiresAt: string;
owner: { computerId: string; ownerId: string; generation: number };
}
export interface TaskComputer {
environmentId: string;
name: string;
}
+8
View File
@@ -175,3 +175,11 @@ export interface EnvironmentLease {
createdAt: Date;
updatedAt: Date;
}
/** Attached persistent computer; credentials always resolve in the owning company. */
export interface BoatEnvironmentConfig {
provider: "boat";
sandboxId: string;
apiKeySecretRef: EnvSecretRefBinding;
runnerIdleTimeoutMs?: number;
}
+2
View File
@@ -82,6 +82,7 @@ export type {
FakeSandboxEnvironmentConfig,
LocalEnvironmentConfig,
PluginSandboxEnvironmentConfig,
BoatEnvironmentConfig,
PluginEnvironmentConfig,
SandboxEnvironmentConfig,
SandboxEnvironmentProvider,
@@ -1101,3 +1102,4 @@ export type { AgentInstructionCandidate } from "./agent.js";
export * from "./skill-source.js";
export * from "./agent-lifecycle.js";
export type { ComputerViewer, TaskComputer } from "./computer-view.js";
+1
View File
@@ -41,6 +41,7 @@ export interface InstanceGeneralSettings {
export interface InstanceExperimentalSettings {
enableEnvironments: boolean;
enableBoatEnvironments: boolean;
/**
* Exposes the experimental Paperclip Runner adapter for new selections.
* Existing native runs ignore later flag changes so they remain recoverable.
@@ -40,6 +40,7 @@ export const patchInstanceGeneralSettingsSchema = z
export const instanceExperimentalSettingsSchema = z.object({
enableEnvironments: z.boolean().default(false),
enableBoatEnvironments: z.boolean().default(false),
enableNativeRunner: z.boolean().default(true),
enableAiConnectionRouters: z.boolean().default(false),
enableManagedSandboxOnly: z.boolean().default(false),
@@ -313,6 +313,44 @@ describe("server adapter registry", () => {
});
});
it("probes native Codex through CLI on a persistent computer without changing agent config", async () => {
const config = { provider: "codex", model: "gpt-5.4", env: { OPENAI_API_KEY: "test-key" } };
const execute = vi.fn(async (input: { command: string; args?: string[] }) => ({
exitCode: 0, timedOut: false, stderr: "", signal: null, pid: null, startedAt: new Date().toISOString(),
stdout: input.args?.includes("--json") ? JSON.stringify({ type: "item.completed", item: { type: "agent_message", text: "hello" } })
: input.args?.includes("--version") ? "codex-cli 0.135.0" : "",
}));
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
companyId: "company-1", adapterType: "paperclip_runner", config,
executionTarget: { kind: "remote", transport: "computer", remoteCwd: "/workspace", resourceAuthority: { computerId: "computer" },
workspaceRealization: { mode: "in_place", authoritativeRoot: "/workspace", pathAliases: [], outboundRestorePaths: [] },
runner: { execute },
} as never,
});
expect(result.checks).toContainEqual(expect.objectContaining({ code: "codex_hello_probe_passed", level: "info" }));
expect(result.checks.some(check => check.code === "adapter_engine_unavailable")).toBe(false);
expect(config).not.toHaveProperty("engine");
});
it("verifies the Claude harness on a computer after checking its platform", async () => {
const calls: string[] = [];
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
companyId: "company-1", adapterType: "paperclip_runner",
config: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6", env: { ANTHROPIC_API_KEY: "test-key" } },
executionTarget: { kind: "remote", transport: "computer", remoteCwd: "/workspace", resourceAuthority: { computerId: "computer" },
runner: { execute: async (input: { command: string; args?: string[] }) => {
calls.push(input.command);
const stdout = input.command === "claude" ? JSON.stringify({ type: "result", result: "hello", usage: { input_tokens: 1, output_tokens: 1 } })
: input.args?.some(arg => arg.includes("uname -s")) ? "Linux\nx86_64\n" : "";
return { exitCode: 0, timedOut: false, stdout, stderr: "", signal: null, pid: null, startedAt: new Date().toISOString() };
} },
} as never,
});
expect(calls).toContain("claude");
expect(result.checks).toContainEqual(expect.objectContaining({ code: "claude_hello_probe_passed", level: "info" }));
expect(result.checks.some(check => check.level === "error")).toBe(false);
});
it.each([true, false])("checks actual local ACPX installation readiness (%s)", async (ready) => {
const probe = vi.mocked(probeAcpxClaudeInstallation);
if (ready) probe.mockResolvedValueOnce(undefined);
@@ -1,3 +1,4 @@
import * as persistentFiles from "../services/persistent-agent-files.js";
import fs from "node:fs/promises";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
@@ -9,7 +10,7 @@ import path from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { agentFileStore, fileHash, inspectAgentFile, snapshotAgentFiles, MAX_AGENT_FILE_BYTES, MAX_AGENT_DIRECTORY_BYTES, MAX_AGENT_DIRECTORY_ENTRIES } from "../services/agent-file-store.js";
import { agentFileStore, preparePersistentAgentExecutionHome, fileHash, inspectAgentFile, snapshotAgentFiles, MAX_AGENT_FILE_BYTES, MAX_AGENT_DIRECTORY_BYTES, MAX_AGENT_DIRECTORY_ENTRIES } from "../services/agent-file-store.js";
import { agents, companies, authUsers, companyMemberships, principalPermissionGrants, heartbeatRuns, environmentLeases, environments, agentInstructionWorkingCopies, agentInstructionRevisions, agentInstructionHeads, createDb } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
@@ -74,6 +75,95 @@ describe("persistent agent directories", () => {
await fs.writeFile(path.join(root, entryFile), initial);
});
it("seeds a persistent execution home before a run or instruction-copy receipt exists", async () => {
const remoteRoot = "/home/user/paperclip/test/agents/target";
let exists = false;
const seedBytes = vi.fn(async () => { exists = true; });
const remote = { root: remoteRoot, listPage: async () => {
if (!exists) throw { code: "not_found" };
return { entries: [], truncated: false };
}, seedBytes };
const lookup = vi.spyOn(persistentFiles, "persistentAgentFiles").mockResolvedValue(remote as never);
const environmentId = randomUUID();
const executionTarget = { kind: "remote", transport: "computer", environmentId,
fileAuthority: { kind: "remote-persistent", agentHome: remoteRoot } } as never;
try {
await fs.writeFile(path.join(root, "memory.bin"), Buffer.from([0, 255, 7]));
expect(await preparePersistentAgentExecutionHome(db, { companyId, agentId, target: executionTarget })).toBe(remoteRoot);
expect(lookup).toHaveBeenCalledWith(db, companyId, agentId, environmentId);
expect(seedBytes).toHaveBeenCalledOnce();
expect(seedBytes).toHaveBeenCalledWith(expect.objectContaining({
[entryFile]: Buffer.from(initial), "memory.bin": Buffer.from([0, 255, 7]),
}));
// Subsequent setup adopts the remote tree, regardless of controller edits.
await fs.writeFile(path.join(root, "memory.bin"), "stale controller bytes");
await preparePersistentAgentExecutionHome(db, { companyId, agentId, target: executionTarget });
expect(seedBytes).toHaveBeenCalledOnce();
expect(await db.select().from(agentInstructionWorkingCopies).where(eq(agentInstructionWorkingCopies.agentId, agentId))).toEqual([]);
} finally { lookup.mockRestore(); }
});
it("creates an external-instruction agent home without importing the external path", async () => {
await db.update(agents).set({ adapterConfig: { instructionsBundleMode: "external", instructionsFilePath: "/private/external.md" } }).where(eq(agents.id, agentId));
const remote = { root: "/home/user/agents/external", seedBytes: vi.fn(async () => ({ seeded: true })) };
const lookup = vi.spyOn(persistentFiles, "persistentAgentFiles").mockResolvedValue(remote as never);
try {
const executionTarget = { kind: "remote", transport: "computer", environmentId: randomUUID(),
fileAuthority: { kind: "remote-persistent", agentHome: remote.root } } as never;
await preparePersistentAgentExecutionHome(db, { companyId, agentId, target: executionTarget });
expect(remote.seedBytes).toHaveBeenCalledWith({});
} finally { lookup.mockRestore(); }
});
it("keeps Boat personal bytes authoritative through warm checkpoints, collection, and editor conflicts", async () => {
const remoteRoot = "/home/user/paperclip/test/agents/target";
const remoteFiles = new Map([[entryFile, Buffer.from("remote instructions")], ["memory.txt", Buffer.from("remote memory")]]);
const readBytes = vi.fn(async (relative: string) => {
const bytes = remoteFiles.get(relative);
if (!bytes) throw { code: "not_found" };
return { bytes, sha256: fileHash(bytes) };
});
const writeBytes = vi.fn(async (relative: string, bytes: Buffer, base: string | null) => {
const current = remoteFiles.get(relative);
if ((current ? fileHash(current) : null) !== base) throw new Error("remote CAS rejected");
remoteFiles.set(relative, bytes); return { sha256: fileHash(bytes) };
});
const seedBytes = vi.fn();
const remote = { root: remoteRoot, list: async () => [], listPage: async () => ({ entries: [], truncated: false }), readBytes, writeBytes, seedBytes };
const lookup = vi.spyOn(persistentFiles, "persistentAgentFiles").mockResolvedValue(remote as never);
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand");
try {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
const target = { kind: "remote", transport: "computer", environmentId: randomUUID(), remoteCwd: "/workspace",
fileAuthority: { kind: "remote-persistent", placementId: randomUUID(), root: "/workspace", agentHome: remoteRoot } } as never;
const copy = (await copies.prepare({ companyId, agentId, runId, cwd: home, target, warm: true }))!;
expect(copy.executionRoot).toBe(remoteRoot);
expect(await fs.readFile(path.join(copy.localRoot, entryFile), "utf8")).toBe("remote instructions");
expect(seedBytes).not.toHaveBeenCalled();
remoteFiles.set("memory.txt", Buffer.from("edited while warm"));
expect(await copies.hasChanges({ companyId, runId, target })).toBe(false);
await copies.checkpointWarm({ companyId, runId, target });
await copies.collectStopped({ companyId, runId, target });
await copies.release(companyId, runId);
expect(shell).not.toHaveBeenCalled();
expect(remoteFiles.get("memory.txt")?.toString()).toBe("edited while warm");
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
await expect(fs.stat(path.join(root, "memory.txt"))).rejects.toMatchObject({ code: "ENOENT" });
const store = agentFileStore(db);
await expect(store.write({ companyId, agentId, path: "memory.txt", bytes: Buffer.from("stale"), baseHash: fileHash(Buffer.from("remote memory")) }, board())).rejects.toMatchObject({ status: 409 });
expect(writeBytes).not.toHaveBeenCalled();
await store.write({ companyId, agentId, path: "memory.txt", bytes: Buffer.from("editor"), baseHash: fileHash(Buffer.from("edited while warm")) }, board());
expect(remoteFiles.get("memory.txt")?.toString()).toBe("editor");
const current = await revisions.readCurrent({ companyId, agentId }, board());
expect(current?.content).toBe("remote instructions");
await revisions.commit({ companyId, agentId, entryFile, content: "editor changed instructions", baseRevisionId: current!.revision.id, source: "api" }, board());
expect(remoteFiles.get(entryFile)?.toString()).toBe("editor changed instructions");
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
} finally { lookup.mockRestore(); shell.mockRestore(); }
});
describe("warm directory ownership", () => {
async function nextRun() {
const runId = randomUUID();
@@ -1,7 +1,8 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as persistentFiles from "../services/persistent-agent-files.js";
import { agentInstructionsService } from "../services/agent-instructions.js";
type TestAgent = {
@@ -37,6 +38,7 @@ describe("agent instructions service", () => {
});
afterEach(async () => {
vi.restoreAllMocks();
if (originalPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = originalPaperclipHome;
if (originalPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
@@ -59,6 +61,48 @@ describe("agent instructions service", () => {
if (entry === "../invalid") expect(bundle.warnings.length).toBeGreaterThan(0);
});
it.each(["explicit", "legacy"])("keeps %s external instructions on their configured root after selecting Boat", async (configuration) => {
const root = await makeTempDir("boat-external-instructions-"); cleanupDirs.add(root);
await fs.writeFile(path.join(root, "AGENTS.md"), "external authority");
await fs.writeFile(path.join(root, "NEXT.md"), "next external entry");
const agent = { ...makeAgent(configuration === "explicit"
? { instructionsBundleMode: "external", instructionsRootPath: root, instructionsEntryFile: "AGENTS.md" }
: { instructionsFilePath: path.join(root, "AGENTS.md") }), defaultEnvironmentId: "boat-environment" };
const remote = vi.spyOn(persistentFiles, "persistentAgentFiles").mockResolvedValue({ root: "/remote/personal" } as never);
const db = { transaction: vi.fn(async () => { throw new Error("Must not adopt external instructions"); }) };
const svc = agentInstructionsService(db as never);
expect((await svc.getBundle(agent)).rootPath).toBe(root);
expect((await svc.readFile(agent, "AGENTS.md")).content).toBe("external authority");
expect((await svc.exportFiles(agent)).files["AGENTS.md"]).toBe("external authority");
const updated = await svc.updateBundle(agent, { entryFile: "NEXT.md" });
expect(updated.bundle.mode).toBe("external");
expect(updated.bundle.rootPath).toBe(root);
expect(updated.bundle.entryFile).toBe("NEXT.md");
expect(remote).not.toHaveBeenCalled();
expect(db.transaction).not.toHaveBeenCalled();
});
it("previews an explicit external-to-managed migration before adopting remote personal files", async () => {
const root = await makeTempDir("boat-instructions-migration-"); cleanupDirs.add(root);
await fs.writeFile(path.join(root, "AGENTS.md"), "external migration source");
const agent = makeAgent({ instructionsBundleMode: "external", instructionsRootPath: root, instructionsEntryFile: "AGENTS.md" });
const seedBytes = vi.fn();
vi.spyOn(persistentFiles, "persistentAgentFiles").mockResolvedValue({ root: "/remote/personal", seedBytes } as never);
const db = {
select: () => ({ from: () => ({ where: async () => [] }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) => fn({
select: () => ({ from: () => ({ where: async () => [agent] }) }),
}),
};
const svc = agentInstructionsService(db as never);
const updated = await svc.updateBundle(agent, { mode: "managed" });
expect(updated.bundle.mode).toBe("managed");
expect(updated.bundle.rootPath).toBe(updated.bundle.managedRootPath);
expect(await fs.readFile(path.join(updated.bundle.managedRootPath, "AGENTS.md"), "utf8")).toBe("external migration source");
expect(seedBytes).not.toHaveBeenCalled();
expect(await fs.readFile(path.join(root, "AGENTS.md"), "utf8")).toBe("external migration source");
});
it("lists external bundles containing large binary assets", async () => {
const root = await makeTempDir("external-large-assets-"); cleanupDirs.add(root);
await fs.writeFile(path.join(root, "AGENTS.md"), "external instructions");
@@ -298,7 +298,7 @@ describe("claude_local environment diagnostics", () => {
expect(result.checks.some((check) => check.code === "claude_cwd_invalid")).toBe(false);
});
it("uses full permission bypass for sandbox hello probes", async () => {
it.each(["sandbox", "computer"] as const)("uses full permission bypass for %s hello probes", async (transport) => {
const executeCalls: Array<{ command: string; args?: string[]; env?: Record<string, string> }> = [];
const result = await testEnvironment({
@@ -310,11 +310,12 @@ describe("claude_local environment diagnostics", () => {
},
executionTarget: {
kind: "remote",
transport: "sandbox",
transport,
resourceAuthority: { computerId: "probe-computer" },
providerKey: "cloudflare",
remoteCwd: "/workspace/paperclip",
runner: {
execute: async (input) => {
execute: async (input: { command: string; args?: string[]; env?: Record<string, string> }) => {
executeCalls.push({ command: input.command, args: input.args, env: input.env });
if (input.command === "claude") {
return {
@@ -345,7 +346,7 @@ describe("claude_local environment diagnostics", () => {
};
},
},
},
} as never,
environmentName: "QA Cloudflare",
});
@@ -0,0 +1,316 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ZodError } from "zod";
import {
agents,
environmentLeases,
environments,
issues,
type Db,
} from "@paperclipai/db";
import {
ComputerError,
type ComputerService,
} from "../modules/computers/index.js";
import { computerRoutes } from "../routes/computers.js";
const mocks = vi.hoisted(() => ({
experimental: vi.fn(),
settings: vi.fn(),
getLease: vi.fn(),
audit: vi.fn(),
owner: vi.fn(),
}));
vi.mock("../modules/computers/index.js", async () => {
const { ComputerError } =
await import("../modules/computers/domain/ledger.js");
return {
ComputerError,
computerService: () => {
throw new Error("Inject the computer service");
},
};
});
vi.mock("@paperclipai/adapter-utils", () => ({
resolvePaperclipRunnerIdleTimeoutMs: (value: number | undefined) =>
value ?? 60_000,
}));
vi.mock("../services/instance-settings.js", () => ({
instanceSettingsService: () => ({
getExperimental: mocks.experimental,
get: mocks.settings,
}),
}));
vi.mock("../services/environments.js", () => ({
environmentService: () => ({ getLeaseById: mocks.getLease }),
}));
vi.mock("../services/computer-environment-driver.js", () => ({
computerOwnerFromLease: mocks.owner,
}));
vi.mock("../services/activity-log.js", () => ({ logActivity: mocks.audit }));
vi.mock("../services/authorization.js", () => ({
responsibleUserAuthzShadowMode: () => false,
}));
const companyId = "11111111-1111-4111-8111-111111111111";
const otherCompanyId = "22222222-2222-4222-8222-222222222222";
const issueId = "33333333-3333-4333-8333-333333333333";
const environmentId = "44444444-4444-4444-8444-444444444444";
const owner = {
computerId: "55555555-5555-4555-8555-555555555555",
ownerId: "66666666-6666-4666-8666-666666666666",
generation: 2,
};
const endpoint = `/api/issues/${issueId}/computer`;
function fixture(
options: {
actor?: "board" | "agent";
issueCompanyId?: string;
attachedCompanyId?: string;
issueMissing?: boolean;
} = {},
) {
const issue = options.issueMissing
? null
: {
id: issueId,
companyId: options.issueCompanyId ?? companyId,
assigneeAgentId: null,
};
const environment = {
id: environmentId,
name: "Boat",
driver: "computer",
status: "active",
config: { runnerIdleTimeoutMs: 30_000 },
metadata: { computerCompanyId: options.attachedCompanyId ?? companyId },
};
const lease = {
id: "lease",
environmentId,
companyId,
issueId,
provider: "boat",
metadata: { computerOwner: owner },
};
const rows = new Map<unknown, unknown[]>([
[issues, issue ? [issue] : []],
[environmentLeases, [lease]],
[environments, [environment]],
[agents, []],
]);
const select = vi.fn(() => ({
from: (table: unknown) => {
const query = {
where: vi.fn(() => query),
orderBy: vi.fn(() => query),
limit: vi.fn(async () => rows.get(table) ?? []),
};
return query;
},
}));
const computers = {
connect: vi.fn(async () => ({
viewerUrl: "https://computer.on.boat.dev/#private",
expiresAt: "2026-10-10T13:00:00Z",
owner,
})),
renewViewer: vi.fn(),
disconnectViewer: vi.fn(),
preview: vi.fn(async () => ({
url: "https://preview.on.boat.dev/?_token=private",
})),
};
mocks.getLease.mockResolvedValue(lease);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor =
options.actor === "agent"
? ({ type: "agent", agentId: "agent", companyId } as typeof req.actor)
: ({
type: "board",
source: "session",
userId: "alice",
companyIds: [companyId],
memberships: [
{ companyId, status: "active", membershipRole: "member" },
],
} as typeof req.actor);
next();
});
app.use(
"/api",
computerRoutes(
{ select } as unknown as Db,
computers as unknown as ComputerService,
),
);
app.use(
(
error: unknown,
_req: express.Request,
res: express.Response,
_next: express.NextFunction,
) => {
res
.status(
error instanceof ZodError
? 400
: ((error as { status?: number }).status ?? 500),
)
.json({ error: error instanceof Error ? error.message : "error" });
},
);
return { app, computers, select, rows, environment, lease };
}
beforeEach(() => {
vi.clearAllMocks();
mocks.experimental.mockResolvedValue({ enableBoatEnvironments: true });
mocks.settings.mockResolvedValue({ defaultEnvironmentId: null });
mocks.owner.mockReturnValue(owner);
});
describe("Computer routes", () => {
it("rejects agents and invalid task IDs before looking up a computer", async () => {
const agent = fixture({ actor: "agent" });
expect((await request(agent.app).get(endpoint)).status).toBe(403);
expect(agent.select).not.toHaveBeenCalled();
const board = fixture();
expect(
(await request(board.app).get("/api/issues/not-a-uuid/computer")).status,
).toBe(404);
expect(board.select).not.toHaveBeenCalled();
});
it("hides cross-company tasks exactly like missing tasks", async () => {
const foreign = fixture({ issueCompanyId: otherCompanyId });
const missing = fixture({ issueMissing: true });
const a = await request(foreign.app)
.post(`${endpoint}/connect`)
.send({ environmentId });
const b = await request(missing.app)
.post(`${endpoint}/connect`)
.send({ environmentId });
expect(a.status).toBe(404);
expect(a.body).toEqual(b.body);
expect(foreign.computers.connect).not.toHaveBeenCalled();
});
it("requires a company-bound environment and rejects a posted environment override", async () => {
const foreign = fixture({ attachedCompanyId: otherCompanyId });
expect((await request(foreign.app).get(endpoint)).body).toBeNull();
expect(
(
await request(foreign.app)
.post(`${endpoint}/connect`)
.send({ environmentId })
).status,
).toBe(404);
const f = fixture();
expect(
(
await request(f.app)
.post(`${endpoint}/connect`)
.send({ environmentId: otherCompanyId })
).status,
).toBe(404);
expect(f.computers.connect).not.toHaveBeenCalled();
});
it("returns ephemeral viewer credentials with no-store and no-referrer and audits only IDs", async () => {
const f = fixture();
const result = await request(f.app)
.post(`${endpoint}/connect`)
.send({ environmentId });
expect(result.status).toBe(200);
expect(result.headers["cache-control"]).toBe("no-store");
expect(result.headers["referrer-policy"]).toBe("no-referrer");
expect(f.computers.connect).toHaveBeenCalledWith({
companyId,
environmentId,
userId: "alice",
idleTimeoutMs: 30_000,
});
expect(JSON.stringify(mocks.audit.mock.calls)).not.toContain("private");
expect(mocks.audit).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "computer.connected",
details: { issueId },
}),
);
});
it("rejects forged preview owners and derives the owner from the scoped stored lease", async () => {
const f = fixture();
expect(
(
await request(f.app)
.post(`${endpoint}/preview`)
.send({
environmentId,
port: 5173,
owner: { ...owner, generation: 9 },
})
).status,
).toBe(400);
expect(f.computers.preview).not.toHaveBeenCalled();
const result = await request(f.app)
.post(`${endpoint}/preview`)
.send({ environmentId, port: 5173 });
expect(result.status).toBe(200);
expect(result.headers["cache-control"]).toBe("no-store");
expect(f.computers.preview).toHaveBeenCalledWith({
companyId,
environmentId,
owner,
port: 5173,
});
});
it("disables new connections and previews while preserving authenticated disconnect", async () => {
mocks.experimental.mockResolvedValue({ enableBoatEnvironments: false });
const f = fixture();
expect((await request(f.app).get(endpoint)).body).toBeNull();
expect(
(await request(f.app).post(`${endpoint}/connect`).send({ environmentId }))
.status,
).toBe(422);
expect(
(
await request(f.app)
.post(`${endpoint}/preview`)
.send({ environmentId, port: 5173 })
).status,
).toBe(422);
expect(
(
await request(f.app)
.post(`${endpoint}/disconnect`)
.send({ environmentId, owner })
).status,
).toBe(204);
expect(f.computers.disconnectViewer).toHaveBeenCalledWith({
companyId,
environmentId,
owner,
userId: "alice",
});
});
it("passes viewer identity to the module and maps ownership errors without exposing credentials", async () => {
const f = fixture();
f.computers.renewViewer.mockRejectedValue(
new ComputerError("forbidden", "Computer viewer belongs to another user"),
);
const result = await request(f.app)
.post(`${endpoint}/presence`)
.send({ environmentId, owner });
expect(result.status).toBe(403);
expect(result.headers["cache-control"]).toBe("no-store");
expect(f.computers.renewViewer).toHaveBeenCalledWith({
companyId,
environmentId,
owner,
userId: "alice",
});
});
});
@@ -3,6 +3,15 @@ import { HttpError } from "../errors.js";
import { normalizeEnvironmentConfig, parseEnvironmentDriverConfig } from "../services/environment-config.ts";
describe("environment config helpers", () => {
it("stores only the Boat secret reference and rejects unmanaged credentials", () => {
const config = { provider: "boat", sandboxId: "bx_fixture", apiKeySecretRef: {
type: "secret_ref", secretId: "11111111-1111-4111-8111-111111111111", version: "latest",
}, runnerIdleTimeoutMs: 300000 };
expect(normalizeEnvironmentConfig({ driver: "computer", config })).toEqual(config);
expect(() => normalizeEnvironmentConfig({ driver: "computer", config: { ...config, apiKey: "must-not-persist" } })).toThrow();
expect(() => normalizeEnvironmentConfig({ driver: "computer", config: { ...config, sandboxId: "../../other" } })).toThrow();
});
it("normalizes SSH config into its canonical stored shape", () => {
const config = normalizeEnvironmentConfig({
driver: "ssh",
@@ -47,11 +47,11 @@ describe("environment driver traits", () => {
});
}
it("only the sandbox driver has a lease capability model today", () => {
it("computer and sandbox drivers have a lease capability model", () => {
const withModel = Object.values(ENVIRONMENT_DRIVER_TRAITS).filter(
(traits) => traits.hasLeaseCapabilityModel,
);
expect(withModel.map((traits) => traits.driver)).toEqual(["sandbox"]);
expect(withModel.map((traits) => traits.driver).sort()).toEqual(["computer", "sandbox"]);
});
it("getEnvironmentDriverTraits resolves a registered driver", () => {
@@ -749,6 +749,20 @@ describe("environmentRunOrchestrator — realizeForRun", () => {
describe("native runner lifecycle changes before lease acquisition", () => {
it("fences computer warm admission when the selected model changes", async () => {
const environment = { ...makeEnvironment("computer"), config: { provider: "boat" } };
mockGetEnvironment.mockResolvedValue(environment);
const acquireRunLease = vi.fn(async (input) => ({ environment: input.environment, lease: makeLease(), leaseContext: {} }));
const orchestrator = environmentRunOrchestrator({} as never, { environmentRuntime: makeMockRuntime({ acquireRunLease }) });
const input = { companyId: "company-1", selectedEnvironmentId: "env-1", localEnvironmentId: "local", adapterType: "paperclip_runner",
issueId: "task", heartbeatRunId: "run-1", agentId: "agent-1", persistedExecutionWorkspace: null, executionWorkspaceSettings: null };
for (const model of ["model-a", "model-a", "model-b"]) await orchestrator.acquireForRun({ ...input, adapterConfig: { provider: "codex", model } });
const keys = acquireRunLease.mock.calls.map(([input]) => input.executionConfigurationKey);
expect(keys[0]).toMatch(/^[a-f0-9]{64}$/);
expect(keys[1]).toBe(keys[0]);
expect(keys[2]).not.toBe(keys[0]);
});
it("requests a reusable lease when an existing task switches from per-turn to warm", async () => {
const environment = { ...makeEnvironment("sandbox"), config: { provider: "daytona", reuseLease: false } };
mockGetEnvironment.mockResolvedValue(environment);
@@ -1,3 +1,4 @@
import * as computerModule from "../modules/computers/index.js";
import express from "express";
import type { Request } from "express";
import { execFile } from "node:child_process";
@@ -208,6 +209,45 @@ describeEmbeddedPostgres("workspace file resources", () => {
await tempDb?.cleanup();
});
it("browses a recorded computer placement and confines previews/downloads to safe relative files", async () => {
const workspace = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot: workspace.projectRoot, executionRoot: workspace.executionRoot });
const environmentId = crypto.randomUUID(), placementId = crypto.randomUUID();
await db.update(executionWorkspaces).set({ providerType: "computer", cwd: "/home/user/paperclip/remote/project",
metadata: { fileAuthority: { kind: "remote-persistent", environmentId, placementId } } })
.where(eq(executionWorkspaces.sourceIssueId, graph.issueId));
const bytes = Buffer.from("only on Boat");
const readBytes = vi.fn(async () => ({ bytes, sha256: "unused-by-preview" }));
const listPage = vi.fn(async () => ({ entries: [{ name: "README.md", kind: "file", size: bytes.length, mtimeMs: 1 },
{ name: ".env", kind: "file", size: 1, mtimeMs: 1 }, { name: ".paperclip-runtime", kind: "directory", size: 0, mtimeMs: 1 }], truncated: true }));
const stat = vi.fn(async (name: string) => ({ name, kind: "file", size: bytes.length, mtimeMs: 1 }));
const workspaceFiles = vi.fn(async () => ({ root: "/home/user/paperclip/remote/project", listPage, stat, readBytes }));
const factory = vi.spyOn(computerModule, "computerService").mockReturnValue({ workspaceFiles } as never);
try {
const service = workspaceFileResourceService(db);
const listed = await service.list(graph.issueId, { workspace: "execution" });
expect(listed.items.map(item => item.title)).toEqual(["README.md"]);
expect(listed.truncated).toBe(true);
expect(listPage).toHaveBeenCalledWith(undefined, { limit: 1000 });
expect(workspaceFiles).toHaveBeenCalledWith({ companyId: graph.companyId, environmentId, placementId });
const content = await service.readContent(graph.issueId, { path: "README.md", workspace: "execution" });
expect(content.content).toEqual({ encoding: "utf8", data: "only on Boat" });
expect(readBytes).toHaveBeenCalledWith("README.md", WORKSPACE_FILE_TEXT_MAX_BYTES);
await service.readContent(graph.issueId, { path: "beyond-listing-cap.md", workspace: "execution" });
expect(stat).toHaveBeenCalledWith("beyond-listing-cap.md");
expect(listPage).toHaveBeenCalledOnce();
expect(await service.prepareDownload(graph.issueId, { path: "README.md", workspace: "execution" })).toMatchObject({ bytes });
readBytes.mockClear();
await expect(service.readContent(graph.issueId, { path: ".env", workspace: "execution" })).rejects.toMatchObject({ status: 403 });
await expect(service.readContent(graph.issueId, { path: "../other-company/secret", workspace: "execution" })).rejects.toMatchObject({ status: 403 });
expect(readBytes).not.toHaveBeenCalled();
workspaceFiles.mockClear();
const readOnly = workspaceFileResourceService(db, undefined, { allowComputerAccess: false });
await expect(readOnly.prepareDownload(graph.issueId, { path: "README.md", workspace: "execution" })).rejects.toMatchObject({ status: 422, details: { code: "remote_workspace" } });
expect(workspaceFiles).not.toHaveBeenCalled();
} finally { factory.mockRestore(); await fs.rm(workspace.root, { recursive: true, force: true }); }
});
it("withholds private task files and private cross-project targets", async () => {
const { projectRoot, executionRoot, targetProjectRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot, targetProjectRoot });
@@ -108,15 +108,19 @@ describe("general capability classifier", () => {
}
});
it("defines static capability support for the four drivers", () => {
it("defines static capability support for every driver", () => {
expect(Object.keys(ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT).sort()).toEqual([
"computer",
"local",
"plugin",
"sandbox",
"ssh",
]);
// The two remote provider drivers support the whole capability set; the two
// The three provider drivers support the whole capability set; the two
// host drivers support none.
expect(ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT.computer.supportedCapabilities.size).toBe(
SANDBOX_CAPABILITY_KEYS.length,
);
expect(ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT.sandbox.supportedCapabilities.size).toBe(
SANDBOX_CAPABILITY_KEYS.length,
);
@@ -47,6 +47,7 @@ describe("instance settings service", () => {
enableWorkspaceDirtyQuarantineRepair: false,
enableNewestFirstIssueThread: true,
})).toEqual({
enableBoatEnvironments: false,
enableEnvironments: true,
enableNativeRunner: false,
enableAiConnectionRouters: false,
@@ -38,6 +38,7 @@ const apiPrefixes: Record<string, string> = {
"cloud.ts": "/api/cloud",
"customer-success.ts": "/api/customer-success/v1",
"companies.ts": "/api/companies",
"computers.ts": "/api",
"company-skills.ts": "/api",
"company-skill-policy.ts": "/api",
"connection-intents.ts": "/api",
@@ -864,6 +865,23 @@ describe("openapi routes", () => {
expect(decision.requestBody.content["application/json"].schema.required).toEqual(expect.arrayContaining(["approve", "approvalCode"]));
});
it("documents board-only computer ownership and private viewer responses", () => {
const { spec } = loadSpecRoutes();
for (const suffix of ["", "/connect", "/presence", "/disconnect", "/preview"]) {
const operation = spec.paths[`/api/issues/{issueId}/computer${suffix}`][suffix ? "post" : "get"];
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
expect(operation.responses["404"]).toBeDefined();
}
const viewer = spec.paths["/api/issues/{issueId}/computer/connect"].post;
expect(viewer.responses["200"].headers["Cache-Control"].schema.enum).toEqual(["no-store"]);
expect(viewer.responses["200"].content["application/json"].schema.required).toContain("owner");
const preview = spec.paths["/api/issues/{issueId}/computer/preview"].post;
expect(preview.requestBody.content["application/json"].schema.properties.owner).toBeUndefined();
const disconnect = spec.paths["/api/issues/{issueId}/computer/disconnect"].post;
expect(disconnect.responses["204"]).toBeDefined();
expect(disconnect.description).toContain("disabled");
});
it("covers the mounted server routes exactly", () => {
const {
routes: actualRoutes,
+9 -1
View File
@@ -432,6 +432,12 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
if (!((os === "Linux" && arch === "x86_64") || (os === "Darwin" && (arch === "arm64" || (profile.acpxAgent !== "grok" && arch === "x86_64"))))) {
throw new Error(`ACPX ${profile.acpxAgent} requires a qualified Linux x64 or macOS architecture.`);
}
if (target.transport === "computer" && profile.acpxAgent === "claude") {
const hello = await claudeTestEnvironment({ ...context, adapterType: "claude_local",
config: { ...context.config, engine: "cli", model: profile.model,
dangerouslySkipPermissions: context.config.acpxPermissionMode === "approve-all" } });
return { ...hello, adapterType: "paperclip_runner" };
}
return {
adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(),
checks: [{ code: "acpx_remote_runtime_unverified", level: "warn" as const,
@@ -485,7 +491,9 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
}
const result = profile.provider === "opencode"
? await openCodeTestEnvironment(context)
: await codexTestEnvironment(context);
: await codexTestEnvironment(context.executionTarget?.kind === "remote" && context.executionTarget.transport === "computer"
? { ...context, config: { ...context.config, engine: "cli" } }
: context);
return { ...result, adapterType: "paperclip_runner" };
},
listSkills: listCodexSkills,
+2
View File
@@ -11,6 +11,7 @@ import type { CloudWarmStandby } from "./services/cloud-warm-standby.js";
import { browserUseRoutes } from "./routes/browser-use.js";
import { registerChatDeliveryWork } from "./services/chat-delivery-work.js";
import { registerBrowserUseCleanup } from "./services/browser-use-work.js";
import { computerRoutes } from "./routes/computers.js";
import { browserUseService } from "./services/browser-use.js";
import { slackToolRoutes } from "./routes/slack-tools.js";
import { createPublicMcpOAuth, publicMcpConfig } from "./services/public-mcp/oauth.js";
@@ -999,6 +1000,7 @@ export async function createApp(
{ cancelWorkForScope: heartbeatService(db, { pluginWorkerManager: workerManager }).cancelBudgetScopeWork },
(session, run) => toolGateway.browserUseSessionAuthorized({ ...session, runId: run.heartbeatRunId, invocationId: run.invocationId }));
api.use(browserUseRoutes(db, browserUse));
api.use(computerRoutes(db));
api.use(toolGatewayRoutes(db, toolGateway));
api.use(
pluginRoutes(
+2
View File
@@ -21,6 +21,7 @@ import { deliverExecutionStatuses } from "./services/execution-status-delivery.j
import { deliverReconciledExecutions, settleUnrecoverableExecutions } from "./services/execution-recovery-resolution.js";
import { reconcileSafeNativeReplacements } from "./services/native-runtime/native-safe-replacement.js";
import { reconcileAbandonedExecutionControl } from "./services/execution-control-reconciliation.js";
import { computerService } from "./modules/computers/index.js";
import { EXECUTION_RECONCILIATION_INTERVAL_MS } from "./services/execution-control-deadline.js";
import { connectionIntentDeliveryService } from "./services/connection-intent-delivery.js";
import { existsSync, readFileSync, rmSync } from "node:fs";
@@ -1184,6 +1185,7 @@ async function startServerWithDatabaseTeardown(
};
const executionControlSweepsInFlight = new Set<string>();
const executionControlSweeps = [
["computers", () => computerService(db).reconcile()],
["finalization", () => reconcileAbandonedExecutionControl(db)],
["replacement", () => heartbeat ? reconcileSafeNativeReplacements(db, new Date(), { verifyStoppedSession: run => verifyStoppedNativeSessionForReplacement(db, run) }) : undefined],
["reconciliation_delivery", () => heartbeat ? deliverReconciledExecutions(db, heartbeat.wakeup) : undefined],
@@ -0,0 +1,252 @@
import {
mkdtempSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
mkdirSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { remoteProgram } from "./remote-program.js";
import { afterEach, describe, it, expect, vi } from "vitest";
import { boatBackend } from "./boat.js";
import type { ComputerRecord } from "../domain/ledger.js";
const record: ComputerRecord = {
id: "computer",
companyId: "company",
environmentId: "environment",
providerId: "bx_test",
ledger: {
controllerId: "controller",
status: "attached",
secretRef: { type: "secret_ref", secretId: "secret" },
owners: [],
placements: {},
action: null,
},
};
const json = (value: unknown, status = 200, headers?: Record<string, string>) =>
new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json", ...headers },
});
describe("Boat transport validation", () => {
it("recognizes idle as an already running computer", async () => {
const fetcher = vi.fn(async () =>
json({
sandbox: { id: "bx_test", state: "idle", snapshots: true, stop: null },
}),
);
await boatBackend(async () => "secret", fetcher).ready(record);
expect(fetcher).toHaveBeenCalledTimes(1);
});
it("rejects another physical ID and snapshot-disabled computers", async () => {
await expect(
boatBackend(
async () => "secret",
vi.fn(async () =>
json({ sandbox: { id: "bx_wrong", state: "idle", snapshots: true } }),
),
).inspect(record),
).rejects.toMatchObject({ code: "provider_error" });
await expect(
boatBackend(
async () => "secret",
vi.fn(async () =>
json({ sandbox: { id: "bx_test", state: "idle", snapshots: false } }),
),
).ready(record),
).rejects.toMatchObject({ code: "invalid" });
});
it("keeps port credentials out of the WSS URL and does not forward API authorization to the hosting origin", async () => {
const fetcher = vi.fn(async (url: any, options?: any) =>
String(url).startsWith("https://boat.dev/")
? json({
url: "https://test.on.boat.dev/?_token=private-token",
access: "private",
})
: new Response(null, {
status: 302,
headers: {
"set-cookie": "_port_auth=private-cookie; Secure; HttpOnly",
},
}),
);
const endpoint = await boatBackend(
async () => "api-secret",
fetcher,
).ingress(record, 43127, "/runner/ws");
expect(endpoint).toEqual({
url: "wss://test.on.boat.dev/runner/ws",
secretHeaders: { Cookie: "_port_auth=private-cookie" },
});
expect(fetcher.mock.calls[1]![1].headers).toBeUndefined();
expect(fetcher.mock.calls[1]![1].redirect).toBe("manual");
});
it("refuses public ports and arbitrary hosting origins", async () => {
for (const url of [
"http://test.on.boat.dev/?_token=x",
"https://evil.example/?_token=x",
])
await expect(
boatBackend(
async () => "secret",
vi.fn(async () => json({ url, access: "private" })),
).preview(record, 5173),
).rejects.toMatchObject({ code: "provider_error" });
await expect(
boatBackend(
async () => "secret",
vi.fn(async () =>
json({ url: "https://test.on.boat.dev/", access: "public" }),
),
).preview(record, 5173),
).rejects.toMatchObject({ code: "provider_error" });
});
});
const roots: string[] = [];
function fixture() {
const temp = realpathSync(mkdtempSync(join(tmpdir(), "computer-files-")));
roots.push(temp);
const root = join(temp, "home");
const call = (input: Record<string, unknown>) => {
const result = spawnSync(
"python3",
["-c", remoteProgram.replaceAll("/home/user/paperclip/", `${temp}/`)],
{ input: JSON.stringify({ ...input, root }), encoding: "utf8" },
);
if (result.status !== 0) throw new Error(result.stderr);
return JSON.parse(result.stdout);
};
return { root, temp, call };
}
afterEach(() => {
for (const root of roots.splice(0))
rmSync(root, { recursive: true, force: true });
});
describe("confined computer files", () => {
it("seeds absent home only and preserves existing content", () => {
const f = fixture();
expect(
f.call({
action: "seed",
files: { "nested/one": Buffer.from("first").toString("base64") },
}),
).toEqual({ seeded: true });
expect(
f.call({
action: "seed",
files: { "nested/one": Buffer.from("overwrite").toString("base64") },
}),
).toEqual({ seeded: false });
expect(
Buffer.from(
f.call({ action: "read", path: "nested/one" }).base64,
"base64",
).toString(),
).toBe("first");
});
it("rejects traversal, symlink escapes, and oversized reads", () => {
const f = fixture();
f.call({
action: "seed",
files: { ok: Buffer.from("12345").toString("base64") },
});
mkdirSync(join(f.temp, "outside"));
writeFileSync(join(f.temp, "outside", "secret"), "private");
symlinkSync(join(f.temp, "outside"), join(f.root, "escape"));
expect(f.call({ action: "read", path: "../outside/secret" })).toEqual({
error: "invalid",
});
expect(f.call({ action: "read", path: "escape/secret" })).toEqual({
error: "invalid",
});
expect(f.call({ action: "read", path: "ok", maxBytes: 4 })).toEqual({
error: "invalid",
});
});
it("rejects stale edits and uses no-clobber move semantics", () => {
const f = fixture();
f.call({ action: "seed", files: {} });
const first = f.call({
action: "write",
path: "file",
expectedSha256: null,
base64: Buffer.from("one").toString("base64"),
});
const second = f.call({
action: "write",
path: "file",
expectedSha256: first.sha256,
base64: Buffer.from("two").toString("base64"),
});
expect(
f.call({
action: "write",
path: "file",
expectedSha256: first.sha256,
base64: "",
}),
).toEqual({ error: "conflict" });
expect(
f.call({
action: "move",
path: "file",
to: "other",
expectedSha256: second.sha256,
}),
).toEqual({ sha256: second.sha256 });
expect(f.call({ action: "list" }).entries.map((e: any) => e.name)).toEqual([
"other",
]);
expect(
f.call({ action: "remove", path: "other", expectedSha256: first.sha256 }),
).toEqual({ error: "conflict" });
});
it("bounds directory enumeration and stats files beyond the listing cap", () => {
const f = fixture();
f.call({ action: "seed", files: {} });
for (let i = 0; i < 1005; i++) writeFileSync(join(f.root, `file-${i}`), "content");
const page = f.call({ action: "list" });
expect(page.entries).toHaveLength(1000);
expect(page.truncated).toBe(true);
const omitted = Array.from({ length: 1005 }, (_, i) => `file-${i}`).find(name => !page.entries.some((entry: { name: string }) => entry.name === name))!;
expect(f.call({ action: "stat", path: omitted })).toMatchObject({ name: omitted, kind: "file", size: 7 });
expect(f.call({ action: "list", limit: 2 }).entries).toHaveLength(2);
expect(f.call({ action: "list", limit: 1001 })).toEqual({ error: "invalid" });
});
it.each([false, true])("uses the requested worktree branch separately from its start ref (explicit base: %s)", explicitBase => {
const f = fixture();
const checkout = join(f.root, "checkout");
mkdirSync(checkout, { recursive: true });
const git = (...args: string[]) => {
const result = spawnSync("git", args, { cwd: checkout, encoding: "utf8" });
if (result.status !== 0) throw new Error(result.stderr);
return result.stdout.trim();
};
git("init");
git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "base");
const base = git("rev-parse", "HEAD");
git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "latest");
const expectedHead = explicitBase ? base : git("rev-parse", "HEAD");
const input = { action: "workspace", mode: "worktree", taskId: "issue-1", branch: "paperclip/task-issue-1", ...(explicitBase ? { baseRef: base } : {}) };
const result = f.call(input);
expect(result.remoteCwd).toBe(join(f.root, "tasks", "issue-1"));
expect(git("-C", result.remoteCwd, "symbolic-ref", "--short", "HEAD")).toBe(input.branch);
expect(git("-C", result.remoteCwd, "rev-parse", "HEAD")).toBe(expectedHead);
writeFileSync(join(result.remoteCwd, "preserved"), "user work");
expect(f.call({ ...input, branch: "another-branch" })).toEqual({ error: "conflict" });
expect(git("-C", result.remoteCwd, "status", "--short")).toContain("preserved");
});
it("roundtrips binary bytes without interpreting text", () => {
const f = fixture();
const bytes = Buffer.from([0, 255, 254, 13, 10, 128]);
f.call({ action: "seed", files: { binary: bytes.toString("base64") } });
expect(
Buffer.from(f.call({ action: "read", path: "binary" }).base64, "base64"),
).toEqual(bytes);
});
});
@@ -0,0 +1,406 @@
import { generateKeyPairSync, randomUUID } from "node:crypto";
import {
createSshCommandManagedRuntimeRunner,
shellQuote,
} from "@paperclipai/adapter-utils/ssh";
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
import type { ComputerBackend } from "../application/ports.js";
import {
ComputerError,
segment,
type ComputerRecord,
type Owner,
} from "../domain/ledger.js";
import { remoteProgram } from "./remote-program.js";
const apiOrigin = "https://boat.dev/api/v1";
const transportCache = new Map<string, Promise<CommandManagedRuntimeRunner>>();
const desktopCache = new Map<
string,
{ viewerUrl: string; expiresAt: string }
>();
export function boatBackend(
resolveKey: (record: ComputerRecord) => Promise<string>,
fetcher: typeof fetch = fetch,
): ComputerBackend {
async function api(
record: ComputerRecord,
method: string,
suffix = "",
body?: unknown,
): Promise<any> {
const response = await fetcher(
`${apiOrigin}/sandboxes/${segment(record.providerId)}${suffix}`,
{
method,
headers: {
authorization: `Bearer ${await resolveKey(record)}`,
"content-type": "application/json",
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal: AbortSignal.timeout(90_000),
},
);
if (!response.ok)
throw new ComputerError(
"provider_error",
`Boat request failed (${response.status})`,
);
const data = await response.json();
if (!data || typeof data !== "object")
throw new ComputerError("provider_error", "Invalid Boat response");
return data;
}
async function inspect(record: ComputerRecord) {
const data = await api(record, "GET");
const sandbox = data.sandbox ?? data;
if (sandbox.id !== record.providerId || typeof sandbox.state !== "string")
throw new ComputerError(
"provider_error",
"Boat returned a different computer",
);
return {
state: sandbox.state as string,
snapshots: sandbox.snapshots === true,
stop: sandbox.stop ?? null,
};
}
async function runner(
record: ComputerRecord,
): Promise<CommandManagedRuntimeRunner> {
const key = `${record.id}:${record.ledger.secretRef.secretId}:${record.ledger.secretRef.version ?? "latest"}`;
let pending = transportCache.get(key);
if (!pending) {
pending = (async () => {
const pair = generateKeyPairSync("ed25519");
const jwk = pair.publicKey.export({ format: "jwk" });
const publicBytes = Buffer.from(jwk.x!, "base64url");
const name = Buffer.from("ssh-ed25519");
const length = (n: number) => {
const b = Buffer.alloc(4);
b.writeUInt32BE(n);
return b;
};
const publicKey = `ssh-ed25519 ${Buffer.concat([length(name.length), name, length(publicBytes.length), publicBytes]).toString("base64")} paperclip-computer`;
// OpenSSH cannot read PKCS8 Ed25519 keys; emit its unencrypted private format in memory.
const seed = Buffer.from(
pair.privateKey.export({ format: "jwk" }).d!,
"base64url",
);
const field = (b: Buffer) => Buffer.concat([length(b.length), b]);
const check = Buffer.from("70617065", "hex");
let privateBody = Buffer.concat([
check,
check,
field(name),
field(publicBytes),
field(Buffer.concat([seed, publicBytes])),
field(Buffer.from("paperclip-computer")),
]);
const padding = 8 - (privateBody.length % 8);
privateBody = Buffer.concat([
privateBody,
Buffer.from(Array.from({ length: padding }, (_, i) => i + 1)),
]);
const publicBlob = Buffer.concat([field(name), field(publicBytes)]);
const blob = Buffer.concat([
Buffer.from("openssh-key-v1\0"),
field(Buffer.from("none")),
field(Buffer.from("none")),
field(Buffer.alloc(0)),
length(1),
field(publicBlob),
field(privateBody),
]);
const privateKey = `-----BEGIN OPENSSH PRIVATE KEY-----\n${blob
.toString("base64")
.match(/.{1,70}/g)!
.join("\n")}\n-----END OPENSSH PRIVATE KEY-----\n`;
const result = await api(record, "POST", "/sshkey", { key: publicKey });
if (
typeof result.hostKey !== "string" ||
!/^ssh-ed25519 [A-Za-z0-9+/=]+$/.test(result.hostKey.trim())
)
throw new ComputerError(
"provider_error",
"Boat did not provide a valid SSH host key",
);
const endpoint =
typeof result.sshEndpoint === "string" ? result.sshEndpoint : null;
const parsed = endpoint?.match(/^([A-Za-z0-9.-]+):(\d+)$/);
const host = parsed?.[1] ?? result.machineIp;
const port = parsed ? Number(parsed[2]) : 22;
if (
typeof host !== "string" ||
!host ||
/[\s/]/.test(host) ||
!Number.isInteger(port) ||
port < 1 ||
port > 65535
)
throw new ComputerError(
"provider_error",
"Invalid Boat SSH endpoint",
);
const knownHost = port === 22 ? host : `[${host}]:${port}`;
return createSshCommandManagedRuntimeRunner({
spec: {
host,
port,
username: "user",
remoteWorkspacePath: "/home/user",
remoteCwd: "/home/user",
privateKey,
knownHosts: `${knownHost} ${result.hostKey.trim()}\n`,
strictHostKeyChecking: true,
},
maxBufferBytes: 24 * 1024 * 1024,
});
})();
transportCache.set(key, pending);
pending.catch(() => transportCache.delete(key));
}
return pending;
}
async function execute(
record: ComputerRecord,
code: string,
payload: unknown,
) {
const result = await (
await runner(record)
).execute({
command: "python3",
args: ["-c", code],
stdin: JSON.stringify(payload),
timeoutMs: 120_000,
});
if (result.exitCode !== 0)
throw new ComputerError("provider_error", "Computer operation failed");
let value: any;
try {
value = JSON.parse(result.stdout);
} catch {
throw new ComputerError(
"provider_error",
"Invalid computer operation response",
);
}
if (value?.error)
throw new ComputerError(
["not_found", "conflict", "invalid"].includes(value.error)
? value.error
: "provider_error",
`Computer operation: ${value.error}`,
);
return value;
}
const processProgram = String.raw`
import os,sys,json,fcntl,subprocess
p=json.load(sys.stdin);base='/home/user/.paperclip-owners';os.makedirs(base,exist_ok=True)
owner=p['owner'];oid=owner['id'];root=os.path.join(base,oid);os.makedirs(root,exist_ok=True)
with open(os.path.join(root,'lock'),'a') as lock:
fcntl.flock(lock,fcntl.LOCK_EX)
tombstone=os.path.join(root,'retired');unit='paperclip-'+oid+'.service';slice='paperclip-'+oid+'.slice'
boot=open('/proc/sys/kernel/random/boot_id').read().strip()
generation_path=os.path.join(root,'generation')
generation=int(open(generation_path).read()) if os.path.exists(generation_path) else 0
if owner['generation']<generation:print(json.dumps({'error':'conflict'}));sys.exit(0)
if p['action']=='advance':
if os.path.exists(tombstone):print(json.dumps({'error':'conflict'}));sys.exit(0)
with open(generation_path,'w') as f:f.write(str(owner['generation']))
print('{}')
elif p['action']=='retire':
open(tombstone,'a').close()
subprocess.run(['systemctl','--user','stop',slice,unit],capture_output=True)
state=subprocess.run(['systemctl','--user','show',slice,'--property=ActiveState','--value'],capture_output=True,text=True).stdout.strip()
if state not in ('inactive','failed',''):raise RuntimeError('process retirement unconfirmed')
print('{}')
elif p['action']=='inspect':
claim=owner.get('process');marker=os.path.join(root,'claim.json');actual=json.load(open(marker)) if os.path.exists(marker) else None
valid=claim and actual and claim['nonce']==actual['nonce'] and claim['launchGeneration']==actual['launchGeneration'] and actual['bootId']==boot
state=subprocess.run(['systemctl','--user','is-active',unit],capture_output=True,text=True).stdout.strip()
print(json.dumps({'running':bool(valid and state=='active' and not os.path.exists(tombstone)),'claim':actual if valid else claim}))
else:
if os.path.exists(tombstone):print(json.dumps({'error':'conflict'}));sys.exit(0)
claim=owner['process'];claim['bootId']=boot
marker=os.path.join(root,'claim.json')
if os.path.exists(marker):
old=json.load(open(marker))
if old['launchGeneration']>claim['launchGeneration'] or (old['launchGeneration']==claim['launchGeneration'] and old['nonce']!=claim['nonce']):print(json.dumps({'error':'conflict'}));sys.exit(0)
active=subprocess.run(['systemctl','--user','is-active',unit],capture_output=True,text=True).stdout.strip()=='active'
if active and old['nonce']!=claim['nonce']:print(json.dumps({'error':'conflict'}));sys.exit(0)
with open(marker,'w') as f:json.dump(claim,f)
# Shell launch scripts own expansion of their positional arguments and identity markers.
payload=p['input'];args=['systemd-run','--user','--expand-environment=no','--unit='+unit,'--slice='+slice,'--collect','--property=KillMode=control-group','--working-directory='+payload.get('cwd','/home/user')]
for key,value in payload.get('env',{}).items():args.append('--setenv='+key+'='+value)
state=subprocess.run(['systemctl','--user','is-active',unit],capture_output=True,text=True).stdout.strip()
if state!='active':
result=subprocess.run(args+['--',payload['command']]+payload.get('args',[]),capture_output=True)
if result.returncode:raise RuntimeError('launch failed')
print(json.dumps(claim))
`;
async function host(record: ComputerRecord, port: number) {
if (!Number.isInteger(port) || port < 1024 || port > 65535)
throw new ComputerError("invalid", "Invalid preview port");
const result = await api(record, "POST", "/host", {
port,
access: "private",
});
const url = new URL(result.url);
if (
url.protocol !== "https:" ||
!url.hostname.endsWith(".on.boat.dev") ||
url.username ||
url.password ||
!url.searchParams.get("_token") ||
result.access === "public"
)
throw new ComputerError(
"provider_error",
"Boat did not provide private hosting",
);
return url;
}
function stopReceipt(
value: any,
expectedId?: string,
): { id: string; status: string } {
if (
!value ||
typeof value.id !== "string" ||
!/^stop_[a-zA-Z0-9]+$/.test(value.id) ||
(expectedId && value.id !== expectedId) ||
!["pending", "failing", "completed", "superseded"].includes(value.status)
) {
throw new ComputerError(
"provider_error",
"Boat returned an invalid stop receipt",
);
}
return { id: value.id, status: value.status };
}
return {
inspect,
runner,
async ready(record) {
const state = await inspect(record);
if (!state.snapshots)
throw new ComputerError("invalid", "Boat snapshots must be enabled");
if (state.stop && ["pending", "failing"].includes(state.stop.status))
throw new ComputerError(
"conflict",
"Boat is still saving a previous stop",
);
if (!["ready", "idle", "running"].includes(state.state)) {
await api(record, "POST", "/resume", { ttlSeconds: 300 });
transportCache.delete(
`${record.id}:${record.ledger.secretRef.secretId}:${record.ledger.secretRef.version ?? "latest"}`,
);
for (let i = 0; i < 90; i++) {
const status = await inspect(record);
if (["ready", "idle", "running"].includes(status.state)) return;
await new Promise((r) => setTimeout(r, 1000));
}
throw new ComputerError("provider_error", "Boat did not become ready");
}
},
async claim(record) {
await execute(
record,
String.raw`
import os,sys,json,fcntl
p=json.load(sys.stdin);path='/home/user/.paperclip-controller.json'
fd=os.open(path,os.O_CREAT|os.O_RDWR|os.O_NOFOLLOW,0o600)
with os.fdopen(fd,'r+') as f:
fcntl.flock(f,fcntl.LOCK_EX);old=f.read()
if old and json.loads(old)!=p:print(json.dumps({'error':'conflict'}));sys.exit(0)
if not old:f.write(json.dumps(p));f.flush();os.fsync(f.fileno())
print('{}')
`,
{
controllerId: record.ledger.controllerId,
companyId: record.companyId,
computerId: record.id,
},
);
},
async advance(record, owner) {
await execute(record, processProgram, { action: "advance", owner });
},
async launch(record, owner, input) {
return execute(record, processProgram, {
action: "launch",
owner,
input,
});
},
async inspectProcess(record, owner) {
return execute(record, processProgram, { action: "inspect", owner });
},
async retire(record, owner) {
await execute(record, processProgram, { action: "retire", owner });
},
async stop(record) {
const value = await api(record, "POST", "/stop", {});
const stop = value.stop ?? value.sandbox?.stop;
return stopReceipt(stop);
},
async stopStatus(record, id) {
const value = await api(record, "GET", `/stops/${segment(id)}`);
return stopReceipt(value.stop ?? value, id);
},
async renew(record) {
await api(record, "PATCH", "", { ttlSeconds: 300 });
},
async desktop(record) {
const cached = desktopCache.get(record.id);
if (cached && Date.parse(cached.expiresAt) > Date.now() + 60_000)
return cached;
const value = await api(record, "POST", "/desktop", {});
const url = new URL(value.desktopUrl);
if (url.protocol !== "https:" || !url.hostname.endsWith(".on.boat.dev"))
throw new ComputerError("provider_error", "Invalid Boat desktop URL");
url.searchParams.set("overlay", "0");
const result = {
viewerUrl: url.toString(),
expiresAt: new Date(Date.now() + 9 * 60_000).toISOString(),
};
desktopCache.set(record.id, result);
return result;
},
async preview(record, port) {
return { url: (await host(record, port)).toString() };
},
async ingress(record, port, path) {
if (
!path.startsWith("/") ||
path.startsWith("//") ||
/[?#\r\n]/.test(path)
)
throw new ComputerError("invalid", "Invalid runner ingress path");
const url = await host(record, port);
const response = await fetcher(url, {
redirect: "manual",
signal: AbortSignal.timeout(30_000),
});
const cookie = response.headers
.getSetCookie()
.find((value) => value.startsWith("_port_auth="))
?.split(";")[0];
if (!cookie || response.status < 200 || response.status >= 400)
throw new ComputerError(
"provider_error",
"Boat private hosting authentication failed",
);
url.search = "";
url.hash = "";
url.protocol = "wss:";
url.pathname = path;
return { url: url.toString(), secretHeaders: { Cookie: cookie } };
},
async remote(record, input) {
return execute(record, remoteProgram, input);
},
};
}
@@ -0,0 +1,28 @@
import { eq } from "drizzle-orm";
import { computers } from "@paperclipai/db";
import type { CompanyDeletionParticipant } from "../../../lib/company-deletion.js";
import { ComputerError, liveOwners, type Ledger } from "../domain/ledger.js";
export const computerCompanyDeletion: CompanyDeletionParticipant = {
async deleteCompanyData(tx, companyId) {
const records = await tx
.select()
.from(computers)
.where(eq(computers.companyId, companyId))
.for("update");
for (const record of records) {
const ledger = record.ledger as unknown as Ledger;
if (
ledger.status !== "detached" ||
ledger.action ||
liveOwners(ledger).length
) {
throw new ComputerError(
"conflict",
"Detach company computers and wait for their snapshots before deleting the company",
);
}
}
await tx.delete(computers).where(eq(computers.companyId, companyId));
},
};
@@ -0,0 +1,196 @@
/** Fixed remote program. Inputs travel as JSON, never interpolated into Python or shell. */
export const remoteProgram = String.raw`
import os,sys,json,hashlib,base64,tempfile,subprocess,fcntl,stat,shutil
p=json.load(sys.stdin)
def fail(code):
print(json.dumps({'error':code}));sys.exit(0)
def digest(data):return hashlib.sha256(data).hexdigest()
def run(args,cwd=None):
r=subprocess.run(args,cwd=cwd,capture_output=True,text=True)
if r.returncode:fail('command_failed')
return r.stdout.strip()
root=p['root']
if not root.startswith('/home/user/paperclip/') or '..' in root.split('/'):fail('invalid')
# Reject symlinks even in existing ancestors; one Unix user is not an OS boundary.
def safe(path):
if not isinstance(path,str) or path.startswith('/') or '\x00' in path or '..' in path.split('/'):fail('invalid')
current=root
for part in path.split('/'):
if part in ('','.'):continue
current=os.path.join(current,part)
if os.path.islink(current):fail('invalid')
if os.path.commonpath([root,os.path.realpath(current)])!=root:fail('invalid')
return current
current='/'
for part in root.split('/'):
if not part:continue
current=os.path.join(current,part)
if os.path.islink(current):fail('invalid')
act=p['action']
if act=='owned-port':
port=p['port'];owner=p['ownerId'];inodes=set()
for table in ['/proc/net/tcp','/proc/net/tcp6']:
for line in open(table).readlines()[1:]:
cols=line.split()
if int(cols[1].split(':')[1],16)==port and cols[3]=='0A':inodes.add(cols[9])
for pid in os.listdir('/proc'):
if not pid.isdigit():continue
try:
if 'paperclip-'+owner+'.slice' not in open('/proc/'+pid+'/cgroup').read():continue
for fd in os.listdir('/proc/'+pid+'/fd'):
target=os.readlink('/proc/'+pid+'/fd/'+fd)
if target.startswith('socket:[') and target[8:-1] in inodes:print('{}');sys.exit(0)
except (FileNotFoundError,PermissionError,ProcessLookupError):pass
fail('conflict')
if act=='seed':
if os.path.exists(root):print(json.dumps({'seeded':False}));sys.exit(0)
os.makedirs(os.path.dirname(root),exist_ok=True)
temp=tempfile.mkdtemp(prefix='.seed-',dir=os.path.dirname(root))
try:
for name,value in p['files'].items():
dest=safe(name);relative=os.path.relpath(dest,root)
if relative=='.':fail('invalid')
target=os.path.join(temp,relative);os.makedirs(os.path.dirname(target),exist_ok=True)
with open(target,'wb') as f:f.write(base64.b64decode(value,validate=True))
try:os.rename(temp,root);result=True
except FileExistsError:result=False
print(json.dumps({'seeded':result}))
finally:
if os.path.exists(temp):shutil.rmtree(temp)
sys.exit(0)
if act=='workspace':
os.makedirs(root,exist_ok=True)
repo=p.get('repositoryUrl');branch=p.get('branch');task=p.get('taskId');base_ref=p.get('baseRef')
if base_ref and (base_ref.startswith('-') or '\n' in base_ref):fail('invalid')
checkout=os.path.join(root,'checkout')
if repo:
if not (repo.startswith('https://') or repo.startswith('ssh://') or repo.startswith('git@')) or '\n' in repo:fail('invalid')
if not os.path.exists(checkout):run(['git','clone','--',repo,checkout])
elif run(['git','remote','get-url','origin'],checkout)!=repo:fail('conflict')
else:
os.makedirs(checkout,exist_ok=True)
if not os.path.isdir(os.path.join(checkout,'.git')):run(['git','init',checkout])
if branch:
if branch.startswith('-') or '\n' in branch:fail('invalid')
run(['git','check-ref-format','--branch',branch])
if p.get('mode')=='worktree':
if not task:fail('invalid')
target=os.path.join(root,'tasks',task);os.makedirs(os.path.dirname(target),exist_ok=True)
task_branch=branch or 'paperclip/task-'+task
if not os.path.exists(target):run(['git','worktree','add','-b',task_branch,target,base_ref or 'HEAD'],checkout)
run(['git','rev-parse','--show-toplevel'],target)
if run(['git','symbolic-ref','--quiet','--short','HEAD'],target)!=task_branch:fail('conflict')
else:
target=checkout
if branch:run(['git','checkout',branch],checkout)
print(json.dumps({'remoteCwd':target}));sys.exit(0)
# Hold directory descriptors throughout each operation. Ancestor symlink swaps cannot
# redirect a checked pathname outside the selected placement.
def directory(parts,create=False,start=None):
fd=os.dup(start) if start is not None else os.open('/',os.O_RDONLY|os.O_DIRECTORY)
try:
for part in parts:
if not part or part=='.':continue
if part=='..':fail('invalid')
try:nxt=os.open(part,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=fd)
except FileNotFoundError:
if not create:raise
try:os.mkdir(part,0o700,dir_fd=fd)
except FileExistsError:pass
nxt=os.open(part,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=fd)
os.close(fd);fd=nxt
return fd
except BaseException:os.close(fd);raise
try:rootfd=directory(root.split('/'))
except FileNotFoundError:fail('not_found')
except OSError:fail('invalid')
def parent(relative,create=False):
safe(relative)
parts=[part for part in relative.split('/') if part not in ('','.')]
if not parts:fail('invalid')
return directory(parts[:-1],create,rootfd),parts[-1]
path=p.get('path','');safe(path)
lock=os.open('.paperclip-editor.lock',os.O_CREAT|os.O_RDWR|os.O_NOFOLLOW,0o600,dir_fd=rootfd)
fcntl.flock(lock,fcntl.LOCK_EX)
try:
if act=='list':
fd=directory(path.split('/'),start=rootfd)
try:
limit=p.get('limit',1000)
if type(limit)!=int or limit<1 or limit>1000:fail('invalid')
out=[];truncated=False;scanned=0
with os.scandir(fd) as entries:
for entry in entries:
if entry.name=='.paperclip-editor.lock':continue
if scanned>=limit:truncated=True;break
scanned+=1
if entry.is_symlink():continue
try:info=entry.stat(follow_symlinks=False)
except FileNotFoundError:continue
if not stat.S_ISDIR(info.st_mode) and not stat.S_ISREG(info.st_mode):continue
out.append({'name':entry.name,'kind':'directory' if stat.S_ISDIR(info.st_mode) else 'file','size':info.st_size,'mtimeMs':info.st_mtime*1000})
print(json.dumps({'entries':out,'truncated':truncated}))
finally:os.close(fd)
elif act=='stat':
parentfd,name=parent(path)
try:info=os.stat(name,dir_fd=parentfd,follow_symlinks=False)
finally:os.close(parentfd)
if not stat.S_ISDIR(info.st_mode) and not stat.S_ISREG(info.st_mode):fail('invalid')
print(json.dumps({'name':name,'kind':'directory' if stat.S_ISDIR(info.st_mode) else 'file','size':info.st_size,'mtimeMs':info.st_mtime*1000}))
elif act=='read':
parentfd,name=parent(path)
try:fd=os.open(name,os.O_RDONLY|os.O_NOFOLLOW,dir_fd=parentfd)
finally:os.close(parentfd)
with os.fdopen(fd,'rb') as f:
st=os.fstat(f.fileno())
if not stat.S_ISREG(st.st_mode) or st.st_size>p.get('maxBytes',16*1024*1024):fail('invalid')
data=f.read(p.get('maxBytes',16*1024*1024)+1)
if len(data)>p.get('maxBytes',16*1024*1024):fail('invalid')
print(json.dumps({'base64':base64.b64encode(data).decode(),'sha256':digest(data)}))
elif act in ('write','remove','move'):
parentfd,name=parent(path,create=act=='write')
try:
old=None
try:fd=os.open(name,os.O_RDONLY|os.O_NOFOLLOW,dir_fd=parentfd)
except FileNotFoundError:fd=None
if fd is not None:
with os.fdopen(fd,'rb') as f:
if not stat.S_ISREG(os.fstat(f.fileno()).st_mode):fail('invalid')
h=hashlib.sha256()
while True:
block=f.read(65536)
if not block:break
h.update(block)
old=h.hexdigest()
if old!=p.get('expectedSha256'):fail('conflict')
if act=='write':
data=base64.b64decode(p['base64'],validate=True)
if len(data)>16*1024*1024:fail('invalid')
import secrets
temp='.paperclip-write-'+secrets.token_hex(16)
fd=os.open(temp,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600,dir_fd=parentfd)
try:
with os.fdopen(fd,'wb') as f:f.write(data);f.flush();os.fsync(f.fileno())
os.replace(temp,name,src_dir_fd=parentfd,dst_dir_fd=parentfd)
finally:
try:os.unlink(temp,dir_fd=parentfd)
except FileNotFoundError:pass
print(json.dumps({'sha256':digest(data)}))
elif act=='remove':
if old is None:fail('not_found')
os.unlink(name,dir_fd=parentfd);print('{}')
else:
if old is None:fail('not_found')
targetfd,target=parent(p['to'],create=True)
try:
try:os.link(name,target,src_dir_fd=parentfd,dst_dir_fd=targetfd,follow_symlinks=False)
except FileExistsError:fail('conflict')
os.unlink(name,dir_fd=parentfd)
finally:os.close(targetfd)
print(json.dumps({'sha256':old}))
finally:os.close(parentfd)
else:fail('invalid')
except FileNotFoundError:fail('not_found')
except OSError:fail('invalid')
finally:os.close(lock);os.close(rootfd)
`;
@@ -0,0 +1,95 @@
import { and, eq } from "drizzle-orm";
import { computers, companies, heartbeatRuns, type Db } from "@paperclipai/db";
import {
ComputerError,
type ComputerRecord,
type Ledger,
} from "../domain/ledger.js";
import type { ComputerRepository } from "../application/ports.js";
export function computerRepository(db: Db): ComputerRepository {
const decode = (row: typeof computers.$inferSelect): ComputerRecord => ({
...row,
ledger: row.ledger as unknown as Ledger,
});
const where = (scope: { companyId: string; environmentId: string }) =>
and(
eq(computers.companyId, scope.companyId),
eq(computers.environmentId, scope.environmentId),
);
return {
async create(record) {
await db.transaction(async (tx) => {
const [company] = await tx
.select({ id: companies.id })
.from(companies)
.where(eq(companies.id, record.companyId))
.for("update");
if (!company) throw new ComputerError("not_found", "Company not found");
await tx
.insert(computers)
.values({
...record,
ledger: record.ledger as unknown as Record<string, unknown>,
});
});
},
async get(scope) {
const [row] = await db.select().from(computers).where(where(scope));
if (!row)
throw new ComputerError(
"not_found",
"Computer is not attached to this company",
);
return decode(row);
},
async update(scope, fn) {
return db.transaction(async (tx) => {
const [row] = await tx
.select()
.from(computers)
.where(where(scope))
.for("update");
if (!row)
throw new ComputerError(
"not_found",
"Computer is not attached to this company",
);
const record = decode(row);
const result = fn(record);
await tx
.update(computers)
.set({
ledger: record.ledger as unknown as Record<string, unknown>,
updatedAt: new Date(),
})
.where(eq(computers.id, row.id));
return result;
});
},
async runState(scope, runId, agentId) {
const [run] = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, scope.companyId),
eq(heartbeatRuns.id, runId),
eq(heartbeatRuns.agentId, agentId),
),
);
if (!run) return "missing";
return [
"succeeded",
"interrupted",
"failed",
"cancelled",
"timed_out",
].includes(run.status)
? "terminal"
: "active";
},
async all() {
return (await db.select().from(computers)).map(decode);
},
};
}
@@ -0,0 +1,67 @@
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
import type {
ComputerRecord,
Ledger,
Owner,
ProcessClaim,
} from "../domain/ledger.js";
export interface ComputerRepository {
create(record: ComputerRecord): Promise<void>;
get(scope: {
companyId: string;
environmentId: string;
}): Promise<ComputerRecord>;
update<T>(
scope: { companyId: string; environmentId: string },
fn: (record: ComputerRecord) => T,
): Promise<T>;
all(): Promise<ComputerRecord[]>;
runState(
scope: { companyId: string },
runId: string,
agentId: string,
): Promise<"active" | "terminal" | "missing">;
}
export interface ComputerBackend {
inspect(record: ComputerRecord): Promise<{
state: string;
snapshots: boolean;
stop: null | { id: string; status: string };
}>;
ready(record: ComputerRecord): Promise<void>;
claim(record: ComputerRecord): Promise<void>;
advance(record: ComputerRecord, owner: Owner): Promise<void>;
runner(record: ComputerRecord): Promise<CommandManagedRuntimeRunner>;
launch(
record: ComputerRecord,
owner: Owner,
input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
},
): Promise<ProcessClaim>;
inspectProcess(
record: ComputerRecord,
owner: Owner,
): Promise<{ running: boolean; claim: ProcessClaim | null }>;
retire(record: ComputerRecord, owner: Owner): Promise<void>;
stop(record: ComputerRecord): Promise<{ id: string; status: string }>;
stopStatus(
record: ComputerRecord,
id: string,
): Promise<{ id: string; status: string }>;
renew(record: ComputerRecord): Promise<void>;
desktop(
record: ComputerRecord,
): Promise<{ viewerUrl: string; expiresAt: string }>;
ingress(
record: ComputerRecord,
port: number,
path: string,
): Promise<{ url: string; secretHeaders: Record<string, string> }>;
preview(record: ComputerRecord, port: number): Promise<{ url: string }>;
remote(record: ComputerRecord, input: Record<string, unknown>): Promise<any>;
}
export type { Ledger };
@@ -0,0 +1,565 @@
import { describe, it, expect, vi } from "vitest";
import {
existsSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
mkdirSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { remoteProgram } from "../adapters/remote-program.js";
import { createComputerService } from "./service.js";
import type { ComputerBackend, ComputerRepository } from "./ports.js";
import { ComputerError, type ComputerRecord } from "../domain/ledger.js";
function fixture() {
let record: ComputerRecord | undefined;
let clock = new Date("2026-10-10T12:00:00Z");
let state = "ready";
let stopStatus = "pending";
const repository: ComputerRepository = {
create: async (value) => {
record = structuredClone(value);
},
get: async (scope) => {
if (
!record ||
record.companyId !== scope.companyId ||
record.environmentId !== scope.environmentId
)
throw new ComputerError("not_found", "missing");
return structuredClone(record);
},
update: async (scope, fn) => {
const next = await repository.get(scope);
const result = fn(next);
record = next;
return result;
},
all: async () => (record ? [structuredClone(record)] : []),
runState: vi.fn(async () => "active" as const),
};
const backend: ComputerBackend = {
inspect: vi.fn(async () => ({ state, snapshots: true, stop: null })),
ready: vi.fn(async () => {
state = "ready";
}),
claim: vi.fn(async () => {}),
advance: vi.fn(async () => {}),
runner: vi.fn(async () => ({ execute: vi.fn() })),
launch: vi.fn(async (_record, owner) => ({
...owner.process!,
bootId: "boot",
})),
inspectProcess: vi.fn(async (_record, owner) => ({
running: true,
claim: owner.process,
})),
retire: vi.fn(async () => {}),
stop: vi.fn(async () => ({ id: "stop_1", status: stopStatus })),
stopStatus: vi.fn(async () => ({ id: "stop_1", status: stopStatus })),
renew: vi.fn(async () => {}),
desktop: vi.fn(async () => ({
viewerUrl: "https://test.on.boat.dev/#credential",
expiresAt: new Date(clock.getTime() + 540_000).toISOString(),
})),
ingress: vi.fn(async () => ({
url: "wss://test.on.boat.dev/",
secretHeaders: { Cookie: "private" },
})),
preview: vi.fn(async () => ({
url: "https://test.on.boat.dev/?_token=private",
})),
remote: vi.fn(async () => ({
remoteCwd: "/home/user/paperclip/company/projects/project/checkout",
})),
};
const service = createComputerService(repository, backend, () => clock, {
admissionWaitMs: 0,
});
const scope = { companyId: "company", environmentId: "environment" };
const attach = () =>
service.attach({
...scope,
sandboxId: "bx_fixture",
apiKeySecretRef: { type: "secret_ref", secretId: "secret" },
});
const admit = (agentId = "agent", sessionKey = "session") =>
service.admit({
...scope,
agentId,
sessionKey,
runId: "run",
idleTimeoutMs: 60_000,
});
return {
service,
backend,
scope,
attach,
admit,
repository,
advance: (ms: number) => {
clock = new Date(clock.getTime() + ms);
},
completeStop: () => {
stopStatus = "completed";
state = "stopped";
},
};
}
describe("computer ownership", () => {
it("explains an unavailable dev server without masking provider errors", async () => {
const f = fixture();
await f.attach();
const binding = await f.admit();
const input = { ...f.scope, owner: binding.owner, port: 5173 };
vi.mocked(f.backend.remote).mockRejectedValueOnce(new ComputerError("conflict", "Computer operation: conflict"));
await expect(f.service.preview(input)).rejects.toMatchObject({
code: "conflict", message: "No dev server is running on this port for this task. Start it in the task and try again.",
});
const offline = new ComputerError("provider_error", "Provider unavailable");
vi.mocked(f.backend.remote).mockRejectedValueOnce(offline);
await expect(f.service.preview(input)).rejects.toBe(offline);
expect(f.backend.preview).not.toHaveBeenCalled();
});
it("reuses warm process identity and port while fencing stale callbacks", async () => {
const f = fixture();
await f.attach();
const first = await f.admit();
const claim = await first.launch({ command: "runnerd" });
await f.service.retainWarm({
...f.scope,
owner: first.owner,
idleTimeoutMs: 60_000,
});
const next = await f.admit();
expect(next.owner.generation).toBe(first.owner.generation + 1);
expect(next.listenerPort).toBe(first.listenerPort);
expect((await next.inspectProcess()).claim?.nonce).toBe(claim.nonce);
await f.service.retire({ ...f.scope, owner: first.owner });
expect(f.backend.retire).not.toHaveBeenCalled();
await f.service.retire({ ...f.scope, owner: next.owner });
expect(f.backend.retire).toHaveBeenCalledOnce();
});
it("does not suspend another agent and fences admission until provider stop completes", async () => {
const f = fixture();
await f.attach();
const a = await f.admit("agent-a", "a");
const b = await f.admit("agent-b", "b");
expect(a.listenerPort).not.toBe(b.listenerPort);
expect(a.agentHome).not.toBe(b.agentHome);
await f.service.retire({ ...f.scope, owner: a.owner });
await f.service.reconcile();
expect(f.backend.stop).not.toHaveBeenCalled();
await f.service.retire({ ...f.scope, owner: b.owner });
await f.service.reconcile();
expect(f.backend.stop).toHaveBeenCalledOnce();
await expect(f.admit()).rejects.toMatchObject({ code: "conflict" });
await f.service.reconcile();
expect(f.backend.stopStatus).toHaveBeenCalledOnce();
f.completeStop();
await f.service.reconcile();
await expect(f.admit()).resolves.toHaveProperty("owner");
});
it("retains a failed stop intent and retries the same idempotent operation", async () => {
const f = fixture();
await f.attach();
vi.mocked(f.backend.stop).mockRejectedValueOnce(
new Error("network response lost"),
);
await expect(f.service.reconcile()).rejects.toBeInstanceOf(AggregateError);
await expect(f.admit()).rejects.toMatchObject({ code: "conflict" });
await f.service.reconcile();
expect(f.backend.stop).toHaveBeenCalledTimes(2);
expect(
(await f.repository.get(f.scope)).ledger.action?.providerStopId,
).toBe("stop_1");
});
it("viewer renewals cannot extend the absolute warm deadline or use another user", async () => {
const f = fixture();
await f.attach();
const viewer = await f.service.connect({
...f.scope,
userId: "alice",
idleTimeoutMs: 10_000,
});
expect(viewer.expiresAt).toBe("2026-10-10T12:00:10.000Z");
await expect(
f.service.renewViewer({ ...f.scope, owner: viewer.owner, userId: "bob" }),
).rejects.toMatchObject({ code: "forbidden" });
f.advance(9_000);
expect(
(
await f.service.renewViewer({
...f.scope,
owner: viewer.owner,
userId: "alice",
})
).expiresAt,
).toBe(viewer.expiresAt);
f.advance(1_001);
await expect(
f.service.renewViewer({
...f.scope,
owner: viewer.owner,
userId: "alice",
}),
).rejects.toMatchObject({ code: "conflict" });
await f.service.reconcile();
expect(f.backend.stop).toHaveBeenCalledOnce();
});
it("a failed exact-process retirement keeps the machine held", async () => {
const f = fixture();
await f.attach();
const a = await f.admit();
vi.mocked(f.backend.retire).mockRejectedValue(new Error("not confirmed"));
await expect(
f.service.retire({ ...f.scope, owner: a.owner }),
).rejects.toThrow("not confirmed");
await expect(f.service.reconcile()).rejects.toBeInstanceOf(AggregateError);
expect(f.backend.stop).not.toHaveBeenCalled();
});
it("keeps snapshot and attachment proof when detached", async () => {
const f = fixture();
await f.attach();
await f.admit();
await f.service.detach(f.scope);
expect((await f.repository.get(f.scope)).ledger.status).toBe("detaching");
f.completeStop();
await f.service.reconcile();
expect((await f.repository.get(f.scope)).ledger.status).toBe("detached");
await expect(f.admit()).rejects.toMatchObject({ code: "conflict" });
});
it("rejects cross company scopes before provider access", async () => {
const f = fixture();
await f.attach();
vi.mocked(f.backend.inspect).mockClear();
await expect(
f.service.inspect({ ...f.scope, companyId: "another" }),
).rejects.toMatchObject({ code: "not_found" });
expect(f.backend.inspect).not.toHaveBeenCalled();
});
it("keeps process capabilities across attempt generations only for the exact same process", async () => {
const f = fixture();
await f.attach();
const first = await f.admit();
await first.launch({ command: "runnerd" });
await f.service.retainWarm({
...f.scope,
owner: first.owner,
idleTimeoutMs: 60_000,
});
await f.admit();
await expect(first.ingress()).rejects.toMatchObject({ code: "conflict" });
await expect(first.process.ingress()).resolves.toHaveProperty("url");
await f.repository.update(f.scope, (record) => {
record.ledger.owners[0]!.process!.nonce = "replacement-process";
});
await expect(first.process.ingress()).rejects.toMatchObject({
code: "conflict",
});
});
it.each(["terminal", "missing"] as const)(
"retires an orphan active owner with a %s run after admission grace",
async (state) => {
const f = fixture();
await f.attach();
await f.admit();
vi.mocked(f.repository.runState).mockResolvedValue(state);
f.advance(119_000);
await f.service.reconcile();
expect(f.backend.retire).not.toHaveBeenCalled();
f.advance(1_001);
await f.service.reconcile();
expect(f.backend.retire).toHaveBeenCalledOnce();
expect(f.backend.stop).toHaveBeenCalledOnce();
},
);
it("preserves recoverable running owners and gives completed warm owners their idle interval", async () => {
const f = fixture();
await f.attach();
const binding = await f.admit();
f.advance(180_000);
await f.service.reconcile();
expect(f.backend.retire).not.toHaveBeenCalled();
await f.service.retainWarm({
...f.scope,
owner: binding.owner,
idleTimeoutMs: 60_000,
});
vi.mocked(f.repository.runState).mockResolvedValue("terminal");
await f.service.reconcile();
expect(f.backend.retire).not.toHaveBeenCalled();
f.advance(60_001);
await f.service.reconcile();
expect(f.backend.retire).toHaveBeenCalledOnce();
});
it("coalesces concurrent reconciliation before provider stop dispatch", async () => {
const f = fixture();
await f.attach();
let complete!: (value: { id: string; status: string }) => void;
vi.mocked(f.backend.stop).mockImplementation(
() =>
new Promise((resolve) => {
complete = resolve;
}),
);
const first = f.service.reconcile();
await vi.waitFor(() => expect(f.backend.stop).toHaveBeenCalledOnce());
const second = f.service.reconcile();
await Promise.resolve();
expect(f.backend.stop).toHaveBeenCalledOnce();
complete({ id: "stop_1", status: "pending" });
await Promise.all([first, second]);
expect(f.backend.stop).toHaveBeenCalledOnce();
});
it.each(["retire", "warm"] as const)("waits for terminal predecessor %s before admitting its follow-up", async (transition) => {
const f = fixture();
await f.attach();
const first = await f.admit();
const claim = await first.launch({ command: "runnerd" });
vi.mocked(f.repository.runState).mockResolvedValue("terminal");
const wait = vi.fn(async () => {
const incumbent = (await f.repository.get(f.scope)).ledger.owners.find(owner => owner.id === first.owner.ownerId)!;
expect(incumbent.phase).toBe("active");
expect(incumbent.generation).toBe(first.owner.generation);
expect(f.backend.retire).not.toHaveBeenCalled();
if (transition === "retire") await f.service.retire({ ...f.scope, owner: first.owner });
else await f.service.retainWarm({ ...f.scope, owner: first.owner, idleTimeoutMs: 60_000 });
});
const waiting = createComputerService(f.repository, f.backend, () => new Date("2026-10-10T12:00:00Z"), { admissionWaitMs: 2000, wait });
const next = await waiting.admit({ ...f.scope, agentId: "agent", runId: "follow-up", sessionKey: "session", idleTimeoutMs: 60_000 });
expect(wait).toHaveBeenCalledOnce();
expect(f.repository.runState).toHaveBeenCalledWith(expect.objectContaining(f.scope), "run", "agent");
if (transition === "warm") {
expect(next.owner.ownerId).toBe(first.owner.ownerId);
expect(next.owner.generation).toBe(first.owner.generation + 1);
expect((await next.inspectProcess()).claim?.nonce).toBe(claim.nonce);
expect(f.backend.retire).not.toHaveBeenCalled();
} else {
expect(next.owner.ownerId).not.toBe(first.owner.ownerId);
expect(f.backend.retire).toHaveBeenCalledOnce();
}
});
it.each(["active", "missing"] as const)("does not wait on or supersede a %s predecessor", async (state) => {
const f = fixture();
await f.attach();
const first = await f.admit();
vi.mocked(f.repository.runState).mockResolvedValue(state);
const wait = vi.fn(async () => {});
const waiting = createComputerService(f.repository, f.backend, () => new Date("2026-10-10T12:00:00Z"), { admissionWaitMs: 2000, wait });
await expect(waiting.admit({ ...f.scope, agentId: "agent", runId: "follow-up", sessionKey: "session", idleTimeoutMs: 60_000 }))
.rejects.toMatchObject({ code: "conflict" });
expect(wait).not.toHaveBeenCalled();
expect(f.backend.retire).not.toHaveBeenCalled();
expect((await f.repository.get(f.scope)).ledger.owners[0]?.generation).toBe(first.owner.generation);
});
it("bounds the wait for terminal predecessor teardown without forcing retirement", async () => {
const f = fixture();
await f.attach();
await f.admit();
vi.mocked(f.repository.runState).mockResolvedValue("terminal");
const wait = vi.fn(async () => {});
const waiting = createComputerService(f.repository, f.backend, () => new Date("2026-10-10T12:00:00Z"), { admissionWaitMs: 2000, wait });
await expect(waiting.admit({ ...f.scope, agentId: "agent", runId: "follow-up", sessionKey: "session", idleTimeoutMs: 60_000 }))
.rejects.toMatchObject({ code: "conflict" });
expect(wait).toHaveBeenCalledTimes(2);
expect(f.backend.retire).not.toHaveBeenCalled();
});
it("waits for a normal provider snapshot stop before admitting the next turn", async () => {
const f = fixture();
await f.attach();
await f.service.reconcile();
const waiting = createComputerService(
f.repository,
f.backend,
() => new Date("2026-10-10T12:00:00Z"),
{
admissionWaitMs: 2000,
wait: async () => {
f.completeStop();
},
},
);
await expect(
waiting.admit({
...f.scope,
agentId: "agent",
runId: "run",
sessionKey: "session",
idleTimeoutMs: 60_000,
}),
).resolves.toHaveProperty("owner");
expect(f.backend.stop).toHaveBeenCalledOnce();
});
it("keeps a short bounded file batch hold between editor operations", async () => {
const f = fixture();
await f.attach();
const files = await f.service.files({ ...f.scope, agentId: "agent" });
await files.seed({});
await f.service.reconcile();
expect(f.backend.stop).not.toHaveBeenCalled();
f.advance(5001);
await f.service.reconcile();
expect(f.backend.stop).toHaveBeenCalledOnce();
});
it("returns foreground output without waiting for detached command descendants", async () => {
const f = fixture();
const temp = realpathSync(mkdtempSync(join(tmpdir(), "computer-command-")));
let backgroundPid: number | undefined;
const runPath = join(temp, "systemd-run");
// The shim starts the real supervisor. Live Boat coverage additionally
// verifies systemd keeps the descendant in the owner slice and retires it.
writeFileSync(
runPath,
`#!/usr/bin/env python3
import sys,subprocess,json
args=sys.argv[1:]
assert '--property=ExitType=cgroup' in args
assert '--property=KillMode=control-group' in args
assert '--wait' not in args
output=next(v.split('append:',1)[1] for v in args if v.startswith('--property=StandardOutput='))
error=next(v.split('append:',1)[1] for v in args if v.startswith('--property=StandardError='))
cwd=next(v.split('=',1)[1] for v in args if v.startswith('--working-directory='))
with open(output,'wb') as out,open(error,'wb') as err:
subprocess.Popen(args[args.index('--')+1:],stdout=out,stderr=err,cwd=cwd,start_new_session=True)
`,
{ mode: 0o700 },
);
writeFileSync(join(temp, "systemctl"), "#!/bin/sh\necho active\n", {
mode: 0o700,
});
vi.mocked(f.backend.runner).mockResolvedValue({
execute: async (input) => {
const payload = JSON.parse(input.stdin!);
const ownerRoot = join(temp, payload.owner);
mkdirSync(ownerRoot, { recursive: true });
writeFileSync(
join(ownerRoot, "generation"),
String(payload.generation),
);
const result = spawnSync(
input.command,
input.args!.map((arg) =>
arg.replaceAll("/home/user/.paperclip-owners/", `${temp}/`),
),
{
input: input.stdin,
encoding: "utf8",
timeout: 5000,
env: { ...process.env, PATH: `${temp}:${process.env.PATH}` },
},
);
if (result.error) throw result.error;
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.status,
timedOut: false,
} as any;
},
});
try {
await f.attach();
const binding = await f.admit();
const result = await binding.runner.execute({
command: "sh",
args: [
"-c",
"sleep 30 </dev/null >/dev/null 2>&1 & echo $!; read word; echo $word >&2; exit 7",
],
cwd: temp,
stdin: "foreground-input\n",
});
backgroundPid = Number(result.stdout.trim());
expect(result.exitCode).toBe(7);
expect(result.stderr).toBe("foreground-input\n");
expect(backgroundPid).toBeGreaterThan(1);
expect(() => process.kill(backgroundPid!, 0)).not.toThrow();
expect(existsSync(join(temp, binding.owner.ownerId))).toBe(true);
} finally {
if (backgroundPid) process.kill(backgroundPid, "SIGKILL");
rmSync(temp, { recursive: true, force: true });
}
});
it("realizes probe cwd before readiness without consuming the initial personal-home seed", async () => {
const f = fixture();
const temp = realpathSync(mkdtempSync(join(tmpdir(), "computer-probe-")));
const local = (value: string) =>
value.replace("/home/user/paperclip/", `${temp}/`);
vi.mocked(f.backend.remote).mockImplementation(async (_record, input) => {
const result = spawnSync(
"python3",
["-c", remoteProgram.replaceAll("/home/user/paperclip/", `${temp}/`)],
{
input: JSON.stringify({
...input,
root: local(input.root as string),
}),
encoding: "utf8",
},
);
if (result.status !== 0) throw new Error(result.stderr);
return JSON.parse(result.stdout);
});
try {
await f.attach();
const probe = await f.service.admitProbe({
...f.scope,
agentId: "agent",
probeId: "probe",
idleTimeoutMs: 60_000,
});
expect(probe.remoteCwd).not.toBe(probe.agentHome);
const ready = spawnSync("sh", ["-c", "pwd"], {
cwd: local(probe.remoteCwd),
encoding: "utf8",
});
expect(ready.status).toBe(0);
expect(ready.stdout.trim()).toBe(local(probe.remoteCwd));
expect(existsSync(local(probe.agentHome))).toBe(false);
const run = await f.admit();
expect(run.remoteCwd).toBe(probe.agentHome);
expect(existsSync(local(run.agentHome))).toBe(false);
const files = await f.service.files({ ...f.scope, agentId: "agent" });
await expect(
files.seed({ "AGENTS.md": "initial managed instructions" }),
).resolves.toEqual({ seeded: true });
expect(
readFileSync(join(local(run.agentHome), "AGENTS.md"), "utf8"),
).toBe("initial managed instructions");
} finally {
rmSync(temp, { recursive: true, force: true });
}
});
it("gives harness probes a finite owner without fabricating a heartbeat run", async () => {
const f = fixture();
await f.attach();
const probe = await f.service.admitProbe({
...f.scope,
agentId: "agent",
probeId: "probe",
idleTimeoutMs: 60_000,
});
await expect(
f.service.retainWarm({
...f.scope,
owner: probe.owner,
idleTimeoutMs: 60_000,
}),
).rejects.toMatchObject({ code: "conflict" });
f.advance(60_001);
await f.service.reconcile();
expect(f.repository.runState).not.toHaveBeenCalled();
expect(f.backend.retire).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,964 @@
import { randomUUID } from "node:crypto";
import type { ComputerRepository, ComputerBackend } from "./ports.js";
import {
ComputerError,
assertAdmission,
exactOwner,
expired,
liveOwners,
nextPort,
segment,
timeout,
type ComputerRecord,
type OwnerRef,
type Owner,
} from "../domain/ledger.js";
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
// A terminal run can enqueue its successor before remote teardown has finished.
// Keep the incumbent owner fenced until its own cleanup or warm handoff settles.
class ComputerSessionBusyError extends ComputerError {
constructor(readonly runId: string | undefined, readonly agentId: string | undefined) {
super("conflict", "Computer session already has an active turn");
}
}
type Scope = { companyId: string; environmentId: string };
export function createComputerService(
repository: ComputerRepository,
backend: ComputerBackend,
now: () => Date = () => new Date(),
options: {
admissionWaitMs?: number;
wait?: (ms: number) => Promise<void>;
} = {},
) {
const ref = (record: ComputerRecord, owner: Owner): OwnerRef => ({
computerId: record.id,
ownerId: owner.id,
generation: owner.generation,
});
const base = (record: ComputerRecord) =>
`/home/user/paperclip/${segment(record.companyId)}`;
async function admitRecord<T>(
scope: Scope,
change: (record: ComputerRecord) => T,
): Promise<T> {
const attempts = Math.ceil((options.admissionWaitMs ?? 120_000) / 1000);
const wait =
options.wait ??
((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
for (let attempt = 0; ; attempt++) {
try {
return await repository.update(scope, (record) => {
assertAdmission(record.ledger);
return change(record);
});
} catch (error) {
if (
!(error instanceof ComputerError) ||
error.code !== "conflict" ||
attempt >= attempts
)
throw error;
const current = await repository.get(scope);
if (current.ledger.status !== "attached") throw error;
if (error instanceof ComputerSessionBusyError && error.runId && error.agentId) {
// A terminal database status does not prove the process has stopped.
// Wait for the existing owner transition; never retire it here or
// advance its generation while final commands might still run.
if (await repository.runState(scope, error.runId, error.agentId) !== "terminal") throw error;
await wait(1000);
continue;
}
if (!current.ledger.action) throw error;
await reconcileRecord(current);
if ((await repository.get(scope)).ledger.action) await wait(1000);
}
}
}
async function scoped(input: Scope & { owner: OwnerRef }) {
const record = await repository.get(input);
return { record, owner: exactOwner(record, input.owner) };
}
async function inspect(input: Scope) {
const record = await repository.get(input);
const status = await backend.inspect(record);
return {
computerId: record.id,
sandboxId: record.providerId,
status: record.ledger.status,
state: status.state,
};
}
async function attach(
input: Scope & {
sandboxId: string;
apiKeySecretRef: {
type: "secret_ref";
secretId: string;
version?: number | "latest";
};
},
) {
segment(input.companyId);
segment(input.environmentId);
segment(input.sandboxId);
segment(input.apiKeySecretRef.secretId);
let record: ComputerRecord;
try {
record = await repository.get(input);
if (record.providerId !== input.sandboxId)
throw new ComputerError(
"conflict",
"Environment already attaches another computer",
);
} catch (error) {
if (!(error instanceof ComputerError) || error.code !== "not_found")
throw error;
record = {
id: randomUUID(),
companyId: input.companyId,
environmentId: input.environmentId,
providerId: input.sandboxId,
ledger: {
controllerId: randomUUID(),
status: "attaching",
secretRef: input.apiKeySecretRef,
owners: [],
placements: {},
action: null,
},
};
await repository.create(record);
}
if (
JSON.stringify(record.ledger.secretRef) !==
JSON.stringify(input.apiKeySecretRef)
) {
const candidate = structuredClone(record);
candidate.ledger.secretRef = input.apiKeySecretRef;
const status = await backend.inspect(candidate);
if (!status.snapshots)
throw new ComputerError(
"invalid",
"Boat snapshots must remain enabled",
);
await repository.update(input, (current) => {
current.ledger.secretRef = input.apiKeySecretRef;
});
record = candidate;
}
if (record.ledger.status === "detaching" || record.ledger.action)
throw new ComputerError("conflict", "Computer cleanup is pending");
if (record.ledger.status !== "attached") {
await backend.ready(record);
await backend.claim(record);
await repository.update(input, (current) => {
if (
current.ledger.status === "attaching" ||
current.ledger.status === "detached"
)
current.ledger.status = "attached";
});
}
return { computerId: record.id };
}
async function binding(record: ComputerRecord, owner: Owner) {
const ownerRef = ref(record, owner);
const placement = record.ledger.placements[owner.sessionKey!];
if (!placement)
throw new ComputerError("conflict", "Computer placement is missing");
const raw = await backend.runner(record);
let pinnedProcess = owner.process ? structuredClone(owner.process) : null;
async function processScope() {
if (!pinnedProcess) return scoped({ ...record, owner: ownerRef });
const latest = await repository.get(record);
const candidate = latest.ledger.owners.find(
(value) => value.id === owner.id,
);
if (
!candidate?.process ||
!["active", "starting", "warm"].includes(candidate.phase) ||
candidate.process.nonce !== pinnedProcess.nonce ||
candidate.process.unitName !== pinnedProcess.unitName ||
candidate.process.bootId !== pinnedProcess.bootId ||
candidate.process.launchGeneration !== pinnedProcess.launchGeneration
) {
throw new ComputerError(
"conflict",
"Computer process capability has expired",
);
}
return { record: latest, owner: candidate };
}
const runnerFor = (
processScoped: boolean,
): CommandManagedRuntimeRunner => ({
execute: async (input) => {
const current = await (processScoped
? processScope()
: scoped({ ...record, owner: ownerRef }));
if (!["active", "starting", "warm"].includes(current.owner.phase))
throw new ComputerError("conflict", "Computer owner is retired");
// Start a command unit under the same owner slice while holding the remote tombstone lock.
// The lock is released only after systemd knows the unit, so retirement cannot miss a delayed launch.
return raw.execute({
command: "python3",
args: [
"-c",
String.raw`
import os,sys,json,fcntl,subprocess,time,tempfile,shutil
p=json.load(sys.stdin);root='/home/user/.paperclip-owners/'+p['owner'];os.makedirs(root,exist_ok=True)
# A receipt reports foreground completion independently from cgroup lifetime.
# ExitType=cgroup keeps nohup/setsid descendants owned until owner retirement.
supervisor="""
import os,sys,json,subprocess
payload=sys.argv[1]
with open(payload) as f:p=json.load(f)
os.unlink(payload)
i=p['input']
try:
with open(p['stdin'],'rb') as source:
os.unlink(p['stdin'])
child=subprocess.Popen([i['command']]+i.get('args',[]),stdin=source)
code=child.wait()
except Exception as error:
print(str(error),file=sys.stderr);code=127
with open(p['receipt']+'.tmp','w') as f:json.dump({'exitCode':code},f)
os.replace(p['receipt']+'.tmp',p['receipt'])
"""
work=None
try:
with open(root+'/lock','a') as lock:
fcntl.flock(lock,fcntl.LOCK_EX)
if os.path.exists(root+'/retired'):raise RuntimeError('computer owner retired')
if p.get('processClaim'):
claim=json.load(open(root+'/claim.json'))
if any(claim[k]!=p['processClaim'][k] for k in ['nonce','unitName','bootId','launchGeneration']) or claim['bootId']!=open('/proc/sys/kernel/random/boot_id').read().strip():raise RuntimeError('computer process superseded')
elif not os.path.exists(root+'/generation') or int(open(root+'/generation').read())!=p['generation']:raise RuntimeError('computer owner superseded')
work=tempfile.mkdtemp(prefix='command-',dir=root)
output=work+'/stdout';error=work+'/stderr';receipt=work+'/receipt';payload=work+'/input.json';source=work+'/stdin'
for path in [output,error,source]:open(path,'wb').close()
i=p['input']
with open(source,'w') as f:f.write(i.get('stdin',''))
with open(payload,'w') as f:json.dump({'input':i,'stdin':source,'receipt':receipt},f)
args=['systemd-run','--user','--quiet','--collect','--unit='+p['unit'],'--slice=paperclip-'+p['owner']+'.slice','--property=Type=exec','--property=ExitType=cgroup','--property=KillMode=control-group','--property=StandardOutput=append:'+output,'--property=StandardError=append:'+error,'--working-directory='+i.get('cwd',p['cwd'])]
for k,v in i.get('env',{}).items():args.append('--setenv='+k+'='+v)
started=subprocess.run(args+['--','python3','-c',supervisor,payload],capture_output=True,text=True)
if started.returncode:sys.stderr.write(started.stderr);sys.exit(started.returncode)
with open(output,'rb') as out,open(error,'rb') as err:
while True:
done=os.path.exists(receipt)
for stream,dest in [(out,sys.stdout.buffer),(err,sys.stderr.buffer)]:
chunk=stream.read()
if chunk:dest.write(chunk);dest.flush()
if done:break
state=subprocess.run(['systemctl','--user','show',p['unit'],'--property=ActiveState','--value'],capture_output=True,text=True).stdout.strip()
if state not in ('active','activating','reloading'):
if os.path.exists(receipt):continue
raise RuntimeError('computer command stopped before completion')
time.sleep(.05)
with open(receipt) as f:code=json.load(f)['exitCode']
sys.exit(code if code>=0 else 128-code)
finally:
if work:shutil.rmtree(work,ignore_errors=True)
`,
],
stdin: JSON.stringify({
owner: owner.id,
generation: current.owner.generation,
processClaim: processScoped ? pinnedProcess : null,
unit: `paperclip-command-${randomUUID()}.service`,
cwd: placement.cwd,
input: {
command: input.command,
args: input.args,
cwd: input.cwd,
env: input.env,
stdin: input.stdin,
},
}),
timeoutMs: input.timeoutMs,
onLog: input.onLog,
onSpawn: input.onSpawn,
});
},
});
return {
owner: ownerRef,
runner: runnerFor(false),
process: {
runner: runnerFor(true),
async ingress(input?: { port?: number; path?: string }) {
const current = await processScope();
return backend.ingress(
current.record,
input?.port ?? current.owner.port,
input?.path ?? "/",
);
},
},
listenerPort: owner.port,
remoteCwd: placement.cwd,
agentHome: placement.root,
placementId: placement.id,
async launch(input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
}) {
const before = await scoped({ ...record, owner: ownerRef });
const existing = before.owner.process
? await backend.inspectProcess(before.record, before.owner)
: null;
const claimed = await repository.update(record, (current) => {
const o = exactOwner(current, ownerRef);
if (o.phase !== "active" && o.phase !== "starting")
throw new ComputerError("conflict", "Computer owner cannot launch");
if (existing?.running && existing.claim) o.process = existing.claim;
else if (!o.process || !existing?.running)
o.process = {
bootId: "",
unitName: `paperclip-${o.id}.service`,
nonce: randomUUID(),
launchGeneration: (o.process?.launchGeneration ?? 0) + 1,
};
return structuredClone(current);
});
const claim = await backend.launch(
claimed,
exactOwner(claimed, ownerRef),
input,
);
await repository.update(record, (current) => {
exactOwner(current, ownerRef).process = claim;
});
pinnedProcess = structuredClone(claim);
return claim;
},
async inspectProcess() {
const current = await scoped({ ...record, owner: ownerRef });
return backend.inspectProcess(current.record, current.owner);
},
async ingress(input?: { port?: number; path?: string }) {
const current = await scoped({ ...record, owner: ownerRef });
if (current.owner.phase === "retired")
throw new ComputerError("conflict", "Computer owner retired");
return backend.ingress(
current.record,
input?.port ?? owner.port,
input?.path ?? "/",
);
},
};
}
async function admit(
input: Scope & {
agentId: string;
runId?: string;
probeId?: string;
sessionKey: string;
idleTimeoutMs: number;
},
) {
segment(input.agentId);
if ((!input.runId && !input.probeId) || (input.runId && input.probeId))
throw new ComputerError(
"invalid",
"Computer admission requires one run or probe identity",
);
if (input.probeId) segment(input.probeId);
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(input.sessionKey))
throw new ComputerError("invalid", "Invalid computer session key");
timeout(input.idleTimeoutMs);
const result = await admitRecord(input, (record) => {
let owner = record.ledger.owners.find(
(o) =>
o.kind === "runner" &&
o.agentId === input.agentId &&
o.sessionKey === input.sessionKey &&
o.phase !== "retired",
);
if (owner && owner.phase !== "warm")
throw new ComputerSessionBusyError(owner.runId, owner.agentId);
if (owner && expired(owner, now()))
throw new ComputerError(
"conflict",
"Computer warm session is retiring",
);
if (owner) {
owner.generation++;
owner.phase = "starting";
owner.runId = input.runId;
owner.admittedAt = now().toISOString();
owner.deadline = new Date(now().getTime() + 120_000).toISOString();
} else {
owner = {
id: randomUUID(),
generation: 1,
kind: "runner",
phase: "starting",
agentId: input.agentId,
runId: input.runId,
probeId: input.probeId,
admittedAt: now().toISOString(),
sessionKey: input.sessionKey,
port: nextPort(record.ledger.owners),
deadline: new Date(now().getTime() + 120_000).toISOString(),
absoluteDeadline: null,
process: null,
};
record.ledger.owners.push(owner);
}
record.ledger.placements[input.agentId] ??= {
id: randomUUID(),
root: `${base(record)}/agents/${input.agentId}`,
cwd: `${base(record)}/agents/${input.agentId}`,
};
record.ledger.placements[input.sessionKey] ??= {
id: randomUUID(),
root: `${base(record)}/agents/${input.agentId}`,
cwd: input.probeId
? `${base(record)}/probes/${input.probeId}`
: `${base(record)}/agents/${input.agentId}`,
};
return { record: structuredClone(record), owner: structuredClone(owner) };
});
await backend.ready(result.record);
await backend.claim(result.record);
await backend.advance(result.record, result.owner);
await backend.renew(result.record);
if (input.probeId) {
// Readiness commands run before managed-home preparation. Give them an
// existing workspace without creating the personal home: an absent home
// must remain eligible for the first atomic managed-file seed.
await backend.remote(result.record, {
action: "seed",
root: result.record.ledger.placements[input.sessionKey]!.cwd,
files: {},
});
}
const active = await repository.update(input, (record) => {
const owner = exactOwner(record, ref(result.record, result.owner));
if (owner.phase !== "starting")
throw new ComputerError("conflict", "Computer admission was retired");
owner.phase = "active";
owner.deadline = owner.probeId
? new Date(
now().getTime() + Math.min(input.idleTimeoutMs, 120_000),
).toISOString()
: null;
return { record: structuredClone(record), owner: structuredClone(owner) };
});
return binding(active.record, active.owner);
}
async function recover(input: Scope & { owner: OwnerRef }) {
const { record, owner } = await scoped(input);
if (!["active", "warm"].includes(owner.phase) || expired(owner, now()))
throw new ComputerError(
"conflict",
"Computer owner is no longer recoverable",
);
await backend.ready(record);
await backend.claim(record);
return binding(record, owner);
}
async function retainWarm(
input: Scope & { owner: OwnerRef; idleTimeoutMs: number },
) {
timeout(input.idleTimeoutMs);
await repository.update(input, (record) => {
const owner = exactOwner(record, input.owner);
if (owner.kind !== "runner" || owner.probeId || owner.phase !== "active")
throw new ComputerError(
"conflict",
"Computer owner cannot become warm",
);
owner.phase = "warm";
owner.deadline = new Date(
now().getTime() + input.idleTimeoutMs,
).toISOString();
});
}
async function retire(input: Scope & { owner: OwnerRef }) {
const state = await repository.update(input, (record) => {
const owner = record.ledger.owners.find(
(value) => value.id === input.owner.ownerId,
);
if (record.id !== input.owner.computerId || !owner)
throw new ComputerError("not_found", "Computer owner not found");
if (owner.generation !== input.owner.generation) return null;
if (owner.phase !== "retired") owner.phase = "retiring";
return { record: structuredClone(record), owner: structuredClone(owner) };
});
if (!state) return { retired: false };
if (state.owner.phase === "retired") return { retired: true };
if (state.owner.kind === "runner") {
const status = await backend.inspect(state.record);
if (!["stopped", "archived"].includes(status.state))
await backend.retire(state.record, state.owner);
}
await repository.update(input, (record) => {
const owner = exactOwner(record, input.owner);
owner.phase = "retired";
owner.deadline = null;
});
return { retired: true };
}
async function connect(
input: Scope & { userId: string; idleTimeoutMs: number },
) {
timeout(input.idleTimeoutMs);
const deadline = new Date(
now().getTime() + input.idleTimeoutMs,
).toISOString();
const data = await admitRecord(input, (record) => {
const owner: Owner = {
id: randomUUID(),
generation: 1,
kind: "viewer",
phase: "active",
userId: input.userId,
port: 0,
deadline,
absoluteDeadline: deadline,
process: null,
};
record.ledger.owners.push(owner);
return { record: structuredClone(record), owner };
});
await backend.ready(data.record);
await backend.claim(data.record);
await backend.renew(data.record);
const viewer = await backend.desktop(data.record);
return {
...viewer,
expiresAt: new Date(
Math.min(Date.parse(viewer.expiresAt), Date.parse(deadline)),
).toISOString(),
owner: ref(data.record, data.owner),
};
}
async function renewViewer(
input: Scope & { owner: OwnerRef; userId: string },
) {
const { record, owner } = await scoped(input);
if (owner.kind !== "viewer" || owner.userId !== input.userId)
throw new ComputerError(
"forbidden",
"Computer viewer belongs to another user",
);
if (owner.phase !== "active" || expired(owner, now()))
throw new ComputerError(
"conflict",
"Computer connection expired; connect again",
);
const viewer = await backend.desktop(record);
return {
...viewer,
expiresAt: new Date(
Math.min(
Date.parse(viewer.expiresAt),
Date.parse(owner.absoluteDeadline!),
),
).toISOString(),
owner: input.owner,
};
}
async function disconnectViewer(
input: Scope & { owner: OwnerRef; userId: string },
) {
const { owner } = await scoped(input);
if (owner.kind !== "viewer" || owner.userId !== input.userId)
throw new ComputerError(
"forbidden",
"Computer viewer belongs to another user",
);
await retire(input);
}
async function preview(input: Scope & { owner: OwnerRef; port: number }) {
const { record, owner } = await scoped(input);
if (
owner.kind !== "runner" ||
!["active", "warm"].includes(owner.phase) ||
expired(owner, now())
)
throw new ComputerError("conflict", "Preview owner is not live");
if (input.port >= 43127 && input.port < 44127)
throw new ComputerError(
"forbidden",
"Runner ports are not application previews",
);
try {
await backend.remote(record, {
root: base(record),
action: "owned-port",
port: input.port,
ownerId: owner.id,
});
} catch (error) {
if (error instanceof ComputerError && error.code === "conflict") {
throw new ComputerError("conflict", "No dev server is running on this port for this task. Start it in the task and try again.");
}
throw error;
}
return backend.preview(record, input.port);
}
async function withFiles<T>(
input: Scope,
root: string,
fn: (record: ComputerRecord) => Promise<T>,
): Promise<T> {
const data = await admitRecord(input, (record) => {
const owner: Owner = {
id: randomUUID(),
generation: 1,
kind: "file-operation",
phase: "active",
port: 0,
deadline: new Date(now().getTime() + 120_000).toISOString(),
absoluteDeadline: null,
process: null,
};
record.ledger.owners.push(owner);
return { record: structuredClone(record), owner };
});
try {
await backend.ready(data.record);
await backend.claim(data.record);
return await fn(data.record);
} finally {
await repository.update(input, (record) => {
const owner = record.ledger.owners.find(
(value) => value.id === data.owner.id,
);
if (owner?.phase === "active") {
owner.phase = "warm";
owner.deadline = new Date(now().getTime() + 5000).toISOString();
}
});
}
}
function fileAccess(input: Scope, root: string) {
const remote = (payload: Record<string, unknown>) =>
withFiles(input, root, (record) =>
backend.remote(record, { ...payload, root }),
);
const readBytes = async (
path: string,
maxBytes = 16 * 1024 * 1024,
): Promise<{ bytes: Buffer; sha256: string }> => {
if (
!Number.isSafeInteger(maxBytes) ||
maxBytes < 0 ||
maxBytes > 16 * 1024 * 1024
)
throw new ComputerError("invalid", "Invalid remote file size limit");
const value = await remote({ action: "read", path, maxBytes });
return {
bytes: Buffer.from(value.base64, "base64"),
sha256: value.sha256,
};
};
const writeBytes = async (
path: string,
bytes: Buffer,
expectedSha256: string | null,
): Promise<{ sha256: string }> =>
remote({
action: "write",
path,
base64: bytes.toString("base64"),
expectedSha256,
});
return {
root,
readBytes,
writeBytes,
async read(path: string) {
const value = await readBytes(path);
return { content: value.bytes.toString("utf8"), sha256: value.sha256 };
},
async write(
path: string,
content: string,
expectedSha256: string | null,
) {
return writeBytes(path, Buffer.from(content), expectedSha256);
},
async listPage(path = "", options: { limit?: number } = {}): Promise<{
entries: Array<{ name: string; kind: "file" | "directory"; size: number; mtimeMs: number }>;
truncated: boolean;
}> {
return remote({ action: "list", path, limit: options.limit ?? 1000 });
},
async list(path = "") {
const page = await this.listPage(path);
if (page.truncated) throw new ComputerError("invalid", "Computer directory exceeds the listing limit");
return page.entries;
},
async stat(path: string): Promise<{ name: string; kind: "file" | "directory"; size: number; mtimeMs: number }> {
return remote({ action: "stat", path });
},
async remove(path: string, expectedSha256: string) {
await remote({ action: "remove", path, expectedSha256 });
},
async move(
from: string,
to: string,
expectedSha256: string,
): Promise<{ sha256: string }> {
return remote({ action: "move", path: from, to, expectedSha256 });
},
async seedBytes(
files: Record<string, Buffer>,
): Promise<{ seeded: boolean }> {
return remote({
action: "seed",
files: Object.fromEntries(
Object.entries(files).map(([key, value]) => [
key,
value.toString("base64"),
]),
),
});
},
async seed(files: Record<string, string>): Promise<{ seeded: boolean }> {
return remote({
action: "seed",
files: Object.fromEntries(
Object.entries(files).map(([key, value]) => [
key,
Buffer.from(value).toString("base64"),
]),
),
});
},
};
}
async function files(input: Scope & { agentId: string }) {
segment(input.agentId);
const placement = await admitRecord(input, (record) => {
return (record.ledger.placements[input.agentId] ??= {
id: randomUUID(),
root: `${base(record)}/agents/${input.agentId}`,
cwd: `${base(record)}/agents/${input.agentId}`,
});
});
return fileAccess(input, placement.root);
}
async function workspaceFiles(input: Scope & { placementId: string }) {
const record = await repository.get(input);
const placement = Object.values(record.ledger.placements).find(
(p) => p.id === input.placementId,
);
if (!placement)
throw new ComputerError(
"not_found",
"Computer workspace placement not found",
);
return fileAccess(input, placement.cwd);
}
async function realizeWorkspace(
input: Scope & {
owner: OwnerRef;
projectId?: string;
taskId?: string;
repositoryUrl?: string;
branch?: string;
baseRef?: string;
mode: "shared" | "worktree";
},
) {
const { record, owner } = await scoped(input);
if (!owner.agentId || owner.phase !== "active")
throw new ComputerError("conflict", "Workspace owner is not active");
const placement = record.ledger.placements[owner.sessionKey!]!;
if (!input.projectId)
return {
remoteCwd: placement.cwd,
agentHome: placement.root,
placementId: placement.id,
};
const root = `${base(record)}/projects/${segment(input.projectId)}`;
if (input.taskId) segment(input.taskId);
const result = await backend.remote(record, {
...input,
root,
action: "workspace",
});
await repository.update(input, (current) => {
exactOwner(current, input.owner);
const placementKey = `workspace:${owner.agentId}:${input.projectId}:${input.mode}:${input.taskId ?? "shared"}:${result.remoteCwd}`;
current.ledger.placements[placementKey] ??= {
id: randomUUID(),
root: placement.root,
cwd: result.remoteCwd,
};
current.ledger.placements[owner.sessionKey!] =
current.ledger.placements[placementKey]!;
});
const updated = await repository.get(input);
return {
remoteCwd: result.remoteCwd as string,
agentHome: placement.root,
placementId: updated.ledger.placements[owner.sessionKey!]!.id,
};
}
async function detach(input: Scope) {
const record = await repository.update(input, (current) => {
current.ledger.status = "detaching";
return structuredClone(current);
});
for (const owner of liveOwners(record.ledger))
await retire({ ...input, owner: ref(record, owner) });
await reconcileRecord(await repository.get(input));
}
const reconciliation = new Map<string, Promise<void>>();
async function reconcileRecord(record: ComputerRecord): Promise<void> {
const existing = reconciliation.get(record.id);
if (existing) return existing;
const pending = (async () =>
reconcileOwnedRecord(await repository.get(record)))();
reconciliation.set(record.id, pending);
try {
await pending;
} finally {
if (reconciliation.get(record.id) === pending)
reconciliation.delete(record.id);
}
}
async function reconcileOwnedRecord(record: ComputerRecord) {
if (record.ledger.status === "attaching") return;
// A crash can happen after admission but before an environment lease exists.
// Run identity, rather than lease presence, decides whether that active owner
// still has work to recover. Warm owners keep their own idle deadline.
for (const owner of liveOwners(record.ledger)) {
const admittedAt = owner.admittedAt ? Date.parse(owner.admittedAt) : 0;
if (
owner.kind === "runner" &&
owner.phase === "active" &&
owner.runId &&
owner.agentId &&
now().getTime() - admittedAt >= 120_000
) {
const runState = await repository.runState(
record,
owner.runId,
owner.agentId,
);
if (runState !== "active")
await retire({ ...record, owner: ref(record, owner) });
}
}
record = await repository.get(record);
for (const owner of liveOwners(record.ledger))
if (owner.phase === "retiring" || expired(owner, now()))
await retire({ ...record, owner: ref(record, owner) });
await repository.update(record, (current) => {
current.ledger.owners = current.ledger.owners.filter(
(owner) => owner.kind !== "file-operation" || owner.phase !== "retired",
);
});
record = await repository.get(record);
if (record.ledger.action) {
const action = record.ledger.action;
// A lost stop response is retried only while admission is fenced. Boat returns its same pending ID.
const stop = action.providerStopId
? await backend.stopStatus(record, action.providerStopId)
: await backend.stop(record);
await repository.update(record, (current) => {
if (current.ledger.action?.id !== action.id) return;
current.ledger.action.providerStopId = stop.id;
if (stop.status === "completed") {
current.ledger.action = null;
if (current.ledger.status === "detaching")
current.ledger.status = "detached";
}
});
return;
}
if (liveOwners(record.ledger).length) {
await backend.renew(record);
return;
}
if (record.ledger.status === "detached") return;
const status = await backend.inspect(record);
if (["stopped", "archived"].includes(status.state)) {
if (record.ledger.status === "detaching")
await repository.update(record, (current) => {
current.ledger.status = "detached";
});
return;
}
const actionId = randomUUID();
const claimed = await repository.update(record, (current) => {
if (current.ledger.action || liveOwners(current.ledger).length)
return false;
current.ledger.action = {
kind: "stop",
id: actionId,
providerStopId: null,
};
return true;
});
if (!claimed) return;
const stop = await backend.stop(record);
await repository.update(record, (current) => {
if (current.ledger.action?.id === actionId)
current.ledger.action.providerStopId = stop.id;
});
}
async function reconcile() {
const failures: unknown[] = [];
for (const record of await repository.all())
try {
await reconcileRecord(record);
} catch (error) {
failures.push(error);
}
if (failures.length)
throw new AggregateError(
failures,
"Computer reconciliation requires retry",
);
}
return {
async list(companyId: string) {
return (await repository.all())
.filter((record) => record.companyId === companyId)
.map((record) => ({
computerId: record.id,
environmentId: record.environmentId,
sandboxId: record.providerId,
status: record.ledger.status,
}));
},
attach,
admit,
admitProbe(
input: Scope & {
agentId: string;
probeId: string;
idleTimeoutMs: number;
},
) {
return admit({ ...input, sessionKey: `probe-${segment(input.probeId)}` });
},
recover,
retainWarm,
retire,
connect,
renewViewer,
disconnectViewer,
preview,
files,
workspaceFiles,
realizeWorkspace,
detach,
reconcile,
inspect,
};
}
@@ -0,0 +1 @@
export { computerCompanyDeletion } from "./adapters/company-deletion.js";
@@ -0,0 +1,112 @@
export type OwnerRef = {
computerId: string;
ownerId: string;
generation: number;
};
export type ProcessClaim = {
bootId: string;
unitName: string;
nonce: string;
launchGeneration: number;
};
export interface Owner {
id: string;
generation: number;
kind: "runner" | "viewer" | "file-operation";
phase: "starting" | "active" | "warm" | "retiring" | "retired";
agentId?: string;
runId?: string;
probeId?: string;
admittedAt?: string;
sessionKey?: string;
userId?: string;
port: number;
deadline: string | null;
absoluteDeadline: string | null;
process: ProcessClaim | null;
}
export interface Ledger {
controllerId: string;
status: "attaching" | "attached" | "detaching" | "detached";
secretRef: {
type: "secret_ref";
secretId: string;
version?: number | "latest";
};
owners: Owner[];
placements: Record<string, { id: string; root: string; cwd: string }>;
action: null | { kind: "stop"; id: string; providerStopId: string | null };
}
export interface ComputerRecord {
id: string;
companyId: string;
environmentId: string;
providerId: string;
ledger: Ledger;
}
export class ComputerError extends Error {
constructor(
public readonly code:
| "not_found"
| "conflict"
| "forbidden"
| "invalid"
| "provider_error",
message: string,
) {
super(message);
this.name = "ComputerError";
}
}
export function segment(value: string): string {
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value))
throw new ComputerError("invalid", "Invalid computer resource identifier");
return value;
}
export function timeout(value: number): number {
if (
!Number.isSafeInteger(value) ||
value < 1000 ||
value > 24 * 60 * 60 * 1000
)
throw new ComputerError(
"invalid",
"Computer timeout must be between one second and one day",
);
return value;
}
export function exactOwner(record: ComputerRecord, ref: OwnerRef): Owner {
const owner = record.ledger.owners.find((o) => o.id === ref.ownerId);
if (
record.id !== ref.computerId ||
!owner ||
owner.generation !== ref.generation
)
throw new ComputerError("conflict", "Computer owner has been superseded");
return owner;
}
export function nextPort(owners: Owner[]): number {
const occupied = new Set(
owners.filter((o) => o.phase !== "retired").map((o) => o.port),
);
for (let port = 43127; port < 44127; port++)
if (!occupied.has(port)) return port;
throw new ComputerError("conflict", "Computer has no available runner ports");
}
export function liveOwners(ledger: Ledger): Owner[] {
return ledger.owners.filter((o) => o.phase !== "retired");
}
export function assertAdmission(ledger: Ledger): void {
if (ledger.status !== "attached" || ledger.action)
throw new ComputerError(
"conflict",
"Computer is connecting or settling a previous stop",
);
}
export function expired(owner: Owner, now: Date): boolean {
return (
owner.phase !== "retired" &&
owner.deadline !== null &&
Date.parse(owner.deadline) <= now.getTime()
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { Db } from "@paperclipai/db";
import { secretService } from "../../services/secrets.js";
import { computerRepository } from "./adapters/repository.js";
import { boatBackend } from "./adapters/boat.js";
import { createComputerService } from "./application/service.js";
export { ComputerError } from "./domain/ledger.js";
export type {
OwnerRef as ComputerOwnerRef,
ProcessClaim as ComputerProcessClaim,
} from "./domain/ledger.js";
export type ComputerService = ReturnType<typeof createComputerService>;
export type ComputerBinding = Awaited<ReturnType<ComputerService["admit"]>>;
const services = new WeakMap<Db, ComputerService>();
export function computerService(db: Db): ComputerService {
const existing = services.get(db);
if (existing) return existing;
const secrets = secretService(db);
const service = createComputerService(
computerRepository(db),
boatBackend((record) =>
secrets.resolveSecretValue(
record.companyId,
record.ledger.secretRef.secretId,
record.ledger.secretRef.version ?? "latest",
{
consumerType: "environment",
consumerId: record.environmentId,
configPath: "apiKeySecretRef",
actorType: "system",
actorId: null,
},
),
),
);
services.set(db, service);
return service;
}
+2 -1
View File
@@ -3060,7 +3060,7 @@ export function agentRoutes(
if (test) {
const testEnvironmentId = await resolveAdapterTestEnvironmentId(companyId, environmentId);
if (testEnvironmentId) await assertAdapterTestEnvironmentForCompany(companyId, testEnvironmentId);
const target = await resolveAdapterTestExecutionContext({ companyId, adapterType, environmentId: testEnvironmentId });
const target = await resolveAdapterTestExecutionContext({ companyId, agentId, adapterType, environmentId: testEnvironmentId });
try {
if (!target.executionTarget && target.fallbackChecks.length > 0) throw unprocessable("The agent environment is not available for adoption");
await withManagedAiProbe(db, { companyId, agentId, responsibleUserId: userId, adapterType, binding, config, allowUninstalledPersonal: newAgent, allowUninstalledShared, allowLegacyValidation: true }, async managed => {
@@ -3193,6 +3193,7 @@ export function agentRoutes(
const { executionTarget, environmentName, fallbackChecks, sandboxIdentityCheck, release } =
await resolveAdapterTestExecutionContext({
companyId,
agentId: savedAgentId ?? undefined,
adapterType: type,
environmentId: requestedEnvironmentId,
});
+106
View File
@@ -0,0 +1,106 @@
import { Router } from "express";
import { and, desc, eq } from "drizzle-orm";
import { z } from "zod";
import { agents, environmentLeases, environments, issues, type Db } from "@paperclipai/db";
import { resolvePaperclipRunnerIdleTimeoutMs } from "@paperclipai/adapter-utils";
import { computerService, ComputerError } from "../modules/computers/index.js";
import { computerOwnerFromLease } from "../services/computer-environment-driver.js";
import { environmentService } from "../services/environments.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { logActivity } from "../services/activity-log.js";
import { conflict, notFound, unprocessable } from "../errors.js";
import { assertBoard, getActorInfo, getAccessibleResource } from "./authz.js";
const ownerSchema = z.object({ computerId: z.string().uuid(), ownerId: z.string().uuid(), generation: z.number().int().positive() }).strict();
const scopeSchema = z.object({ environmentId: z.string().uuid() });
const presenceSchema = scopeSchema.extend({ owner: ownerSchema }).strict();
export function computerRoutes(db: Db, computers = computerService(db)) {
const router = Router();
const settings = instanceSettingsService(db);
const envs = environmentService(db);
router.use("/issues/:issueId/computer", async (req, res, next) => {
assertBoard(req);
if (!z.string().uuid().safeParse(req.params.issueId).success) throw notFound("Task not found");
const [row] = await db.select().from(issues).where(eq(issues.id, req.params.issueId)).limit(1);
const issue = await getAccessibleResource(req, res, row, "Task not found");
if (!issue) return;
res.setHeader("Cache-Control", "no-store");
res.setHeader("Referrer-Policy", "no-referrer");
if (!(await settings.getExperimental()).enableBoatEnvironments && !req.path.endsWith("/disconnect")) {
if (req.method === "GET") { res.json(null); return; }
throw unprocessable("Boat environments are disabled.");
}
const [lease] = await db.select().from(environmentLeases)
.where(and(eq(environmentLeases.companyId, issue.companyId), eq(environmentLeases.issueId, issue.id), eq(environmentLeases.provider, "boat")))
.orderBy(desc(environmentLeases.updatedAt)).limit(1);
const [agent] = issue.assigneeAgentId ? await db.select().from(agents)
.where(and(eq(agents.companyId, issue.companyId), eq(agents.id, issue.assigneeAgentId))).limit(1) : [];
const environmentId = lease?.environmentId ?? agent?.defaultEnvironmentId ?? (await settings.get()).defaultEnvironmentId;
const [environment] = environmentId ? await db.select().from(environments).where(eq(environments.id, environmentId)).limit(1) : [];
if (!environment || environment.driver !== "computer" || environment.metadata?.computerCompanyId !== issue.companyId || environment.status !== "active") {
if (req.method === "GET") { res.json(null); return; }
throw notFound("Computer not available for this task");
}
res.locals.computer = { environment, issue, lease, agent };
next();
});
router.get("/issues/:issueId/computer", async (_req, res) => {
const { environment } = res.locals.computer;
res.json({ environmentId: environment.id, name: environment.name });
});
router.post("/issues/:issueId/computer/connect", async (req, res) => {
const input = scopeSchema.strict().parse(req.body);
const { environment, issue, agent } = res.locals.computer;
if (input.environmentId !== environment.id) throw notFound("Computer not available for this task");
const actor = getActorInfo(req);
const viewer = await computers.connect({ companyId: issue.companyId, environmentId: environment.id,
userId: actor.actorId, idleTimeoutMs: resolvePaperclipRunnerIdleTimeoutMs(environment.config.runnerIdleTimeoutMs ?? agent?.adapterConfig?.idleTimeoutMs) });
await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId,
action: "computer.connected", entityType: "environment", entityId: environment.id, details: { issueId: issue.id } });
res.json(viewer);
});
router.post("/issues/:issueId/computer/presence", async (req, res) => {
const input = presenceSchema.parse(req.body);
const { environment, issue } = res.locals.computer;
if (input.environmentId !== environment.id) throw notFound("Computer not available for this task");
res.json(await computers.renewViewer({ companyId: issue.companyId, environmentId: environment.id,
owner: input.owner, userId: getActorInfo(req).actorId }));
});
router.post("/issues/:issueId/computer/disconnect", async (req, res) => {
const input = presenceSchema.parse(req.body);
const { environment, issue } = res.locals.computer;
if (input.environmentId !== environment.id) throw notFound("Computer not available for this task");
const actor = getActorInfo(req);
await computers.disconnectViewer({ companyId: issue.companyId, environmentId: environment.id,
owner: input.owner, userId: actor.actorId });
await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId,
action: "computer.disconnected", entityType: "environment", entityId: environment.id, details: { issueId: issue.id } });
res.status(204).end();
});
router.post("/issues/:issueId/computer/preview", async (req, res) => {
const input = scopeSchema.extend({ port: z.number().int().min(1024).max(65535) }).strict().parse(req.body);
const { environment, issue, lease } = res.locals.computer;
if (input.environmentId !== environment.id) throw notFound("Computer not available for this task");
const current = lease ? await envs.getLeaseById(lease.id) : null;
if (!current) throw conflict("Start a dev server in this task before opening its preview.");
const result = await computers.preview({ companyId: issue.companyId, environmentId: environment.id,
owner: computerOwnerFromLease(current), port: input.port });
const actor = getActorInfo(req);
await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId,
action: "computer.preview_opened", entityType: "environment", entityId: environment.id, details: { issueId: issue.id, port: input.port } });
res.json(result);
});
router.use((error: unknown, _req: import("express").Request, res: import("express").Response, next: import("express").NextFunction) => {
if (!(error instanceof ComputerError)) return next(error);
res.status(error.code === "conflict" ? 409 : error.code === "invalid" ? 422 : error.code === "not_found" ? 404 : error.code === "forbidden" ? 403 : 502).json({ error: error.message, code: error.code });
});
return router;
}
+71 -4
View File
@@ -44,6 +44,7 @@ import {
collectEnvironmentSecretRefs,
normalizeEnvironmentConfigForPersistence,
normalizeEnvironmentConfigForProbe,
parseEnvironmentDriverConfig,
readSshEnvironmentPrivateKeySecretId,
type ParsedEnvironmentConfig,
} from "../services/environment-config.js";
@@ -54,7 +55,8 @@ import {
type ReadyPluginWorkerRecovery,
} from "../services/plugin-environment-driver.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
import { assertBoardOrgAccess, getActorInfo } from "./authz.js";
import { assertBoardOrgAccess, assertCompanyAccess, getActorInfo } from "./authz.js";
import { computerService, ComputerError } from "../modules/computers/index.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { environmentService } from "../services/environments.js";
import { environmentRuntimeService } from "../services/environment-runtime.js";
@@ -1014,6 +1016,11 @@ export function environmentRoutes(
router.post("/companies/:companyId/environments", validate(createEnvironmentSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCanAccessInstanceEnvironments(req);
assertCompanyAccess(req, companyId);
if (req.body.metadata?.computerCompanyId !== undefined) throw unprocessable("Computer ownership is assigned by Paperclip.");
if (req.body.driver === "computer" && !(await instanceSettings.getExperimental()).enableBoatEnvironments) {
throw unprocessable("Enable Boat environments in experimental settings first.");
}
assertNoClientPlatformProvisionedMarkers(req.body.metadata);
if (req.body.driver === "local") {
const existingLocal = await svc.list({ driver: "local" });
@@ -1024,6 +1031,10 @@ export function environmentRoutes(
const actor = getActorInfo(req);
const input = {
...req.body,
...(req.body.driver === "computer" ? {
status: "archived" as const,
metadata: { ...req.body.metadata, computerCompanyId: companyId },
} : {}),
envVars: await secrets.normalizeEnvBindingsForPersistence(
companyId,
req.body.envVars,
@@ -1045,7 +1056,7 @@ export function environmentRoutes(
};
// Create the row and its binding rows atomically so an invalid secret
// ref cannot leave an environment persisted without its bindings.
const environment = await db.transaction(async (tx) => {
let environment = await db.transaction(async (tx) => {
const created = await svc.create(input, undefined, { db: tx });
await secrets.replaceSecretRefsForInstanceTarget(
{ targetType: "environment", targetId: created.id },
@@ -1060,6 +1071,13 @@ export function environmentRoutes(
);
return created;
});
if (environment.driver === "computer") {
const parsed = parseEnvironmentDriverConfig(environment);
if (parsed.driver !== "computer") throw unprocessable("Invalid Boat configuration.");
await computerService(db).attach({ companyId, environmentId: environment.id,
sandboxId: parsed.config.sandboxId, apiKeySecretRef: parsed.config.apiKeySecretRef });
environment = (await svc.update(environment.id, { status: "active" }))!;
}
await logInstanceEnvironmentActivity({
actor,
action: "environment.created",
@@ -1137,6 +1155,24 @@ export function environmentRoutes(
}),
});
assertNoClientPlatformProvisionedMarkers(req.body.metadata);
if (req.body.metadata?.computerCompanyId !== undefined && req.body.metadata.computerCompanyId !== existing.metadata?.computerCompanyId) {
throw unprocessable("Computer ownership cannot be changed.");
}
if (existing.driver === "computer" || req.body.driver === "computer") {
if (existing.driver !== "computer" || req.body.driver && req.body.driver !== "computer") {
throw unprocessable("Create a separate environment to change its type.");
}
if (req.body.config?.sandboxId !== undefined && req.body.config.sandboxId !== existing.config.sandboxId) {
throw unprocessable("Disconnect this environment and attach the other Boat separately.");
}
const computerCompanyId = existing.metadata?.computerCompanyId;
if (typeof computerCompanyId !== "string") throw unprocessable("Computer attachment has no company.");
assertCompanyAccess(req, computerCompanyId);
if ((req.body.config !== undefined || req.body.status === "active") && !(await instanceSettings.getExperimental()).enableBoatEnvironments) {
throw unprocessable("Boat environments are disabled.");
}
if (req.body.metadata) req.body.metadata = { ...req.body.metadata, computerCompanyId };
}
// The durable `pending_cleanup` lease row stores the provider, the provider
// lease id, and the immutable config metadata for an orphan sandbox. The
// teardown retry reads that row alone and never reads the current environment
@@ -1163,7 +1199,9 @@ export function environmentRoutes(
const nextName = req.body.name ?? existing.name;
const companyIdForSecrets =
req.body.config !== undefined || req.body.driver !== undefined || req.body.envVars !== undefined
? await resolveEnvironmentSecretContextCompanyId(req, existing.id, { required: true })
? existing.driver === "computer"
? String(existing.metadata?.computerCompanyId)
: await resolveEnvironmentSecretContextCompanyId(req, existing.id, { required: true })
: null;
const configSource =
req.body.config !== undefined
@@ -1208,7 +1246,7 @@ export function environmentRoutes(
// Persist the config change and its binding rows atomically: a binding
// ref that fails validation (e.g. a deleted secret) must roll the whole
// save back instead of leaving the config re-pointed with stale bindings.
const environment = await db.transaction(async (tx) => {
let environment = await db.transaction(async (tx) => {
const updated = await svc.update(existing.id, patch, { db: tx });
if (!updated) return null;
if (patch.config !== undefined || patch.driver !== undefined) {
@@ -1232,6 +1270,14 @@ export function environmentRoutes(
res.status(404).json({ error: "Environment not found" });
return;
}
if (environment.driver === "computer" && (req.body.config !== undefined || req.body.status === "active")) {
const computerCompanyId = String(environment.metadata?.computerCompanyId);
const parsed = parseEnvironmentDriverConfig(environment);
if (parsed.driver !== "computer") throw unprocessable("Invalid Boat configuration.");
await computerService(db).attach({ companyId: computerCompanyId, environmentId: environment.id,
sandboxId: parsed.config.sandboxId, apiKeySecretRef: parsed.config.apiKeySecretRef });
if (req.body.status !== "archived") environment = (await svc.update(environment.id, { status: "active" }))!;
}
let customImageReconciliation: Awaited<
ReturnType<typeof customImages.reconcileActiveTemplateForConfigChange>
> = { action: "none" };
@@ -1272,6 +1318,23 @@ export function environmentRoutes(
res.status(404).json({ error: "Environment not found" });
return;
}
if (existing.driver === "computer") {
const companyId = existing.metadata?.computerCompanyId;
if (typeof companyId !== "string") throw unprocessable("Computer attachment has no company.");
assertCompanyAccess(req, companyId);
if (impact.staticReferences.isInstanceDefault) throw conflict("Choose another default environment before disconnecting this computer.");
await svc.update(existing.id, { status: "archived" });
await computerService(db).detach({ companyId, environmentId: existing.id });
await Promise.all([
executionWorkspaces.clearEnvironmentSelection(companyId, existing.id),
issues.clearExecutionWorkspaceEnvironmentSelection(companyId, existing.id),
projects.clearExecutionWorkspaceEnvironmentSelection(companyId, existing.id),
]);
await logInstanceEnvironmentActivity({ actor, action: "environment.disconnected", entityId: existing.id,
details: { name: existing.name, driver: "computer", filesRetained: true } });
res.json({ ...existing, status: "archived", disconnected: true, destroyedReusableSandboxLeaseCount: 0 });
return;
}
// With explicit consent, destroy the environment's reusable sandbox leases
// so the delete can proceed — but only while those leases are the sole
// blocker. Destroying provider resources and then rejecting on another
@@ -1476,5 +1539,9 @@ export function environmentRoutes(
},
);
router.use((error: unknown, _req: import("express").Request, res: import("express").Response, next: import("express").NextFunction) => {
if (!(error instanceof ComputerError)) return next(error);
res.status(error.code === "conflict" ? 409 : error.code === "invalid" ? 422 : error.code === "not_found" ? 404 : error.code === "forbidden" ? 403 : 502).json({ error: error.message, code: error.code });
});
return router;
}
+3 -2
View File
@@ -56,7 +56,7 @@ export type WorkspaceFileResourceService = {
issueId: string,
input: { path: string; workspace?: "auto" | "execution" | "project" | null; projectId?: string | null; workspaceId?: string | null },
opts?: { issue?: Awaited<ReturnType<WorkspaceFileResourceService["getIssue"]>> },
): Promise<{ resource: ResolvedWorkspaceResource; realPath: string }>;
): Promise<{ resource: ResolvedWorkspaceResource; realPath: string } | { resource: ResolvedWorkspaceResource; bytes: Buffer }>;
};
type FileResourceLimiter = {
@@ -886,7 +886,8 @@ export function fileResourceRoutes(db: Db, opts: {
res.setHeader("Cache-Control", "private, max-age=60");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Content-Disposition", `attachment; filename="${safeAttachmentFilename(result.resource.title)}"`);
await pipeline(createReadStream(result.realPath), res);
if ("bytes" in result) res.end(result.bytes);
else await pipeline(createReadStream(result.realPath), res);
return;
}
+36
View File
@@ -1367,11 +1367,27 @@ const browserUseOperations = [
["put", "/api/companies/{companyId}/browser-use-cloud/grants/{grantId}/settings", "Update browser credential settings", browserUseSettingsSchema],
] as const;
const computerOwnerSchema = z.object({
computerId: z.string().uuid(), ownerId: z.string().uuid(), generation: z.number().int().positive(),
}).strict();
const computerScopeSchema = z.object({ environmentId: z.string().uuid() }).strict();
const computerPresenceSchema = computerScopeSchema.extend({ owner: computerOwnerSchema });
const computerViewerSchema = z.object({ viewerUrl: z.string().url(), expiresAt: z.string().datetime(), owner: computerOwnerSchema });
const computerOperations = [
["get", "/api/issues/{issueId}/computer", "Read the task computer selection", undefined,
z.object({ environmentId: z.string().uuid(), name: z.string() }).nullable()],
["post", "/api/issues/{issueId}/computer/connect", "Connect to the shared computer desktop", computerScopeSchema, computerViewerSchema],
["post", "/api/issues/{issueId}/computer/presence", "Refresh an owned computer viewer", computerPresenceSchema, computerViewerSchema],
["post", "/api/issues/{issueId}/computer/disconnect", "Disconnect an owned computer viewer", computerPresenceSchema, undefined],
["post", "/api/issues/{issueId}/computer/preview", "Open a task-owned computer port", computerScopeSchema.extend({ port: z.number().int().min(1024).max(65535) }), z.object({ url: z.string().url() })],
] as const;
const BOARD_ONLY_OPERATIONS = new Set([
"GET /api/companies/{companyId}/decision-model",
"PUT /api/companies/{companyId}/decision-model",
"POST /api/companies/{companyId}/decision-model/test",
...browserUseOperations.map(([method, path]) => `${method.toUpperCase()} ${path}`),
...computerOperations.map(([method, path]) => `${method.toUpperCase()} ${path}`),
"GET /api/companies/{companyId}/ai-connections",
"POST /api/companies/{companyId}/ai-connections",
"POST /api/companies/{companyId}/ai-connections/local",
@@ -2232,6 +2248,26 @@ for (const [method, path, summary, body] of browserUseOperations) {
});
}
for (const [method, path, summary, body, response] of computerOperations) {
const success = {
...(response ? r.ok(response) : r.noContent),
headers: {
"Cache-Control": { schema: { type: "string", enum: ["no-store"] } },
"Referrer-Policy": { schema: { type: "string", enum: ["no-referrer"] } },
},
};
registry.registerPath({
method, path, summary, tags: ["Computers"],
description: "Experimental Boat environment operation. Requires board access to the task and its company-bound computer. Viewer URLs are private credentials. Presence refresh cannot extend the original viewer deadline. Disconnect remains available when the experiment is disabled. Preview resolves ownership from the task lease and rejects reserved runner ports.",
request: { params: z.object({ issueId: z.string().uuid() }), ...(body ? { body: jsonBody(body) } : {}) },
responses: {
[response ? 200 : 204]: success,
400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound,
409: r.conflict, 422: r.unprocessable, 502: { description: "Computer provider request failed" },
},
});
}
// Voice endpoints accept current authenticated board sessions (or local implicit board),
// never agent keys or board API keys. Provider callbacks use signatures and are
// intentionally documented separately from this board API contract.
@@ -1,3 +1,5 @@
import { persistentAgentFiles, readPersistentAgentFile, seedPersistentAgentHome } from "./persistent-agent-files.js";
import { materializeInstructionBytes, readInstructionBytes } from "./agent-instruction-files.js";
import fs from "node:fs/promises";
import path from "node:path";
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
@@ -20,6 +22,7 @@ import { logger } from "../middleware/logger.js";
type Copy = typeof copies.$inferSelect;
export class AgentDirectoryReuseInvalidatedError extends Error {}
const persistent = (row: Pick<Copy, "receipt">) => row.receipt?.fileAuthority === "remote-persistent";
const completed = new Set(["saved", "unchanged", "resolved", "unavailable"]);
const transports = new Map<string, PreparedAdapterExecutionTargetRuntime>();
const key = (row: Pick<Copy, "companyId" | "runId">) => `${row.companyId}:${row.runId}`;
@@ -42,12 +45,17 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
const store = agentFileStore(db);
const ownerFile = (row: Copy) => path.join(path.dirname(row.localRoot), "owner.json");
async function owns(row: Copy) {
if (row.receipt?.warm !== true) return true;
if (persistent(row) || row.receipt?.warm !== true) return true;
return await fs.readFile(ownerFile(row), "utf8").then(s => JSON.parse(s).runId === row.runId).catch(error => error.code === "ENOENT" && row.processStoppedAt !== null);
}
async function canReuse(companyId: string, agentId: string, runId: string) {
const row = await get(companyId, runId);
if (!row || row.agentId !== agentId || row.state !== "warm_saved" || row.errorCode || !await owns(row)) return false;
if (persistent(row)) {
const remote = await persistentAgentFiles(db, companyId, agentId, String(row.receipt?.environmentId));
if (!remote || remote.root !== row.executionRoot) return false;
return (await readPersistentAgentFile(remote, row.entryFile))?.sha256 === row.receipt?.sessionEntryHash;
}
const entry = baseline(row).entries.get(row.entryFile);
if (entry?.kind !== "file" || entry.hash !== row.receipt?.sessionEntryHash) return false;
return store.locked(companyId, agentId, actor(row), false, async (_tx, _agent, root) =>
@@ -96,6 +104,41 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
if (agentInstructionsBundleMode(agent) !== "managed") return null;
const bound = await resolveInstructionActor(db, { type: "agent", companyId: input.companyId, agentId: input.agentId, runId: input.runId });
const root = resolveManagedInstructionsRoot(agent);
if (input.target?.kind === "remote" && input.target.transport === "computer" && input.target.fileAuthority?.kind === "remote-persistent") {
const authority = input.target.fileAuthority;
const remote = await persistentAgentFiles(db, input.companyId, input.agentId, input.target.environmentId ?? undefined);
if (!remote || remote.root !== authority.agentHome) throw conflict("Persistent agent placement changed");
await store.locked(input.companyId, input.agentId, bound, false, async (_tx, _agent, canonical) => seedPersistentAgentHome(remote, canonical));
const entryFile = deriveBundleState(agent).entryFile;
let entry = await readPersistentAgentFile(remote, entryFile);
if (!entry) {
// An adopted directory may predate Paperclip. Deliver the missing managed
// entry with create-only CAS, without replacing any personal bytes.
const seed = await readInstructionBytes(root, entryFile);
if (!seed) throw notFound("The persistent agent instruction entry is missing");
await remote.writeBytes(entryFile, seed, null);
entry = await readPersistentAgentFile(remote, entryFile);
if (!entry) throw notFound("The persistent agent instruction entry is missing");
}
// Only prompt bytes are staged locally. This scratch is never an authority
// for personal files, nor is it uploaded into the persistent directory.
const localRoot = path.join(path.dirname(root), "file-sync", "runs", input.runId, "live");
await materializeInstructionBytes(localRoot, entryFile, entry.bytes);
const values = { entryFile, baseRevisionId: null, baseHash: entry.sha256, localRoot,
executionRoot: authority.agentHome, location: `remote:${input.target.environmentId}`, state: "prepared",
candidateBase64: null, candidateHash: null, processStoppedAt: null, attempts: 0,
nextAttemptAt: null, errorCode: null, errorMessage: null,
receipt: { schema: AGENT_FILES_CONTRACT, fileAuthority: "remote-persistent", placementId: authority.placementId,
environmentId: input.target.environmentId, workspaceCwd: input.cwd, warm: input.warm === true,
sessionEntryHash: entry.sha256, cleanupPending: false } };
const existing = await get(input.companyId, input.runId);
if (existing && (!persistent(existing) || existing.executionRoot !== authority.agentHome)) throw conflict("Agent file authority changed");
const result = existing ? await patch(existing, values)
: (await db.insert(copies).values({ companyId: input.companyId, agentId: input.agentId, runId: input.runId,
responsibleUserId: bound.onBehalfOfUserId!, ...values }).returning())[0]!;
input.onWarmHandoff?.(result);
return result;
}
if (input.reuseRunId) {
const prior = await get(input.companyId, input.reuseRunId);
if (!prior || prior.agentId !== input.agentId) throw conflict("Managed warm directory owner changed");
@@ -241,6 +284,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
return inspectAgentDirectory(row.localRoot);
}
async function hasChanges(row: Copy, target?: AdapterExecutionTarget | null) {
if (persistent(row)) return false; // Bytes already live on the authoritative computer.
try {
if (row.receipt?.retainedByRunId || row.receipt?.retirementRequired || typeof row.receipt?.materializationIdentity !== "string") return true;
const observed = await observe(row, target);
@@ -251,6 +295,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
}
/** Rebind one physical materialization; earlier runs lose collection authority. */
async function adopt(input: { companyId: string; agentId: string; runId: string; previousRunId: string; target?: AdapterExecutionTarget | null; cwd: string; allowRetirementHandoff?: boolean }) {
if (input.target?.kind === "remote" && input.target.transport === "computer" && input.target.fileAuthority?.kind === "remote-persistent") return prepareCopy({ ...input, warm: true });
let prior = await get(input.companyId, input.previousRunId);
if (!prior || prior.agentId !== input.agentId || prior.state !== "unchanged_turn" || prior.receipt?.retainedByRunId || prior.processStoppedAt
|| prior.receipt?.workspaceCwd !== input.cwd || prior.location !== (input.target?.kind === "remote" ? `remote:${input.target.environmentId ?? ""}` : "local")) return null;
@@ -317,6 +362,8 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
return adopted;
}
async function checkpoint(row: Copy, target?: AdapterExecutionTarget | null, stopped = false): Promise<Copy> {
if (persistent(row)) return patch(row, { state: stopped ? "unchanged" : "warm_saved", nextAttemptAt: null,
...(stopped ? { processStoppedAt: new Date() } : {}) });
if (!await owns(row) || row.state === "superseded") return row;
let failure: unknown;
for (let attempt = 0; attempt < 3; attempt++) {
@@ -356,6 +403,11 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
receipt: { ...row.receipt, storageWarning: storageLimit ? agentStorageWarning(failure instanceof AgentFileLimitError ? failure.message : "Agent folder exceeds a storage limit") : null } });
}
async function collectStopped(row: Copy, target?: AdapterExecutionTarget | null) {
if (persistent(row)) {
const settled = await patch(row, { state: "unchanged", processStoppedAt: new Date(), nextAttemptAt: null });
await release(settled);
return settled;
}
if (row.receipt?.retainedByRunId) return row;
if (!await owns(row) || row.state === "superseded") return row;
if (row.receipt?.warm === true && !completed.has(row.state)) {
@@ -412,6 +464,11 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
completed.has(row.state) && row.receipt?.cleanupPending === false;
}
async function release(row: Copy, target?: AdapterExecutionTarget | null) {
if (persistent(row)) {
// No remote command, snapshot, or directory deletion is legal here.
if (row.processStoppedAt) await fs.rm(path.dirname(row.localRoot), { recursive: true, force: true });
return;
}
if (releaseIsNoop(row) || !await owns(row)) return;
const destroyedOnly = row.receipt?.cleanupDestroyedOnly === true;
if (row.receipt?.retainedByRunId || row.state === "superseded" || !await owns(row) || !row.processStoppedAt) return;
@@ -503,6 +560,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
return lease;
}
async function recoverStoppedRemote(row: Copy) {
if (persistent(row)) return patch(row, { state: "unchanged", processStoppedAt: row.processStoppedAt ?? new Date(), nextAttemptAt: null });
const lease = await ownedRemoteLease(row);
const destroyed = lease && hasRemoteTerminationReceipt(lease)
&& (lease.metadata?.remoteExecutionTermination as { state?: string } | undefined)?.state === "destroyed";
@@ -553,7 +611,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
const current = await get(row.companyId, row.runId);
// This check changes no receipt or bytes. A concurrent stop merely leaves
// cleanup to its owner or the recovery sweep; it grants no new authority.
if (!current || releaseIsNoop(current)) return;
if (!current || (!persistent(current) && releaseIsNoop(current))) return;
await serial(current, release, "agent_directory_release");
},
};
@@ -361,7 +361,11 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin
executionTarget: target,
environmentName: environment.name,
fallbackChecks: [],
sandboxIdentityCheck: buildSandboxIdentityCheck({
sandboxIdentityCheck: environment.driver === "computer" ? {
code: "computer_test_identity", level: "info",
message: `Environment test ran on "${environment.name}" with a bounded computer owner.`,
detail: `paperclipLeaseId=${leaseRecord.lease.id}; ownerId=${leaseRecord.lease.providerLeaseId}`,
} : buildSandboxIdentityCheck({
environmentName: environment.name,
lease: leaseRecord.lease,
}),
@@ -433,7 +437,7 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin
// The environment drivers the adapter Test route accepts. A local, SSH, or
// sandbox environment can host a probe; a plugin environment cannot.
const ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS = ["local", "ssh", "sandbox"];
const ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS = ["local", "ssh", "sandbox", "computer"];
// The fail-closed tenant-binding guard for the adapter Test route. A caller
// may name any instance environment by id, so the route must reject an
+50 -7
View File
@@ -1,3 +1,5 @@
import { persistentAgentFiles, readPersistentAgentFile, seedPersistentAgentHome } from "./persistent-agent-files.js";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import { cachedAgentFileManifest, checkpointSnapshot, type AgentFileManifest } from "./agent-file-checkpoints.js";
@@ -161,15 +163,43 @@ export async function snapshotAgentFiles(root: string, enforceLimits = true): Pr
return (await inspectAgentDirectory(root, enforceLimits)).snapshot;
}
/** Realize personal authority before any remote command needs it as cwd.
* This is runtime setup, not a working-copy checkout: no run receipt or copyback
* is created, and an existing remote home is always adopted untouched. */
export async function preparePersistentAgentExecutionHome(db: Db, input: {
companyId: string; agentId: string; target?: AdapterExecutionTarget | null;
}): Promise<string | null> {
const target = input.target;
if (target?.kind !== "remote" || target.transport !== "computer") return null;
if (!target.environmentId) throw conflict("Persistent agent environment is missing");
return db.transaction(async tx => {
const [agent] = await tx.select().from(agents)
.where(and(eq(agents.companyId, input.companyId), eq(agents.id, input.agentId))).for("update");
if (!agent) throw notFound("Agent not found");
const remote = await persistentAgentFiles(db, input.companyId, input.agentId, target.environmentId!);
if (!remote || remote.root !== target.fileAuthority.agentHome) throw conflict("Persistent agent placement changed");
if (agentInstructionsBundleMode(agent) === "managed") {
await seedPersistentAgentHome(remote, await adoptAgentFiles(tx, agent));
} else {
// An external instruction path is not an authority for personal files.
// The seed primitive atomically creates an absent home or adopts it.
await remote.seedBytes({});
}
return remote.root;
});
}
export function agentFileStore(db: Db) {
async function locked<T>(companyId: string, agentId: string, actor: AuthorizationActor, write: boolean,
fn: (tx: Tx, agent: Agent, root: string, bound: AuthorizationActor) => Promise<T>) {
fn: (tx: Tx, agent: Agent, root: string, bound: AuthorizationActor, remote: Awaited<ReturnType<typeof persistentAgentFiles>>) => Promise<T>) {
return db.transaction(async tx => {
const [agent] = await tx.select().from(agents).where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("update");
if (!agent) throw notFound("Agent not found");
const bound = write ? await authorizeInstructionCommit(tx, actor, agent) : await authorizeInstructionRead(tx, actor, agent);
const root = await adoptAgentFiles(tx, agent);
return fn(tx, agent, root, bound);
const remote = await persistentAgentFiles(db, companyId, agentId);
if (remote) await seedPersistentAgentHome(remote, root);
return fn(tx, agent, root, bound, remote);
});
}
async function audit(tx: Tx, agent: Agent, actor: AuthorizationActor, details: Record<string, unknown>) {
@@ -183,29 +213,41 @@ export function agentFileStore(db: Db) {
download: async (companyId: string, agentId: string, relative: string, actor: AuthorizationActor) => {
const opened: { file: Awaited<ReturnType<typeof openAgentFile>> } = { file: null };
try {
const file = await locked(companyId, agentId, actor, false, async (_tx, _agent, root) => {
const file = await locked(companyId, agentId, actor, false, async (_tx, _agent, root, _bound, remote) => {
if (remote) return { remote: await readPersistentAgentFile(remote, relative) };
opened.file = await openAgentFile(root, instructionPath(relative));
return opened.file;
});
// Transfer outside the database lock. The open descriptor pins the file
// across concurrent atomic replacements; end bounds concurrent growth.
if (!file) return null;
if ("remote" in file) return file.remote ? { size: file.remote.bytes.length, stream: Readable.from([file.remote.bytes]) } : null;
if (file.size === 0) { await file.handle.close(); return { size: 0, stream: Readable.from([]) }; }
return { size: file.size, stream: file.handle.createReadStream({ end: file.size - 1 }) };
} catch (error) { await opened.file?.handle.close(); throw error; }
},
read: (companyId: string, agentId: string, relative: string, actor: AuthorizationActor) =>
locked(companyId, agentId, actor, false, (_tx, _agent, root) => readAgentFile(root, instructionPath(relative))),
locked(companyId, agentId, actor, false, async (_tx, _agent, root, _bound, remote) => remote ? (await readPersistentAgentFile(remote, relative))?.bytes ?? null : readAgentFile(root, instructionPath(relative))),
write: (input: { companyId: string; agentId: string; path: string; bytes: Buffer | null; baseHash: string | null }, actor: AuthorizationActor) =>
locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound) => {
locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound, remote) => {
const relative = agentFilePath(input.path);
if (input.bytes) assertFileSize(input.bytes.length, relative);
const previous = await inspectAgentFile(root, relative);
const remotePrevious = remote ? await readPersistentAgentFile(remote, relative) : null;
const previous = remote ? (remotePrevious ? { hash: remotePrevious.sha256, size: remotePrevious.bytes.length } : null) : await inspectAgentFile(root, relative);
const currentHash = previous?.hash ?? null;
const incomingHash = input.bytes === null ? null : fileHash(input.bytes);
if (currentHash === incomingHash) return { contentHash: currentHash, changed: false };
if (currentHash !== input.baseHash) throw conflict("This file changed since it was read. Reload before saving.", { code: "AGENT_FILE_CONFLICT", path: relative, currentHash });
if (input.bytes === null && relative === deriveBundleState(agent).entryFile) throw unprocessable("The configured instruction entry cannot be deleted");
if (remote) {
if (input.bytes !== null && relative === deriveBundleState(agent).entryFile) instructionBytes(input.bytes);
// The remote operation checks the hash again immediately before atomic
// replacement. Arbitrary shell writers do not participate in this lock.
if (input.bytes === null) await remote.remove(relative, currentHash!);
else await remote.writeBytes(relative, input.bytes, input.baseHash);
await audit(tx, agent, bound, { path: relative, contentHash: incomingHash });
return { contentHash: incomingHash, changed: true };
}
if (input.bytes !== null) {
if (relative === deriveBundleState(agent).entryFile) instructionBytes(input.bytes);
// A quota check needs metadata only. Do not hash unrelated large
@@ -221,7 +263,8 @@ export function agentFileStore(db: Db) {
return { contentHash: incomingHash, changed: true };
}),
apply: (input: { companyId: string; agentId: string; sourceDir: string; baseline: DirectorySnapshot; checkpoint?: AgentFileManifest }, actor: AuthorizationActor) =>
locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound) => {
locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound, remote) => {
if (remote) throw unprocessable("Persistent remote agent files cannot accept controller copy-back");
const incoming = input.checkpoint ? checkpointSnapshot(input.checkpoint) : await snapshotAgentFiles(input.sourceDir);
const entryPath = deriveBundleState(agent).entryFile;
if (incoming.entries.get(entryPath)?.kind !== "file") throw unprocessable("The configured instruction entry cannot be deleted");
@@ -1,3 +1,4 @@
import { persistentAgentFiles, readPersistentAgentFile, seedPersistentAgentHome } from "./persistent-agent-files.js";
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
import { createHash } from "node:crypto";
import { adoptAgentFiles, agentFileToken } from "./agent-file-store.js";
@@ -107,7 +108,7 @@ export function agentInstructionRevisionService(db: Db) {
}
const state = deriveBundleState(agent);
const entryFile = instructionPath(state.entryFile);
return { agent, entryFile, root: resolveManagedInstructionsRoot(agent) };
return { agent, entryFile, root: resolveManagedInstructionsRoot(agent), remote: await persistentAgentFiles(db, agent.companyId, agent.id) };
}
async function authorizeRead(
tx: Tx,
@@ -156,7 +157,9 @@ export function agentInstructionRevisionService(db: Db) {
// live in the agent directory; old UUID clients receive a content ETag.
async function currentFile(tx: Tx, target: InstructionTarget, state: Awaited<ReturnType<typeof lockTarget>>, actor?: RevisionActor) {
await adoptAgentFiles(tx, state.agent);
const bytes = await readInstructionBytes(state.root, state.entryFile);
if (state.remote) await seedPersistentAgentHome(state.remote, state.root);
const bytes = state.remote ? (await readPersistentAgentFile(state.remote, state.entryFile))?.bytes ?? null
: await readInstructionBytes(state.root, state.entryFile);
if (bytes === null) return null;
return currentRow(target, state.entryFile, bytes, actor);
}
@@ -235,7 +238,7 @@ export function agentInstructionRevisionService(db: Db) {
"Configured instruction entry changed; read the current entry and retry",
{ code: "INSTRUCTION_ENTRY_CHANGED", entryFile: state.entryFile },
);
await assertInstructionPathSafe(state.root, state.entryFile);
if (!state.remote) await assertInstructionPathSafe(state.root, state.entryFile);
const current = await currentFile(tx, input, state, bound);
const restored =
"restoreRevisionId" in input
@@ -285,7 +288,8 @@ export function agentInstructionRevisionService(db: Db) {
},
);
}
await materializeInstructionBytes(state.root, state.entryFile, candidate);
if (state.remote) await state.remote.writeBytes(state.entryFile, candidate, current?.contentHash ?? null);
else await materializeInstructionBytes(state.root, state.entryFile, candidate);
const row = currentRow(input, input.entryFile, candidate, bound);
await tx.insert(activityLog).values({ companyId: input.companyId,
actorType: bound.type === "plugin" ? "plugin" : bound.type === "board" ? "user" : "agent",
@@ -47,6 +47,7 @@ export async function collectStoppedInstructionCopyWithRetries<T extends Pick<Co
}
export function instructionWorkingCopyGuidance(copy: Pick<Copy, "executionRoot" | "entryFile" | "receipt">) {
if (copy.receipt?.fileAuthority === "remote-persistent") return `Your persistent agent directory is ${copy.executionRoot} (AGENT_HOME). Your instruction entry is ${copy.executionRoot}/${copy.entryFile}. Changes are saved directly on this computer and remain across turns. Provider HOME and credentials are separate runtime directories.`;
if (isAgentDirectoryCopy(copy)) return `Your persistent agent directory is ${copy.executionRoot} (AGENT_HOME). Your instruction entry is ${copy.executionRoot}/${copy.entryFile}. Read and write your own files and subfolders there. This directory belongs to this agent across tasks and sessions; task files belong in the task working directory. Paperclip restores this directory before execution and saves validated changes at turn boundaries. A warm native Codex session keeps the same writable directory between turns. Other sessions collect after the provider stops. Regular files, including binary files, persist; symlinks and special files are unsupported. Check the agent-files save receipt before claiming persistence; Only files you change or delete are synchronized. If another run changes the same file, the last completed synchronization wins. Temporary copies are removed when the owning session stops; there is no per-run file history. Storage allows 256 MiB per file, 2 GiB total, and 100,000 entries; the instruction entry must remain UTF-8 and at most 1 MiB. Reaching a storage limit never prevents this or future tasks from running. Remove or shrink files to free space; changes that exceed the limits will not be saved.${typeof copy.receipt?.storageWarning === "string" ? `\n\n${copy.receipt.storageWarning}` : ""}`;
return `Your editable agent instruction file is ${copy.executionRoot}/${copy.entryFile}. Edit this registered private copy normally. After this run stops, Paperclip saves changed content as a persistent revision if your responsible user still has permission and the baseline has not changed. Check the run's instruction-save receipt before claiming persistence. Use read_agent_instructions, update_agent_instructions, get_agent_instruction_history, and restore_agent_instructions for immediate saves and history. Read first and pin the returned revision. Preserve conflicts; never silently retry against a newer head. Repository instructions, skills, and the loaded prompt are separate and are not collected.`;
}
@@ -103,7 +104,7 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen
}
async function prepare(input: { companyId: string; agentId: string; runId: string; target?: AdapterExecutionTarget | null; cwd: string; legacy?: boolean; warm?: boolean; reuseRunId?: string; onWarmHandoff?: (copy: Copy) => void }) {
const existing = await get(input.companyId, input.runId);
if (isAgentDirectoryCopy(existing) || (!existing && !input.legacy)) return directories.prepare(input);
if ((input.target?.kind === "remote" && input.target.transport === "computer" && input.target.fileAuthority?.kind === "remote-persistent") || isAgentDirectoryCopy(existing) || (!existing && !input.legacy)) return directories.prepare(input);
let refreshStoppedCopy = false;
const workspace = await fs.realpath(input.cwd);
const expectedLocalRoot = path.join(workspace, ".paperclip-runtime", `instruction-edits-${input.runId}`, "instructions");
+71 -2
View File
@@ -1,9 +1,10 @@
import { inspectAgentFile, fileHash, agentFilePath, MAX_AGENT_FILE_BYTES } from "./agent-file-store.js";
import { persistentAgentFiles, readPersistentAgentFile, seedPersistentAgentHome } from "./persistent-agent-files.js";
import { adoptAgentFiles, inspectAgentFile, fileHash, agentFilePath, MAX_AGENT_FILE_BYTES } from "./agent-file-store.js";
import fs from "node:fs/promises";
import path from "node:path";
import { and, eq } from "drizzle-orm";
import { agentInstructionHeads, agents, type Db } from "@paperclipai/db";
import { instructionPath, assertInstructionPathSafe, instructionBytes, readInstructionBytes } from "./agent-instruction-files.js";
import { instructionPath, assertInstructionPathSafe, instructionBytes, readInstructionBytes, MAX_INSTRUCTION_BYTES } from "./agent-instruction-files.js";
import { notFound, unprocessable } from "../errors.js";
import { resolveHomeAwarePath, resolvePaperclipInstanceRoot } from "../home-paths.js";
@@ -489,6 +490,37 @@ export function syncInstructionsBundleConfigFromFilePath(
}
export function agentInstructionsService(db?: Db) {
async function remoteFiles(agent: AgentLike) {
// External instructions keep their configured authority even when the
// agent has a separate personal directory on a computer.
if (agentInstructionsBundleMode(agent) === "external") return null;
const remote = db ? await persistentAgentFiles(db, agent.companyId, agent.id) : null;
if (remote && db) {
const root = await db.transaction(async tx => {
const [current] = await tx.select().from(agents).where(and(eq(agents.id, agent.id), eq(agents.companyId, agent.companyId)));
if (!current) throw notFound("Agent not found");
// updateBundle previews the prospective managed config before the
// caller saves it. Do not adopt a still-external persisted bundle.
if (agentInstructionsBundleMode(current) === "external") return null;
return adoptAgentFiles(tx, current);
});
if (!root) return null;
await seedPersistentAgentHome(remote, root);
}
return remote;
}
async function remotePaths(remote: NonNullable<Awaited<ReturnType<typeof remoteFiles>>>, relative = ""): Promise<Array<{ path: string; size: number }>> {
const result: Array<{ path: string; size: number }> = [];
for (const entry of await remote.list(relative || undefined)) {
const name = relative ? `${relative}/${entry.name}` : entry.name;
instructionPath(name);
if (entry.kind === "directory") {
if (!IGNORED_INSTRUCTIONS_DIRECTORY_NAMES.has(entry.name)) result.push(...await remotePaths(remote, name));
} else if (!IGNORED_INSTRUCTIONS_FILE_NAMES.has(entry.name)) result.push({ path: name, size: entry.size });
if (result.length > 100_000) throw unprocessable("Agent folder exceeds its entry limit");
}
return result;
}
async function assertUnversionedEntry(agent: AgentLike, entryFile: string, connection: Db | Parameters<Parameters<Db["transaction"]>[0]>[0] | undefined = db) {
if (!connection) throw unprocessable("Bundle initialization requires the database-backed instructions service");
const [head] = await connection.select().from(agentInstructionHeads).where(and(eq(agentInstructionHeads.companyId, agent.companyId),
@@ -497,6 +529,15 @@ export function agentInstructionsService(db?: Db) {
}
async function getBundle(agent: AgentLike): Promise<AgentInstructionsBundle> {
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
const remote = await remoteFiles(agent);
if (remote) {
const summaries: AgentInstructionsFileSummary[] = [];
for (const { path: relative, size } of await remotePaths(remote)) {
const file = size <= MAX_INSTRUCTION_BYTES ? await readPersistentAgentFile(remote, relative) : null;
summaries.push(summarizeFile(relative, state.entryFile, size, file?.bytes ?? null, file?.sha256));
}
return toBundle(agent, { ...state, rootPath: remote.root, resolvedEntryPath: `${remote.root}/${state.entryFile}` }, summaries);
}
if (!state.rootPath) return toBundle(agent, state, []);
await assertInstructionPathSafe(state.rootPath, state.entryFile);
const stat = await statIfExists(state.rootPath);
@@ -532,6 +573,13 @@ export function agentInstructionsService(db?: Db) {
content,
};
}
const remote = await remoteFiles(agent);
if (remote) {
const file = await readPersistentAgentFile(remote, relativePath);
if (!file) throw notFound("Instructions file not found");
const summary = summarizeFile(relativePath, state.entryFile, file.bytes.length, file.bytes, file.sha256);
return { ...summary, content: summary.binary ? "" : file.bytes.toString("utf8") };
}
if (!state.rootPath) throw notFound("Agent instructions bundle is not configured");
await assertInstructionPathSafe(state.rootPath, relativePath);
const file = await inspectAgentFile(state.rootPath, relativePath);
@@ -593,6 +641,14 @@ export function agentInstructionsService(db?: Db) {
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
const nextMode = input.mode ?? state.mode ?? "managed";
const nextEntryFile = input.entryFile ? normalizeRelativeFilePath(input.entryFile) : state.entryFile;
const remote = await remoteFiles(agent);
if (remote) {
if (nextMode !== "managed") throw unprocessable("Persistent computer files require managed instructions");
if (!await readPersistentAgentFile(remote, nextEntryFile)) throw notFound("The selected instruction entry does not exist on the computer");
const adapterConfig = applyBundleConfig(state.config, { mode: "managed", rootPath: resolveManagedInstructionsRoot(agent),
entryFile: nextEntryFile, clearLegacyPromptTemplate: input.clearLegacyPromptTemplate });
return { adapterConfig, bundle: await getBundle({ ...agent, adapterConfig }) };
}
let nextRootPath: string;
if (nextMode === "managed") {
@@ -665,6 +721,7 @@ export function agentInstructionsService(db?: Db) {
throw unprocessable("Entry edits require the canonical instruction commit service and baseRevisionId", { code: "INSTRUCTION_REVISION_REQUIRED" });
}
if (configured.mode !== "external") agentFilePath(relativePath);
if (await remoteFiles(agent)) throw unprocessable("Persistent remote file edits require the content API and expected content hash");
const prepared = await ensureWritableBundle(agent, options);
instructionBytes(content);
await assertInstructionPathSafe(prepared.state.rootPath!, relativePath);
@@ -693,6 +750,7 @@ export function agentInstructionsService(db?: Db) {
if (normalizedPath === state.entryFile) {
throw unprocessable("Cannot delete the bundle entry file");
}
if (await remoteFiles(agent)) throw unprocessable("Persistent remote file deletion requires the content API and expected content hash");
const absolutePath = await assertInstructionPathSafe(state.rootPath, normalizedPath);
await fs.rm(absolutePath, { force: true });
const adapterConfig = buildPersistedBundleConfig(derived, state);
@@ -728,6 +786,16 @@ export function agentInstructionsService(db?: Db) {
warnings: string[];
}> {
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
const remote = await remoteFiles(agent);
if (remote) {
const files: Record<string, string> = {};
for (const { path: relative, size } of await remotePaths(remote)) {
if (size > MAX_INSTRUCTION_BYTES) continue;
const file = await readPersistentAgentFile(remote, relative);
if (file) files[relative] = file.bytes.toString("utf8");
}
return { files, entryFile: state.entryFile, warnings: state.warnings };
}
if (state.rootPath) {
const stat = await statIfExists(state.rootPath);
if (stat?.isDirectory()) {
@@ -760,6 +828,7 @@ export function agentInstructionsService(db?: Db) {
entryFile?: string;
},
): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record<string, unknown> }> {
if (await remoteFiles(agent)) throw unprocessable("Replace individual persistent files using their expected content hash");
const rootPath = resolveManagedInstructionsRoot(agent);
const entryFile = options?.entryFile ? normalizeRelativeFilePath(options.entryFile) : ENTRY_FILE_DEFAULT;
+7
View File
@@ -1,6 +1,8 @@
import type { Db } from "@paperclipai/db";
import { eq, inArray } from "drizzle-orm";
import { createAgentLifecycle, scheduleAgentLifecycle } from "./agent-lifecycle.js";
import { computerService } from "../modules/computers/index.js";
import { computerCompanyDeletion } from "../modules/computers/company-deletion.js";
import { agentLifecycleCompanyDeletion } from "../modules/agent-lifecycle/company-deletion.js";
import {
companies,
@@ -47,6 +49,10 @@ export async function deleteCompany(db: Db, id: string) {
await lifecycle.terminateAgent(agent.id);
await scheduleAgentLifecycle(db, agent.id);
}
const computers = computerService(db);
for (const computer of await computers.list(id)) {
await computers.detach({ companyId: id, environmentId: computer.environmentId });
}
return db.transaction(async (tx) => {
// Exclude accounting writers before taking child locks. KEY SHARE must
// remain compatible: native writers can already hold a child row while
@@ -83,6 +89,7 @@ export async function deleteCompany(db: Db, id: string) {
await tx.delete(issueComments).where(eq(issueComments.companyId, id));
await tx.delete(approvalComments).where(eq(approvalComments.companyId, id));
await tx.delete(approvals).where(eq(approvals.companyId, id));
await computerCompanyDeletion.deleteCompanyData(tx, id);
await tx.delete(companySecrets).where(eq(companySecrets.companyId, id));
await tx.delete(joinRequests).where(eq(joinRequests.companyId, id));
await tx.delete(invites).where(eq(invites.companyId, id));
@@ -0,0 +1,135 @@
import { and, eq } from "drizzle-orm";
import { environmentLeases } from "@paperclipai/db";
import { remoteTerminationReceipt } from "./remote-execution-termination.js";
import { instanceSettingsService } from "./instance-settings.js";
import { resolvePaperclipRunnerIdleTimeoutMs } from "@paperclipai/adapter-utils";
import { createHash, randomUUID } from "node:crypto";
import type { Db } from "@paperclipai/db";
import type { EnvironmentLease } from "@paperclipai/shared";
import type { AdapterComputerExecutionTarget, EffectiveExecutionCapabilities } from "@paperclipai/adapter-utils/execution-target";
import type { RunnerIngressEndpoint } from "@paperclipai/adapter-utils/runner-connectivity";
import { computerService } from "../modules/computers/index.js";
import { environmentService } from "./environments.js";
import type { EnvironmentRuntimeDriver } from "./environment-runtime.js";
export const COMPUTER_CAPABILITIES: EffectiveExecutionCapabilities = {
reusableLeases: true, nativeSyncIn: false, nativeSyncOut: false,
persistentProcessSessions: true, independentControlCommands: true,
incrementalSessionOutput: false, concurrentSyncOperations: false,
duplexCommandStream: false, runnerWebSocketIngress: true,
};
export function computerOwnerFromLease(lease: EnvironmentLease) {
const value = lease.metadata?.computerOwner as Record<string, unknown> | undefined;
if (!value || typeof value.computerId !== "string" || typeof value.ownerId !== "string" ||
!Number.isSafeInteger(value.generation) || Number(value.generation) < 1 ||
lease.providerLeaseId !== value.ownerId) throw new Error("computer_owner_binding_invalid");
return { computerId: value.computerId, ownerId: value.ownerId, generation: Number(value.generation) };
}
export function createComputerEnvironmentDriver(db: Db): EnvironmentRuntimeDriver {
const computers = computerService(db);
const leases = environmentService(db);
const scope = (lease: EnvironmentLease) => {
if (!lease.environmentId) throw new Error("computer_environment_missing");
return { companyId: lease.companyId, environmentId: lease.environmentId };
};
const recover = (lease: EnvironmentLease) => computers.recover({ ...scope(lease), owner: computerOwnerFromLease(lease) });
const retire = async (lease: EnvironmentLease, status: "released" | "expired" | "failed") => {
const result = await computers.retire({ ...scope(lease), owner: computerOwnerFromLease(lease) });
if (!result.retired) throw new Error("computer_owner_retirement_superseded");
return leases.releaseLease(lease.id, status, { cleanupStatus: "success",
remoteExecutionTermination: remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: "stopped" }) });
};
return {
driver: "computer",
async acquireRunLease(input) {
if (!(await instanceSettingsService(db).getExperimental()).enableBoatEnvironments) throw new Error("boat_environments_disabled");
if (!input.agentId) throw new Error("computer_agent_required");
const idleTimeoutMs = resolvePaperclipRunnerIdleTimeoutMs(input.environment.config.runnerIdleTimeoutMs);
const binding = input.heartbeatRunId ? await computers.admit({ companyId: input.companyId, environmentId: input.environment.id,
agentId: input.agentId, runId: input.heartbeatRunId, idleTimeoutMs,
sessionKey: createHash("sha256").update(JSON.stringify([input.agentId, input.executionWorkspaceId ?? input.issueId, input.adapterType, input.executionConfigurationKey])).digest("hex") })
: await computers.admitProbe({ companyId: input.companyId, environmentId: input.environment.id,
agentId: input.agentId, probeId: randomUUID(), idleTimeoutMs });
try {
// The durable owner advanced generation; prior retained bookkeeping no
// longer owns the process and must not be reaped as an allocation.
await db.update(environmentLeases).set({ status: "released", releasedAt: new Date(), cleanupStatus: "success", updatedAt: new Date() })
.where(and(eq(environmentLeases.companyId, input.companyId), eq(environmentLeases.environmentId, input.environment.id),
eq(environmentLeases.providerLeaseId, binding.owner.ownerId), eq(environmentLeases.status, "retained")));
return await leases.acquireLease({ companyId: input.companyId, environmentId: input.environment.id,
executionWorkspaceId: input.executionWorkspaceId, issueId: input.issueId, heartbeatRunId: input.heartbeatRunId,
leasePolicy: "reuse_by_environment", provider: "boat", providerLeaseId: binding.owner.ownerId,
metadata: { driver: "computer", agentId: input.agentId, executionWorkspaceMode: input.executionWorkspaceMode,
computerOwner: binding.owner, listenerPort: binding.listenerPort, remoteCwd: binding.remoteCwd,
fileAuthority: { kind: "remote-persistent", placementId: binding.placementId, root: binding.remoteCwd, agentHome: binding.agentHome } } });
} catch (error) {
await computers.retire({ companyId: input.companyId, environmentId: input.environment.id, owner: binding.owner });
throw error;
}
},
releaseRunLease: ({ lease, status }) => retire(lease, status),
destroyRunLease: ({ lease }) => retire(lease, "released"),
async resumeRunLease({ lease }) { await recover(lease); return lease; },
async retryPendingSandboxTeardown({ lease }) {
const result = await computers.retire({ ...scope(lease), owner: computerOwnerFromLease(lease) });
if (!result.retired) throw new Error("computer_owner_retirement_superseded");
return { providerLeaseId: lease.providerLeaseId, state: "stopped" };
},
async realizeWorkspace({ lease, workspace }) {
const request = workspace.metadata?.workspaceRealizationRequest as { source?: { projectId?: string; repoUrl?: string; repoRef?: string; branchName?: string; strategy?: string }; issueId?: string } | undefined;
const result = await computers.realizeWorkspace({ ...scope(lease), owner: computerOwnerFromLease(lease),
projectId: request?.source?.projectId ?? undefined, taskId: request?.issueId ?? undefined,
repositoryUrl: request?.source?.repoUrl ?? undefined,
branch: request?.source?.branchName ?? request?.source?.repoRef ?? undefined,
baseRef: request?.source?.repoRef ?? undefined,
mode: request?.source?.strategy === "git_worktree" ? "worktree" : "shared" });
await leases.updateLeaseMetadata(lease.id, { ...lease.metadata, remoteCwd: result.remoteCwd,
fileAuthority: { kind: "remote-persistent", placementId: result.placementId, root: result.remoteCwd, agentHome: result.agentHome } });
return { cwd: result.remoteCwd, metadata: { workspaceRealization: { mode: "in_place", authoritativeRoot: result.remoteCwd,
pathAliases: [], outboundRestorePaths: [] } } };
},
async execute({ lease, command, args, cwd, env, stdin, timeoutMs, onLog }) {
const binding = await recover(lease);
return binding.runner.execute({ command, args, cwd: cwd ?? binding.remoteCwd, env, stdin, timeoutMs,
onLog: onLog ? async (stream, chunk) => { await onLog(stream, chunk); } : undefined });
},
async resolveCapabilities() { return COMPUTER_CAPABILITIES; },
};
}
export async function computerExecutionTarget(db: Db, lease: EnvironmentLease, idleTimeoutMs = resolvePaperclipRunnerIdleTimeoutMs(undefined)): Promise<AdapterComputerExecutionTarget> {
if (!lease.environmentId) throw new Error("computer_environment_missing");
const computers = computerService(db);
const scope = { companyId: lease.companyId, environmentId: lease.environmentId };
const owner = computerOwnerFromLease(lease);
const binding = await computers.recover({ ...scope, owner });
const ingress = async (path: string): Promise<RunnerIngressEndpoint> => {
const endpoint = await binding.process.ingress({ path });
return { kind: "authenticated_websocket", websocketUrl: endpoint.url,
secretHeaders: Object.entries(endpoint.secretHeaders).map(([name, value]) => ({ name, value })),
generation: `${owner.ownerId}:${owner.generation}`, refresh: () => ingress(path), close: async () => {} };
};
return { kind: "remote", transport: "computer", providerKey: "boat", environmentId: lease.environmentId, leaseId: lease.id,
remoteCwd: binding.remoteCwd, listenerPort: binding.listenerPort, runner: binding.runner, processRunner: binding.process.runner, shellCommand: "bash",
effectiveCapabilities: COMPUTER_CAPABILITIES, reusableLeaseConfigured: true,
runnerLifecyclePolicy: { mode: "warm", idleTimeoutMs },
resourceAuthority: { kind: "computer-owner", ...owner },
fileAuthority: { kind: "remote-persistent", placementId: binding.placementId, root: binding.remoteCwd, agentHome: binding.agentHome },
workspaceRealization: { mode: "in_place", authoritativeRoot: binding.remoteCwd, pathAliases: [], outboundRestorePaths: [] },
launch: binding.launch, inspectProcess: binding.inspectProcess,
retainWarm: (idleTimeoutMs) => computers.retainWarm({ ...scope, owner, idleTimeoutMs }),
retire: async () => {
const result = await computers.retire({ ...scope, owner });
if (result.retired) await environmentService(db).releaseLease(lease.id, "released", { cleanupStatus: "success",
remoteExecutionTermination: remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: "stopped" }) });
return result.retired;
},
getRunnerIngressEndpoint: ({ port, path }) => {
if (port !== binding.listenerPort) throw new Error("computer_runner_port_mismatch");
return ingress(path);
},
computerTool: { command: "/opt/ascii/cua-driver/cua-driver", args: ["mcp", "--socket", "/run/ascii-cua/driver.sock"] },
};
}
@@ -329,7 +329,7 @@ export async function readCustomerSuccessResource(
if (q.operation.startsWith("files.")) {
try {
const issue = await ownRow(tx, schema.issues, q.companyId!, q.resourceId!);
const files = workspaceFileResourceService(tx);
const files = workspaceFileResourceService(tx, undefined, { allowComputerAccess: false });
const input = { path: q.path ?? "", ...q.context, limit: q.limit, offset: q.offset };
const opts = {
issue: issue as unknown as NonNullable<Parameters<typeof files.list>[2]>["issue"],
@@ -338,6 +338,17 @@ export async function readCustomerSuccessResource(
if (q.operation === "files.read")
return await files.readContent(q.resourceId!, input, opts);
const resolved = await files.prepareDownload(q.resourceId!, input, opts);
if ("bytes" in resolved) {
const bytes = resolved.bytes;
if (bytes.length === 0 && !q.range) return binary(bytes, 0);
const start = q.range?.start ?? 0;
const end = q.range?.end ?? bytes.length - 1;
if (start >= bytes.length || end >= bytes.length || end - start + 1 > MAX_INSPECTION_BYTES)
throw unprocessable("Request a bounded byte range");
// The remote backend already captured a bounded, confined snapshot;
// do not reinterpret its path on the controller filesystem.
return binary(bytes.subarray(start, end + 1), bytes.length, start);
}
const file = await fs.open(resolved.realPath, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const stat = await file.stat();
+36
View File
@@ -37,6 +37,18 @@ const secretRefSchema = z.object({
version: z.union([z.literal("latest"), z.number().int().positive()]).optional().default("latest"),
}).strict();
const computerEnvironmentConfigSchema = z.object({
provider: z.literal("boat"),
sandboxId: z.string().trim().regex(/^bx_[a-zA-Z0-9]+$/, "Enter an existing Boat ID."),
apiKeySecretRef: secretRefSchema,
runnerIdleTimeoutMs: z.number().int().min(1_000).max(86_400_000).optional(),
}).strict();
const computerEnvironmentInputSchema = computerEnvironmentConfigSchema.extend({
apiKeySecretRef: secretRefSchema.optional(),
apiKey: z.string().trim().min(1).optional(),
}).refine((value) => value.apiKey || value.apiKeySecretRef, "A Boat API key is required.");
const sshEnvironmentConfigSchema = z.object({
host: z.string({ error: "SSH environments require a host." }).trim().min(1, "SSH environments require a host."),
port: z.coerce.number().int().min(1).max(65535).default(22),
@@ -121,6 +133,7 @@ const pluginEnvironmentConfigSchema = z.object({
export type ParsedEnvironmentConfig =
| { driver: "local"; config: LocalEnvironmentConfig }
| { driver: "computer"; config: z.infer<typeof computerEnvironmentConfigSchema> }
| { driver: "ssh"; config: SshEnvironmentConfig }
| { driver: "sandbox"; config: SandboxEnvironmentConfig }
| { driver: "plugin"; config: PluginEnvironmentConfig };
@@ -373,6 +386,10 @@ export async function collectEnvironmentSecretRefs(input: {
environment: Pick<Environment, "id" | "driver" | "config">;
}): Promise<Array<{ secretId: string; configPath: string; versionSelector?: SecretVersionSelector }>> {
const parsed = parseEnvironmentDriverConfig(input.environment);
if (parsed.driver === "computer") {
return [{ secretId: parsed.config.apiKeySecretRef.secretId,
configPath: "apiKeySecretRef", versionSelector: parsed.config.apiKeySecretRef.version }];
}
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef) {
return [{
secretId: parsed.config.privateKeySecretRef.secretId,
@@ -437,6 +454,11 @@ export function normalizeEnvironmentConfig(input: {
driver: EnvironmentDriver;
config: Record<string, unknown> | null | undefined;
}): Record<string, unknown> {
if (input.driver === "computer") {
const parsed = computerEnvironmentConfigSchema.safeParse(parseObject(input.config));
if (!parsed.success) throw unprocessable(toErrorMessage(parsed.error));
return parsed.data;
}
if (input.driver === "local") {
return { ...parseObject(input.config) };
}
@@ -551,6 +573,17 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
actor?: { userId?: string | null; agentId?: string | null };
pluginWorkerManager?: PluginWorkerManager;
}): Promise<Record<string, unknown>> {
if (input.driver === "computer") {
const parsed = computerEnvironmentInputSchema.safeParse(parseObject(input.config));
if (!parsed.success) throw unprocessable(toErrorMessage(parsed.error));
const { apiKey, ...stored } = parsed.data;
const apiKeySecretRef = apiKey ? await createEnvironmentSecret({
db: input.db, companyId: input.companyId, environmentName: input.environmentName,
driver: input.driver, field: "boat-api-key", provider: input.secretProvider,
value: apiKey, actor: input.actor,
}) : stored.apiKeySecretRef;
return computerEnvironmentConfigSchema.parse({ ...stored, apiKeySecretRef });
}
if (input.driver === "ssh") {
const parsed = sshEnvironmentConfigPersistenceSchema.safeParse(parseObject(input.config));
if (!parsed.success) {
@@ -788,6 +821,9 @@ export function readSshEnvironmentPrivateKeySecretId(
export function parseEnvironmentDriverConfig(
environment: Pick<Environment, "driver" | "config">,
): ParsedEnvironmentConfig {
if (environment.driver === "computer") {
return { driver: "computer", config: computerEnvironmentConfigSchema.parse(environment.config) };
}
if (environment.driver === "local") {
return {
driver: "local",
@@ -75,6 +75,7 @@ export const ENVIRONMENT_DRIVER_CAPABILITY_SUPPORT: Record<
EnvironmentDriver,
EnvironmentDriverCapabilitySupport
> = {
computer: { driver: "computer", supportedCapabilities: ALL_CAPABILITY_SUPPORT },
local: { driver: "local", supportedCapabilities: NO_CAPABILITY_SUPPORT },
ssh: { driver: "ssh", supportedCapabilities: NO_CAPABILITY_SUPPORT },
sandbox: { driver: "sandbox", supportedCapabilities: ALL_CAPABILITY_SUPPORT },
@@ -131,6 +132,7 @@ export interface EnvironmentDriverTraits {
* it.
*/
export const ENVIRONMENT_DRIVER_TRAITS: Record<EnvironmentDriver, EnvironmentDriverTraits> = {
computer: { driver: "computer", realizesWorkspace: true, runsWorkspaceOffHost: true, confinesStagedProjects: false, hasLeaseCapabilityModel: true },
local: {
driver: "local",
realizesWorkspace: true,
@@ -1,3 +1,5 @@
import { resolvePaperclipRunnerIdleTimeoutMs } from "@paperclipai/adapter-utils";
import { computerExecutionTarget } from "./computer-environment-driver.js";
import type { Db } from "@paperclipai/db";
import type { Environment, EnvironmentLease } from "@paperclipai/shared";
import { adapterSupportsRemoteManagedEnvironments } from "@paperclipai/shared";
@@ -246,6 +248,10 @@ export async function resolveEnvironmentExecutionTarget(input: {
// bridge, so the surface stays inert until the host injects a real recorder.
duplexObservabilityRecorder?: DuplexObservabilityRecorder | null;
}): Promise<AdapterExecutionTarget | null> {
if (input.environment.driver === "computer") {
if (!input.lease || input.lease.companyId !== input.companyId) throw new Error("computer_lease_required");
return computerExecutionTarget(input.db, input.lease, resolvePaperclipRunnerIdleTimeoutMs(input.environment.config?.runnerIdleTimeoutMs));
}
if (input.environment.driver === "local") {
return {
kind: "local",
@@ -673,5 +679,8 @@ export async function resolveEnvironmentExecutionTarget(input: {
export async function resolveEnvironmentExecutionTransport(
input: Parameters<typeof resolveEnvironmentExecutionTarget>[0],
): Promise<Record<string, unknown> | null> {
// Computer execution uses a live owner capability, resolved after workspace
// realization. It has no legacy serialized transport to resolve at acquire.
if (input.environment.driver === "computer") return null;
return adapterExecutionTargetToRemoteSpec(await resolveEnvironmentExecutionTarget(input)) as Record<string, unknown> | null;
}
+11
View File
@@ -11,6 +11,7 @@ import { isBuiltinSandboxProvider, probeSandboxProvider } from "./sandbox-provid
import { probePluginEnvironmentDriver, probePluginSandboxProviderDriver } from "./plugin-environment-driver.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import { environmentRuntimeService } from "./environment-runtime.js";
import { computerService } from "../modules/computers/index.js";
export async function probeEnvironment(
db: Db,
@@ -46,6 +47,16 @@ export async function probeEnvironment(
};
}
if (parsed.driver === "computer") {
if (!resolvedCompanyId || environment.metadata?.computerCompanyId !== resolvedCompanyId) {
return { ok: false, driver: "computer", summary: "This computer belongs to a different company.", details: null };
}
const result = await computerService(db).inspect({ companyId: resolvedCompanyId, environmentId: environment.id });
return { ok: result.status === "attached", driver: "computer",
summary: result.status === "attached" ? `Boat is attached (${result.state}).` : "Boat is not attached.",
details: { sandboxId: result.sandboxId, state: result.state } };
}
if (parsed.driver === "sandbox") {
if (options.acquireSandboxRuntimeLease) {
if (!resolvedCompanyId) {
@@ -15,6 +15,7 @@
* and transport logic.
*/
import { createHash } from "node:crypto";
import type { Db } from "@paperclipai/db";
import type {
Environment,
@@ -203,6 +204,7 @@ export function environmentRunOrchestrator(
* Wraps the runtime driver's acquire call with standardized error handling.
*/
async function acquireLease(input: {
executionConfigurationKey?: string;
companyId: string;
environment: Environment;
issueId: string | null;
@@ -267,6 +269,7 @@ export function environmentRunOrchestrator(
localEnvironmentId: string;
adapterType: string;
adapterConfig?: Record<string, unknown>;
executionConfigurationKey?: string;
admittedLifecycleMode?: "warm" | "per_turn";
issueId: string | null;
heartbeatRunId: string;
@@ -289,6 +292,9 @@ export function environmentRunOrchestrator(
// Step 2: Acquire lease
const acquisitionInput = {
executionConfigurationKey: environment.driver === "computer" ? createHash("sha256").update(JSON.stringify([
input.adapterConfig, input.executionConfigurationKey, environment.config, environment.envVars,
])).digest("hex") : undefined,
companyId: input.companyId,
environment,
issueId: input.issueId,
@@ -305,14 +311,14 @@ export function environmentRunOrchestrator(
// when the admitted per-turn policy deliberately disables reusable leases.
const expected = input.reattachRemoteLease;
const lease = await environmentsSvc.getLeaseById(expected.leaseId);
if (!lease || environment.driver !== "sandbox" ||
if (!lease || (environment.driver !== "sandbox" && environment.driver !== "computer") ||
lease.companyId !== input.companyId || lease.environmentId !== environment.id ||
lease.heartbeatRunId !== input.heartbeatRunId || lease.issueId !== input.issueId ||
lease.executionWorkspaceId !== (input.persistedExecutionWorkspace?.id ?? null) ||
lease.metadata?.agentId !== input.agentId || lease.status !== "active" ||
lease.releasedAt !== null || lease.cleanupStatus !== null ||
(lease.expiresAt !== null && new Date(lease.expiresAt).getTime() <= Date.now()) ||
lease.provider !== environment.config.provider || lease.providerLeaseId !== expected.providerLeaseId ||
lease.provider !== (environment.driver === "computer" ? "boat" : environment.config.provider) || lease.providerLeaseId !== expected.providerLeaseId ||
lease.metadata?.remoteCwd !== expected.remoteCwd) {
throw new Error("native_remote_recovery_lease_mismatch");
}
@@ -517,8 +523,9 @@ export function environmentRunOrchestrator(
// Step 3: Persist realization metadata on lease and execution workspace
if (Object.keys(workspaceRealization).length > 0) {
const refreshedLease = environment.driver === "computer" ? await environmentsSvc.getLeaseById(lease.id) : null;
const nextLeaseMetadata = {
...(lease.metadata ?? {}),
...(refreshedLease?.metadata ?? lease.metadata ?? {}),
workspaceRealization,
};
const updatedLease = await environmentsSvc.updateLeaseMetadata(lease.id, nextLeaseMetadata);
@@ -594,6 +601,14 @@ export function environmentRunOrchestrator(
);
}
if (executionTarget?.kind === "remote" && executionTarget.transport === "computer" && persistedExecutionWorkspace) {
persistedExecutionWorkspace = await executionWorkspacesSvc.update(persistedExecutionWorkspace.id, {
cwd: executionTarget.remoteCwd,
providerRef: executionTarget.remoteCwd,
metadata: { ...(persistedExecutionWorkspace.metadata ?? {}),
fileAuthority: { ...executionTarget.fileAuthority, environmentId: environment.id } },
}) ?? persistedExecutionWorkspace;
}
return {
lease,
workspaceRealization,
@@ -1,3 +1,4 @@
import { createComputerEnvironmentDriver } from "./computer-environment-driver.js";
import { beginIdleTrackedWork } from "./task-admission.js";
import { hasStopOnlyCleanup, prepareSandboxStopAndRetain, readStopOnlyCleanup, settleStopOnlyCleanup, stopOnlyCleanupKey } from "./sandbox-stop-and-retain.js";
import { JsonRpcCallError, readEnvironmentAcquisitionDiagnostic, readEnvironmentCreationCleanupError } from "@paperclipai/plugin-sdk";
@@ -432,6 +433,8 @@ function stripSecretRefValuesFromPluginLeaseMetadata(input: {
}
export interface EnvironmentDriverAcquireInput {
/** Stable selected execution configuration; command-backed warm owners fence changes. */
executionConfigurationKey?: string;
companyId: string;
environment: Environment;
issueId: string | null;
@@ -3839,6 +3842,7 @@ export function environmentRuntimeService(
const defaultDrivers = [
createLocalEnvironmentDriver(db),
createSshEnvironmentDriver(db),
createComputerEnvironmentDriver(db),
createSandboxEnvironmentDriver(db, {
pluginWorkerManager: options.pluginWorkerManager,
pluginWorkerReadyTimeoutMs: options.pluginWorkerReadyTimeoutMs,
@@ -3977,6 +3981,7 @@ export function environmentRuntimeService(
},
async acquireRunLease(input: {
executionConfigurationKey?: string;
companyId: string;
environment: Environment;
issueId: string | null;
@@ -4015,6 +4020,7 @@ export function environmentRuntimeService(
});
const driver = requireDriver(input.environment);
const lease = await driver.acquireRunLease({
executionConfigurationKey: input.executionConfigurationKey,
companyId: input.companyId,
environment: input.environment,
issueId: input.issueId,
+5 -1
View File
@@ -7,9 +7,10 @@ export async function assertEnvironmentSelectionForCompany(
driver: string;
status?: string | null;
config: Record<string, unknown> | null;
metadata?: Record<string, unknown> | null;
} | null>;
},
_companyId: string,
companyId: string,
environmentId: string | null | undefined,
options?: {
allowedDrivers?: string[];
@@ -21,6 +22,9 @@ export async function assertEnvironmentSelectionForCompany(
if (!environment) {
throw unprocessable("Environment not found.");
}
if (environment.driver === "computer" && environment.metadata?.computerCompanyId !== companyId) {
throw unprocessable("Computer environment is not available in this company.");
}
if (environment.status === "archived") {
throw unprocessable("Environment is archived.");
}
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { githubBrokerEnvironment } from "@paperclipai/adapter-utils/github-launcher";
import { cleanupGitHubOperationLaunchers, prepareGitHubOperationLaunchers } from "@paperclipai/adapter-utils/execution-target";
import { adapterExecutionTargetIsCommandBacked, cleanupGitHubOperationLaunchers, prepareGitHubOperationLaunchers } from "@paperclipai/adapter-utils/execution-target";
type LauncherInput = Parameters<typeof prepareGitHubOperationLaunchers>[0];
@@ -21,12 +21,12 @@ export async function prepareHeartbeatGitHubLaunchers(
if (input.native && input.githubConfigured) {
return { env: githubBrokerEnvironment(input.env, { url: "", token: "" }), cleanupLocation: null };
}
// An unconfigured sandbox has no managed GitHub identity to broker. Its
// An unconfigured command-backed environment has no managed GitHub identity to broker. Its
// token-free wrappers still isolate image credentials, but may live as long
// as the workspace so a warm provider never inherits a deleted run path.
// Other adapter paths retain their run-scoped capability/retirement rules.
const anonymous = input.native && !input.githubConfigured &&
input.target?.kind === "remote" && input.target.transport === "sandbox";
adapterExecutionTargetIsCommandBacked(input.target);
const location = {
runId: anonymous
? `anonymous-${input.agentId}-${createHash("sha256").update(input.env.PATH ?? "").digest("hex").slice(0, 16)}`
+38 -11
View File
@@ -1,5 +1,6 @@
import { executeHeartbeatRuntime, NativeSessionResumeScheduledError, NativeWorkspaceFinalizeScheduledError } from "./heartbeat/runtime-execution.js";
import { selectHeartbeatRuntime } from "./heartbeat/runtime-selection.js";
import { readNativeComputerWorkspaceReference } from "./native-runtime/native-workspace-sync.js";
import { createHeartbeatRunCompletion } from "./heartbeat/run-completion.js";
export {
MAX_TURN_CONTINUATION_WAKE_REASON,
@@ -331,7 +332,7 @@ import {
startAdapterExecutionTargetPaperclipBridge,
} from "@paperclipai/adapter-utils/execution-target";
import { agentInstructionWorkingCopyService, collectStoppedInstructionCopyWithRetries, instructionWorkingCopyGuidance } from "./agent-instruction-working-copies.js";
import { preparePersistentAgentExecutionHome } from "./agent-file-store.js";
import fs from "node:fs/promises";
import path from "node:path";
import { execFile as execFileCallback } from "node:child_process";
@@ -3784,7 +3785,7 @@ export function heartbeatService(
{
useProjectWorkspace:
requestedExecutionWorkspaceMode !== "agent_default",
anchorWorkspace: requestedShouldReuseExisting && reusableExistingExecutionWorkspace?.strategyType === "git_worktree"
anchorWorkspace: selectedEnvironmentForConfig?.driver !== "computer" && requestedShouldReuseExisting && reusableExistingExecutionWorkspace?.strategyType === "git_worktree"
? await resolveReusedGitWorkspaceAnchor({
agent,
workspace: reusableExistingExecutionWorkspace,
@@ -3834,7 +3835,7 @@ export function heartbeatService(
repoRef: resolvedWorkspace.repoRef,
additionalWorkspaces: resolvedWorkspace.additionalWorkspaces,
} satisfies ExecutionWorkspaceInput;
await assertGitWorktreeBaseWorkspaceReady({
if (selectedEnvironmentForConfig?.driver !== "computer") await assertGitWorktreeBaseWorkspaceReady({
requestedExecutionWorkspaceMode,
config: hostExecutionWorkspaceConfig,
issue: issueRef,
@@ -3969,7 +3970,18 @@ export function heartbeatService(
executionWorkspace,
reusedExecutionWorkspace,
policy: resolvedWorkspaceReusePolicy,
} = isDotRun ? { executionWorkspace: { ...executionWorkspaceBase, strategy: "project_primary" as const, cwd: resolvedWorkspace.cwd, branchName: null, worktreePath: null, warnings: [], created: false, branchCreatedByRuntime: false } as RealizedExecutionWorkspace, reusedExecutionWorkspace: false, policy: workspaceReuseProvisioningPolicy } : await provisionExecutionWorkspaceForFreshnessDecision<RealizedExecutionWorkspace>(
} = (isDotRun || selectedEnvironmentForConfig?.driver === "computer") ? {
executionWorkspace: {
...executionWorkspaceBase,
strategy: selectedEnvironmentForConfig?.driver === "computer" ? latestWorkspaceStrategyType : "project_primary",
cwd: resolvedWorkspace.cwd,
branchName: selectedEnvironmentForConfig?.driver === "computer" && latestWorkspaceStrategyType === "git_worktree" && issueId
? reusableExistingExecutionWorkspace?.branchName ?? `paperclip/task-${issueId}` : null,
worktreePath: null, warnings: [], created: false, branchCreatedByRuntime: false,
} as RealizedExecutionWorkspace,
reusedExecutionWorkspace: false,
policy: workspaceReuseProvisioningPolicy,
} : await provisionExecutionWorkspaceForFreshnessDecision<RealizedExecutionWorkspace>(
{
requestedShouldReuseExisting,
existingExecutionWorkspaceId:
@@ -4084,7 +4096,7 @@ export function heartbeatService(
issueRef?.executionWorkspacePreference ?? null;
let issueExecutionWorkspaceModeForRun =
issueExecutionWorkspaceSettings?.mode ?? null;
const warmReusableExecutionWorkspace =
const warmReusableExecutionWorkspace = selectedEnvironmentForConfig?.driver === "computer" ||
selectedEnvironmentForConfig?.driver === "sandbox" &&
selectedEnvironmentConfigForFingerprint.reuseLease === true &&
selectedEnvironmentConfigForFingerprint.runnerLifecycleMode === "warm";
@@ -4365,7 +4377,7 @@ export function heartbeatService(
}
await bindIssueToPersistedExecutionWorkspace(persistedExecutionWorkspace);
const projectRepositoryPaths: string[] = [];
if (executionWorkspace.projectId && resolvedWorkspace.source === "project_primary" && !resolvedWorkspace.baseCwdFallback) {
if (selectedEnvironmentForConfig?.driver !== "computer" && executionWorkspace.projectId && resolvedWorkspace.source === "project_primary" && !resolvedWorkspace.baseCwdFallback) {
const repositoryRows = await db.select().from(projectWorkspaces).where(and(
eq(projectWorkspaces.companyId, agent.companyId),
eq(projectWorkspaces.projectId, executionWorkspace.projectId),
@@ -4402,7 +4414,7 @@ export function heartbeatService(
const remoteRecovery = runOptions.nativeRestartRecovery?.kind === "reattach_remote_runner"
? runOptions.nativeRestartRecovery : null;
const recoveryWorkspace = remoteRecovery
? readNativeWorkspaceSyncReference(parseObject(run.runnerProfileJson).nativeWorkspaceSync) : null;
? (readNativeWorkspaceSyncReference(parseObject(run.runnerProfileJson).nativeWorkspaceSync) ?? readNativeComputerWorkspaceReference(parseObject(run.runnerProfileJson).nativeComputerWorkspace)) : null;
if (remoteRecovery && (!recoveryWorkspace || remoteRecovery.runId !== run.id ||
recoveryWorkspace.providerLeaseId !== remoteRecovery.remote.providerLeaseId ||
recoveryWorkspace.remoteCwd !== remoteRecovery.remote.remoteCwd)) {
@@ -4414,6 +4426,11 @@ export function heartbeatService(
localEnvironmentId: localEnvironment.id,
adapterType: agent.adapterType,
adapterConfig: parseObject(agent.adapterConfig),
executionConfigurationKey: createHash("sha256").update(JSON.stringify([
config, agent.runtimeConfig, agent.permissions, managedAiRuntime?.sessionIdentity,
managedAiRuntime?.identity, githubSelection.configured, useHostGitHub, issueContext?.workMode,
context.refreshTools === true ? run.id : null,
])).digest("hex"),
admittedLifecycleMode: persistedNativeExecutionInput?.session.lifecyclePolicy.mode,
issueId: issueId ?? null,
heartbeatRunId: run.id,
@@ -4548,6 +4565,10 @@ export function heartbeatService(
// Preserve the host-owned source before adapter context can share lease
// metadata. A later copy-back failure must not adopt a rebound source.
const workspaceRestoreSource = structuredClone(realizationResult.lease);
if (executionTarget?.kind === "remote" && executionTarget.transport === "computer") {
executionWorkspace.cwd = executionTarget.remoteCwd;
if (executionWorkspace.strategy === "git_worktree") executionWorkspace.worktreePath = executionTarget.remoteCwd;
}
let instructionCopy: Awaited<ReturnType<typeof instructionCopies.prepare>> = null;
let instructionSave: Record<string, unknown> | null = null;
const instructionPreparationKey = createHash("sha256").update(JSON.stringify({
@@ -4745,6 +4766,11 @@ export function heartbeatService(
} else {
delete context.paperclipScratch;
}
await preparePersistentAgentExecutionHome(db, {
companyId: agent.companyId,
agentId: agent.id,
target: executionTarget,
});
const gitExecutionEnv = await prepareGitHubExecutionEnvironment({
target: executionTarget,
cwd: executionWorkspace.cwd,
@@ -4861,7 +4887,8 @@ export function heartbeatService(
branchName: executionWorkspace.branchName,
worktreePath: executionWorkspace.worktreePath,
realization: workspaceRealization,
agentHome: await (async () => {
agentHome: executionTarget?.kind === "remote" && executionTarget.transport === "computer"
? executionTarget.fileAuthority.agentHome : await (async () => {
const home = resolveDefaultAgentWorkspaceDir(agent.id);
await fs.mkdir(home, { recursive: true });
return home;
@@ -5307,7 +5334,7 @@ export function heartbeatService(
typeof entry[0] === "string" && typeof entry[1] === "string",
),
);
const runtimeServices = await ensureRuntimeServicesForRun({
const runtimeServices = selectedEnvironmentForConfig?.driver === "computer" ? [] : await ensureRuntimeServicesForRun({
db,
runId: run.id,
agent: {
@@ -5446,9 +5473,9 @@ export function heartbeatService(
const savedFileInput = parseObject(parseObject(run.runnerProfileJson).nativeExecutionInput);
const priorFileInput = Object.keys(savedFileInput).length ? savedFileInput : parseObject(parseObject(priorFileRun?.profile).nativeExecutionInput);
const priorWorkingCopy = parseObject(parseObject(parseObject(priorFileInput.runtimeContext).instructions).workingCopy);
const warmFiles = nativeRuntimeResolution.kind === "native" && (nativeRuntimeResolution.profile.backend === "codex_app_server" ||
const warmFiles = nativeRuntimeResolution.kind === "native" && ((executionTarget?.kind === "remote" && executionTarget.transport === "computer") || nativeRuntimeResolution.profile.backend === "codex_app_server" ||
(nativeRuntimeResolution.profile.backend === "acpx_runtime" && parseObject(agent.adapterConfig).acpxAgent === "cursor")) &&
(executionTarget?.kind === "remote" && executionTarget.transport === "sandbox"
(executionTarget?.kind === "remote" && (executionTarget.transport === "sandbox" || executionTarget.transport === "computer")
? executionTarget.runnerLifecyclePolicy?.mode === "warm"
: parseObject(agent.adapterConfig).lifecycleMode === "warm");
if (warmFiles && taskSessionForRun?.lastRunId) {
+2 -31
View File
@@ -91,7 +91,7 @@ import {
} from "../issue-queued-comment-queue.js";
import {
cancelNativeSession,
closeWarmNativeSessionsForEnvironment,
closeWarmNativeSessionsForRun,
} from "../native-runtime/index.js";
import { runningProcesses } from "../../adapters/index.js";
import { parseObject } from "../../adapters/utils.js";
@@ -292,36 +292,7 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
if (providerResourceDisposition === "destroy") {
const closeResult = await (
options.closeWarmNativeSessionsForRun ??
(async ({ runId, reason }) => {
const environmentIds = await db
.selectDistinct({ environmentId: environmentLeases.environmentId })
.from(environmentLeases)
.where(
and(
eq(environmentLeases.heartbeatRunId, runId),
eq(environmentLeases.status, "active"),
),
)
.then((rows) =>
rows.flatMap((row) =>
typeof row.environmentId === "string" &&
row.environmentId.length > 0
? [row.environmentId]
: [],
),
);
const aggregate = { closed: 0, busy: 0, failed: 0 };
for (const environmentId of environmentIds) {
const result = await closeWarmNativeSessionsForEnvironment({
environmentId,
reason,
});
aggregate.closed += result.closed;
aggregate.busy += result.busy;
aggregate.failed += result.failed;
}
return aggregate;
})
closeWarmNativeSessionsForRun
)({
runId: input.runId,
reason: "terminal heartbeat run destroyed its environment lease",
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -218,6 +219,44 @@ describe.skipIf(!support.supported)("heartbeat runtime execution boundary", () =
expectCleanup(input);
});
it.each([true, false])("validates computer worktrees remotely before finalization (matching branch: %s)", async matches => {
const { input, adapter } = await fixture();
const remoteCwd = "/home/user/paperclip/company/projects/project/task";
const runner: CommandManagedRuntimeRunner = {
execute: vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false, pid: null, startedAt: new Date().toISOString(),
stdout: matches ? "paperclip/task\n" : "other-task\n", stderr: "" })),
};
input.workspace.executionTarget = {
kind: "remote", transport: "computer", remoteCwd, runner, processRunner: runner,
listenerPort: 43100,
resourceAuthority: { kind: "computer-owner", computerId: randomUUID(), ownerId: randomUUID(), generation: 1 },
fileAuthority: { kind: "remote-persistent", placementId: randomUUID(), root: remoteCwd, agentHome: "/home/user/paperclip/company/agents/agent" },
launch: vi.fn(), inspectProcess: vi.fn(async () => ({ running: false, claim: null })),
retainWarm: vi.fn(), retire: vi.fn(async () => true), computerTool: { command: "computer", args: [] },
};
Object.assign(input.workspace.executionWorkspace, {
cwd: remoteCwd, worktreePath: remoteCwd, strategy: "git_worktree", branchName: "paperclip/task",
});
adapter.execute.mockImplementationOnce(async ctx => {
expect(ctx.stopRemoteStartup).toBeTypeOf("function");
await expect(ctx.stopRemoteStartup!()).rejects.toThrow("Remote startup stop requires a cancelled run");
expect(input.services.envOrchestrator.releaseForRun).not.toHaveBeenCalled();
return { exitCode: 0, signal: null, timedOut: false };
});
if (matches) {
await expect(executeHeartbeatRuntime(db, input)).resolves.toMatchObject({ dispatched: true });
expect(await finalizations()).toMatchObject([{ status: "succeeded" }]);
} else {
await expect(executeHeartbeatRuntime(db, input)).rejects.toThrow("computer_workspace_branch_mismatch");
expect(await finalizations()).toMatchObject([{ status: "failed" }]);
}
expect(runner.execute).toHaveBeenCalledOnce();
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
command: "git", args: ["symbolic-ref", "--quiet", "--short", "HEAD"], cwd: remoteCwd, bypassSession: true,
}));
expectCleanup(input);
});
it("retains a failed sandbox restore even when persisting its receipt throws", async () => {
const { input, adapter, run } = await fixture();
input.workspace.executionTarget = { kind: "remote", transport: "sandbox", remoteCwd: home };
@@ -265,6 +265,22 @@ export async function executeHeartbeatRuntime(db: Db, input: HeartbeatRuntimeExe
}
let adapterFinalizeOutcome: "succeeded" | "failed" | null = null;
const inspectFinalizeWorkspaceBranch = async () => {
// A remote pathname must never be inspected as a controller Git tree.
if (executionTarget?.kind === "remote" && executionTarget.transport === "computer") {
if (executionWorkspace.strategy === "git_worktree" && executionWorkspace.branchName) {
const branch = await executionTarget.runner!.execute({
command: "git",
args: ["symbolic-ref", "--quiet", "--short", "HEAD"],
cwd: executionTarget.remoteCwd,
timeoutMs: 10_000,
bypassSession: true,
});
if (branch.exitCode !== 0 || branch.timedOut || branch.stdout.trim() !== executionWorkspace.branchName) {
throw new Error("computer_workspace_branch_mismatch");
}
}
return null;
}
const workspaceRecord = persistedExecutionWorkspace?.id
? await executionWorkspacesSvc.getById(
persistedExecutionWorkspace.id,
@@ -828,7 +844,7 @@ export async function executeHeartbeatRuntime(db: Db, input: HeartbeatRuntimeExe
onProviderStopped: collectStoppedInstructions,
onDispatch: markDispatchStarted,
signal: executionControl.controller.signal,
...(executionTarget?.kind === "remote" && executionTarget.transport === "sandbox" ? {
...(executionTarget?.kind === "remote" && (executionTarget.transport === "sandbox" || executionTarget.transport === "computer") ? {
stopRemoteStartup: async () => {
// Scope comes from the running host invocation, never agent
// config. Keep adapter ownership until setup has unwound.
@@ -495,14 +495,15 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
: { mode: "per_turn" as const, idleTimeoutMs: null };
const environmentLifecyclePolicy =
executionTarget?.kind === "remote" &&
executionTarget.transport === "sandbox"
(executionTarget.transport === "sandbox" || executionTarget.transport === "computer")
? (executionTarget.runnerLifecyclePolicy ?? null)
: null;
// Native Codex owns a durable, session-scoped home. It flushes refreshed
// auth into each invocation's private home before that home is removed.
// Other managed harnesses still require per-turn credential cleanup.
const supportsManagedWarmSession = agent.adapterType === "paperclip_runner" &&
nativeRuntimeResolution.profile.backend === "codex_app_server";
(nativeRuntimeResolution.profile.backend === "codex_app_server" ||
executionTarget?.kind === "remote" && executionTarget.transport === "computer");
const effectiveLifecyclePolicy = persistedNativeExecutionInput?.session.lifecyclePolicy ??
(nativeRuntimeResolution.profile.backend === "openai_dot_mcp" || managedAiRuntime && !supportsManagedWarmSession
? { mode: "per_turn" as const, idleTimeoutMs: null }
@@ -864,8 +865,9 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
});
selectedLifecycleSpan.end();
}
providerResourceDispositionForRun =
nativeSandboxLifecycle?.sandboxResource === "keep_running"
providerResourceDispositionForRun = executionTarget?.kind === "remote" && executionTarget.transport === "computer"
? nativeExecution.session.lifecyclePolicy.mode === "warm" ? "keep_running" : "stop_and_retain"
: nativeSandboxLifecycle?.sandboxResource === "keep_running"
? "keep_running"
: nativeSandboxLifecycle?.sandboxResource === "stop_and_reuse"
? "stop_and_retain"
+14 -1
View File
@@ -3455,7 +3455,7 @@ export function createHeartbeatWorkspaceResolver(db: Db) {
agent: typeof agents.$inferSelect,
context: Record<string, unknown>,
previousSessionParams: Record<string, unknown> | null,
opts?: { useProjectWorkspace?: boolean | null },
opts?: { useProjectWorkspace?: boolean | null; executionEnvironmentDriver?: string | null },
): Promise<ResolvedAnchorWorkspaceForRun> {
const issueId =
readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
@@ -3507,6 +3507,19 @@ export function createHeartbeatWorkspaceResolver(db: Db) {
preferredProjectWorkspaceId,
);
if (opts?.executionEnvironmentDriver === "computer") {
const selected = projectWorkspaceRows[0];
// This is a stable intent path. Computer realization supplies its actual
// remote path without cloning, statting, or provisioning controller files.
return {
cwd: `/paperclip/remote/${agent.companyId}/${selected?.projectId ?? agent.id}`,
source: selected ? "project_primary" : "agent_home",
projectId: workspaceProjectId, workspaceId: selected?.id ?? null,
repoUrl: selected?.repoUrl ?? null, repoRef: selected?.repoRef ?? null,
workspaceHints: [], warnings: [], baseCwdFallback: false, materializationFailures: [],
};
}
const workspaceHints = projectWorkspaceRows.map((workspace) => ({
workspaceId: workspace.id,
cwd: readNonEmptyString(workspace.cwd),
+2
View File
@@ -225,6 +225,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableNativeRunner: parsed.data.enableNativeRunner ?? true,
enableAiConnectionRouters: parsed.data.enableAiConnectionRouters ?? false,
enableManagedSandboxOnly: parsed.data.enableManagedSandboxOnly ?? false,
enableBoatEnvironments: parsed.data.enableBoatEnvironments ?? false,
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
enableIsolatedWorkspacesByDefault: parsed.data.enableIsolatedWorkspacesByDefault ?? false,
enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true,
@@ -275,6 +276,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableNativeRunner: true,
enableAiConnectionRouters: false,
enableManagedSandboxOnly: false,
enableBoatEnvironments: false,
enableIsolatedWorkspaces: false,
enableIsolatedWorkspacesByDefault: false,
enableStreamlinedLeftNavigation: true,
@@ -25,7 +25,13 @@ export async function createNativeGitHubAccess(input: {
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}, startBridge = startAdapterExecutionTargetPaperclipBridge) {
const token = randomBytes(32).toString("hex");
const location = { runId: `native-session-${randomUUID()}`, target: input.target };
// The broker belongs to the retained process, while its activate() binding
// grants one run at a time. A computer attempt advances generation on each
// turn; polling must retain the exact process capability across that handoff.
const target = input.target?.kind === "remote" && input.target.transport === "computer"
? { ...input.target, runner: input.target.processRunner }
: input.target;
const location = { runId: `native-session-${randomUUID()}`, target };
let active: Binding | null = null;
let stopped = false;
let ready = true;
@@ -1,3 +1,4 @@
import { readNativeComputerWorkspaceReference } from "./native-workspace-sync.js";
import { readNativeWorkspaceSyncReference } from "./native-workspace-sync.js";
import { recordNativeLocalProcessStop } from "../native-local-process-stop.js";
import { randomUUID } from "node:crypto";
@@ -49,7 +50,8 @@ export type NativeRestartRecoveryClaim =
providerAttempt: number;
restartKind: NativeRestartKind;
recoveryRequestId: string | null;
remote: { providerLeaseId: string; remoteCwd: string };
remote: { providerLeaseId: string; remoteCwd: string;
computerOwner?: { computerId: string; ownerId: string; generation: number; listenerPort: number } };
}
| {
kind: "resume_dead_runner";
@@ -615,8 +617,8 @@ export async function claimNativeRestartRecoveries(input: {
const profile = row.run.runnerProfileJson ?? {};
const remoteWorkspace = readNativeWorkspaceSyncReference(
profile.nativeWorkspaceSync,
);
const remoteSandbox = profile.nativeWorkspaceSync != null;
) ?? readNativeComputerWorkspaceReference(profile.nativeComputerWorkspace);
const remoteSandbox = profile.nativeWorkspaceSync != null || profile.nativeComputerWorkspace != null;
const runnerPidAlive =
!remoteSandbox && processIsAlive(row.run.processPid);
const runnerGroupAlive =
@@ -1001,6 +1003,7 @@ export async function claimNativeRestartRecoveries(input: {
remote: {
providerLeaseId: remoteWorkspace!.providerLeaseId,
remoteCwd: remoteWorkspace!.remoteCwd,
...("computerOwner" in remoteWorkspace! ? { computerOwner: remoteWorkspace.computerOwner } : {}),
},
} satisfies NativeRestartRecoveryClaim;
}
@@ -1,3 +1,5 @@
import { prepareHeartbeatGitHubLaunchers } from "../heartbeat-github-launchers.js";
import { gunzipSync } from "node:zlib";
import { configuredEnvironmentProjection } from "../../vendor/paperclip-runner/index.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
@@ -47,7 +49,7 @@ import {
type NativeExecutionInput,
type PrpEvent,
} from "@paperclipai/paperclip-runner";
import { createHash, generateKeyPairSync, randomUUID } from "node:crypto";
import { createHash, generateKeyPairSync, randomBytes, randomUUID } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { nativeSha256 } from "./canonical.js";
import { agentDirectoryWorkingCopyService } from "../agent-directory-working-copies.js";
@@ -309,6 +311,7 @@ import {
buildNativeHarnessBackupManifest,
cancelNativeSession,
closeWarmNativeSessionsForEnvironment,
closeWarmNativeSessionsForRun,
claimWarmNativeInstructionCopy,
nativeSessionWorkspaceScope,
reserveWarmNativeInstructionDirectory,
@@ -330,6 +333,8 @@ import {
retainedNativeCleanupJournalMatches,
nativeProviderUsageLimitFromEvent,
nativeSessionFailureSourceCode,
nativeProviderRecoveryEvidence,
verifyPriorRunnerdStateForSessionScope,
nativeSessionRecoveryProjection,
nativeGovernedWaitResult,
nativeConversationReplyResult,
@@ -362,6 +367,8 @@ import {
semanticProviderPlanMarkdown,
sha256DirectoryTree,
stageRemoteRunnerDirectory,
stageRemoteRunnerFile,
nativeComputerToolForExecution,
steerNativeSession,
syncRemoteRunnerDirectoryOut,
verifyNativeHarnessBackup,
@@ -475,6 +482,28 @@ describe("remote controller restart adoption", () => {
};
return { execute, target };
}
it("requires the exact durable computer owner generation and port on restart", async () => {
const { execute, target: sandbox } = fixture();
const { sandboxLeaseAcquisition, ...common } = sandbox;
const inspectProcess = vi.fn(async () => ({ running: true, claim: { nonce: "process-claim" } }));
const target = { ...common, transport: "computer", listenerPort: 45101,
resourceAuthority: { kind: "computer-owner", computerId: "computer", ownerId: "owner", generation: 7 }, inspectProcess };
const computerClaim = { ...claim, remote: { ...claim.remote, providerLeaseId: "owner",
computerOwner: { computerId: "computer", ownerId: "owner", generation: 7, listenerPort: 45101 } } };
const adopted = await verifyRemoteRunnerReattachment({ claim: computerClaim, target: target as never,
identity, runId: "run", normalizedSessionId: "session" });
expect(adopted.pid).toBe(123);
expect(inspectProcess).toHaveBeenCalledOnce();
const readCount = execute.mock.calls.length;
await expect(verifyRemoteRunnerReattachment({ claim: { ...computerClaim, remote: {
...computerClaim.remote, computerOwner: { ...computerClaim.remote.computerOwner, generation: 6 } } }, target: target as never,
identity, runId: "run", normalizedSessionId: "session" })).rejects.toThrow("computer_process_recovery_mismatch");
expect(execute).toHaveBeenCalledTimes(readCount);
await expect(verifyRemoteRunnerReattachment({ claim: { ...computerClaim, remote: {
...computerClaim.remote, computerOwner: { ...computerClaim.remote.computerOwner, listenerPort: 45102 } } }, target: target as never,
identity, runId: "run", normalizedSessionId: "session" })).rejects.toThrow("computer_process_recovery_mismatch");
});
it("adopts the exact live sandbox process without spawning or copying state", async () => {
const { execute, target } = fixture();
const adopted = await verifyRemoteRunnerReattachment({
@@ -682,6 +711,94 @@ describe("remote runner launch fingerprint compatibility", () => {
});
describe("remote runner process supervision", () => {
it.each(["timeout", "ssh_failure", "rejected_rpc"] as const)(
"keeps the admitted process after a transient %s monitor failure", async failure => {
vi.useFakeTimers();
try {
let nonce = "";
let polls = 0;
const launch = vi.fn(async (request: { args: string[] }) => { nonce = request.args[4]!; });
const execute = vi.fn(async (request: { args: string[] }) => {
const label = request.args[2];
const result = { exitCode: 0, timedOut: false, signal: null, stdout: "", stderr: "" };
if (label === "paperclip-runner-process-identity") return { ...result,
stdout: `${nonce}\n4321\n2026-09-06T00:00:00.000Z\ncomputer-runner\nlinux:abcd-1234:100\n` };
if (label === "paperclip-runner-monitor") {
polls += 1;
if (polls === 1) {
if (failure === "rejected_rpc") throw new Error("SSH connection reset");
return { ...result, exitCode: failure === "timeout" ? 3 : 255, timedOut: failure === "timeout" };
}
return { ...result, exitCode: polls === 3 ? 4 : 0 };
}
if (label === "paperclip-runner-diagnostics") return result;
return { ...result, stdout: Buffer.from("{}").toString("base64") };
});
const launcher = createRemoteRunnerProcessLauncher({
target: { kind: "remote", transport: "computer", remoteCwd: "/home/user/project", launch } as never,
runner: { execute } as never, remoteBinary: "/runtime/runnerd", processIdentityPath: "/runtime/state/identity",
stateDirectory: "/runtime/state", diagnosticsDirectory: "/runtime/logs", runnerInstanceId: "computer-runner",
});
const handle = launcher({ command: "/controller/runnerd", args: ["--runner-id", "computer-runner"], cwd: "/controller", environment: {} });
let settled = false;
void handle.completion.then(() => { settled = true; });
await vi.advanceTimersByTimeAsync(1_100);
expect(polls).toBe(2);
expect(settled).toBe(false);
expect(launch).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(1_000);
await expect(handle.completion).resolves.toMatchObject({ stderr: "runner_remote_process_identity_mismatch" });
expect(polls).toBe(3);
} finally { vi.useRealTimers(); }
},
);
it("bounds inconclusive process monitoring without claiming the process exited", async () => {
vi.useFakeTimers();
try {
let nonce = "";
const launch = vi.fn(async (request: { args: string[] }) => { nonce = request.args[4]!; });
const execute = vi.fn(async (request: { args: string[] }) => ({
exitCode: request.args[2] === "paperclip-runner-monitor" ? 255 : 0,
timedOut: false, signal: null, stderr: "",
stdout: `${nonce}\n4321\n2026-09-06T00:00:00.000Z\ncomputer-runner\nlinux:abcd-1234:100\n`,
}));
const launcher = createRemoteRunnerProcessLauncher({
target: { kind: "remote", transport: "computer", remoteCwd: "/home/user/project", launch } as never,
runner: { execute } as never, remoteBinary: "/runtime/runnerd", processIdentityPath: "/runtime/state/identity",
stateDirectory: "/runtime/state", diagnosticsDirectory: "/runtime/logs", runnerInstanceId: "computer-runner",
});
const handle = launcher({ command: "/controller/runnerd", args: ["--runner-id", "computer-runner"], cwd: "/controller", environment: {} });
const completion = expect(handle.completion).rejects.toThrow("runner_remote_process_monitor_unavailable exitCode=255 timedOut=false");
await vi.advanceTimersByTimeAsync(2_100);
await completion;
expect(launch).toHaveBeenCalledOnce();
} finally { vi.useRealTimers(); }
});
it("launches a computer runner through owned process admission, never detached shell allocation", async () => {
let nonce = "";
const launch = vi.fn(async (request: { args: string[] }) => { nonce = request.args[4]!; return {}; });
const execute = vi.fn(async (request: { args: string[] }) => {
const label = request.args[2];
return { exitCode: label === "paperclip-runner-monitor" ? 3 : 0, timedOut: false, signal: null,
stdout: label === "paperclip-runner-process-identity" ? `${nonce}\n4321\n2026-09-06T00:00:00.000Z\ncomputer-runner\nlinux:abcd-1234:100\n`
: label === "paperclip-runner-diagnostics" ? "stopped" : Buffer.from('{}').toString('base64'), stderr: "" };
});
const launcher = createRemoteRunnerProcessLauncher({
target: { kind: "remote", transport: "computer", remoteCwd: "/home/user/project", launch } as never,
runner: { execute } as never, remoteBinary: "/runtime/runnerd", processIdentityPath: "/runtime/state/identity",
stateDirectory: "/runtime/state", diagnosticsDirectory: "/runtime/logs", runnerInstanceId: "computer-runner",
});
const handle = launcher({ command: "/controller/runnerd", args: ["--runner-id", "computer-runner"], cwd: "/controller", environment: {} });
await handle.completion;
expect(launch).toHaveBeenCalledOnce();
expect(launch.mock.calls[0]![0].args).toContain("/runtime/runnerd");
expect(launch.mock.calls[0]![0].args[1]).not.toContain("nohup");
expect(execute.mock.calls.every(([request]) => request.args[2] !== "paperclip-runner-launch")).toBe(true);
expect(handle.child.pid).toBe(4321);
});
it.each(["delivered", "sandbox_missing", "logging_failed"] as const)(
"detaches runnerd and contains asynchronous signal failures (%s)", async (signalOutcome) => {
let launchNonce = "";
@@ -2415,7 +2532,164 @@ describe("remote provider checkpoint snapshots", () => {
});
});
describe("computer tool execution authority", () => {
it.each(["standard", "planning", "ask"] as const)("only installs mutation-capable desktop tools for standard work (%s)", (workMode) => {
const tool = { command: "cua-driver", args: ["mcp"] };
const target = { kind: "remote", transport: "computer", computerTool: tool } as never;
const input = { task: { workMode }, executionMode: "default" } as NativeExecutionInput;
expect(nativeComputerToolForExecution(input, target)).toBe(workMode === "standard" ? tool : undefined);
expect(nativeComputerToolForExecution({ ...input, executionMode: "plan" } as NativeExecutionInput, target)).toBeUndefined();
expect(nativeComputerToolForExecution(input, { kind: "remote", transport: "sandbox" } as never)).toBeUndefined();
});
});
describe("remote provider checkpoint restores", () => {
it("budgets artifact upload time and retains redacted staging diagnostics", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-runner-file-"));
const sourcePath = join(root, "runner");
await writeFile(sourcePath, "fixture binary");
const execute = vi.fn(async () => ({ exitCode: 255, timedOut: true,
stdout: "", stderr: "Host key verification failed; Bearer abcdefghijkl" }));
try {
const stage = stageRemoteRunnerFile({
target: { kind: "remote", transport: "computer" } as never,
runner: { execute } as never, sourcePath, targetPath: "/private/runner", mode: 0o700,
});
await expect(stage).rejects.toThrow("exit=255 timedOut=true Host key verification failed");
await expect(stage).rejects.not.toThrow("abcdefghijkl");
expect(execute).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 180_000 }));
} finally { await rm(root, { recursive: true, force: true }); }
});
it.each([false, true])("uploads a binary in bounded chunks and preserves the installed binary on corruption=%s", async (corrupt) => {
const root = await mkdtemp(join(tmpdir(), "paperclip-runner-large-file-"));
const sourcePath = join(root, "source");
const targetPath = join(root, "remote runner's binary");
const bytes = randomBytes(9 * 1024 * 1024 + 17);
await writeFile(sourcePath, bytes);
await writeFile(targetPath, "previous verified binary");
let inFlight = 0;
let maxInFlight = 0;
const execute = vi.fn(async (request: { command: string; args: string[]; stdin?: string }) => {
expect(request.stdin?.length ?? 0).toBeLessThanOrEqual(Math.ceil(4 * 1024 * 1024 / 3) * 4);
inFlight++;
maxInFlight = Math.max(maxInFlight, inFlight);
try {
await new Promise((resolve) => setTimeout(resolve, 5));
const stdin = corrupt && request.stdin ? Buffer.from("corrupt").toString("base64") : request.stdin;
execFileSync(request.command, request.args, { input: stdin });
return { exitCode: 0, timedOut: false, stdout: "", stderr: "" };
} finally { inFlight--; }
});
try {
const stage = stageRemoteRunnerFile({
target: { kind: "remote", transport: "computer" } as never,
runner: { execute } as never, sourcePath, targetPath, mode: 0o700,
});
if (corrupt) {
await expect(stage).rejects.toThrow();
expect(await readFile(targetPath, "utf8")).toBe("previous verified binary");
} else {
await stage;
expect((await readFile(targetPath)).equals(bytes)).toBe(true);
expect((await lstat(targetPath)).mode & 0o777).toBe(0o700);
}
expect(maxInFlight).toBe(3);
expect((await readdir(root)).some((name) => name.includes(".upload-"))).toBe(false);
} finally { await rm(root, { recursive: true, force: true }); }
});
it("shares eight upload slots across concurrent artifact transfers", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-runner-upload-slots-"));
const sourcePath = join(root, "source");
await writeFile(sourcePath, randomBytes(20 * 1024 * 1024));
let active = 0;
let peak = 0;
const execute = vi.fn(async (request: { command: string; args: string[]; stdin?: string }) => {
if (request.stdin) { active++; peak = Math.max(peak, active); }
try {
if (request.stdin) await new Promise((resolve) => setTimeout(resolve, 25));
execFileSync(request.command, request.args, { input: request.stdin });
return { exitCode: 0, timedOut: false, stdout: "", stderr: "" };
} finally { if (request.stdin) active--; }
});
try {
await Promise.all(["one", "two"].map(target => stageRemoteRunnerFile({
target: { kind: "remote", transport: "computer" } as never,
runner: { execute } as never, sourcePath, targetPath: join(root, target), mode: 0o700,
})));
expect(peak).toBe(8);
expect(active).toBe(0);
expect((await readFile(join(root, "one"))).equals(await readFile(join(root, "two")))).toBe(true);
} finally { await rm(root, { recursive: true, force: true }); }
});
it.each([{ sizeMiB: 0, corrupt: false }, { sizeMiB: 65, corrupt: false }, { sizeMiB: 0, corrupt: true }])("stages a $sizeMiB MiB provider pack through a command-only computer runner (corrupt=$corrupt)", async ({ sizeMiB, corrupt }) => {
const root = await mkdtemp(join(tmpdir(), "paperclip-computer-pack-"));
const sourcePath = join(root, "source");
const targetPath = join(root, "remote pack's files");
const manifest = JSON.stringify({ artifactDigest: "exact-release-digest" });
try {
await mkdir(join(sourcePath, "bin"), { recursive: true });
await writeFile(join(sourcePath, "provider-pack.json"), manifest);
await writeFile(join(sourcePath, "bin", "provider"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
if (process.platform === "darwin") {
execFileSync("xattr", ["-w", "com.apple.metadata:paperclip-test", "fixture", join(sourcePath, "bin", "provider")]);
}
const chunk = randomBytes(1024 * 1024);
const expectedDigest = createHash("sha256");
const payload = await open(join(sourcePath, "payload.bin"), "w");
try {
for (let index = 0; index < sizeMiB; index++) {
await payload.write(chunk);
expectedDigest.update(chunk);
}
} finally { await payload.close(); }
const execute = vi.fn(async (request: { command: string; args: string[]; stdin?: string }) => {
expect(request.stdin?.length ?? 0).toBeLessThanOrEqual(Math.ceil(4 * 1024 * 1024 / 3) * 4);
const stdin = corrupt && request.stdin
? Buffer.from(request.stdin, "base64").subarray(0, 32).toString("base64")
: request.stdin;
execFileSync(request.command, request.args, { input: stdin });
return { exitCode: 0, timedOut: false, stdout: "", stderr: "" };
});
const runner = { execute };
const onProgress = vi.fn(async (_completed: number, _total: number) => undefined);
const staging = stageRemoteRunnerDirectory({
target: { kind: "remote", transport: "computer", remoteCwd: root, runner } as never,
runner: runner as never,
sourcePath,
targetPath,
mode: 0o700,
onProgress,
});
if (corrupt) {
await expect(staging).rejects.toThrow();
await expect(access(targetPath)).rejects.toThrow();
expect((await readdir(root)).some((name) => name.includes(".upload-"))).toBe(false);
return;
}
await staging;
expect(onProgress.mock.calls[0]![0]).toBe(0);
expect(onProgress.mock.calls.at(-1)![0]).toBe(onProgress.mock.calls.at(-1)![1]);
expect(await readFile(join(targetPath, "provider-pack.json"), "utf8")).toBe(manifest);
expect((await lstat(join(targetPath, "bin", "provider"))).mode & 0o100).toBe(0o100);
expect((await lstat(targetPath)).mode & 0o777).toBe(0o700);
const actualDigest = execFileSync("shasum", ["-a", "256", join(targetPath, "payload.bin")], { encoding: "utf8" }).split(" ")[0];
expect(actualDigest).toBe(expectedDigest.digest("hex"));
const uploads = execute.mock.calls.filter(([request]) => request.stdin);
expect(uploads).toHaveLength(sizeMiB ? 17 : 1);
if (sizeMiB === 0) {
const tarBytes = gunzipSync(Buffer.from(uploads[0]![0].stdin!, "base64"));
expect(tarBytes.includes(Buffer.from("/._provider"))).toBe(false);
}
expect(execute.mock.calls[0]![0]).toMatchObject({ bypassSession: true });
expect((await readdir(root)).some((name) => name.includes(".upload-"))).toBe(false);
} finally {
await rm(root, { recursive: true, force: true });
}
}, 60_000);
it("does not upload excluded Codex scratch trees or credentials", async () => {
const sourcePath = await mkdtemp(
join(tmpdir(), "paperclip-codex-restore-source-"),
@@ -7353,6 +7627,121 @@ describe("native warm session supervision", () => {
expect(canonicalNote).toBe("before shutdown\nafter shutdown");
});
it("retains durable computer owners before ack and closes only the requested run", async () => {
const owners: Array<{ runId: string; close: ReturnType<typeof vi.fn>; retainWarm: ReturnType<typeof vi.fn>; retire: ReturnType<typeof vi.fn> }> = [];
for (const suffix of ["a", "b"]) {
const name = `computer-owner-${suffix}`;
const current = { ...execution, binding: { ...execution.binding, runId: name, agentId: name, executionWorkspaceId: name },
session: { ...execution.session, normalizedSessionId: name, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 } } } as NativeExecutionInputV1;
const close = vi.fn(async () => undefined), retainWarm = vi.fn(async () => undefined), retire = vi.fn(async () => true);
const target = { kind: "remote" as const, transport: "computer" as const, environmentId: "shared-computer",
remoteCwd: `/home/user/${name}`, listenerPort: suffix === "a" ? 45001 : 45002,
resourceAuthority: { kind: "computer-owner", computerId: "computer", ownerId: name, generation: 1 },
fileAuthority: { kind: "remote-persistent", placementId: name, root: `/home/user/${name}`, agentHome: `/home/user/${name}/home` },
retainWarm, retire, inspectProcess: async () => ({ running: true, claim: {} }), launch: async () => ({}),
computerTool: { command: "cua-driver", args: ["mcp"] } };
state.execute.mockReset().mockImplementationOnce(async options => {
await options.onSession?.({ close });
return { result: { summary: "completed" }, terminal: { runTerminalState: "succeeded" }, turnId: name,
normalizedSessionId: name, providerSessionId: name, driverKind: "test", driverVersion: "1",
nativeEventCount: 1, highestContiguousSourceSeq: 1, usage: null };
});
await executePaperclipNativeSession({ db: leaseDb(current), execution: current, runnerInstanceId: name, runnerExecutionTarget: target as never });
expect(retainWarm).toHaveBeenCalledWith(60_000);
expect(retire).not.toHaveBeenCalled();
owners.push({ runId: name, close, retainWarm, retire });
}
const result = await closeWarmNativeSessionsForRun({ runId: owners[0]!.runId, reason: "cancel exact task" });
expect(result.closed).toBe(1);
expect(owners[0]!.close).toHaveBeenCalledOnce();
expect(owners[0]!.retire).toHaveBeenCalledOnce();
expect(owners[0]!.retire.mock.invocationCallOrder[0]).toBeLessThan(owners[0]!.close.mock.invocationCallOrder[0]!);
expect(owners[1]!.close).not.toHaveBeenCalled();
expect(owners[1]!.retire).not.toHaveBeenCalled();
await closeWarmNativeSessionsForRun({ runId: owners[1]!.runId, reason: "fixture cleanup" });
});
it.each(["model", "credential", "tools", "read-only"])("replaces a computer warm runner after %s configuration admits a fresh owner", async (change) => {
const name = `computer-config-change-${change}`;
const current = { ...execution, binding: { ...execution.binding, runId: `${name}-one`, agentId: name, executionWorkspaceId: name },
session: { ...execution.session, normalizedSessionId: name, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 } } } as NativeExecutionInputV1;
const firstClose = vi.fn(async () => undefined), secondClose = vi.fn(async () => undefined);
const firstRetire = vi.fn(async () => true), secondRetire = vi.fn(async () => true);
const target = { kind: "remote", transport: "computer", environmentId: name, remoteCwd: `/home/user/${name}`,
resourceAuthority: { kind: "computer-owner", computerId: name, ownerId: "prior-owner", generation: 1 },
retainWarm: vi.fn(async () => undefined), retire: firstRetire };
const result = { result: { summary: "completed" }, terminal: { runTerminalState: "succeeded" }, turnId: name,
normalizedSessionId: name, providerSessionId: name, driverKind: "test", driverVersion: "1", nativeEventCount: 1,
highestContiguousSourceSeq: 1, usage: null };
state.execute.mockReset().mockImplementationOnce(async options => {
await options.onSession?.({ close: firstClose }); return result;
}).mockImplementationOnce(async options => {
expect(options.existingSession).toBeUndefined();
expect(secondRetire).not.toHaveBeenCalled();
await options.onSession?.({ close: secondClose }); return result;
});
await executePaperclipNativeSession({ db: leaseDb(current), execution: current, runnerInstanceId: name, runnerExecutionTarget: target as never,
managedAiCredentialIdentity: "credential-before" });
const next = { ...current, binding: { ...current.binding, runId: `${name}-two` }, provider: change === "model" ? { ...current.provider, model: "changed-model" } : current.provider,
task: { ...current.task, workMode: change === "read-only" ? "ask" : "standard" } } as NativeExecutionInputV1;
await executePaperclipNativeSession({ db: leaseDb(next), execution: next, runnerInstanceId: name,
managedAiCredentialIdentity: change === "credential" ? "credential-after" : "credential-before",
refreshTools: change === "tools",
runnerExecutionTarget: { ...target, resourceAuthority: { ...target.resourceAuthority, ownerId: "fresh-owner" }, retire: secondRetire } as never });
expect(firstRetire).toHaveBeenCalledOnce();
expect(firstClose).toHaveBeenCalledOnce();
expect(secondRetire).not.toHaveBeenCalled();
await closeWarmNativeSessionsForRun({ runId: next.binding.runId, reason: "fixture cleanup" });
expect(secondRetire).toHaveBeenCalledOnce();
});
it("keeps a computer transport alive when its old idle timer loses to warm admission", async () => {
const name = "computer-idle-admission-race";
const current = { ...execution, binding: { ...execution.binding, runId: `${name}-one`, agentId: name, executionWorkspaceId: name },
session: { ...execution.session, normalizedSessionId: name, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 } } } as NativeExecutionInputV1;
const close = vi.fn(async () => undefined);
const detachControllerForRestart = vi.fn(async () => undefined);
const session = { close, detachControllerForRestart };
let generation = 1;
const retire = vi.fn(async () => generation === 1);
const target = { kind: "remote", transport: "computer", environmentId: name, remoteCwd: `/home/user/${name}`,
resourceAuthority: { kind: "computer-owner", computerId: name, ownerId: name, generation: 1 },
retainWarm: vi.fn(async () => undefined), retire };
const result = { result: { summary: "completed" }, terminal: { runTerminalState: "succeeded" }, turnId: name,
normalizedSessionId: name, providerSessionId: name, driverKind: "test", driverVersion: "1",
nativeEventCount: 1, highestContiguousSourceSeq: 1, usage: null };
state.execute.mockReset().mockImplementationOnce(async options => {
await options.onSession?.(session); return result;
}).mockImplementationOnce(async options => {
expect(options.existingSession).toBe(session);
await options.onSession?.(session); return result;
});
const environmentForRun = async (runId: string) => (await prepareHeartbeatGitHubLaunchers({
native: true, githubConfigured: false, agentId: name, runId, target: target as never,
cwd: target.remoteCwd, env: {}, brokerUrl: "http://127.0.0.1:3104",
createBrokerToken: () => { throw new Error("Anonymous computer must not mint per-run authority"); },
}, async (input) => input.env)).env;
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
await executePaperclipNativeSession({ db: leaseDb(current), execution: current, runnerInstanceId: name, runnerExecutionTarget: target as never,
runnerEnvironment: await environmentForRun(current.binding.runId) });
generation = 2; // The durable admission committed before local reservation.
await vi.advanceTimersByTimeAsync(60_000);
expect(retire).toHaveBeenCalledOnce();
expect(close).not.toHaveBeenCalled();
expect(detachControllerForRestart).not.toHaveBeenCalled();
const next = { ...current, binding: { ...current.binding, runId: `${name}-two` } };
const nextRetire = vi.fn(async () => true);
await executePaperclipNativeSession({ db: leaseDb(next), execution: next, runnerInstanceId: name,
runnerEnvironment: await environmentForRun(next.binding.runId),
runnerExecutionTarget: { ...target, resourceAuthority: { ...target.resourceAuthority, generation: 2 }, retire: nextRetire } as never });
await closeWarmNativeSessionsForRun({ runId: next.binding.runId, reason: "fixture cleanup" });
expect(nextRetire).toHaveBeenCalledOnce();
expect(detachControllerForRestart).toHaveBeenCalledOnce();
expect(close).not.toHaveBeenCalled();
} finally { vi.useRealTimers(); }
});
describe("managed directory warm checkpoints", () => {
const result = { result: { summary: "completed" }, terminal: { runTerminalState: "succeeded" },
turnId: "turn", normalizedSessionId: "managed", providerSessionId: "provider", driverKind: "test", driverVersion: "1",
@@ -9188,6 +9577,37 @@ describe("native session bounded recovery", () => {
).toBe("native_current_wake_comments_changed_after_read");
});
it.each([
[{}, [], "bootstrap_retry"],
[{ uncertain: true }, [], "ambiguous_state"],
[{}, [{ eventType: "turn.started" }], "ambiguous_state"],
])("classifies a platform mismatch without weakening persisted recovery evidence", async (checkpoint, events, mode) => {
const code = nativeSessionFailureSourceCode(
new Error("runner_remote_artifact_platform_mismatch: configure the remote binary"),
);
expect(code).toBe("runner_remote_artifact_platform_mismatch");
expect(nativeSessionFailureDisposition(1, new Date(), code)).toMatchObject({
phase: "terminal_failure",
failureCode: code,
nextAttemptAt: null,
});
let reads = 0;
const db = {
select: () => ({
from: () => ({
where: () => ({
limit: async () => ++reads === 1
? [{ runnerProfileJson: { sessionCheckpoint: checkpoint } }]
: events,
}),
}),
}),
};
expect(await nativeProviderRecoveryEvidence({
db: db as never, runId: "run", sourceFailureCode: code,
})).toMatchObject({ recoveryMode: mode });
});
it("requires operator action without retrying an approval-required terminal", () => {
const code = nativeSessionFailureSourceCode(
new NativeProviderTerminalFailure("approval_required", false, "Approval required"),
@@ -12935,6 +13355,76 @@ describe("runnerd provider runtime wiring", () => {
}
});
it.each([
{ owner: true, status: "succeeded", expected: "retained_warm_runner" },
{ owner: false, status: "succeeded", expected: "terminal_state_indeterminate" },
{ owner: true, status: "running", expected: "active" },
])("requires exact idle ownership for remote computer state ($owner, $status)", async ({ owner, status, expected }) => {
const root = await mkdtemp(join(tmpdir(), "paperclip-remote-warm-state-"));
const prior = execution;
const current = { ...execution, binding: { ...execution.binding, runId: "next-computer-run" } };
const db = { select: () => ({ from: () => ({ where: () => ({ limit: async () => [
{ status, runnerProfileJson: { nativeExecutionInput: prior } },
] }) }) }) } as unknown as Db;
try {
expect(await verifyPriorRunnerdStateForSessionScope({
db, root, execution: current,
identity: { runId: prior.binding.runId, normalizedSessionId: prior.session.normalizedSessionId!,
runnerInstanceId: "computer-runner", environmentLeaseId: "computer-lease" },
allowVerifiedBackup: false, remoteRunnerState: true, allowRetainedWarmRunner: owner,
})).toBe(expected);
} finally { await rm(root, { recursive: true, force: true }); }
});
it.each([
{ override: undefined, expected: "@openai/codex@0.160.0" },
{ override: "@openai/codex@0.159.0", expected: "@openai/codex@0.159.0" },
])("installs compatible computer Codex privately with override=$override", async ({ override, expected }) => {
const remoteExecute = vi.fn(async (command: { command: string; args?: string[] }) => {
const script = command.args?.[1] ?? "";
let stdout = "";
if (command.args?.[0] === "--build-metadata") {
stdout = JSON.stringify({
schema: "paperclip-runner/runnerd-build-metadata/v1",
binaryName: "paperclip-runnerd", packageName: "@paperclipai/paperclip-runner",
binaryContractVersion: 2,
durableSessionCapabilities: ["unlimited_runtime", "connection_lease_renewal"],
prpTransportModes: ["listen_ws"],
});
} else if (command.args?.[0] === "--version") {
if (command.command.includes("/harnesses/codex/")) throw new Error("reached-private-codex-verification");
stdout = "codex-cli 0.162.1";
} else if (script.includes("command -v paperclip-runnerd")) {
stdout = "/usr/local/bin/paperclip-runnerd\n";
} else if (script.includes("command -v codex")) {
stdout = "/usr/local/bin/codex\n";
}
return { exitCode: 0, signal: null, timedOut: false, stderr: "", stdout };
});
await createRunnerdBackend({
db: leaseDb(execution), execution, runnerInstanceId: "computer-codex-install",
runnerIngressAuthorized: true, runnerRemoteCodexNpmSpec: override,
runnerExecutionTarget: {
kind: "remote", transport: "computer", remoteCwd: "/workspace",
environmentId: "environment", leaseId: "lease", providerKey: "boat",
effectiveCapabilities: { runnerWebSocketIngress: true },
runner: { execute: remoteExecute }, processRunner: { execute: remoteExecute },
} as never,
});
state.createTransport.mockClear();
state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!();
const transport = state.createTransport.mock.calls[0]![0] as RunnerTransportOptions & {
controlPlaneRegistration: (authority: unknown) => Promise<unknown>;
};
await expect(transport.controlPlaneRegistration({})).rejects.toThrow("reached-private-codex-verification");
expect(remoteExecute).toHaveBeenCalledWith(expect.objectContaining({
command: "npm",
args: ["install", "--prefix", "/workspace/.paperclip-runtime/paperclip-runner/harnesses/codex", "--no-audit", "--no-fund", expected],
}));
expect(remoteExecute.mock.calls.filter(([command]) => command.command === "npm")).toHaveLength(1);
expect(remoteExecute.mock.calls.some(([command]) => command.args?.includes("--global"))).toBe(false);
});
it("binds a remote launch to the configured controller-owned runner artifact", async () => {
const remoteCwd = "/home/daytona/paperclip-workspace";
const controllerArtifact = "/controller/artifacts/paperclip-runnerd";
@@ -1,3 +1,4 @@
import { adapterExecutionTargetIsCommandBacked, type AdapterComputerExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { configuredEnvironment } from "../../vendor/paperclip-runner/index.js";
import { agents } from "@paperclipai/db";
import { isCloudManagedInstance } from "../cloud-instance.js";
@@ -48,9 +49,10 @@ import {
EXECUTION_CONTROL_DEADLINE_MS,
} from "../execution-control-deadline.js";
import { createHash, randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { execFile, execFileSync } from "node:child_process";
import {
chmodSync,
createReadStream,
copyFileSync,
closeSync,
constants,
@@ -441,6 +443,8 @@ export type NativeInstructionWorkingCopy = {
};
type WarmNativeSession = {
computerTarget?: AdapterComputerExecutionTarget;
runId: string;
instructionWorkingCopy?: NativeInstructionWorkingCopy;
// Keep the admitted physical root independent of the per-run collection
// capability, which instruction preparation adopts before final admission.
@@ -467,11 +471,26 @@ type WarmNativeSession = {
lastActivityAt: string;
};
class ComputerOwnerSupersededError extends Error {
constructor() { super("computer_owner_retirement_superseded"); }
}
async function closeWarmNativeSession(entry: WarmNativeSession, reason: string, preserveInstructionsForRunId?: string) {
// Computer admission can advance while an old idle timer is firing. Only
// the exact durable owner may stop the runner or its controller transport.
if (entry.computerTarget && !(await entry.computerTarget.retire())) {
throw new ComputerOwnerSupersededError();
}
// Revoke before awaiting process retirement/checkpoint IO.
const stopping = entry.githubAccess?.stop();
try {
try { await entry.session.close({ reason }); }
try {
if (entry.computerTarget && entry.session.detachControllerForRestart) {
await entry.session.detachControllerForRestart();
} else {
await entry.session.close({ reason });
}
}
catch (error) {
try { await entry.instructionWorkingCopy?.retirementFailed?.(); }
catch (receiptError) { throw new AggregateError([error, receiptError], "Warm instruction retirement and receipt both failed"); }
@@ -550,7 +569,7 @@ export async function claimWarmNativeInstructionCopy(input: {
function instructionTargetIdentity(target?: AdapterExecutionTarget | null): string {
return JSON.stringify(target?.kind === "remote" ? {
environmentId: target.environmentId, cwd: target.remoteCwd,
providerLeaseId: target.transport === "sandbox" ? target.sandboxLeaseAcquisition?.providerLeaseId : target.spec,
providerLeaseId: target.transport === "sandbox" ? target.sandboxLeaseAcquisition?.providerLeaseId : target.transport === "computer" ? [target.resourceAuthority.computerId, target.resourceAuthority.ownerId] : target.spec,
} : { kind: "local", environmentId: target?.environmentId });
}
@@ -563,6 +582,12 @@ export async function reserveWarmNativeInstructionDirectory(input: {
for (const [id, entry] of warmNativeSessions) {
if (entry.companyId !== input.companyId || entry.agentId !== input.agentId || entry.instructionCopy?.runId !== input.previousRunId) continue;
if (entry.busy || entry.preparingRunId) throw new Error("native_session_supervisor_busy");
if (input.target?.kind === "remote" && input.target.transport === "computer" &&
entry.computerTarget?.resourceAuthority.computerId === input.target.resourceAuthority.computerId &&
entry.computerTarget.resourceAuthority.ownerId === input.target.resourceAuthority.ownerId &&
entry.computerTarget.resourceAuthority.generation < input.target.resourceAuthority.generation) {
entry.computerTarget = input.target;
}
if (entry.idleTimer) clearTimeout(entry.idleTimer);
entry.idleTimer = null;
entry.preparingRunId = input.runId;
@@ -611,6 +636,11 @@ export async function closeWarmNativeSessionsForEnvironment(input: {
return closeIdleWarmNativeSessions(input);
}
/** Cancel one attempt without closing other agents on its computer. */
export async function closeWarmNativeSessionsForRun(input: { runId: string; reason: string }) {
return closeIdleWarmNativeSessions(input);
}
/** Suspend idle owners and persist their remote backup before a controller
* exits. Active turns keep their separate authenticated restart handoff. */
export async function closeIdleWarmNativeSessionsForRestart(): Promise<{
@@ -624,6 +654,7 @@ export async function closeIdleWarmNativeSessionsForRestart(): Promise<{
async function closeIdleWarmNativeSessions(input: {
environmentId?: string;
runId?: string;
reason: string;
closeBusyOnRelease?: boolean;
}): Promise<{ closed: number; busy: number; failed: number }> {
@@ -631,6 +662,7 @@ async function closeIdleWarmNativeSessions(input: {
let busy = 0;
let failed = 0;
for (const [sessionId, entry] of [...warmNativeSessions]) {
if (input.runId !== undefined && entry.runId !== input.runId) continue;
if (input.environmentId !== undefined && entry.environmentId !== input.environmentId) {
continue;
}
@@ -4243,13 +4275,14 @@ export function nativePreProviderRetryAfterCleanupStateIsSafe(input: {
}
}
async function verifyPriorRunnerdStateForSessionScope(input: {
export async function verifyPriorRunnerdStateForSessionScope(input: {
db: Db;
root: string;
identity: RunnerdDurableIdentity;
execution: NativeExecutionInput;
allowVerifiedBackup: boolean;
allowRetainedWarmRunner: boolean;
remoteRunnerState?: boolean;
}): Promise<PriorRunnerdStateVerification> {
let priorRun: {
status: string;
@@ -4303,7 +4336,7 @@ async function verifyPriorRunnerdStateForSessionScope(input: {
// there is intentionally no suspended failover backup yet. The exact
// idle in-memory session owner is the authority for this one case;
// after a restart that owner is absent and this remains fail-closed.
(input.allowVerifiedBackup && directLifecycle === "absent"))
((input.allowVerifiedBackup || input.remoteRunnerState) && directLifecycle === "absent"))
) {
return "retained_warm_runner";
}
@@ -4849,6 +4882,8 @@ async function migrateRunnerdStateRootForExecution(input: {
execution: input.execution,
allowVerifiedBackup: input.allowVerifiedBackup,
allowRetainedWarmRunner: input.allowRetainedWarmRunner,
remoteRunnerState: input.runnerExecutionTarget?.kind === "remote" &&
input.runnerExecutionTarget.transport === "computer",
});
if (
verification !== "verified" &&
@@ -4891,6 +4926,8 @@ async function migrateRunnerdStateRootForExecution(input: {
execution: input.execution,
allowVerifiedBackup: input.allowVerifiedBackup,
allowRetainedWarmRunner: input.allowRetainedWarmRunner,
remoteRunnerState: input.runnerExecutionTarget?.kind === "remote" &&
input.runnerExecutionTarget.transport === "computer",
});
if (
verification !== "verified" &&
@@ -5393,6 +5430,7 @@ function nativeSessionConfigDigest(
provider: execution.provider,
driverKind: execution.session.driverKind,
lifecyclePolicy: execution.session.lifecyclePolicy,
workMode: execution.task.workMode,
executionMode:
"executionMode" in execution ? execution.executionMode : "default",
runtimeContextDigest:
@@ -6165,6 +6203,8 @@ async function releaseWarmNativeSession(
} finally { entry.busy = false; }
return;
}
// Durable shared-computer ownership must be retained before completion is acknowledged.
await entry.computerTarget?.retainWarm(idleTimeoutMs);
entry.idleTimer = setTimeout(() => {
const current = warmNativeSessions.get(sessionId);
if (current !== entry || current.busy) return;
@@ -6172,7 +6212,16 @@ async function releaseWarmNativeSession(
current.closeOnReleaseReason = "warm native session idle timeout";
void closeWarmNativeSession(current, current.closeOnReleaseReason)
.then(() => { if (warmNativeSessions.get(sessionId) === current) warmNativeSessions.delete(sessionId); })
.catch(() => { if (!current.instructionWorkingCopy?.runId && warmNativeSessions.get(sessionId) === current) warmNativeSessions.delete(sessionId); })
.catch((error) => {
if (error instanceof ComputerOwnerSupersededError) {
// Admission owns the next generation. Leave this transport available
// for its imminent warm reservation instead of closing or deleting it.
current.closeOnReleaseReason = undefined;
current.idleTimer = null;
return;
}
if (!current.instructionWorkingCopy?.runId && warmNativeSessions.get(sessionId) === current) warmNativeSessions.delete(sessionId);
})
.finally(() => { current.busy = false; });
}, idleTimeoutMs);
entry.idleTimer.unref();
@@ -6201,6 +6250,7 @@ export function nativeSessionFailureDisposition(
sourceFailureCode === "native_provider_approval_required" ||
sourceFailureCode === "native_event_replay_conflict" ||
sourceFailureCode === "runner_remote_provider_artifact_incompatible" ||
sourceFailureCode === "runner_remote_artifact_platform_mismatch" ||
sourceFailureCode === "native_provider_terminal_failed" ||
sourceFailureCode === "native_current_wake_comments_unread" ||
sourceFailureCode === "native_current_wake_comments_changed_after_read" ||
@@ -6257,6 +6307,7 @@ export function nativeSessionFailureSourceCode(
| "native_session_cleanup_quarantined"
| "native_adopted_runner_authentication_timeout"
| "runner_remote_provider_artifact_incompatible"
| "runner_remote_artifact_platform_mismatch"
| "provider_process_exited"
| "provider_stdout_closed"
| "provider_process_output_closed"
@@ -6297,6 +6348,9 @@ export function nativeSessionFailureSourceCode(
if (/native_adopted_runner_authentication_timeout/i.test(message)) {
return "native_adopted_runner_authentication_timeout";
}
if (/^runner_remote_artifact_platform_mismatch(?::|$)/.test(message)) {
return "runner_remote_artifact_platform_mismatch";
}
if (/runner_remote_provider_artifact_incompatible/i.test(message)) {
return "runner_remote_provider_artifact_incompatible";
}
@@ -6424,6 +6478,7 @@ export async function nativeProviderRecoveryEvidence(input: {
ReturnType<typeof nativeSessionFailureSourceCode>
>([
"runner_remote_provider_artifact_incompatible",
"runner_remote_artifact_platform_mismatch",
"provider_process_exited",
"provider_stdout_closed",
"provider_process_output_closed",
@@ -8450,6 +8505,9 @@ async function executePaperclipNativeSessionWithinScope(
entry.credentialRunId !== input.execution.binding.runId);
if (
input.refreshTools === true ||
(input.runnerExecutionTarget?.kind === "remote" && input.runnerExecutionTarget.transport === "computer" &&
(entry.computerTarget?.resourceAuthority.computerId !== input.runnerExecutionTarget.resourceAuthority.computerId ||
entry.computerTarget?.resourceAuthority.ownerId !== input.runnerExecutionTarget.resourceAuthority.ownerId)) ||
entry.closeOnReleaseReason !== undefined ||
entry.configDigest !== warmConfigDigest ||
entry.configuredEnvironmentDigest !== configuredEnvironmentDigest ||
@@ -8495,6 +8553,8 @@ async function executePaperclipNativeSessionWithinScope(
entry.instructionPreparationRunId = undefined;
entry.instructionWorkingCopy = input.instructionWorkingCopy?.checkpointWarm ? undefined : input.instructionWorkingCopy;
entry.busy = true;
entry.runId = input.execution.binding.runId;
entry.computerTarget = input.runnerExecutionTarget?.kind === "remote" && input.runnerExecutionTarget.transport === "computer" ? input.runnerExecutionTarget : undefined;
entry.ownerToken = warmSessionOwnerToken;
entry.environmentId =
input.runnerExecutionTarget?.environmentId ?? null;
@@ -8861,6 +8921,8 @@ async function executePaperclipNativeSessionWithinScope(
? input.execution.binding.runId
: undefined,
session,
runId: input.execution.binding.runId,
computerTarget: input.runnerExecutionTarget?.kind === "remote" && input.runnerExecutionTarget.transport === "computer" ? input.runnerExecutionTarget : undefined,
ownerToken: warmSessionOwnerToken,
configDigest: warmConfigDigest,
configuredEnvironmentDigest,
@@ -8875,6 +8937,10 @@ async function executePaperclipNativeSessionWithinScope(
lastActivityAt: new Date().toISOString(),
});
const owner = warmNativeSessions.get(warmSessionId);
if (owner) {
owner.runId = input.execution.binding.runId;
owner.computerTarget = input.runnerExecutionTarget?.kind === "remote" && input.runnerExecutionTarget.transport === "computer" ? input.runnerExecutionTarget : undefined;
}
if (owner && input.instructionWorkingCopy?.runId && input.instructionWorkingCopy.checkpointWarm) owner.instructionCopy = {
runId: input.instructionWorkingCopy.runId, root: input.instructionWorkingCopy.root, targetIdentity: instructionTargetIdentity(input.runnerExecutionTarget),
collectStopped: input.instructionWorkingCopy.collectStopped,
@@ -10184,7 +10250,17 @@ export function assertRemoteRunnerBuildMetadata(
}
}
async function stageRemoteRunnerFile(input: {
export function nativeComputerToolForExecution(
execution: NativeExecutionInput,
target: AdapterExecutionTarget | null | undefined,
): AdapterComputerExecutionTarget["computerTool"] | undefined {
if (target?.kind !== "remote" || target.transport !== "computer" ||
execution.task.workMode !== "standard" ||
("executionMode" in execution && execution.executionMode === "plan")) return undefined;
return target.computerTool;
}
export async function stageRemoteRunnerFile(input: {
target: Extract<AdapterExecutionTarget, { kind: "remote" }>;
runner: CommandManagedRuntimeRunner;
sourcePath: string;
@@ -10208,20 +10284,97 @@ async function stageRemoteRunnerFile(input: {
]);
return;
}
const bytes = readFileSync(input.sourcePath);
const directory = posix.dirname(input.targetPath);
const script =
`umask 077; mkdir -p '${directory.replaceAll("'", "'\\''")}' && ` +
`base64 -d > '${input.targetPath.replaceAll("'", "'\\''")}' && ` +
`chmod ${input.mode.toString(8)} '${input.targetPath.replaceAll("'", "'\\''")}'`;
const result = await runner.execute({
command: "sh",
args: ["-c", script],
stdin: bytes.toString("base64"),
bypassSession: true,
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
const run = async (script: string, stdin?: string) => {
const result = await runner.execute({
command: "sh", args: ["-c", script], stdin,
bypassSession: true, timeoutMs: 180_000,
});
if (result.exitCode !== 0 || result.timedOut) {
throw new Error(`runner_remote_staging_failed: exit=${result.exitCode} timedOut=${result.timedOut}${result.stderr.trim() ? ` ${redactSensitiveText(result.stderr).trim().slice(-512)}` : ""}`);
}
};
if (lstatSync(input.sourcePath).size <= 4 * 1024 * 1024) {
await run(`umask 077; mkdir -p ${quote(posix.dirname(input.targetPath))} && ` +
`base64 -d > ${quote(input.targetPath)} && chmod ${input.mode.toString(8)} ${quote(input.targetPath)}`,
readFileSync(input.sourcePath).toString("base64"));
return;
}
const remoteUpload = `${input.targetPath}.upload-${randomUUID()}`;
try {
await uploadRemoteRunnerFileChunks({ sourcePath: input.sourcePath, remotePath: remoteUpload, run });
await run(`chmod ${input.mode.toString(8)} ${quote(remoteUpload)} && mv -f ${quote(remoteUpload)} ${quote(input.targetPath)}`);
} finally {
await run(`rm -f ${quote(remoteUpload)}`).catch(() => undefined);
}
}
const REMOTE_ARTIFACT_UPLOAD_CONCURRENCY = 8;
let activeRemoteArtifactUploads = 0;
const remoteArtifactUploadWaiters: Array<() => void> = [];
async function withRemoteArtifactUploadSlot(operation: () => Promise<void>): Promise<void> {
await new Promise<void>((resolve) => {
if (activeRemoteArtifactUploads < REMOTE_ARTIFACT_UPLOAD_CONCURRENCY) {
activeRemoteArtifactUploads++;
resolve();
} else remoteArtifactUploadWaiters.push(resolve);
});
if (result.exitCode !== 0 || result.timedOut) {
throw new Error("runner_remote_staging_failed");
try { await operation(); }
finally {
const next = remoteArtifactUploadWaiters.shift();
if (next) next();
else activeRemoteArtifactUploads--;
}
}
async function uploadRemoteRunnerFileChunks(input: {
sourcePath: string;
remotePath: string;
run: (script: string, stdin?: string) => Promise<void>;
onProgress?: (completedBytes: number, totalBytes: number) => Promise<void>;
}): Promise<void> {
const { run, remotePath } = input;
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
const pending: Promise<void>[] = [];
let uploadError: unknown;
try {
await run(`umask 077; mkdir -p ${quote(posix.dirname(remotePath))} && : > ${quote(remotePath)}`);
const digest = createHash("sha256");
const totalBytes = lstatSync(input.sourcePath).size;
let completedBytes = 0;
let progressAt = Date.now();
await input.onProgress?.(0, totalBytes);
let chunkIndex = 0;
const stream = createReadStream(input.sourcePath, { highWaterMark: 4 * 1024 * 1024 });
for await (const bytes of stream) {
const chunk = bytes as Buffer;
digest.update(chunk);
completedBytes += chunk.length;
// Disjoint fixed offsets permit bounded parallel upload and idempotent
// writes. The final digest rejects missing, duplicated, or partial bytes.
const offset = chunkIndex++;
pending.push(withRemoteArtifactUploadSlot(() => run(
`base64 -d | dd of=${quote(remotePath)} bs=4194304 seek=${offset} conv=notrunc 2>/dev/null`,
chunk.toString("base64"))).catch((error) => { uploadError ??= error; }));
if (pending.length === REMOTE_ARTIFACT_UPLOAD_CONCURRENCY) {
await Promise.all(pending);
pending.length = 0;
if (uploadError) throw uploadError;
if (Date.now() - progressAt >= 15_000) {
await input.onProgress?.(completedBytes, totalBytes);
progressAt = Date.now();
}
}
}
await Promise.all(pending);
if (uploadError) throw uploadError;
await input.onProgress?.(totalBytes, totalBytes);
const expectedDigest = digest.digest("hex");
await run(`test "$(if command -v sha256sum >/dev/null 2>&1; then sha256sum ${quote(remotePath)}; else shasum -a 256 ${quote(remotePath)}; fi | cut -d ' ' -f 1)" = ${quote(expectedDigest)}`);
} finally {
// Do not remove or rename the upload while another offset write is active.
await Promise.all(pending);
}
}
@@ -10252,6 +10405,7 @@ export async function stageRemoteRunnerDirectory(input: {
targetPath: string;
mode: number;
excludeEntries?: readonly string[];
onProgress?: (completedBytes: number, totalBytes: number) => Promise<void>;
}): Promise<void> {
const excludeArgs = archiveExcludeArgs(input.excludeEntries ?? []);
if (input.runner.syncIn) {
@@ -10289,24 +10443,35 @@ export async function stageRemoteRunnerDirectory(input: {
}
return;
}
const archive = execFileSync(
"tar",
[...excludeArgs, "-czf", "-", "-C", input.sourcePath, "."],
{ encoding: "buffer", maxBuffer: 64 * 1024 * 1024 },
);
const escapedTarget = input.targetPath.replaceAll("'", "'\\''");
const script =
`umask 077; mkdir -p '${escapedTarget}' && ` +
`base64 -d | tar -xzf - -C '${escapedTarget}' && ` +
`chmod ${input.mode.toString(8)} '${escapedTarget}'`;
const result = await input.runner.execute({
command: "sh",
args: ["-c", script],
stdin: archive.toString("base64"),
bypassSession: true,
});
if (result.exitCode !== 0 || result.timedOut) {
throw new Error("runner_remote_directory_staging_failed");
const stagingRoot = mkdtempSync(join(tmpdir(), "paperclip-runner-upload-"));
const archivePath = join(stagingRoot, "payload.tar.gz");
const remoteArchive = `${input.targetPath}.upload-${randomUUID()}.tar.gz`;
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
const run = async (script: string, stdin?: string) => {
const result = await input.runner.execute({
command: "sh", args: ["-c", script], stdin,
bypassSession: true, timeoutMs: 180_000,
});
if (result.exitCode !== 0 || result.timedOut) {
throw new Error(`runner_remote_directory_staging_failed: exit=${result.exitCode} timedOut=${result.timedOut}${result.stderr.trim() ? ` ${redactSensitiveText(result.stderr).trim().slice(-512)}` : ""}`);
}
};
try {
// Provider packs can exceed a gigabyte. Keep compression off the event loop
// and transfer from disk in bounded chunks instead of buffering the archive.
await new Promise<void>((resolve, reject) => {
execFile("tar", [...excludeArgs, "-czf", archivePath, "-C", input.sourcePath, "."],
{ maxBuffer: 1024 * 1024, env: { ...process.env, COPYFILE_DISABLE: "1" } },
(error) => error ? reject(error) : resolve());
});
await uploadRemoteRunnerFileChunks({
sourcePath: archivePath, remotePath: remoteArchive, run, onProgress: input.onProgress,
});
await run(`umask 077 && mkdir -p ${quote(input.targetPath)} && ` +
`tar -xzf ${quote(remoteArchive)} -C ${quote(input.targetPath)} && chmod ${input.mode.toString(8)} ${quote(input.targetPath)}`);
} finally {
rmSync(stagingRoot, { recursive: true, force: true });
await run(`rm -f ${quote(remoteArchive)}`).catch(() => undefined);
}
}
@@ -10681,19 +10846,23 @@ export async function verifyRemoteRunnerReattachment(input: {
}) {
const { claim, target, identity } = input;
if (
target?.kind !== "remote" ||
target.transport !== "sandbox" ||
!adapterExecutionTargetIsCommandBacked(target) ||
!target.runner ||
claim.runId !== input.runId ||
identity.runId !== input.runId ||
identity.normalizedSessionId !== input.normalizedSessionId ||
claim.remote.providerLeaseId !==
target.sandboxLeaseAcquisition?.providerLeaseId ||
target.sandboxLeaseAcquisition.outcome === "replacement" ||
claim.remote.providerLeaseId !== (target.transport === "computer" ? target.resourceAuthority.ownerId : target.sandboxLeaseAcquisition?.providerLeaseId) ||
(target.transport === "sandbox" && target.sandboxLeaseAcquisition?.outcome === "replacement") ||
claim.remote.remoteCwd !== target.remoteCwd
) {
throw new Error("native_remote_recovery_lease_mismatch");
}
if (target.transport === "computer") {
const owner = claim.remote.computerOwner;
if (!owner || owner.computerId !== target.resourceAuthority.computerId || owner.ownerId !== target.resourceAuthority.ownerId ||
owner.generation !== target.resourceAuthority.generation || owner.listenerPort !== target.listenerPort ||
!(await target.inspectProcess()).running) throw new Error("computer_process_recovery_mismatch");
}
const runner = target.runner;
const stateDirectory = posix.join(
target.remoteCwd,
@@ -10943,7 +11112,13 @@ export function createRemoteRunnerProcessLauncher(input: {
// into its own session instead; its own bounded diagnostics directory and
// durable PRP state remain the authorities, and the controller monitors
// the exact persisted process identity below.
const launchResult = await runner.execute({
const launchResult = input.target.transport === "computer"
? await input.target.launch({ command: "sh", args: ["-c", 'mkdir -p -- "$(dirname -- "$1")"; ' + REMOTE_RUNNER_CHILD_LAUNCH_SCRIPT,
"paperclip-runner-child", input.processIdentityPath, identityNonce, input.runnerInstanceId,
input.diagnosticsDirectory, input.remoteBinary, ...remoteArgs],
cwd: input.target.remoteCwd, env: processEnvironment(spec.environment) })
.then(() => ({ exitCode: 0, timedOut: false }))
: await runner.execute({
command: "sh",
args: [
"-c",
@@ -11016,6 +11191,7 @@ export function createRemoteRunnerProcessLauncher(input: {
attributes: { identitySource: "remote_marker", detached: true },
});
let consecutiveUnknownObservations = 0;
while (true) {
const observed = await runner.execute({
command: "sh",
@@ -11030,8 +11206,18 @@ export function createRemoteRunnerProcessLauncher(input: {
],
bypassSession: true,
timeoutMs: 10_000,
});
if (observed.exitCode === 0 && !observed.timedOut) {
}).catch(() => null);
const identityConfirmed = observed?.exitCode === 0 && !observed.timedOut;
const identityLost = observed !== null && !observed.timedOut
&& (observed.exitCode === 3 || observed.exitCode === 4);
if (!identityLost) {
// SSH/provider failures do not prove that runnerd exited. In particular,
// concurrent artifact uploads can delay a monitor RPC while PRP stays
// healthy. Retry unknown observations without allocating another runner.
consecutiveUnknownObservations = identityConfirmed ? 0 : consecutiveUnknownObservations + 1;
if (consecutiveUnknownObservations >= 3) {
throw new Error(`runner_remote_process_monitor_unavailable exitCode=${observed?.exitCode ?? "unknown"} timedOut=${observed?.timedOut ?? "unknown"}`);
}
await new Promise<void>((resolve) =>
setTimeout(resolve, REMOTE_RUNNER_PROCESS_POLL_MS),
);
@@ -11104,7 +11290,7 @@ export function resolveRemoteRunnerTransportMode(input: {
throw new Error("runner_transport_ineligible: remote target is required");
}
const requiredMode =
input.target.transport === "sandbox" &&
adapterExecutionTargetIsCommandBacked(input.target) &&
input.target.effectiveCapabilities?.runnerWebSocketIngress === true
? "listen_ws"
: "dial_wss";
@@ -11355,7 +11541,7 @@ async function createRunnerdBackendWithinSessionClaim(
spec: remoteTarget.spec,
defaultCwd: remoteTarget.remoteCwd,
})
: remoteTarget.runner
: remoteTarget.transport === "computer" ? remoteTarget.processRunner : remoteTarget.runner
: null;
if (remoteTarget && !remoteCommandRunner) {
throw new Error(
@@ -11555,7 +11741,10 @@ async function createRunnerdBackendWithinSessionClaim(
? input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || (useBundledRemoteImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary())
: resolvePaperclipRunnerBinary();
const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null;
const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() || null;
const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() ||
(remoteTarget?.transport === "computer" && !explicitRemoteCodex
? `@openai/codex@${REMOTE_PROVIDER_PACK_PINS.codex}`
: null);
if (explicitRemoteCodex && remoteCodexNpmSpec) {
throw new Error("runner_remote_codex_source_conflict");
}
@@ -12141,11 +12330,6 @@ async function createRunnerdBackendWithinSessionClaim(
if (!configuredProviderPackRoot) {
throw new Error("runner_remote_provider_artifact_incompatible: install the matching Paperclip package and Daytona image; the image provider pack did not match the bundled release identity");
}
if (!remoteCommandRunner.syncIn) {
throw new Error(
"runner_remote_provider_artifact_incompatible: this remote transport cannot stage a provider pack; preinstall the exact manifest-matched pack",
);
}
const escapedPackRoot = stagedRemoteProviderPackRoot.replaceAll(
"'",
"'\\''",
@@ -12162,12 +12346,16 @@ async function createRunnerdBackendWithinSessionClaim(
"runner_remote_provider_artifact_incompatible: stale provider pack could not be replaced",
);
}
await input.onLog?.("stderr", "[paperclip-runner] Preparing the provider pack for first use.\n");
await stageRemoteRunnerDirectory({
target: remoteTarget,
runner: remoteCommandRunner,
sourcePath: configuredProviderPackRoot,
targetPath: stagedRemoteProviderPackRoot,
mode: 0o700,
onProgress: async (completed, total) => {
await input.onLog?.("stderr", `[paperclip-runner] Uploading provider pack: ${Math.round(completed / 1024 / 1024)} / ${Math.round(total / 1024 / 1024)} MiB.\n`);
},
});
await measureNativeRunnerSpan(input.trace, "provider_pack.verify", () =>
verifyRemoteProviderPack(stagedRemoteProviderPackRoot),
@@ -12467,7 +12655,7 @@ async function createRunnerdBackendWithinSessionClaim(
timeoutMs: 10_000,
});
if (created.exitCode !== 0 || created.timedOut) {
throw new Error("runner_remote_directory_staging_failed");
throw new Error(`runner_remote_directory_staging_failed: exit=${created.exitCode} timedOut=${created.timedOut}${created.stderr.trim() ? ` ${redactSensitiveText(created.stderr).trim().slice(-512)}` : ""}`);
}
// Codex launch credentials are intentionally excluded from failover
@@ -13056,7 +13244,7 @@ async function createRunnerdBackendWithinSessionClaim(
HOME: posix.join(remoteRunnerFilesystemRoot!, "codex-home"),
CODEX_HOME: posix.join(remoteRunnerFilesystemRoot!, "codex-home"),
PAPERCLIP_WORKSPACE_CWD: remoteTarget!.remoteCwd,
...(remoteTarget!.transport === "sandbox"
...(adapterExecutionTargetIsCommandBacked(remoteTarget)
? { PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1" }
: {}),
}
@@ -13305,7 +13493,9 @@ async function createRunnerdBackendWithinSessionClaim(
baseInstructions: recoveryContext?.baseInstructions,
runnerFilesystemRoot: remoteRunnerFilesystemRoot ?? undefined,
resumeWorkingDirectory: runnerExecution.workspace.cwd,
externallySandboxed: remoteTarget?.transport === "sandbox",
externallySandboxed: adapterExecutionTargetIsCommandBacked(remoteTarget),
computerTool: nativeComputerToolForExecution(input.execution, remoteTarget),
persistentAgentHome: remoteTarget?.transport === "computer" ? remoteTarget.fileAuthority.agentHome : undefined,
opencodeRuntimeDirectory: remoteRunnerFilesystemRoot
? posix.join(remoteRunnerFilesystemRoot, "opencode")
: undefined,
@@ -743,6 +743,13 @@ const recoveryFakeCodex = resolve(
runnerDiagnostics.push(chunk.slice(-4_096));
if (runnerDiagnostics.length > 32) runnerDiagnostics.shift();
};
// The production resolver prefers installed Codex over PATH. Explicitly
// bind this disposable fixture so it can never invoke a real provider.
const codexCommandResolver = await vi.importActual<{ resolveCodexCommand: () => string }>(
"../../../../packages/paperclip-runner/src/drivers/codex/codex-command.js",
);
const resolveFixtureCodex = vi.spyOn(codexCommandResolver, "resolveCodexCommand")
.mockReturnValue(join(bin, "codex"));
process.env.PAPERCLIP_RUNNER_STATE_DIR = stateBase;
try {
await Promise.all(
@@ -1227,6 +1234,7 @@ const recoveryFakeCodex = resolve(
Promise.resolve().then(() => session.close({ reason: "Recovery fixture cleanup" })),
));
const closeFailures = closed.filter((result) => result.status === "rejected");
resolveFixtureCodex.mockRestore();
runnerPrpWebSocketInternals.resetForTests();
server.closeAllConnections();
await new Promise<void>((done) => server.close(() => done()));
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import { createReadStream } from "node:fs";
import path from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { environmentLeases, heartbeatRuns } from "@paperclipai/db";
import type { EnvironmentLease } from "@paperclipai/shared";
@@ -825,6 +825,21 @@ async function finalizePreparedRuntime(input: {
return reference;
}
/** Persistent remote bytes have no controller export/restore descriptor. */
export interface NativeComputerWorkspaceReference {
leaseId: string; providerLeaseId: string; remoteCwd: string;
computerOwner: { computerId: string; ownerId: string; generation: number; listenerPort: number };
}
export function readNativeComputerWorkspaceReference(value: unknown): NativeComputerWorkspaceReference | null {
const parsed = parseObject(value);
if (parsed.kind !== "remote-persistent" || typeof parsed.leaseId !== "string" ||
typeof parsed.providerLeaseId !== "string" || typeof parsed.remoteCwd !== "string" ||
typeof parsed.computerId !== "string" || !Number.isSafeInteger(parsed.ownerGeneration) || Number(parsed.ownerGeneration) < 1 ||
!Number.isInteger(parsed.listenerPort) || Number(parsed.listenerPort) < 1 || Number(parsed.listenerPort) > 65535) return null;
return { leaseId: parsed.leaseId, providerLeaseId: parsed.providerLeaseId, remoteCwd: parsed.remoteCwd,
computerOwner: { computerId: parsed.computerId, ownerId: parsed.providerLeaseId, generation: Number(parsed.ownerGeneration), listenerPort: Number(parsed.listenerPort) } };
}
export async function prepareNativeWorkspaceSync(input: {
db: Db;
runId: string;
@@ -837,6 +852,20 @@ export async function prepareNativeWorkspaceSync(input: {
sameRunRecovery?: boolean;
resourceDisposition?: NativeWorkspaceResourceDisposition;
}): Promise<PreparedNativeWorkspaceSync | null> {
if (input.target?.kind === "remote" && input.target.transport === "computer") {
const target = input.target;
if (input.restartRecovery?.kind === "reattach_remote_runner" &&
(input.restartRecovery.remote.providerLeaseId !== target.resourceAuthority.ownerId ||
input.restartRecovery.remote.remoteCwd !== target.remoteCwd)) throw new Error("native_remote_recovery_lease_mismatch");
const descriptor = { kind: "remote-persistent", leaseId: input.lease.id,
providerLeaseId: target.resourceAuthority.ownerId, computerId: target.resourceAuthority.computerId,
ownerGeneration: target.resourceAuthority.generation, listenerPort: target.listenerPort, remoteCwd: target.remoteCwd,
placementId: target.fileAuthority.placementId };
await input.db.update(heartbeatRuns).set({
runnerProfileJson: sql`coalesce(${heartbeatRuns.runnerProfileJson}, '{}'::jsonb) || ${JSON.stringify({ nativeComputerWorkspace: descriptor })}::jsonb`,
}).where(eq(heartbeatRuns.id, input.runId));
return null;
}
if (input.target?.kind !== "remote" || input.target.transport !== "sandbox") {
return null;
}
@@ -919,6 +919,10 @@ export class PaperclipRunnerToolAuthority {
return { bytes: Buffer.concat(chunks), filename: asset.originalFilename ?? "file", contentType: asset.contentType };
}
const resolved = await workspaceFileResourceService(this.db).prepareDownload(this.binding.issueId, { path: file.path!, workspace: "auto" });
if ("bytes" in resolved) {
if (resolved.bytes.length > RUNNER_API_MAX_BYTES) throw badRequest("Workspace file exceeds API transfer limit");
return { bytes: resolved.bytes, filename: resolved.resource.title, contentType: resolved.resource.contentType ?? "application/octet-stream" };
}
const handle = await openRunnerApiWorkspaceFile(resolved.realPath);
try {
const stat = await handle.stat();
@@ -0,0 +1,92 @@
import fs from "node:fs/promises";
import path from "node:path";
import { and, eq } from "drizzle-orm";
import { agents, environments, instanceSettings, type Db } from "@paperclipai/db";
import { computerService } from "../modules/computers/index.js";
import { HttpError } from "../errors.js";
import { instructionPath } from "./agent-instruction-files.js";
/** An assignment selects authority, never an arbitrary path supplied by a caller.
* The computer service separately checks the company attachment and placement. */
export async function persistentAgentFiles(db: Db, companyId: string, agentId: string, environmentId?: string) {
let selected = environmentId;
if (!selected) {
const [agent] = await db.select({ environmentId: agents.defaultEnvironmentId }).from(agents)
.where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)));
if (!agent) return null;
selected = agent.environmentId ?? undefined;
}
if (!selected) {
const [settings] = await db.select({ environmentId: instanceSettings.defaultEnvironmentId }).from(instanceSettings)
.where(eq(instanceSettings.singletonKey, "default"));
selected = settings?.environmentId ?? undefined;
}
if (!selected) return null;
const [environment] = await db.select({ driver: environments.driver, config: environments.config }).from(environments)
.where(eq(environments.id, selected));
if (environment?.driver !== "computer" || environment.config.provider !== "boat") return null;
return computerFileAccess(() => computerService(db).files({ companyId, agentId, environmentId: selected! }));
}
export function isMissingRemoteFile(error: unknown) {
const value = error as { status?: number; code?: string };
return value?.status === 404 || value?.code === "not_found";
}
export async function readPersistentAgentFile(files: NonNullable<Awaited<ReturnType<typeof persistentAgentFiles>>>, relative: string) {
try { return await files.readBytes(instructionPath(relative)); }
catch (error) { if (isMissingRemoteFile(error)) return null; throw error; }
}
/** First attachment only. Existing directories are adopted, including empty ones.
* Later turns never read/upload the controller copy of personal files. */
export async function seedPersistentAgentHome(files: NonNullable<Awaited<ReturnType<typeof persistentAgentFiles>>>, localRoot: string) {
try { await files.listPage("", { limit: 1 }); return; }
catch (error) { if (!isMissingRemoteFile(error)) throw error; }
const seed: Record<string, Buffer> = {};
let total = 0, count = 0;
async function walk(relative: string) {
const entries = await fs.readdir(path.join(localRoot, relative), { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT" && relative === "") return [];
throw error;
});
for (const entry of entries) {
const name = relative ? `${relative}/${entry.name}` : entry.name;
instructionPath(name);
if (++count > 100_000) throw new Error("Initial agent folder exceeds its entry limit");
const stat = await fs.lstat(path.join(localRoot, name));
if (stat.isDirectory()) await walk(name);
else if (stat.isFile() && stat.nlink === 1) {
total += stat.size;
if (stat.size > 256 * 1024 * 1024 || total > 2 * 1024 * 1024 * 1024) throw new Error("Initial agent folder exceeds its storage limit");
seed[name] = await fs.readFile(path.join(localRoot, name));
} else throw new Error("Agent folders cannot contain links or special files");
}
}
await walk("");
await files.seedBytes(seed);
}
/** Translate the domain's transport-neutral errors at the HTTP service edge. */
export async function computerFileAccess<T extends object>(open: () => Promise<T>): Promise<T> {
function translate(error: unknown): never {
const code = (error as { code?: string })?.code;
const status = code === "not_found" ? 404 : code === "forbidden" ? 403 : code === "conflict" ? 409
: code === "invalid" ? 422 : code === "provider_error" ? 502 : null;
if (status) throw new HttpError(status, error instanceof Error ? error.message : "Computer file operation failed", {
code: code === "conflict" ? "AGENT_FILE_CONFLICT" : code,
});
throw error;
}
let access: T;
try { access = await open(); } catch (error) { return translate(error); }
return new Proxy(access, {
get(target, key, receiver) {
const value = Reflect.get(target, key, receiver);
if (typeof value !== "function") return value;
return async (...args: unknown[]) => {
try { return await value.apply(target, args); } catch (error) { return translate(error); }
};
},
});
}
@@ -1,3 +1,5 @@
import { computerFileAccess } from "./persistent-agent-files.js";
import { computerService } from "../modules/computers/index.js";
import { issueReadSqlCondition, executionWorkspaceReadSqlCondition, projectReadSqlCondition, type AuthorizationActor } from "./authorization.js";
import fs from "node:fs/promises";
import path from "node:path";
@@ -45,6 +47,7 @@ export interface WorkspaceFileScanContext {
const DENIED_SEGMENTS = new Set([
".git",
".paperclip",
".paperclip-runtime",
"node_modules",
".pnpm-store",
".yarn",
@@ -121,6 +124,7 @@ type WorkspaceCandidate = {
label: string;
rootPath: string | null;
remote: boolean;
placement?: { companyId: string; environmentId: string; placementId: string };
};
type NormalizedPath = {
@@ -480,6 +484,13 @@ function candidateFromExecutionWorkspace(row: ExecutionWorkspaceRow): WorkspaceC
label: row.name,
rootPath,
remote,
...(row.metadata?.fileAuthority && typeof row.metadata.fileAuthority === "object" &&
(row.metadata.fileAuthority as Record<string, unknown>).kind === "remote-persistent" &&
typeof (row.metadata.fileAuthority as Record<string, unknown>).environmentId === "string" &&
typeof (row.metadata.fileAuthority as Record<string, unknown>).placementId === "string"
? { placement: { companyId: row.companyId,
environmentId: (row.metadata.fileAuthority as Record<string, string>).environmentId!,
placementId: (row.metadata.fileAuthority as Record<string, string>).placementId! } } : {}),
};
}
@@ -1038,7 +1049,76 @@ async function listChangedWorkspaceFiles(input: {
};
}
export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor) {
export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor, options: { allowComputerAccess?: boolean } = {}) {
async function placementFiles(candidate: WorkspaceCandidate) {
// Computer reads acquire a bounded machine owner and may resume it. Pure
// inspection transactions must remain read-only, including provider effects.
if (options.allowComputerAccess === false) throw unprocessable("Computer access is unavailable in read-only inspection", { code: "remote_workspace" });
if (!candidate.placement) throw unprocessable("Workspace placement is unavailable", { code: "remote_workspace" });
return computerFileAccess(() => computerService(db).workspaceFiles(candidate.placement!));
}
async function statPersistentCandidate(candidate: WorkspaceCandidate, normalized: NormalizedPath, directory = false) {
throwIfDenied(normalized.segments);
const files = await placementFiles(candidate);
if (directory) {
if (normalized.relativePath) {
if ((await files.stat(normalized.relativePath)).kind !== "directory") throw notFound("Workspace directory not found");
} else {
await files.listPage("", { limit: 1 });
}
return directoryResource({ candidate, relativePath: normalized.relativePath });
}
const entry = await files.stat(normalized.relativePath);
if (entry.kind !== "file") throw notFound("Workspace file not found");
const item = listItemFromStat({ candidate, relativePath: normalized.relativePath,
stat: { size: entry.size, mtime: new Date(entry.mtimeMs) } })!;
const { relativePath: _relative, modifiedAt: _modified, ...resource } = item;
return { ...resource, denialReason: item.capabilities.preview ? null : entry.size > previewCapForKind(item.previewKind) ? "too_large" : "unsupported_content" } as ResolvedWorkspaceResource;
}
async function persistentContent(candidate: WorkspaceCandidate, normalized: NormalizedPath): Promise<WorkspaceFileContent> {
const resource = await statPersistentCandidate(candidate, normalized);
if (!resource.capabilities.preview) throw unprocessable("Workspace file cannot be previewed", { code: resource.denialReason ?? "unsupported_content" });
const { bytes } = await (await placementFiles(candidate)).readBytes(normalized.relativePath, previewCapForKind(resource.previewKind));
if (resource.previewKind === "text" && !looksLikeText(bytes.subarray(0, TEXT_SNIFF_BYTES))) throw unprocessable("Workspace file is not text", { code: "binary_content" });
return { resource, content: { encoding: resource.previewKind === "text" ? "utf8" : "base64", data: bytes.toString(resource.previewKind === "text" ? "utf8" : "base64") } };
}
async function persistentList(candidate: WorkspaceCandidate, options: {
selector: WorkspaceFileSelector; mode: WorkspaceFileListMode; normalizedPath: NormalizedPath | null;
q: string | null; normalizedQuery: string | null; limit: number; offset: number;
}): Promise<WorkspaceFileListResponse> {
const { selector, mode, normalizedPath, q, normalizedQuery, limit, offset } = options;
if (mode === "changed") return unavailableFileList({ selector, mode, path: normalizedPath?.relativePath ?? null, q, limit, offset, candidate, reason: "changed_unavailable" });
if (normalizedPath) throwIfDenied(normalizedPath.segments);
const files = await placementFiles(candidate);
const items: WorkspaceFileListItem[] = [];
let scannedCount = 0, truncated = false;
const recursive = mode === "recent" || Boolean(normalizedQuery);
async function walk(relative: string, depth: number) {
if (depth > MAX_LIST_DEPTH) { truncated = true; return; }
const remaining = WORKSPACE_FILE_LIST_MAX_SCANNED_ENTRIES - scannedCount;
if (remaining <= 0) { truncated = true; return; }
const page = await files.listPage(relative || undefined, { limit: Math.min(1000, remaining) });
truncated ||= page.truncated;
for (const entry of page.entries) {
if (++scannedCount > WORKSPACE_FILE_LIST_MAX_SCANNED_ENTRIES) { truncated = true; return; }
const name = relative ? `${relative}/${entry.name}` : entry.name;
const normalized = normalizeWorkspaceRelativePath(name);
if (shouldPruneSegments(normalized.segments)) continue;
if (entry.kind === "directory") {
if (recursive) await walk(name, depth + 1);
else items.push(listItemFromDirectory({ candidate, relativePath: name, stat: { mtime: new Date(entry.mtimeMs) } }));
} else if (matchesSearch(name, normalizedQuery)) {
items.push(listItemFromStat({ candidate, relativePath: name, stat: { size: entry.size, mtime: new Date(entry.mtimeMs) } })!);
}
if (scannedCount >= WORKSPACE_FILE_LIST_MAX_SCANNED_ENTRIES) { truncated = true; return; }
}
}
await walk(normalizedPath?.relativePath ?? "", 0);
items.sort((a, b) => mode === "recent" ? String(b.modifiedAt).localeCompare(String(a.modifiedAt)) :
(a.kind === b.kind ? a.relativePath.localeCompare(b.relativePath) : a.kind === "directory" ? -1 : 1));
return availableFileList({ selector, mode, path: normalizedPath?.relativePath ?? null, q, limit, offset, candidate,
items: items.slice(offset, offset + limit), scannedCount, truncated: truncated || items.length > offset + limit });
}
async function visibleCandidates(candidates: WorkspaceCandidate[]) {
if (!actor) return candidates;
const visible: WorkspaceCandidate[] = [];
@@ -1305,6 +1385,7 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor)
const hasExplicitTarget = Boolean(explicitTarget?.candidate);
let lastNotFound: unknown = null;
for (const candidate of candidates) {
if (candidate.placement) return availabilityResult(item.query, await statPersistentCandidate(candidate, item.normalizedPath, item.directory));
if (candidate.remote) {
if (hasExplicitTarget || item.query.workspace !== "auto") {
return availabilityResult(item.query, remoteResource(candidate, item.normalizedPath.relativePath));
@@ -1373,6 +1454,7 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor)
let lastNotFound: unknown = null;
for (const candidate of candidates) {
if (candidate.placement) return statPersistentCandidate(candidate, normalized, isDirectoryRequest);
if (candidate.remote) {
if (explicitTarget || selector !== "auto") return remoteResource(candidate, normalized.relativePath);
continue;
@@ -1431,6 +1513,7 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor)
let firstUnavailable: { candidate: WorkspaceCandidate; reason: string } | null = null;
let lastNotFound: unknown = null;
for (const candidate of candidates) {
if (candidate.placement) return persistentList(candidate, { selector, mode, normalizedPath, q, normalizedQuery, limit, offset });
if (candidate.remote) {
firstUnavailable ??= { candidate, reason: "remote_workspace" };
if (explicitTarget || selector !== "auto") {
@@ -1637,6 +1720,7 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor)
let lastNotFound: unknown = null;
for (const candidate of candidates) {
if (candidate.placement) return persistentContent(candidate, normalized);
if (candidate.remote) {
if (explicitTarget || selector !== "auto") {
throw unprocessable("Remote workspaces cannot be previewed by the server", { code: "remote_workspace" });
@@ -1709,7 +1793,7 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor)
workspace?: WorkspaceFileSelector | null;
projectId?: string | null;
workspaceId?: string | null;
}, opts: { issue?: IssueRow } = {}): Promise<LocalResolvedFile> {
}, opts: { issue?: IssueRow } = {}): Promise<LocalResolvedFile | { resource: ResolvedWorkspaceResource; bytes: Buffer }> {
const issue = opts.issue ?? await getIssue(issueId);
const selector = input.workspace ?? "auto";
const explicitTarget = Boolean(input.projectId || input.workspaceId);
@@ -1721,6 +1805,11 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor)
let lastNotFound: unknown = null;
for (const candidate of candidates) {
if (candidate.placement) {
const resource = await statPersistentCandidate(candidate, normalized);
const { bytes } = await (await placementFiles(candidate)).readBytes(normalized.relativePath);
return { resource: { ...resource, byteSize: bytes.length }, bytes };
}
if (candidate.remote) {
if (explicitTarget || selector !== "auto") {
throw unprocessable("Remote workspaces cannot be downloaded by the server", { code: "remote_workspace" });
+1
View File
@@ -181,6 +181,7 @@ const environmentCapabilities: EnvironmentCapabilities = {
local: "supported",
ssh: "supported",
sandbox: "supported",
computer: "supported",
plugin: "supported",
},
sandboxProviders: {
@@ -76,6 +76,7 @@ const PAPERCLIP_RUNNER_TOGGLE_SELECTOR =
function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
return {
enableEnvironments: false,
enableBoatEnvironments: false,
enableNativeRunner: false,
enableAiConnectionRouters: false,
enableManagedSandboxOnly: false,
@@ -244,6 +244,7 @@ export function InstanceExperimentalSettings() {
const showDeveloperSection = showWorktreeRunExecution || ([
"autoRestartDevServerWhenIdle",
"enableManagedSandboxOnly",
"enableBoatEnvironments",
"enablePaperclipDeveloperMode",
"enableServerInfoDebugView",
"enableSmokeLab",
@@ -607,6 +608,17 @@ export function InstanceExperimentalSettings() {
ariaLabel="Toggle guarded dev-server auto-restart"
/>
<ExperimentalToggleCard
title="Boat Environments"
description="Attach an existing Boat to run agents with persistent files and a shared desktop."
checked={experimentalQuery.data?.enableBoatEnvironments === true}
onCheckedChange={(checked) => toggleMutation.mutate({ enableBoatEnvironments: checked })}
disabled={toggleMutation.isPending}
settingKey="enableBoatEnvironments"
managed={managedKeys.enableBoatEnvironments}
ariaLabel="Toggle Boat environments"
/>
<ExperimentalToggleCard
title="Managed Environment Only"
description="Hide the local environment and run all agents in the platform-managed environment."