Files
paperclip/server/src/__tests__/plugin-managed-agents.test.ts
T
Dotta 3c73ed26b5 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>
2026-05-05 07:42:57 -05:00

366 lines
12 KiB
TypeScript

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