mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
Add sandbox environment support (#4415)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The environment/runtime layer decides where agent work executes and how the control plane reaches those runtimes. > - Today Paperclip can run locally and over SSH, but sandboxed execution needs a first-class environment model instead of one-off adapter behavior. > - We also want sandbox providers to be pluggable so the core does not hardcode every provider implementation. > - This branch adds the Sandbox environment path, the provider contract, and a deterministic fake provider plugin. > - That required synchronized changes across shared contracts, plugin SDK surfaces, server runtime orchestration, and the UI environment/workspace flows. > - The result is that sandbox execution becomes a core control-plane capability while keeping provider implementations extensible and testable. ## What Changed - Added sandbox runtime support to the environment execution path, including runtime URL discovery, sandbox execution targeting, orchestration, and heartbeat integration. - Added plugin-provider support for sandbox environments so providers can be supplied via plugins instead of hardcoded server logic. - Added the fake sandbox provider plugin with deterministic behavior suitable for local and automated testing. - Updated shared types, validators, plugin protocol definitions, and SDK helpers to carry sandbox provider and workspace-runtime contracts across package boundaries. - Updated server routes and services so companies can create sandbox environments, select them for work, and execute work through the sandbox runtime path. - Updated the UI environment and workspace surfaces to expose sandbox environment configuration and selection. - Added test coverage for sandbox runtime behavior, provider seams, environment route guards, orchestration, and the fake provider plugin. ## Verification - Ran locally before the final fixture-only scrub: - `pnpm -r typecheck` - `pnpm test:run` - `pnpm build` - Ran locally after the final scrub amend: - `pnpm vitest run server/src/__tests__/runtime-api.test.ts` - Reviewer spot checks: - create a sandbox environment backed by the fake provider plugin - run work through that environment - confirm sandbox provider execution does not inherit host secrets implicitly ## Risks - This touches shared contracts, plugin SDK plumbing, server runtime orchestration, and UI environment/workspace flows, so regressions would likely show up as cross-layer mismatches rather than isolated type errors. - Runtime URL discovery and sandbox callback selection are sensitive to host/bind configuration; if that logic is wrong, sandbox-backed callbacks may fail even when execution succeeds. - The fake provider plugin is intentionally deterministic and test-oriented; future providers may expose capability gaps that this branch does not yet cover. ## Model Used - OpenAI Codex coding agent on a GPT-5-class backend in the Paperclip/Codex harness. Exact backend model ID is not exposed in-session. Tool-assisted workflow with shell execution, file editing, git history inspection, and local test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
641eb44949
commit
70679a3321
91 changed files with 10469 additions and 1498 deletions
|
|
@ -1,5 +1,9 @@
|
|||
import path from "node:path";
|
||||
import type { SshRemoteExecutionSpec } from "./ssh.js";
|
||||
import {
|
||||
prepareCommandManagedRuntime,
|
||||
type CommandManagedRuntimeRunner,
|
||||
} from "./command-managed-runtime.js";
|
||||
import {
|
||||
buildRemoteExecutionSessionIdentity,
|
||||
prepareRemoteManagedRuntime,
|
||||
|
|
@ -31,9 +35,22 @@ export interface AdapterSshExecutionTarget {
|
|||
spec: SshRemoteExecutionSpec;
|
||||
}
|
||||
|
||||
export interface AdapterSandboxExecutionTarget {
|
||||
kind: "remote";
|
||||
transport: "sandbox";
|
||||
providerKey?: string | null;
|
||||
environmentId?: string | null;
|
||||
leaseId?: string | null;
|
||||
remoteCwd: string;
|
||||
paperclipApiUrl?: string | null;
|
||||
timeoutMs?: number | null;
|
||||
runner?: CommandManagedRuntimeRunner;
|
||||
}
|
||||
|
||||
export type AdapterExecutionTarget =
|
||||
| AdapterLocalExecutionTarget
|
||||
| AdapterSshExecutionTarget;
|
||||
| AdapterSshExecutionTarget
|
||||
| AdapterSandboxExecutionTarget;
|
||||
|
||||
export type AdapterRemoteExecutionSpec = SshRemoteExecutionSpec;
|
||||
|
||||
|
|
@ -84,7 +101,8 @@ 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;
|
||||
return false;
|
||||
if (parsed.transport !== "sandbox") return false;
|
||||
return readStringMeta(parsed, "remoteCwd") !== null;
|
||||
}
|
||||
|
||||
export function adapterExecutionTargetToRemoteSpec(
|
||||
|
|
@ -102,10 +120,7 @@ export function adapterExecutionTargetIsRemote(
|
|||
export function adapterExecutionTargetUsesManagedHome(
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
): boolean {
|
||||
// SSH execution targets sync the runtime assets they need into the remote cwd today,
|
||||
// so neither local nor remote targets provision a separate managed adapter home.
|
||||
void target;
|
||||
return false;
|
||||
return target?.kind === "remote" && target.transport === "sandbox";
|
||||
}
|
||||
|
||||
export function adapterExecutionTargetRemoteCwd(
|
||||
|
|
@ -119,14 +134,25 @@ export function adapterExecutionTargetPaperclipApiUrl(
|
|||
target: AdapterExecutionTarget | null | undefined,
|
||||
): string | null {
|
||||
if (target?.kind !== "remote") return null;
|
||||
return target.paperclipApiUrl ?? target.spec.paperclipApiUrl ?? null;
|
||||
if (target.transport === "ssh") return target.paperclipApiUrl ?? target.spec.paperclipApiUrl ?? null;
|
||||
return target.paperclipApiUrl ?? null;
|
||||
}
|
||||
|
||||
export function describeAdapterExecutionTarget(
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
): string {
|
||||
if (!target || target.kind === "local") return "local environment";
|
||||
return `SSH environment ${target.spec.username}@${target.spec.host}:${target.spec.port}`;
|
||||
if (target.transport === "ssh") {
|
||||
return `SSH environment ${target.spec.username}@${target.spec.host}:${target.spec.port}`;
|
||||
}
|
||||
return `sandbox environment${target.providerKey ? ` (${target.providerKey})` : ""}`;
|
||||
}
|
||||
|
||||
function requireSandboxRunner(target: AdapterSandboxExecutionTarget): 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.",
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureAdapterExecutionTargetCommandResolvable(
|
||||
|
|
@ -135,6 +161,9 @@ export async function ensureAdapterExecutionTargetCommandResolvable(
|
|||
cwd: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
) {
|
||||
if (target?.kind === "remote" && target.transport === "sandbox") {
|
||||
return;
|
||||
}
|
||||
await ensureCommandResolvable(command, cwd, env, {
|
||||
remoteExecution: adapterExecutionTargetToRemoteSpec(target),
|
||||
});
|
||||
|
|
@ -146,6 +175,9 @@ export async function resolveAdapterExecutionTargetCommandForLogs(
|
|||
cwd: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<string> {
|
||||
if (target?.kind === "remote" && target.transport === "sandbox") {
|
||||
return `sandbox://${target.providerKey ?? "provider"}/${target.leaseId ?? "lease"}/${target.remoteCwd} :: ${command}`;
|
||||
}
|
||||
return await resolveCommandForLogs(command, cwd, env, {
|
||||
remoteExecution: adapterExecutionTargetToRemoteSpec(target),
|
||||
});
|
||||
|
|
@ -158,6 +190,22 @@ export async function runAdapterExecutionTargetProcess(
|
|||
args: string[],
|
||||
options: AdapterExecutionTargetProcessOptions,
|
||||
): Promise<RunProcessResult> {
|
||||
if (target?.kind === "remote" && target.transport === "sandbox") {
|
||||
const runner = requireSandboxRunner(target);
|
||||
return await runner.execute({
|
||||
command,
|
||||
args,
|
||||
cwd: target.remoteCwd,
|
||||
env: options.env,
|
||||
stdin: options.stdin,
|
||||
timeoutMs: options.timeoutSec > 0 ? options.timeoutSec * 1000 : target.timeoutMs ?? undefined,
|
||||
onLog: options.onLog,
|
||||
onSpawn: options.onSpawn
|
||||
? async (meta) => options.onSpawn?.({ ...meta, processGroupId: null })
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return await runChildProcess(runId, command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
|
|
@ -180,57 +228,68 @@ export async function runAdapterExecutionTargetShellCommand(
|
|||
const onLog = options.onLog ?? (async () => {});
|
||||
if (target?.kind === "remote") {
|
||||
const startedAt = new Date().toISOString();
|
||||
try {
|
||||
const result = await runSshCommand(target.spec, `sh -lc ${shellQuote(command)}`, {
|
||||
timeoutMs: (options.timeoutSec ?? 15) * 1000,
|
||||
});
|
||||
if (result.stdout) await onLog("stdout", result.stdout);
|
||||
if (result.stderr) await onLog("stderr", result.stderr);
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
pid: null,
|
||||
startedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
const timedOutError = error as NodeJS.ErrnoException & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
signal?: string | null;
|
||||
};
|
||||
const stdout = timedOutError.stdout ?? "";
|
||||
const stderr = timedOutError.stderr ?? "";
|
||||
if (typeof timedOutError.code === "number") {
|
||||
if (target.transport === "ssh") {
|
||||
try {
|
||||
const result = await runSshCommand(target.spec, `sh -lc ${shellQuote(command)}`, {
|
||||
timeoutMs: (options.timeoutSec ?? 15) * 1000,
|
||||
});
|
||||
if (result.stdout) await onLog("stdout", result.stdout);
|
||||
if (result.stderr) await onLog("stderr", result.stderr);
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
pid: null,
|
||||
startedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
const timedOutError = error as NodeJS.ErrnoException & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
signal?: string | null;
|
||||
};
|
||||
const stdout = timedOutError.stdout ?? "";
|
||||
const stderr = timedOutError.stderr ?? "";
|
||||
if (typeof timedOutError.code === "number") {
|
||||
if (stdout) await onLog("stdout", stdout);
|
||||
if (stderr) await onLog("stderr", stderr);
|
||||
return {
|
||||
exitCode: timedOutError.code,
|
||||
signal: timedOutError.signal ?? null,
|
||||
timedOut: false,
|
||||
stdout,
|
||||
stderr,
|
||||
pid: null,
|
||||
startedAt,
|
||||
};
|
||||
}
|
||||
if (timedOutError.code !== "ETIMEDOUT") {
|
||||
throw error;
|
||||
}
|
||||
if (stdout) await onLog("stdout", stdout);
|
||||
if (stderr) await onLog("stderr", stderr);
|
||||
return {
|
||||
exitCode: timedOutError.code,
|
||||
exitCode: null,
|
||||
signal: timedOutError.signal ?? null,
|
||||
timedOut: false,
|
||||
timedOut: true,
|
||||
stdout,
|
||||
stderr,
|
||||
pid: null,
|
||||
startedAt,
|
||||
};
|
||||
}
|
||||
if (timedOutError.code !== "ETIMEDOUT") {
|
||||
throw error;
|
||||
}
|
||||
if (stdout) await onLog("stdout", stdout);
|
||||
if (stderr) await onLog("stderr", stderr);
|
||||
return {
|
||||
exitCode: null,
|
||||
signal: timedOutError.signal ?? null,
|
||||
timedOut: true,
|
||||
stdout,
|
||||
stderr,
|
||||
pid: null,
|
||||
startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
return await requireSandboxRunner(target).execute({
|
||||
command: "sh",
|
||||
args: ["-lc", command],
|
||||
cwd: target.remoteCwd,
|
||||
env: options.env,
|
||||
timeoutMs: (options.timeoutSec ?? 15) * 1000,
|
||||
onLog,
|
||||
});
|
||||
}
|
||||
|
||||
return await runAdapterExecutionTargetProcess(
|
||||
|
|
@ -281,7 +340,15 @@ export function adapterExecutionTargetSessionIdentity(
|
|||
target: AdapterExecutionTarget | null | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
if (!target || target.kind === "local") return null;
|
||||
return buildRemoteExecutionSessionIdentity(target.spec);
|
||||
if (target.transport === "ssh") return buildRemoteExecutionSessionIdentity(target.spec);
|
||||
return {
|
||||
transport: "sandbox",
|
||||
providerKey: target.providerKey ?? null,
|
||||
environmentId: target.environmentId ?? null,
|
||||
leaseId: target.leaseId ?? null,
|
||||
remoteCwd: target.remoteCwd,
|
||||
...(target.paperclipApiUrl ? { paperclipApiUrl: target.paperclipApiUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function adapterExecutionTargetSessionMatches(
|
||||
|
|
@ -291,7 +358,17 @@ export function adapterExecutionTargetSessionMatches(
|
|||
if (!target || target.kind === "local") {
|
||||
return Object.keys(parseObject(saved)).length === 0;
|
||||
}
|
||||
return remoteExecutionSessionMatches(saved, target.spec);
|
||||
if (target.transport === "ssh") return remoteExecutionSessionMatches(saved, target.spec);
|
||||
const current = adapterExecutionTargetSessionIdentity(target);
|
||||
const parsedSaved = parseObject(saved);
|
||||
return (
|
||||
readStringMeta(parsedSaved, "transport") === current?.transport &&
|
||||
readStringMeta(parsedSaved, "providerKey") === current?.providerKey &&
|
||||
readStringMeta(parsedSaved, "environmentId") === current?.environmentId &&
|
||||
readStringMeta(parsedSaved, "leaseId") === current?.leaseId &&
|
||||
readStringMeta(parsedSaved, "remoteCwd") === current?.remoteCwd &&
|
||||
readStringMeta(parsedSaved, "paperclipApiUrl") === (current?.paperclipApiUrl ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTarget | null {
|
||||
|
|
@ -320,6 +397,21 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar
|
|||
};
|
||||
}
|
||||
|
||||
if (kind === "remote" && readStringMeta(parsed, "transport") === "sandbox") {
|
||||
const remoteCwd = readStringMeta(parsed, "remoteCwd");
|
||||
if (!remoteCwd) return null;
|
||||
return {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: readStringMeta(parsed, "providerKey"),
|
||||
environmentId: readStringMeta(parsed, "environmentId"),
|
||||
leaseId: readStringMeta(parsed, "leaseId"),
|
||||
remoteCwd,
|
||||
paperclipApiUrl: readStringMeta(parsed, "paperclipApiUrl"),
|
||||
timeoutMs: typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -376,11 +468,36 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
|
|||
};
|
||||
}
|
||||
|
||||
const prepared = await prepareRemoteManagedRuntime({
|
||||
spec: target.spec,
|
||||
if (target.transport === "ssh") {
|
||||
const prepared = await prepareRemoteManagedRuntime({
|
||||
spec: target.spec,
|
||||
adapterKey: input.adapterKey,
|
||||
workspaceLocalDir: input.workspaceLocalDir,
|
||||
assets: input.assets,
|
||||
});
|
||||
return {
|
||||
target,
|
||||
runtimeRootDir: prepared.runtimeRootDir,
|
||||
assetDirs: prepared.assetDirs,
|
||||
restoreWorkspace: prepared.restoreWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
const prepared = await prepareCommandManagedRuntime({
|
||||
runner: requireSandboxRunner(target),
|
||||
spec: {
|
||||
providerKey: target.providerKey,
|
||||
leaseId: target.leaseId,
|
||||
remoteCwd: target.remoteCwd,
|
||||
timeoutMs: target.timeoutMs,
|
||||
paperclipApiUrl: target.paperclipApiUrl,
|
||||
},
|
||||
adapterKey: input.adapterKey,
|
||||
workspaceLocalDir: input.workspaceLocalDir,
|
||||
workspaceExclude: input.workspaceExclude,
|
||||
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
|
||||
assets: input.assets,
|
||||
installCommand: input.installCommand,
|
||||
});
|
||||
return {
|
||||
target,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue