Files
PaperClipAI/server/src/__tests__/plugin-database.test.ts
T
Jannes StubbemannandClaude Opus 4.8 4ad94d0bde feat(server): kubernetes execution integration for sandbox-provider plugins (stage 2/3) (#7938)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The execution subsystem runs those agents in environments (local,
ssh, sandbox), and sandbox-provider plugins let an environment
materialize per-run sandboxes
> - Stage 1 (#5790) contributed a first-party Kubernetes
sandbox-provider plugin, but the server core has no way to adopt it
operationally: no per-run adapter selection, no way to force an instance
onto sandboxed execution, no declarative adapter/model configuration,
and the plugin must be installed by hand
> - Without this, a multi-tenant or security-conscious deployment cannot
guarantee that agent runs never execute on the host, and a single
environment cannot serve agents with different harnesses
> - This pull request adds the server + SDK integration: per-run
adapterType on the lease protocol, an env-gated forced-Kubernetes
execution policy with provisioning and a per-run allowlist guard, a
declarative adapter registry and model list, in-cluster env passthrough
for sandbox plugin workers, fail-safe auto-install of the bundled
plugin, and the matching UI affordance
> - The benefit is that sandbox-provider plugins become fully usable for
Kubernetes execution: operators configure everything via environment
variables and GitOps, while self-hosters who set none of the variables
see exactly the behavior they have today

## Linked Issues or Issue Description

Refs #5790 (stage 1 of 3: the Kubernetes sandbox-provider plugin
package).

No existing issue. Feature description: the server core lacks the
integration seams to operate a sandbox-provider plugin as the mandatory
execution path of an instance. This PR is stage 2 of 3 of the staged
Kubernetes contribution; stage 3 will contribute the agent runtime
images and their build pipeline.

## What Changed

One line per piece:

- `packages/plugins/sdk/protocol.ts`: optional `adapterType` on
`PluginEnvironmentAcquireLeaseParams` so a provider can select the
runtime image per run; existing providers simply ignore it
- `server/services/environment-runtime.ts` +
`environment-run-orchestrator.ts`: thread the agent's adapter type into
both lease-acquiring drivers, including the heartbeat path (the two call
sites have historically drifted, hence the pinned test)
- `server/services/environments.ts`: `ensureKubernetesEnvironment` /
`findKubernetesEnvironment`, an idempotent managed Kubernetes
environment per company, identified by a metadata marker and refreshed
(not recreated) on config change; `timeoutMs` rides on the config for
slow cold-start leases
- `server/services/execution-allowlist.ts`: pure (driver, provider,
policy) -> allow/deny guard; `executionMode=kubernetes` only allows the
kubernetes sandbox provider
- `server/services/execution-policy-bootstrap.ts` + startup hook in
`server/index.ts`: parse `PAPERCLIP_EXECUTION_MODE` / `PAPERCLIP_K8S_*`,
persist `executionMode` into instance general settings, and provision
the managed environment for every company; fails loud on
misconfiguration
- `server/services/heartbeat.ts`: when the policy forces Kubernetes, pin
run selection to the managed environment (also overriding any persisted
workspace environment id), refuse to fall back to local, and re-check
the actually acquired environment against the allowlist as defense in
depth
- `server/services/adapter-registry-bootstrap.ts` + shared
`AdapterRegistryEntry` type/validator: declarative `PAPERCLIP_ADAPTERS`
registry (inline JSON or file) that reconciles adapter availability at
startup and rides on the Kubernetes environment config
- `server/services/adapter-models-env.ts` + `adapters/registry.ts`:
`PAPERCLIP_ADAPTER_MODELS` lets an operator declare picker model lists
the server cannot CLI-discover
- `server/services/plugin-loader.ts`: pass
`KUBERNETES_SERVICE_HOST/PORT(_HTTPS)` through to plugin workers that
register environment drivers, so in-cluster API clients can be
constructed; all other host env stays stripped
- `server/app.ts`: fail-safe auto-install of the bundled kubernetes
plugin at boot; no-ops when the bundle is absent and never blocks
startup on error
- `packages/shared` types/validators: `InstanceExecutionMode` on general
settings (optional, strict schema)
- `ui/lib/forced-kubernetes-environment.ts` + `AgentConfigForm`: when
the policy is active, show a read-only Kubernetes environment instead of
the environment picker and default new agents onto the managed
environment
- Tests for every new module plus the adapterType pin in
`heartbeat-plugin-environment` and the managed-environment lifecycle in
`environment-service`

Everything is gated: with `PAPERCLIP_EXECUTION_MODE`,
`PAPERCLIP_ADAPTERS`, and `PAPERCLIP_ADAPTER_MODELS` unset (and no
bundled plugin present), every code path reduces to current behavior.
The per-run `adapterType` is an optional SDK parameter that existing
providers ignore.

## Verification

- `cd server && npx tsc --noEmit`: clean (0 errors); `ui` typecheck also
clean
- Targeted suites all green (11 files, 90 tests): `npx vitest run
server/src/__tests__/heartbeat-plugin-environment.test.ts
server/src/__tests__/environment-service.test.ts
server/src/__tests__/environment-runtime.test.ts
server/src/__tests__/environment-run-orchestrator.test.ts
server/src/__tests__/plugin-database.test.ts
server/src/services/execution-policy-bootstrap.test.ts
server/src/services/execution-allowlist.test.ts
server/src/services/adapter-registry-bootstrap.test.ts
server/src/services/adapter-registry-bootstrap.reconcile.test.ts
server/src/services/adapter-models-env.test.ts
packages/shared/src/validators/adapter-registry.test.ts`
- `npx vitest run ui/src/components/AgentConfigForm.test.ts`: green (6
tests)
- Full `npx vitest run server/src/__tests__`: 2323 passed, 1 skipped;
the only failures (heartbeat-process-recovery pid-retry,
workspace-runtime symbolic-ref/git tests) reproduce identically on
pristine `master` in the same environment, so they are
machine-environment issues unrelated to this change;
`server-startup-feedback-export` needed its `services/index.js` mock
extended with the new export and is green
- This integration has been running in production on a hosted
multi-tenant deployment, where it executes agent runs across five
different harnesses through the stage 1 plugin

## Risks

- Low for existing deployments: every behavior is env-gated and the
defaults preserve current semantics; the auto-install block is wrapped
fail-safe and skips silently when the plugin bundle is absent
- `executionMode` is a new optional field on a strict zod schema; absent
input normalizes exactly as before
- The forced policy intentionally fails runs loudly (rather than falling
back to local) when no managed Kubernetes environment exists; this only
affects instances that explicitly set
`PAPERCLIP_EXECUTION_MODE=kubernetes`

## Model Used

Claude Opus 4.8 (claude-opus-4-8, 1M context), extended thinking,
agentic tool use via Claude Code.

## UI screenshots

The UI change is a new read-only "Execution" section in
`AgentConfigForm`, shown only when the instance execution policy forces
Kubernetes (`executionMode=kubernetes`); there is no "before" state for
it (the section did not exist, and instances without the forced policy
render the existing picker unchanged). Captured from the new Storybook
stories added in this PR (`Product/Agent Management`):

Managed Kubernetes environment present (read-only display, no local/SSH
picker):

![AgentConfigForm with forced Kubernetes
execution](https://raw.githubusercontent.com/paperclipinc/paperclip/296ad06e8/screenshots/PR-7938-agent-config-forced-kubernetes.png)

No managed environment available yet (warning notice, no silent local
fallback):

![AgentConfigForm forced Kubernetes, missing environment
warning](https://raw.githubusercontent.com/paperclipinc/paperclip/296ad06e8/screenshots/PR-7938-agent-config-forced-kubernetes-missing-env.png)

## 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 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:09:02 -07:00

663 lines
23 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("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.slots.register"] },
instanceInfo,
processEnv: {
OPENAI_API_KEY: "openai-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);
});
});