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:
Devin Foley 2026-04-24 12:15:53 -07:00 committed by GitHub
parent 641eb44949
commit 70679a3321
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 10469 additions and 1498 deletions

View file

@ -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"),

View 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"
}
}

View file

@ -0,0 +1,2 @@
export { default as manifest } from "./manifest.js";
export { default as plugin } from "./plugin.js";

View file

@ -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;

View file

@ -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,
});
});
});

View 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;

View file

@ -0,0 +1,5 @@
import { runWorker } from "@paperclipai/plugin-sdk";
import plugin from "./plugin.js";
export default plugin;
runWorker(plugin, import.meta.url);

View file

@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2023"],
"types": ["node", "vitest"]
},
"include": ["src"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
environment: "node",
},
});

View file

@ -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` |

View file

@ -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>;
}
// ---------------------------------------------------------------------------

View file

@ -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,

View file

@ -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;
// ---------------------------------------------------------------------------

View file

@ -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;
}

View file

@ -41,6 +41,7 @@ export type {
PluginJobDeclaration,
PluginWebhookDeclaration,
PluginToolDeclaration,
PluginEnvironmentDriverDeclaration,
PluginUiSlotDeclaration,
PluginUiDeclaration,
PluginLauncherActionDeclaration,

View file

@ -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
// -----------------------------------------------------------------------