Files
PaperClipAI/server/src/__tests__/plugin-database.test.ts
T
Nicky LeachandClaude Opus 5.5 2c39a2866b feat(plugins): let managed config grant worker variables to a plugin (#19049)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A managed instance gets its plugins from the control plane, and
people do not install them
> - Some of these plugins need deployment values, such as a service
origin and a capability
> - The plugin worker environment is scrubbed, and only a fixed list of
first-party providers gets deployment variables
> - Core also sends lifecycle events and project instance secrets only
to plugins with a saved config row
> - So a provisioned plugin cannot work until a person saves
configuration for it
> - This pull request lets the managed config grant named variables to a
selected plugin
> - The benefit is that a distribution plugin can run with no tenant
configuration, and Core does not need to name it

## Linked Issues or Issue Description

No issue exists. This describes the problem. I searched open pull
requests for related work and found no duplicate.

**What existing behavior does this improve?**

How a plugin provisioned through `plugins.autoInstall` receives its
deployment settings.

**Current behavior**

`buildPluginWorkerEnv` forwards variables only from a fixed list that
names first-party packages. A distribution plugin cannot be on that list
without Core naming it. A plugin with no `pluginConfig` row gets no
resource lifecycle events, and project instance secrets refuse it.

**Proposed behavior**

The managed config can include `plugins.workerEnv`. It maps a catalog
key to variable names. The worker of that plugin receives those
variables from the server process environment. The managed config can
also include `plugins.enabledCompanies`. It maps a catalog key to
company IDs. In those companies, the plugin gets lifecycle events and
can use project instance secrets with no config row.

**Reason and benefit**

The control plane stays the only place that decides which plugin gets
which values. Tenants do not handle these values, and they are not in
the database or in the managed config document.

**Alternatives considered**

- Create a config or enablement row for each company. The control plane
would need a new tenant write API, and a row can stay after the document
removes access or return after a database restore. The document is never
saved, like the other managed settings.
- Give a grant access to every company. This has no per-company opt-in.
- Put the values in the managed config document. The document does not
hold secrets, and this rule stays true because it holds only names.

## What Changed

- `parseManagedConfigEnv` accepts an optional `plugins.workerEnv`
object. Each key must be in `plugins.autoInstall`. Names must be
uppercase variable names, with no duplicates.
- Startup fails if a name looks like a host credential, such as a name
with `KEY`, `TOKEN`, `SECRET`, `PASSWORD`, `DATABASE` or `JWT` as a
word, or if it controls how the worker loads code, such as
`NODE_OPTIONS`.
- `parseManagedConfigEnv` accepts an optional `plugins.enabledCompanies`
object. Each key must be in `plugins.autoInstall`. Values must be
lowercase company UUIDs, with no duplicates.
- `managedPluginWorkerEnvGrant` and `managedPluginEnabledCompanies`
return values by plugin key. A managed instance only starts a selected
plugin from the current image, so the saved package path is not part of
this check. It can be old or null.
- `buildPluginWorkerEnv` forwards granted variables. This does not
depend on the `environment.drivers.register` capability.
- Plugin lifecycle delivery includes a plugin with no config row only
for the companies the document names. It still skips a company that
disabled the plugin. The runtime event listener follows the same rule.
- Project instance secrets accept a plugin with no config row only for a
company the document names.
- A variable grant alone gives no company access.
- `doc/plugins/DISTRIBUTION-PLUGINS.md` describes both sections.

## Verification

- `npx vitest run` in `server/` on `managed-config`,
`managed-plugin-worker-env`, `plugin-lifecycle-delivery`,
`project-instance-secrets`, `plugin-database` and `bundled-plugins`: 201
tests pass.
- The new tests cover a digest-verified distribution plugin, a stale
saved package path, a catalog key named after an inherited property, a
company the document does not name, a company that disabled the plugin,
a named company that does not exist, a grant with no enabled company,
and host credential names.
- `tsc --noEmit` in `server/` shows no errors in the changed files. My
worktree does not have the built `@paperclipai/paperclip-runner`
package, so unrelated files show errors locally. CI runs the full
typecheck.
- I did not run the full test suite locally. CI runs it.
- I did not test this change on a running instance.

## Risks

- **Older builds:** a build without this change refuses a document with
`plugins.workerEnv` or `plugins.enabledCompanies`, because it fails
closed on unknown keys. A control plane must send these sections only to
a build that supports them.
- **Trust:** the granted plugin gets the variable values in its process.
The image owner and the control plane already trust this plugin. The
name check stops a mistake with host credentials, but a name check
cannot prove that a value is not sensitive. Core cannot use an
allowlist, because it must not name the variables of a private plugin.
- **Company scope:** the control plane sets which companies a plugin
serves. A new company needs a new document, and the server reads the
document when it starts. Self-hosted instances do not change.
- **Rotation:** a new value reaches the worker only when the server
restarts. Rotation without a restart is not part of this change.
- **Background calls:** a grant does not add proactive company access. A
plugin that makes background calls with no invocation still needs a
saved config for that company.
- This changes core plugin behavior, and I did not discuss it in `#dev`
first. Maintainers can tell me if they want a discussion before review.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), through Claude Code, with tool
use and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:24:43 -08:00

829 lines
29 KiB
TypeScript

import { randomUUID } from "node:crypto";
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, vi } from "vitest";
import {
companies,
createDb,
issueRelations,
issues,
pluginDatabaseNamespaces,
pluginMigrations,
plugins,
} from "@paperclipai/db";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import {
derivePluginDatabaseNamespace,
pluginDatabaseService,
validatePluginMigrationStatement,
validatePluginRuntimeExecute,
validatePluginRuntimeQuery,
} from "../services/plugin-database.js";
import { buildPluginWorkerEnv, pluginLoader } from "../services/plugin-loader.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const multiMigrationPluginKey = "paperclip.dbfixture";
const llmWikiPluginKey = "paperclipai.plugin-llm-wiki";
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres plugin database tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describe("plugin database SQL validation", () => {
it("allows namespace migrations with whitelisted public foreign keys", () => {
expect(() =>
validatePluginMigrationStatement(
"CREATE TABLE plugin_test.rows (id uuid PRIMARY KEY, issue_id uuid REFERENCES public.issues(id))",
"plugin_test",
["issues"],
)
).not.toThrow();
});
it("allows qualified index creation and namespace-scoped migration backfills", () => {
expect(() =>
validatePluginMigrationStatement(
"CREATE INDEX IF NOT EXISTS rows_issue_idx ON plugin_test.rows (issue_id)",
"plugin_test",
)
).not.toThrow();
expect(() =>
validatePluginMigrationStatement(
`
WITH source_rows AS (
SELECT id FROM plugin_test.rows
)
INSERT INTO plugin_test.row_copies (id)
SELECT id FROM source_rows
ON CONFLICT (id) DO NOTHING
`,
"plugin_test",
)
).not.toThrow();
expect(() =>
validatePluginMigrationStatement(
`
UPDATE plugin_test.rows r
SET copied_from_id = s.id
FROM plugin_test.source_rows s
WHERE s.id = r.id
`,
"plugin_test",
)
).not.toThrow();
});
it("keeps migration backfill writes scoped to the plugin namespace", () => {
expect(() =>
validatePluginMigrationStatement(
"CREATE TABLE rows (id uuid PRIMARY KEY, issue_id uuid REFERENCES public.issues(id))",
"plugin_test",
["issues"],
)
).toThrow(/fully qualified/i);
expect(() =>
validatePluginMigrationStatement(
"WITH source_rows AS (SELECT id FROM plugin_test.rows) INSERT INTO public.issues (id) SELECT id FROM source_rows",
"plugin_test",
["issues"],
)
).toThrow(/public/i);
expect(() =>
validatePluginMigrationStatement(
"UPDATE public.issues SET title = 'bad'",
"plugin_test",
["issues"],
)
).toThrow(/public/i);
});
it("rejects migrations that create public objects", () => {
expect(() =>
validatePluginMigrationStatement(
"CREATE TABLE public.rows (id uuid PRIMARY KEY)",
"plugin_test",
["issues"],
)
).toThrow(/public/i);
});
it("allows whitelisted runtime reads but rejects public writes", () => {
expect(() =>
validatePluginRuntimeQuery(
"SELECT r.id FROM plugin_test.rows r JOIN public.issues i ON i.id = r.issue_id",
"plugin_test",
["issues"],
)
).not.toThrow();
expect(() =>
validatePluginRuntimeExecute("UPDATE public.issues SET title = $1", "plugin_test")
).toThrow(/namespace/i);
});
it("targets anonymous DO blocks without rejecting do-prefixed aliases", () => {
expect(() =>
validatePluginRuntimeQuery(
"SELECT EXTRACT(DOW FROM created_at) AS do_flag FROM plugin_test.rows",
"plugin_test",
)
).not.toThrow();
expect(() =>
validatePluginMigrationStatement("DO $$ BEGIN END $$;", "plugin_test")
).toThrow(/disallowed/i);
});
});
describe("buildPluginWorkerEnv", () => {
const instanceInfo = {
deploymentMode: "authenticated",
deploymentExposure: "public",
};
it("forwards only granted variables to a plugin that registers no environment driver", () => {
const env = buildPluginWorkerEnv({
manifest: { capabilities: [] },
instanceInfo,
grantedEnvVars: ["PROVIDER_ORIGIN", "PROVIDER_BLANK", "PROVIDER_MISSING"],
processEnv: { PROVIDER_ORIGIN: "https://provider.test", PROVIDER_BLANK: " ", DATABASE_URL: "postgres://host", OPENAI_API_KEY: "openai-token" },
});
expect(env).toEqual({
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
PROVIDER_ORIGIN: "https://provider.test",
});
});
it("passes only model provider keys through to environment driver plugins", () => {
const env = buildPluginWorkerEnv({
manifest: { capabilities: ["environment.drivers.register"] },
instanceInfo,
processEnv: {
ANTHROPIC_API_KEY: "anthropic-token",
OPENAI_API_KEY: "openai-token",
GEMINI_API_KEY: " ",
AWS_SECRET_ACCESS_KEY: "aws-secret",
},
});
expect(env).toEqual({
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
ANTHROPIC_API_KEY: "anthropic-token",
OPENAI_API_KEY: "openai-token",
});
});
it("passes in-cluster Kubernetes service-discovery vars to environment driver plugins", () => {
const env = buildPluginWorkerEnv({
manifest: { capabilities: ["environment.drivers.register"] },
instanceInfo,
processEnv: {
KUBERNETES_SERVICE_HOST: "10.0.0.1",
KUBERNETES_SERVICE_PORT: "443",
KUBERNETES_SERVICE_PORT_HTTPS: " ",
AWS_SECRET_ACCESS_KEY: "aws-secret",
},
});
expect(env).toEqual({
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
KUBERNETES_SERVICE_HOST: "10.0.0.1",
KUBERNETES_SERVICE_PORT: "443",
});
});
it("does not pass provider keys to non-environment plugins", () => {
const env = buildPluginWorkerEnv({
manifest: { capabilities: ["ui.sidebar.register"] },
instanceInfo,
processEnv: {
OPENAI_API_KEY: "openai-token",
},
});
expect(env).toEqual({
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
});
});
it.each([
{ packagePath: null, packageName: "@paperclipai/plugin-createos", driverKey: "createos", allowed: true },
{ packagePath: "/app/packages/plugins/sandbox-providers/createos", packageName: "@paperclipai/plugin-createos", driverKey: "createos", allowed: true },
{ packagePath: "/home/operator/plugins/fake-createos", packageName: "@paperclipai/plugin-createos", driverKey: "createos", allowed: false },
{ packagePath: null, packageName: "@acme/plugin-createos", driverKey: "createos", allowed: false },
{ packagePath: null, packageName: "@paperclipai/plugin-createos", driverKey: "daytona", allowed: false },
])("confines the CreateOS fallback credential to its trusted worker: $packageName / $packagePath / $driverKey", ({ allowed, driverKey, ...installation }) => {
const env = buildPluginWorkerEnv({
...installation,
manifest: {
capabilities: ["environment.drivers.register"],
environmentDrivers: [{ driverKey }],
},
trustedLocalPluginRoots: ["/app/packages/plugins"],
instanceInfo,
processEnv: { CREATEOS_API_KEY: "createos-token", DAYTONA_API_KEY: "daytona-token" },
});
expect(env).toEqual({
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
...(allowed ? { CREATEOS_API_KEY: "createos-token" } : {}),
});
});
it.each([
{ packagePath: null, packageName: "@paperclipai/plugin-boxd", driverKey: "boxd", allowed: true },
{ packagePath: "/app/packages/plugins/sandbox-providers/boxd", packageName: "@paperclipai/plugin-boxd", driverKey: "boxd", allowed: true },
{ packagePath: "/home/operator/plugins/fake-boxd", packageName: "@paperclipai/plugin-boxd", driverKey: "boxd", allowed: false },
{ packagePath: null, packageName: "@acme/plugin-boxd", driverKey: "boxd", allowed: false },
{ packagePath: null, packageName: "@paperclipai/plugin-boxd", driverKey: "daytona", allowed: false },
])("confines the boxd fallback credential to its trusted worker: $packageName / $packagePath / $driverKey", ({ allowed, driverKey, ...installation }) => {
const env = buildPluginWorkerEnv({
...installation,
manifest: {
capabilities: ["environment.drivers.register"],
environmentDrivers: [{ driverKey }],
},
trustedLocalPluginRoots: ["/app/packages/plugins"],
instanceInfo,
processEnv: { BOXD_API_KEY: "boxd-token", DAYTONA_API_KEY: "daytona-token" },
});
expect(env).toEqual({
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
...(allowed ? { BOXD_API_KEY: "boxd-token" } : {}),
});
});
it("passes a first-party sandbox provider's documented credential env var to its own worker", () => {
const env = buildPluginWorkerEnv({
manifest: {
capabilities: ["environment.drivers.register"],
environmentDrivers: [{ driverKey: "daytona" }],
},
packageName: "@paperclipai/plugin-daytona",
packagePath: null,
instanceInfo,
processEnv: {
DAYTONA_API_KEY: "daytona-token",
NOVITA_API_KEY: "novita-token",
E2B_API_KEY: " ",
},
});
expect(env).toEqual({
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
DAYTONA_API_KEY: "daytona-token",
});
});
it("passes the credential to a first-party plugin installed from the bundled catalog", () => {
const env = buildPluginWorkerEnv({
manifest: {
capabilities: ["environment.drivers.register"],
environmentDrivers: [{ driverKey: "daytona" }],
},
packageName: "@paperclipai/plugin-daytona",
packagePath: "/app/packages/plugins/sandbox-providers/daytona",
trustedLocalPluginRoots: ["/app/packages/plugins"],
instanceInfo,
processEnv: {
DAYTONA_API_KEY: "daytona-token",
},
});
expect(env).toEqual({
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
DAYTONA_API_KEY: "daytona-token",
});
});
it("does not pass the credential to a local plugin that self-declares the first-party name", () => {
const env = buildPluginWorkerEnv({
manifest: {
capabilities: ["environment.drivers.register"],
environmentDrivers: [{ driverKey: "daytona" }],
},
packageName: "@paperclipai/plugin-daytona",
packagePath: "/home/operator/.paperclip/plugins/fake-daytona",
trustedLocalPluginRoots: ["/app/packages/plugins"],
instanceInfo,
processEnv: {
DAYTONA_API_KEY: "daytona-token",
},
});
expect(env).toEqual({
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
});
});
it("does not pass a credential to a third-party plugin that claims a first-party driver key", () => {
const env = buildPluginWorkerEnv({
manifest: {
capabilities: ["environment.drivers.register"],
environmentDrivers: [{ driverKey: "daytona" }],
},
packageName: "@acme/plugin-fake-daytona",
instanceInfo,
processEnv: {
DAYTONA_API_KEY: "daytona-token",
},
});
expect(env).toEqual({
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
});
});
it("does not pass a credential when the first-party package omits its expected driver key", () => {
const env = buildPluginWorkerEnv({
manifest: {
capabilities: ["environment.drivers.register"],
environmentDrivers: [{ driverKey: "kubernetes" }],
},
packageName: "@paperclipai/plugin-daytona",
instanceInfo,
processEnv: {
DAYTONA_API_KEY: "daytona-token",
},
});
expect(env).toEqual({
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
});
});
});
describeEmbeddedPostgres("plugin database namespaces", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let packageRoots: string[] = [];
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-db-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
for (const pluginKey of ["paperclip.dbtest", "paperclip.escape", "paperclip.refresh", multiMigrationPluginKey, llmWikiPluginKey]) {
const namespace = derivePluginDatabaseNamespace(pluginKey);
await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${namespace}" CASCADE`));
}
await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${derivePluginDatabaseNamespace(llmWikiPluginKey, "llm_wiki")}" CASCADE`));
await db.delete(pluginMigrations);
await db.delete(pluginDatabaseNamespaces);
await db.delete(plugins);
await db.delete(issueRelations);
await db.delete(issues);
await db.delete(companies);
await Promise.all(packageRoots.map((root) => rm(root, { recursive: true, force: true })));
packageRoots = [];
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function createPluginPackage(manifest: PaperclipPluginManifestV1, migrationSql: string) {
const packageRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-package-"));
packageRoots.push(packageRoot);
const migrationsDir = path.join(packageRoot, manifest.database!.migrationsDir);
await mkdir(migrationsDir, { recursive: true });
await writeFile(path.join(migrationsDir, "001_init.sql"), migrationSql, "utf8");
return packageRoot;
}
function llmWikiManifest(): PaperclipPluginManifestV1 {
return {
id: llmWikiPluginKey,
apiVersion: 1,
version: "0.1.0",
displayName: "LLM Wiki",
description: "Local-file LLM Wiki plugin.",
author: "Paperclip",
categories: ["automation", "ui"],
capabilities: [
"database.namespace.migrate",
"database.namespace.read",
"database.namespace.write",
],
entrypoints: { worker: "./dist/worker.js" },
database: {
namespaceSlug: "llm_wiki",
migrationsDir: "migrations",
coreReadTables: ["companies", "issues", "projects", "agents"],
},
};
}
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({
id: pluginId,
pluginKey: manifest.id,
packageName: manifest.id,
version: manifest.version,
apiVersion: manifest.apiVersion,
categories: manifest.categories,
manifestJson: manifest,
status: "installed",
installOrder: 1,
});
return pluginId;
}
function manifest(pluginKey = "paperclip.dbtest"): PaperclipPluginManifestV1 {
return {
id: pluginKey,
apiVersion: 1,
version: "1.0.0",
displayName: "DB Test",
description: "Exercises restricted plugin database access.",
author: "Paperclip",
categories: ["automation"],
capabilities: [
"database.namespace.migrate",
"database.namespace.read",
"database.namespace.write",
],
entrypoints: { worker: "./dist/worker.js" },
database: {
migrationsDir: "migrations",
coreReadTables: ["issues"],
},
};
}
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 the bundled LLM Wiki migrations through the production validator", async () => {
const pluginManifest = llmWikiManifest();
const repoRoot = path.basename(process.cwd()) === "server" ? path.resolve(process.cwd(), "..") : process.cwd();
const packageRoot = path.join(repoRoot, "packages", "plugins", "plugin-llm-wiki");
const namespace = derivePluginDatabaseNamespace(pluginManifest.id, pluginManifest.database?.namespaceSlug);
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.map((migration) => migration.migrationKey)).toEqual([
"001_llm_wiki.sql",
"002_paperclip_distillation.sql",
"003_spaces.sql",
]);
const constraintRows = Array.from(
await db.execute(
sql<{ table_name: string; conname: string; columns: string[] }>`
SELECT t.relname AS table_name, c.conname, array_agg(a.attname ORDER BY constraint_columns.ordinality)::text[] AS columns
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN unnest(c.conkey) WITH ORDINALITY AS constraint_columns(attnum, ordinality) ON true
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = constraint_columns.attnum
WHERE c.connamespace = ${namespace}::regnamespace AND c.contype = 'u'
GROUP BY t.relname, c.conname
ORDER BY t.relname, c.conname
`,
) as Iterable<{ table_name: string; conname: string; columns: string[] }>,
);
const constraints = constraintRows.map((row) => row.conname);
const uniqueColumnSets = new Set(
constraintRows.map((row) => `${row.table_name}:${row.columns.join(",")}`),
);
expect(constraints).toEqual(
expect.arrayContaining([
"wiki_pages_company_wiki_space_path_key",
"distillation_cursors_company_wiki_space_scope_key",
"distillation_work_items_company_wiki_space_idempotency_key",
"page_bindings_company_wiki_space_page_path_key",
]),
);
expect(constraints).not.toContain("wiki_pages_company_id_wiki_id_path_key");
expect(constraints).not.toContain("paperclip_distillation_cursor_company_id_wiki_id_source_sco_key");
expect(constraints).not.toContain("paperclip_distillation_work_i_company_id_wiki_id_idempotenc_key");
expect(constraints).not.toContain("paperclip_page_bindings_company_id_wiki_id_page_path_key");
expect(uniqueColumnSets).not.toContain("wiki_pages:company_id,wiki_id,path");
expect(uniqueColumnSets).not.toContain("paperclip_distillation_cursors:company_id,wiki_id,source_scope,scope_key,source_kind");
expect(uniqueColumnSets).not.toContain("paperclip_distillation_work_items:company_id,wiki_id,idempotency_key");
expect(uniqueColumnSets).not.toContain("paperclip_page_bindings:company_id,wiki_id,page_path");
});
it("applies migrations once and allows whitelisted core joins at runtime", async () => {
const pluginManifest = manifest();
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
const packageRoot = await createPluginPackage(
pluginManifest,
`
CREATE TABLE ${namespace}.mission_rows (
id uuid PRIMARY KEY,
issue_id uuid NOT NULL REFERENCES public.issues(id),
label text NOT NULL
);
`,
);
const pluginId = await installPluginRecord(pluginManifest);
const companyId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "TST",
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Joined issue",
status: "todo",
priority: "medium",
identifier: "TST-1",
});
const pluginDb = pluginDatabaseService(db);
await pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot);
await pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot);
await pluginDb.execute(
pluginId,
`INSERT INTO ${namespace}.mission_rows (id, issue_id, label) VALUES ($1, $2, $3)`,
[randomUUID(), issueId, "alpha"],
);
const rows = await pluginDb.query<{ label: string; title: string }>(
pluginId,
`SELECT m.label, i.title FROM ${namespace}.mission_rows m JOIN public.issues i ON i.id = m.issue_id`,
);
expect(rows).toEqual([{ label: "alpha", title: "Joined issue" }]);
const migrations = await db
.select()
.from(pluginMigrations)
.where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.status, "applied")));
expect(migrations).toHaveLength(1);
});
it("rejects runtime writes to public core tables", async () => {
const pluginManifest = manifest();
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
const packageRoot = await createPluginPackage(
pluginManifest,
`CREATE TABLE ${namespace}.notes (id uuid PRIMARY KEY, body text NOT NULL);`,
);
const pluginId = await installPluginRecord(pluginManifest);
const pluginDb = pluginDatabaseService(db);
await pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot);
await expect(
pluginDb.execute(pluginId, "UPDATE public.issues SET title = $1", ["bad"]),
).rejects.toThrow(/plugin namespace/i);
});
it("records a failed migration when SQL escapes the plugin namespace", async () => {
const pluginManifest = manifest("paperclip.escape");
const packageRoot = await createPluginPackage(
pluginManifest,
"CREATE TABLE public.plugin_escape (id uuid PRIMARY KEY);",
);
const pluginId = await installPluginRecord(pluginManifest);
await expect(
pluginDatabaseService(db).applyMigrations(pluginId, pluginManifest, packageRoot),
).rejects.toThrow(/public\.plugin_escape|public/i);
const [migration] = await db
.select()
.from(pluginMigrations)
.where(eq(pluginMigrations.pluginId, pluginId));
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,
capabilities: [...staleManifest.capabilities, "agent.tools.register"],
database: {
...staleManifest.database!,
coreReadTables: ["companies"],
},
tools: [
{
name: "db-smoke",
displayName: "DB Smoke",
description: "Exercises plugin tool registration worker lookup.",
parametersSchema: { type: "object", properties: {} },
},
],
};
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 toolDispatcher = {
registerPluginTools: vi.fn(),
};
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,
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"] }),
}),
}),
);
expect(toolDispatcher.registerPluginTools).toHaveBeenCalledWith(
refreshedManifest.id,
expect.objectContaining({
tools: refreshedManifest.tools,
}),
pluginId,
);
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);
const packageRoot = await createPluginPackage(
pluginManifest,
`CREATE TABLE ${namespace}.checksum_rows (id uuid PRIMARY KEY);`,
);
const pluginId = await installPluginRecord(pluginManifest);
const pluginDb = pluginDatabaseService(db);
await pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot);
await writeFile(
path.join(packageRoot, "migrations", "001_init.sql"),
`CREATE TABLE ${namespace}.checksum_rows (id uuid PRIMARY KEY, note text);`,
"utf8",
);
await expect(pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot))
.rejects.toThrow(/checksum mismatch/i);
});
});