Skip to content

feat(plugins): add local plugin system with shareable git sources - #6158

Open
yemirhan wants to merge 2 commits into
pingdotgg:mainfrom
yemirhan:feat/plugin-system
Open

feat(plugins): add local plugin system with shareable git sources#6158
yemirhan wants to merge 2 commits into
pingdotgg:mainfrom
yemirhan:feat/plugin-system

Conversation

@yemirhan

@yemirhan yemirhan commented Aug 11, 2026

Copy link
Copy Markdown

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, and addSource / updateSource / removeSource — all operate-scoped
  • Signed, short-lived view URLs scoped to a single command's entry directory
  • Host SDK: showToast, openExternal, invoke
  • Settings → Plugins manages plugins and sources; repo-provided plugins show their source and are uninstalled by removing that source

Security notes

  • Plugin→host messages are authenticated with a per-mount nonce delivered in the iframe URL fragment. event.source alone is spoofable: a sandboxed frame keeps its contentWindow across a self-navigation, and its origin is opaque ("null") either way, so an origin check cannot distinguish legitimate from hostile content.
  • 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, so a symlinked plugin directory cannot serve files from outside the tree.
  • Desktop CSP frame-src remains an allowlist rather than opening http:/https:.
  • Plugin assets set no wildcard CORS, and frame-ancestors is limited to trusted embedder origins.
  • Git operations pass -- 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-Policy must stay cross-origin; same-origin makes 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

  • Server 33 tests, contracts 10, web 3, desktop 6; all packages typecheck; lint and format clean
  • Verified end to end against a real Jenkins instance in an isolated environment: source discovery from the nested repo layout, entry-scoped signed assets, the nonce handshake, and backend invoke returning live data
  • Also verified that navigating between two plugin pages swaps the frame and mints a fresh nonce

Not covered

  • git clone/pull has not been exercised against a real remote; tests cover URL validation, slug derivation, duplicate rejection, and path containment, but no network clone
  • Mobile plugin navigation is not implemented

Note

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-ancestors allowlists, and Cross-Origin-Resource-Policy: cross-origin for opaque-origin sandbox frames, and advertises capabilities.plugins.

Git sources clone into pluginsDir/.sources/ with hardened git (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 PluginPage embedding a sandboxed iframe (allow-scripts only) with a per-mount nonce for postMessage, throttled openExternal, bounded invoke to the server, and reload semantics that avoid silent iframe navigations wiping trust.

Desktop renderer CSP widens frame-src to http:/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 versus connect-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

  • Introduces a full plugin system: plugins are packages with a t3-plugin.json manifest defining view commands (HTML entry) and optional JS backends, stored under a plugins/ directory in the server base dir.
  • Adds PluginRegistry on 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.
  • Serves plugin assets over a new HTTP route (/api/plugins) with strict CSP, CORP, and security headers appropriate for sandboxed iframes (http.ts).
  • Renders plugin view commands in a sandboxed iframe (PluginPage.tsx) with a nonce-authenticated postMessage bridge supporting toasts, throttled external URL opens, and up to 4 concurrent backend invocations; the bridge is disabled if the iframe self-navigates.
  • Adds a Plugin Settings panel (PluginSettingsPanel.tsx) for managing installed plugins and git sources, and surfaces enabled plugin pages in the sidebar and command palette.
  • Risk: plugin backends execute as Node.js child processes over stdin/stdout JSON; git clone/update runs with a hardened env and output size limits, but sourced plugins are third-party code running with server-process privileges.

Macroscope summarized e273147.

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

coderabbitai Bot commented Aug 11, 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 Plus

Run ID: 2a0c4a8f-0712-4c8e-90fb-6ca293a5408d

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

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 vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 11, 2026
Comment thread apps/web/src/components/plugins/PluginPage.tsx
Comment thread apps/desktop/src/electron/ElectronProtocol.ts Outdated
Comment thread apps/web/src/components/plugins/PluginPage.tsx

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

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

Comment thread apps/server/src/plugins/PluginRegistry.ts
Comment on lines +676 to +683
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"}).`,
});
}

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.

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

Comment on lines +131 to +141
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) });
}

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.

Two convention violations in this block:

  • PluginRegistryError stores an unstructured message as its only data and is reused for ~20 semantically different failures (invalid id, not found, disabled, traversal, git failure, backend failure). Service failures should be Schema.TaggedErrorClass with structured attributes (operation/stage, plugin or source id, path) and a derived message.
  • operationError derives the wrapper's message from describeError(error) (i.e. cause.message or 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

Comment on lines +159 to +175
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,
},
) {}

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.

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

@macroscopeapp

macroscopeapp Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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") {

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/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.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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;
}
}}

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.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e273147. Configure here.

@gsimone

gsimone commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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?

@yemirhan

Copy link
Copy Markdown
Author

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:

  • Datadog, k8s etc. tooling for backend developers
  • Sentry, Firebase etc. for mobile developers

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.

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.

2 participants