Replace plugin diff bridge with workspace metadata

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-16 08:41:18 -05:00
1 parent 56eb9a5638
commit 09ea87fb12
16 files changed
+1058 -128

No files matched your search

@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
import { createHostClientHandlers } from "../../../packages/plugins/sdk/src/host-client-factory.js";
import { PLUGIN_RPC_ERROR_CODES } from "../../../packages/plugins/sdk/src/protocol.js";
describe("plugin execution workspace bridge", () => {
it("routes metadata reads through the host client when the capability is declared", async () => {
const get = vi.fn().mockResolvedValue({
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
path: "/tmp/workspace-1",
cwd: "/tmp/workspace-1",
repoUrl: null,
baseRef: "main",
branchName: "feature/workspace-1",
providerType: "git_worktree",
providerMetadata: null,
});
const handlers = createHostClientHandlers({
pluginId: "workspace-plugin",
capabilities: ["execution.workspaces.read"],
services: {
executionWorkspaces: { get },
} as any,
});
await expect(
handlers["executionWorkspaces.get"]({ workspaceId: "workspace-1", companyId: "company-1" }),
).resolves.toMatchObject({
id: "workspace-1",
cwd: "/tmp/workspace-1",
});
expect(get).toHaveBeenCalledWith({ workspaceId: "workspace-1", companyId: "company-1" });
});
it("rejects metadata reads when the plugin lacks execution.workspace read access", async () => {
const get = vi.fn();
const handlers = createHostClientHandlers({
pluginId: "workspace-plugin",
capabilities: [],
services: {
executionWorkspaces: { get },
} as any,
});
await expect(
handlers["executionWorkspaces.get"]({ workspaceId: "workspace-1", companyId: "company-1" }),
).rejects.toMatchObject({
code: PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED,
});
expect(get).not.toHaveBeenCalled();
});
});
@@ -11,6 +11,7 @@ import {
companies,
costEvents,
createDb,
executionWorkspaces,
heartbeatRuns,
issueRelations,
issues,
@@ -67,6 +68,7 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
await db.delete(agentWakeupRequests);
await db.delete(issueRelations);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(pluginManagedResources);
await db.delete(projects);
await db.delete(plugins);
@@ -107,6 +109,61 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
return root;
}
it("returns plugin-safe execution workspace metadata scoped to the company", async () => {
const { companyId } = await seedCompanyAndAgent();
const otherCompanyId = randomUUID();
const projectId = randomUUID();
const workspaceId = randomUUID();
await db.insert(companies).values({
id: otherCompanyId,
name: "Other",
issuePrefix: issuePrefix(otherCompanyId),
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspaces",
status: "in_progress",
});
await db.insert(executionWorkspaces).values({
id: workspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Feature workspace",
status: "active",
cwd: "/tmp/paperclip-feature",
repoUrl: "https://example.com/paperclip.git",
baseRef: "main",
branchName: "feature/workspace",
providerType: "git_worktree",
providerRef: "/tmp/paperclip-feature",
metadata: {
providerMetadata: { sandboxId: "sandbox-1" },
workspaceRealizationRequest: { hiddenInternal: true },
},
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.workspace", createEventBusStub());
await expect(services.executionWorkspaces.get({ workspaceId, companyId })).resolves.toMatchObject({
id: workspaceId,
companyId,
projectId,
projectWorkspaceId: null,
path: "/tmp/paperclip-feature",
cwd: "/tmp/paperclip-feature",
repoUrl: "https://example.com/paperclip.git",
baseRef: "main",
branchName: "feature/workspace",
providerType: "git_worktree",
providerMetadata: { sandboxId: "sandbox-1" },
});
await expect(services.executionWorkspaces.get({ workspaceId, companyId: otherCompanyId })).resolves.toBeNull();
});
it("creates plugin-origin issues with full orchestration fields and audit activity", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const blockerIssueId = randomUUID();
@@ -3,6 +3,63 @@ import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
describe("plugin SDK test harness", () => {
it("returns scoped execution workspace metadata with the read capability", async () => {
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.test-execution-workspace-metadata",
apiVersion: 1,
version: "0.1.0",
displayName: "Execution Workspace Metadata",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["execution.workspaces.read"],
entrypoints: { worker: "./dist/worker.js" },
};
const harness = createTestHarness({ manifest });
harness.seed({
executionWorkspaces: [{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "project-workspace-1",
path: "/tmp/paperclip-test",
cwd: "/tmp/paperclip-test",
repoUrl: "https://example.com/repo.git",
baseRef: "main",
branchName: "feature/test",
providerType: "git_worktree",
providerMetadata: { sandboxId: "sandbox-1" },
}],
});
await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-1")).resolves.toMatchObject({
id: "workspace-1",
cwd: "/tmp/paperclip-test",
branchName: "feature/test",
providerMetadata: { sandboxId: "sandbox-1" },
});
await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-2")).resolves.toBeNull();
});
it("requires execution.workspaces.read before returning workspace metadata", async () => {
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.test-missing-execution-workspace-read",
apiVersion: 1,
version: "0.1.0",
displayName: "Missing Workspace Read Capability",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: { worker: "./dist/worker.js" },
};
const harness = createTestHarness({ manifest });
await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-1")).rejects.toThrow(
"missing required capability 'execution.workspaces.read'",
);
});
it("requires skills.managed capability before resetting a missing declaration", async () => {
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.test-missing-managed-skill-capability",
@@ -55,7 +55,7 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
"routines.managed.reset": ["routines.managed"],
"project.workspaces.list": ["project.workspaces.read"],
"project.workspaces.get": ["project.workspaces.read"],
"execution.workspaces.getDiff": ["execution.workspaces.read"],
"execution.workspaces.get": ["execution.workspaces.read"],
"issues.list": ["issues.read"],
"issues.get": ["issues.read"],
"issues.relations.get": ["issue.relations.read"],
+33 -39
View File
@@ -7,7 +7,6 @@ import {
heartbeatRuns,
issues as issuesTable,
pluginLogs,
projectWorkspaces as projectWorkspacesTable,
} from "@paperclipai/db";
import { eq, and, like, desc, inArray, sql } from "drizzle-orm";
import type {
@@ -21,6 +20,7 @@ import type {
IssueComment,
PluginIssueAssigneeSummary,
PluginIssueOrchestrationSummary,
PluginExecutionWorkspaceMetadata,
} from "@paperclipai/plugin-sdk";
import type { CreateIssueThreadInteraction, IssueDocumentSummary } from "@paperclipai/shared";
import { pluginOperationIssueOriginKind } from "@paperclipai/shared";
@@ -71,7 +71,6 @@ import { request as httpsRequest } from "node:https";
import { isIP } from "node:net";
import { logger } from "../middleware/logger.js";
import { getTelemetryClient } from "../telemetry.js";
import { workspaceDiffService } from "./workspace-diff.js";
// ---------------------------------------------------------------------------
// SSRF protection for plugin HTTP fetch
@@ -524,7 +523,6 @@ export function buildHostServices(
});
const projects = projectService(db);
const executionWorkspaces = executionWorkspaceService(db);
const workspaceDiff = workspaceDiffService();
const issues = issueService(db);
const documents = documentService(db);
const goals = goalService(db);
@@ -571,11 +569,6 @@ export function buildHostServices(
*/
const ensurePluginAvailableForCompany = async (_companyId: string) => {};
const ensureDeclaredCapability = (capability: import("@paperclipai/shared").PluginCapability) => {
if (!options.manifest || options.manifest.capabilities.includes(capability)) return;
throw new Error(`Missing required capability: ${capability}`);
};
const getLocalFolderDeclaration = (folderKey: string) =>
requireLocalFolderDeclaration(options.manifest?.localFolders, folderKey);
@@ -598,6 +591,35 @@ export function buildHostServices(
companyId: string,
): record is T => Boolean(record && record.companyId === companyId);
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const readProviderMetadata = (metadata: Record<string, unknown> | null | undefined) => {
if (!isRecord(metadata)) return null;
if (isRecord(metadata.providerMetadata)) return { ...metadata.providerMetadata };
const rebuild = metadata.rebuild;
if (!isRecord(rebuild)) return null;
const rebuildMetadata = rebuild.metadata;
if (!isRecord(rebuildMetadata) || !isRecord(rebuildMetadata.providerMetadata)) return null;
return { ...rebuildMetadata.providerMetadata };
};
const toPluginExecutionWorkspaceMetadata = (
workspace: NonNullable<Awaited<ReturnType<typeof executionWorkspaces.getById>>>,
): PluginExecutionWorkspaceMetadata => ({
id: workspace.id,
companyId: workspace.companyId,
projectId: workspace.projectId,
projectWorkspaceId: workspace.projectWorkspaceId,
path: workspace.cwd ?? workspace.providerRef,
cwd: workspace.cwd,
repoUrl: workspace.repoUrl,
baseRef: workspace.baseRef,
branchName: workspace.branchName,
providerType: workspace.providerType,
providerMetadata: readProviderMetadata(workspace.metadata),
});
const requireInCompany = <T extends { companyId: string | null | undefined }>(
entityName: string,
record: T | null | undefined,
@@ -1208,42 +1230,14 @@ export function buildHostServices(
},
executionWorkspaces: {
async getDiff(params) {
async get(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
const workspace = await executionWorkspaces.getById(params.workspaceId);
if (inCompany(workspace, companyId)) {
return workspaceDiff.getDiff(workspace, {
view: params.options?.view ?? "working-tree",
baseRef: params.options?.baseRef ?? null,
includeUntracked: params.options?.includeUntracked ?? true,
paths: params.options?.paths ?? [],
});
return toPluginExecutionWorkspaceMetadata(workspace);
}
const projectWorkspace = await db
.select()
.from(projectWorkspacesTable)
.where(and(
eq(projectWorkspacesTable.id, params.workspaceId),
eq(projectWorkspacesTable.companyId, companyId),
))
.then((rows) => rows[0] ?? null);
if (!projectWorkspace) {
throw new Error("Workspace not found");
}
ensureDeclaredCapability("project.workspaces.read");
return workspaceDiff.getDiff({
id: projectWorkspace.id,
companyId: projectWorkspace.companyId,
cwd: projectWorkspace.cwd,
baseRef: projectWorkspace.defaultRef ?? projectWorkspace.repoRef ?? null,
}, {
view: params.options?.view ?? "working-tree",
baseRef: params.options?.baseRef ?? null,
includeUntracked: params.options?.includeUntracked ?? true,
paths: params.options?.paths ?? [],
});
return null;
},
},