feat(plugins): add local plugin system with shareable git sources - #6158
feat(plugins): add local plugin system with shareable git sources#6158yemirhan wants to merge 2 commits into
Conversation
Adds a Raycast-style plugin system where local plugins contribute sidebar pages and command palette entries, render in a sandboxed iframe, and can run environment-side JavaScript backends over an authenticated RPC. Plugins are discovered from the environment's plugins directory. A plugin's id is decoupled from its location on disk, so a single git repository can ship several plugins from `plugins/<plugin-id>/` and be installed as a source: add by URL, update to pull new commits, remove to uninstall the repository and every plugin it provided. Host surface: - plugins.list/create/setEnabled/delete and addSource/updateSource/ removeSource, all operate-scoped - signed, short-lived view URLs scoped to a command's entry directory - host SDK: showToast, openExternal, invoke Security notes: - plugin->host messages are authenticated with a per-mount nonce delivered in the iframe URL fragment; event.source alone is spoofable because a sandboxed frame keeps its contentWindow across a self-navigation, and its origin is opaque either way - view tokens sign the plugin's location and are scoped to one command's entry directory; asset serving re-checks the disabled marker and re-anchors the canonical root inside the plugins directory - desktop CSP frame-src stays an allowlist instead of opening http:/https: - plugin assets set no wildcard CORS, and frame-ancestors is limited to trusted embedder origins - git operations pass `--` before the URL, clone shallow and single-branch, disable hooks, templates, credential helpers and interactive prompts, and time out Plugin pages must load a classic script: the frame is an opaque origin, so ES modules would require a CORS grant the host deliberately does not give. Cross-Origin-Resource-Policy must stay cross-origin for the same reason. Plugin backends are trusted local code that runs with the environment's permissions and are not sandboxed; adding or updating a source runs code from that repository. Documented in docs/user/plugins.md. Mobile plugin navigation is not implemented.
|
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 Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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 |
There was a problem hiding this comment.
Effect Service Conventions review of the new plugin service code. The plugin feature's error model departs from the conventions (and from the closely analogous packages/contracts/src/assets.ts / apps/server/src/assets/AssetAccess.ts pair, which is the pattern this feature mirrors): failures are modeled as a single free-form message string, the underlying error is discarded instead of preserved as cause, and raw subprocess output plus the remote URL are copied into that caller-visible message. Details inline.
Posted via Macroscope — Effect Service Conventions
| function gitFailure(action: string, result: ProcessRunner.ProcessRunOutput): PluginRegistryError { | ||
| const detail = result.stderr.trim().slice(0, GIT_DETAIL_LIMIT); | ||
| return new PluginRegistryError({ | ||
| message: detail | ||
| ? `git ${action} failed (exit ${result.code ?? "unknown"}): ${detail}` | ||
| : `git ${action} failed (exit ${result.code ?? "unknown"}).`, | ||
| }); | ||
| } |
There was a problem hiding this comment.
Raw git stderr (up to 500 chars) is copied into an error message that is returned to clients over RPC and rendered in the UI. git stderr can echo the remote URL — including credentials in userinfo — and other environment detail, so it should not become an error attribute.
Suggest deriving the message from bounded structural fields only (action, exit code, whether output was truncated) and preserving the ProcessRunOutput/underlying failure as cause (or logging it server-side) instead of inlining stderr. The same pattern applies to the plugin backend stderr at line 927.
Posted via Macroscope — Effect Service Conventions
| class PluginRegistryError extends Data.TaggedError("PluginRegistryError")<{ | ||
| readonly message: string; | ||
| }> {} | ||
|
|
||
| function describeError(error: unknown): string { | ||
| return error instanceof Error ? error.message : String(error); | ||
| } | ||
|
|
||
| function operationError(operation: PluginOperation, error: unknown): PluginOperationError { | ||
| return new PluginOperationError({ operation, message: describeError(error) }); | ||
| } |
There was a problem hiding this comment.
Two convention violations in this block:
PluginRegistryErrorstores an unstructuredmessageas its only data and is reused for ~20 semantically different failures (invalid id, not found, disabled, traversal, git failure, backend failure). Service failures should beSchema.TaggedErrorClasswith structured attributes (operation/stage, plugin or source id, path) and a derivedmessage.operationErrorderives the wrapper'smessagefromdescribeError(error)(i.e.cause.messageor a stringified defect) and then drops the underlying error entirely, so the error chain and stack are lost at the RPC boundary.
Suggest declaring structured errors per failure and, when wrapping, passing the immediate failure through as cause while deriving message only from the wrapper's own attributes.
Posted via Macroscope — Effect Service Conventions
| export class PluginOperationError extends Schema.TaggedErrorClass<PluginOperationError>()( | ||
| "PluginOperationError", | ||
| { | ||
| operation: Schema.Literals([ | ||
| "list", | ||
| "create", | ||
| "set-enabled", | ||
| "delete", | ||
| "add-source", | ||
| "update-source", | ||
| "remove-source", | ||
| "create-view-url", | ||
| "invoke", | ||
| ]), | ||
| message: Schema.String, | ||
| }, | ||
| ) {} |
There was a problem hiding this comment.
PluginOperationError carries an unstructured message as its only payload besides operation, and has no cause. Convention here is to declare failures with structured attributes, keep the underlying failure as cause, and derive message from the structural fields — see the sibling AssetAccess* errors in assets.ts, which each carry resource + cause: Schema.Defect() and a get message().
Suggest splitting the semantically distinct plugin failures (not found, disabled, invalid id, entry missing, git clone/pull failure, backend failure) into their own error classes with stable fields (pluginId/sourceId/operation, cause) and derived messages, and exposing them as a Schema.Union in the RPC error channel. If a single service-level error is kept, it still needs structured context plus cause and a message derived from those attributes rather than a caller-supplied string.
Posted via Macroscope — Effect Service Conventions
ApprovabilityVerdict: Needs human review 1 blocking correctness issue found. This PR introduces a complete local plugin system (~3700 lines) with git-based sources, sandboxed iframe rendering, backend subprocess invocation, and 9 new RPC methods. The scope constitutes a major new feature with security-sensitive components. Additionally, open review comments identify potential bugs in iframe lifecycle handling and error message credential leakage. You can customize Macroscope's approvability policy. Learn more. |
- Scope desktop CSP frame-src by http/https scheme for remote plugin assets - Redact git URLs and credentials from user-facing plugin source errors - Tear down postMessage/invoke bridge when the plugin frame navigates away - Remount plugin pages when the primary environment changes
|
|
||
| const frameUrl = frame && frame.generation === frameGeneration ? frame.src : null; | ||
|
|
||
| if (viewUrlResult._tag === "Failure") { |
There was a problem hiding this comment.
🟡 Medium plugins/PluginPage.tsx:274
A transient viewUrlResult failure unmounts an already loaded plugin iframe and erases its in-frame state. The failure branch runs even after frame has snapshotted a valid URL; only return the failure UI when no frame has been created yet.
- if (viewUrlResult._tag === "Failure") {
+ if (viewUrlResult._tag === "Failure" && !frame) {🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/plugins/PluginPage.tsx around line 274:
A transient `viewUrlResult` failure unmounts an already loaded plugin iframe and erases its in-frame state. The failure branch runs even after `frame` has snapshotted a valid URL; only return the failure UI when no frame has been created yet.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e273147. Configure here.
| // was minted for. Reload (or navigate away and back) to get a new one. | ||
| frameReplacedRef.current = true; | ||
| } | ||
| }} |
There was a problem hiding this comment.
Iframe load count kills host bridge
High Severity
The new bridge guard treats every iframe load after the first as a hostile navigation and then ignores showToast, openExternal, and invoke. Counters reset only when a new nonce is minted. The iframe is also unmounted whenever createViewUrl is Failure or frameUrl is empty, and remounting fires load again without that reset, so a recovered or remounted frame permanently loses the host API until Reload. An extra browser load for the initial document would trip the same guard on first paint.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit e273147. Configure here.
|
this should bring at least one valid product idea that would live as a plugin, what did you have in mind when you made this? |
Well more than one, actually. For some background, I work as a engineering manager in a mobile app studio, where we juggle multiple apps at the same time. Most of our apps use the same stack, and I'm trying to figure out a workflow for my coworkers. These include:
I want to use T3 Code for a single plane to manage all of this for me and the other developers that I work together, so this was the experiment of that :D I tried to write a Jenkins management to check our EAS local builds, and send them to context of a chat for example asking things like "Why did this build fail" and so on. |


Summary
Adds a Raycast-style plugin system. Local plugins contribute sidebar pages and command palette entries, render in a sandboxed iframe, and can run environment-side JavaScript backends over an authenticated RPC.
A plugin's id is decoupled from its location on disk, so one git repository can ship several plugins from
plugins/<plugin-id>/and be installed as a source: add by URL, update to pull new commits, remove to uninstall the repository and every plugin it provided.Host surface
plugins.list/create/setEnabled/delete, andaddSource/updateSource/removeSource— all operate-scopedshowToast,openExternal,invokeSecurity notes
event.sourcealone is spoofable: a sandboxed frame keeps itscontentWindowacross a self-navigation, and its origin is opaque ("null") either way, so an origin check cannot distinguish legitimate from hostile content.frame-srcremains an allowlist rather than openinghttp:/https:.frame-ancestorsis limited to trusted embedder origins.--before the URL (blocking option injection), clone shallow and single-branch, disable hooks, templates, credential helpers and interactive prompts, and time out.Constraints worth knowing
Plugin pages must load a classic script, not an ES module. The frame is an opaque origin, so module scripts would be fetched in CORS mode and require a grant the host deliberately does not give. A module script fails quietly — the page renders, the network shows the file fetched, and nothing executes. For the same reason
Cross-Origin-Resource-Policymust staycross-origin;same-originmakes the browser discard every subresource and the plugin renders blank. Both are covered by regression tests.Plugin backends are trusted local code. They run with the environment's permissions and are not sandboxed; adding or updating a source runs code from that repository. Documented in
docs/user/plugins.md.Testing
invokereturning live dataNot covered
git clone/pullhas not been exercised against a real remote; tests cover URL validation, slug derivation, duplicate rejection, and path containment, but no network cloneNote
High Risk
Plugin backends and git-installed code run on the environment with full local permissions; framing and asset policy tradeoffs are mitigated but the attack surface is materially larger than typical UI changes.
Overview
Adds a Raycast-style plugin platform on each environment: typed contracts and WebSocket RPC for listing, creating, enabling/disabling, deleting, git source add/update/remove, minting signed view URLs, and invoke for optional Node backends. The server discovers packages under a new
pluginsDir, serves assets at/api/plugins/*with tight CSP,frame-ancestorsallowlists, andCross-Origin-Resource-Policy: cross-originfor opaque-origin sandbox frames, and advertisescapabilities.plugins.Git sources clone into
pluginsDir/.sources/with hardenedgit(no hooks/credential helpers, URL validation, path containment, symlink rejection). Local scaffolds ship a starter manifest, Vite layout, and vendored plugin SDK.Web/desktop surfacing: Settings → Plugins, routes for plugin commands, sidebar and command-palette entries, and
PluginPageembedding a sandboxed iframe (allow-scriptsonly) with a per-mount nonce forpostMessage, throttledopenExternal, boundedinvoketo the server, and reload semantics that avoid silent iframe navigations wiping trust.Desktop renderer CSP widens
frame-srctohttp:/https:(plus Turnstile) so plugin iframes can load from user-selected environment hosts whose origins are unknown at policy build time; tests lock this in versusconnect-src.Reviewed by Cursor Bugbot for commit e273147. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add local plugin system with git-shareable sources, sandboxed iframe rendering, and plugin management UI
t3-plugin.jsonmanifest defining view commands (HTML entry) and optional JS backends, stored under aplugins/directory in the server base dir.PluginRegistryon the server (PluginRegistry.ts) exposing WebSocket RPCs for listing, creating, enabling/disabling, deleting, and invoking plugins, plus managing git-sourced plugin repositories via clone/update/remove./api/plugins) with strict CSP, CORP, and security headers appropriate for sandboxed iframes (http.ts).Macroscope summarized e273147.