Fix LLM Wiki package and migration validation (#6010)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Plugins extend the control plane with optional capabilities such as
LLM Wiki.
> - LLM Wiki needs its package assets and plugin-owned database
migrations to work when installed from the packaged plugin.
> - The bundled spaces migration used validation-hostile dynamic SQL,
and the packaged plugin could omit non-dist runtime assets.
> - This pull request makes the LLM Wiki package include its required
assets and cuts the spaces migration over to explicit, idempotent SQL
that passes the production plugin database validator.
> - The benefit is a simpler plugin install path that validates and
applies the bundled LLM Wiki migrations without adding plugin-specific
legacy handling to Paperclip core.

## What Changed

- Added the LLM Wiki package asset allowlist so agents, migrations,
skills, templates, dist output, and README are included when packaged.
- Renamed the bootstrap `.gitignore` template to `gitignore.template`
and updated the runtime lookup so package tooling does not drop the
hidden template file.
- Relaxed plugin migration validation to allow namespace-scoped
`INSERT`/`UPDATE` backfills and `CREATE INDEX` statements while
continuing to reject destructive or cross-namespace SQL.
- Replaced the LLM Wiki spaces migration's dynamic constraint-drop DO
block with explicit `DROP CONSTRAINT IF EXISTS` statements.
- Replaced fragile regex-source dispatch in SQL reference extraction
with explicit capture-group descriptors.
- Added regression coverage that applies the bundled LLM Wiki migrations
through the production validator and checks the expected constraints.

## Verification

- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/plugin-database.test.ts --pool=forks
--poolOptions.forks.isolate=true`
- `pnpm --filter @paperclipai/plugin-llm-wiki build`
- `git diff --check`
- Confirmed `pnpm-lock.yaml` is not included in the branch diff.

## Risks

- Low migration risk for current users: LLM Wiki spaces are new, so this
intentionally cuts over the plugin migration instead of adding legacy
handling in core.
- Validator behavior is broader than before, but still requires fully
qualified plugin namespace targets, blocks deletes/destructive DDL, and
keeps public table access read-only and allowlisted.

> Checked [`ROADMAP.md`](ROADMAP.md); this is a targeted plugin
packaging/migration fix and does not duplicate planned core feature
work. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 based coding agent, tool-enabled local repo
access, reasoning mode managed by the Paperclip/Codex runtime. Exact
context window was not surfaced in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-15 10:20:02 -05:00 committed by GitHub
parent dfcebf082b
commit eb38b226c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 203 additions and 41 deletions

View file

@ -19,6 +19,9 @@ const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const MAX_POSTGRES_IDENTIFIER_LENGTH = 63;
type SqlRef = { schema: string; table: string; keyword: string };
type QualifiedRefPattern =
| { pattern: RegExp; groups: "keyword-schema-table" }
| { pattern: RegExp; groups: "schema-table"; keyword: string };
export type PluginDatabaseRuntimeResult<T = Record<string, unknown>> = {
rows?: T[];
@ -123,14 +126,29 @@ function normaliseSql(input: string): string {
function extractQualifiedRefs(statement: string): SqlRef[] {
const refs: SqlRef[] = [];
const patterns = [
/\b(from|join|references|into|update)\s+"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi,
/\b(alter\s+table|create\s+table|create\s+view|drop\s+table|truncate\s+table)\s+(?:if\s+(?:not\s+)?exists\s+)?"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi,
const patterns: QualifiedRefPattern[] = [
{
pattern: /\b(from|join|references|into|update)\s+"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi,
groups: "keyword-schema-table",
},
{
pattern: /\b(alter\s+table|create\s+table|create\s+view|drop\s+table|truncate\s+table)\s+(?:if\s+(?:not\s+)?exists\s+)?"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi,
groups: "keyword-schema-table",
},
{
pattern: /\bcreate\s+(?:unique\s+)?index(?:\s+concurrently)?\s+(?:if\s+not\s+exists\s+)?"?[A-Za-z_][A-Za-z0-9_]*"?\s+on\s+"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi,
groups: "schema-table",
keyword: "create index",
},
];
for (const pattern of patterns) {
for (const { pattern, ...mapping } of patterns) {
for (const match of statement.matchAll(pattern)) {
refs.push({ keyword: match[1]!.toLowerCase(), schema: match[2]!, table: match[3]! });
if (mapping.groups === "keyword-schema-table") {
refs.push({ keyword: match[1]!.toLowerCase(), schema: match[2]!, table: match[3]! });
} else {
refs.push({ keyword: mapping.keyword, schema: match[1]!, table: match[2]! });
}
}
}
return refs;
@ -182,9 +200,16 @@ export function validatePluginMigrationStatement(
throw new Error("Destructive plugin migrations are not allowed in Phase 1");
}
const ddlAllowed = /^(create|alter|comment)\b/.test(normalized);
if (!ddlAllowed) {
throw new Error("Plugin migrations may contain DDL statements only");
if (/\bdelete\s+from\b/.test(normalized)) {
throw new Error("Plugin migrations cannot delete data");
}
const ddlOrBackfillAllowed =
/^(create|alter|comment)\b/.test(normalized) ||
/^(insert\s+into|update)\b/.test(normalized) ||
(normalized.startsWith("with ") && /\b(insert\s+into|update)\b/.test(normalized));
if (!ddlOrBackfillAllowed) {
throw new Error("Plugin migrations may contain DDL or namespace-scoped backfill statements only");
}
const refs = extractQualifiedRefs(statement);
@ -192,6 +217,21 @@ export function validatePluginMigrationStatement(
throw new Error("Plugin migration objects must use fully qualified schema names");
}
const objectRefKeywords = new Set([
"alter table",
"create index",
"create table",
"create view",
"drop table",
"into",
"truncate table",
"update",
]);
const hasQualifiedObjectRef = refs.some((ref) => objectRefKeywords.has(ref.keyword));
if (!hasQualifiedObjectRef && !normalized.startsWith("comment ")) {
throw new Error("Plugin migration objects must use fully qualified schema names");
}
const allowedCoreReadTables = new Set(coreReadTables);
for (const ref of refs) {
if (ref.schema === namespace) continue;