mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## 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):  No managed environment available yet (warning notice, no silent local fallback):  ## 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>
663 lines
23 KiB
TypeScript
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);
|
|
});
|
|
});
|