Repository navigation
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
|
Stacked on #16053 (and #15010). Review only the top 12 commits: fec5fa9.
Problem
Plugins can only be added from a directory already on the server's disk. To share a plugin, an author has to tell people to clone or copy files by hand, and there is no safe way to update one: overwriting the directory changes the bytes under a running plugin and drops its consent.
This PR lets an administrator install a plugin from an npm registry, and update it in two steps: stage the new version, review it, then approve and apply it. Installing and updating run nothing; the catalogue still needs consent to the exact unpacked files before a plugin can be enabled. Nothing changes in the web, desktop or mobile UI yet.
Why this qualifies
This is the proposal route in CONTRIBUTING, and no maintainer has agreed to it yet. It needs #6714 (plugin distribution / marketplace), on top of the plugin-system approval the plugin host PR needs on #6714 / #6837.
It stacks on the plugin views PR. It uses only the plugin host (catalogue, consent, supervised children); the dependency on the layers between is ordering. No client calls these RPCs in this PR. The install and update buttons arrive with the plugin management UI PR; until then they are reachable from an administrative connection (the evidence uses a script). We recommend reviewing this together with that UI PR. If the answer is no, we close this and the plugin PRs above it. Previous PR in this stack: feat: show plugin views as isolated right-panel tabs on web and desktop (#16053).
Fix
RPCs, registered in the RPC scope middleware like every other method, and gated on a new optional
pluginNpmenvironment capability so clients never call an older server:plugins.npm.listorchestration:readplugins.npm.add({ name, version, registry? })needs-consent; runs nothingaccess:writeplugins.npm.stageUpdate({ installationId, version })access:writeplugins.npm.applyUpdate({ installationId, digest })access:writeplugins.npm.discardUpdate({ installationId })access:writeRemoval is the existing
plugins.remove; the server then deletes the downloaded files once the plugin has stopped.Layout. Each package lives under
<state>/plugins/npm/<key>/:package/is the catalogue installation's directory (same path across updates),npm.jsonrecords where it came from,.staging-*holds a download in progress or a staged update, and.previousexists only while an update is applied.Applying an update journals the swap in
npm.json, then runs one catalogue step (PluginCatalog.replace, new and server-internal) under the catalogue's management lock: disable, wait for the process to exit, renamepackage→.previousand staged →package, re-digest, and consent to the reviewed digest. That consent is the commit point; a failure before it restores.previousand the old consent still applies. If the plugin was enabled it restarts on the new version with a new generation, so calls in flight on the old one can never reach the new bytes. A swap a crash interrupted is finished (if the catalogue already holds the new consent) or rolled back at the next start, and the installation is left disabled.One rule for file moves. Every move or deletion of plugin files under the npm root goes through one of three catalogue steps (
replace,settleReplace,changeFiles). Each claims its paths under the management lock: it is refused while any other installation is rooted in or around them, and it waits until every process that ran from those files has actually exited, even if the disable that stopped it was interrupted. A deletion that is refused or fails stays queued and is retried before every npm step, after every catalogue change, and at startup. The one exception is the provenance recordnpm.jsonbesidepackage/, written whole by temp-file-and-rename under the npm lock.Size: 20 files, +4076 / −5. About 2.45k of the added lines are tests and the tarball test kit; production is about 1.6k (server ~1.4k, contracts ~0.2k).
Security: supply chain
access:write). A standard pairing can list packages and is refused for every mutation, through the same middleware as the other plugin management RPCs.npm, no package scripts, norequire. A package with apreinstall,installorpostinstallscript, or a native build (binding.gyp), is refused (npm-install-scripts). Dependencies are never installed: every runtime dependency (and each of theirs, by Node's lookup, never above the package root) must be bundled inside the package (npm-dependencies). The plugin then needs consent and an enable like any other plugin.sha512SRI value for the resolved version, or the install fails (npm-integrity-missing); sha1shasumis not accepted. The downloaded tarball must hash to it, or the install fails (npm-integrity-mismatch) and nothing is written. Only the exact resolved version and its integrity are stored, never a range or tag; ranges are refused by the schema. This protects against a tampered tarball host or transport. It does not protect against a compromised registry or a malicious publish, which can publish matching integrity for bad bytes; that is what consent to the unpacked digest is for. Signatures and provenance attestations are not checked.http/httpsURL; credentials, queries and fragments are refused, and no.npmrcor auth helper is read. The registry response'snamemust match the request, and itsversionmust be exact (and equal the request when the request was exact). The tarball URL may be anyhttp(s)URL, because the integrity covers the bytes. Requests go out from the server with the server's network access; only administrators can trigger them.package/are accepted; links, devices, absolute paths,..segments, colons (which would name a hidden NTFS stream the consent digest cannot see) and anything that leaves the package root are refused (npm-archive-unsafe). Files are written withwxinto a freshmkdtempstaging directory, so nothing outside it can be created, followed or replaced.package.jsonmust exist with the resolved name and version (npm-package-mismatch). The catalogue validatest3-plugin.jsonas usual. A new version must keep the same plugin id (npm-plugin-id-changed).applyUpdatetakes the digest the administrator was shown; it must equal the staged digest, and the staged files are re-digested before the swap and again after it. The catalogue then holds consent to the new digest only: the old digest is refused (source-changed), so a stale consent can never enable new bytes.requireof something outside the package is not caught by the dependency check.Evidence
Environment: macOS arm64; this PR on top of the plugin views PR. Tests use an in-memory registry (a mocked HTTP client serving generated tarballs); no test touches the network.
How to exercise it (isolated
vp run dev, an administrative session and a standard pairing): from the administrative session,plugins.npm.adda tiny plugin package (a local registry fixture or a real small package), see itneeds-consentinplugins.list, consent and enable it;stageUpdatea newer version,applyUpdatewith its digest, and see the old digest refused byplugins.consent. From the standard pairing,plugins.npm.listworks andplugins.npm.addis refused.Live trace at this head (isolated server on a fresh home, macOS 26 arm64, Node 24; an administrative session and a standard pairing; a local registry on 127.0.0.1 named through the
registryinput, so nothing touched the public registry). The test package has one action that reports which version is running, records each activation, and carriesprepareandtestscripts that would leave a marker if anything ran them.capabilities.pluginNpmtrueplugins.npm.*(all five, administrative session)Unknown request tagWhat the trace shows, in order:
plugins.npm.addwith thelatesttag resolves to exact1.0.0and returns the installation asneeds-consentwith its digest. No plugin process starts and no script runs.npm-integrity-mismatch, "Nothing was installed."), a tarball with the entrypackage/payload:code.mjs(npm-archive-unsafe, "has a colon in its path"), and a package with apostinstallscript (npm-install-scripts).proof.npm 1.0.0 running.plugins.npm.listworks.add,stageUpdate,applyUpdate,discardUpdateandplugins.removeare each refused withrequiredScope=access:write.1.1.0shows its manifest and a new digest while the plugin still answers1.0.0. Discarding deletes the staged files.applyUpdatewith the old digest is refused (source-changed) and1.0.0keeps running. With the staged digest the installation moves to1.1.0at generation 2 with consent to the new digest, and the action answersproof.npm 1.1.0 running.plugins.consentwith the old digest is then refused (source-changed).postinstall,prepareandtestmarkers are absent.Remote pass (
vp run dev --share, fresh isolated home, clients reached the server only through the tailnet HTTPS origin): every npm mutation from a remote standard pairing, and its remove, was refused withrequiredScope=access:write, though it could list. A remote administrative session installed a package (needs-consent), approved and enabled it, ran its action, and removed it, leaving no files.Trace excerpt
Checks at this head (
25422fd6c3), re-run 2026-10-05 (vp test run,CI=true, all exit 0):PluginNpm,PluginNpmRpc,npmTarball,PluginCatalog,PluginSupervisor,PluginSettings,PluginTools,PluginCatalogRpcandRpcAuthorizationtests: 9 files, 99 tests pass. packages/contractspluginNpm,pluginCatalogandplugintests: 3 files, 17 tests pass.PluginNpm.test.tsruns real plugin child processes from the installed files: install runs nothing until the digest is approved; tampered (integrity mismatch), unsafe, script-bearing and unbundled packages are refused and leave nothing behind; staged update keeps the old version running, applies with new consent, and the old digest is refused; rollback when the update fails after the swap; other management waits behind an apply; an update interrupted by a restart is finished or rolled back; file moves wait for a plugin whose disable was cut short; a home another installation now owns is kept; the reply, list, consent and running version agree whichever cleanup after the commit fails. A fault-injecting file system and deferreds order the steps; no sleeps.npmTarball.test.ts: links, special files, paths with a colon and paths that leave the package are refused; corrupt or truncated archives; file, size, entry, path-length and header limits; macOS extended attributes.PluginNpmRpc.test.tsserves the five RPCs through the real scope middleware: a standard pairing lists but every mutation is refused withrequiredScope: access:writeand no handler runs; an administrative session reaches every handler; a session withoutorchestration:readcannot list.plugins.npm.addregistered atorchestration:read, the denial test fails.vp run --filtertypecheck for@t3tools/contractsandt3;vp lint --report-unused-disable-directivesandvp fmt --checkon the touched files (three warnings, all on unchanged lines ofws.ts, present on the parent);vp run knip:check; web build;vp run build:desktop;node scripts/release-smoke.ts. All pass.Surfaces
plugins.npm.*requests; install and update buttons arrive with the management UI PR.pluginNpm.ts(records and inputs), five RPCs, optionalpluginNpmenvironment capability, new open-stringPluginCatalogErrorreasons. Old server: no capability, so clients do not call. New server + old client: new methods only; records ignore unknown fields.plugins.remove(files deleted after the plugin stops); stage ↔ discard; apply rolls back on failure; a restart forgets a staged update.docs/user/plugin-npm.md: installing, what is checked, writing a package for T3 Code (bundle dependencies), the two-step update and its re-approval, removal. The management UI PR consolidates the plugin pages into one guide. No internals doc.Not verified
..paths, missing integrity, unbundled dependencies, a name or version mismatch, and a changed plugin id are shown by tests, not in the live trace.--share) with a scripted RPC client; no relay or T3 Connect tunnel run.npm.jsonpublication by rename is atomic on the tested platform; fsync/power-loss durability and Windows rename semantics were not verified.Claude Opus 5.5 (build) and GPT-6.1 Sol (review) via T3 Code
🤖 Generated with Claude Code