mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Add typed task operations to environment plugins
Provide a negotiated task RPC for environment providers that own Runner admission. Dispatch from persisted company-scoped leases and validate typed requests and receipts without coupling the host to a provider API. Cover the worker contract, host-derived identity, capability negotiation, and failed or mismatched receipts. Document retry and credential handling. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
13 files changed
+348
No files matched your search
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { environmentTaskOperationSchema, parseEnvironmentTaskResult } from "@paperclipai/plugin-sdk";
|
||||
import { executeEnvironmentTask } from "../services/environment-task-runtime.js";
|
||||
|
||||
const state = vi.hoisted(() => ({ plugin: {} as any }));
|
||||
vi.mock("../services/plugin-registry.js", () => ({ pluginRegistryService: () => ({ getById: vi.fn(async () => state.plugin) }) }));
|
||||
const leaseId = "10000000-0000-4000-8000-000000000001";
|
||||
const row = () => ({
|
||||
lease: { id: leaseId, companyId: "company", environmentId: "environment", providerLeaseId: "attempt-1", heartbeatRunId: "run", issueId: "issue", status: "active", expiresAt: null,
|
||||
metadata: { driver: "plugin", pluginId: "original", driverKey: "tasks" } },
|
||||
environment: { id: "environment", config: { pluginKey: "test.provider", driverKey: "tasks", driverConfig: {} } },
|
||||
run: { id: "run", agentId: "agent", status: "running" },
|
||||
});
|
||||
function database(value: ReturnType<typeof row> | null = row()) {
|
||||
const results = [value ? [value] : [], [{ projectId: "project" }]];
|
||||
const query: any = { from: () => query, innerJoin: () => query, where: vi.fn(() => query), limit: () => Promise.resolve(results.shift()) };
|
||||
return { db: { select: () => query } as unknown as Db, query };
|
||||
}
|
||||
function worker(result: unknown = { kind: "accepted", taskId: "attempt-1" }) {
|
||||
return { getWorker: vi.fn(() => ({ supportedMethods: ["environmentTask"] })), call: vi.fn(async () => result) };
|
||||
}
|
||||
const submit = { kind: "submit" as const, runner: { revision: "a".repeat(40), harness: "codex", runnerId: "runner", leaseId, runId: "run", sessionId: "session", turnId: "turn", itemId: "item" }, bootstrapTicket: "transient-test-ticket" };
|
||||
|
||||
beforeEach(() => {
|
||||
state.plugin = { id: "original", pluginKey: "test.provider", status: "ready", manifestJson: { capabilities: ["environment.drivers.register"], environmentDrivers: [{ driverKey: "tasks", supportsTasks: true }] } };
|
||||
});
|
||||
describe("typed environment task admission", () => {
|
||||
it("dispatches with host-derived scope and a persisted attempt identity", async () => {
|
||||
const { db, query } = database(); const workers = worker();
|
||||
await expect(executeEnvironmentTask(db, workers as never, { companyId: "company", leaseId, operation: submit })).resolves.toEqual({ kind: "accepted", taskId: "attempt-1" });
|
||||
const scope = new PgDialect().sqlToQuery(query.where.mock.calls[0][0]);
|
||||
expect(scope.sql).toContain('"environment_leases"."company_id" =');
|
||||
expect(scope.sql).toContain('"environment_leases"."id" =');
|
||||
expect(scope.params).toEqual([leaseId, "company"]);
|
||||
expect(workers.call).toHaveBeenCalledWith("original", "environmentTask", expect.objectContaining({
|
||||
taskId: "attempt-1", companyId: "company", agentId: "agent", projectId: "project", runId: "run", operation: submit,
|
||||
}), 15_000);
|
||||
});
|
||||
it("rejects an absent or cross-company lease before calling a worker", async () => {
|
||||
const workers = worker();
|
||||
await expect(executeEnvironmentTask(database(null).db, workers as never, { companyId: "other", leaseId, operation: submit })).rejects.toThrow("lease unavailable");
|
||||
expect(workers.call).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects mismatched Runner binding and inactive submission", async () => {
|
||||
const workers = worker();
|
||||
await expect(executeEnvironmentTask(database().db, workers as never, { companyId: "company", leaseId, operation: { ...submit, runner: { ...submit.runner, runId: "other" } } })).rejects.toThrow("identity mismatch");
|
||||
const value = row(); value.lease.status = "released";
|
||||
await expect(executeEnvironmentTask(database(value).db, workers as never, { companyId: "company", leaseId, operation: submit })).rejects.toThrow("not active");
|
||||
expect(workers.call).not.toHaveBeenCalled();
|
||||
});
|
||||
it("uses the pinned provider for cleanup after environment edits", async () => {
|
||||
const value = row(); value.lease.status = "released"; value.environment.config.pluginKey = "replacement";
|
||||
const workers = worker();
|
||||
await executeEnvironmentTask(database(value).db, workers as never, { companyId: "company", leaseId, operation: { kind: "stop" } });
|
||||
expect(workers.call).toHaveBeenCalledWith("original", "environmentTask", expect.objectContaining({ config: {}, operation: { kind: "stop" } }), 15_000);
|
||||
});
|
||||
it.each(["manifest", "worker"])("requires live %s support", async kind => {
|
||||
const workers = worker();
|
||||
if (kind === "manifest") state.plugin.manifestJson.environmentDrivers[0].supportsTasks = false;
|
||||
else workers.getWorker.mockReturnValue({ supportedMethods: [] });
|
||||
await expect(executeEnvironmentTask(database().db, workers as never, { companyId: "company", leaseId, operation: submit })).rejects.toThrow("provider unavailable");
|
||||
expect(workers.call).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects wrong task receipts and sanitizes worker errors", async () => {
|
||||
await expect(executeEnvironmentTask(database().db, worker({ kind: "accepted", taskId: "other" }) as never, { companyId: "company", leaseId, operation: submit })).rejects.toThrow("reconcile the same task");
|
||||
const workers = worker(); workers.call.mockRejectedValue(new Error("private-credential"));
|
||||
await expect(executeEnvironmentTask(database().db, workers as never, { companyId: "company", leaseId, operation: submit })).rejects.toThrow(/^Environment task operation unavailable; reconcile the same task before retrying$/);
|
||||
});
|
||||
it("validates operation and result shape without making acceptance mean readiness", () => {
|
||||
expect(environmentTaskOperationSchema.safeParse({ ...submit, surprise: true }).success).toBe(false);
|
||||
expect(environmentTaskOperationSchema.safeParse({ ...submit, runner: { ...submit.runner, connectUrl: "wss://user:secret@example.test/path" } }).success).toBe(false);
|
||||
expect(() => parseEnvironmentTaskResult({ kind: "status" }, "attempt-1", { kind: "accepted", taskId: "attempt-1" })).toThrow();
|
||||
expect(parseEnvironmentTaskResult(submit, "attempt-1", { kind: "accepted", taskId: "attempt-1" }).kind).toBe("accepted");
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,48 @@ describe("plugin environment driver seam", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("negotiates and validates typed task RPCs", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const plugin = definePlugin({
|
||||
async setup() {},
|
||||
async onEnvironmentTask(input) {
|
||||
calls.push(input);
|
||||
return { kind: "accepted", taskId: input.taskId };
|
||||
},
|
||||
});
|
||||
const stdin = new PassThrough();
|
||||
const stdout = new PassThrough();
|
||||
const host = startWorkerRpcHost({ plugin, stdin, stdout });
|
||||
const responses: unknown[] = [];
|
||||
stdout.on("data", chunk => {
|
||||
for (const line of String(chunk).split("\n").filter(Boolean)) responses.push(parseMessage(line));
|
||||
});
|
||||
try {
|
||||
const manifest = { ...baseManifest, environmentDrivers: [{ ...baseManifest.environmentDrivers![0], supportsTasks: true }] };
|
||||
expect(pluginManifestV1Schema.parse(manifest).environmentDrivers![0].supportsTasks).toBe(true);
|
||||
stdin.write(serializeMessage(createRequest("initialize", {
|
||||
manifest, config: {}, instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" }, apiVersion: 1,
|
||||
}, 1)));
|
||||
await waitForResponses(responses, 1);
|
||||
const initialized = responses[0];
|
||||
expect(isJsonRpcSuccessResponse(initialized)).toBe(true);
|
||||
if (!isJsonRpcSuccessResponse(initialized)) return;
|
||||
expect(initialized.result.supportedMethods).toContain("environmentTask");
|
||||
const params = { driverKey: "fake-plugin", companyId: "company", environmentId: "environment", config: {},
|
||||
taskId: "attempt", runId: "run", agentId: "agent", projectId: null, lease: { providerLeaseId: "attempt" }, operation: { kind: "stop" } };
|
||||
stdin.write(serializeMessage(createRequest("environmentTask", params, 2)));
|
||||
await waitForResponses(responses, 2);
|
||||
expect(responses[1]).toMatchObject({ result: { kind: "accepted", taskId: "attempt" } });
|
||||
stdin.write(serializeMessage(createRequest("environmentTask", { ...params, operation: { kind: "status" } }, 3)));
|
||||
await waitForResponses(responses, 3);
|
||||
expect(isJsonRpcErrorResponse(responses[2])).toBe(true); // Wrong response kind.
|
||||
stdin.write(serializeMessage(createRequest("environmentTask", { ...params, operation: { kind: "shell", command: "echo test" } }, 4)));
|
||||
await waitForResponses(responses, 4);
|
||||
expect(isJsonRpcErrorResponse(responses[3])).toBe(true);
|
||||
expect(calls).toHaveLength(2); // Invalid operation did not reach the plugin.
|
||||
} finally { host.stop(); }
|
||||
});
|
||||
|
||||
it("dispatches environment driver worker hooks and reports support", async () => {
|
||||
const plugin = definePlugin({
|
||||
async setup() {},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { executeEnvironmentTask } from "./environment-task-runtime.js";
|
||||
import type { PluginEnvironmentTaskOperation } from "@paperclipai/plugin-sdk";
|
||||
import { hasStopOnlyCleanup, prepareSandboxStopAndRetain, readStopOnlyCleanup, settleStopOnlyCleanup, stopOnlyCleanupKey } from "./sandbox-stop-and-retain.js";
|
||||
import { readEnvironmentCreationCleanupError } from "@paperclipai/plugin-sdk";
|
||||
import { remoteTerminationReceipt } from "./remote-execution-termination.js";
|
||||
@@ -3870,6 +3872,12 @@ export function environmentRuntimeService(
|
||||
return resolveSandboxDuplexBridgeInput(experimental);
|
||||
},
|
||||
|
||||
/** Typed task providers own process admission; this does not execute a shell command. */
|
||||
async task(input: { companyId: string; leaseId: string; operation: PluginEnvironmentTaskOperation }) {
|
||||
if (!options.pluginWorkerManager) throw new Error("Environment task worker manager unavailable");
|
||||
return executeEnvironmentTask(db, options.pluginWorkerManager, input);
|
||||
},
|
||||
|
||||
async acquireRunLease(input: {
|
||||
companyId: string;
|
||||
environment: Environment;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { environmentLeases, environments, heartbeatRuns, issues, type Db } from "@paperclipai/db";
|
||||
import { environmentTaskOperationSchema, parseEnvironmentTaskResult, type PluginEnvironmentTaskOperation } from "@paperclipai/plugin-sdk";
|
||||
import { pluginRegistryService } from "./plugin-registry.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
|
||||
/** Server-only dispatch. The caller supplies an authorized company and a persisted lease,
|
||||
* never a plugin identity, resource endpoint, or agent/project authority.
|
||||
* A timeout is ambiguous: retry the same operation against the same lease.
|
||||
*/
|
||||
export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManager, input: {
|
||||
companyId: string;
|
||||
leaseId: string;
|
||||
operation: PluginEnvironmentTaskOperation;
|
||||
}) {
|
||||
const operation = environmentTaskOperationSchema.parse(input.operation);
|
||||
const [row] = await db.select({ lease: environmentLeases, environment: environments, run: heartbeatRuns })
|
||||
.from(environmentLeases)
|
||||
.innerJoin(environments, eq(environments.id, environmentLeases.environmentId))
|
||||
.innerJoin(heartbeatRuns, and(eq(heartbeatRuns.id, environmentLeases.heartbeatRunId), eq(heartbeatRuns.companyId, environmentLeases.companyId)))
|
||||
.where(and(eq(environmentLeases.id, input.leaseId), eq(environmentLeases.companyId, input.companyId))).limit(1);
|
||||
if (!row || !row.lease.providerLeaseId) throw new Error("Environment task lease unavailable");
|
||||
const { lease, environment, run } = row;
|
||||
const taskId = row.lease.providerLeaseId;
|
||||
if ((operation.kind === "submit" || operation.kind === "connection") &&
|
||||
(lease.status !== "active" || (lease.expiresAt && lease.expiresAt.getTime() <= Date.now()) ||
|
||||
run.status !== "running")) throw new Error("Environment task lease is not active");
|
||||
if (operation.kind === "submit" && (operation.runner.runId !== run.id || operation.runner.leaseId !== lease.id)) {
|
||||
throw new Error("Environment task runner identity mismatch");
|
||||
}
|
||||
const metadata = lease.metadata ?? {};
|
||||
if (metadata.driver !== "plugin" || typeof metadata.pluginId !== "string" || typeof metadata.driverKey !== "string") {
|
||||
throw new Error("Environment task lease has no pinned plugin driver");
|
||||
}
|
||||
const plugin = await pluginRegistryService(db).getById(metadata.pluginId);
|
||||
const driver = plugin?.manifestJson.environmentDrivers?.find(value => value.driverKey === metadata.driverKey);
|
||||
if (!plugin || plugin.status !== "ready" || !driver?.supportsTasks ||
|
||||
!plugin.manifestJson.capabilities.includes("environment.drivers.register") ||
|
||||
!workers.getWorker(plugin.id)?.supportedMethods.includes("environmentTask")) {
|
||||
throw new Error("Environment task provider unavailable");
|
||||
}
|
||||
const project = lease.issueId ? await db.select({ projectId: issues.projectId }).from(issues)
|
||||
.where(and(eq(issues.id, lease.issueId), eq(issues.companyId, input.companyId))).limit(1).then(rows => rows[0]) : null;
|
||||
if (lease.issueId && !project) throw new Error("Environment task issue unavailable");
|
||||
// Provider identity comes from the lease. Editing the environment must never
|
||||
// redirect status or cleanup to a replacement plugin.
|
||||
const config = environment.config as Record<string, unknown>;
|
||||
const driverConfig = config.pluginKey === plugin.pluginKey && config.driverKey === metadata.driverKey
|
||||
? (config.driverConfig as Record<string, unknown> | undefined) ?? {} : {};
|
||||
try {
|
||||
const result = await workers.call(plugin.id, "environmentTask", {
|
||||
driverKey: metadata.driverKey, companyId: input.companyId, environmentId: environment.id,
|
||||
issueId: lease.issueId, config: driverConfig,
|
||||
taskId, runId: run.id, agentId: run.agentId, projectId: project?.projectId ?? null,
|
||||
lease: { providerLeaseId: lease.providerLeaseId, metadata: lease.metadata ?? undefined }, operation,
|
||||
}, 15_000);
|
||||
return parseEnvironmentTaskResult(operation, taskId, result);
|
||||
} catch {
|
||||
// Worker errors and schema diagnostics can contain request credentials.
|
||||
throw new Error("Environment task operation unavailable; reconcile the same task before retrying");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user