Expand plugin host surface (#5205)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The plugin system is the extension boundary for optional product
capabilities
> - Rich plugins need more than a worker entrypoint: they need scoped
database storage, local project folders, managed agents/routines, host
navigation, and reusable UI components
> - The LLM Wiki work exposed those missing host surfaces while keeping
plugin code outside the core control plane
> - This pull request expands the core plugin host, SDK, server APIs,
and UI bridge so plugins can declare and use those surfaces
> - The benefit is that future plugins can integrate with Paperclip
through documented, validated contracts instead of bespoke server or UI
imports

## What Changed

- Added plugin-managed database namespaces and migration tracking,
including Drizzle schema/migration files and SQL validation for
namespace isolation.
- Added server support for plugin local folders, managed agents, managed
routines, scoped plugin APIs, and plugin operation visibility.
- Expanded shared plugin manifest/types/validators and SDK
host/testing/UI exports for richer plugin surfaces.
- Added reusable UI pieces for file trees, managed routines, resizable
sidebars, route sidebars, and plugin bridge initialization.
- Updated plugin docs and example plugins to use the expanded host and
SDK surface.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm run preflight:workspace-links && pnpm exec vitest run
packages/shared/src/validators/plugin.test.ts
server/src/__tests__/plugin-database.test.ts
server/src/__tests__/plugin-local-folders.test.ts
server/src/__tests__/plugin-managed-agents.test.ts
server/src/__tests__/plugin-managed-routines.test.ts
server/src/__tests__/plugin-orchestration-apis.test.ts
ui/src/api/plugins.test.ts ui/src/components/FileTree.test.tsx
ui/src/components/ResizableSidebarPane.test.tsx
ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts` passed:
11 files, 67 tests.
- Confirmed this PR changes 89 files and does not include
`pnpm-lock.yaml` or `.github/workflows/*`.

## Risks

- Medium: this expands plugin host contracts across db/shared/server/ui
and includes a new core migration (`0076_useful_elektra.sql`).
- The plugin database namespace validator is intentionally restrictive;
plugin authors may need follow-up affordances for SQL patterns that
remain blocked.
- Merge this before the LLM Wiki plugin PR so the plugin can resolve the
new SDK and host APIs.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool-enabled shell/git/GitHub
workflow. Context window size was not exposed by the runtime.

## 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
- [x] 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-05 07:42:57 -05:00 committed by GitHub
parent d6bee62f02
commit 3c73ed26b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
89 changed files with 27516 additions and 914 deletions

View file

@ -657,6 +657,143 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
expect(projectResult.map((issue) => issue.id).sort()).toEqual([executionLinkedIssueId, projectLinkedIssueId].sort());
});
it("hides plugin operation issues from default lists and inbox-style filters while preserving explicit retrieval", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const normalIssueId = randomUUID();
const pluginVisibleIssueId = randomUUID();
const operationIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Plugin Runner",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Plugin operations",
status: "in_progress",
});
await db.insert(issues).values([
{
id: normalIssueId,
companyId,
title: "Normal issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
},
{
id: pluginVisibleIssueId,
companyId,
title: "Plugin-visible issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
originKind: "plugin:paperclip.missions:feature",
},
{
id: operationIssueId,
companyId,
projectId,
title: "Plugin operation issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
originKind: "plugin:paperclip.missions:operation",
originId: "mission-alpha:operation-1",
},
]);
const defaultIssueIds = (await svc.list(companyId)).map((issue) => issue.id);
expect(defaultIssueIds).toContain(normalIssueId);
expect(defaultIssueIds).toContain(pluginVisibleIssueId);
expect(defaultIssueIds).not.toContain(operationIssueId);
const inboxIssueIds = (await svc.list(companyId, {
assigneeAgentId: agentId,
status: "todo,in_progress,blocked",
includeRoutineExecutions: true,
})).map((issue) => issue.id);
expect(inboxIssueIds).toContain(normalIssueId);
expect(inboxIssueIds).not.toContain(operationIssueId);
await expect(svc.list(companyId, { originKind: "plugin:paperclip.missions:operation" }))
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
await expect(svc.list(companyId, { originId: "mission-alpha:operation-1" }))
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
const projectIssueIds = (await svc.list(companyId, { projectId })).map((issue) => issue.id);
expect(projectIssueIds).toContain(operationIssueId);
const advancedIssueIds = (await svc.list(companyId, { includePluginOperations: true })).map((issue) => issue.id);
expect(advancedIssueIds).toContain(operationIssueId);
});
it("excludes plugin operation issues from unread inbox counts", async () => {
const companyId = randomUUID();
const userId = "board-user";
const otherUserId = "other-user";
const normalIssueId = randomUUID();
const operationIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: normalIssueId,
companyId,
title: "Normal touched issue",
status: "todo",
priority: "medium",
createdByUserId: userId,
},
{
id: operationIssueId,
companyId,
title: "Plugin operation touched issue",
status: "todo",
priority: "medium",
createdByUserId: userId,
originKind: "plugin:paperclip.missions:operation",
},
]);
await db.insert(issueComments).values([
{
companyId,
issueId: normalIssueId,
authorUserId: otherUserId,
body: "Unread normal update.",
},
{
companyId,
issueId: operationIssueId,
authorUserId: otherUserId,
body: "Unread operation update.",
},
]);
await expect(svc.countUnreadTouchedByUser(companyId, userId, "todo")).resolves.toBe(1);
});
it("hides archived inbox issues until new external activity arrives", async () => {
const companyId = randomUUID();
const userId = "user-1";

View file

@ -3,7 +3,7 @@ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
companies,
createDb,
@ -25,9 +25,11 @@ import {
validatePluginRuntimeExecute,
validatePluginRuntimeQuery,
} from "../services/plugin-database.js";
import { pluginLoader } from "../services/plugin-loader.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const multiMigrationPluginKey = "paperclip.dbfixture";
if (!embeddedPostgresSupport.supported) {
console.warn(
@ -93,7 +95,7 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
}, 20_000);
afterEach(async () => {
for (const pluginKey of ["paperclip.dbtest", "paperclip.escape"]) {
for (const pluginKey of ["paperclip.dbtest", "paperclip.escape", "paperclip.refresh", multiMigrationPluginKey]) {
const namespace = derivePluginDatabaseNamespace(pluginKey);
await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${namespace}" CASCADE`));
}
@ -120,6 +122,31 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
return packageRoot;
}
async function createInstallablePluginPackage(
pluginManifest: PaperclipPluginManifestV1,
migrationSql: string,
) {
const packageRoot = await createPluginPackage(pluginManifest, migrationSql);
await writeFile(
path.join(packageRoot, "package.json"),
JSON.stringify({
name: pluginManifest.id,
version: pluginManifest.version,
type: "module",
paperclipPlugin: { manifest: "./manifest.js" },
}),
"utf8",
);
await writeFile(
path.join(packageRoot, "manifest.js"),
`export default ${JSON.stringify(pluginManifest, null, 2)};\n`,
"utf8",
);
await mkdir(path.join(packageRoot, "dist"), { recursive: true });
await writeFile(path.join(packageRoot, "dist", "worker.js"), "export {};\n", "utf8");
return packageRoot;
}
async function installPluginRecord(manifest: PaperclipPluginManifestV1) {
const pluginId = randomUUID();
await db.insert(plugins).values({
@ -158,6 +185,31 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
};
}
it("applies multi-file plugin migrations through the production validator", async () => {
const pluginManifest = manifest(multiMigrationPluginKey);
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
const packageRoot = await createPluginPackage(
pluginManifest,
`CREATE TABLE ${namespace}.source_rows (id uuid PRIMARY KEY, label text NOT NULL);`,
);
await writeFile(
path.join(packageRoot, pluginManifest.database!.migrationsDir, "002_derived.sql"),
`CREATE TABLE ${namespace}.derived_rows (
id uuid PRIMARY KEY,
source_id uuid NOT NULL REFERENCES ${namespace}.source_rows(id)
);`,
"utf8",
);
const pluginId = await installPluginRecord(pluginManifest);
await pluginDatabaseService(db).applyMigrations(pluginId, pluginManifest, packageRoot);
const migrations = await db
.select()
.from(pluginMigrations)
.where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.status, "applied")));
expect(migrations).toHaveLength(2);
});
it("applies migrations once and allows whitelisted core joins at runtime", async () => {
const pluginManifest = manifest();
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
@ -246,6 +298,131 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
expect(migration?.status).toBe("failed");
});
it("rolls back plugin install when migration validation fails", async () => {
const pluginManifest = manifest("paperclip.escape");
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
const packageRoot = await createInstallablePluginPackage(
pluginManifest,
"CREATE TABLE public.plugin_escape (id uuid PRIMARY KEY);",
);
const loader = pluginLoader(db, {
enableLocalFilesystem: false,
enableNpmDiscovery: false,
});
await expect(loader.installPlugin({ localPath: packageRoot }))
.rejects.toThrow(/public\.plugin_escape|public/i);
const installedPlugins = await db
.select()
.from(plugins)
.where(eq(plugins.pluginKey, pluginManifest.id));
const namespaces = await db
.select()
.from(pluginDatabaseNamespaces)
.where(eq(pluginDatabaseNamespaces.pluginKey, pluginManifest.id));
const migrations = await db
.select()
.from(pluginMigrations)
.where(eq(pluginMigrations.pluginKey, pluginManifest.id));
const schemaRows = Array.from(
await db.execute(
sql<{ schema_name: string }>`SELECT schema_name FROM information_schema.schemata WHERE schema_name = ${namespace}`,
) as Iterable<{ schema_name: string }>,
);
expect(installedPlugins).toHaveLength(0);
expect(namespaces).toHaveLength(0);
expect(migrations).toHaveLength(0);
expect(schemaRows).toHaveLength(0);
});
it("refreshes persisted manifests from disk before activation", async () => {
const staleManifest = manifest("paperclip.refresh");
const refreshedManifest: PaperclipPluginManifestV1 = {
...staleManifest,
database: {
...staleManifest.database!,
coreReadTables: ["companies"],
},
};
const namespace = derivePluginDatabaseNamespace(refreshedManifest.id);
const packageRoot = await createInstallablePluginPackage(
refreshedManifest,
`
CREATE TABLE ${namespace}.company_refs (
id uuid PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id)
);
`,
);
const pluginId = await installPluginRecord(staleManifest);
await db
.update(plugins)
.set({
packagePath: packageRoot,
status: "ready",
})
.where(eq(plugins.id, pluginId));
const workerManager = {
startWorker: vi.fn().mockResolvedValue(undefined),
stopAll: vi.fn().mockResolvedValue(undefined),
};
const loader = pluginLoader(db, {
enableLocalFilesystem: false,
enableNpmDiscovery: false,
}, {
workerManager,
eventBus: {
forPlugin: vi.fn(() => ({})),
subscriptionCount: vi.fn(() => 0),
},
jobScheduler: {
registerPlugin: vi.fn().mockResolvedValue(undefined),
stop: vi.fn(),
},
jobStore: {
syncJobDeclarations: vi.fn().mockResolvedValue(undefined),
},
toolDispatcher: {
registerPluginTools: vi.fn(),
},
lifecycleManager: {
markError: vi.fn().mockResolvedValue(undefined),
},
buildHostHandlers: vi.fn(() => ({})),
instanceInfo: {
instanceId: "test-instance",
hostVersion: "1.0.0",
deploymentMode: "authenticated",
deploymentExposure: "public",
},
} as never);
const result = await loader.loadSingle(pluginId);
expect(result.success).toBe(true);
expect(workerManager.startWorker).toHaveBeenCalledWith(
pluginId,
expect.objectContaining({
databaseNamespace: namespace,
env: {
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
},
manifest: expect.objectContaining({
database: expect.objectContaining({ coreReadTables: ["companies"] }),
}),
}),
);
const [plugin] = await db
.select()
.from(plugins)
.where(eq(plugins.id, pluginId));
expect(plugin?.manifestJson.database?.coreReadTables).toEqual(["companies"]);
});
it("rejects checksum changes for already applied migrations", async () => {
const pluginManifest = manifest();
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);

View file

@ -0,0 +1,263 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import os from "node:os";
import path from "node:path";
import { promises as fs } from "node:fs";
import {
assertConfiguredLocalFolder,
assertWritableConfiguredLocalFolder,
inspectPluginLocalFolder,
listPluginLocalFolderEntries,
preparePluginLocalFolder,
readPluginLocalFolderText,
resolvePluginLocalFolderPath,
writePluginLocalFolderTextAtomic,
} from "../services/plugin-local-folders.js";
describe("plugin local folders", () => {
const tempRoots: string[] = [];
afterEach(async () => {
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
tempRoots.length = 0;
});
async function makeRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-folder-"));
tempRoots.push(root);
return root;
}
it("reports a healthy generic folder when required paths exist", async () => {
const root = await makeRoot();
await fs.mkdir(path.join(root, "sources"));
await fs.writeFile(path.join(root, "schema.md"), "schema", "utf8");
const status = await inspectPluginLocalFolder({
folderKey: "content-root",
storedConfig: {
path: root,
access: "readWrite",
requiredDirectories: ["sources"],
requiredFiles: ["schema.md"],
},
});
expect(status.healthy).toBe(true);
expect(status.problems).toEqual([]);
expect(status.requiredDirectories).toEqual(["sources"]);
expect(status.requiredFiles).toEqual(["schema.md"]);
});
it("reports missing required folders and files without using product-specific branches", async () => {
const root = await makeRoot();
const status = await inspectPluginLocalFolder({
folderKey: "content-root",
storedConfig: {
path: root,
requiredDirectories: ["sources"],
requiredFiles: ["schema.md"],
},
});
expect(status.healthy).toBe(false);
expect(status.missingDirectories).toEqual(["sources"]);
expect(status.missingFiles).toEqual(["schema.md"]);
expect(status.problems.map((item) => item.code)).toEqual(
expect.arrayContaining(["missing_directory", "missing_file"]),
);
});
it("reports all required paths as missing when the configured root does not exist", async () => {
const root = await makeRoot();
const missingRoot = path.join(root, "missing-root");
const status = await inspectPluginLocalFolder({
folderKey: "content-root",
storedConfig: {
path: missingRoot,
requiredDirectories: ["sources"],
requiredFiles: ["schema.md"],
},
});
expect(status.healthy).toBe(false);
expect(status.configured).toBe(true);
expect(status.readable).toBe(false);
expect(status.missingDirectories).toEqual(["sources"]);
expect(status.missingFiles).toEqual(["schema.md"]);
expect(status.problems.map((item) => item.code)).toContain("missing");
});
it("uses manifest declaration access and required paths over stored or caller overrides", async () => {
const root = await makeRoot();
await fs.mkdir(path.join(root, "manifest-dir"));
await fs.writeFile(path.join(root, "manifest.md"), "schema", "utf8");
const status = await inspectPluginLocalFolder({
folderKey: "content-root",
declaration: {
folderKey: "content-root",
displayName: "Content root",
access: "read",
requiredDirectories: ["manifest-dir"],
requiredFiles: ["manifest.md"],
},
storedConfig: {
path: root,
access: "readWrite",
requiredDirectories: ["stored-dir"],
requiredFiles: ["stored.md"],
},
overrideConfig: {
access: "readWrite",
requiredDirectories: ["override-dir"],
requiredFiles: ["override.md"],
},
});
expect(status.access).toBe("read");
expect(status.writable).toBe(false);
expect(status.requiredDirectories).toEqual(["manifest-dir"]);
expect(status.requiredFiles).toEqual(["manifest.md"]);
expect(status.healthy).toBe(true);
});
it("prepares required directories for a read-write folder without creating required files", async () => {
const root = await makeRoot();
await preparePluginLocalFolder({
folderKey: "content-root",
storedConfig: {
path: root,
access: "readWrite",
requiredDirectories: ["sources", "wiki/concepts"],
requiredFiles: ["schema.md"],
},
});
await expect(fs.stat(path.join(root, "sources"))).resolves.toMatchObject({});
await expect(fs.stat(path.join(root, "wiki/concepts"))).resolves.toMatchObject({});
await expect(fs.stat(path.join(root, "schema.md"))).rejects.toMatchObject({ code: "ENOENT" });
const status = await inspectPluginLocalFolder({
folderKey: "content-root",
storedConfig: {
path: root,
access: "readWrite",
requiredDirectories: ["sources", "wiki/concepts"],
requiredFiles: ["schema.md"],
},
});
expect(status.missingDirectories).toEqual([]);
expect(status.missingFiles).toEqual(["schema.md"]);
});
it("allows write access to repair folders that are only missing required paths", async () => {
const root = await makeRoot();
const status = await inspectPluginLocalFolder({
folderKey: "content-root",
storedConfig: {
path: root,
access: "readWrite",
requiredFiles: ["schema.md"],
},
});
expect(status.healthy).toBe(false);
expect(() => assertConfiguredLocalFolder(status)).toThrow("Local folder is not healthy");
expect(() => assertWritableConfiguredLocalFolder(status)).not.toThrow();
await writePluginLocalFolderTextAtomic(root, "schema.md", "schema");
const repaired = await inspectPluginLocalFolder({
folderKey: "content-root",
storedConfig: {
path: root,
access: "readWrite",
requiredFiles: ["schema.md"],
},
});
expect(repaired.healthy).toBe(true);
});
it("rejects traversal outside the configured folder", async () => {
const root = await makeRoot();
await expect(resolvePluginLocalFolderPath(root, "../outside.txt")).rejects.toMatchObject({
status: 403,
});
});
it("detects required symlinks that escape the configured folder", async () => {
const root = await makeRoot();
const outside = await makeRoot();
await fs.writeFile(path.join(outside, "secret.txt"), "nope", "utf8");
await fs.symlink(path.join(outside, "secret.txt"), path.join(root, "linked.txt"));
const status = await inspectPluginLocalFolder({
folderKey: "content-root",
storedConfig: {
path: root,
requiredFiles: ["linked.txt"],
},
});
expect(status.healthy).toBe(false);
expect(status.problems.some((item) => item.code === "symlink_escape")).toBe(true);
});
it("writes files atomically under the root and can read them back", async () => {
const root = await makeRoot();
await fs.mkdir(path.join(root, "nested"));
await writePluginLocalFolderTextAtomic(root, "nested/page.md", "hello");
await writePluginLocalFolderTextAtomic(root, "nested/page.md", "updated");
await expect(readPluginLocalFolderText(root, "nested/page.md")).resolves.toBe("updated");
const leftovers = await fs.readdir(path.join(root, "nested"));
expect(leftovers.filter((name) => name.includes(".paperclip-"))).toEqual([]);
});
it("lists nested local folder entries without following symlink escapes", async () => {
const root = await makeRoot();
const outside = await makeRoot();
await fs.mkdir(path.join(root, "wiki/concepts"), { recursive: true });
await fs.writeFile(path.join(root, "wiki/concepts/live.md"), "# Live\n", "utf8");
await fs.writeFile(path.join(outside, "secret.md"), "# Secret\n", "utf8");
await fs.symlink(outside, path.join(root, "wiki/outside"));
const listing = await listPluginLocalFolderEntries(root, {
relativePath: "wiki",
recursive: true,
maxEntries: 20,
});
expect(listing.entries.map((entry) => entry.path)).toContain("wiki/concepts/live.md");
expect(listing.entries.map((entry) => entry.path)).not.toContain("wiki/outside/secret.md");
expect(listing.truncated).toBe(false);
});
it("revalidates temp-file containment before writing atomic contents", async () => {
const root = await makeRoot();
const outside = await makeRoot();
const nested = path.join(root, "nested");
await fs.mkdir(nested);
const originalOpen = fs.open.bind(fs);
const openSpy = vi.spyOn(fs, "open");
openSpy.mockImplementationOnce(async (file, flags, mode) => {
await fs.rm(nested, { recursive: true, force: true });
await fs.symlink(outside, nested);
return originalOpen(file, flags, mode);
});
try {
await expect(writePluginLocalFolderTextAtomic(root, "nested/page.md", "secret")).rejects.toMatchObject({
status: 403,
});
await expect(fs.readFile(path.join(outside, "page.md"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readdir(outside)).toEqual([]);
} finally {
openSpy.mockRestore();
}
});
});

View file

@ -0,0 +1,365 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agentConfigRevisions,
agents,
approvals,
companies,
createDb,
pluginEntities,
pluginCompanySettings,
pluginManagedResources,
plugins,
} from "@paperclipai/db";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { buildHostServices } from "../services/plugin-host-services.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
function createEventBusStub() {
return {
forPlugin() {
return {
emit: async () => {},
subscribe: () => {},
};
},
} as any;
}
function issuePrefix(id: string) {
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
}
function manifest(): PaperclipPluginManifestV1 {
return {
id: "paperclip.managed-agents-test",
apiVersion: 1,
version: "0.1.0",
displayName: "Managed Agents Test",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["agents.managed"],
entrypoints: { worker: "./dist/worker.js" },
agents: [
{
agentKey: "wiki-maintainer",
displayName: "Wiki Maintainer",
role: "engineer",
title: "Maintains plugin-owned knowledge",
capabilities: "Maintains a plugin-owned wiki.",
adapterType: "process",
adapterConfig: { command: "pnpm wiki:maintain" },
runtimeConfig: { modelProfiles: { cheap: { enabled: true, adapterConfig: { model: "small" } } } },
permissions: { canCreateAgents: false },
budgetMonthlyCents: 1234,
},
],
};
}
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres plugin-managed agent tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("plugin-managed agents", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-agents-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(agentConfigRevisions);
await db.delete(activityLog);
await db.delete(pluginEntities);
await db.delete(pluginManagedResources);
await db.delete(pluginCompanySettings);
await db.delete(approvals);
await db.delete(agents);
await db.delete(plugins);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompanyAndPlugin(options: { requireApproval?: boolean; manifest?: PaperclipPluginManifestV1 } = {}) {
const companyId = randomUUID();
const pluginId = randomUUID();
const pluginManifest = options.manifest ?? manifest();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: issuePrefix(companyId),
requireBoardApprovalForNewAgents: options.requireApproval ?? false,
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: pluginManifest.id,
packageName: "@paperclipai/plugin-managed-agents-test",
version: pluginManifest.version,
apiVersion: pluginManifest.apiVersion,
categories: pluginManifest.categories,
manifestJson: pluginManifest,
status: "ready",
installOrder: 1,
});
const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, {
manifest: pluginManifest,
});
return { companyId, pluginId, pluginManifest, services };
}
it("creates and resolves managed agents by stable resource key", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const created = await services.agents.managedReconcile({
companyId,
agentKey: "wiki-maintainer",
});
expect(created.status).toBe("created");
expect(created.agentId).toBeTruthy();
expect(created.agent).toMatchObject({
name: "Wiki Maintainer",
role: "engineer",
adapterConfig: { command: "pnpm wiki:maintain" },
});
const resolved = await services.agents.managedGet({
companyId,
agentKey: "wiki-maintainer",
});
expect(resolved.status).toBe("resolved");
expect(resolved.agentId).toBe(created.agentId);
const [binding] = await db.select().from(pluginEntities);
expect(binding?.entityType).toBe("managed_agent");
expect(binding?.scopeKind).toBe("company");
expect(binding?.scopeId).toBe(companyId);
expect(binding?.data).toMatchObject({
resourceKind: "agent",
resourceKey: "wiki-maintainer",
agentId: created.agentId,
});
});
it("preserves user edits during reconcile and resets only on explicit reset", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(created.agentId).toBeTruthy();
await db
.update(agents)
.set({
name: "Knowledge Lead",
adapterConfig: { command: "custom" },
updatedAt: new Date(),
})
.where(eq(agents.id, created.agentId!));
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(reconciled.status).toBe("resolved");
expect(reconciled.agent).toMatchObject({
name: "Knowledge Lead",
adapterConfig: { command: "custom" },
});
const reset = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
expect(reset.status).toBe("reset");
expect(reset.agent).toMatchObject({
name: "Wiki Maintainer",
adapterConfig: { command: "pnpm wiki:maintain" },
});
});
it("creates managed agents with the most-used compatible company adapter", async () => {
const pluginManifest = manifest();
pluginManifest.agents![0] = {
...pluginManifest.agents![0]!,
adapterType: "claude_local",
adapterPreference: ["claude_local", "codex_local"],
adapterConfig: {},
};
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest });
await db.insert(agents).values([
{
id: randomUUID(),
companyId,
name: "Codex One",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: randomUUID(),
companyId,
name: "Codex Two",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: randomUUID(),
companyId,
name: "Claude One",
role: "engineer",
status: "idle",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(created.status).toBe("created");
expect(created.agent?.adapterType).toBe("codex_local");
});
it("materializes declared managed agent instructions with local folder paths", async () => {
const previousHome = process.env.PAPERCLIP_HOME;
const previousInstance = process.env.PAPERCLIP_INSTANCE_ID;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-home-"));
const wikiRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-wiki-")));
process.env.PAPERCLIP_HOME = tempHome;
process.env.PAPERCLIP_INSTANCE_ID = "test";
try {
const pluginManifest = manifest();
pluginManifest.localFolders = [
{
folderKey: "wiki-root",
displayName: "Wiki root",
access: "readWrite",
requiredDirectories: [],
requiredFiles: ["AGENTS.md"],
},
];
pluginManifest.agents![0] = {
...pluginManifest.agents![0]!,
adapterType: "claude_local",
adapterConfig: {},
instructions: {
entryFile: "AGENTS.md",
content: [
"# LLM Wiki Maintainer",
"",
"You are the LLM Wiki Maintainer.",
"Wiki root: `{{localFolders.wiki-root.path}}`",
"Wiki schema: `{{localFolders.wiki-root.agentsPath}}`",
"",
].join("\n"),
},
};
const { companyId, pluginId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest });
await fs.writeFile(path.join(wikiRoot, "AGENTS.md"), "# Wiki schema\n", "utf8");
await db.insert(pluginCompanySettings).values({
companyId,
pluginId,
enabled: true,
settingsJson: {
localFolders: {
"wiki-root": {
path: wikiRoot,
access: "readWrite",
requiredDirectories: [],
requiredFiles: ["AGENTS.md"],
},
},
},
});
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
const instructionsFilePath = created.agent?.adapterConfig.instructionsFilePath;
expect(typeof instructionsFilePath).toBe("string");
const content = await fs.readFile(instructionsFilePath as string, "utf8");
expect(content).toContain("You are the LLM Wiki Maintainer.");
expect(content).toContain(`Wiki root: \`${wikiRoot}\``);
expect(content).toContain(`Wiki schema: \`${path.join(wikiRoot, "AGENTS.md")}\``);
} finally {
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousHome;
if (previousInstance === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = previousInstance;
await fs.rm(tempHome, { recursive: true, force: true });
await fs.rm(wikiRoot, { recursive: true, force: true });
}
});
it("repairs a missing binding by relinking a same-company managed agent marker", async () => {
const { companyId, pluginId, pluginManifest, services } = await seedCompanyAndPlugin();
const agentId = randomUUID();
await db.insert(agents).values({
id: agentId,
companyId,
name: "Renamed Wiki Agent",
role: "engineer",
status: "idle",
adapterType: "process",
adapterConfig: { command: "custom" },
runtimeConfig: {},
permissions: {},
metadata: {
paperclipManagedResource: {
pluginId,
pluginKey: pluginManifest.id,
resourceKind: "agent",
resourceKey: "wiki-maintainer",
},
},
});
const relinked = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(relinked.status).toBe("relinked");
expect(relinked.agentId).toBe(agentId);
const [binding] = await db.select().from(pluginEntities);
expect(binding?.data).toMatchObject({ agentId });
});
it("respects board approval policy for new managed agents", async () => {
const { companyId, services } = await seedCompanyAndPlugin({ requireApproval: true });
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(created.status).toBe("created");
expect(created.agent?.status).toBe("pending_approval");
expect(created.approvalId).toBeTruthy();
const [approval] = await db.select().from(approvals).where(eq(approvals.id, created.approvalId!));
expect(approval).toMatchObject({
type: "hire_agent",
status: "pending",
});
expect(approval?.payload).toMatchObject({
agentId: created.agentId,
sourcePluginKey: "paperclip.managed-agents-test",
managedResourceKey: "wiki-maintainer",
});
});
});

View file

@ -0,0 +1,249 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agentConfigRevisions,
agents,
companies,
createDb,
issues,
pluginManagedResources,
plugins,
projects,
routineRuns,
routineTriggers,
routines,
} from "@paperclipai/db";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { buildHostServices } from "../services/plugin-host-services.js";
import { routineService } from "../services/routines.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
function createEventBusStub() {
return {
forPlugin() {
return {
emit: async () => {},
subscribe: () => {},
};
},
} as any;
}
function issuePrefix(id: string) {
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
}
function manifest(): PaperclipPluginManifestV1 {
return {
id: "paperclip.managed-routines-test",
apiVersion: 1,
version: "0.1.0",
displayName: "Managed Routines Test",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["agents.managed", "projects.managed", "routines.managed"],
entrypoints: { worker: "./dist/worker.js" },
agents: [{
agentKey: "wiki-maintainer",
displayName: "Wiki Maintainer",
role: "engineer",
adapterType: "process",
adapterConfig: { command: "pnpm wiki:maintain" },
}],
projects: [{
projectKey: "operations",
displayName: "Plugin Operations",
description: "Plugin operation inspection",
status: "in_progress",
}],
routines: [{
routineKey: "nightly-lint",
title: "Nightly lint",
description: "Lint plugin state",
assigneeRef: { resourceKind: "agent", resourceKey: "wiki-maintainer" },
projectRef: { resourceKind: "project", resourceKey: "operations" },
status: "active",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
triggers: [{
kind: "schedule",
label: "Nightly",
cronExpression: "0 3 * * *",
timezone: "UTC",
}],
issueTemplate: {
surfaceVisibility: "plugin_operation",
originId: "operation:nightly-lint",
billingCode: "plugin-test:nightly-lint",
},
}],
};
}
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres plugin-managed routine tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("plugin-managed routines", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-routines-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(routineRuns);
await db.delete(routineTriggers);
await db.delete(routines);
await db.delete(issues);
await db.delete(agentConfigRevisions);
await db.delete(activityLog);
await db.delete(pluginManagedResources);
await db.delete(agents);
await db.delete(projects);
await db.delete(plugins);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompanyAndPlugin(pluginManifest = manifest()) {
const companyId = randomUUID();
const pluginId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: issuePrefix(companyId),
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: pluginManifest.id,
packageName: "@paperclipai/plugin-managed-routines-test",
version: pluginManifest.version,
apiVersion: pluginManifest.apiVersion,
categories: pluginManifest.categories,
manifestJson: pluginManifest,
status: "ready",
installOrder: 1,
});
const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, {
manifest: pluginManifest,
});
return { companyId, pluginId, pluginManifest, services };
}
it("resolves routine agent and project refs by stable managed keys", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const agent = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
const project = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
const created = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
expect(created.status).toBe("created");
expect(created.routine).toMatchObject({
title: "Nightly lint",
assigneeAgentId: agent.agentId,
projectId: project.projectId,
managedByPlugin: expect.objectContaining({
pluginKey: "paperclip.managed-routines-test",
resourceKind: "routine",
resourceKey: "nightly-lint",
}),
});
const [trigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, created.routineId!));
expect(trigger).toMatchObject({
kind: "schedule",
cronExpression: "0 3 * * *",
timezone: "UTC",
});
});
it("returns missing refs until the operator repairs them and preserves routine edits on reconcile", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const missing = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
expect(missing.status).toBe("missing_refs");
expect(missing.missingRefs).toEqual([
expect.objectContaining({ resourceKind: "agent", resourceKey: "wiki-maintainer" }),
expect.objectContaining({ resourceKind: "project", resourceKey: "operations" }),
]);
const [agent] = await db.insert(agents).values({
companyId,
name: "Operator-selected maintainer",
role: "engineer",
status: "idle",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
}).returning();
const [project] = await db.insert(projects).values({
companyId,
name: "Operator-selected project",
status: "in_progress",
}).returning();
const repaired = await services.routines.managedReconcile({
companyId,
routineKey: "nightly-lint",
assigneeAgentId: agent.id,
projectId: project.id,
});
expect(repaired.status).toBe("created");
expect(repaired.routine).toMatchObject({
assigneeAgentId: agent.id,
projectId: project.id,
});
await db
.update(routines)
.set({ title: "Operator renamed lint", updatedAt: new Date() })
.where(eq(routines.id, repaired.routineId!));
const reconciled = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
expect(reconciled.status).toBe("resolved");
expect(reconciled.routine?.title).toBe("Operator renamed lint");
});
it("creates routine operation issues with plugin visibility and managed project scoping", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const agent = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
const project = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
const routine = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
const wakeup = vi.fn(async () => ({ id: randomUUID() }));
const routinesSvc = routineService(db, { heartbeat: { wakeup } });
const run = await routinesSvc.runRoutine(routine.routineId!, { source: "manual" }, { userId: "board-user" });
expect(run.status).toBe("issue_created");
const [issue] = await db.select().from(issues).where(eq(issues.id, run.linkedIssueId!));
expect(issue).toMatchObject({
originKind: "plugin:paperclip.managed-routines-test:operation",
originId: "operation:nightly-lint",
billingCode: "plugin-test:nightly-lint",
projectId: project.projectId,
assigneeAgentId: agent.agentId,
});
expect(wakeup).toHaveBeenCalledWith(agent.agentId, expect.objectContaining({
reason: "issue_assigned",
}));
});
});

View file

@ -1,4 +1,7 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
@ -11,6 +14,9 @@ import {
heartbeatRuns,
issueRelations,
issues,
pluginManagedResources,
plugins,
projects,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@ -45,6 +51,7 @@ if (!embeddedPostgresSupport.supported) {
describeEmbeddedPostgres("plugin orchestration APIs", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const tempRoots: string[] = [];
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-orchestration-");
@ -52,12 +59,17 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
}, 20_000);
afterEach(async () => {
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
tempRoots.length = 0;
await db.delete(activityLog);
await db.delete(costEvents);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(issueRelations);
await db.delete(issues);
await db.delete(pluginManagedResources);
await db.delete(projects);
await db.delete(plugins);
await db.delete(agents);
await db.delete(companies);
});
@ -89,6 +101,12 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
return { companyId, agentId };
}
async function makeLocalRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-host-folder-"));
tempRoots.push(root);
return root;
}
it("creates plugin-origin issues with full orchestration fields and audit activity", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const blockerIssueId = randomUUID();
@ -189,6 +207,293 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.missions");
});
it("creates plugin operation issues with the generic operation origin", async () => {
const { companyId } = await seedCompanyAndAgent();
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
const issue = await services.issues.create({
companyId,
title: "Background operation",
surfaceVisibility: "plugin_operation",
originId: "mission-alpha:operation-1",
});
expect(issue.originKind).toBe("plugin:paperclip.missions:operation");
expect(issue.originId).toBe("mission-alpha:operation-1");
});
it("lets bootstrap-style actions initialize required local folders from an empty root", async () => {
const { companyId } = await seedCompanyAndAgent();
const pluginId = randomUUID();
await db.insert(plugins).values({
id: pluginId,
pluginKey: "paperclipai.plugin-llm-wiki",
packageName: "@paperclipai/plugin-llm-wiki",
version: "0.1.0",
manifestJson: {
id: "paperclipai.plugin-llm-wiki",
apiVersion: 1,
version: "0.1.0",
displayName: "LLM Wiki",
description: "Local-file LLM Wiki plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["local.folders"],
entrypoints: { worker: "./dist/worker.js" },
localFolders: [
{
folderKey: "wiki-root",
displayName: "Wiki root",
access: "readWrite",
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
requiredFiles: ["WIKI.md", "AGENTS.md"],
},
],
},
status: "ready",
});
const root = await makeLocalRoot();
const services = buildHostServices(
db,
pluginId,
"paperclipai.plugin-llm-wiki",
createEventBusStub(),
undefined,
{
manifest: {
id: "paperclipai.plugin-llm-wiki",
apiVersion: 1,
version: "0.1.0",
displayName: "LLM Wiki",
description: "Local-file LLM Wiki plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["local.folders"],
entrypoints: { worker: "./dist/worker.js" },
localFolders: [
{
folderKey: "wiki-root",
displayName: "Wiki root",
access: "readWrite",
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
requiredFiles: ["WIKI.md", "AGENTS.md"],
},
],
},
},
);
const configured = await services.localFolders.configure({
companyId,
folderKey: "wiki-root",
path: root,
access: "readWrite",
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
requiredFiles: ["WIKI.md", "AGENTS.md"],
});
expect(configured.healthy).toBe(false);
expect(configured.missingDirectories).toEqual([]);
expect(configured.missingFiles).toEqual(["WIKI.md", "AGENTS.md"]);
await fs.rm(path.join(root, "raw"), { recursive: true, force: true });
await fs.rm(path.join(root, "wiki"), { recursive: true, force: true });
await expect(services.localFolders.readText({ companyId, folderKey: "wiki-root", relativePath: "WIKI.md" }))
.rejects.toThrow("Local folder is not healthy");
await services.localFolders.writeTextAtomic({
companyId,
folderKey: "wiki-root",
relativePath: "WIKI.md",
contents: "# Wiki\n",
});
await services.localFolders.writeTextAtomic({
companyId,
folderKey: "wiki-root",
relativePath: "AGENTS.md",
contents: "# Agents\n",
});
const finalStatus = await services.localFolders.status({ companyId, folderKey: "wiki-root" });
expect(finalStatus.healthy).toBe(true);
await expect(fs.stat(path.join(root, "raw"))).resolves.toMatchObject({});
await expect(fs.stat(path.join(root, "wiki/concepts"))).resolves.toMatchObject({});
await expect(fs.readFile(path.join(root, "WIKI.md"), "utf8")).resolves.toBe("# Wiki\n");
});
it("rejects worker local-folder access for undeclared manifest keys", async () => {
const { companyId } = await seedCompanyAndAgent();
const pluginId = randomUUID();
await db.insert(plugins).values({
id: pluginId,
pluginKey: "paperclip.local-folders",
packageName: "@paperclip/plugin-local-folders",
version: "0.1.0",
manifestJson: {
id: "paperclip.local-folders",
apiVersion: 1,
version: "0.1.0",
displayName: "Local Folders",
description: "Local folder fixture",
author: "Paperclip",
categories: ["automation"],
capabilities: ["local.folders"],
entrypoints: { worker: "./dist/worker.js" },
localFolders: [
{
folderKey: "content-root",
displayName: "Content root",
access: "readWrite",
},
],
},
status: "ready",
});
const services = buildHostServices(
db,
pluginId,
"paperclip.local-folders",
createEventBusStub(),
undefined,
{
manifest: {
id: "paperclip.local-folders",
apiVersion: 1,
version: "0.1.0",
displayName: "Local Folders",
description: "Local folder fixture",
author: "Paperclip",
categories: ["automation"],
capabilities: ["local.folders"],
entrypoints: { worker: "./dist/worker.js" },
localFolders: [
{
folderKey: "content-root",
displayName: "Content root",
access: "readWrite",
},
],
},
},
);
await expect(services.localFolders.configure({
companyId,
folderKey: "ssh",
path: "/tmp",
access: "read",
})).rejects.toThrow("Local folder key is not declared");
await expect(services.localFolders.status({ companyId, folderKey: "ssh" }))
.rejects.toThrow("Local folder key is not declared");
await expect(services.localFolders.readText({ companyId, folderKey: "ssh", relativePath: "id_rsa" }))
.rejects.toThrow("Local folder key is not declared");
await expect(services.localFolders.writeTextAtomic({
companyId,
folderKey: "ssh",
relativePath: "id_rsa",
contents: "secret",
})).rejects.toThrow("Local folder key is not declared");
});
it("resolves plugin-managed projects by stable key without overwriting user edits", async () => {
const { companyId } = await seedCompanyAndAgent();
const pluginId = randomUUID();
await db.insert(plugins).values({
id: pluginId,
pluginKey: "paperclip.missions",
packageName: "@paperclip/plugin-missions",
version: "0.1.0",
apiVersion: 1,
categories: ["automation"],
status: "ready",
manifestJson: {
id: "paperclip.missions",
apiVersion: 1,
version: "0.1.0",
displayName: "Missions",
description: "Mission orchestration",
author: "Paperclip",
categories: ["automation"],
capabilities: ["projects.managed"],
entrypoints: { worker: "./dist/worker.js" },
projects: [{
projectKey: "operations",
displayName: "Mission Operations",
description: "Plugin operation inspection area",
status: "in_progress",
color: "#14b8a6",
settings: { surface: "operations" },
}],
},
});
const services = buildHostServices(db, pluginId, "paperclip.missions", createEventBusStub());
const missing = await services.projects.getManaged({ companyId, projectKey: "operations" });
expect(missing.status).toBe("missing");
expect(missing.projectId).toBeNull();
await expect(
db
.select()
.from(pluginManagedResources)
.where(and(
eq(pluginManagedResources.companyId, companyId),
eq(pluginManagedResources.pluginId, pluginId),
eq(pluginManagedResources.resourceKind, "project"),
eq(pluginManagedResources.resourceKey, "operations"),
)),
).resolves.toHaveLength(0);
const created = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
expect(created.status).toBe("created");
expect(created.projectId).toEqual(expect.any(String));
expect(created.project?.managedByPlugin).toMatchObject({
pluginId,
pluginKey: "paperclip.missions",
pluginDisplayName: "Missions",
resourceKind: "project",
resourceKey: "operations",
});
await db
.update(projects)
.set({ name: "Renamed by operator", description: "User-owned text", updatedAt: new Date() })
.where(eq(projects.id, created.projectId!));
await db
.update(plugins)
.set({
manifestJson: {
id: "paperclip.missions",
apiVersion: 1,
version: "0.2.0",
displayName: "Missions",
description: "Mission orchestration",
author: "Paperclip",
categories: ["automation"],
capabilities: ["projects.managed"],
entrypoints: { worker: "./dist/worker.js" },
projects: [{
projectKey: "operations",
displayName: "Upgraded Default Name",
description: "Upgraded default description",
status: "planned",
color: "#f97316",
settings: { surface: "operations", upgraded: true },
}],
},
updatedAt: new Date(),
})
.where(eq(plugins.id, pluginId));
const resolved = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
expect(resolved.status).toBe("resolved");
expect(resolved.projectId).toBe(created.projectId);
expect(resolved.project?.name).toBe("Renamed by operator");
expect(resolved.project?.description).toBe("User-owned text");
expect(resolved.project?.managedByPlugin?.defaultsJson).toMatchObject({
displayName: "Upgraded Default Name",
settings: { upgraded: true },
});
});
it("asserts checkout ownership for run-scoped plugin actions", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const issueId = randomUUID();

View file

@ -6,6 +6,8 @@ const mockRegistry = vi.hoisted(() => ({
getById: vi.fn(),
getByKey: vi.fn(),
upsertConfig: vi.fn(),
getCompanySettings: vi.fn(),
upsertCompanySettings: vi.fn(),
}));
const mockLifecycle = vi.hoisted(() => ({
@ -317,6 +319,61 @@ describe.sequential("scoped plugin API routes", () => {
}, 20_000);
});
describe.sequential("plugin local folder routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockRegistry.getCompanySettings.mockResolvedValue(null);
});
function readyLocalFolderPlugin() {
mockRegistry.getById.mockResolvedValue({
id: pluginId,
pluginKey: "paperclip.example",
version: "1.0.0",
status: "ready",
manifestJson: {
id: "paperclip.example",
capabilities: ["local.folders"],
localFolders: [
{
folderKey: "content-root",
displayName: "Content root",
access: "readWrite",
requiredDirectories: ["docs"],
requiredFiles: ["README.md"],
},
],
},
});
}
it("rejects validation for undeclared local folder keys", async () => {
readyLocalFolderPlugin();
const { app } = await createApp(boardActor());
const res = await request(app)
.post(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/ssh/validate`)
.send({ path: "/tmp" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("Local folder key is not declared");
expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled();
});
it("rejects saving undeclared local folder keys", async () => {
readyLocalFolderPlugin();
const { app } = await createApp(boardActor());
const res = await request(app)
.put(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/ssh`)
.send({ path: "/tmp" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("Local folder key is not declared");
expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled();
});
});
describe.sequential("plugin tool and bridge authz", () => {
beforeEach(() => {
vi.clearAllMocks();

View file

@ -98,6 +98,7 @@ describe.sequential("plugin scoped API routes", () => {
const pluginId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
const agentId = "33333333-3333-4333-8333-333333333333";
const peerAgentId = "33333333-3333-4333-8333-333333333334";
const runId = "44444444-4444-4444-8444-444444444444";
const issueId = "55555555-5555-4555-8555-555555555555";
@ -252,6 +253,55 @@ describe.sequential("plugin scoped API routes", () => {
}));
});
it("allows non-assignee agents on in-progress required checkout routes without claiming checkout ownership", async () => {
const apiRoutes = manifest([
{
routeKey: "issue.advance",
method: "POST",
path: "/issues/:issueId/advance",
auth: "agent",
capability: "api.routes.register",
checkoutPolicy: "required-for-agent-in-progress",
companyResolution: { from: "issue", param: "issueId" },
},
]);
mockIssueService.getById.mockResolvedValue({
id: issueId,
companyId,
status: "in_progress",
assigneeAgentId: agentId,
});
const { app, workerManager } = await createApp({
actor: {
type: "agent",
agentId: peerAgentId,
companyId,
runId,
source: "agent_key",
},
plugin: {
id: pluginId,
pluginKey: apiRoutes.id,
status: "ready",
manifestJson: apiRoutes,
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/api/issues/${issueId}/advance`)
.send({ step: "next" });
expect(res.status).toBe(200);
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "handleApiRequest", expect.objectContaining({
routeKey: "issue.advance",
params: { issueId },
body: { step: "next" },
actor: expect.objectContaining({ actorType: "agent", agentId: peerAgentId, runId }),
companyId,
}));
});
it("rejects checkout-protected agent routes without a run id before worker dispatch", async () => {
const apiRoutes = manifest([
{

View file

@ -150,6 +150,23 @@ describe("plugin SDK orchestration contract", () => {
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.test-orchestration");
});
it("supports generic plugin operation issue visibility in the test harness", async () => {
const companyId = randomUUID();
const harness = createTestHarness({
manifest: manifest(["issues.create"]),
});
const created = await harness.ctx.issues.create({
companyId,
title: "Background operation",
surfaceVisibility: "plugin_operation",
originId: "operation-1",
});
expect(created.originKind).toBe("plugin:paperclip.test-orchestration:operation");
expect(created.originId).toBe("operation-1");
});
it("enforces checkout and wakeup capabilities in the test harness", async () => {
const companyId = randomUUID();
const agentId = randomUUID();