mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +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
152
packages/adapter-utils/src/command-managed-runtime.ts
Normal file
152
packages/adapter-utils/src/command-managed-runtime.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import path from "node:path";
|
||||
import {
|
||||
prepareSandboxManagedRuntime,
|
||||
type PreparedSandboxManagedRuntime,
|
||||
type SandboxManagedRuntimeAsset,
|
||||
type SandboxManagedRuntimeClient,
|
||||
type SandboxRemoteExecutionSpec,
|
||||
} from "./sandbox-managed-runtime.js";
|
||||
import type { RunProcessResult } from "./server-utils.js";
|
||||
|
||||
export interface CommandManagedRuntimeRunner {
|
||||
execute(input: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
}): Promise<RunProcessResult>;
|
||||
}
|
||||
|
||||
export interface CommandManagedRuntimeSpec {
|
||||
providerKey?: string | null;
|
||||
leaseId?: string | null;
|
||||
remoteCwd: string;
|
||||
timeoutMs?: number | null;
|
||||
paperclipApiUrl?: string | null;
|
||||
}
|
||||
|
||||
export type CommandManagedRuntimeAsset = SandboxManagedRuntimeAsset;
|
||||
|
||||
function shellQuote(value: string) {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function toBuffer(bytes: Buffer | Uint8Array | ArrayBuffer): Buffer {
|
||||
if (Buffer.isBuffer(bytes)) return bytes;
|
||||
if (bytes instanceof ArrayBuffer) return Buffer.from(bytes);
|
||||
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
}
|
||||
|
||||
function requireSuccessfulResult(result: RunProcessResult, action: string): void {
|
||||
if (result.exitCode === 0 && !result.timedOut) return;
|
||||
const stderr = result.stderr.trim();
|
||||
const detail = stderr.length > 0 ? `: ${stderr}` : "";
|
||||
throw new Error(`${action} failed with exit code ${result.exitCode ?? "null"}${detail}`);
|
||||
}
|
||||
|
||||
function createCommandManagedRuntimeClient(input: {
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
remoteCwd: string;
|
||||
timeoutMs: number;
|
||||
}): SandboxManagedRuntimeClient {
|
||||
const runShell = async (script: string, opts: { stdin?: string; timeoutMs?: number } = {}) => {
|
||||
const result = await input.runner.execute({
|
||||
command: "sh",
|
||||
args: ["-lc", script],
|
||||
cwd: input.remoteCwd,
|
||||
stdin: opts.stdin,
|
||||
timeoutMs: opts.timeoutMs ?? input.timeoutMs,
|
||||
});
|
||||
requireSuccessfulResult(result, script);
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
makeDir: async (remotePath) => {
|
||||
await runShell(`mkdir -p ${shellQuote(remotePath)}`);
|
||||
},
|
||||
writeFile: async (remotePath, bytes) => {
|
||||
const body = toBuffer(bytes).toString("base64");
|
||||
await runShell(
|
||||
`mkdir -p ${shellQuote(path.posix.dirname(remotePath))} && base64 -d > ${shellQuote(remotePath)}`,
|
||||
{ stdin: body },
|
||||
);
|
||||
},
|
||||
readFile: async (remotePath) => {
|
||||
const result = await runShell(`base64 < ${shellQuote(remotePath)}`);
|
||||
return Buffer.from(result.stdout.replace(/\s+/g, ""), "base64");
|
||||
},
|
||||
remove: async (remotePath) => {
|
||||
const result = await input.runner.execute({
|
||||
command: "sh",
|
||||
args: ["-lc", `rm -rf ${shellQuote(remotePath)}`],
|
||||
cwd: input.remoteCwd,
|
||||
timeoutMs: input.timeoutMs,
|
||||
});
|
||||
requireSuccessfulResult(result, `remove ${remotePath}`);
|
||||
},
|
||||
run: async (command, options) => {
|
||||
const result = await input.runner.execute({
|
||||
command: "sh",
|
||||
args: ["-lc", command],
|
||||
cwd: input.remoteCwd,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
requireSuccessfulResult(result, command);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareCommandManagedRuntime(input: {
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
spec: CommandManagedRuntimeSpec;
|
||||
adapterKey: string;
|
||||
workspaceLocalDir: string;
|
||||
workspaceRemoteDir?: string;
|
||||
workspaceExclude?: string[];
|
||||
preserveAbsentOnRestore?: string[];
|
||||
assets?: CommandManagedRuntimeAsset[];
|
||||
installCommand?: string | null;
|
||||
}): Promise<PreparedSandboxManagedRuntime> {
|
||||
const timeoutMs = input.spec.timeoutMs && input.spec.timeoutMs > 0 ? input.spec.timeoutMs : 300_000;
|
||||
const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
|
||||
const runtimeSpec: SandboxRemoteExecutionSpec = {
|
||||
transport: "sandbox",
|
||||
provider: input.spec.providerKey ?? "sandbox",
|
||||
sandboxId: input.spec.leaseId ?? "managed",
|
||||
remoteCwd: workspaceRemoteDir,
|
||||
timeoutMs,
|
||||
apiKey: null,
|
||||
paperclipApiUrl: input.spec.paperclipApiUrl ?? null,
|
||||
};
|
||||
const client = createCommandManagedRuntimeClient({
|
||||
runner: input.runner,
|
||||
remoteCwd: workspaceRemoteDir,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
if (input.installCommand?.trim()) {
|
||||
const result = await input.runner.execute({
|
||||
command: "sh",
|
||||
args: ["-lc", input.installCommand.trim()],
|
||||
cwd: workspaceRemoteDir,
|
||||
timeoutMs,
|
||||
});
|
||||
requireSuccessfulResult(result, input.installCommand.trim());
|
||||
}
|
||||
|
||||
return await prepareSandboxManagedRuntime({
|
||||
spec: runtimeSpec,
|
||||
client,
|
||||
adapterKey: input.adapterKey,
|
||||
workspaceLocalDir: input.workspaceLocalDir,
|
||||
workspaceRemoteDir,
|
||||
workspaceExclude: input.workspaceExclude,
|
||||
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
|
||||
assets: input.assets,
|
||||
});
|
||||
}
|
||||
96
packages/adapter-utils/src/execution-target-sandbox.test.ts
Normal file
96
packages/adapter-utils/src/execution-target-sandbox.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
adapterExecutionTargetSessionIdentity,
|
||||
adapterExecutionTargetToRemoteSpec,
|
||||
runAdapterExecutionTargetProcess,
|
||||
runAdapterExecutionTargetShellCommand,
|
||||
type AdapterSandboxExecutionTarget,
|
||||
} from "./execution-target.js";
|
||||
|
||||
describe("sandbox adapter execution targets", () => {
|
||||
it("executes through the provider-neutral runner without a remote spec", async () => {
|
||||
const runner = {
|
||||
execute: vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "ok\n",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
})),
|
||||
};
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "acme-sandbox",
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-1",
|
||||
remoteCwd: "/workspace",
|
||||
timeoutMs: 30_000,
|
||||
runner,
|
||||
};
|
||||
|
||||
expect(adapterExecutionTargetToRemoteSpec(target)).toBeNull();
|
||||
|
||||
const result = await runAdapterExecutionTargetProcess("run-1", target, "agent-cli", ["--json"], {
|
||||
cwd: "/local/workspace",
|
||||
env: { TOKEN: "token" },
|
||||
stdin: "prompt",
|
||||
timeoutSec: 5,
|
||||
graceSec: 1,
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.stdout).toBe("ok\n");
|
||||
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
||||
command: "agent-cli",
|
||||
args: ["--json"],
|
||||
cwd: "/workspace",
|
||||
env: { TOKEN: "token" },
|
||||
stdin: "prompt",
|
||||
timeoutMs: 5000,
|
||||
}));
|
||||
expect(adapterExecutionTargetSessionIdentity(target)).toEqual({
|
||||
transport: "sandbox",
|
||||
providerKey: "acme-sandbox",
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-1",
|
||||
remoteCwd: "/workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("runs shell commands through the same runner", async () => {
|
||||
const runner = {
|
||||
execute: vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "/home/sandbox",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
})),
|
||||
};
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
remoteCwd: "/workspace",
|
||||
runner,
|
||||
};
|
||||
|
||||
await runAdapterExecutionTargetShellCommand("run-2", target, 'printf %s "$HOME"', {
|
||||
cwd: "/local/workspace",
|
||||
env: {},
|
||||
timeoutSec: 7,
|
||||
});
|
||||
|
||||
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
||||
command: "sh",
|
||||
args: ["-lc", 'printf %s "$HOME"'],
|
||||
cwd: "/workspace",
|
||||
timeoutMs: 7000,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
126
packages/adapter-utils/src/sandbox-managed-runtime.test.ts
Normal file
126
packages/adapter-utils/src/sandbox-managed-runtime.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
mirrorDirectory,
|
||||
prepareSandboxManagedRuntime,
|
||||
type SandboxManagedRuntimeClient,
|
||||
} from "./sandbox-managed-runtime.js";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
describe("sandbox managed runtime", () => {
|
||||
const cleanupDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanupDirs.length > 0) {
|
||||
const dir = cleanupDirs.pop();
|
||||
if (!dir) continue;
|
||||
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves excluded local workspace artifacts during restore mirroring", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-restore-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const sourceDir = path.join(rootDir, "source");
|
||||
const targetDir = path.join(rootDir, "target");
|
||||
await mkdir(path.join(sourceDir, "src"), { recursive: true });
|
||||
await mkdir(path.join(targetDir, ".claude"), { recursive: true });
|
||||
await mkdir(path.join(targetDir, ".paperclip-runtime"), { recursive: true });
|
||||
await writeFile(path.join(sourceDir, "src", "app.ts"), "export const value = 2;\n", "utf8");
|
||||
await writeFile(path.join(targetDir, "stale.txt"), "remove me\n", "utf8");
|
||||
await writeFile(path.join(targetDir, ".claude", "settings.json"), "{\"keep\":true}\n", "utf8");
|
||||
await writeFile(path.join(targetDir, ".claude.json"), "{\"keep\":true}\n", "utf8");
|
||||
await writeFile(path.join(targetDir, ".paperclip-runtime", "state.json"), "{}\n", "utf8");
|
||||
|
||||
await mirrorDirectory(sourceDir, targetDir, {
|
||||
preserveAbsent: [".paperclip-runtime", ".claude", ".claude.json"],
|
||||
});
|
||||
|
||||
await expect(readFile(path.join(targetDir, "src", "app.ts"), "utf8")).resolves.toBe("export const value = 2;\n");
|
||||
await expect(readFile(path.join(targetDir, ".claude", "settings.json"), "utf8")).resolves.toBe("{\"keep\":true}\n");
|
||||
await expect(readFile(path.join(targetDir, ".claude.json"), "utf8")).resolves.toBe("{\"keep\":true}\n");
|
||||
await expect(readFile(path.join(targetDir, ".paperclip-runtime", "state.json"), "utf8")).resolves.toBe("{}\n");
|
||||
await expect(readFile(path.join(targetDir, "stale.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("syncs workspace and assets through a provider-neutral sandbox client", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-managed-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const localWorkspaceDir = path.join(rootDir, "local-workspace");
|
||||
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
||||
const localAssetsDir = path.join(rootDir, "local-assets");
|
||||
const linkedAssetPath = path.join(rootDir, "linked-skill.md");
|
||||
await mkdir(path.join(localWorkspaceDir, ".claude"), { recursive: true });
|
||||
await mkdir(localAssetsDir, { recursive: true });
|
||||
await writeFile(path.join(localWorkspaceDir, "README.md"), "local workspace\n", "utf8");
|
||||
await writeFile(path.join(localWorkspaceDir, "._README.md"), "appledouble\n", "utf8");
|
||||
await writeFile(path.join(localWorkspaceDir, ".claude", "settings.json"), "{\"local\":true}\n", "utf8");
|
||||
await writeFile(linkedAssetPath, "skill body\n", "utf8");
|
||||
await symlink(linkedAssetPath, path.join(localAssetsDir, "skill.md"));
|
||||
|
||||
const client: SandboxManagedRuntimeClient = {
|
||||
makeDir: async (remotePath) => {
|
||||
await mkdir(remotePath, { recursive: true });
|
||||
},
|
||||
writeFile: async (remotePath, bytes) => {
|
||||
await mkdir(path.dirname(remotePath), { recursive: true });
|
||||
await writeFile(remotePath, Buffer.from(bytes));
|
||||
},
|
||||
readFile: async (remotePath) => await readFile(remotePath),
|
||||
remove: async (remotePath) => {
|
||||
await rm(remotePath, { recursive: true, force: true });
|
||||
},
|
||||
run: async (command) => {
|
||||
await execFile("sh", ["-lc", command], {
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const prepared = await prepareSandboxManagedRuntime({
|
||||
spec: {
|
||||
transport: "sandbox",
|
||||
provider: "test",
|
||||
sandboxId: "sandbox-1",
|
||||
remoteCwd: remoteWorkspaceDir,
|
||||
timeoutMs: 30_000,
|
||||
apiKey: null,
|
||||
},
|
||||
adapterKey: "test-adapter",
|
||||
client,
|
||||
workspaceLocalDir: localWorkspaceDir,
|
||||
workspaceExclude: [".claude"],
|
||||
preserveAbsentOnRestore: [".claude"],
|
||||
assets: [{
|
||||
key: "skills",
|
||||
localDir: localAssetsDir,
|
||||
followSymlinks: true,
|
||||
}],
|
||||
});
|
||||
|
||||
await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("local workspace\n");
|
||||
await expect(readFile(path.join(remoteWorkspaceDir, "._README.md"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(readFile(path.join(remoteWorkspaceDir, ".claude", "settings.json"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(readFile(path.join(prepared.assetDirs.skills, "skill.md"), "utf8")).resolves.toBe("skill body\n");
|
||||
expect((await lstat(path.join(prepared.assetDirs.skills, "skill.md"))).isFile()).toBe(true);
|
||||
|
||||
await writeFile(path.join(remoteWorkspaceDir, "README.md"), "remote workspace\n", "utf8");
|
||||
await writeFile(path.join(remoteWorkspaceDir, "remote-only.txt"), "sync back\n", "utf8");
|
||||
await mkdir(path.join(localWorkspaceDir, ".paperclip-runtime"), { recursive: true });
|
||||
await writeFile(path.join(localWorkspaceDir, ".paperclip-runtime", "state.json"), "{}\n", "utf8");
|
||||
await writeFile(path.join(localWorkspaceDir, "local-stale.txt"), "remove\n", "utf8");
|
||||
await prepared.restoreWorkspace();
|
||||
|
||||
await expect(readFile(path.join(localWorkspaceDir, "README.md"), "utf8")).resolves.toBe("remote workspace\n");
|
||||
await expect(readFile(path.join(localWorkspaceDir, "remote-only.txt"), "utf8")).resolves.toBe("sync back\n");
|
||||
await expect(readFile(path.join(localWorkspaceDir, "local-stale.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(readFile(path.join(localWorkspaceDir, ".claude", "settings.json"), "utf8")).resolves.toBe("{\"local\":true}\n");
|
||||
await expect(readFile(path.join(localWorkspaceDir, ".paperclip-runtime", "state.json"), "utf8")).resolves.toBe("{}\n");
|
||||
});
|
||||
});
|
||||
338
packages/adapter-utils/src/sandbox-managed-runtime.ts
Normal file
338
packages/adapter-utils/src/sandbox-managed-runtime.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { constants as fsConstants, promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
export interface SandboxRemoteExecutionSpec {
|
||||
transport: "sandbox";
|
||||
provider: string;
|
||||
sandboxId: string;
|
||||
remoteCwd: string;
|
||||
timeoutMs: number;
|
||||
apiKey: string | null;
|
||||
paperclipApiUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface SandboxManagedRuntimeAsset {
|
||||
key: string;
|
||||
localDir: string;
|
||||
followSymlinks?: boolean;
|
||||
exclude?: string[];
|
||||
}
|
||||
|
||||
export interface SandboxManagedRuntimeClient {
|
||||
makeDir(remotePath: string): Promise<void>;
|
||||
writeFile(remotePath: string, bytes: ArrayBuffer): Promise<void>;
|
||||
readFile(remotePath: string): Promise<Buffer | Uint8Array | ArrayBuffer>;
|
||||
remove(remotePath: string): Promise<void>;
|
||||
run(command: string, options: { timeoutMs: number }): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PreparedSandboxManagedRuntime {
|
||||
spec: SandboxRemoteExecutionSpec;
|
||||
workspaceLocalDir: string;
|
||||
workspaceRemoteDir: string;
|
||||
runtimeRootDir: string;
|
||||
assetDirs: Record<string, string>;
|
||||
restoreWorkspace(): Promise<void>;
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number {
|
||||
return typeof value === "number" ? value : Number(value);
|
||||
}
|
||||
|
||||
function shellQuote(value: string) {
|
||||
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
export function parseSandboxRemoteExecutionSpec(value: unknown): SandboxRemoteExecutionSpec | null {
|
||||
const parsed = asObject(value);
|
||||
const transport = asString(parsed.transport).trim();
|
||||
const provider = asString(parsed.provider).trim();
|
||||
const sandboxId = asString(parsed.sandboxId).trim();
|
||||
const remoteCwd = asString(parsed.remoteCwd).trim();
|
||||
const timeoutMs = asNumber(parsed.timeoutMs);
|
||||
|
||||
if (
|
||||
transport !== "sandbox" ||
|
||||
provider.length === 0 ||
|
||||
sandboxId.length === 0 ||
|
||||
remoteCwd.length === 0 ||
|
||||
!Number.isFinite(timeoutMs) ||
|
||||
timeoutMs <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
transport: "sandbox",
|
||||
provider,
|
||||
sandboxId,
|
||||
remoteCwd,
|
||||
timeoutMs,
|
||||
apiKey: asString(parsed.apiKey).trim() || null,
|
||||
paperclipApiUrl: asString(parsed.paperclipApiUrl).trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSandboxExecutionSessionIdentity(spec: SandboxRemoteExecutionSpec | null) {
|
||||
if (!spec) return null;
|
||||
return {
|
||||
transport: "sandbox",
|
||||
provider: spec.provider,
|
||||
sandboxId: spec.sandboxId,
|
||||
remoteCwd: spec.remoteCwd,
|
||||
...(spec.paperclipApiUrl ? { paperclipApiUrl: spec.paperclipApiUrl } : {}),
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function sandboxExecutionSessionMatches(saved: unknown, current: SandboxRemoteExecutionSpec | null): boolean {
|
||||
const currentIdentity = buildSandboxExecutionSessionIdentity(current);
|
||||
if (!currentIdentity) return false;
|
||||
const parsedSaved = asObject(saved);
|
||||
return (
|
||||
asString(parsedSaved.transport) === currentIdentity.transport &&
|
||||
asString(parsedSaved.provider) === currentIdentity.provider &&
|
||||
asString(parsedSaved.sandboxId) === currentIdentity.sandboxId &&
|
||||
asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd &&
|
||||
asString(parsedSaved.paperclipApiUrl) === asString(currentIdentity.paperclipApiUrl)
|
||||
);
|
||||
}
|
||||
|
||||
async function withTempDir<T>(prefix: string, fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
try {
|
||||
return await fn(dir);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function execTar(args: string[]): Promise<void> {
|
||||
await execFile("tar", args, {
|
||||
env: {
|
||||
...process.env,
|
||||
COPYFILE_DISABLE: "1",
|
||||
},
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
async function createTarballFromDirectory(input: {
|
||||
localDir: string;
|
||||
archivePath: string;
|
||||
exclude?: string[];
|
||||
followSymlinks?: boolean;
|
||||
}): Promise<void> {
|
||||
const excludeArgs = ["._*", ...(input.exclude ?? [])].flatMap((entry) => ["--exclude", entry]);
|
||||
await execTar([
|
||||
"-c",
|
||||
...(input.followSymlinks ? ["-h"] : []),
|
||||
"-f",
|
||||
input.archivePath,
|
||||
"-C",
|
||||
input.localDir,
|
||||
...excludeArgs,
|
||||
".",
|
||||
]);
|
||||
}
|
||||
|
||||
async function extractTarballToDirectory(input: {
|
||||
archivePath: string;
|
||||
localDir: string;
|
||||
}): Promise<void> {
|
||||
await fs.mkdir(input.localDir, { recursive: true });
|
||||
await execTar(["-xf", input.archivePath, "-C", input.localDir]);
|
||||
}
|
||||
|
||||
async function walkDirectory(root: string, relative = ""): Promise<string[]> {
|
||||
const current = path.join(root, relative);
|
||||
const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
|
||||
const out: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const nextRelative = relative ? path.posix.join(relative, entry.name) : entry.name;
|
||||
out.push(nextRelative);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...(await walkDirectory(root, nextRelative)));
|
||||
}
|
||||
}
|
||||
return out.sort((left, right) => right.length - left.length);
|
||||
}
|
||||
|
||||
function isRelativePathOrDescendant(relative: string, candidate: string): boolean {
|
||||
return relative === candidate || relative.startsWith(`${candidate}/`);
|
||||
}
|
||||
|
||||
export async function mirrorDirectory(
|
||||
sourceDir: string,
|
||||
targetDir: string,
|
||||
options: { preserveAbsent?: string[] } = {},
|
||||
): Promise<void> {
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
const preserveAbsent = new Set(options.preserveAbsent ?? []);
|
||||
const shouldPreserveAbsent = (relative: string) =>
|
||||
[...preserveAbsent].some((candidate) => isRelativePathOrDescendant(relative, candidate));
|
||||
|
||||
const sourceEntries = new Set(await walkDirectory(sourceDir));
|
||||
const targetEntries = await walkDirectory(targetDir);
|
||||
for (const relative of targetEntries) {
|
||||
if (shouldPreserveAbsent(relative)) continue;
|
||||
if (!sourceEntries.has(relative)) {
|
||||
await fs.rm(path.join(targetDir, relative), { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const copyEntry = async (relative: string) => {
|
||||
const sourcePath = path.join(sourceDir, relative);
|
||||
const targetPath = path.join(targetDir, relative);
|
||||
const stats = await fs.lstat(sourcePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
await fs.mkdir(targetPath, { recursive: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.rm(targetPath, { recursive: true, force: true }).catch(() => undefined);
|
||||
if (stats.isSymbolicLink()) {
|
||||
const linkTarget = await fs.readlink(sourcePath);
|
||||
await fs.symlink(linkTarget, targetPath);
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_FICLONE).catch(async () => {
|
||||
await fs.copyFile(sourcePath, targetPath);
|
||||
});
|
||||
await fs.chmod(targetPath, stats.mode);
|
||||
};
|
||||
|
||||
const entries = (await walkDirectory(sourceDir)).sort((left, right) => left.localeCompare(right));
|
||||
for (const relative of entries) {
|
||||
await copyEntry(relative);
|
||||
}
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Buffer): ArrayBuffer {
|
||||
return Uint8Array.from(bytes).buffer;
|
||||
}
|
||||
|
||||
function toBuffer(bytes: Buffer | Uint8Array | ArrayBuffer): Buffer {
|
||||
if (Buffer.isBuffer(bytes)) return bytes;
|
||||
if (bytes instanceof ArrayBuffer) return Buffer.from(bytes);
|
||||
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
}
|
||||
|
||||
function tarExcludeFlags(exclude: string[] | undefined): string {
|
||||
return ["._*", ...(exclude ?? [])].map((entry) => `--exclude ${shellQuote(entry)}`).join(" ");
|
||||
}
|
||||
|
||||
export async function prepareSandboxManagedRuntime(input: {
|
||||
spec: SandboxRemoteExecutionSpec;
|
||||
adapterKey: string;
|
||||
client: SandboxManagedRuntimeClient;
|
||||
workspaceLocalDir: string;
|
||||
workspaceRemoteDir?: string;
|
||||
workspaceExclude?: string[];
|
||||
preserveAbsentOnRestore?: string[];
|
||||
assets?: SandboxManagedRuntimeAsset[];
|
||||
}): Promise<PreparedSandboxManagedRuntime> {
|
||||
const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
|
||||
const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey);
|
||||
|
||||
await withTempDir("paperclip-sandbox-sync-", async (tempDir) => {
|
||||
const workspaceTarPath = path.join(tempDir, "workspace.tar");
|
||||
await createTarballFromDirectory({
|
||||
localDir: input.workspaceLocalDir,
|
||||
archivePath: workspaceTarPath,
|
||||
exclude: input.workspaceExclude,
|
||||
});
|
||||
const workspaceTarBytes = await fs.readFile(workspaceTarPath);
|
||||
const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-upload.tar");
|
||||
await input.client.makeDir(runtimeRootDir);
|
||||
await input.client.writeFile(remoteWorkspaceTar, toArrayBuffer(workspaceTarBytes));
|
||||
const preservedNames = new Set([".paperclip-runtime", ...(input.preserveAbsentOnRestore ?? [])]);
|
||||
const findPreserveArgs = [...preservedNames].map((entry) => `! -name ${shellQuote(entry)}`).join(" ");
|
||||
await input.client.run(
|
||||
`sh -lc ${shellQuote(
|
||||
`mkdir -p ${shellQuote(workspaceRemoteDir)} && ` +
|
||||
`find ${shellQuote(workspaceRemoteDir)} -mindepth 1 -maxdepth 1 ${findPreserveArgs} -exec rm -rf -- {} + && ` +
|
||||
`tar -xf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} && ` +
|
||||
`rm -f ${shellQuote(remoteWorkspaceTar)}`,
|
||||
)}`,
|
||||
{ timeoutMs: input.spec.timeoutMs },
|
||||
);
|
||||
|
||||
for (const asset of input.assets ?? []) {
|
||||
const assetTarPath = path.join(tempDir, `${asset.key}.tar`);
|
||||
await createTarballFromDirectory({
|
||||
localDir: asset.localDir,
|
||||
archivePath: assetTarPath,
|
||||
followSymlinks: asset.followSymlinks,
|
||||
exclude: asset.exclude,
|
||||
});
|
||||
const assetTarBytes = await fs.readFile(assetTarPath);
|
||||
const remoteAssetDir = path.posix.join(runtimeRootDir, asset.key);
|
||||
const remoteAssetTar = path.posix.join(runtimeRootDir, `${asset.key}-upload.tar`);
|
||||
await input.client.writeFile(remoteAssetTar, toArrayBuffer(assetTarBytes));
|
||||
await input.client.run(
|
||||
`sh -lc ${shellQuote(
|
||||
`rm -rf ${shellQuote(remoteAssetDir)} && ` +
|
||||
`mkdir -p ${shellQuote(remoteAssetDir)} && ` +
|
||||
`tar -xf ${shellQuote(remoteAssetTar)} -C ${shellQuote(remoteAssetDir)} && ` +
|
||||
`rm -f ${shellQuote(remoteAssetTar)}`,
|
||||
)}`,
|
||||
{ timeoutMs: input.spec.timeoutMs },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const assetDirs = Object.fromEntries(
|
||||
(input.assets ?? []).map((asset) => [asset.key, path.posix.join(runtimeRootDir, asset.key)]),
|
||||
);
|
||||
|
||||
return {
|
||||
spec: input.spec,
|
||||
workspaceLocalDir: input.workspaceLocalDir,
|
||||
workspaceRemoteDir,
|
||||
runtimeRootDir,
|
||||
assetDirs,
|
||||
restoreWorkspace: async () => {
|
||||
await withTempDir("paperclip-sandbox-restore-", async (tempDir) => {
|
||||
const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-download.tar");
|
||||
await input.client.run(
|
||||
`sh -lc ${shellQuote(
|
||||
`mkdir -p ${shellQuote(runtimeRootDir)} && ` +
|
||||
`tar -cf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} ` +
|
||||
`${tarExcludeFlags(input.workspaceExclude)} .`,
|
||||
)}`,
|
||||
{ timeoutMs: input.spec.timeoutMs },
|
||||
);
|
||||
const archiveBytes = await input.client.readFile(remoteWorkspaceTar);
|
||||
await input.client.remove(remoteWorkspaceTar).catch(() => undefined);
|
||||
const localArchivePath = path.join(tempDir, "workspace.tar");
|
||||
const extractedDir = path.join(tempDir, "workspace");
|
||||
await fs.writeFile(localArchivePath, toBuffer(archiveBytes));
|
||||
await extractTarballToDirectory({
|
||||
archivePath: localArchivePath,
|
||||
localDir: extractedDir,
|
||||
});
|
||||
await mirrorDirectory(extractedDir, input.workspaceLocalDir, {
|
||||
preserveAbsent: [".paperclip-runtime", ...(input.preserveAbsentOnRestore ?? [])],
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -4,9 +4,9 @@ import fs from "node:fs";
|
|||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const VALID_TEMPLATES = ["default", "connector", "workspace"] as const;
|
||||
const VALID_TEMPLATES = ["default", "connector", "workspace", "environment"] as const;
|
||||
type PluginTemplate = (typeof VALID_TEMPLATES)[number];
|
||||
const VALID_CATEGORIES = new Set(["connector", "workspace", "automation", "ui"] as const);
|
||||
const VALID_CATEGORIES = new Set(["connector", "workspace", "automation", "ui", "environment"] as const);
|
||||
|
||||
export interface ScaffoldPluginOptions {
|
||||
pluginName: string;
|
||||
|
|
@ -15,7 +15,7 @@ export interface ScaffoldPluginOptions {
|
|||
displayName?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
category?: "connector" | "workspace" | "automation" | "ui";
|
||||
category?: "connector" | "workspace" | "automation" | "ui" | "environment";
|
||||
sdkPath?: string;
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
|
|||
const displayName = options.displayName ?? makeDisplayName(options.pluginName);
|
||||
const description = options.description ?? "A Paperclip plugin";
|
||||
const author = options.author ?? "Plugin Author";
|
||||
const category = options.category ?? (template === "workspace" ? "workspace" : "connector");
|
||||
const category = options.category ?? (template === "workspace" ? "workspace" : template === "environment" ? "environment" : "connector");
|
||||
const manifestId = packageToManifestId(options.pluginName);
|
||||
const localSdkPath = path.resolve(options.sdkPath ?? getLocalSdkPackagePath());
|
||||
const localSharedPath = getLocalSharedPackagePath(localSdkPath);
|
||||
|
|
@ -296,9 +296,231 @@ export default defineConfig({
|
|||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "manifest.ts"),
|
||||
`import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
if (template === "environment") {
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "manifest.ts"),
|
||||
`import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: ${quote(manifestId)},
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: ${quote(displayName)},
|
||||
description: ${quote(description)},
|
||||
author: ${quote(author)},
|
||||
categories: [${quote(category)}],
|
||||
capabilities: [
|
||||
"environment.drivers.register",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write"
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui"
|
||||
},
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: ${quote(manifestId + "-driver")},
|
||||
displayName: ${quote(displayName + " Driver")}
|
||||
}
|
||||
],
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: "health-widget",
|
||||
displayName: ${quote(`${displayName} Health`)},
|
||||
exportName: "DashboardWidget"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "worker.ts"),
|
||||
`import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
import type {
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
PluginEnvironmentProbeParams,
|
||||
PluginEnvironmentAcquireLeaseParams,
|
||||
PluginEnvironmentResumeLeaseParams,
|
||||
PluginEnvironmentReleaseLeaseParams,
|
||||
PluginEnvironmentDestroyLeaseParams,
|
||||
PluginEnvironmentRealizeWorkspaceParams,
|
||||
PluginEnvironmentExecuteParams,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.data.register("health", async () => {
|
||||
return { status: "ok", checkedAt: new Date().toISOString() };
|
||||
});
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: "Environment plugin worker is running" };
|
||||
},
|
||||
|
||||
async onEnvironmentValidateConfig(params: PluginEnvironmentValidateConfigParams) {
|
||||
if (!params.config || typeof params.config !== "object") {
|
||||
return { ok: false, errors: ["Config must be a non-null object"] };
|
||||
}
|
||||
return { ok: true, normalizedConfig: params.config };
|
||||
},
|
||||
|
||||
async onEnvironmentProbe(_params: PluginEnvironmentProbeParams) {
|
||||
return { ok: true, summary: "Environment is reachable" };
|
||||
},
|
||||
|
||||
async onEnvironmentAcquireLease(params: PluginEnvironmentAcquireLeaseParams) {
|
||||
const providerLeaseId = \`lease-\${params.runId}-\${Date.now()}\`;
|
||||
return {
|
||||
providerLeaseId,
|
||||
metadata: { acquiredAt: new Date().toISOString() },
|
||||
};
|
||||
},
|
||||
|
||||
async onEnvironmentResumeLease(params: PluginEnvironmentResumeLeaseParams) {
|
||||
return {
|
||||
providerLeaseId: params.providerLeaseId,
|
||||
metadata: { ...params.leaseMetadata, resumed: true },
|
||||
};
|
||||
},
|
||||
|
||||
async onEnvironmentReleaseLease(_params: PluginEnvironmentReleaseLeaseParams) {
|
||||
// Release provider-side resources here
|
||||
},
|
||||
|
||||
async onEnvironmentDestroyLease(_params: PluginEnvironmentDestroyLeaseParams) {
|
||||
// Destroy provider-side resources here
|
||||
},
|
||||
|
||||
async onEnvironmentRealizeWorkspace(params: PluginEnvironmentRealizeWorkspaceParams) {
|
||||
const cwd = params.workspace.remotePath ?? params.workspace.localPath ?? "/tmp/workspace";
|
||||
return { cwd, metadata: { realized: true } };
|
||||
},
|
||||
|
||||
async onEnvironmentExecute(params: PluginEnvironmentExecuteParams) {
|
||||
// Replace this with real command execution against your provider
|
||||
return {
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
stdout: \`Executed: \${params.command}\`,
|
||||
stderr: "",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "ui", "index.tsx"),
|
||||
`import { usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
type HealthData = {
|
||||
status: "ok" | "degraded" | "error";
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
export function DashboardWidget(_props: PluginWidgetProps) {
|
||||
const { data, loading, error } = usePluginData<HealthData>("health");
|
||||
|
||||
if (loading) return <div>Loading environment health...</div>;
|
||||
if (error) return <div>Plugin error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "0.5rem" }}>
|
||||
<strong>${displayName}</strong>
|
||||
<div>Health: {data?.status ?? "unknown"}</div>
|
||||
<div>Checked: {data?.checkedAt ?? "never"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "tests", "plugin.spec.ts"),
|
||||
`import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createEnvironmentTestHarness,
|
||||
createFakeEnvironmentDriver,
|
||||
assertEnvironmentEventOrder,
|
||||
assertLeaseLifecycle,
|
||||
} from "@paperclipai/plugin-sdk/testing";
|
||||
import manifest from "../src/manifest.js";
|
||||
import plugin from "../src/worker.js";
|
||||
|
||||
const ENV_ID = "env-test-1";
|
||||
const BASE_PARAMS = {
|
||||
driverKey: manifest.environmentDrivers![0].driverKey,
|
||||
companyId: "co-1",
|
||||
environmentId: ENV_ID,
|
||||
config: {},
|
||||
};
|
||||
|
||||
describe("environment plugin scaffold", () => {
|
||||
it("validates config", async () => {
|
||||
const driver = createFakeEnvironmentDriver({ driverKey: BASE_PARAMS.driverKey });
|
||||
const harness = createEnvironmentTestHarness({ manifest, environmentDriver: driver });
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
||||
driverKey: BASE_PARAMS.driverKey,
|
||||
config: { host: "test" },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("probes the environment", async () => {
|
||||
const driver = createFakeEnvironmentDriver({ driverKey: BASE_PARAMS.driverKey });
|
||||
const harness = createEnvironmentTestHarness({ manifest, environmentDriver: driver });
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const result = await plugin.definition.onEnvironmentProbe!(BASE_PARAMS);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("runs a full lease lifecycle through the harness", async () => {
|
||||
const driver = createFakeEnvironmentDriver({ driverKey: BASE_PARAMS.driverKey });
|
||||
const harness = createEnvironmentTestHarness({ manifest, environmentDriver: driver });
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const lease = await harness.acquireLease({ ...BASE_PARAMS, runId: "run-1" });
|
||||
expect(lease.providerLeaseId).toBeTruthy();
|
||||
|
||||
await harness.realizeWorkspace({
|
||||
...BASE_PARAMS,
|
||||
lease,
|
||||
workspace: { localPath: "/tmp/test" },
|
||||
});
|
||||
|
||||
await harness.releaseLease({
|
||||
...BASE_PARAMS,
|
||||
providerLeaseId: lease.providerLeaseId,
|
||||
});
|
||||
|
||||
assertEnvironmentEventOrder(harness.environmentEvents, [
|
||||
"acquireLease",
|
||||
"realizeWorkspace",
|
||||
"releaseLease",
|
||||
]);
|
||||
assertLeaseLifecycle(harness.environmentEvents, ENV_ID);
|
||||
});
|
||||
});
|
||||
`,
|
||||
);
|
||||
} else {
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "manifest.ts"),
|
||||
`import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: ${quote(manifestId)},
|
||||
|
|
@ -331,11 +553,11 @@ const manifest: PaperclipPluginManifestV1 = {
|
|||
|
||||
export default manifest;
|
||||
`,
|
||||
);
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "worker.ts"),
|
||||
`import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "worker.ts"),
|
||||
`import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
|
|
@ -363,11 +585,11 @@ const plugin = definePlugin({
|
|||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
`,
|
||||
);
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "ui", "index.tsx"),
|
||||
`import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "ui", "index.tsx"),
|
||||
`import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
type HealthData = {
|
||||
status: "ok" | "degraded" | "error";
|
||||
|
|
@ -391,11 +613,11 @@ export function DashboardWidget(_props: PluginWidgetProps) {
|
|||
);
|
||||
}
|
||||
`,
|
||||
);
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "tests", "plugin.spec.ts"),
|
||||
`import { describe, expect, it } from "vitest";
|
||||
writeFile(
|
||||
path.join(outputDir, "tests", "plugin.spec.ts"),
|
||||
`import { describe, expect, it } from "vitest";
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import manifest from "../src/manifest.js";
|
||||
import plugin from "../src/worker.js";
|
||||
|
|
@ -416,7 +638,8 @@ describe("plugin scaffold", () => {
|
|||
});
|
||||
});
|
||||
`,
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "README.md"),
|
||||
|
|
|
|||
29
packages/plugins/paperclip-plugin-fake-sandbox/package.json
Normal file
29
packages/plugins/paperclip-plugin-fake-sandbox/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "@paperclipai/plugin-fake-sandbox",
|
||||
"version": "0.1.0",
|
||||
"description": "First-party deterministic fake sandbox provider plugin for Paperclip environments",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js"
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "node ../../../scripts/ensure-plugin-build-deps.mjs",
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit",
|
||||
"test": "vitest run --config vitest.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as manifest } from "./manifest.js";
|
||||
export { default as plugin } from "./plugin.js";
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_ID = "paperclip.fake-sandbox-provider";
|
||||
const PLUGIN_VERSION = "0.1.0";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: PLUGIN_VERSION,
|
||||
displayName: "Fake Sandbox Provider",
|
||||
description:
|
||||
"First-party deterministic sandbox provider plugin for exercising Paperclip provider-plugin integration without external infrastructure.",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
},
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "fake-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Fake Sandbox Provider",
|
||||
description:
|
||||
"Runs commands in an isolated local temporary directory while exercising the sandbox provider plugin lifecycle.",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
image: {
|
||||
type: "string",
|
||||
description: "Deterministic fake image label for metadata and matching.",
|
||||
default: "fake:latest",
|
||||
},
|
||||
timeoutMs: {
|
||||
type: "number",
|
||||
description: "Command timeout in milliseconds.",
|
||||
default: 300000,
|
||||
},
|
||||
reuseLease: {
|
||||
type: "boolean",
|
||||
description: "Whether to reuse fake leases by environment id.",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertEnvironmentEventOrder,
|
||||
createEnvironmentTestHarness,
|
||||
} from "@paperclipai/plugin-sdk/testing";
|
||||
import manifest from "./manifest.js";
|
||||
import plugin from "./plugin.js";
|
||||
|
||||
describe("fake sandbox provider plugin", () => {
|
||||
it("runs a deterministic provider lifecycle through environment hooks", async () => {
|
||||
const definition = plugin.definition;
|
||||
const harness = createEnvironmentTestHarness({
|
||||
manifest,
|
||||
environmentDriver: {
|
||||
driverKey: "fake-plugin",
|
||||
onValidateConfig: definition.onEnvironmentValidateConfig,
|
||||
onProbe: definition.onEnvironmentProbe,
|
||||
onAcquireLease: definition.onEnvironmentAcquireLease,
|
||||
onResumeLease: definition.onEnvironmentResumeLease,
|
||||
onReleaseLease: definition.onEnvironmentReleaseLease,
|
||||
onDestroyLease: definition.onEnvironmentDestroyLease,
|
||||
onRealizeWorkspace: definition.onEnvironmentRealizeWorkspace,
|
||||
onExecute: definition.onEnvironmentExecute,
|
||||
},
|
||||
});
|
||||
const base = {
|
||||
driverKey: "fake-plugin",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: { image: "fake:test", reuseLease: false },
|
||||
};
|
||||
|
||||
const validation = await harness.validateConfig({
|
||||
driverKey: "fake-plugin",
|
||||
config: base.config,
|
||||
});
|
||||
expect(validation).toMatchObject({
|
||||
ok: true,
|
||||
normalizedConfig: { image: "fake:test", reuseLease: false },
|
||||
});
|
||||
|
||||
const probe = await harness.probe(base);
|
||||
expect(probe).toMatchObject({
|
||||
ok: true,
|
||||
metadata: { provider: "fake-plugin", image: "fake:test" },
|
||||
});
|
||||
|
||||
const lease = await harness.acquireLease({ ...base, runId: "run-1" });
|
||||
expect(lease.providerLeaseId).toContain("fake-plugin://run-1/");
|
||||
|
||||
const realized = await harness.realizeWorkspace({
|
||||
...base,
|
||||
lease,
|
||||
workspace: { mode: "isolated_workspace" },
|
||||
});
|
||||
expect(realized.cwd).toContain("paperclip-fake-sandbox-");
|
||||
|
||||
const executed = await harness.execute({
|
||||
...base,
|
||||
lease,
|
||||
command: "sh",
|
||||
args: ["-lc", "printf fake-plugin-ok"],
|
||||
cwd: realized.cwd,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
expect(executed).toMatchObject({
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
stdout: "fake-plugin-ok",
|
||||
});
|
||||
|
||||
await harness.destroyLease({
|
||||
...base,
|
||||
providerLeaseId: lease.providerLeaseId,
|
||||
});
|
||||
|
||||
assertEnvironmentEventOrder(harness.environmentEvents, [
|
||||
"validateConfig",
|
||||
"probe",
|
||||
"acquireLease",
|
||||
"realizeWorkspace",
|
||||
"execute",
|
||||
"destroyLease",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not expose host-only environment variables to executed commands", async () => {
|
||||
const previousSecret = process.env.PAPERCLIP_FAKE_PLUGIN_HOST_SECRET;
|
||||
process.env.PAPERCLIP_FAKE_PLUGIN_HOST_SECRET = "should-not-leak";
|
||||
try {
|
||||
const definition = plugin.definition;
|
||||
const harness = createEnvironmentTestHarness({
|
||||
manifest,
|
||||
environmentDriver: {
|
||||
driverKey: "fake-plugin",
|
||||
onAcquireLease: definition.onEnvironmentAcquireLease,
|
||||
onDestroyLease: definition.onEnvironmentDestroyLease,
|
||||
onRealizeWorkspace: definition.onEnvironmentRealizeWorkspace,
|
||||
onExecute: definition.onEnvironmentExecute,
|
||||
},
|
||||
});
|
||||
const base = {
|
||||
driverKey: "fake-plugin",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: { image: "fake:test", reuseLease: false },
|
||||
};
|
||||
const lease = await harness.acquireLease({ ...base, runId: "run-1" });
|
||||
const realized = await harness.realizeWorkspace({
|
||||
...base,
|
||||
lease,
|
||||
workspace: { mode: "isolated_workspace" },
|
||||
});
|
||||
|
||||
const executed = await harness.execute({
|
||||
...base,
|
||||
lease,
|
||||
command: "sh",
|
||||
args: ["-lc", "test -z \"${PAPERCLIP_FAKE_PLUGIN_HOST_SECRET+x}\" && printf \"$EXPLICIT_ONLY\""],
|
||||
cwd: realized.cwd,
|
||||
env: { EXPLICIT_ONLY: "visible" },
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(executed).toMatchObject({
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
stdout: "visible",
|
||||
});
|
||||
|
||||
await harness.destroyLease({
|
||||
...base,
|
||||
providerLeaseId: lease.providerLeaseId,
|
||||
});
|
||||
} finally {
|
||||
if (previousSecret === undefined) {
|
||||
delete process.env.PAPERCLIP_FAKE_PLUGIN_HOST_SECRET;
|
||||
} else {
|
||||
process.env.PAPERCLIP_FAKE_PLUGIN_HOST_SECRET = previousSecret;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("includes /usr/local/bin in the default PATH when no PATH override is provided", async () => {
|
||||
const definition = plugin.definition;
|
||||
const harness = createEnvironmentTestHarness({
|
||||
manifest,
|
||||
environmentDriver: {
|
||||
driverKey: "fake-plugin",
|
||||
onAcquireLease: definition.onEnvironmentAcquireLease,
|
||||
onDestroyLease: definition.onEnvironmentDestroyLease,
|
||||
onRealizeWorkspace: definition.onEnvironmentRealizeWorkspace,
|
||||
onExecute: definition.onEnvironmentExecute,
|
||||
},
|
||||
});
|
||||
const base = {
|
||||
driverKey: "fake-plugin",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: { image: "fake:test", reuseLease: false },
|
||||
};
|
||||
const lease = await harness.acquireLease({ ...base, runId: "run-1" });
|
||||
const realized = await harness.realizeWorkspace({
|
||||
...base,
|
||||
lease,
|
||||
workspace: { mode: "isolated_workspace" },
|
||||
});
|
||||
|
||||
const executed = await harness.execute({
|
||||
...base,
|
||||
lease,
|
||||
command: "sh",
|
||||
args: ["-lc", "printf %s \"$PATH\""],
|
||||
cwd: realized.cwd,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(executed.stdout).toContain("/usr/local/bin");
|
||||
|
||||
await harness.destroyLease({
|
||||
...base,
|
||||
providerLeaseId: lease.providerLeaseId,
|
||||
});
|
||||
});
|
||||
|
||||
it("escalates to SIGKILL after timeout if the child ignores SIGTERM", async () => {
|
||||
const definition = plugin.definition;
|
||||
const harness = createEnvironmentTestHarness({
|
||||
manifest,
|
||||
environmentDriver: {
|
||||
driverKey: "fake-plugin",
|
||||
onAcquireLease: definition.onEnvironmentAcquireLease,
|
||||
onDestroyLease: definition.onEnvironmentDestroyLease,
|
||||
onRealizeWorkspace: definition.onEnvironmentRealizeWorkspace,
|
||||
onExecute: definition.onEnvironmentExecute,
|
||||
},
|
||||
});
|
||||
const base = {
|
||||
driverKey: "fake-plugin",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: { image: "fake:test", reuseLease: false },
|
||||
};
|
||||
const lease = await harness.acquireLease({ ...base, runId: "run-1" });
|
||||
const realized = await harness.realizeWorkspace({
|
||||
...base,
|
||||
lease,
|
||||
workspace: { mode: "isolated_workspace" },
|
||||
});
|
||||
|
||||
const executed = await harness.execute({
|
||||
...base,
|
||||
lease,
|
||||
command: "sh",
|
||||
args: ["-lc", "trap '' TERM; while :; do sleep 1; done"],
|
||||
cwd: realized.cwd,
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
expect(executed.timedOut).toBe(true);
|
||||
expect(executed.exitCode).toBeNull();
|
||||
|
||||
await harness.destroyLease({
|
||||
...base,
|
||||
providerLeaseId: lease.providerLeaseId,
|
||||
});
|
||||
});
|
||||
});
|
||||
282
packages/plugins/paperclip-plugin-fake-sandbox/src/plugin.ts
Normal file
282
packages/plugins/paperclip-plugin-fake-sandbox/src/plugin.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { definePlugin } from "@paperclipai/plugin-sdk";
|
||||
import type {
|
||||
PluginEnvironmentAcquireLeaseParams,
|
||||
PluginEnvironmentDestroyLeaseParams,
|
||||
PluginEnvironmentExecuteParams,
|
||||
PluginEnvironmentExecuteResult,
|
||||
PluginEnvironmentLease,
|
||||
PluginEnvironmentProbeParams,
|
||||
PluginEnvironmentProbeResult,
|
||||
PluginEnvironmentRealizeWorkspaceParams,
|
||||
PluginEnvironmentRealizeWorkspaceResult,
|
||||
PluginEnvironmentReleaseLeaseParams,
|
||||
PluginEnvironmentResumeLeaseParams,
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
PluginEnvironmentValidationResult,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
|
||||
interface FakeDriverConfig {
|
||||
image: string;
|
||||
timeoutMs: number;
|
||||
reuseLease: boolean;
|
||||
}
|
||||
|
||||
interface FakeLeaseState {
|
||||
providerLeaseId: string;
|
||||
rootDir: string;
|
||||
remoteCwd: string;
|
||||
image: string;
|
||||
reuseLease: boolean;
|
||||
}
|
||||
|
||||
const leases = new Map<string, FakeLeaseState>();
|
||||
const DEFAULT_FAKE_SANDBOX_PATH = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
||||
const FAKE_SANDBOX_SIGKILL_GRACE_MS = 250;
|
||||
|
||||
function parseConfig(raw: Record<string, unknown>): FakeDriverConfig {
|
||||
return {
|
||||
image: typeof raw.image === "string" && raw.image.trim().length > 0 ? raw.image.trim() : "fake:latest",
|
||||
timeoutMs: typeof raw.timeoutMs === "number" && Number.isFinite(raw.timeoutMs) ? raw.timeoutMs : 300_000,
|
||||
reuseLease: raw.reuseLease === true,
|
||||
};
|
||||
}
|
||||
|
||||
async function createLeaseState(input: {
|
||||
providerLeaseId: string;
|
||||
image: string;
|
||||
reuseLease: boolean;
|
||||
}): Promise<FakeLeaseState> {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-fake-sandbox-"));
|
||||
const remoteCwd = path.join(rootDir, "workspace");
|
||||
await mkdir(remoteCwd, { recursive: true });
|
||||
const state = {
|
||||
providerLeaseId: input.providerLeaseId,
|
||||
rootDir,
|
||||
remoteCwd,
|
||||
image: input.image,
|
||||
reuseLease: input.reuseLease,
|
||||
};
|
||||
leases.set(input.providerLeaseId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function leaseMetadata(state: FakeLeaseState) {
|
||||
return {
|
||||
provider: "fake-plugin",
|
||||
image: state.image,
|
||||
reuseLease: state.reuseLease,
|
||||
remoteCwd: state.remoteCwd,
|
||||
fakeRootDir: state.rootDir,
|
||||
};
|
||||
}
|
||||
|
||||
async function removeLease(providerLeaseId: string | null | undefined): Promise<void> {
|
||||
if (!providerLeaseId) return;
|
||||
const state = leases.get(providerLeaseId);
|
||||
leases.delete(providerLeaseId);
|
||||
if (state) {
|
||||
await rm(state.rootDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function buildCommandLine(command: string, args: string[] | undefined): string {
|
||||
return [command, ...(args ?? [])].join(" ");
|
||||
}
|
||||
|
||||
function buildCommandEnvironment(explicitEnv: Record<string, string> | undefined): Record<string, string> {
|
||||
return {
|
||||
PATH: explicitEnv?.PATH ?? DEFAULT_FAKE_SANDBOX_PATH,
|
||||
...(explicitEnv ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function runCommand(params: PluginEnvironmentExecuteParams, timeoutMs: number): Promise<PluginEnvironmentExecuteResult> {
|
||||
const cwd = typeof params.cwd === "string" && params.cwd.length > 0 ? params.cwd : process.cwd();
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(params.command, params.args ?? [], {
|
||||
cwd,
|
||||
env: buildCommandEnvironment(params.env),
|
||||
shell: false,
|
||||
stdio: [params.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
let killTimer: NodeJS.Timeout | null = null;
|
||||
const timer = timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
killTimer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
}, FAKE_SANDBOX_SIGKILL_GRACE_MS);
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
resolve({
|
||||
exitCode: timedOut ? null : code,
|
||||
signal,
|
||||
timedOut,
|
||||
stdout,
|
||||
stderr,
|
||||
metadata: {
|
||||
startedAt,
|
||||
commandLine: buildCommandLine(params.command, params.args),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (params.stdin != null && child.stdin) {
|
||||
child.stdin.write(params.stdin);
|
||||
child.stdin.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.logger.info("Fake sandbox provider plugin ready");
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: "Fake sandbox provider plugin healthy" };
|
||||
},
|
||||
|
||||
async onEnvironmentValidateConfig(
|
||||
params: PluginEnvironmentValidateConfigParams,
|
||||
): Promise<PluginEnvironmentValidationResult> {
|
||||
const config = parseConfig(params.config);
|
||||
return {
|
||||
ok: true,
|
||||
normalizedConfig: { ...config },
|
||||
};
|
||||
},
|
||||
|
||||
async onEnvironmentProbe(
|
||||
params: PluginEnvironmentProbeParams,
|
||||
): Promise<PluginEnvironmentProbeResult> {
|
||||
const config = parseConfig(params.config);
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Fake sandbox provider is ready for image ${config.image}.`,
|
||||
metadata: {
|
||||
provider: "fake-plugin",
|
||||
image: config.image,
|
||||
timeoutMs: config.timeoutMs,
|
||||
reuseLease: config.reuseLease,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async onEnvironmentAcquireLease(
|
||||
params: PluginEnvironmentAcquireLeaseParams,
|
||||
): Promise<PluginEnvironmentLease> {
|
||||
const config = parseConfig(params.config);
|
||||
const providerLeaseId = config.reuseLease
|
||||
? `fake-plugin://${params.environmentId}`
|
||||
: `fake-plugin://${params.runId}/${randomUUID()}`;
|
||||
const existing = leases.get(providerLeaseId);
|
||||
const state = existing ?? await createLeaseState({
|
||||
providerLeaseId,
|
||||
image: config.image,
|
||||
reuseLease: config.reuseLease,
|
||||
});
|
||||
|
||||
return {
|
||||
providerLeaseId,
|
||||
metadata: {
|
||||
...leaseMetadata(state),
|
||||
resumedLease: Boolean(existing),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async onEnvironmentResumeLease(
|
||||
params: PluginEnvironmentResumeLeaseParams,
|
||||
): Promise<PluginEnvironmentLease> {
|
||||
const config = parseConfig(params.config);
|
||||
const existing = leases.get(params.providerLeaseId);
|
||||
const state = existing ?? await createLeaseState({
|
||||
providerLeaseId: params.providerLeaseId,
|
||||
image: config.image,
|
||||
reuseLease: config.reuseLease,
|
||||
});
|
||||
|
||||
return {
|
||||
providerLeaseId: state.providerLeaseId,
|
||||
metadata: {
|
||||
...leaseMetadata(state),
|
||||
resumedLease: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async onEnvironmentReleaseLease(
|
||||
params: PluginEnvironmentReleaseLeaseParams,
|
||||
): Promise<void> {
|
||||
const config = parseConfig(params.config);
|
||||
if (!config.reuseLease) {
|
||||
await removeLease(params.providerLeaseId);
|
||||
}
|
||||
},
|
||||
|
||||
async onEnvironmentDestroyLease(
|
||||
params: PluginEnvironmentDestroyLeaseParams,
|
||||
): Promise<void> {
|
||||
await removeLease(params.providerLeaseId);
|
||||
},
|
||||
|
||||
async onEnvironmentRealizeWorkspace(
|
||||
params: PluginEnvironmentRealizeWorkspaceParams,
|
||||
): Promise<PluginEnvironmentRealizeWorkspaceResult> {
|
||||
const state = params.lease.providerLeaseId
|
||||
? leases.get(params.lease.providerLeaseId)
|
||||
: null;
|
||||
const remoteCwd =
|
||||
state?.remoteCwd ??
|
||||
(typeof params.lease.metadata?.remoteCwd === "string" ? params.lease.metadata.remoteCwd : null) ??
|
||||
params.workspace.remotePath ??
|
||||
params.workspace.localPath ??
|
||||
path.join(os.tmpdir(), "paperclip-fake-sandbox-workspace");
|
||||
|
||||
await mkdir(remoteCwd, { recursive: true });
|
||||
|
||||
return {
|
||||
cwd: remoteCwd,
|
||||
metadata: {
|
||||
provider: "fake-plugin",
|
||||
remoteCwd,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async onEnvironmentExecute(
|
||||
params: PluginEnvironmentExecuteParams,
|
||||
): Promise<PluginEnvironmentExecuteResult> {
|
||||
const config = parseConfig(params.config);
|
||||
return await runCommand(params, params.timeoutMs ?? config.timeoutMs);
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { runWorker } from "@paperclipai/plugin-sdk";
|
||||
import plugin from "./plugin.js";
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
10
packages/plugins/paperclip-plugin-fake-sandbox/tsconfig.json
Normal file
10
packages/plugins/paperclip-plugin-fake-sandbox/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node", "vitest"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
|
|
@ -337,6 +337,7 @@ Declare in `manifest.capabilities`. Grouped by scope:
|
|||
| | `api.routes.register` |
|
||||
| | `http.outbound` |
|
||||
| | `secrets.read-ref` |
|
||||
| | `environment.drivers.register` |
|
||||
| **Agent** | `agent.tools.register` |
|
||||
| | `agents.invoke` |
|
||||
| | `agent.sessions.create` |
|
||||
|
|
|
|||
|
|
@ -48,6 +48,21 @@
|
|||
*/
|
||||
|
||||
import type { PluginContext } from "./types.js";
|
||||
import type {
|
||||
PluginEnvironmentAcquireLeaseParams,
|
||||
PluginEnvironmentDestroyLeaseParams,
|
||||
PluginEnvironmentExecuteParams,
|
||||
PluginEnvironmentExecuteResult,
|
||||
PluginEnvironmentLease,
|
||||
PluginEnvironmentProbeParams,
|
||||
PluginEnvironmentProbeResult,
|
||||
PluginEnvironmentRealizeWorkspaceParams,
|
||||
PluginEnvironmentRealizeWorkspaceResult,
|
||||
PluginEnvironmentReleaseLeaseParams,
|
||||
PluginEnvironmentResumeLeaseParams,
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
PluginEnvironmentValidationResult,
|
||||
} from "./protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check result
|
||||
|
|
@ -228,6 +243,48 @@ export interface PluginDefinition {
|
|||
* access, capabilities, and checkout policy.
|
||||
*/
|
||||
onApiRequest?(input: PluginApiRequestInput): Promise<PluginApiResponse>;
|
||||
/**
|
||||
* Called to validate provider-specific configuration for a plugin-hosted
|
||||
* environment driver.
|
||||
*/
|
||||
onEnvironmentValidateConfig?(
|
||||
params: PluginEnvironmentValidateConfigParams,
|
||||
): Promise<PluginEnvironmentValidationResult>;
|
||||
|
||||
/** Called to test reachability or readiness of a plugin-hosted environment. */
|
||||
onEnvironmentProbe?(
|
||||
params: PluginEnvironmentProbeParams,
|
||||
): Promise<PluginEnvironmentProbeResult>;
|
||||
|
||||
/** Called before a run starts to acquire a provider lease. */
|
||||
onEnvironmentAcquireLease?(
|
||||
params: PluginEnvironmentAcquireLeaseParams,
|
||||
): Promise<PluginEnvironmentLease>;
|
||||
|
||||
/** Called to reconnect to a previously acquired provider lease. */
|
||||
onEnvironmentResumeLease?(
|
||||
params: PluginEnvironmentResumeLeaseParams,
|
||||
): Promise<PluginEnvironmentLease>;
|
||||
|
||||
/** Called when a run finishes and the provider lease can be released. */
|
||||
onEnvironmentReleaseLease?(
|
||||
params: PluginEnvironmentReleaseLeaseParams,
|
||||
): Promise<void>;
|
||||
|
||||
/** Called when the host needs to force-destroy provider state. */
|
||||
onEnvironmentDestroyLease?(
|
||||
params: PluginEnvironmentDestroyLeaseParams,
|
||||
): Promise<void>;
|
||||
|
||||
/** Called to materialize the run workspace inside the provider lease. */
|
||||
onEnvironmentRealizeWorkspace?(
|
||||
params: PluginEnvironmentRealizeWorkspaceParams,
|
||||
): Promise<PluginEnvironmentRealizeWorkspaceResult>;
|
||||
|
||||
/** Called to execute a command inside the provider lease. */
|
||||
onEnvironmentExecute?(
|
||||
params: PluginEnvironmentExecuteParams,
|
||||
): Promise<PluginEnvironmentExecuteResult>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { definePlugin } from "./define-plugin.js";
|
||||
export { createTestHarness } from "./testing.js";
|
||||
export { createTestHarness, createEnvironmentTestHarness, createFakeEnvironmentDriver, filterEnvironmentEvents, assertEnvironmentEventOrder, assertLeaseLifecycle, assertWorkspaceRealizationLifecycle, assertExecutionLifecycle, assertEnvironmentError } from "./testing.js";
|
||||
export { createPluginBundlerPresets } from "./bundlers.js";
|
||||
export { startPluginDevServer, getUiBuildSnapshot } from "./dev-server.js";
|
||||
export { startWorkerRpcHost, runWorker } from "./worker-rpc-host.js";
|
||||
|
|
@ -102,6 +102,10 @@ export type {
|
|||
TestHarness,
|
||||
TestHarnessOptions,
|
||||
TestHarnessLogEntry,
|
||||
EnvironmentTestHarness,
|
||||
EnvironmentTestHarnessOptions,
|
||||
EnvironmentEventRecord,
|
||||
FakeEnvironmentDriverOptions,
|
||||
} from "./testing.js";
|
||||
export type {
|
||||
PluginBundlerPresetInput,
|
||||
|
|
@ -142,6 +146,21 @@ export type {
|
|||
GetDataParams,
|
||||
PerformActionParams,
|
||||
ExecuteToolParams,
|
||||
PluginEnvironmentDiagnostic,
|
||||
PluginEnvironmentDriverBaseParams,
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
PluginEnvironmentValidationResult,
|
||||
PluginEnvironmentProbeParams,
|
||||
PluginEnvironmentProbeResult,
|
||||
PluginEnvironmentLease,
|
||||
PluginEnvironmentAcquireLeaseParams,
|
||||
PluginEnvironmentResumeLeaseParams,
|
||||
PluginEnvironmentReleaseLeaseParams,
|
||||
PluginEnvironmentDestroyLeaseParams,
|
||||
PluginEnvironmentRealizeWorkspaceParams,
|
||||
PluginEnvironmentRealizeWorkspaceResult,
|
||||
PluginEnvironmentExecuteParams,
|
||||
PluginEnvironmentExecuteResult,
|
||||
PluginModalBoundsRequest,
|
||||
PluginRenderCloseEvent,
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
|
|
@ -235,6 +254,7 @@ export type {
|
|||
PluginJobDeclaration,
|
||||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginEnvironmentDriverDeclaration,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginUiDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
|
|
|
|||
|
|
@ -325,6 +325,99 @@ export interface ExecuteToolParams {
|
|||
runContext: ToolRunContext;
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentDiagnostic {
|
||||
severity: "info" | "warning" | "error";
|
||||
message: string;
|
||||
code?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentDriverBaseParams {
|
||||
driverKey: string;
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentValidateConfigParams {
|
||||
driverKey: string;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentValidationResult {
|
||||
ok: boolean;
|
||||
warnings?: string[];
|
||||
errors?: string[];
|
||||
normalizedConfig?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentProbeParams extends PluginEnvironmentDriverBaseParams {}
|
||||
|
||||
export interface PluginEnvironmentProbeResult {
|
||||
ok: boolean;
|
||||
summary?: string;
|
||||
diagnostics?: PluginEnvironmentDiagnostic[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentLease {
|
||||
providerLeaseId: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentAcquireLeaseParams extends PluginEnvironmentDriverBaseParams {
|
||||
runId: string;
|
||||
workspaceMode?: string;
|
||||
requestedCwd?: string;
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentResumeLeaseParams extends PluginEnvironmentDriverBaseParams {
|
||||
providerLeaseId: string;
|
||||
leaseMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentReleaseLeaseParams extends PluginEnvironmentDriverBaseParams {
|
||||
providerLeaseId: string | null;
|
||||
leaseMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentDestroyLeaseParams extends PluginEnvironmentReleaseLeaseParams {}
|
||||
|
||||
export interface PluginEnvironmentRealizeWorkspaceParams extends PluginEnvironmentDriverBaseParams {
|
||||
lease: PluginEnvironmentLease;
|
||||
workspace: {
|
||||
localPath?: string;
|
||||
remotePath?: string;
|
||||
mode?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentRealizeWorkspaceResult {
|
||||
cwd: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentExecuteParams extends PluginEnvironmentDriverBaseParams {
|
||||
lease: PluginEnvironmentLease;
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentExecuteResult {
|
||||
exitCode: number | null;
|
||||
signal?: string | null;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI launcher / modal host interaction payloads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -394,6 +487,38 @@ export interface HostToWorkerMethods {
|
|||
performAction: [params: PerformActionParams, result: unknown];
|
||||
/** @see PLUGIN_SPEC.md §13.10 */
|
||||
executeTool: [params: ExecuteToolParams, result: ToolResult];
|
||||
environmentValidateConfig: [
|
||||
params: PluginEnvironmentValidateConfigParams,
|
||||
result: PluginEnvironmentValidationResult,
|
||||
];
|
||||
environmentProbe: [
|
||||
params: PluginEnvironmentProbeParams,
|
||||
result: PluginEnvironmentProbeResult,
|
||||
];
|
||||
environmentAcquireLease: [
|
||||
params: PluginEnvironmentAcquireLeaseParams,
|
||||
result: PluginEnvironmentLease,
|
||||
];
|
||||
environmentResumeLease: [
|
||||
params: PluginEnvironmentResumeLeaseParams,
|
||||
result: PluginEnvironmentLease,
|
||||
];
|
||||
environmentReleaseLease: [
|
||||
params: PluginEnvironmentReleaseLeaseParams,
|
||||
result: void,
|
||||
];
|
||||
environmentDestroyLease: [
|
||||
params: PluginEnvironmentDestroyLeaseParams,
|
||||
result: void,
|
||||
];
|
||||
environmentRealizeWorkspace: [
|
||||
params: PluginEnvironmentRealizeWorkspaceParams,
|
||||
result: PluginEnvironmentRealizeWorkspaceResult,
|
||||
];
|
||||
environmentExecute: [
|
||||
params: PluginEnvironmentExecuteParams,
|
||||
result: PluginEnvironmentExecuteResult,
|
||||
];
|
||||
}
|
||||
|
||||
/** Union of all host→worker method names. */
|
||||
|
|
@ -417,6 +542,14 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[]
|
|||
"getData",
|
||||
"performAction",
|
||||
"executeTool",
|
||||
"environmentValidateConfig",
|
||||
"environmentProbe",
|
||||
"environmentAcquireLease",
|
||||
"environmentResumeLease",
|
||||
"environmentReleaseLease",
|
||||
"environmentDestroyLease",
|
||||
"environmentRealizeWorkspace",
|
||||
"environmentExecute",
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -29,6 +29,21 @@ import type {
|
|||
AgentSession,
|
||||
AgentSessionEvent,
|
||||
} from "./types.js";
|
||||
import type {
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
PluginEnvironmentValidationResult,
|
||||
PluginEnvironmentProbeParams,
|
||||
PluginEnvironmentProbeResult,
|
||||
PluginEnvironmentLease,
|
||||
PluginEnvironmentAcquireLeaseParams,
|
||||
PluginEnvironmentResumeLeaseParams,
|
||||
PluginEnvironmentReleaseLeaseParams,
|
||||
PluginEnvironmentDestroyLeaseParams,
|
||||
PluginEnvironmentRealizeWorkspaceParams,
|
||||
PluginEnvironmentRealizeWorkspaceResult,
|
||||
PluginEnvironmentExecuteParams,
|
||||
PluginEnvironmentExecuteResult,
|
||||
} from "./protocol.js";
|
||||
|
||||
export interface TestHarnessOptions {
|
||||
/** Plugin manifest used to seed capability checks and metadata. */
|
||||
|
|
@ -80,6 +95,262 @@ export interface TestHarness {
|
|||
dbExecutes: Array<{ sql: string; params?: unknown[] }>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment test harness types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Recorded environment lifecycle event for assertion helpers. */
|
||||
export interface EnvironmentEventRecord {
|
||||
type:
|
||||
| "validateConfig"
|
||||
| "probe"
|
||||
| "acquireLease"
|
||||
| "resumeLease"
|
||||
| "releaseLease"
|
||||
| "destroyLease"
|
||||
| "realizeWorkspace"
|
||||
| "execute";
|
||||
driverKey: string;
|
||||
environmentId: string;
|
||||
timestamp: string;
|
||||
params: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Options for creating an environment-aware test harness. */
|
||||
export interface EnvironmentTestHarnessOptions extends TestHarnessOptions {
|
||||
/** Environment driver hooks provided by the plugin under test. */
|
||||
environmentDriver: {
|
||||
driverKey: string;
|
||||
onValidateConfig?: (params: PluginEnvironmentValidateConfigParams) => Promise<PluginEnvironmentValidationResult>;
|
||||
onProbe?: (params: PluginEnvironmentProbeParams) => Promise<PluginEnvironmentProbeResult>;
|
||||
onAcquireLease?: (params: PluginEnvironmentAcquireLeaseParams) => Promise<PluginEnvironmentLease>;
|
||||
onResumeLease?: (params: PluginEnvironmentResumeLeaseParams) => Promise<PluginEnvironmentLease>;
|
||||
onReleaseLease?: (params: PluginEnvironmentReleaseLeaseParams) => Promise<void>;
|
||||
onDestroyLease?: (params: PluginEnvironmentDestroyLeaseParams) => Promise<void>;
|
||||
onRealizeWorkspace?: (params: PluginEnvironmentRealizeWorkspaceParams) => Promise<PluginEnvironmentRealizeWorkspaceResult>;
|
||||
onExecute?: (params: PluginEnvironmentExecuteParams) => Promise<PluginEnvironmentExecuteResult>;
|
||||
};
|
||||
}
|
||||
|
||||
/** Extended test harness with environment driver simulation. */
|
||||
export interface EnvironmentTestHarness extends TestHarness {
|
||||
/** Recorded environment lifecycle events for assertion. */
|
||||
environmentEvents: EnvironmentEventRecord[];
|
||||
/** Invoke the environment driver's validateConfig hook. */
|
||||
validateConfig(params: PluginEnvironmentValidateConfigParams): Promise<PluginEnvironmentValidationResult>;
|
||||
/** Invoke the environment driver's probe hook. */
|
||||
probe(params: PluginEnvironmentProbeParams): Promise<PluginEnvironmentProbeResult>;
|
||||
/** Invoke the environment driver's acquireLease hook. */
|
||||
acquireLease(params: PluginEnvironmentAcquireLeaseParams): Promise<PluginEnvironmentLease>;
|
||||
/** Invoke the environment driver's resumeLease hook. */
|
||||
resumeLease(params: PluginEnvironmentResumeLeaseParams): Promise<PluginEnvironmentLease>;
|
||||
/** Invoke the environment driver's releaseLease hook. */
|
||||
releaseLease(params: PluginEnvironmentReleaseLeaseParams): Promise<void>;
|
||||
/** Invoke the environment driver's destroyLease hook. */
|
||||
destroyLease(params: PluginEnvironmentDestroyLeaseParams): Promise<void>;
|
||||
/** Invoke the environment driver's realizeWorkspace hook. */
|
||||
realizeWorkspace(params: PluginEnvironmentRealizeWorkspaceParams): Promise<PluginEnvironmentRealizeWorkspaceResult>;
|
||||
/** Invoke the environment driver's execute hook. */
|
||||
execute(params: PluginEnvironmentExecuteParams): Promise<PluginEnvironmentExecuteResult>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment event assertion helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Filter environment events by type. */
|
||||
export function filterEnvironmentEvents(
|
||||
events: EnvironmentEventRecord[],
|
||||
type: EnvironmentEventRecord["type"],
|
||||
): EnvironmentEventRecord[] {
|
||||
return events.filter((e) => e.type === type);
|
||||
}
|
||||
|
||||
/** Assert that environment events occurred in the expected order. */
|
||||
export function assertEnvironmentEventOrder(
|
||||
events: EnvironmentEventRecord[],
|
||||
expectedOrder: EnvironmentEventRecord["type"][],
|
||||
): void {
|
||||
const actual = events.map((e) => e.type);
|
||||
const matched: EnvironmentEventRecord["type"][] = [];
|
||||
let cursor = 0;
|
||||
for (const eventType of actual) {
|
||||
if (cursor < expectedOrder.length && eventType === expectedOrder[cursor]) {
|
||||
matched.push(eventType);
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
if (matched.length !== expectedOrder.length) {
|
||||
throw new Error(
|
||||
`Environment event order mismatch.\nExpected: ${JSON.stringify(expectedOrder)}\nActual: ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert that a full lease lifecycle (acquire → release) occurred for an environment. */
|
||||
export function assertLeaseLifecycle(
|
||||
events: EnvironmentEventRecord[],
|
||||
environmentId: string,
|
||||
): { acquire: EnvironmentEventRecord; release: EnvironmentEventRecord } {
|
||||
const acquire = events.find((e) => e.type === "acquireLease" && e.environmentId === environmentId);
|
||||
const release = events.find((e) => (e.type === "releaseLease" || e.type === "destroyLease") && e.environmentId === environmentId);
|
||||
if (!acquire) throw new Error(`No acquireLease event found for environment ${environmentId}`);
|
||||
if (!release) throw new Error(`No releaseLease/destroyLease event found for environment ${environmentId}`);
|
||||
if (acquire.timestamp > release.timestamp) {
|
||||
throw new Error(`acquireLease occurred after release for environment ${environmentId}`);
|
||||
}
|
||||
return { acquire, release };
|
||||
}
|
||||
|
||||
/** Assert that workspace realization occurred between lease acquire and release. */
|
||||
export function assertWorkspaceRealizationLifecycle(
|
||||
events: EnvironmentEventRecord[],
|
||||
environmentId: string,
|
||||
): EnvironmentEventRecord {
|
||||
const lifecycle = assertLeaseLifecycle(events, environmentId);
|
||||
const realize = events.find(
|
||||
(e) => e.type === "realizeWorkspace" && e.environmentId === environmentId,
|
||||
);
|
||||
if (!realize) throw new Error(`No realizeWorkspace event found for environment ${environmentId}`);
|
||||
if (realize.timestamp < lifecycle.acquire.timestamp) {
|
||||
throw new Error(`realizeWorkspace occurred before acquireLease for environment ${environmentId}`);
|
||||
}
|
||||
if (realize.timestamp > lifecycle.release.timestamp) {
|
||||
throw new Error(`realizeWorkspace occurred after release for environment ${environmentId}`);
|
||||
}
|
||||
return realize;
|
||||
}
|
||||
|
||||
/** Assert that an execute call occurred within the lease lifecycle. */
|
||||
export function assertExecutionLifecycle(
|
||||
events: EnvironmentEventRecord[],
|
||||
environmentId: string,
|
||||
): EnvironmentEventRecord[] {
|
||||
const lifecycle = assertLeaseLifecycle(events, environmentId);
|
||||
const execEvents = events.filter(
|
||||
(e) => e.type === "execute" && e.environmentId === environmentId,
|
||||
);
|
||||
if (execEvents.length === 0) {
|
||||
throw new Error(`No execute events found for environment ${environmentId}`);
|
||||
}
|
||||
for (const exec of execEvents) {
|
||||
if (exec.timestamp < lifecycle.acquire.timestamp || exec.timestamp > lifecycle.release.timestamp) {
|
||||
throw new Error(`Execute event occurred outside lease lifecycle for environment ${environmentId}`);
|
||||
}
|
||||
}
|
||||
return execEvents;
|
||||
}
|
||||
|
||||
/** Assert that an event recorded an error. */
|
||||
export function assertEnvironmentError(
|
||||
events: EnvironmentEventRecord[],
|
||||
type: EnvironmentEventRecord["type"],
|
||||
environmentId?: string,
|
||||
): EnvironmentEventRecord {
|
||||
const match = events.find(
|
||||
(e) => e.type === type && e.error != null && (!environmentId || e.environmentId === environmentId),
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(`No error event of type '${type}'${environmentId ? ` for environment ${environmentId}` : ""}`);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake environment plugin driver
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Options for creating a fake environment driver for contract testing. */
|
||||
export interface FakeEnvironmentDriverOptions {
|
||||
driverKey?: string;
|
||||
/** Simulated acquire delay in ms. */
|
||||
acquireDelayMs?: number;
|
||||
/** If true, probe will return `ok: false`. */
|
||||
probeFailure?: boolean;
|
||||
/** If true, acquireLease will throw. */
|
||||
acquireFailure?: string;
|
||||
/** If true, execute will return a non-zero exit code. */
|
||||
executeFailure?: boolean;
|
||||
/** Custom metadata returned on lease acquire. */
|
||||
leaseMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fake environment driver suitable for contract testing.
|
||||
*
|
||||
* This returns a driver hooks object compatible with `EnvironmentTestHarnessOptions.environmentDriver`.
|
||||
* It simulates the full environment lifecycle with configurable failure injection.
|
||||
*/
|
||||
export function createFakeEnvironmentDriver(options: FakeEnvironmentDriverOptions = {}): EnvironmentTestHarnessOptions["environmentDriver"] {
|
||||
const driverKey = options.driverKey ?? "fake";
|
||||
const leases = new Map<string, { providerLeaseId: string; metadata: Record<string, unknown> }>();
|
||||
let leaseCounter = 0;
|
||||
|
||||
return {
|
||||
driverKey,
|
||||
async onValidateConfig(params) {
|
||||
if (!params.config || typeof params.config !== "object") {
|
||||
return { ok: false, errors: ["Config must be an object"] };
|
||||
}
|
||||
return { ok: true, normalizedConfig: params.config };
|
||||
},
|
||||
async onProbe(_params) {
|
||||
if (options.probeFailure) {
|
||||
return { ok: false, summary: "Simulated probe failure", diagnostics: [{ severity: "error", message: "Probe failed" }] };
|
||||
}
|
||||
return { ok: true, summary: "Fake environment is healthy" };
|
||||
},
|
||||
async onAcquireLease(params) {
|
||||
if (options.acquireFailure) {
|
||||
throw new Error(options.acquireFailure);
|
||||
}
|
||||
if (options.acquireDelayMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, options.acquireDelayMs));
|
||||
}
|
||||
const providerLeaseId = `fake-lease-${++leaseCounter}`;
|
||||
const metadata = { ...options.leaseMetadata, acquiredAt: new Date().toISOString(), runId: params.runId };
|
||||
leases.set(providerLeaseId, { providerLeaseId, metadata });
|
||||
return { providerLeaseId, metadata };
|
||||
},
|
||||
async onResumeLease(params) {
|
||||
const existing = leases.get(params.providerLeaseId);
|
||||
if (!existing) {
|
||||
throw new Error(`Lease ${params.providerLeaseId} not found — cannot resume`);
|
||||
}
|
||||
return { providerLeaseId: existing.providerLeaseId, metadata: { ...existing.metadata, resumed: true } };
|
||||
},
|
||||
async onReleaseLease(params) {
|
||||
if (params.providerLeaseId) {
|
||||
leases.delete(params.providerLeaseId);
|
||||
}
|
||||
},
|
||||
async onDestroyLease(params) {
|
||||
if (params.providerLeaseId) {
|
||||
leases.delete(params.providerLeaseId);
|
||||
}
|
||||
},
|
||||
async onRealizeWorkspace(params) {
|
||||
return {
|
||||
cwd: params.workspace.localPath ?? params.workspace.remotePath ?? "/tmp/fake-workspace",
|
||||
metadata: { realized: true },
|
||||
};
|
||||
},
|
||||
async onExecute(params) {
|
||||
if (options.executeFailure) {
|
||||
return { exitCode: 1, timedOut: false, stdout: "", stderr: "Simulated execution failure" };
|
||||
}
|
||||
return {
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
stdout: `Executed: ${params.command} ${(params.args ?? []).join(" ")}`.trim(),
|
||||
stderr: "",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type EventRegistration = {
|
||||
name: PluginEventType | `plugin.${string}`;
|
||||
filter?: EventFilter;
|
||||
|
|
@ -1036,3 +1307,89 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||
|
||||
return harness;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an environment-aware test harness that wraps the base harness with
|
||||
* environment driver simulation and lifecycle event recording.
|
||||
*
|
||||
* Use this to test environment plugins through the full host contract:
|
||||
* validateConfig → probe → acquireLease → realizeWorkspace → execute → releaseLease.
|
||||
*/
|
||||
export function createEnvironmentTestHarness(options: EnvironmentTestHarnessOptions): EnvironmentTestHarness {
|
||||
const base = createTestHarness(options);
|
||||
const environmentEvents: EnvironmentEventRecord[] = [];
|
||||
const driver = options.environmentDriver;
|
||||
|
||||
function record(
|
||||
type: EnvironmentEventRecord["type"],
|
||||
params: Record<string, unknown>,
|
||||
result?: unknown,
|
||||
error?: string,
|
||||
): EnvironmentEventRecord {
|
||||
const event: EnvironmentEventRecord = {
|
||||
type,
|
||||
driverKey: (params as { driverKey?: string }).driverKey ?? driver.driverKey,
|
||||
environmentId: (params as { environmentId?: string }).environmentId ?? "unknown",
|
||||
timestamp: new Date().toISOString(),
|
||||
params,
|
||||
result,
|
||||
error,
|
||||
};
|
||||
environmentEvents.push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function callHook<R>(
|
||||
type: EnvironmentEventRecord["type"],
|
||||
hook: ((...args: any[]) => Promise<R>) | undefined,
|
||||
params: unknown,
|
||||
hookName: string,
|
||||
): Promise<R> {
|
||||
if (!hook) {
|
||||
const err = `Environment driver '${driver.driverKey}' does not implement ${hookName}`;
|
||||
record(type, params as Record<string, unknown>, undefined, err);
|
||||
throw new Error(err);
|
||||
}
|
||||
try {
|
||||
const result = await hook(params);
|
||||
record(type, params as Record<string, unknown>, result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
record(type, params as Record<string, unknown>, undefined, msg);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const envHarness: EnvironmentTestHarness = {
|
||||
...base,
|
||||
environmentEvents,
|
||||
async validateConfig(params) {
|
||||
return callHook("validateConfig", driver.onValidateConfig, params, "onValidateConfig");
|
||||
},
|
||||
async probe(params) {
|
||||
return callHook("probe", driver.onProbe, params, "onProbe");
|
||||
},
|
||||
async acquireLease(params) {
|
||||
return callHook("acquireLease", driver.onAcquireLease, params, "onAcquireLease");
|
||||
},
|
||||
async resumeLease(params) {
|
||||
return callHook("resumeLease", driver.onResumeLease, params, "onResumeLease");
|
||||
},
|
||||
async releaseLease(params) {
|
||||
return callHook("releaseLease", driver.onReleaseLease, params, "onReleaseLease");
|
||||
},
|
||||
async destroyLease(params) {
|
||||
return callHook("destroyLease", driver.onDestroyLease, params, "onDestroyLease");
|
||||
},
|
||||
async realizeWorkspace(params) {
|
||||
return callHook("realizeWorkspace", driver.onRealizeWorkspace, params, "onRealizeWorkspace");
|
||||
},
|
||||
async execute(params) {
|
||||
return callHook("execute", driver.onExecute, params, "onExecute");
|
||||
},
|
||||
};
|
||||
|
||||
return envHarness;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export type {
|
|||
PluginJobDeclaration,
|
||||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginEnvironmentDriverDeclaration,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginUiDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,14 @@ import type {
|
|||
GetDataParams,
|
||||
PerformActionParams,
|
||||
ExecuteToolParams,
|
||||
PluginEnvironmentAcquireLeaseParams,
|
||||
PluginEnvironmentDestroyLeaseParams,
|
||||
PluginEnvironmentExecuteParams,
|
||||
PluginEnvironmentRealizeWorkspaceParams,
|
||||
PluginEnvironmentReleaseLeaseParams,
|
||||
PluginEnvironmentResumeLeaseParams,
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
PluginEnvironmentProbeParams,
|
||||
WorkerToHostMethodName,
|
||||
WorkerToHostMethods,
|
||||
} from "./protocol.js";
|
||||
|
|
@ -1079,6 +1087,30 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
case "executeTool":
|
||||
return handleExecuteTool(params as ExecuteToolParams);
|
||||
|
||||
case "environmentValidateConfig":
|
||||
return handleEnvironmentValidateConfig(params as PluginEnvironmentValidateConfigParams);
|
||||
|
||||
case "environmentProbe":
|
||||
return handleEnvironmentProbe(params as PluginEnvironmentProbeParams);
|
||||
|
||||
case "environmentAcquireLease":
|
||||
return handleEnvironmentAcquireLease(params as PluginEnvironmentAcquireLeaseParams);
|
||||
|
||||
case "environmentResumeLease":
|
||||
return handleEnvironmentResumeLease(params as PluginEnvironmentResumeLeaseParams);
|
||||
|
||||
case "environmentReleaseLease":
|
||||
return handleEnvironmentReleaseLease(params as PluginEnvironmentReleaseLeaseParams);
|
||||
|
||||
case "environmentDestroyLease":
|
||||
return handleEnvironmentDestroyLease(params as PluginEnvironmentDestroyLeaseParams);
|
||||
|
||||
case "environmentRealizeWorkspace":
|
||||
return handleEnvironmentRealizeWorkspace(params as PluginEnvironmentRealizeWorkspaceParams);
|
||||
|
||||
case "environmentExecute":
|
||||
return handleEnvironmentExecute(params as PluginEnvironmentExecuteParams);
|
||||
|
||||
default:
|
||||
throw Object.assign(
|
||||
new Error(`Unknown method: ${method}`),
|
||||
|
|
@ -1112,6 +1144,14 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
if (plugin.definition.onHealth) supportedMethods.push("health");
|
||||
if (plugin.definition.onShutdown) supportedMethods.push("shutdown");
|
||||
if (plugin.definition.onApiRequest) supportedMethods.push("handleApiRequest");
|
||||
if (plugin.definition.onEnvironmentValidateConfig) supportedMethods.push("environmentValidateConfig");
|
||||
if (plugin.definition.onEnvironmentProbe) supportedMethods.push("environmentProbe");
|
||||
if (plugin.definition.onEnvironmentAcquireLease) supportedMethods.push("environmentAcquireLease");
|
||||
if (plugin.definition.onEnvironmentResumeLease) supportedMethods.push("environmentResumeLease");
|
||||
if (plugin.definition.onEnvironmentReleaseLease) supportedMethods.push("environmentReleaseLease");
|
||||
if (plugin.definition.onEnvironmentDestroyLease) supportedMethods.push("environmentDestroyLease");
|
||||
if (plugin.definition.onEnvironmentRealizeWorkspace) supportedMethods.push("environmentRealizeWorkspace");
|
||||
if (plugin.definition.onEnvironmentExecute) supportedMethods.push("environmentExecute");
|
||||
|
||||
return { ok: true, supportedMethods };
|
||||
}
|
||||
|
|
@ -1255,6 +1295,71 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
return entry.fn(params.parameters, params.runContext);
|
||||
}
|
||||
|
||||
function methodNotImplemented(method: string): Error & { code: number } {
|
||||
return Object.assign(
|
||||
new Error(`${method} is not implemented by this plugin`),
|
||||
{ code: PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED },
|
||||
);
|
||||
}
|
||||
|
||||
async function handleEnvironmentValidateConfig(
|
||||
params: PluginEnvironmentValidateConfigParams,
|
||||
) {
|
||||
if (!plugin.definition.onEnvironmentValidateConfig) {
|
||||
throw methodNotImplemented("environmentValidateConfig");
|
||||
}
|
||||
return plugin.definition.onEnvironmentValidateConfig(params);
|
||||
}
|
||||
|
||||
async function handleEnvironmentProbe(params: PluginEnvironmentProbeParams) {
|
||||
if (!plugin.definition.onEnvironmentProbe) {
|
||||
throw methodNotImplemented("environmentProbe");
|
||||
}
|
||||
return plugin.definition.onEnvironmentProbe(params);
|
||||
}
|
||||
|
||||
async function handleEnvironmentAcquireLease(params: PluginEnvironmentAcquireLeaseParams) {
|
||||
if (!plugin.definition.onEnvironmentAcquireLease) {
|
||||
throw methodNotImplemented("environmentAcquireLease");
|
||||
}
|
||||
return plugin.definition.onEnvironmentAcquireLease(params);
|
||||
}
|
||||
|
||||
async function handleEnvironmentResumeLease(params: PluginEnvironmentResumeLeaseParams) {
|
||||
if (!plugin.definition.onEnvironmentResumeLease) {
|
||||
throw methodNotImplemented("environmentResumeLease");
|
||||
}
|
||||
return plugin.definition.onEnvironmentResumeLease(params);
|
||||
}
|
||||
|
||||
async function handleEnvironmentReleaseLease(params: PluginEnvironmentReleaseLeaseParams) {
|
||||
if (!plugin.definition.onEnvironmentReleaseLease) {
|
||||
throw methodNotImplemented("environmentReleaseLease");
|
||||
}
|
||||
return plugin.definition.onEnvironmentReleaseLease(params);
|
||||
}
|
||||
|
||||
async function handleEnvironmentDestroyLease(params: PluginEnvironmentDestroyLeaseParams) {
|
||||
if (!plugin.definition.onEnvironmentDestroyLease) {
|
||||
throw methodNotImplemented("environmentDestroyLease");
|
||||
}
|
||||
return plugin.definition.onEnvironmentDestroyLease(params);
|
||||
}
|
||||
|
||||
async function handleEnvironmentRealizeWorkspace(params: PluginEnvironmentRealizeWorkspaceParams) {
|
||||
if (!plugin.definition.onEnvironmentRealizeWorkspace) {
|
||||
throw methodNotImplemented("environmentRealizeWorkspace");
|
||||
}
|
||||
return plugin.definition.onEnvironmentRealizeWorkspace(params);
|
||||
}
|
||||
|
||||
async function handleEnvironmentExecute(params: PluginEnvironmentExecuteParams) {
|
||||
if (!plugin.definition.onEnvironmentExecute) {
|
||||
throw methodNotImplemented("environmentExecute");
|
||||
}
|
||||
return plugin.definition.onEnvironmentExecute(params);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event filter helper
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -218,16 +218,21 @@ export const PROJECT_STATUSES = [
|
|||
] as const;
|
||||
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
|
||||
|
||||
export const ENVIRONMENT_DRIVERS = ["local", "ssh"] as const;
|
||||
export const ENVIRONMENT_DRIVERS = ["local", "ssh", "sandbox", "plugin"] as const;
|
||||
export type EnvironmentDriver = (typeof ENVIRONMENT_DRIVERS)[number];
|
||||
|
||||
export const ENVIRONMENT_STATUSES = ["active", "archived"] as const;
|
||||
export type EnvironmentStatus = (typeof ENVIRONMENT_STATUSES)[number];
|
||||
|
||||
export const ENVIRONMENT_LEASE_STATUSES = ["active", "released", "expired", "failed"] as const;
|
||||
export const ENVIRONMENT_LEASE_STATUSES = ["active", "released", "expired", "failed", "retained"] as const;
|
||||
export type EnvironmentLeaseStatus = (typeof ENVIRONMENT_LEASE_STATUSES)[number];
|
||||
|
||||
export const ENVIRONMENT_LEASE_POLICIES = ["ephemeral"] as const;
|
||||
export const ENVIRONMENT_LEASE_POLICIES = [
|
||||
"ephemeral",
|
||||
"reuse_by_environment",
|
||||
"reuse_by_execution_workspace",
|
||||
"retain_on_failure",
|
||||
] as const;
|
||||
export type EnvironmentLeasePolicy = (typeof ENVIRONMENT_LEASE_POLICIES)[number];
|
||||
|
||||
export const ENVIRONMENT_LEASE_CLEANUP_STATUSES = ["pending", "success", "failed"] as const;
|
||||
|
|
@ -480,13 +485,13 @@ export type JoinRequestStatus = (typeof JOIN_REQUEST_STATUSES)[number];
|
|||
|
||||
export const PERMISSION_KEYS = [
|
||||
"agents:create",
|
||||
"environments:manage",
|
||||
"users:invite",
|
||||
"users:manage_permissions",
|
||||
"tasks:assign",
|
||||
"tasks:assign_scope",
|
||||
"tasks:manage_active_checkouts",
|
||||
"joins:approve",
|
||||
"environments:manage",
|
||||
] as const;
|
||||
export type PermissionKey = (typeof PERMISSION_KEYS)[number];
|
||||
|
||||
|
|
@ -598,6 +603,7 @@ export const PLUGIN_CAPABILITIES = [
|
|||
"api.routes.register",
|
||||
"http.outbound",
|
||||
"secrets.read-ref",
|
||||
"environment.drivers.register",
|
||||
// Agent Tools
|
||||
"agent.tools.register",
|
||||
// UI
|
||||
|
|
|
|||
16
packages/shared/src/environment-support.test.ts
Normal file
16
packages/shared/src/environment-support.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { isSandboxProviderSupportedForAdapter } from "./environment-support.js";
|
||||
|
||||
describe("isSandboxProviderSupportedForAdapter", () => {
|
||||
it("accepts additional sandbox providers for remote-managed adapters", () => {
|
||||
expect(
|
||||
isSandboxProviderSupportedForAdapter("codex_local", "fake-plugin", ["fake-plugin"]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects providers for adapters without remote-managed environment support", () => {
|
||||
expect(
|
||||
isSandboxProviderSupportedForAdapter("openclaw", "fake-plugin", ["fake-plugin"]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,15 +1,31 @@
|
|||
import type { AgentAdapterType, EnvironmentDriver } from "./constants.js";
|
||||
import type { SandboxEnvironmentProvider } from "./types/environment.js";
|
||||
|
||||
export type EnvironmentSupportStatus = "supported" | "unsupported";
|
||||
|
||||
export interface AdapterEnvironmentSupport {
|
||||
adapterType: AgentAdapterType;
|
||||
drivers: Record<EnvironmentDriver, EnvironmentSupportStatus>;
|
||||
sandboxProviders: Record<SandboxEnvironmentProvider, EnvironmentSupportStatus>;
|
||||
}
|
||||
|
||||
export interface EnvironmentProviderCapability {
|
||||
status: EnvironmentSupportStatus;
|
||||
supportsSavedProbe: boolean;
|
||||
supportsUnsavedProbe: boolean;
|
||||
supportsRunExecution: boolean;
|
||||
supportsReusableLeases: boolean;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
source?: "builtin" | "plugin";
|
||||
pluginKey?: string;
|
||||
pluginId?: string;
|
||||
}
|
||||
|
||||
export interface EnvironmentCapabilities {
|
||||
adapters: AdapterEnvironmentSupport[];
|
||||
drivers: Record<EnvironmentDriver, EnvironmentSupportStatus>;
|
||||
sandboxProviders: Record<SandboxEnvironmentProvider, EnvironmentProviderCapability>;
|
||||
}
|
||||
|
||||
const REMOTE_MANAGED_ADAPTERS = new Set<AgentAdapterType>([
|
||||
|
|
@ -27,10 +43,19 @@ export function adapterSupportsRemoteManagedEnvironments(adapterType: string): b
|
|||
|
||||
export function supportedEnvironmentDriversForAdapter(adapterType: string): EnvironmentDriver[] {
|
||||
return adapterSupportsRemoteManagedEnvironments(adapterType)
|
||||
? ["local", "ssh"]
|
||||
? ["local", "ssh", "sandbox"]
|
||||
: ["local"];
|
||||
}
|
||||
|
||||
export function supportedSandboxProvidersForAdapter(
|
||||
adapterType: string,
|
||||
additionalProviders: readonly string[] = [],
|
||||
): SandboxEnvironmentProvider[] {
|
||||
return adapterSupportsRemoteManagedEnvironments(adapterType)
|
||||
? Array.from(new Set(additionalProviders)) as SandboxEnvironmentProvider[]
|
||||
: [];
|
||||
}
|
||||
|
||||
export function isEnvironmentDriverSupportedForAdapter(
|
||||
adapterType: string,
|
||||
driver: string,
|
||||
|
|
@ -38,27 +63,83 @@ export function isEnvironmentDriverSupportedForAdapter(
|
|||
return supportedEnvironmentDriversForAdapter(adapterType).includes(driver as EnvironmentDriver);
|
||||
}
|
||||
|
||||
export function isSandboxProviderSupportedForAdapter(
|
||||
adapterType: string,
|
||||
provider: string | null | undefined,
|
||||
additionalProviders: readonly string[] = [],
|
||||
): boolean {
|
||||
if (!provider) return false;
|
||||
return supportedSandboxProvidersForAdapter(adapterType, additionalProviders).includes(
|
||||
provider as SandboxEnvironmentProvider,
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdapterEnvironmentSupport(
|
||||
adapterType: AgentAdapterType,
|
||||
additionalSandboxProviders: readonly string[] = [],
|
||||
): AdapterEnvironmentSupport {
|
||||
const supportedDrivers = new Set(supportedEnvironmentDriversForAdapter(adapterType));
|
||||
const supportedProviders = new Set(supportedSandboxProvidersForAdapter(adapterType, additionalSandboxProviders));
|
||||
const sandboxProviders: Record<SandboxEnvironmentProvider, EnvironmentSupportStatus> = {
|
||||
fake: supportedProviders.has("fake") ? "supported" : "unsupported",
|
||||
};
|
||||
for (const provider of additionalSandboxProviders) {
|
||||
sandboxProviders[provider as SandboxEnvironmentProvider] = supportedProviders.has(provider as SandboxEnvironmentProvider)
|
||||
? "supported"
|
||||
: "unsupported";
|
||||
}
|
||||
return {
|
||||
adapterType,
|
||||
drivers: {
|
||||
local: supportedDrivers.has("local") ? "supported" : "unsupported",
|
||||
ssh: supportedDrivers.has("ssh") ? "supported" : "unsupported",
|
||||
sandbox: supportedDrivers.has("sandbox") ? "supported" : "unsupported",
|
||||
plugin: supportedDrivers.has("plugin") ? "supported" : "unsupported",
|
||||
},
|
||||
sandboxProviders,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEnvironmentCapabilities(
|
||||
adapterTypes: readonly AgentAdapterType[],
|
||||
options: {
|
||||
sandboxProviders?: Record<string, Partial<EnvironmentProviderCapability>>;
|
||||
} = {},
|
||||
): EnvironmentCapabilities {
|
||||
const pluginProviderKeys = Object.keys(options.sandboxProviders ?? {});
|
||||
const sandboxProviders: Record<SandboxEnvironmentProvider, EnvironmentProviderCapability> = {
|
||||
fake: {
|
||||
status: "unsupported",
|
||||
supportsSavedProbe: true,
|
||||
supportsUnsavedProbe: true,
|
||||
supportsRunExecution: false,
|
||||
supportsReusableLeases: true,
|
||||
displayName: "Fake",
|
||||
source: "builtin",
|
||||
},
|
||||
};
|
||||
for (const [provider, capability] of Object.entries(options.sandboxProviders ?? {})) {
|
||||
sandboxProviders[provider as SandboxEnvironmentProvider] = {
|
||||
status: capability.status ?? "supported",
|
||||
supportsSavedProbe: capability.supportsSavedProbe ?? true,
|
||||
supportsUnsavedProbe: capability.supportsUnsavedProbe ?? true,
|
||||
supportsRunExecution: capability.supportsRunExecution ?? true,
|
||||
supportsReusableLeases: capability.supportsReusableLeases ?? true,
|
||||
displayName: capability.displayName,
|
||||
description: capability.description,
|
||||
source: capability.source ?? "plugin",
|
||||
pluginKey: capability.pluginKey,
|
||||
pluginId: capability.pluginId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
adapters: adapterTypes.map((adapterType) => getAdapterEnvironmentSupport(adapterType)),
|
||||
adapters: adapterTypes.map((adapterType) => getAdapterEnvironmentSupport(adapterType, pluginProviderKeys)),
|
||||
drivers: {
|
||||
local: "supported",
|
||||
ssh: "supported",
|
||||
sandbox: "supported",
|
||||
plugin: "unsupported",
|
||||
},
|
||||
sandboxProviders,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,7 +219,12 @@ export type {
|
|||
Environment,
|
||||
EnvironmentLease,
|
||||
EnvironmentProbeResult,
|
||||
FakeSandboxEnvironmentConfig,
|
||||
LocalEnvironmentConfig,
|
||||
PluginSandboxEnvironmentConfig,
|
||||
PluginEnvironmentConfig,
|
||||
SandboxEnvironmentConfig,
|
||||
SandboxEnvironmentProvider,
|
||||
SshEnvironmentConfig,
|
||||
FeedbackVote,
|
||||
FeedbackDataSharingPreference,
|
||||
|
|
@ -300,6 +305,10 @@ export type {
|
|||
WorkspaceOperationPhase,
|
||||
WorkspaceOperationStatus,
|
||||
WorkspaceRuntimeDesiredState,
|
||||
WorkspaceRealizationRecord,
|
||||
WorkspaceRealizationRequest,
|
||||
WorkspaceRealizationSyncStrategy,
|
||||
WorkspaceRealizationTransport,
|
||||
ExecutionWorkspaceStrategyType,
|
||||
ExecutionWorkspaceMode,
|
||||
ExecutionWorkspaceProviderType,
|
||||
|
|
@ -471,6 +480,7 @@ export type {
|
|||
PluginJobDeclaration,
|
||||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginEnvironmentDriverDeclaration,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
PluginLauncherRenderDeclaration,
|
||||
|
|
@ -542,17 +552,6 @@ export {
|
|||
isClosedIsolatedExecutionWorkspace,
|
||||
} from "./execution-workspace-guards.js";
|
||||
|
||||
export {
|
||||
adapterSupportsRemoteManagedEnvironments,
|
||||
getAdapterEnvironmentSupport,
|
||||
getEnvironmentCapabilities,
|
||||
isEnvironmentDriverSupportedForAdapter,
|
||||
supportedEnvironmentDriversForAdapter,
|
||||
type AdapterEnvironmentSupport,
|
||||
type EnvironmentCapabilities,
|
||||
type EnvironmentSupportStatus,
|
||||
} from "./environment-support.js";
|
||||
|
||||
export {
|
||||
instanceGeneralSettingsSchema,
|
||||
patchInstanceGeneralSettingsSchema,
|
||||
|
|
@ -824,6 +823,7 @@ export {
|
|||
pluginJobDeclarationSchema,
|
||||
pluginWebhookDeclarationSchema,
|
||||
pluginToolDeclarationSchema,
|
||||
pluginEnvironmentDriverDeclarationSchema,
|
||||
pluginUiSlotDeclarationSchema,
|
||||
pluginLauncherActionDeclarationSchema,
|
||||
pluginLauncherRenderDeclarationSchema,
|
||||
|
|
@ -842,6 +842,7 @@ export {
|
|||
type PluginJobDeclarationInput,
|
||||
type PluginWebhookDeclarationInput,
|
||||
type PluginToolDeclarationInput,
|
||||
type PluginEnvironmentDriverDeclarationInput,
|
||||
type PluginUiSlotDeclarationInput,
|
||||
type PluginLauncherActionDeclarationInput,
|
||||
type PluginLauncherRenderDeclarationInput,
|
||||
|
|
@ -926,3 +927,20 @@ export {
|
|||
type SecretsLocalEncryptedConfig,
|
||||
type ConfigMeta,
|
||||
} from "./config-schema.js";
|
||||
|
||||
export {
|
||||
adapterSupportsRemoteManagedEnvironments,
|
||||
getEnvironmentCapabilities,
|
||||
getAdapterEnvironmentSupport,
|
||||
isEnvironmentDriverSupportedForAdapter,
|
||||
isSandboxProviderSupportedForAdapter,
|
||||
supportedEnvironmentDriversForAdapter,
|
||||
supportedSandboxProvidersForAdapter,
|
||||
} from "./environment-support.js";
|
||||
|
||||
export type {
|
||||
AdapterEnvironmentSupport,
|
||||
EnvironmentCapabilities,
|
||||
EnvironmentProviderCapability,
|
||||
EnvironmentSupportStatus,
|
||||
} from "./environment-support.js";
|
||||
|
|
|
|||
|
|
@ -22,6 +22,41 @@ export interface SshEnvironmentConfig {
|
|||
strictHostKeyChecking: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Known sandbox environment provider keys.
|
||||
*
|
||||
* `"fake"` is a built-in test-only provider.
|
||||
*
|
||||
* Additional providers can be added by installing sandbox provider plugins
|
||||
* that declare matching `environmentDrivers` in their manifest. The type
|
||||
* includes `string` to allow plugin-backed providers without requiring
|
||||
* shared type changes.
|
||||
*/
|
||||
export type SandboxEnvironmentProvider = "fake" | (string & {});
|
||||
|
||||
export interface FakeSandboxEnvironmentConfig {
|
||||
provider: "fake";
|
||||
image: string;
|
||||
reuseLease: boolean;
|
||||
}
|
||||
|
||||
export interface PluginSandboxEnvironmentConfig {
|
||||
provider: SandboxEnvironmentProvider;
|
||||
reuseLease: boolean;
|
||||
timeoutMs?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type SandboxEnvironmentConfig =
|
||||
| FakeSandboxEnvironmentConfig
|
||||
| PluginSandboxEnvironmentConfig;
|
||||
|
||||
export interface PluginEnvironmentConfig {
|
||||
pluginKey: string;
|
||||
driverKey: string;
|
||||
driverConfig: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface EnvironmentProbeResult {
|
||||
ok: boolean;
|
||||
driver: EnvironmentDriver;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ export type {
|
|||
Environment,
|
||||
EnvironmentLease,
|
||||
EnvironmentProbeResult,
|
||||
FakeSandboxEnvironmentConfig,
|
||||
LocalEnvironmentConfig,
|
||||
PluginSandboxEnvironmentConfig,
|
||||
PluginEnvironmentConfig,
|
||||
SandboxEnvironmentConfig,
|
||||
SandboxEnvironmentProvider,
|
||||
SshEnvironmentConfig,
|
||||
} from "./environment.js";
|
||||
export type {
|
||||
|
|
@ -85,6 +90,10 @@ export type {
|
|||
WorkspaceRuntimeService,
|
||||
WorkspaceRuntimeServiceStateMap,
|
||||
WorkspaceRuntimeDesiredState,
|
||||
WorkspaceRealizationRecord,
|
||||
WorkspaceRealizationRequest,
|
||||
WorkspaceRealizationSyncStrategy,
|
||||
WorkspaceRealizationTransport,
|
||||
ExecutionWorkspaceStrategyType,
|
||||
ExecutionWorkspaceMode,
|
||||
ExecutionWorkspaceProviderType,
|
||||
|
|
@ -281,6 +290,7 @@ export type {
|
|||
PluginJobDeclaration,
|
||||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginEnvironmentDriverDeclaration,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
PluginLauncherRenderDeclaration,
|
||||
|
|
|
|||
|
|
@ -89,6 +89,30 @@ export interface PluginToolDeclaration {
|
|||
parametersSchema: JsonSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares an environment runtime driver contributed by the plugin.
|
||||
*
|
||||
* Requires the `environment.drivers.register` capability.
|
||||
*/
|
||||
export interface PluginEnvironmentDriverDeclaration {
|
||||
/** Stable driver key, unique within the plugin. Namespaced by plugin ID at runtime. */
|
||||
driverKey: string;
|
||||
/**
|
||||
* Driver classification.
|
||||
*
|
||||
* `environment_driver` is used by core `driver: "plugin"` environments.
|
||||
* `sandbox_provider` is used by core `driver: "sandbox"` environments whose
|
||||
* provider key is implemented by a plugin.
|
||||
*/
|
||||
kind?: "environment_driver" | "sandbox_provider";
|
||||
/** Human-readable name shown in environment configuration UI. */
|
||||
displayName: string;
|
||||
/** Optional description for operator-facing docs or UI affordances. */
|
||||
description?: string;
|
||||
/** JSON Schema describing the driver's provider-specific configuration. */
|
||||
configSchema: JsonSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a UI extension slot the plugin fills with a React component.
|
||||
*
|
||||
|
|
@ -296,6 +320,8 @@ export interface PaperclipPluginManifestV1 {
|
|||
database?: PluginDatabaseDeclaration;
|
||||
/** Scoped JSON API routes mounted under `/api/plugins/:pluginId/api/*`. */
|
||||
apiRoutes?: PluginApiRouteDeclaration[];
|
||||
/** Environment drivers this plugin contributes. Requires `environment.drivers.register` capability. */
|
||||
environmentDrivers?: PluginEnvironmentDriverDeclaration[];
|
||||
/**
|
||||
* Legacy top-level launcher declarations.
|
||||
* Prefer `ui.launchers` for new manifests.
|
||||
|
|
|
|||
|
|
@ -231,11 +231,13 @@ export interface WorkspaceRuntimeService {
|
|||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type WorkspaceRealizationTransport = "local" | "ssh";
|
||||
export type WorkspaceRealizationTransport = "local" | "ssh" | "sandbox" | "plugin";
|
||||
|
||||
export type WorkspaceRealizationSyncStrategy =
|
||||
| "none"
|
||||
| "ssh_git_import_export";
|
||||
| "ssh_git_import_export"
|
||||
| "sandbox_archive_upload_download"
|
||||
| "provider_defined";
|
||||
|
||||
export interface WorkspaceRealizationRequest {
|
||||
version: 1;
|
||||
|
|
@ -288,6 +290,7 @@ export interface WorkspaceRealizationRecord {
|
|||
host?: string | null;
|
||||
port?: number | null;
|
||||
username?: string | null;
|
||||
sandboxId?: string | null;
|
||||
};
|
||||
sync: {
|
||||
strategy: WorkspaceRealizationSyncStrategy;
|
||||
|
|
|
|||
|
|
@ -344,6 +344,7 @@ export {
|
|||
pluginJobDeclarationSchema,
|
||||
pluginWebhookDeclarationSchema,
|
||||
pluginToolDeclarationSchema,
|
||||
pluginEnvironmentDriverDeclarationSchema,
|
||||
pluginUiSlotDeclarationSchema,
|
||||
pluginLauncherActionDeclarationSchema,
|
||||
pluginLauncherRenderDeclarationSchema,
|
||||
|
|
@ -362,6 +363,7 @@ export {
|
|||
type PluginJobDeclarationInput,
|
||||
type PluginWebhookDeclarationInput,
|
||||
type PluginToolDeclarationInput,
|
||||
type PluginEnvironmentDriverDeclarationInput,
|
||||
type PluginUiSlotDeclarationInput,
|
||||
type PluginLauncherActionDeclarationInput,
|
||||
type PluginLauncherRenderDeclarationInput,
|
||||
|
|
|
|||
|
|
@ -107,6 +107,21 @@ export const pluginToolDeclarationSchema = z.object({
|
|||
parametersSchema: jsonSchemaSchema,
|
||||
});
|
||||
|
||||
export const pluginEnvironmentDriverDeclarationSchema = z.object({
|
||||
driverKey: z.string().min(1).regex(
|
||||
/^[a-z0-9][a-z0-9._-]*$/,
|
||||
"Environment driver key must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, hyphens, or underscores",
|
||||
),
|
||||
kind: z.enum(["environment_driver", "sandbox_provider"]).optional(),
|
||||
displayName: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
configSchema: jsonSchemaSchema,
|
||||
});
|
||||
|
||||
export type PluginEnvironmentDriverDeclarationInput = z.infer<
|
||||
typeof pluginEnvironmentDriverDeclarationSchema
|
||||
>;
|
||||
|
||||
export type PluginToolDeclarationInput = z.infer<typeof pluginToolDeclarationSchema>;
|
||||
|
||||
/**
|
||||
|
|
@ -410,11 +425,13 @@ export type PluginApiRouteDeclarationInput = z.infer<typeof pluginApiRouteDeclar
|
|||
* Cross-field rules enforced via `superRefine`:
|
||||
* - `entrypoints.ui` required when `ui.slots` declared
|
||||
* - `agent.tools.register` capability required when `tools` declared
|
||||
* - `environment.drivers.register` capability required when `environmentDrivers` declared
|
||||
* - `jobs.schedule` capability required when `jobs` declared
|
||||
* - `webhooks.receive` capability required when `webhooks` declared
|
||||
* - duplicate `jobs[].jobKey` values are rejected
|
||||
* - duplicate `webhooks[].endpointKey` values are rejected
|
||||
* - duplicate `tools[].name` values are rejected
|
||||
* - duplicate `environmentDrivers[].driverKey` values are rejected
|
||||
* - duplicate `ui.slots[].id` values are rejected
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §10.1 — Manifest shape
|
||||
|
|
@ -453,6 +470,7 @@ export const pluginManifestV1Schema = z.object({
|
|||
tools: z.array(pluginToolDeclarationSchema).optional(),
|
||||
database: pluginDatabaseDeclarationSchema.optional(),
|
||||
apiRoutes: z.array(pluginApiRouteDeclarationSchema).optional(),
|
||||
environmentDrivers: z.array(pluginEnvironmentDriverDeclarationSchema).optional(),
|
||||
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
|
||||
ui: z.object({
|
||||
slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(),
|
||||
|
|
@ -500,6 +518,17 @@ export const pluginManifestV1Schema = z.object({
|
|||
}
|
||||
}
|
||||
|
||||
// environment drivers require environment.drivers.register
|
||||
if (manifest.environmentDrivers && manifest.environmentDrivers.length > 0) {
|
||||
if (!manifest.capabilities.includes("environment.drivers.register")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'environment.drivers.register' is required when environmentDrivers are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// jobs require jobs.schedule (PLUGIN_SPEC.md §17)
|
||||
if (manifest.jobs && manifest.jobs.length > 0) {
|
||||
if (!manifest.capabilities.includes("jobs.schedule")) {
|
||||
|
|
@ -622,6 +651,19 @@ export const pluginManifestV1Schema = z.object({
|
|||
}
|
||||
}
|
||||
|
||||
// environment driver keys must be unique within the plugin
|
||||
if (manifest.environmentDrivers) {
|
||||
const driverKeys = manifest.environmentDrivers.map((d) => d.driverKey);
|
||||
const duplicates = driverKeys.filter((key, i) => driverKeys.indexOf(key) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate environment driver keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["environmentDrivers"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// UI slot ids must be unique within the plugin (namespaced at runtime)
|
||||
if (manifest.ui) {
|
||||
if (manifest.ui.slots) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue