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
|
|
@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue