Skip to content

fix(plugins): isolate third-party SDK-consuming plugins from the shared store (BLO-20961) - #1138

Merged
allyblockcast[bot] merged 3 commits into
masterfrom
platformsre/blo-20961-plugin-sdk-installdir-isolation
Aug 15, 2026
Merged

fix(plugins): isolate third-party SDK-consuming plugins from the shared store (BLO-20961)#1138
allyblockcast[bot] merged 3 commits into
masterfrom
platformsre/blo-20961-plugin-sdk-installdir-isolation

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 7, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • The plugin system lets third-party npm packages extend Paperclip; several (lucitra.plugin-secrets, paperclip-plugin-hindsight) depend on @paperclipai/plugin-sdk
  • server/src/index.ts unconditionally re-vendors the workspace SDK fork (1.0.0) into the shared ~/.paperclip/plugins/node_modules/@paperclipai/plugin-sdk on every boot, independently of npm reconciliation, tearing any plugin sharing that store from whatever package-lock.json says
  • This has now recurred on essentially every paperclip-0 restart (2026-07-27, 08-01/02, 08-04), each time requiring a manual npm install + operator re-enable — confirmed recurring toil, not a one-off. PR fix(server): fail closed on torn plugin-store SDK version mismatch (BLO-18384) #804 added checkSharedDependencyConsistency() which fails closed fast instead of hanging, but that's detection, not a fix for the tear itself
  • This pull request gives third-party plugins that declare their own real @paperclipai/plugin-sdk dependency a dedicated install directory (plugins-isolated/<pkg>), structurally outside the path the fork-copy touches, so they're immune to the tear rather than merely reconciled-until-the-next-boot
  • The benefit is lucitra.plugin-secrets / paperclip-plugin-hindsight (and any future third-party SDK consumer) stay ready across restarts with zero manual reconciliation

Linked Issues or Issue Description

Fixes: BLO-20961 (internal Paperclip tracker — no public GitHub issue)
Refs: BLO-18384, BLO-18405 (prior manual repairs of the same recurring tear)
Refs: PR #804 (added the detection-only checkSharedDependencyConsistency guard this PR builds on)

What Changed

  • server/src/bootstrap/isolated-sdk-plugins.ts (new): ISOLATED_SDK_PLUGIN_PACKAGES allowlist + resolveDefaultInstallDir() — resolves a package's install dir to plugins-isolated/<sanitized-pkg-name> when it's in the allowlist, else the shared store (unchanged default).
  • server/src/services/plugin-loader.ts: installPlugin/upgradePlugin default installDir via resolveDefaultInstallDir() instead of always using the shared localPluginDir. loadSingle resolves the package root, worker entrypoint, and shared-dependency consistency check against the plugin's own persisted installDir, not the shared store. cleanupInstallArtifacts now uninstalls/removes from the plugin's actual installDir (previously always targeted the shared store, which would have silently no-op'd and orphaned an isolated plugin's files on uninstall).
  • server/src/services/plugin-registry.ts, packages/db/src/schema/plugins.ts, packages/shared/src/types/plugin.ts, packages/shared/src/validators/plugin.ts, packages/db/src/migrations/0211_plugin_install_dir.sql: new nullable installDir column on plugins, persisted through install/upgrade so later operations target the right directory.
  • No route changes — isolation is automatic by package name, not operator-configured, per the issue's scope note that no route currently exposes installDir.
  • paperclip-chat and penstock.paperclip-plugin are included in ISOLATED_SDK_PLUGIN_PACKAGES (the mechanism generalizes to any third-party SDK consumer per the CEO's 2026-08-05 scope decision on the issue) but are explicitly out of scope for this PR's verification — only lucitra.plugin-secrets / paperclip-plugin-hindsight are covered by the two-boot test below.

Verification

  • server/src/__tests__/plugin-store-consistency.test.ts (extended): 3 new tests —
    • resolveDefaultInstallDir isolates every allowlisted package and leaves everything else in the shared store.
    • Pure two-boot simulation: an isolated install stays congruent after the shared store is re-torn a second time (the exact BLO-18384/BLO-20961 recurrence).
    • End-to-end loadSingle test: an isolated plugin activates successfully even when localPluginDir (the shared store) is torn between "boot 1" and "boot 2".
  • Ran locally: pnpm exec vitest run server/src/__tests__/plugin-store-consistency.test.ts (15/15 pass), plugin-install-autobuild.test.ts (9/9), plugin-lifecycle-restart.test.ts (2/2), plugin-routes-authz.test.ts (49/49) — all green, no regressions from the installDir plumbing.
  • pnpm --filter @paperclipai/db check:migrations (numbering + safety) passed.
  • tsc --noEmit over server/ passed clean.
  • Not yet done, and required before this issue closes: two consecutive live paperclip-0 pod restarts post-merge, pasting the version-check output showing continued lockfile/installed congruence, plus a GET /api/plugins/rag-health snapshot showing both plugins ready throughout — see BLO-20961 acceptance criteria.

Risks

  • Adds a nullable DB column (plugins.install_dir) — additive, no backfill needed, existing rows default to null (shared store), matching current behavior.
  • cleanupInstallArtifacts behavior changes for already-installed isolated plugins: today there are none in the live DB (both target plugins are currently in error with installDir = null pending reconciliation), so this only takes effect on their next install/upgrade after this ships.
  • Does not touch copyWorkspaceSdkFiles() or autoInstallBundledPlugins() boot ordering — deliberately, since isolation makes the ordering question moot for isolated plugins. The shared store itself can still tear for any plugin not isolated; that's unchanged/expected.
  • Medium blast radius: touches the plugin install/upgrade/uninstall code path used by all plugins, not just the isolated ones. Mitigated by running the full existing plugin-loader/registry/authz test suites above with no regressions.

Model Used

Claude Sonnet 5 (claude-sonnet-5[1m]), 1M context window, agentic tool use (Bash, Read/Edit/Write, Task tracking) via Claude Code / Paperclip claude_k8s adapter. No extended-thinking mode.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above (none found for this branch/issue)
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots (N/A — no UI change)
  • I have updated relevant documentation to reflect my changes
  • I have considered and documented any risks above
  • All Paperclip CI gates are green (pending CI run on this PR)
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

@allyblockcast

allyblockcast Bot commented Aug 7, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-18384
🔗 Paperclip issue: BLO-18405
🔗 Paperclip issue: BLO-20961

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 7, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-18384
🔗 Paperclip issue: BLO-18405
🔗 Paperclip issue: BLO-20961

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: ef139fa

Critical Issues (0)

Important Issues (1)

  • [code] server/src/services/plugin-loader.ts:2556 — Existing installations of the affected plugins remain on the torn shared store after this migration. loadSingle resolves pluginInstallDir exclusively from activePlugin.installDir ?? localPluginDir; the additive migration leaves installDir null, so all existing rows continue to load and fail the consistency check against localPluginDir. Isolation is only selected during a later install/upgrade (:2136), with no migration, startup relocation, or explicit reconciliation path for the two known broken rows.
    • Move/reinstall the allowlisted existing plugin records into their resolved isolated directories as part of rollout, or add a safe startup/upgrade migration that persists the isolated directory before activation. Cover the existing-null-row path in an integration test.

Suggestions (0)

Strengths

  • The new install-directory persistence keeps later upgrade and cleanup operations targeted at the plugin's actual filesystem tree.
  • The two-boot consistency test directly models the shared-store tear rather than only testing a successful first install.

Recommended Action

  1. Address the Important issue before merge so the affected production plugins recover on the deployment that introduces this change.

PlatformSREEngineer and others added 2 commits August 14, 2026 10:40
…ed store (BLO-20961)

`copyWorkspaceSdkFiles()` unconditionally re-vendors the workspace
plugin-sdk fork into the shared plugins node_modules on every boot,
independently of npm reconciliation. Any plugin sharing that store
(lucitra.plugin-secrets, paperclip-plugin-hindsight) has its installed
@paperclipai/plugin-sdk torn from package-lock.json every restart
(BLO-18384/BLO-18405), which the existing checkSharedDependencyConsistency
guard (PR #804) detects but does not prevent.

Give third-party plugins that declare a real @paperclipai/plugin-sdk
dependency their own install directory (plugins-isolated/<pkg>), outside
the path copyWorkspaceSdkFiles() and the fork-copy touch, so they are
structurally immune rather than merely reconciled-until-next-boot.
Isolation is automatic based on package name (ISOLATED_SDK_PLUGIN_PACKAGES)
via installPlugin's default installDir resolution — no route change
needed. Persist the resolved installDir on the plugin row so upgrade and
uninstall target the same directory the plugin actually lives in.

paperclip-chat and penstock.paperclip-plugin are included in the
isolation list per the mechanism (any third-party SDK consumer
generalizes), but out of scope for this issue's two-boot verification,
which covers only lucitra.plugin-secrets / paperclip-plugin-hindsight
per the CEO's 2026-08-05 scope decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…(BLO-20961)

The BLO-20961 isolation change makes `installDir` a required (nullable)
field on `PluginRecord`. `makePlugin()` in InstanceSidebar.test.tsx builds
a full record literal and did not set it, so `tsc -b` over the `ui`
workspace failed with TS2741 — which is the sole reason the
`Typecheck + Release Registry` lane went red, and `verify` is only its
roll-up (it reports `Upstream lane(s) reported failure:
typecheck_release_registry`), not an independent failure.

Defaults to `null` (= the shared store) to match the sibling nullable
fixture fields and the column's own semantics.
@allyblockcast
allyblockcast Bot force-pushed the platformsre/blo-20961-plugin-sdk-installdir-isolation branch from 00baaa0 to 779d551 Compare August 14, 2026 10:41
…d store (BLO-20961)

Ally's Important finding on this PR: the installDir column is additive, so
every pre-existing row carries installDir = NULL and keeps resolving to the
shared plugin store. That includes the two plugins this issue exists to fix.
On the worker tier the shared store is torn BY CONSTRUCTION -- index.ts
re-vendors the workspace SDK fork (1.0.0) over it on every boot while the
lockfile permanently records 2026.513.0 -- so those rows fail the consistency
guard 100% of the time and isolation would only ever engage on some later
install/upgrade that never comes. AC #1 was not met as the PR stood.

Changing activatePlugin's fallback to resolveDefaultInstallDir() is NOT the
fix on its own: the isolated dir does not exist yet for these rows, so that
only trades a torn-store failure for a missing-entrypoint one. This does the
real relocation instead:

- reconcileLegacyIsolatedInstall(): npm-install the package into its isolated
  dir, verify the reinstall is the same plugin identity, then persist
  installDir -- so later boots resolve somewhere that exists.
- reconcileLegacyIsolatedInstallsAtStartup(): run that across installed rows
  before loadAll() selects by status, and un-latch rows the torn-store guard
  parked in `error`. loadAll() only selects status='ready', so a row refused
  once stays invisible to every later boot even after the cause is fixed
  (five plugins have been latched since 2026-08-01 -- BLO-20410). Only rows
  whose lastError came from that guard are revived, and only when the
  relocation actually moved them; best-effort per row so one bad package
  cannot abort boot.
- Wire into loadAll() and loadSingle() (operator re-enable gets the same
  treatment rather than re-running against the shared store).
- Extract TORN_STORE_ERROR_MARKER so the un-latch decision stays coupled to
  the message that latched the row.

Tests in a separate file because node:child_process must be mocked to stub
npm install, and promisify(execFile) captures the binding at module init --
hoisting that mock into plugin-store-consistency.test.ts would take the real
npm path away from its local-filesystem fixtures.

Verified with a control: reverting only the loadAll() wiring fails exactly
two of the four new tests with the production symptoms (installDir stays
null; status stays error). Existing plugin-store-consistency suite: 15/15.
@allyblockcast
allyblockcast Bot added this pull request to the merge queue Aug 15, 2026
Merged via the queue into master with commit 2f91c80 Aug 15, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants