Skip to content

Plugin system (3/8): RPC transport + plugin-aware auth scopes#3727

Draft
ccdwyer wants to merge 4 commits into
pingdotgg:mainfrom
ccdwyer:wb/03-transport-scopes
Draft

Plugin system (3/8): RPC transport + plugin-aware auth scopes#3727
ccdwyer wants to merge 4 commits into
pingdotgg:mainfrom
ccdwyer:wb/03-transport-scopes

Conversation

@ccdwyer

@ccdwyer ccdwyer commented Jul 6, 2026

Copy link
Copy Markdown

What Changed

The plugin RPC transport: a WS envelope (plugins.call / subscribe / list), a PluginRpcDispatcher that validates each call's method against the plugin's registered descriptors, and the plugin-aware auth scope model — a plugins:manage scope, per-plugin plugin:<id>:read|operate scopes, and a satisfiesScope marker so a full standard client implicitly holds plugin scopes.

Why

Part 3 of 8 of a runtime plugin system (see #3725 for the overview). Builds on #3726. This is how a plugin's server code becomes callable, and how those calls are authorized — the client-facing security boundary of the system.

Review — four-model tribunal (GPT-5.5 · Grok · GLM-5.2 · Fable)

Fable independently verified the security core holds (no scope bypass; the grant/consumption path reads scopes unfiltered).

  • Stream-scope validation failed open (SHOULD, auth boundary): registration.streams was unvalidated and an unrecognized scope value downgraded to the weaker read → streams validated like RPC + authorizeDescriptor fails closed.
  • Plugin-scoped tokens couldn't call anything (MUST): the plugins.call/subscribe baseline required orchestration:read → a plugin:* scope now satisfies it (with a no-broadening test).
  • Sessions audit hid plugin scopes (SHOULD): widened AuthClientSession.scopes so the Clients panel shows the real grant.
  • Skipped with evidence: a reviewer's "pluginRpcError yields a success" claim — it's a Schema.TaggedError (yieldable-as-failure); the existing Result.failure tests prove it.

UI Changes

N/A — server + contracts only.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • Screenshots for UI changes (no UI)
  • Video for animation/interaction (N/A)

https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk

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
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 3a80ffa1-2c0e-4f2c-9926-03c10bc786ec

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the vouch:unvouched PR author is not yet trusted in the VOUCHED list. label Jul 6, 2026
@github-actions github-actions Bot added the size:XXL 1,000+ changed lines (additions + deletions). label Jul 6, 2026
),
);

function validateRegistration(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium plugins/PluginHost.ts:100

validateRegistration only checks for duplicate rpc.method entries but never checks registration.streams. When a plugin registers two stream descriptors with the same method, PluginRpcDispatcher.subscribe() uses Array.find, which returns the first match and silently ignores the rest. Activation succeeds, so the duplicate is never rejected and part of the plugin's API is unreachable at runtime. Add a duplicate-method check for registration.streams alongside the existing rpc loop.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginHost.ts around line 100:

`validateRegistration` only checks for duplicate `rpc.method` entries but never checks `registration.streams`. When a plugin registers two stream descriptors with the same `method`, `PluginRpcDispatcher.subscribe()` uses `Array.find`, which returns the first match and silently ignores the rest. Activation succeeds, so the duplicate is never rejected and part of the plugin's API is unreachable at runtime. Add a duplicate-method check for `registration.streams` alongside the existing `rpc` loop.

),
// Exponential backoff capped at 30s so a flapping service keeps
// retrying at a bounded cadence instead of backing off forever.
Effect.repeat(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium plugins/PluginHost.ts:200

startService restarts plugin services after any successful return, not just after failures. Effect.repeat(...) repeats only on success, and the preceding Effect.catchCause(...) converts failures into success after logging, so both a clean completion and a crash are retried forever. Any plugin service implemented as a one-shot task will be re-run every 250ms/30s indefinitely, causing duplicate side effects instead of a single startup run. Consider using Effect.retry (repeats only on failure) instead of Effect.repeat, or only retrying on failure causes rather than catching and logging them.

Also found in 1 other location(s)

packages/client-runtime/src/rpc/client.ts:275

subscribePlugin always routes plugin streams through the durable subscribe(...) helper at line 275, which automatically re-subscribes whenever EnvironmentSupervisor.session changes. That is only correct for long-lived subscriptions, but plugin streams are defined as arbitrary Stream.Stream&lt;unknown, Error&gt; handlers and can be finite command-style streams as well. If a plugin stream is mid-flight when the session reconnects, the same { pluginId, method, payload } will be invoked again on the new session, duplicating side effects or restarting the operation instead of behaving like a one-shot stream bound to the original session.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginHost.ts around line 200:

`startService` restarts plugin services after *any* successful return, not just after failures. `Effect.repeat(...)` repeats only on success, and the preceding `Effect.catchCause(...)` converts failures into success after logging, so both a clean completion and a crash are retried forever. Any plugin service implemented as a one-shot task will be re-run every 250ms/30s indefinitely, causing duplicate side effects instead of a single startup run. Consider using `Effect.retry` (repeats only on failure) instead of `Effect.repeat`, or only retrying on failure causes rather than catching and logging them.

Also found in 1 other location(s):
- packages/client-runtime/src/rpc/client.ts:275 -- `subscribePlugin` always routes plugin streams through the durable `subscribe(...)` helper at line `275`, which automatically re-subscribes whenever `EnvironmentSupervisor.session` changes. That is only correct for long-lived subscriptions, but plugin `streams` are defined as arbitrary `Stream.Stream<unknown, Error>` handlers and can be finite command-style streams as well. If a plugin stream is mid-flight when the session reconnects, the same `{ pluginId, method, payload }` will be invoked again on the new session, duplicating side effects or restarting the operation instead of behaving like a one-shot stream bound to the original session.

: Effect.fail(new PluginLockfileReadError({ path: lockfilePath, cause })),
),
);
if (raw === null) return EMPTY_PLUGIN_LOCKFILE;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium plugins/PluginLockfileStore.ts:143

readLockfileFromPath returns the shared EMPTY_PLUGIN_LOCKFILE singleton when the lockfile is missing, but PluginLockfile holds mutable sources (array) and plugins (object). Callers like updatePlugin spread lockfile into a new object yet keep references to the same sources array and plugins object, so mutations poison the shared constant for every subsequent "missing lockfile" read in the process. Consider returning a fresh copy of EMPTY_PLUGIN_LOCKFILE so each caller gets its own sources and plugins instances.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginLockfileStore.ts around line 143:

`readLockfileFromPath` returns the shared `EMPTY_PLUGIN_LOCKFILE` singleton when the lockfile is missing, but `PluginLockfile` holds mutable `sources` (array) and `plugins` (object). Callers like `updatePlugin` spread `lockfile` into a new object yet keep references to the same `sources` array and `plugins` object, so mutations poison the shared constant for every subsequent "missing lockfile" read in the process. Consider returning a fresh copy of `EMPTY_PLUGIN_LOCKFILE` so each caller gets its own `sources` and `plugins` instances.

textGeneration: unavailable("textGeneration"),
});

const upgradeLockfileEntry = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium plugins/PluginHost.ts:148

upgradeLockfileEntry copies entry.activation into the upgraded lockfile entry, carrying over the old version's crashCount. When a plugin is upgraded after one crash, the new version starts with crashCount already at 1, so a single startup failure of the new version hits the crashCount >= 2 threshold and the plugin is disabled — the new build never gets a clean slate. The activation state should be reset when promoting a staged version.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginHost.ts around line 148:

`upgradeLockfileEntry` copies `entry.activation` into the upgraded lockfile entry, carrying over the old version's `crashCount`. When a plugin is upgraded after one crash, the new version starts with `crashCount` already at 1, so a single startup failure of the new version hits the `crashCount >= 2` threshold and the plugin is disabled — the new build never gets a clean slate. The activation state should be reset when promoting a staged version.

// bundles are user-readable — and the clearer typo diagnostics win.
const call: PluginRpcDispatcher["Service"]["call"] = (pluginId, method, payload, session) =>
Effect.gen(function* () {
const runtime = yield* lookupRuntime(registry, pluginId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium plugins/PluginRpcDispatcher.ts:164

call and subscribe treat any runtime found in PluginRuntimeRegistry as active, but a runtime is stored in the registry before activation completes and is never removed if later startup work fails. As a result, when activation fails after the put, the dispatcher still finds the stale runtime: non-always methods return not-ready forever instead of treating the plugin as failed, and readiness: "always" methods can still be invoked against a plugin whose scope was already closed. Consider removing the runtime from the registry on activation failure, or checking the readiness deferred's failure state so stale runtimes are rejected.

Also found in 1 other location(s)

apps/server/src/serverRuntimeStartup.ts:436

pluginHost.start is invoked only after commandGate.signalCommandReady and after the HTTP server may already be accepting connections. The WS handlers expose plugins.list / plugins.call / plugins.subscribe directly from PluginCatalog / PluginRpcDispatcher without waiting for startup readiness, and both read PluginRuntimeRegistry. Until pluginHost.start finishes populating that registry, early clients can get an empty plugin list or not-found for installed plugins even though the server is already reachable.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginRpcDispatcher.ts around line 164:

`call` and `subscribe` treat any runtime found in `PluginRuntimeRegistry` as active, but a runtime is stored in the registry before activation completes and is never removed if later startup work fails. As a result, when activation fails after the `put`, the dispatcher still finds the stale runtime: non-`always` methods return `not-ready` forever instead of treating the plugin as failed, and `readiness: "always"` methods can still be invoked against a plugin whose scope was already closed. Consider removing the runtime from the registry on activation failure, or checking the `readiness` deferred's failure state so stale runtimes are rejected.

Also found in 1 other location(s):
- apps/server/src/serverRuntimeStartup.ts:436 -- `pluginHost.start` is invoked only after `commandGate.signalCommandReady` and after the HTTP server may already be accepting connections. The WS handlers expose `plugins.list` / `plugins.call` / `plugins.subscribe` directly from `PluginCatalog` / `PluginRpcDispatcher` without waiting for startup readiness, and both read `PluginRuntimeRegistry`. Until `pluginHost.start` finishes populating that registry, early clients can get an empty plugin list or `not-found` for installed plugins even though the server is already reachable.

}

const unavailable = (capability: string) =>
Effect.die(new PluginCapabilityUnavailable({ capability }));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High plugins/PluginHost.ts:122

unavailable() uses Effect.die(...) to create unavailable capabilities, so calling any unavailable capability (e.g. hostApi.http) raises an unrecoverable defect instead of a typed PluginCapabilityUnavailable failure. This bypasses normal error-channel recovery — Effect.catchTag("PluginCapabilityUnavailable", ...) cannot intercept it, and plugin code aborts rather than receiving a catchable error. Use Effect.fail(...) instead so the error is recoverable as declared by the PluginHostApi type.

Suggested change
Effect.die(new PluginCapabilityUnavailable({ capability }));
const unavailable = (capability: string) =>
Effect.fail(new PluginCapabilityUnavailable({ capability }));
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginHost.ts around line 122:

`unavailable()` uses `Effect.die(...)` to create unavailable capabilities, so calling any unavailable capability (e.g. `hostApi.http`) raises an unrecoverable defect instead of a typed `PluginCapabilityUnavailable` failure. This bypasses normal error-channel recovery — `Effect.catchTag("PluginCapabilityUnavailable", ...)` cannot intercept it, and plugin code aborts rather than receiving a catchable error. Use `Effect.fail(...)` instead so the error is recoverable as declared by the `PluginHostApi` type.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

message: Schema.String,

AuthScope now includes PluginScope, but EnvironmentAuthorizationError still declares requiredScope as AuthEnvironmentScope. When the server denies a plugin RPC over WebSocket, the real missing scope is something like plugin:<id>:operate, which does not satisfy AuthEnvironmentScope, so the error cannot serialize the actual required scope. The server must either fail to encode the error or misrepresent the required scope as an environment scope, producing a wrong or undecodable error response. Consider widening requiredScope to AuthScope so WebSocket errors can faithfully report the missing plugin scope.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/contracts/src/auth.ts around line 357:

`AuthScope` now includes `PluginScope`, but `EnvironmentAuthorizationError` still declares `requiredScope` as `AuthEnvironmentScope`. When the server denies a plugin RPC over WebSocket, the real missing scope is something like `plugin:<id>:operate`, which does not satisfy `AuthEnvironmentScope`, so the error cannot serialize the actual required scope. The server must either fail to encode the error or misrepresent the required scope as an environment scope, producing a wrong or undecodable error response. Consider widening `requiredScope` to `AuthScope` so WebSocket errors can faithfully report the missing plugin scope.

Comment thread apps/server/src/ws.ts
[WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope],
[WS_METHODS.subscribeServerLifecycle, AuthOrchestrationReadScope],
[WS_METHODS.subscribeAuthAccess, AuthAccessReadScope],
[PLUGINS_WS_METHODS.list, AuthOrchestrationReadScope],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High src/ws.ts:350

RPC_REQUIRED_SCOPE gates PLUGINS_WS_METHODS.call and PLUGINS_WS_METHODS.subscribe with AuthOrchestrationReadScope, but satisfiesScope only recognizes plugin:<id>:read|operate as satisfying a required AuthOrchestrationReadScope when the required scope itself is a plugin scope. A token issued with only plugin:<id>:read or plugin:<id>:operate therefore fails the outer websocket authorization before PluginRpcDispatcher runs its per-plugin check, so plugin-scoped clients cannot call or subscribe to any plugin method. Consider mapping these entries to a scope that plugin-scoped tokens satisfy, or handle plugin methods before the static RPC_REQUIRED_SCOPE check.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/ws.ts around line 350:

`RPC_REQUIRED_SCOPE` gates `PLUGINS_WS_METHODS.call` and `PLUGINS_WS_METHODS.subscribe` with `AuthOrchestrationReadScope`, but `satisfiesScope` only recognizes `plugin:<id>:read|operate` as satisfying a required `AuthOrchestrationReadScope` when the *required* scope itself is a plugin scope. A token issued with only `plugin:<id>:read` or `plugin:<id>:operate` therefore fails the outer websocket authorization before `PluginRpcDispatcher` runs its per-plugin check, so plugin-scoped clients cannot call or subscribe to any plugin method. Consider mapping these entries to a scope that plugin-scoped tokens satisfy, or handle plugin methods before the static `RPC_REQUIRED_SCOPE` check.

hostResolutionHookRegistered = true;
}),
),
Effect.catchCause((cause) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium plugins/PluginModuleLoader.ts:158

ensureHostSingletonResolution catches every registerHook failure (lines 158–162), logs it as a warning, and still returns Effect.void. This means PluginHost.start proceeds to loadServerEntry without the resolver hook installed, so a single transient nodeModule.register(...) failure causes plugins to import against the wrong module graph (or fail to import), and PluginHost then marks the plugin state: "failed" in the lockfile — the exact persistent-failure path the rest of this change was designed to prevent. Consider letting registration failures propagate (or surface a typed error) so callers do not continue without the hook.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginModuleLoader.ts around line 158:

`ensureHostSingletonResolution` catches every `registerHook` failure (lines 158–162), logs it as a warning, and still returns `Effect.void`. This means `PluginHost.start` proceeds to `loadServerEntry` without the resolver hook installed, so a single transient `nodeModule.register(...)` failure causes plugins to import against the wrong module graph (or fail to import), and `PluginHost` then marks the plugin `state: "failed"` in the lockfile — the exact persistent-failure path the rest of this change was designed to prevent. Consider letting registration failures propagate (or surface a typed error) so callers do not continue without the hook.

return before.filter((entry) => !afterKeys.has(objectKey(entry)));
};

const validateMigrationObjects = (input: {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium plugins/PluginMigrator.ts:112

validateMigrationObjects only diffs sqlite_master schema objects, so a plugin migration that runs direct DML against core tables (e.g. DELETE FROM core_users or UPDATE sessions ...) passes validation and commits — no schema object changes, so the namespace guard never fires. This lets a plugin migration silently modify or destroy non-plugin data. Consider blocking DML against non-plugin tables (e.g. by inspecting the migration's SQL or enforcing read-only access to core tables) so data-only changes are covered too.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginMigrator.ts around line 112:

`validateMigrationObjects` only diffs `sqlite_master` schema objects, so a plugin migration that runs direct DML against core tables (e.g. `DELETE FROM core_users` or `UPDATE sessions ...`) passes validation and commits — no schema object changes, so the namespace guard never fires. This lets a plugin migration silently modify or destroy non-plugin data. Consider blocking DML against non-plugin tables (e.g. by inspecting the migration's SQL or enforcing read-only access to core tables) so data-only changes are covered too.

ccdwyer added 3 commits July 5, 2026 22:50
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
Plugin RPC dispatcher + catalog wiring over ws, plugin-aware auth scope
model (plugin:<id>:read|operate), session/pairing-grant scope plumbing,
and the client-runtime authorization surface for plugin scopes.

Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
@ccdwyer ccdwyer force-pushed the wb/03-transport-scopes branch from 776bfdd to d96dadd Compare July 6, 2026 03:01

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One error-handling convention finding in the new plugin services. See inline comment.

Posted via Macroscope — Effect Service Conventions

Comment on lines +90 to +94
Effect.catchCause((cause) =>
Effect.fail(
new PluginRegistrationError({
pluginId,
detail: Cause.pretty(cause),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PluginRegistrationError has no cause field, so this wrapping site flattens a genuine thrown failure with Cause.pretty(cause) into the detail: Schema.String attribute, and message is then derived from that stringified defect. This erases the structured upstream error and its stack chain, which the conventions ask you to preserve as a cause (never derive the wrapper message from a stringified defect).

Add a cause: Schema.Defect() field and preserve the underlying failure here, mirroring the sibling PluginMigrationExecutionError (whose message derives only from pluginId/version and keeps cause). The detail-based validation constructions (invalid RPC scope, duplicate RPC method, manifest-id mismatch) are pure domain errors and can keep using detail.

Suggested change
Effect.catchCause((cause) =>
Effect.fail(
new PluginRegistrationError({
pluginId,
detail: Cause.pretty(cause),
Effect.catchCause((cause) =>
Effect.fail(
new PluginRegistrationError({
pluginId,
detail: "plugin register() threw",
cause,
}),
),
),

(This requires adding cause: Schema.Defect() to the PluginRegistrationError fields and building message from the structural attributes rather than detail alone.)

Posted via Macroscope — Effect Service Conventions

),
);

const scope = yield* Scope.make("sequential");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High plugins/PluginHost.ts:268

loadPlugin creates a Scope at line 268 with Scope.make("sequential") and forks all plugin services into it, but that scope is never attached to the surrounding server lifetime via Effect.addFinalizer. When the server runtime shuts down, scope is never closed, so plugin service fibers are not interrupted and can keep file watchers, sockets, and timers alive — hanging graceful shutdown or leaking background work. Consider registering a finalizer that closes scope when the enclosing server scope exits.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginHost.ts around line 268:

`loadPlugin` creates a `Scope` at line 268 with `Scope.make("sequential")` and forks all plugin services into it, but that scope is never attached to the surrounding server lifetime via `Effect.addFinalizer`. When the server runtime shuts down, `scope` is never closed, so plugin service fibers are not interrupted and can keep file watchers, sockets, and timers alive — hanging graceful shutdown or leaking background work. Consider registering a finalizer that closes `scope` when the enclosing server scope exits.

Comment on lines +78 to +80
id: manifest.id,
name: manifest.name,
version: manifest.version,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium plugins/PluginCatalog.ts:78

pluginInfoFromLockfileEntry uses manifest.id and manifest.version from the on-disk manifest.json instead of the lockfile's pluginId key and entry.version. If the manifest is corrupt or belongs to a different plugin, list returns a PluginInfo with the wrong identity — it can duplicate an active plugin's id (which is deduplicated by the activePluginIds set, causing the active runtime to be hidden) while the real lockfile entry silently disappears from the results. Use the lockfile pluginId and entry.version for id and version, as fallbackPluginInfo already does.

Suggested change
id: manifest.id,
name: manifest.name,
version: manifest.version,
id: PluginId.make(pluginId),
name: manifest.name,
version: entry.version,
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginCatalog.ts around lines 78-80:

`pluginInfoFromLockfileEntry` uses `manifest.id` and `manifest.version` from the on-disk `manifest.json` instead of the lockfile's `pluginId` key and `entry.version`. If the manifest is corrupt or belongs to a different plugin, `list` returns a `PluginInfo` with the wrong identity — it can duplicate an active plugin's id (which is deduplicated by the `activePluginIds` set, causing the active runtime to be hidden) while the real lockfile entry silently disappears from the results. Use the lockfile `pluginId` and `entry.version` for `id` and `version`, as `fallbackPluginInfo` already does.

Comment on lines +275 to +280
return subscribe(PLUGINS_WS_METHODS.subscribe, {
pluginId,
method,
...(payload === undefined ? {} : { payload }),
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium rpc/client.ts:275

subscribePlugin routes all plugin streams through the durable subscribe helper, which re-invokes the RPC method whenever the supervisor session is replaced. Plugin streams are not classified as subscriptions versus finite commands, so a one-shot or side-effecting plugin stream restarts after a session swap and can run twice, emitting duplicated or inconsistent results. Consider routing plugin streams through runStream (which binds to a single session) instead, or add an option to control reconnection behavior.

Suggested change
return subscribe(PLUGINS_WS_METHODS.subscribe, {
pluginId,
method,
...(payload === undefined ? {} : { payload }),
});
}
return runStream(PLUGINS_WS_METHODS.subscribe, {
pluginId,
method,
...(payload === undefined ? {} : { payload }),
});
🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/rpc/client.ts around lines 275-280:

`subscribePlugin` routes all plugin streams through the durable `subscribe` helper, which re-invokes the RPC method whenever the supervisor session is replaced. Plugin streams are not classified as subscriptions versus finite commands, so a one-shot or side-effecting plugin stream restarts after a session swap and can run twice, emitting duplicated or inconsistent results. Consider routing plugin streams through `runStream` (which binds to a single session) instead, or add an option to control reconnection behavior.

Comment on lines +191 to +192
input.service.run({ pluginId: input.pluginId, logger: input.logger }).pipe(
Effect.catchCause((cause) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium plugins/PluginHost.ts:191

startService calls input.service.run(...) eagerly. If a plugin service's run implementation throws synchronously before returning its Effect, the exception bypasses the Effect.catchCause(...).repeat(...) restart wrapper and aborts plugin activation instead of being logged and retried. Consider wrapping the run call in Effect.suspend so a synchronous throw becomes a failed effect that the catchCause/repeat handlers can catch.

-  input.service.run({ pluginId: input.pluginId, logger: input.logger }).pipe(
+  Effect.suspend(() => input.service.run({ pluginId: input.pluginId, logger: input.logger })).pipe(
     Effect.catchCause((cause) =>
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginHost.ts around lines 191-192:

`startService` calls `input.service.run(...)` eagerly. If a plugin service's `run` implementation throws synchronously before returning its `Effect`, the exception bypasses the `Effect.catchCause(...).repeat(...)` restart wrapper and aborts plugin activation instead of being logged and retried. Consider wrapping the `run` call in `Effect.suspend` so a synchronous throw becomes a failed effect that the `catchCause`/`repeat` handlers can catch.

detail: `object name must start with ${input.prefix}`,
});
}
if (entry.type !== "trigger" && entry.type !== "view") continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium plugins/PluginMigrator.ts:147

validateMigrationObjects skips the core-table reference check for table and index objects because the loop continues whenever entry.type is not trigger or view. A migration that creates a prefixed table with a foreign key to a core table — e.g. CREATE TABLE p_test_items (... REFERENCES users(id)) — passes the prefix check and is never inspected, so the cross-namespace dependency is not detected. Apply the same body reference scan to all object types (or at least to table and index) rather than only trigger and view.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginMigrator.ts around line 147:

`validateMigrationObjects` skips the core-table reference check for `table` and `index` objects because the loop `continue`s whenever `entry.type` is not `trigger` or `view`. A migration that creates a prefixed table with a foreign key to a core table — e.g. `CREATE TABLE p_test_items (... REFERENCES users(id))` — passes the prefix check and is never inspected, so the cross-namespace dependency is not detected. Apply the same body reference scan to all object types (or at least to `table` and `index`) rather than only `trigger` and `view`.

threadId: ThreadId,
projectId: ProjectId,
title: TrimmedNonEmptyString,
owner: Schema.optional(ThreadOwner),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High src/orchestration.ts:514

ThreadCreateCommand now accepts an owner field, enabling plugin-owned (hidden) threads. However, subscribeThread is gated only by orchestration:read and calls getThreadDetailById without any owner filter — the projection layer only filters by owner = 'user' in shell/list views, not in thread-detail lookups. Any client that guesses or learns a plugin thread id can call orchestration.subscribeThread and read the full hidden thread detail and messages, defeating the privacy boundary this change introduces. Consider applying the same owner-based visibility filter in the subscribeThread handler (or getThreadDetailById) that the shell/list views use.

Also found in 1 other location(s)

apps/server/src/relay/AgentAwarenessRelay.ts:329

The new owner check in publishThreadUnsafe drops every thread whose owner is not &#34;user&#34; before computing or publishing relay state. That means plugin-created / agent-owned threads can never reach relayClient.server.publishAgentActivity, so their activity/notifications stop propagating to the relay even when projectThreadAwareness(...) would otherwise produce a publishable state. This breaks agent-awareness for the new plugin-owned thread flow introduced by the PR.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/contracts/src/orchestration.ts around line 514:

`ThreadCreateCommand` now accepts an `owner` field, enabling plugin-owned (hidden) threads. However, `subscribeThread` is gated only by `orchestration:read` and calls `getThreadDetailById` without any owner filter — the projection layer only filters by `owner = 'user'` in shell/list views, not in thread-detail lookups. Any client that guesses or learns a plugin thread id can call `orchestration.subscribeThread` and read the full hidden thread detail and messages, defeating the privacy boundary this change introduces. Consider applying the same owner-based visibility filter in the `subscribeThread` handler (or `getThreadDetailById`) that the shell/list views use.

Also found in 1 other location(s):
- apps/server/src/relay/AgentAwarenessRelay.ts:329 -- The new owner check in `publishThreadUnsafe` drops every thread whose owner is not `"user"` before computing or publishing relay state. That means plugin-created / agent-owned threads can never reach `relayClient.server.publishAgentActivity`, so their activity/notifications stop propagating to the relay even when `projectThreadAwareness(...)` would otherwise produce a publishable state. This breaks agent-awareness for the new plugin-owned thread flow introduced by the PR.

const file = yield* fs.open(tempPath, { flag: "w", mode: 0o600 });
yield* file.writeAll(bytes);
yield* file.sync;
yield* fs.rename(tempPath, input.lockfilePath);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High plugins/PluginLockfileStore.ts:176

The ownership re-check at owner !== lock.token runs once before writeLockfileToPath is called, but nothing re-verifies ownership after the write completes. If the writer is paused (GC/CPU starvation) or the temp-file write, sync, and rename take longer than STALE_LOCK_MS, another process can reclaim the advisory lock, perform its own mutation, and release the lock while the original writer is still inside writeLockfileToPath. When the original writer resumes and completes fs.rename(tempPath, input.lockfilePath), it overwrites the newer lockfile, silently losing the other writer's update. Consider re-checking the owner token immediately before the rename (or using a write path that fails if ownership was lost) so a reclaimed lock aborts the write instead of clobbering the newer contents.

       yield* file.writeAll(bytes);
       yield* file.sync;
-      yield* fs.rename(tempPath, input.lockfilePath);
+      // Re-verify lock ownership immediately before the atomic rename so a
+      // writer paused past STALE_LOCK_MS does not clobber a newer lockfile
+      // produced by a reclaimer.
+      const owner = yield* fs.readFileString(input.lockfilePath).pipe(
+        Effect.map((content) => content.trim()),
+        Effect.orElseSucceed(() => ""),
+      );
+      if (owner !== input.ownerToken) {
+        return yield* new PluginLockfileWriteError({
+          path: input.lockfilePath,
+          cause: new Error("advisory lock ownership lost before rename"),
+        });
+      }
+      yield* fs.rename(tempPath, input.lockfilePath);
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginLockfileStore.ts around line 176:

The ownership re-check at `owner !== lock.token` runs once before `writeLockfileToPath` is called, but nothing re-verifies ownership after the write completes. If the writer is paused (GC/CPU starvation) or the temp-file write, `sync`, and `rename` take longer than `STALE_LOCK_MS`, another process can reclaim the advisory lock, perform its own mutation, and release the lock while the original writer is still inside `writeLockfileToPath`. When the original writer resumes and completes `fs.rename(tempPath, input.lockfilePath)`, it overwrites the newer lockfile, silently losing the other writer's update. Consider re-checking the owner token immediately before the `rename` (or using a write path that fails if ownership was lost) so a reclaimed lock aborts the write instead of clobbering the newer contents.

Comment on lines +36 to +46
const RelativeEntryPath = TrimmedNonEmptyString.check(
Schema.makeFilter<string>((entryPath) => {
if (entryPath.startsWith("/") || entryPath.startsWith("\\")) {
return "entry paths must be relative";
}
if (entryPath.split(/[\\/]/).includes("..")) {
return "entry paths may not contain '..' segments";
}
return true;
}),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium src/plugin.ts:36

RelativeEntryPath only rejects paths starting with / or \ and paths containing .. segments. A Windows drive-letter absolute path such as C:\plugin\server.js passes both checks, so PluginManifest.entries.server / web can be an absolute path even though the schema is supposed to require a relative entry. Any loader that resolves these entries on Windows can load code from outside the plugin package. Consider adding a check for Windows drive-letter prefixes (e.g., ^[a-zA-Z]:) so absolute Windows paths are also rejected.

Suggested change
const RelativeEntryPath = TrimmedNonEmptyString.check(
Schema.makeFilter<string>((entryPath) => {
if (entryPath.startsWith("/") || entryPath.startsWith("\\")) {
return "entry paths must be relative";
}
if (entryPath.split(/[\\/]/).includes("..")) {
return "entry paths may not contain '..' segments";
}
return true;
}),
);
const RelativeEntryPath = TrimmedNonEmptyString.check(
Schema.makeFilter<string>((entryPath) => {
if (entryPath.startsWith("/") || entryPath.startsWith("\\")) {
return "entry paths must be relative";
}
if (/^[a-zA-Z]:/.test(entryPath)) {
return "entry paths must be relative";
}
if (entryPath.split(/[\\/]/).includes("..")) {
return "entry paths may not contain '..' segments";
}
return true;
}),
);
🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/contracts/src/plugin.ts around lines 36-46:

`RelativeEntryPath` only rejects paths starting with `/` or `\` and paths containing `..` segments. A Windows drive-letter absolute path such as `C:\plugin\server.js` passes both checks, so `PluginManifest.entries.server` / `web` can be an absolute path even though the schema is supposed to require a relative entry. Any loader that resolves these entries on Windows can load code from outside the plugin package. Consider adding a check for Windows drive-letter prefixes (e.g., `^[a-zA-Z]:`) so absolute Windows paths are also rejected.

@@ -430,6 +432,8 @@ export const make = Effect.gen(function* () {

yield* Effect.logDebug("Accepting commands");
yield* commandGate.signalCommandReady;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium src/serverRuntimeStartup.ts:434

pluginHost.start runs after commandGate.signalCommandReady, so the command gate opens before plugins are initialized. Clients that connect in that window can invoke plugin RPCs while PluginHost is still loading manifests, and PluginRpcDispatcher.lookupRuntime returns not-found, making installed plugins intermittently appear unavailable during startup. Consider moving pluginHost.start before commandGate.signalCommandReady so plugins are initialized before the server accepts commands.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serverRuntimeStartup.ts around line 434:

`pluginHost.start` runs after `commandGate.signalCommandReady`, so the command gate opens before plugins are initialized. Clients that connect in that window can invoke plugin RPCs while `PluginHost` is still loading manifests, and `PluginRpcDispatcher.lookupRuntime` returns `not-found`, making installed plugins intermittently appear unavailable during startup. Consider moving `pluginHost.start` before `commandGate.signalCommandReady` so plugins are initialized before the server accepts commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant