Plugin system (2/8): host foundation + plugin SDK#3726
Conversation
Threads carry an owner (user or plugin:<id>); plugin-owned threads are hidden from user-facing projections, activity relays, and client thread state. Includes migration 033 and the decider/projector/read-model wiring. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| threadId: command.threadId, | ||
| projectId: command.projectId, | ||
| title: command.title, | ||
| owner: command.owner ?? "user", |
There was a problem hiding this comment.
🟠 High orchestration/decider.ts:237
decideOrchestrationCommand copies command.owner directly into the emitted thread.created event payload. Because thread.create is a client-dispatchable command type, an untrusted client can submit owner: "plugin:test", which produces a thread marked as plugin-owned. The owner-based read-model filters then hide that thread from user-facing snapshots, lists, relay, and shell streams — so clients can forge server-only plugin threads that are invisible in the UI. Consider defaulting owner to "user" unconditionally for client-dispatched thread.create commands rather than trusting command.owner.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/decider.ts around line 237:
`decideOrchestrationCommand` copies `command.owner` directly into the emitted `thread.created` event payload. Because `thread.create` is a client-dispatchable command type, an untrusted client can submit `owner: "plugin:test"`, which produces a thread marked as plugin-owned. The owner-based read-model filters then hide that thread from user-facing snapshots, lists, relay, and shell streams — so clients can forge server-only plugin threads that are invisible in the UI. Consider defaulting `owner` to `"user"` unconditionally for client-dispatched `thread.create` commands rather than trusting `command.owner`.
| export interface PluginHostApi { | ||
| readonly hostApiVersion: string; | ||
| readonly config: PluginHostConfig; | ||
| readonly agents: Effect.Effect<AgentsCapability, PluginCapabilityUnavailable>; |
There was a problem hiding this comment.
🟠 High src/index.ts:83
PluginHostApi declares each capability as Effect.Effect<..., PluginCapabilityUnavailable>, but the host implementation raises unavailable capabilities via Effect.die(...), producing an unrecoverable defect rather than the typed error. Any plugin that probes an unavailable capability with Effect.catchTag('PluginCapabilityUnavailable') or catchAll will still crash, because defects bypass the typed error channel. The advertised type contract is violated: plugins cannot recover from capability absence as the types promise. Either build these effects with Effect.fail(new PluginCapabilityUnavailable(...)) so they land in the typed error channel, or document that unavailable capabilities abort the fiber and should not be caught.
Also found in 1 other location(s)
apps/server/src/plugins/PluginHost.ts:128
unavailableusesEffect.die(...)to signal missing host capabilities, butPluginHostApiexposes these fields asEffect<..., PluginCapabilityUnavailable>. A plugin that tries to recover with normal typed handlers likeEffect.catch/catchTagwill not see this error, because defects bypass the typed error channel. Any plugin probing an optional capability will instead defect and fail activation or crash its service instead of handlingPluginCapabilityUnavailablegracefully.
🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/plugin-sdk/src/index.ts around line 83:
`PluginHostApi` declares each capability as `Effect.Effect<..., PluginCapabilityUnavailable>`, but the host implementation raises unavailable capabilities via `Effect.die(...)`, producing an unrecoverable defect rather than the typed error. Any plugin that probes an unavailable capability with `Effect.catchTag('PluginCapabilityUnavailable')` or `catchAll` will still crash, because defects bypass the typed error channel. The advertised type contract is violated: plugins cannot recover from capability absence as the types promise. Either build these effects with `Effect.fail(new PluginCapabilityUnavailable(...))` so they land in the typed error channel, or document that unavailable capabilities abort the fiber and should not be caught.
Also found in 1 other location(s):
- apps/server/src/plugins/PluginHost.ts:128 -- `unavailable` uses `Effect.die(...)` to signal missing host capabilities, but `PluginHostApi` exposes these fields as `Effect<..., PluginCapabilityUnavailable>`. A plugin that tries to recover with normal typed handlers like `Effect.catch`/`catchTag` will not see this error, because defects bypass the typed error channel. Any plugin probing an optional capability will instead defect and fail activation or crash its service instead of handling `PluginCapabilityUnavailable` gracefully.
| ), | ||
| ); | ||
|
|
||
| const scope = yield* Scope.make("sequential"); |
There was a problem hiding this comment.
🟡 Medium plugins/PluginHost.ts:274
The per-plugin Scope created at line 274 is only closed in the activation-failure branch (line 323). When a plugin activates successfully, nothing ever closes that scope — there is no shutdown hook or finalizer attached to the host's own lifetime. As a result, every service forked into that scope at lines 291–294 keeps running until process exit, and the scoped cleanup (interrupting background fibers, releasing resources) never runs on normal host shutdown. Consider adding a finalizer that closes each plugin's scope when the PluginHost itself shuts down, for example by forking the plugin lifecycle into a scope owned by start or by registering Scope.close(scope, Exit.interrupt(...)) as a finalizer on the host's scope.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginHost.ts around line 274:
The per-plugin `Scope` created at line 274 is only closed in the activation-failure branch (line 323). When a plugin activates successfully, nothing ever closes that scope — there is no shutdown hook or finalizer attached to the host's own lifetime. As a result, every service forked into that scope at lines 291–294 keeps running until process exit, and the scoped cleanup (interrupting background fibers, releasing resources) never runs on normal host shutdown. Consider adding a finalizer that closes each plugin's `scope` when the `PluginHost` itself shuts down, for example by forking the plugin lifecycle into a scope owned by `start` or by registering `Scope.close(scope, Exit.interrupt(...))` as a finalizer on the host's scope.
| import * as ServerConfig from "../config.ts"; | ||
| import { pluginAdvisoryLockPath, pluginLockfilePath } from "./PluginPaths.ts"; | ||
|
|
||
| const STALE_LOCK_MS = 60_000; |
There was a problem hiding this comment.
🟡 Medium plugins/PluginLockfileStore.ts:22
STALE_LOCK_MS = 60_000 lets a second process reclaim a lock that a still-live writer is holding. If the writer pauses longer than 60s (OS suspend, debugger stop, event-loop stall), the second process deletes and recreates the lock file, and both processes then write the lockfile concurrently because the first process still believes it owns the lock. Consider using an OS-level advisory lock (e.g. flock) instead of a pid-file with an age threshold, or document why this 60s heuristic is acceptable for the expected runtime environment.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginLockfileStore.ts around line 22:
`STALE_LOCK_MS = 60_000` lets a second process reclaim a lock that a still-live writer is holding. If the writer pauses longer than 60s (OS suspend, debugger stop, event-loop stall), the second process deletes and recreates the lock file, and both processes then write the lockfile concurrently because the first process still believes it owns the lock. Consider using an OS-level advisory lock (e.g. `flock`) instead of a pid-file with an age threshold, or document why this 60s heuristic is acceptable for the expected runtime environment.
Clients could send thread.create with owner=plugin:* via the client wire union and forge a hidden thread. Add an owner-less ClientThreadCreateCommand and use it in the client-facing unions; owner stays server-injected. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
PluginHost lifecycle (discover, activate, deactivate), lockfile store, per-plugin migrations (034), module loader + resolve hooks, plugin manifest contract, and the @t3tools/plugin-sdk server package. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
098db3c to
a1c2655
Compare
|
|
||
| const objectKey = (entry: SqliteMasterObject) => `${entry.type}:${entry.name}`; | ||
|
|
||
| const changedObjects = ( |
There was a problem hiding this comment.
🟠 High plugins/PluginMigrator.ts:96
changedObjects compares sqlite_master rows only by name, type, and sql. A migration that DROPs a non-plugin table and recreates it with identical CREATE TABLE SQL produces before/after snapshots that are identical, so removedObjects detects no removal and validateMigrationObjects accepts the migration. The original table data is permanently lost despite the migration passing validation. Consider snapshotting table row counts or contents for non-plugin objects, or blocking any DROP/CREATE on non-plugin tables regardless of whether the final schema matches.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginMigrator.ts around line 96:
`changedObjects` compares `sqlite_master` rows only by `name`, `type`, and `sql`. A migration that `DROP`s a non-plugin table and recreates it with identical `CREATE TABLE` SQL produces before/after snapshots that are identical, so `removedObjects` detects no removal and `validateMigrationObjects` accepts the migration. The original table data is permanently lost despite the migration passing validation. Consider snapshotting table row counts or contents for non-plugin objects, or blocking any `DROP`/`CREATE` on non-plugin tables regardless of whether the final schema matches.
| } | ||
| }); | ||
|
|
||
| const exit = yield* activation.pipe(Scope.provide(scope), Effect.exit); |
There was a problem hiding this comment.
🟡 Medium plugins/PluginHost.ts:321
If startup is interrupted while a plugin is activating, the activation.pipe(Scope.provide(scope), Effect.exit) call at line 321 never reaches the Scope.close(scope, exit) / updateFailure cleanup below, so the plugin scope leaks and activation.activatingSince stays set. On the next boot, reconcilePendingState sees that stale activatingSince marker, treats it as a crash, and disables the plugin after a single interrupted startup. Consider handling interruption — for example by catching the interruption cause explicitly or running activation with Effect.ensuring so the scope is closed and activatingSince is cleared even when the effect is interrupted.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginHost.ts around line 321:
If startup is interrupted while a plugin is activating, the `activation.pipe(Scope.provide(scope), Effect.exit)` call at line 321 never reaches the `Scope.close(scope, exit)` / `updateFailure` cleanup below, so the plugin scope leaks and `activation.activatingSince` stays set. On the next boot, `reconcilePendingState` sees that stale `activatingSince` marker, treats it as a crash, and disables the plugin after a single interrupted startup. Consider handling interruption — for example by catching the interruption cause explicitly or running `activation` with `Effect.ensuring` so the scope is closed and `activatingSince` is cleared even when the effect is interrupted.
| const HostApiRange = TrimmedNonEmptyString.check(Schema.isPattern(HOST_API_RANGE_PATTERN)); | ||
| const OptionalUrl = Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(2048))); | ||
|
|
||
| const RelativeEntryPath = TrimmedNonEmptyString.check( |
There was a problem hiding this comment.
🟡 Medium src/plugin.ts:35
RelativeEntryPath only rejects paths starting with / or \, so Windows drive-qualified absolute paths like C:\plugins\entry.js or C:/plugins/entry.js pass validation. Code that resolves these manifest entries as relative plugin paths can be redirected outside the plugin package on Windows. Consider also rejecting paths that match a Windows drive-letter pattern (e.g. /^[a-zA-Z]:[\/]/).
🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/contracts/src/plugin.ts around line 35:
`RelativeEntryPath` only rejects paths starting with `/` or `\`, so Windows drive-qualified absolute paths like `C:\plugins\entry.js` or `C:/plugins/entry.js` pass validation. Code that resolves these manifest entries as relative plugin paths can be redirected outside the plugin package on Windows. Consider also rejecting paths that match a Windows drive-letter pattern (e.g. `/^[a-zA-Z]:[\/]/`).
| } | ||
| }); | ||
|
|
||
| const validateMigrationList = (pluginId: PluginId, migrations: ReadonlyArray<PluginMigration>) => |
There was a problem hiding this comment.
🟠 High plugins/PluginMigrator.ts:164
The run function sorts migrations by version and applies every migration with migration.version > recordedHead, but it never checks that the provided versions are contiguous. When the database has recordedHead = 1 and the new bundle contains versions [1, 3], the loop skips 1 (already applied), runs migration 3, and records version 3 in plugin_migrations — even though migration 2 was never applied. Future runs treat recordedHead = 3 as up to date, so migration 2 can never be applied, leaving the schema in an inconsistent state. Consider verifying contiguity of the provided versions in validateMigrationList.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginMigrator.ts around line 164:
The `run` function sorts migrations by version and applies every migration with `migration.version > recordedHead`, but it never checks that the provided versions are contiguous. When the database has `recordedHead = 1` and the new bundle contains versions `[1, 3]`, the loop skips `1` (already applied), runs migration `3`, and records version `3` in `plugin_migrations` — even though migration `2` was never applied. Future runs treat `recordedHead = 3` as up to date, so migration `2` can never be applied, leaving the schema in an inconsistent state. Consider verifying contiguity of the provided versions in `validateMigrationList`.
| textGeneration: unavailable("textGeneration"), | ||
| }); | ||
|
|
||
| const upgradeLockfileEntry = ( |
There was a problem hiding this comment.
🟡 Medium plugins/PluginHost.ts:154
upgradeLockfileEntry copies the old plugin's activation object into the upgraded version, carrying over crashCount and a non-null activatingSince. When the new version fails to activate, reconcilePendingState sees the stale crashCount and can mark it as a repeated crash after a single failure. A stale non-null activatingSince can also make the host treat the freshly upgraded plugin as if it were mid-activation. The upgraded entry should reset activation to { activatingSince: null, crashCount: 0 }.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginHost.ts around line 154:
`upgradeLockfileEntry` copies the old plugin's `activation` object into the upgraded version, carrying over `crashCount` and a non-null `activatingSince`. When the new version fails to activate, `reconcilePendingState` sees the stale `crashCount` and can mark it as a repeated crash after a single failure. A stale non-null `activatingSince` can also make the host treat the freshly upgraded plugin as if it were mid-activation. The upgraded entry should reset `activation` to `{ activatingSince: null, crashCount: 0 }`.
What Changed
The server plugin host: a per-plugin Effect
Scopelifecycle (verify → import → register → migrate → recover → mount → ready),PluginMigrator(plugin tables gated to ap_<id>_*prefix at migration time via asqlite_masterdiff, core migration 034),PluginModuleLoader+ an ESM resolve hook sharing the host'seffect/SDK singletons, a single-writerPluginLockfileStore, and the@t3tools/plugin-sdkpackage.Why
Part 2 of 8 of a runtime plugin system (see #3725 for the full overview + which parts deliver what). Builds on #3725. This is the foundation every capability and the marketplace sit on: it owns each plugin's lifecycle, isolation scope, and on-disk state.
Review — four-model tribunal (GPT-5.5 · Grok · GLM-5.2 · Fable)
startbypassed the single-flight lock (MUST): an enable/install RPC racing startup could double-load a plugin and orphan a runtime scope → routed through the per-plugin activation lock.Effect.exitdoesn't capture external interrupts in effect beta.78 (MUST): on real shutdown / a canceled RPC,Scope.close+ the activating-marker clear never ran → wrapped the ladder inuninterruptibleMask(confirmed against the vendored runtime; verified by runtime probes since the integration test hangs in vitest under forked activation).writeFileAtomiccouldn't replace an existing file (MUST) → overwrite-permitted rename mode.rename-claim + owner-token re-verify; singleton-resolution latching before/despite failure; non-crash-safe preserve-data; no host-shutdown scope-close; manifest-version validation.UI Changes
N/A — server only.
Checklist
https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk