feat: collapse plugin runtime to one tier behind a plugin SDK

This commit is contained in:
LukeGus committed 2026-09-22 14:11:50 -05:00
1 parent 6e058f6e9e
commit 4758016f41
96 files changed
+34631 -8551

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE `plugin_permission_grants` MODIFY COLUMN `granted_by` varchar(255);--> statement-breakpoint
ALTER TABLE `plugin_permission_grants` ADD `source` text DEFAULT ('admin') NOT NULL;--> statement-breakpoint
ALTER TABLE `plugins` ADD `last_error` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -183,6 +183,13 @@
"when": 1790083819606,
"tag": "0025_wet_master_mold",
"breakpoints": true
},
{
"idx": 26,
"version": "5",
"when": 1790102859925,
"tag": "0026_empty_black_bird",
"breakpoints": true
}
]
}
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE "plugin_permission_grants" ALTER COLUMN "granted_by" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "plugin_permission_grants" ADD COLUMN "source" text DEFAULT 'admin' NOT NULL;--> statement-breakpoint
ALTER TABLE "plugins" ADD COLUMN "last_error" text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -190,6 +190,13 @@
"when": 1790083819084,
"tag": "0026_perfect_masque",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1790102859387,
"tag": "0027_sharp_guardian",
"breakpoints": true
}
]
}
@@ -0,0 +1,18 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_plugin_permission_grants` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`plugin_id` text NOT NULL,
`capability` text NOT NULL,
`granted_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`granted_by` text,
`source` text DEFAULT 'admin' NOT NULL,
FOREIGN KEY (`plugin_id`) REFERENCES `plugins`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_plugin_permission_grants`("id", "plugin_id", "capability", "granted_at", "granted_by", "source") SELECT "id", "plugin_id", "capability", "granted_at", "granted_by", 'admin' FROM `plugin_permission_grants`;--> statement-breakpoint
DROP TABLE `plugin_permission_grants`;--> statement-breakpoint
ALTER TABLE `__new_plugin_permission_grants` RENAME TO `plugin_permission_grants`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE UNIQUE INDEX `idx_plugin_permission_grants_plugin_capability` ON `plugin_permission_grants` (`plugin_id`,`capability`);--> statement-breakpoint
ALTER TABLE `plugins` ADD `last_error` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -162,6 +162,13 @@
"when": 1790083818524,
"tag": "0022_steady_the_hood",
"breakpoints": true
},
{
"idx": 23,
"version": "6",
"when": 1790102858837,
"tag": "0023_talented_shooting_star",
"breakpoints": true
}
]
}
+81
View File
@@ -99,4 +99,85 @@ export default tseslint.config([
],
},
},
{
// Core must not reach into a plugin. A plugin can be disabled, upgraded
// or removed, so an import from core turns "disabled" into a broken
// build rather than a missing feature. Core talks to plugins through the
// runtime in src/backend/plugins/ and the dispatchers in
// database/routes/*-dispatch.ts, both of which handle absence.
//
// Tests are exempt: they import a plugin's modules on purpose to test
// them, and until A2 gives each plugin its own suite they live under
// src/*/tests/plugins/.
files: ["src/**/*.{ts,tsx}"],
ignores: ["src/**/tests/**"],
rules: {
// An error for the backend, which has dispatchers to register through
// and no remaining offenders.
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["**/plugins/*/backend/**"],
message:
"Core must not import a plugin backend. Register a dispatcher or an SDK service instead, so disabling the plugin degrades cleanly.",
},
],
},
],
},
},
{
// The shell still imports plugin components directly (the terminal tab,
// the Guacamole app, the Proxmox dialog). There is no frontend plugin
// loader yet, so there is nothing to move them to: A7 builds the app
// object that registers these surfaces and this becomes an error.
files: ["src/ui/**/*.{ts,tsx}"],
ignores: ["src/ui/tests/**"],
rules: {
"no-restricted-imports": [
"warn",
{
patterns: [
{
group: ["**/plugins/*/frontend/**"],
message:
"The shell should not import plugin components directly. A7 replaces these with registrations through the app object.",
},
],
},
],
},
},
{
// The other direction. A plugin is supposed to talk to core only through
// @termix/plugin-sdk, which is the contract D1 finishes enforcing.
//
// Only the backend is fenced today. Plugin backends reach core by
// relative path (../../../src/backend/...) and plugin frontends use the
// "@/" alias for shared UI, both of which are the "legacy core imports"
// debt recorded in packages/plugin-sdk/ARCHITECTURE.md: the SDK does not
// yet expose the SSH pool, host resolution, repositories or the
// component library. A7 moves the frontends onto the app object and D1
// moves the backends onto SDK APIs, and this rule tightens to match.
//
// What it already catches: a plugin backend importing the frontend tree,
// which is not debt in any direction, just a mistake.
files: ["plugins/**/backend/**/*.{ts,mjs}"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["@/*", "**/src/ui/**"],
message:
"A plugin backend cannot import frontend code. Import from @termix/plugin-sdk, or use a relative path within the plugin.",
},
],
},
],
},
},
]);
+15
View File
@@ -8,6 +8,9 @@
"name": "termix",
"version": "2.9.0",
"hasInstallScript": true,
"workspaces": [
"packages/*"
],
"dependencies": {
"@anthropic-ai/sdk": "^0.120.0",
"@simplewebauthn/browser": "^13.3.0",
@@ -7002,6 +7005,10 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@termix/plugin-sdk": {
"resolved": "packages/plugin-sdk",
"link": true
},
"node_modules/@testing-library/dom": {
"version": "10.4.2",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz",
@@ -18476,6 +18483,14 @@
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"packages/plugin-sdk": {
"name": "@termix/plugin-sdk",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"semver": "^7.6.3"
}
}
}
}
+9 -5
View File
@@ -1,6 +1,9 @@
{
"name": "termix",
"private": true,
"workspaces": [
"packages/*"
],
"version": "2.9.0",
"description": "Self-hosted SSH and remote desktop management.",
"author": "Karmaa",
@@ -15,7 +18,7 @@
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-guacamole-common-js.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-xterm-android-ime.cjs && node scripts/patch-ssh2-agent.cjs",
"prepare": "husky || true",
"prebuild": "node scripts/write-electron-build-info.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint .",
"lint": "node scripts/generate-dialect-schema.cjs --check && npm run plugin:validate-manifest && eslint .",
"lint:fix": "eslint --fix .",
"type-check": "tsc -b --force",
"test": "vitest run",
@@ -24,9 +27,9 @@
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"dev": "vite",
"build": "vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs",
"build:backend": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs",
"dev:backend": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs && node ./dist/backend/backend/starter.js",
"build": "npm run build:sdk && vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs",
"build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs",
"dev:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs && node ./dist/backend/backend/starter.js",
"dev:docker": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker build -f docker/Dockerfile -t termix:dev --no-cache . && docker run -d --name termix-dev -p 3000:3000 -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev",
"dev:docker:restart": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker run -d --name termix-dev -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev",
"generate:openapi": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node ./dist/backend/backend/utils/swagger.js",
@@ -44,7 +47,8 @@
"schema:generate": "node scripts/generate-dialect-schema.cjs",
"schema:check": "node scripts/generate-dialect-schema.cjs --check",
"schema:migrations": "drizzle-kit generate --config=drizzle.config.sqlite.ts && drizzle-kit generate --config=drizzle.config.pg.ts && drizzle-kit generate --config=drizzle.config.mysql.ts",
"plugin:validate-manifest": "node scripts/validate-plugin-manifest.cjs"
"plugin:validate-manifest": "node scripts/validate-plugin-manifest.cjs",
"build:sdk": "npm run build --workspace @termix/plugin-sdk"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.120.0",
+470
View File
@@ -0,0 +1,470 @@
# Termix plugin architecture
This is the contract between Termix core and a plugin. It is the reference for
anyone writing a plugin and the specification the runtime in
`src/backend/plugins/` implements.
Status: the redesign lands across several steps. Everything marked **A1** is
built today. Later markers name the step that delivers the rest.
---
## The shape of it
### 1. One tier
Every plugin, official or community, runs in-process in the Termix server and
in the browser. There is no first-party allowlist, no worker tier, and no
special case keyed on a plugin id anywhere in core.
This is not a sandbox. See [What this protects](#what-this-protects-and-what-it-does-not).
### 2. SDK only
A plugin imports `@termix/plugin-sdk`, its own files and its own npm
dependencies. Entry points:
| Entry | Contents |
| --------------------------------- | ---------------------------------------------------------- |
| `@termix/plugin-sdk/backend` | `PluginContext`, `definePlugin()`, `PluginCapabilityError` |
| `@termix/plugin-sdk/frontend` | The `app` object types (stubs until A7) |
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
| `@termix/plugin-sdk/capabilities` | The capability catalog |
| `@termix/plugin-sdk/testing` | Test helpers, incl. `createFakeContext()` |
Plugins never import from `src/` or `@/`, and core never imports from
`plugins/`. Anything a plugin needs from core becomes a typed, documented SDK
API. Enforced by lint, with the exceptions recorded under
[legacy core imports](#legacy-core-imports-the-debt-d1-removes).
The SDK holds the contract: types, the catalog, and pure helpers. The ctx
implementation lives in core (`src/backend/plugins/ctx.ts`) and is injected at
activation, so a plugin cannot reach around it.
### 3. Capabilities are what a plugin's code may do
Declared in the manifest's `capabilities` array using `domain:action` names
from one catalog in the SDK. Every privileged SDK call checks the capability
and writes an audit line naming the plugin and the acting user.
Two things must both be true, and a grant for something the manifest never
declared is ignored rather than honoured:
1. the manifest declares it, and
2. it is granted in `plugin_permission_grants`.
That means widening a plugin's reach always requires a new manifest the user
can see. In 2.9.0 bundled plugins are granted everything they declare
automatically, with `source: "bundled"`.
Adding a capability is one catalog entry (id, risk, i18n title and consequence
text) plus the SDK method that checks it.
### 4. User permissions are what a user may do
These stay in core RBAC and keep their dotted spelling (`hosts.view`), so they
can never be confused with colon-separated capabilities.
A plugin declares short names in `contributes.permissionGroup`. Core registers
them, groups them under the plugin in the role editor, and only lets a plugin
set defaults for its own namespace: a `defaultForRole` entry naming a
permission the plugin does not declare is a validation error, not a silent
escalation. Permissions stored in a role survive the plugin being disabled.
Role defaults are applied once and the fact is recorded, so an admin who
revokes one does not get it handed back on the next restart.
### 5. Data
Plugins own their tables through SDK table definitions and ship migrations for
sqlite, pg and mysql. Table names are prefixed `p_<id with - as _>_`. Legacy
core tables are adopted by rename in the plugin's first migration. `ctx.kv` is
for small key/value state. Core `schema.ts` ends up holding only core tables.
**A3** delivers the table definitions and migration runner. A1 ships `ctx.kv`
only.
### 6. HTTP and WebSockets
Plugin routes live under `/plugin-api/<id>/` and sockets under
`/plugin-ws/<id>/`, served by the main backend with core auth. No plugin gets
its own port or nginx block.
**A4** delivers this. In A1 `/plugin-api` is mounted and auth-gated but has no
routes registered, so it returns 404. The bundled plugins still keep their
original URLs through the dispatchers in `src/backend/database/routes/*-dispatch.ts`,
and four still own ports of their own.
### 7. Settings
Plugins declare settings fields in the manifest with scope `admin`, `user` or
`host`. Core renders them from the schema (with an optional custom component),
stores values in `plugin_settings`, and encrypts secret fields. One Settings
screen has a Plugins section with a page per plugin; host settings render in
the host editor. **A6.**
### 8. UI
A plugin frontend is a built ESM bundle loaded at runtime by the core loader.
It registers everything through the `app` object: rail items, panels, tabs,
host editor sections, host actions and badges, context menu items, palette
entries, dashboard cards, homepage widgets, settings components, action slots,
login/2FA UI, SSH auth editors. The shell contains no plugin ids. **A7.**
### 9. Cross-plugin use
Through `ctx.services` (typed, versioned, RBAC-checked per call), `ctx.events`,
and frontend actions and slots. Dependencies are declared in the manifest:
`dependencies` are hard, `optionalDependencies` are soft. The loader activates
in dependency order. A plugin must keep working when an optional dependency is
missing.
### 10. Lifecycle
`activate(ctx)` creates all state. Everything registered through `ctx` or `app`
is disposed automatically on deactivate. See [Lifecycle rules](#lifecycle-rules).
### 11. Tests
Each plugin has `plugins/<id>/tests/backend` and `plugins/<id>/tests/frontend`,
run by its own vitest config built on the SDK preset (**A2**). Core tests
(`src/backend/tests`, `src/ui/tests`) never test plugin code; they may test the
plugin runtime with fixture plugins.
Until A2, plugin tests still live under `src/*/tests/plugins/`.
### 12. Auth
Core keeps password login, sessions, API keys, trusted proxy login, Electron
auto-session and trusted devices, plus the base SSH auth types password, key,
stored credential, agent and none. Every other login method, second factor and
SSH auth method is a plugin through `ctx.auth`. **A8.**
### 13. Where plugins live
Official plugins live in `plugins/<id>/` as npm workspace packages in this
monorepo for all of 2.9.0, and get split into their own repos later. Every
plugin is enabled by default in 2.9.0; there is no install UI until 3.0.0.
### 14. Upgrades must be lossless
Every move of data, settings or host columns into a plugin comes with a
migration for all three dialects and a test proving existing data survives.
---
## Manifest v2 reference
`plugins/<id>/manifest.json`. The directory name must equal `id`. Unknown
fields are rejected at every level: a typo'd `contribute` used to validate
clean and be silently dropped, which made a manifest describe something it did
not do.
```jsonc
{
"id": "example", // ^[a-z][a-z0-9-]{1,39}$, equals the directory name
"name": "Example",
"version": "1.0.0", // semver
"description": "What it does.",
"author": { "name": "You", "url": "https://..." },
"license": "MIT",
"repository": "https://...", // optional
"category": "Productivity", // see the enum below
"icon": "Sparkles", // optional: Lucide name or a bundled icon.svg
"engine": {
"termix": ">=2.9.0", // display only, never enforced
"api": "1", // the real compatibility gate
},
"capabilities": ["kv:own", "ui:surface"],
"dependencies": { "other-plugin": "^1.0.0" }, // missing one blocks activation
"optionalDependencies": { "maybe": "^1.0.0" }, // missing one is normal
"provides": [
{
"service": "example.thing",
"version": "1.0.0",
"permission": "example.use",
},
],
"requires": [
{ "service": "other.thing", "versionRange": "^1.0.0", "optional": true },
],
"providesSecret": [
{ "key": "api-key", "permission": "example.secrets.share" },
],
"requiresSecret": [
{ "plugin": "other-plugin", "key": "api-key", "optional": true },
],
"contributes": {
"tabs": [
{
"id": "example",
"titleKey": "nav.example",
"icon": "Sparkles",
"openFrom": ["rail"],
},
],
"actions": [
{
"id": "example.open",
"titleKey": "k",
"handler": "open",
"permission": "example.use",
},
],
"actionSlots": [{ "id": "example.toolbar", "accepts": ["button"] }],
"permissionGroup": {
"group": "example",
"permissions": ["example.use"],
"defaultForRole": { "admin": ["example.use"] }, // own namespace only
},
"hostCapability": {
"key": "enableExample",
"labelKey": "k",
"editorTab": "general",
},
},
"backend": "dist/backend.js", // default
"frontend": "dist/frontend.js", // default
"locales": "locales", // default
"platforms": ["linux", "win32", "darwin"], // optional
}
```
`category` is one of: Terminal, Files & Transfer, Infrastructure, Monitoring,
Networking, Access & Security, Productivity.
`openFrom` is one of: rail, host-context-menu, palette.
Cross-field rules the JSON schema cannot express, checked by `parseManifest`:
- `engine.api` must match the SDK major version this build implements.
- Every `provides[].permission`, `providesSecret[].permission` and
`contributes.actions[].permission` must appear in the plugin's own
`contributes.permissionGroup.permissions`. A permission the catalog never
sees is one no admin can grant, so the surface would be invisible rather
than denied.
- `requiresSecret[].plugin` and both dependency maps must not name the plugin
itself, and a plugin cannot be in both `dependencies` and
`optionalDependencies`.
- Every `defaultForRole` entry must be a permission the plugin declares.
`contributes.hostCapability`, `provides`, `requires`, `providesSecret` and
`requiresSecret` are carried forward from v1 unchanged because the service and
secret registries read them at activation. A5, A6 and A7 reshape them.
Removed in v2: `permissions` (replaced by `capabilities`), `sidecars`, the
`capabilities.{backend,frontend,electron,platforms}` object (replaced by the
`backend`/`frontend`/`platforms` fields), `contributes.dashboardCards`,
`contributes.settingsPanel`, `contributes.apiPrefix`, and
`process:transport-owner` (the marker for the tier that no longer exists).
---
## Capability catalog
Colon-separated, ordered worst first. The i18n keys resolve under
`plugins.capabilities.<id>.title` and `.consequence` in
`src/ui/locales/en.json`, and the copy describes the consequence to the person
deciding, not the mechanism.
| Capability | Risk | Meaning |
| -------------------- | -------- | -------------------------------------------------------------- |
| `credentials:read` | critical | See plaintext secrets for any reachable host |
| `ssh:connect` | high | Run commands on hosts it is connected to |
| `process:spawn` | high | Run programs on the Termix server |
| `users:write` | high | Create and change user accounts |
| `auth:provide` | high | Add a login method or second factor |
| `system:tls` | high | Request and replace the server certificate |
| `hosts:write` | medium | Create and change hosts |
| `credentials:use` | medium | Connect using a host's stored credentials, without seeing them |
| `network:outbound` | medium | Make outbound requests |
| `network:serve` | medium | Open a listening port |
| `users:read` | medium | See usernames and roles, never hashes or 2FA state |
| `events:core` | medium | Subscribe to and emit core events carrying other users' data |
| `notify:send` | medium | Send notifications through configured channels |
| `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open desktop windows (Electron only) |
| `hosts:read` | low | See the host list it can already see |
| `db:own` | low | Own tables |
| `kv:own` | low | Small key/value state |
| `secrets:own` | low | Its own encrypted secrets |
| `settings:read-core` | low | Read core server settings |
| `ui:surface` | low | Contribute tabs, panels and settings |
### Rules
- One catalog, in `packages/plugin-sdk/src/capabilities.ts`. Nothing else
defines a capability.
- Adding one is an entry here plus the SDK method that checks it. A capability
nothing checks is decoration, and v1 shipped nineteen of those.
- A capability is checked where the privileged thing happens, not at
activation. Activation-time checks say what a plugin might do; call-time
checks say what it did.
- `events:core` is the exception that proves the shape: without it a plugin may
only emit under `plugin.<its id>.`, because otherwise it could publish
`host.status` and drive the automations engine as though core had.
---
## The ctx surface
Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| Member | Capability | Status |
| ------------------------------------------------ | -------------------------------- | ------ |
| `ctx.pluginId`, `ctx.manifest` | none | **A1** |
| `ctx.log.*` | none | **A1** |
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
| `ctx.registry.*` | none | **A1** |
| `ctx.services.provide` / `.get` | per-service RBAC | **A1** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
| `ctx.disposables.add` | none | **A1** |
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
| `ctx.currentActor()` | none | **A1** |
| `ctx.db` | `db:own` | A3 |
| `ctx.http.route` / `ctx.ws` | `network:serve` | A4 |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | A4 |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | A4 |
| `ctx.settings.*` | `settings:read-core` | A6 |
| `ctx.notify.*` | `notify:send` | A6 |
| `ctx.auth.*` | `auth:provide` | A8 |
| `ctx.fetch` | `network:outbound` | A4 |
### The actor
Never comes from plugin code. It is held in `AsyncLocalStorage` and set two
ways, with no third:
- **A request.** A4's HTTP and WebSocket middleware runs the handler as the
user core already authenticated.
- **`ctx.asUser(userId, fn)`** for background work with no request behind it.
Always audited, and every core API inside still applies that user's RBAC.
A plugin cannot pass a user id to a guarded method and have it believed. This
is what closes the forged-caller bug the old worker broker had, where a worker
could set `callerUserId` on its own messages.
---
## Lifecycle rules
1. **Everything is created inside `activate`.** No servers, timers, listeners
or connections at module scope. ESM caches modules, so disable-then-enable
re-runs `activate` against the same module object: anything created at
module scope is already torn down by the second call and never comes back.
2. **Everything is released on deactivate.** Registrations made through `ctx`
are tracked automatically. Anything the plugin creates itself goes in
`ctx.disposables.add(fn)`. A plugin that keeps a port after deactivate makes
"disabled" a lie and makes re-enabling fail on a port it still holds.
3. **Disposal does not depend on the plugin behaving.** The runtime empties the
disposable bag even when the plugin's `deactivate()` throws or does not
exist, in reverse registration order, isolating each disposer.
4. **A failed `activate` is still cleaned up**, and the plugin's own
`deactivate()` is _not_ called: it never finished starting, so it has no
state to tear down.
5. **Plugins never touch process signals or `process.exit`.** Core calls
`deactivate` on shutdown: `gracefulShutdown` in `src/backend/starter.ts`
calls `shutdownPlugins()`, which stops every plugin in reverse activation
order. A plugin that installs its own `SIGTERM` handler and exits skips the
rest of that sequence, including other plugins and the database flush.
6. **Enable, disable, enable must work without a restart.**
### States
`enabled`, `disabled`, `blocked` (a hard dependency is missing, recoverable),
`failed` (activation threw, a dependency cycle, or the error budget tripped).
`lastError` carries the reason.
### Error budget
Core wraps plugin callbacks it invokes. Five failures inside 60 seconds (both
configurable) marks the plugin `failed` and deactivates it, because a plugin
throwing on every call is worse than one that is off.
`POST /plugins/:id/retry` clears the budget and starts it again.
---
## What this protects, and what it does not
Be precise about this, because it is easy to overclaim and the previous
architecture did.
**What the capability system gives you**
- Every privileged thing a plugin can do through `ctx` is declared in a
manifest you can read before installing it.
- Every privileged call is checked against what was granted, and refused if it
was not.
- Every privileged call, allowed or refused, leaves an audit line naming the
plugin and the acting user. You can answer "what did this plugin do, and on
whose behalf".
- A plugin cannot name a user and be believed, so it cannot act as someone it
was not invoked by.
- Removing a capability from a manifest revokes the grant on the next boot.
**What it does not give you**
- **Containment.** A plugin runs in the server process. It can
`import("node:fs")` and read whatever the server user can read, open its own
sockets, spawn processes, and reach core modules directly. Guarding `ctx`
does not change any of that.
- **Protection from malicious code.** A plugin that wants to bypass the gate
can. Nothing here stops it.
So the honest boundary is: capabilities stop accidental and casual overreach,
and make every privileged call auditable. What stops malicious code is
**signing, review and the kill list** - the same trust model as any other
software you choose to install on a server.
The old worker tier claimed more than this, and the claim was not true either:
`worker_threads` shares the process, so a worker plugin could already reach
anything core could. The honest options for real containment are a child
process with `--permission`, or a WASM isolate. Neither is on the roadmap, and
until one is, this document does not pretend otherwise.
---
## Legacy core imports: the debt D1 removes
The twelve bundled plugins predate the SDK. They still reach core by relative
path (`../../../src/backend/...`), which a build-time string rewrite in
`scripts/copy-bundled-plugins.cjs` patches to the compiled output path.
This is expected and temporary. The SDK does not yet expose what they need:
the SSH connection pool, host resolution, the repository layer, the shared
component library. D1 removes the debt once A3 through A8 have given them
supported APIs to move onto.
What the lint fence enforces today:
- Core must not import a plugin backend. **Error**, no offenders.
- A plugin backend must not import frontend code or use the `@/` alias.
**Error**, no offenders.
- The shell must not import plugin components. **Warning** with 21 offenders,
which is exactly the work A7 does. It becomes an error then.
- Plugin backends importing core by relative path is not yet flagged, because
there is nowhere for them to go.
Known specifics:
- **TODO(B18):** `plugins/ai/backend/tools/executor.ts` imports
`../../../automations/backend/routes.js` directly. It is declared as a hard
`dependencies` entry so the loader starts automations first and marks `ai`
blocked if automations is disabled. In B18 `ai` calls automations through an
`automations` service as an `optionalDependency` and the direct import goes
away. This is the only cross-plugin import in the repo.
- Eight plugins keep their original core URLs through the dispatchers in
`src/backend/database/routes/*-dispatch.ts` rather than `/plugin-api`. A4
moves them.
- Four plugins own ports directly (ssh-terminal 30002, host-metrics 30005,
docker 30007 and 30009, remote-desktop 30008). A4 moves them onto the main
server.
- `src/ui/shell/pluginLoader.ts` still hardcodes plugin ids in
`BUILT_IN_TABS_BY_PLUGIN` and `applyFirstPartyActions`. A7 removes both.
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@termix/plugin-sdk",
"version": "1.0.0",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "MIT",
"type": "module",
"exports": {
"./backend": {
"types": "./dist/backend.d.ts",
"default": "./dist/backend.js"
},
"./frontend": {
"types": "./dist/frontend.d.ts",
"default": "./dist/frontend.js"
},
"./manifest": {
"types": "./dist/manifest.d.ts",
"default": "./dist/manifest.js"
},
"./capabilities": {
"types": "./dist/capabilities.d.ts",
"default": "./dist/capabilities.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"default": "./dist/testing.js"
},
"./schema/manifest.schema.json": "./schema/manifest.schema.json"
},
"files": [
"dist",
"schema",
"ARCHITECTURE.md"
],
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"semver": "^7.6.3"
}
}
@@ -0,0 +1,317 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://termix.site/schemas/plugin-manifest.schema.json",
"title": "Termix Plugin Manifest v2",
"type": "object",
"additionalProperties": false,
"required": [
"id",
"name",
"version",
"description",
"author",
"license",
"engine",
"category",
"capabilities"
],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{1,39}$",
"description": "Unique plugin id. Lowercase, starts with a letter, 2-40 characters. Must equal the plugin's directory name."
},
"name": { "type": "string", "minLength": 1 },
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$",
"description": "Semver version string."
},
"description": { "type": "string", "minLength": 1 },
"author": {
"type": "object",
"additionalProperties": false,
"required": ["name"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"url": { "type": "string", "minLength": 1 }
}
},
"license": { "type": "string", "minLength": 1 },
"repository": { "type": "string", "minLength": 1 },
"category": {
"type": "string",
"enum": [
"Terminal",
"Files & Transfer",
"Infrastructure",
"Monitoring",
"Networking",
"Access & Security",
"Productivity"
]
},
"icon": {
"type": "string",
"minLength": 1,
"description": "A Lucide icon name, or a relative path to an icon.svg in the plugin root."
},
"engine": {
"type": "object",
"additionalProperties": false,
"required": ["termix", "api"],
"properties": {
"termix": {
"type": "string",
"minLength": 1,
"description": "Display string only. Never enforced."
},
"api": {
"type": "string",
"pattern": "^[0-9]+$",
"description": "Plugin SDK major version. The real compatibility gate."
}
}
},
"capabilities": {
"type": "array",
"description": "Catalog capability ids this plugin's code needs. See the SDK capability catalog.",
"items": {
"type": "string",
"enum": [
"credentials:read",
"ssh:connect",
"process:spawn",
"users:write",
"auth:provide",
"system:tls",
"hosts:write",
"credentials:use",
"network:outbound",
"network:serve",
"users:read",
"events:core",
"notify:send",
"audit:read",
"desktop:window",
"hosts:read",
"db:own",
"kv:own",
"secrets:own",
"settings:read-core",
"ui:surface"
]
}
},
"dependencies": {
"type": "object",
"description": "Plugin ids this plugin cannot run without, mapped to a semver range.",
"additionalProperties": { "type": "string", "minLength": 1 },
"propertyNames": { "pattern": "^[a-z][a-z0-9-]{1,39}$" }
},
"optionalDependencies": {
"type": "object",
"description": "Plugin ids this plugin uses when present. It must keep working without them.",
"additionalProperties": { "type": "string", "minLength": 1 },
"propertyNames": { "pattern": "^[a-z][a-z0-9-]{1,39}$" }
},
"provides": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["service", "version", "permission"],
"properties": {
"service": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$"
},
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$"
},
"permission": { "type": "string", "minLength": 1 }
}
}
},
"requires": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["service", "versionRange"],
"properties": {
"service": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$"
},
"versionRange": { "type": "string", "minLength": 1 },
"optional": { "type": "boolean" }
}
}
},
"providesSecret": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["key", "permission"],
"properties": {
"key": { "type": "string", "pattern": "^[a-z0-9-]+$" },
"permission": { "type": "string", "minLength": 1 },
"descriptionKey": { "type": "string", "minLength": 1 }
}
}
},
"requiresSecret": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["plugin", "key"],
"properties": {
"plugin": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$" },
"key": { "type": "string", "pattern": "^[a-z0-9-]+$" },
"optional": { "type": "boolean" }
}
}
},
"contributes": {
"type": "object",
"additionalProperties": false,
"properties": {
"tabs": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "titleKey", "icon", "openFrom"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"titleKey": { "type": "string", "minLength": 1 },
"icon": { "type": "string", "minLength": 1 },
"openFrom": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"enum": ["rail", "host-context-menu", "palette"]
}
}
}
}
},
"actions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "titleKey", "handler"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$"
},
"titleKey": { "type": "string", "minLength": 1 },
"handler": {
"type": "string",
"pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$"
},
"icon": { "type": "string", "minLength": 1 },
"permission": { "type": "string", "minLength": 1 },
"slot": { "type": "string", "minLength": 1 },
"kind": { "type": "string", "enum": ["button"] }
}
}
},
"actionSlots": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "accepts"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$"
},
"accepts": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "enum": ["button"] }
},
"descriptionKey": { "type": "string", "minLength": 1 }
}
}
},
"permissionGroup": {
"type": "object",
"additionalProperties": false,
"required": ["group", "permissions"],
"properties": {
"group": { "type": "string", "minLength": 1 },
"permissions": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-z0-9_-]+)+$"
}
},
"defaultForRole": {
"type": "object",
"description": "Role name -> permissions. Only permissions this plugin declares above are allowed.",
"additionalProperties": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
}
},
"hostCapability": {
"description": "A host-editor checkbox backed by a boolean column on the host record. Reshaped by a later step.",
"oneOf": [
{ "$ref": "#/$defs/hostCapability" },
{
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/hostCapability" }
}
]
}
}
},
"backend": {
"type": "string",
"minLength": 1,
"description": "Built backend entry, relative to the plugin root. Defaults to dist/backend.js."
},
"frontend": {
"type": "string",
"minLength": 1,
"description": "Built frontend entry, relative to the plugin root. Defaults to dist/frontend.js."
},
"locales": {
"type": "string",
"minLength": 1,
"description": "Locales directory, relative to the plugin root. Defaults to locales."
},
"platforms": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "enum": ["linux", "win32", "darwin"] }
}
},
"$defs": {
"hostCapability": {
"type": "object",
"additionalProperties": false,
"required": ["key", "labelKey", "editorTab"],
"properties": {
"key": { "type": "string", "minLength": 1 },
"labelKey": { "type": "string", "minLength": 1 },
"editorTab": { "type": "string", "minLength": 1 }
}
}
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* The backend contract: what a plugin's activate(ctx) receives.
*
* These are types only. Core builds the real object in
* src/backend/plugins/ctx.ts and injects it at activation, so the SDK stays a
* description of the contract rather than an implementation a plugin could
* reach around.
*
* Members marked A3-A8 are not built yet. A plugin written against them today
* will type-check and fail at runtime, which is the intended signal.
*/
import type { PluginManifest } from "./manifest.js";
/**
* Thrown when a plugin calls a guarded member without the capability.
*
* Two things must be true: the manifest declares it, and it is granted. A
* grant for something the manifest never declared is ignored, so widening a
* plugin's reach always needs a new manifest the user can see.
*/
export class PluginCapabilityError extends Error {
readonly code = "EPLUGINCAP";
readonly pluginId: string;
readonly capability: string;
constructor(pluginId: string, capability: string) {
super(
`Plugin "${pluginId}" is not granted the "${capability}" capability. ` +
`Declare it in the manifest's capabilities array and grant it in the plugin's settings.`,
);
this.name = "PluginCapabilityError";
this.pluginId = pluginId;
this.capability = capability;
}
}
export interface PluginLogger {
debug: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string, error?: Error) => void;
}
export interface PluginEvents {
/** Topics are namespaced to plugin.<id>.* unless the plugin holds events:core. */
emit: (topic: string, payload: unknown) => void;
on: (topic: string, listener: (payload: unknown) => void) => () => void;
}
/** Small key/value state owned by this plugin. Requires kv:own. */
export interface PluginKeyValue {
get: (key: string) => Promise<unknown>;
set: (key: string, value: unknown) => Promise<void>;
delete: (key: string) => Promise<boolean>;
list: () => Promise<string[]>;
}
export interface PluginRegistry {
provide: <T>(key: string, value: T) => void;
consume: <T>(key: string) => T | undefined;
revoke: (key: string, value?: unknown) => boolean;
}
export interface PluginServices {
provide: <T extends object>(service: string, implementation: T) => void;
get: <T extends object>(service: string, options?: { userId?: string }) => T;
}
export interface PluginSecrets {
offer: (
key: string,
resolve: (userId: string) => Promise<string | null> | string | null,
) => void;
withdraw: (key: string) => boolean;
getShared: (
pluginId: string,
key: string,
options?: { userId?: string },
) => Promise<string | null>;
}
/**
* Anything the runtime should undo when the plugin deactivates. Everything
* registered through ctx is tracked automatically; this is for resources a
* plugin creates itself, such as a server or an interval.
*/
export interface PluginDisposables {
add: (dispose: () => void | Promise<void>) => void;
}
export interface PluginContext {
readonly pluginId: string;
readonly manifest: PluginManifest;
readonly log: PluginLogger;
readonly events: PluginEvents;
readonly kv: PluginKeyValue;
readonly registry: PluginRegistry;
readonly services: PluginServices;
readonly secrets: PluginSecrets;
readonly disposables: PluginDisposables;
/**
* Runs `fn` with `userId` as the acting user, for background work that has
* no request behind it. Always audited, and every core API inside still
* applies that user's RBAC.
*
* This is the only way a plugin can name a user. Nothing else trusts a user
* id that came from plugin code.
*/
asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
/** The acting user for the current call, when there is one. */
currentActor: () => string | undefined;
}
export type PluginActivate = (ctx: PluginContext) => void | Promise<void>;
export type PluginDeactivate = () => void | Promise<void>;
export interface PluginModule {
activate: PluginActivate;
deactivate?: PluginDeactivate;
}
/**
* Identity helper that gives a plugin entry its types without importing the
* runtime. Everything a plugin creates belongs inside activate: the module is
* cached by the ESM loader, so disable-then-enable re-runs activate on the
* same module object.
*/
export function definePlugin(plugin: PluginModule): PluginModule {
return plugin;
}
export type { PluginManifest } from "./manifest.js";
+97
View File
@@ -0,0 +1,97 @@
/**
* What a plugin's code may do.
*
* A capability is declared in the manifest and checked by the SDK method that
* needs it. It is not a user permission: those stay in core RBAC and are dotted
* (hosts.view), while capabilities are colon-separated (hosts:read) so the two
* can never be confused in a grant table or a log line.
*
* Adding one means an entry here plus the SDK method that checks it. The i18n
* keys resolve against core's en.json under plugins.capabilities.<id>.
*
* The copy those keys carry describes the consequence to the person deciding,
* not the mechanism. "Run commands on your servers", not "grants ssh:connect".
*/
export type CapabilityRisk = "low" | "medium" | "high" | "critical";
export interface CapabilityInfo {
id: string;
risk: CapabilityRisk;
/** i18n key for the short consequence, e.g. "Run commands on your servers". */
titleKey: string;
/** i18n key for the qualifying sentence under the title. */
consequenceKey: string;
}
function entry(id: string, risk: CapabilityRisk): CapabilityInfo {
return {
id,
risk,
titleKey: `plugins.capabilities.${id}.title`,
consequenceKey: `plugins.capabilities.${id}.consequence`,
};
}
/**
* Ordered critical -> low. The install and review UI reads this order directly
* so the worst line is never folded into a summary row.
*/
export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("credentials:read", "critical"),
entry("ssh:connect", "high"),
entry("process:spawn", "high"),
entry("users:write", "high"),
entry("auth:provide", "high"),
entry("system:tls", "high"),
entry("hosts:write", "medium"),
entry("credentials:use", "medium"),
entry("network:outbound", "medium"),
entry("network:serve", "medium"),
entry("users:read", "medium"),
entry("events:core", "medium"),
entry("notify:send", "medium"),
entry("audit:read", "medium"),
entry("desktop:window", "medium"),
entry("hosts:read", "low"),
entry("db:own", "low"),
entry("kv:own", "low"),
entry("secrets:own", "low"),
entry("settings:read-core", "low"),
entry("ui:surface", "low"),
] as const;
export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map(
(capability) => capability.id,
);
/**
* Kept as a string rather than a union of the catalog ids: a capability is
* data the runtime checks at load time, and a union here would force every
* caller holding a manifest value to cast.
*/
export type Capability = string;
const BY_ID = new Map(
CAPABILITY_CATALOG.map((capability) => [capability.id, capability]),
);
export function isKnownCapability(capability: string): boolean {
return BY_ID.has(capability);
}
export function getCapabilityInfo(
capability: string,
): CapabilityInfo | undefined {
return BY_ID.get(capability);
}
export const CAPABILITY_RISK_ORDER: readonly CapabilityRisk[] = [
"critical",
"high",
"medium",
"low",
];
+40
View File
@@ -0,0 +1,40 @@
/**
* The frontend contract: what a plugin's frontend entry receives.
*
* Stubs only. A7 builds the real loader and fills these in, at which point a
* plugin registers every surface it contributes through this object and the
* shell stops carrying plugin ids of its own.
*/
import type { PluginManifest } from "./manifest.js";
export interface TermixAppInfo {
readonly pluginId: string;
readonly manifest: PluginManifest;
}
/**
* Registration surface for a plugin frontend.
*
* A7 adds: rail items, panels, tabs, host editor sections, host actions and
* badges, context menu items, palette entries, dashboard cards, homepage
* widgets, settings components, action slots, login/2FA UI and SSH auth
* editors. Everything registered here is removed when the plugin is disabled.
*/
export interface TermixApp extends TermixAppInfo {
/** Registered cleanup, run when the plugin is disabled. */
onDispose: (dispose: () => void) => void;
}
export type FrontendActivate = (app: TermixApp) => void | Promise<void>;
export interface FrontendModule {
activate: FrontendActivate;
deactivate?: () => void | Promise<void>;
}
export function definePluginFrontend(plugin: FrontendModule): FrontendModule {
return plugin;
}
export type { PluginManifest } from "./manifest.js";
+840
View File
@@ -0,0 +1,840 @@
/**
* Manifest v2: the inert JSON description of a plugin.
*
* Core reads this without running any plugin code, so everything that decides
* whether a plugin may load, what it may do and where its UI appears has to be
* expressible here.
*
* Unknown fields are rejected at every level. A typo'd "contribute" or
* "permissionGroups" used to validate clean and be silently dropped, which
* made a manifest look like it declared something it did not.
*/
import semver from "semver";
import { isKnownCapability } from "./capabilities.js";
/**
* The SDK major version this build implements. engine.api is the real
* compatibility gate; engine.termix is a display string and is never enforced.
*/
export const SUPPORTED_PLUGIN_API_VERSION = "1";
export const PLUGIN_CATEGORIES = [
"Terminal",
"Files & Transfer",
"Infrastructure",
"Monitoring",
"Networking",
"Access & Security",
"Productivity",
] as const;
export const PLUGIN_PLATFORMS = ["linux", "win32", "darwin"] as const;
export const ACTION_CONTRIBUTION_KINDS = ["button"] as const;
export type ActionContributionKind = (typeof ACTION_CONTRIBUTION_KINDS)[number];
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"] as const;
const ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
const API_VERSION_PATTERN = /^[0-9]+$/;
/** Service names are dotted, e.g. "ssh.transport". */
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/;
/** Action ids name a frontend function, so segments may be camelCase. */
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
const PERMISSION_PATTERN = /^[a-z0-9-]+(\.[a-z0-9_-]+)+$/;
export interface PluginAuthor {
name: string;
url?: string;
}
export interface PluginEngine {
/** Display string only, e.g. ">=2.9.0". Never enforced. */
termix: string;
/** SDK major version as a string. The real gate. */
api: string;
}
export interface HostCapabilityContribution {
key: string;
labelKey: string;
editorTab: string;
}
export interface PluginTabContribution {
id: string;
titleKey: string;
icon: string;
openFrom: string[];
}
export interface PluginPermissionGroup {
group: string;
permissions: string[];
/** Role name -> permissions. Only this plugin's own permissions are allowed. */
defaultForRole?: Record<string, string[]>;
}
export interface PluginActionContribution {
id: string;
titleKey: string;
/** Name of the frontend export implementing this action. */
handler: string;
icon?: string;
permission?: string;
slot?: string;
kind?: ActionContributionKind;
}
export interface PluginActionSlot {
id: string;
accepts: ActionContributionKind[];
descriptionKey?: string;
}
export interface PluginServiceProvide {
service: string;
version: string;
/** The role permission a calling user needs. */
permission: string;
}
export interface PluginServiceRequire {
service: string;
versionRange: string;
optional?: boolean;
}
export interface PluginSecretProvide {
key: string;
permission: string;
descriptionKey?: string;
}
export interface PluginSecretRequire {
plugin: string;
key: string;
optional?: boolean;
}
export interface PluginContributions {
tabs?: PluginTabContribution[];
actions?: PluginActionContribution[];
actionSlots?: PluginActionSlot[];
permissionGroup?: PluginPermissionGroup;
/**
* A host-editor checkbox backed by a boolean column on the host record.
* A5/A6/A7 reshape this; it stays in v2 because remote-desktop, docker,
* proxmox and web-endpoint ship it today.
*/
hostCapability?: HostCapabilityContribution | HostCapabilityContribution[];
}
export interface PluginManifest {
id: string;
name: string;
version: string;
description: string;
author: PluginAuthor;
license: string;
repository?: string;
category: string;
icon?: string;
engine: PluginEngine;
/** Catalog capability ids. */
capabilities: string[];
/** pluginId -> semver range. A missing one blocks activation. */
dependencies?: Record<string, string>;
/** pluginId -> semver range. A missing one is normal. */
optionalDependencies?: Record<string, string>;
provides?: PluginServiceProvide[];
requires?: PluginServiceRequire[];
providesSecret?: PluginSecretProvide[];
requiresSecret?: PluginSecretRequire[];
contributes?: PluginContributions;
/** Built backend entry, relative to the plugin root. */
backend?: string;
/** Built frontend entry, relative to the plugin root. */
frontend?: string;
/** Locales directory, relative to the plugin root. */
locales?: string;
platforms?: string[];
}
export const DEFAULT_BACKEND_ENTRY = "dist/backend.js";
export const DEFAULT_FRONTEND_ENTRY = "dist/frontend.js";
export const DEFAULT_LOCALES_DIR = "locales";
const REQUIRED_TOP_LEVEL = [
"id",
"name",
"version",
"description",
"author",
"license",
"engine",
"category",
"capabilities",
];
const ALLOWED_TOP_LEVEL = new Set([
...REQUIRED_TOP_LEVEL,
"repository",
"icon",
"dependencies",
"optionalDependencies",
"provides",
"requires",
"providesSecret",
"requiresSecret",
"contributes",
"backend",
"frontend",
"locales",
"platforms",
]);
const ALLOWED_CONTRIBUTES = new Set([
"tabs",
"actions",
"actionSlots",
"permissionGroup",
"hostCapability",
]);
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Pushes an error for every key that is not in `allowed`. */
function rejectUnknown(
value: Record<string, unknown>,
allowed: Set<string> | string[],
where: string,
errors: string[],
): void {
const set = Array.isArray(allowed) ? new Set(allowed) : allowed;
for (const key of Object.keys(value)) {
if (!set.has(key)) {
errors.push(`Unknown field "${key}" in ${where}`);
}
}
}
function requireString(
value: unknown,
where: string,
errors: string[],
): value is string {
if (typeof value !== "string" || value.trim().length === 0) {
errors.push(`Field "${where}" must be a non-empty string`);
return false;
}
return true;
}
export function validateManifest(manifest: unknown): string[] {
const errors: string[] = [];
if (!isPlainObject(manifest)) {
return ["Manifest must be a JSON object"];
}
const m = manifest;
rejectUnknown(m, ALLOWED_TOP_LEVEL, "the manifest", errors);
for (const field of REQUIRED_TOP_LEVEL) {
if (!(field in m)) errors.push(`Missing required field: "${field}"`);
}
if ("id" in m) {
if (typeof m.id !== "string" || !ID_PATTERN.test(m.id)) {
errors.push(
`Field "id" must match ${ID_PATTERN} (lowercase, starts with a letter, 2-40 chars), got: ${JSON.stringify(m.id)}`,
);
}
}
if ("name" in m) requireString(m.name, "name", errors);
if ("description" in m) requireString(m.description, "description", errors);
if ("license" in m) requireString(m.license, "license", errors);
if ("version" in m) {
if (typeof m.version !== "string" || !SEMVER_PATTERN.test(m.version)) {
errors.push(
`Field "version" must be valid semver, got: ${JSON.stringify(m.version)}`,
);
}
}
if ("repository" in m) requireString(m.repository, "repository", errors);
if ("icon" in m) requireString(m.icon, "icon", errors);
if ("backend" in m) requireString(m.backend, "backend", errors);
if ("frontend" in m) requireString(m.frontend, "frontend", errors);
if ("locales" in m) requireString(m.locales, "locales", errors);
validateAuthor(m.author, errors);
validateEngine(m.engine, errors);
validateCategory(m.category, errors);
validateCapabilities(m.capabilities, errors);
validatePlatforms(m.platforms, errors);
validateDependencyMap(m.dependencies, "dependencies", errors);
validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors);
validateProvides(m.provides, errors);
validateRequires(m.requires, errors);
validateProvidesSecret(m.providesSecret, errors);
validateRequiresSecret(m.requiresSecret, errors);
validateContributes(m.contributes, errors);
return errors;
}
function validateAuthor(author: unknown, errors: string[]): void {
if (author === undefined) return;
if (!isPlainObject(author)) {
errors.push('Field "author" must be an object');
return;
}
rejectUnknown(author, ["name", "url"], '"author"', errors);
requireString(author.name, "author.name", errors);
if ("url" in author) requireString(author.url, "author.url", errors);
}
function validateEngine(engine: unknown, errors: string[]): void {
if (engine === undefined) return;
if (!isPlainObject(engine)) {
errors.push('Field "engine" must be an object');
return;
}
rejectUnknown(engine, ["termix", "api"], '"engine"', errors);
requireString(engine.termix, "engine.termix", errors);
if (typeof engine.api !== "string" || !API_VERSION_PATTERN.test(engine.api)) {
errors.push(
`Field "engine.api" must be an integer string, got: ${JSON.stringify(engine.api)}`,
);
}
}
function validateCategory(category: unknown, errors: string[]): void {
if (category === undefined) return;
if (typeof category !== "string") {
errors.push('Field "category" must be a string');
return;
}
if (!PLUGIN_CATEGORIES.includes(category as never)) {
errors.push(
`Field "category" must be one of: ${PLUGIN_CATEGORIES.join(", ")}, got: "${category}"`,
);
}
}
function validateCapabilities(capabilities: unknown, errors: string[]): void {
if (capabilities === undefined) return;
if (!Array.isArray(capabilities)) {
errors.push('Field "capabilities" must be an array of capability ids');
return;
}
const seen = new Set<string>();
capabilities.forEach((capability, index) => {
if (typeof capability !== "string") {
errors.push(`capabilities[${index}] must be a string`);
return;
}
if (!isKnownCapability(capability)) {
errors.push(
`capabilities[${index}] is not a known capability: "${capability}"`,
);
return;
}
if (seen.has(capability)) {
errors.push(`capabilities[${index}] duplicates "${capability}"`);
}
seen.add(capability);
});
}
function validatePlatforms(platforms: unknown, errors: string[]): void {
if (platforms === undefined) return;
if (!Array.isArray(platforms) || platforms.length === 0) {
errors.push('Field "platforms" must be a non-empty array when present');
return;
}
platforms.forEach((platform, index) => {
if (!PLUGIN_PLATFORMS.includes(platform as never)) {
errors.push(
`platforms[${index}] must be one of: ${PLUGIN_PLATFORMS.join(", ")}, got: ${JSON.stringify(platform)}`,
);
}
});
}
function validateDependencyMap(
value: unknown,
field: string,
errors: string[],
): void {
if (value === undefined) return;
if (!isPlainObject(value)) {
errors.push(
`Field "${field}" must be an object of pluginId -> semver range`,
);
return;
}
for (const [pluginId, range] of Object.entries(value)) {
if (!ID_PATTERN.test(pluginId)) {
errors.push(`${field} key "${pluginId}" is not a valid plugin id`);
}
if (typeof range !== "string" || !semver.validRange(range)) {
errors.push(
`${field}["${pluginId}"] must be a valid semver range, got: ${JSON.stringify(range)}`,
);
}
}
}
function validateProvides(provides: unknown, errors: string[]): void {
if (provides === undefined) return;
if (!Array.isArray(provides)) {
errors.push('Field "provides" must be an array');
return;
}
const seen = new Set<string>();
provides.forEach((raw, index) => {
const where = `provides[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, ["service", "version", "permission"], where, errors);
if (typeof raw.service !== "string" || !SERVICE_PATTERN.test(raw.service)) {
errors.push(`${where}.service must be a dotted lowercase name`);
} else {
if (seen.has(raw.service)) {
errors.push(`${where}.service duplicates "${raw.service}"`);
}
seen.add(raw.service);
}
if (typeof raw.version !== "string" || !SEMVER_PATTERN.test(raw.version)) {
errors.push(`${where}.version must be valid semver`);
}
requireString(raw.permission, `${where}.permission`, errors);
});
}
function validateRequires(requires: unknown, errors: string[]): void {
if (requires === undefined) return;
if (!Array.isArray(requires)) {
errors.push('Field "requires" must be an array');
return;
}
requires.forEach((raw, index) => {
const where = `requires[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, ["service", "versionRange", "optional"], where, errors);
if (typeof raw.service !== "string" || !SERVICE_PATTERN.test(raw.service)) {
errors.push(`${where}.service must be a dotted lowercase name`);
}
if (
typeof raw.versionRange !== "string" ||
!semver.validRange(raw.versionRange)
) {
errors.push(`${where}.versionRange must be a valid semver range`);
}
if ("optional" in raw && typeof raw.optional !== "boolean") {
errors.push(`${where}.optional must be a boolean`);
}
});
}
function validateProvidesSecret(value: unknown, errors: string[]): void {
if (value === undefined) return;
if (!Array.isArray(value)) {
errors.push('Field "providesSecret" must be an array');
return;
}
const seen = new Set<string>();
value.forEach((raw, index) => {
const where = `providesSecret[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, ["key", "permission", "descriptionKey"], where, errors);
if (typeof raw.key !== "string" || !SECRET_KEY_PATTERN.test(raw.key)) {
errors.push(`${where}.key must be a lowercase slug`);
} else {
if (seen.has(raw.key))
errors.push(`${where}.key duplicates "${raw.key}"`);
seen.add(raw.key);
}
requireString(raw.permission, `${where}.permission`, errors);
if ("descriptionKey" in raw) {
requireString(raw.descriptionKey, `${where}.descriptionKey`, errors);
}
});
}
function validateRequiresSecret(value: unknown, errors: string[]): void {
if (value === undefined) return;
if (!Array.isArray(value)) {
errors.push('Field "requiresSecret" must be an array');
return;
}
value.forEach((raw, index) => {
const where = `requiresSecret[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, ["plugin", "key", "optional"], where, errors);
if (typeof raw.plugin !== "string" || !ID_PATTERN.test(raw.plugin)) {
errors.push(`${where}.plugin must be a valid plugin id`);
}
if (typeof raw.key !== "string" || !SECRET_KEY_PATTERN.test(raw.key)) {
errors.push(`${where}.key must be a lowercase slug`);
}
if ("optional" in raw && typeof raw.optional !== "boolean") {
errors.push(`${where}.optional must be a boolean`);
}
});
}
function validateContributes(contributes: unknown, errors: string[]): void {
if (contributes === undefined) return;
if (!isPlainObject(contributes)) {
errors.push('Field "contributes" must be an object');
return;
}
rejectUnknown(contributes, ALLOWED_CONTRIBUTES, '"contributes"', errors);
validateTabs(contributes.tabs, errors);
validatePermissionGroup(contributes.permissionGroup, errors);
validateActions(contributes.actions, errors);
validateActionSlots(contributes.actionSlots, errors);
validateHostCapability(contributes.hostCapability, errors);
}
function validateTabs(tabs: unknown, errors: string[]): void {
if (tabs === undefined) return;
if (!Array.isArray(tabs)) {
errors.push('Field "contributes.tabs" must be an array');
return;
}
const seen = new Set<string>();
tabs.forEach((raw, index) => {
const where = `contributes.tabs[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, ["id", "titleKey", "icon", "openFrom"], where, errors);
if (requireString(raw.id, `${where}.id`, errors)) {
if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`);
seen.add(raw.id);
}
requireString(raw.titleKey, `${where}.titleKey`, errors);
requireString(raw.icon, `${where}.icon`, errors);
if (!Array.isArray(raw.openFrom) || raw.openFrom.length === 0) {
errors.push(`${where}.openFrom must be a non-empty array`);
return;
}
raw.openFrom.forEach((value: unknown, openIndex: number) => {
if (!OPEN_FROM_VALUES.includes(value as never)) {
errors.push(
`${where}.openFrom[${openIndex}] must be one of: ${OPEN_FROM_VALUES.join(", ")}`,
);
}
});
});
}
function validatePermissionGroup(group: unknown, errors: string[]): void {
if (group === undefined) return;
const where = "contributes.permissionGroup";
if (!isPlainObject(group)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(
group,
["group", "permissions", "defaultForRole"],
where,
errors,
);
requireString(group.group, `${where}.group`, errors);
if (!Array.isArray(group.permissions) || group.permissions.length === 0) {
errors.push(`${where}.permissions must be a non-empty array`);
} else {
group.permissions.forEach((permission: unknown, index: number) => {
if (
typeof permission !== "string" ||
!PERMISSION_PATTERN.test(permission)
) {
errors.push(
`${where}.permissions[${index}] must be a dotted permission name`,
);
}
});
}
if (group.defaultForRole === undefined) return;
if (!isPlainObject(group.defaultForRole)) {
errors.push(
`${where}.defaultForRole must be an object of role -> permissions`,
);
return;
}
const declared = new Set(
Array.isArray(group.permissions)
? (group.permissions as unknown[]).filter(
(value): value is string => typeof value === "string",
)
: [],
);
for (const [role, wanted] of Object.entries(group.defaultForRole)) {
if (!Array.isArray(wanted)) {
errors.push(`${where}.defaultForRole["${role}"] must be an array`);
continue;
}
wanted.forEach((permission: unknown, index: number) => {
if (typeof permission !== "string") {
errors.push(
`${where}.defaultForRole["${role}"][${index}] must be a string`,
);
return;
}
// A plugin may only suggest defaults for permissions it declares itself.
// Without this a manifest could add admin.* to any role at boot.
if (!declared.has(permission)) {
errors.push(
`${where}.defaultForRole["${role}"][${index}] is "${permission}", which this plugin does not declare in contributes.permissionGroup.permissions`,
);
}
});
}
}
function validateActions(actions: unknown, errors: string[]): void {
if (actions === undefined) return;
if (!Array.isArray(actions)) {
errors.push('Field "contributes.actions" must be an array');
return;
}
const seen = new Set<string>();
actions.forEach((raw, index) => {
const where = `contributes.actions[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(
raw,
["id", "titleKey", "handler", "icon", "permission", "slot", "kind"],
where,
errors,
);
if (typeof raw.id !== "string" || !ACTION_ID_PATTERN.test(raw.id)) {
errors.push(`${where}.id must be a dotted action id`);
} else {
if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`);
seen.add(raw.id);
}
requireString(raw.titleKey, `${where}.titleKey`, errors);
if (typeof raw.handler !== "string" || !HANDLER_PATTERN.test(raw.handler)) {
errors.push(`${where}.handler must be a JavaScript identifier`);
}
if ("icon" in raw) requireString(raw.icon, `${where}.icon`, errors);
if ("permission" in raw) {
requireString(raw.permission, `${where}.permission`, errors);
}
if ("slot" in raw) requireString(raw.slot, `${where}.slot`, errors);
if (
"kind" in raw &&
!ACTION_CONTRIBUTION_KINDS.includes(raw.kind as never)
) {
errors.push(
`${where}.kind must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`,
);
}
});
}
function validateActionSlots(slots: unknown, errors: string[]): void {
if (slots === undefined) return;
if (!Array.isArray(slots)) {
errors.push('Field "contributes.actionSlots" must be an array');
return;
}
const seen = new Set<string>();
slots.forEach((raw, index) => {
const where = `contributes.actionSlots[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, ["id", "accepts", "descriptionKey"], where, errors);
if (typeof raw.id !== "string" || !ACTION_ID_PATTERN.test(raw.id)) {
errors.push(`${where}.id must be a dotted slot id`);
} else {
if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`);
seen.add(raw.id);
}
if (!Array.isArray(raw.accepts) || raw.accepts.length === 0) {
errors.push(`${where}.accepts must be a non-empty array`);
} else {
raw.accepts.forEach((kind: unknown, kindIndex: number) => {
if (!ACTION_CONTRIBUTION_KINDS.includes(kind as never)) {
errors.push(
`${where}.accepts[${kindIndex}] must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`,
);
}
});
}
if ("descriptionKey" in raw) {
requireString(raw.descriptionKey, `${where}.descriptionKey`, errors);
}
});
}
function validateHostCapability(value: unknown, errors: string[]): void {
if (value === undefined) return;
const entries = Array.isArray(value) ? value : [value];
if (Array.isArray(value) && value.length === 0) {
errors.push(
'Field "contributes.hostCapability" must not be an empty array',
);
return;
}
const seen = new Set<string>();
entries.forEach((raw, index) => {
const where = Array.isArray(value)
? `contributes.hostCapability[${index}]`
: "contributes.hostCapability";
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, ["key", "labelKey", "editorTab"], where, errors);
if (requireString(raw.key, `${where}.key`, errors)) {
if (seen.has(raw.key))
errors.push(`${where}.key duplicates "${raw.key}"`);
seen.add(raw.key);
}
requireString(raw.labelKey, `${where}.labelKey`, errors);
requireString(raw.editorTab, `${where}.editorTab`, errors);
});
}
export interface ParsedManifest {
manifest?: PluginManifest;
errors: string[];
}
/**
* Validates, then applies the cross-field rules a JSON schema cannot express
* and fills in the entry-point defaults.
*/
export function parseManifest(raw: unknown): ParsedManifest {
const errors = validateManifest(raw);
if (errors.length > 0) return { errors };
const manifest = raw as PluginManifest;
if (manifest.engine.api !== SUPPORTED_PLUGIN_API_VERSION) {
return {
errors: [
`Plugin targets SDK API version ${manifest.engine.api}, but this build implements ${SUPPORTED_PLUGIN_API_VERSION}`,
],
};
}
const declared = new Set(
manifest.contributes?.permissionGroup?.permissions ?? [],
);
// A permission the catalog never sees is one no admin can grant, so the
// service or action would be invisible rather than denied.
for (const provide of manifest.provides ?? []) {
if (!declared.has(provide.permission)) {
errors.push(
`provides["${provide.service}"].permission "${provide.permission}" is not declared in contributes.permissionGroup.permissions`,
);
}
}
for (const secret of manifest.providesSecret ?? []) {
if (!declared.has(secret.permission)) {
errors.push(
`providesSecret["${secret.key}"].permission "${secret.permission}" is not declared in contributes.permissionGroup.permissions`,
);
}
}
for (const action of manifest.contributes?.actions ?? []) {
if (action.permission && !declared.has(action.permission)) {
errors.push(
`contributes.actions["${action.id}"].permission "${action.permission}" is not declared in contributes.permissionGroup.permissions`,
);
}
}
for (const secret of manifest.requiresSecret ?? []) {
if (secret.plugin === manifest.id) {
errors.push(
`requiresSecret["${secret.key}"] points at this plugin; use ctx.secrets.get instead`,
);
}
}
for (const dependency of Object.keys(manifest.dependencies ?? {})) {
if (dependency === manifest.id) {
errors.push(`dependencies["${dependency}"] points at this plugin`);
}
if (manifest.optionalDependencies?.[dependency]) {
errors.push(
`"${dependency}" is listed in both dependencies and optionalDependencies`,
);
}
}
for (const dependency of Object.keys(manifest.optionalDependencies ?? {})) {
if (dependency === manifest.id) {
errors.push(
`optionalDependencies["${dependency}"] points at this plugin`,
);
}
}
if (errors.length > 0) return { errors };
return {
manifest: {
...manifest,
backend: manifest.backend ?? DEFAULT_BACKEND_ENTRY,
frontend: manifest.frontend ?? DEFAULT_FRONTEND_ENTRY,
locales: manifest.locales ?? DEFAULT_LOCALES_DIR,
},
errors: [],
};
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Test helpers for plugin authors.
*
* A2 adds the shared vitest preset each plugin's config extends. For now this
* exposes the one thing a plugin test needs before that lands: a context
* double that satisfies the PluginContext contract without a running server.
*/
import type {
PluginContext,
PluginDisposables,
PluginLogger,
} from "./backend.js";
import type { PluginManifest } from "./manifest.js";
export interface FakeContextOptions {
pluginId?: string;
manifest?: Partial<PluginManifest>;
/** Acting user returned by currentActor and used by asUser. */
actor?: string;
}
export interface FakePluginContext {
ctx: PluginContext;
/** Everything the plugin registered through ctx.disposables. */
disposals: Array<() => void | Promise<void>>;
/** Topics emitted through ctx.events.emit, in order. */
emitted: Array<{ topic: string; payload: unknown }>;
/** Backing store behind ctx.kv. */
kv: Map<string, unknown>;
}
function noopLogger(): PluginLogger {
return {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
}
/**
* A context double good enough to call activate() against in a unit test.
* Nothing here is capability-checked: a test asserting a capability gate
* should use the real runtime, not this.
*/
export function createFakeContext(
options: FakeContextOptions = {},
): FakePluginContext {
const pluginId = options.pluginId ?? "test-plugin";
const disposals: Array<() => void | Promise<void>> = [];
const emitted: Array<{ topic: string; payload: unknown }> = [];
const kv = new Map<string, unknown>();
const listeners = new Map<string, Set<(payload: unknown) => void>>();
let actor = options.actor;
const manifest = {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: [],
...options.manifest,
} as PluginManifest;
const disposables: PluginDisposables = {
add: (dispose) => disposals.push(dispose),
};
const ctx: PluginContext = {
pluginId,
manifest,
log: noopLogger(),
events: {
emit: (topic, payload) => {
emitted.push({ topic, payload });
for (const listener of listeners.get(topic) ?? []) listener(payload);
},
on: (topic, listener) => {
let set = listeners.get(topic);
if (!set) {
set = new Set();
listeners.set(topic, set);
}
set.add(listener);
return () => set!.delete(listener);
},
},
kv: {
get: async (key) => (kv.has(key) ? kv.get(key) : null),
set: async (key, value) => {
kv.set(key, value);
},
delete: async (key) => kv.delete(key),
list: async () => [...kv.keys()],
},
registry: {
provide: () => {},
consume: () => undefined,
revoke: () => false,
},
services: {
provide: () => {},
get: () => {
throw new Error("createFakeContext does not implement services.get");
},
},
secrets: {
offer: () => {},
withdraw: () => false,
getShared: async () => null,
},
disposables,
asUser: async (userId, fn) => {
const previous = actor;
actor = userId;
try {
return await fn();
} finally {
actor = previous;
}
},
currentActor: () => actor,
};
return { ctx, disposals, emitted, kv };
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"tsBuildInfoFile": "../../node_modules/.tmp/tsconfig.plugin-sdk.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "nodenext",
"moduleResolution": "nodenext",
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"esModuleInterop": true,
"declaration": true,
"noEmit": false,
"rootDir": "./src",
"outDir": "./dist",
"strict": false,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}
+43 -28
View File
@@ -3,19 +3,38 @@
"name": "AI Assistant",
"version": "1.0.0",
"description": "An AI assistant that can read your hosts, snippets, fleets, alerts and automations, and propose changes for you to approve.",
"author": { "name": "Termix" },
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Productivity",
"icon": "Sparkles",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"permissions": ["process:transport-owner"],
"capabilities": [
"hosts:read",
"hosts:write",
"credentials:use",
"ssh:connect",
"db:own",
"secrets:own",
"network:outbound",
"audit:read",
"ui:surface"
],
"dependencies": {
"automations": "^1.0.0"
},
"providesSecret": [
{
"key": "api-key",
"permission": "ai.secrets.share",
"descriptionKey": "ai.secrets.shareApiKey"
}
],
"contributes": {
"tabs": [
{
@@ -25,6 +44,17 @@
"openFrom": ["rail", "palette"]
}
],
"actions": [
{
"id": "ai.openWithContext",
"titleKey": "ai.assistant",
"handler": "openWithContext",
"icon": "Bot",
"permission": "ai.services.use",
"slot": "terminal.toolbar",
"kind": "button"
}
],
"permissionGroup": {
"group": "ai",
"permissions": [
@@ -37,25 +67,10 @@
"defaultForRole": {
"admin": ["ai.services.use"]
}
},
"actions": [
{
"id": "ai.openWithContext",
"titleKey": "ai.assistant",
"handler": "openWithContext",
"icon": "Bot",
"permission": "ai.services.use",
"slot": "terminal.toolbar",
"kind": "button"
}
]
},
"providesSecret": [
{
"key": "api-key",
"permission": "ai.secrets.share",
"descriptionKey": "ai.secrets.shareApiKey"
}
],
"sidecars": []
},
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+21 -9
View File
@@ -3,18 +3,30 @@
"name": "Automations",
"version": "1.0.0",
"description": "Trigger-and-action automation engine: schedules, metric/status/docker-event triggers, and multi-step actions across managed hosts.",
"author": { "name": "Termix" },
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Infrastructure",
"icon": "Workflow",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"permissions": ["process:transport-owner"],
"sidecars": []
"capabilities": [
"hosts:read",
"hosts:write",
"credentials:use",
"ssh:connect",
"db:own",
"events:core",
"notify:send",
"network:outbound",
"ui:surface"
],
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+68 -19
View File
@@ -45,17 +45,57 @@ interface SSHSession {
const activeSessions = new Map<string, SSHSession>();
const wss = new WebSocketServer({
host: "127.0.0.1",
port: 30009,
maxPayload: MAX_WS_MESSAGE_BYTES,
});
const CONSOLE_PORT = 30009;
wss.on("error", (error) => {
sshLogger.error("Docker console WebSocket server error", error, {
operation: "wss_error",
/**
* Created by startConsoleServer(), not at module load.
*
* Importing this file used to bind the port as a side effect, which meant the
* plugin could not be disabled and re-enabled: the module stays in the ESM
* cache, so the second activate() got a server that had already been closed.
*/
let wss: WebSocketServer | null = null;
function handleConnection(ws: WebSocket, req: import("http").IncomingMessage) {
void onConsoleConnection(ws, req);
}
/** Starts the console WebSocket server. Call from the plugin's activate(). */
export function startConsoleServer(): Promise<void> {
if (wss) return Promise.resolve();
return new Promise((resolve, reject) => {
const server = new WebSocketServer({
host: "127.0.0.1",
port: CONSOLE_PORT,
maxPayload: MAX_WS_MESSAGE_BYTES,
});
const onStartupError = (error: Error) => {
server.off("listening", onListening);
wss = null;
reject(error);
};
const onListening = () => {
server.off("error", onStartupError);
// Past startup an error is logged rather than fatal: one bad socket
// must not take the server down.
server.on("error", (error) => {
sshLogger.error("Docker console WebSocket server error", error, {
operation: "wss_error",
});
});
resolve();
};
server.once("error", onStartupError);
server.once("listening", onListening);
server.on("connection", handleConnection);
wss = server;
});
});
}
async function detectShell(
session: SSHSession,
@@ -296,7 +336,10 @@ async function createJumpHostChain(
return currentClient;
}
wss.on("connection", async (ws: WebSocket, req) => {
async function onConsoleConnection(
ws: WebSocket,
req: import("http").IncomingMessage,
) {
const token = extractWebSocketToken(req);
if (!token) {
@@ -824,12 +867,17 @@ wss.on("connection", async (ws: WebSocket, req) => {
cleanup();
});
});
}
/**
* Closes every live console session and the WebSocket server. Called by the
* plugin's deactivate() as well as SIGTERM, since disabling the docker
* plugin has to free port 30009 the same way process shutdown does.
* Closes every live console session and the WebSocket server, freeing port
* 30009. Called from the plugin's deactivate().
*
* There is deliberately no process signal handler here. Core owns shutdown:
* gracefulShutdown in src/backend/starter.ts calls shutdownPlugins(), which
* runs every plugin's deactivate. A plugin calling process.exit() itself
* skipped the rest of that sequence, including other plugins and the
* database flush.
*/
export function closeConsoleServer(): Promise<void> {
activeSessions.forEach((session) => {
@@ -841,9 +889,10 @@ export function closeConsoleServer(): Promise<void> {
activeSessions.clear();
return new Promise((resolve) => wss.close(() => resolve()));
}
const server = wss;
wss = null;
if (!server) return Promise.resolve();
process.on("SIGTERM", () => {
void closeConsoleServer().then(() => process.exit(0));
});
server.off("connection", handleConnection);
return new Promise((resolve) => server.close(() => resolve()));
}
+4 -3
View File
@@ -118,10 +118,11 @@ export async function activate(ctx) {
});
}
// Importing console.js binds its WebSocket server on port 30009 as a
// module-scope side effect -- unchanged from before the move, see that
// file's own header.
// Importing console.js no longer binds anything: the server is started
// here so disabling the plugin frees port 30009 and re-enabling can take
// it again, even though the module stays in the ESM cache.
consoleModule = await loadRelative("console");
await consoleModule.startConsoleServer();
ctx.log.info(`Docker plugin listening on ${port} (console on 30009)`);
}
+18 -9
View File
@@ -3,19 +3,25 @@
"name": "Docker",
"version": "1.0.0",
"description": "Container management over SSH: list, inspect, start, stop and view logs for Docker or Podman containers, plus an interactive container console.",
"author": { "name": "Termix" },
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Infrastructure",
"icon": "Box",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"permissions": ["process:transport-owner"],
"capabilities": [
"hosts:read",
"credentials:use",
"ssh:connect",
"network:serve",
"audit:read",
"ui:surface"
],
"contributes": {
"tabs": [
{
@@ -31,5 +37,8 @@
"editorTab": "docker"
}
},
"sidecars": []
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+17 -9
View File
@@ -3,18 +3,26 @@
"name": "Fleets",
"version": "1.0.0",
"description": "Group hosts into fleets by static membership or tag rules, and run commands, package actions, and file transfers across every member at once.",
"author": { "name": "Termix" },
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Infrastructure",
"icon": "Boxes",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"permissions": ["process:transport-owner"],
"sidecars": []
"capabilities": [
"hosts:read",
"credentials:use",
"ssh:connect",
"db:own",
"ui:surface"
],
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+19 -9
View File
@@ -3,19 +3,26 @@
"name": "Host Metrics",
"version": "1.0.0",
"description": "Live CPU, memory, disk, network and process metrics, plus host management (services, packages, firewall, cron, users, SSL, logs, WireGuard, Tailscale).",
"author": { "name": "Termix" },
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Monitoring",
"icon": "Activity",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"permissions": ["process:transport-owner"],
"capabilities": [
"hosts:read",
"credentials:use",
"ssh:connect",
"db:own",
"network:serve",
"events:core",
"ui:surface"
],
"contributes": {
"tabs": [
{
@@ -26,5 +33,8 @@
}
]
},
"sidecars": []
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+11 -12
View File
@@ -3,19 +3,18 @@
"name": "Network Topology",
"version": "1.0.0",
"description": "Visualize hosts and their connections as an interactive graph, saved per user.",
"author": { "name": "Termix" },
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Infrastructure",
"icon": "Network",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"permissions": ["process:transport-owner"],
"capabilities": ["db:own", "ui:surface"],
"contributes": {
"tabs": [
{
@@ -24,10 +23,10 @@
"icon": "Network",
"openFrom": ["rail", "palette"]
}
],
"dashboardCards": [
{ "id": "network_graph", "titleKey": "dashboard.networkGraph" }
]
},
"sidecars": []
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+18 -9
View File
@@ -3,19 +3,25 @@
"name": "Proxmox",
"version": "1.0.0",
"description": "Discover Proxmox VE guests over an existing SSH host, import them as Termix hosts, and keep them in sync.",
"author": { "name": "Termix" },
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Infrastructure",
"icon": "Server",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"permissions": ["process:transport-owner"],
"capabilities": [
"hosts:read",
"hosts:write",
"credentials:use",
"ssh:connect",
"db:own",
"ui:surface"
],
"contributes": {
"hostCapability": {
"key": "enableProxmox",
@@ -23,5 +29,8 @@
"editorTab": "proxmox"
}
},
"sidecars": []
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+28 -11
View File
@@ -3,19 +3,25 @@
"name": "Remote Desktop",
"version": "1.0.0",
"description": "RDP, VNC and Telnet remote desktop sessions, with clipboard, drive redirection, session recording and session sharing.",
"author": { "name": "Termix" },
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Plugin-RemoteDesktop",
"category": "Terminal",
"icon": "Monitor",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"permissions": ["process:transport-owner"],
"capabilities": [
"hosts:read",
"credentials:use",
"ssh:connect",
"network:serve",
"audit:read",
"ui:surface"
],
"contributes": {
"tabs": [
{
@@ -38,8 +44,16 @@
}
],
"hostCapability": [
{ "key": "enableRdp", "labelKey": "hosts.tabRdp", "editorTab": "rdp" },
{ "key": "enableVnc", "labelKey": "hosts.tabVnc", "editorTab": "vnc" },
{
"key": "enableRdp",
"labelKey": "hosts.tabRdp",
"editorTab": "rdp"
},
{
"key": "enableVnc",
"labelKey": "hosts.tabVnc",
"editorTab": "vnc"
},
{
"key": "enableTelnet",
"labelKey": "hosts.tabTelnet",
@@ -47,5 +61,8 @@
}
]
},
"sidecars": []
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+17 -9
View File
@@ -3,19 +3,24 @@
"name": "SSH Terminal",
"version": "1.0.0",
"description": "Interactive SSH terminal sessions, with jump hosts, host key verification and session sharing.",
"author": { "name": "Termix" },
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Terminal",
"icon": "SquareTerminal",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"permissions": ["process:transport-owner"],
"capabilities": [
"hosts:read",
"credentials:use",
"ssh:connect",
"network:serve",
"ui:surface"
],
"contributes": {
"tabs": [
{
@@ -32,5 +37,8 @@
}
]
},
"sidecars": []
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+73 -52
View File
@@ -5,6 +5,7 @@
import { Router } from "express";
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
import { PermissionManager } from "../../../src/backend/utils/permission-manager.js";
import { apiLogger } from "../../../src/backend/utils/logger.js";
import { fetchWithProxy } from "../../../src/backend/utils/proxy-agent.js";
import { createCurrentSettingsRepository } from "../../../src/backend/database/repositories/factory.js";
@@ -39,6 +40,16 @@ export const router = Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* Listing the tailnet uses the admin-stored API key, so it needs more than a
* session. Writing the key is already admin-only and reading it back is
* masked, but using it to enumerate every device was open to any logged-in
* user, which handed out an internal network inventory.
*/
const requireDeviceAccess = PermissionManager.getInstance().requirePermission(
"tailscale.devices.view",
);
function normalizeApiBase(raw: string | null): string {
const trimmed = (raw ?? "").trim().replace(/\/+$/, "");
return trimmed || DEFAULT_TAILSCALE_API_BASE;
@@ -49,12 +60,17 @@ function normalizeApiBase(raw: string | null): string {
* /tailscale/devices:
* get:
* summary: List Tailscale devices
* description: Returns the list of devices in the configured tailnet using the stored API key.
* description: >
* Returns the devices in the configured tailnet using the stored admin
* API key. Requires the tailscale.devices.view permission, because the
* response is a full inventory of the tailnet.
* tags:
* - Tailscale
* responses:
* 200:
* description: List of tailnet devices.
* 403:
* description: The caller lacks tailscale.devices.view.
* content:
* application/json:
* schema:
@@ -67,67 +83,72 @@ function normalizeApiBase(raw: string | null): string {
* 500:
* description: Failed to fetch Tailscale devices.
*/
router.get("/devices", authenticateJWT, async (_req, res) => {
try {
const settingsRepo = createCurrentSettingsRepository();
const apiKey = (await settingsRepo.get("tailscale_api_key")) ?? "";
if (!apiKey) {
return res.json({ devices: [], hasApiKey: false });
}
const apiBase = normalizeApiBase(
await settingsRepo.get("tailscale_api_base_url"),
);
router.get(
"/devices",
authenticateJWT,
requireDeviceAccess,
async (_req, res) => {
try {
const settingsRepo = createCurrentSettingsRepository();
const apiKey = (await settingsRepo.get("tailscale_api_key")) ?? "";
if (!apiKey) {
return res.json({ devices: [], hasApiKey: false });
}
const apiBase = normalizeApiBase(
await settingsRepo.get("tailscale_api_base_url"),
);
const url = `${apiBase}/tailnet/-/devices?fields=all`;
const response = await fetchWithProxy(url, {
headers: {
Authorization: `Bearer ${apiKey}`,
"User-Agent": "Termix/1.0",
},
});
if (!response.ok) {
apiLogger.warn("Tailscale API returned non-OK status", {
operation: "tailscale_devices",
status: response.status,
const url = `${apiBase}/tailnet/-/devices?fields=all`;
const response = await fetchWithProxy(url, {
headers: {
Authorization: `Bearer ${apiKey}`,
"User-Agent": "Termix/1.0",
},
});
if (response.status === 401 || response.status === 403) {
return res.status(401).json({
error: "Invalid Tailscale API key",
if (!response.ok) {
apiLogger.warn("Tailscale API returned non-OK status", {
operation: "tailscale_devices",
status: response.status,
});
if (response.status === 401 || response.status === 403) {
return res.status(401).json({
error: "Invalid Tailscale API key",
devices: [],
hasApiKey: true,
});
}
return res.status(502).json({
error: "Tailscale API error",
devices: [],
hasApiKey: true,
});
}
return res.status(502).json({
error: "Tailscale API error",
const data = (await response.json()) as { devices: TailscaleAPIDevice[] };
const devices: TailscaleDevice[] = (data.devices ?? []).map((d) => ({
id: d.id,
name: d.name,
hostname: d.hostname,
addresses: d.addresses ?? [],
os: d.os,
lastSeen: d.lastSeen,
}));
res.json({ devices, hasApiKey: true });
} catch (err) {
apiLogger.error("Failed to fetch Tailscale devices", err, {
operation: "tailscale_devices",
});
res.status(500).json({
error: "Failed to fetch Tailscale devices",
devices: [],
hasApiKey: true,
});
}
const data = (await response.json()) as { devices: TailscaleAPIDevice[] };
const devices: TailscaleDevice[] = (data.devices ?? []).map((d) => ({
id: d.id,
name: d.name,
hostname: d.hostname,
addresses: d.addresses ?? [],
os: d.os,
lastSeen: d.lastSeen,
}));
res.json({ devices, hasApiKey: true });
} catch (err) {
apiLogger.error("Failed to fetch Tailscale devices", err, {
operation: "tailscale_devices",
});
res.status(500).json({
error: "Failed to fetch Tailscale devices",
devices: [],
hasApiKey: true,
});
}
});
},
);
/** Called from activate(). Mounts this router at /tailscale via the shared
* dispatcher. */
+17 -9
View File
@@ -3,18 +3,26 @@
"name": "Tailscale",
"version": "1.0.0",
"description": "Browse and connect to tailnet devices, manage the Tailscale API key, and show the Tailscale SSH re-authentication dialog.",
"author": { "name": "Termix" },
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Networking",
"icon": "Network",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"permissions": ["process:transport-owner"],
"sidecars": []
"capabilities": ["settings:read-core", "network:outbound", "ui:surface"],
"contributes": {
"permissionGroup": {
"group": "tailscale",
"permissions": ["tailscale.devices.view"]
}
},
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+16 -9
View File
@@ -3,19 +3,23 @@
"name": "Web Endpoint",
"version": "1.0.0",
"description": "Open a host's web UI in a tab or an isolated desktop window, directly or over an SSH tunnel.",
"author": { "name": "Termix" },
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Plugin-WebEndpoint",
"category": "Networking",
"icon": "Globe",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"permissions": ["process:transport-owner"],
"capabilities": [
"hosts:read",
"credentials:use",
"ssh:connect",
"ui:surface"
],
"contributes": {
"tabs": [
{
@@ -31,5 +35,8 @@
"editorTab": "general"
}
},
"sidecars": []
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+11 -9
View File
@@ -3,18 +3,20 @@
"name": "Workspaces",
"version": "1.0.0",
"description": "Save and restore named tab/split layouts per user.",
"author": { "name": "Termix" },
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Productivity",
"icon": "LayoutTemplate",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"permissions": ["process:transport-owner"],
"sidecars": []
"capabilities": ["db:own", "ui:surface"],
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
@@ -1,10 +1,10 @@
{
"id": "sample-bad-action",
"name": "Sample Bad Action",
"id": "sample-plugin",
"name": "Sample",
"version": "1.0.0",
"description": "An action gated by a permission the plugin never declares.",
"description": "A sample.",
"author": {
"name": "Jane Doe"
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
@@ -12,26 +12,19 @@
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": {
"backend": false,
"frontend": true,
"electron": false,
"platforms": ["linux", "win32", "darwin"]
},
"permissions": ["ui.tab"],
"capabilities": ["kv:own", "ui:surface"],
"contributes": {
"permissionGroup": {
"group": "samplebadaction",
"permissions": ["samplebadaction.use"]
"group": "sample-plugin",
"permissions": ["sample-plugin.use"]
},
"actions": [
{
"id": "samplebadaction.open",
"titleKey": "sampleBadAction.open",
"id": "sample.open",
"titleKey": "sample.open",
"handler": "open",
"permission": "samplebadaction.never_declared"
"permission": "sample-plugin.nope"
}
]
},
"sidecars": []
}
}
@@ -1,10 +1,10 @@
{
"id": "Sample_Plugin!",
"name": "Sample Plugin",
"id": "Sample_Plugin",
"name": "Sample",
"version": "1.0.0",
"description": "Has an id that violates the required pattern.",
"description": "A sample.",
"author": {
"name": "Jane Doe"
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
@@ -12,12 +12,5 @@
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": {
"backend": true,
"frontend": true,
"electron": false,
"platforms": ["linux"]
},
"permissions": [],
"sidecars": []
"capabilities": ["kv:own", "ui:surface"]
}
@@ -1,9 +1,9 @@
{
"id": "sample-plugin",
"name": "Sample Plugin",
"name": "Sample",
"version": "1.0.0",
"description": "A sample.",
"author": {
"name": "Jane Doe"
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
@@ -11,12 +11,5 @@
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": {
"backend": true,
"frontend": true,
"electron": false,
"platforms": ["linux"]
},
"permissions": [],
"sidecars": []
"capabilities": ["kv:own", "ui:surface"]
}
@@ -0,0 +1,27 @@
{
"id": "sample-plugin",
"name": "Sample",
"version": "1.0.0",
"description": "A sample.",
"author": {
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": ["kv:own", "ui:surface"],
"contributes": {
"tabs": [
{
"id": "t",
"titleKey": "k",
"icon": "i",
"openFrom": ["rail"],
"extra": 1
}
]
}
}
@@ -0,0 +1,25 @@
{
"id": "sample-plugin",
"name": "Sample",
"version": "1.0.0",
"description": "A sample.",
"author": {
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": ["kv:own", "ui:surface"],
"contributes": {
"permissionGroup": {
"group": "sample-plugin",
"permissions": ["sample-plugin.use"],
"defaultForRole": {
"user": ["admin.users.manage"]
}
}
}
}
@@ -1,10 +1,10 @@
{
"id": "sample-provider",
"name": "Sample Provider",
"id": "sample-plugin",
"name": "Sample",
"version": "1.0.0",
"description": "Gates a service behind a permission its own group never declares.",
"description": "A sample.",
"author": {
"name": "Jane Doe"
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
@@ -12,25 +12,18 @@
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": {
"backend": true,
"frontend": false,
"electron": false,
"platforms": ["linux"]
},
"permissions": ["hosts.read"],
"contributes": {
"permissionGroup": {
"group": "sampleprovider",
"permissions": ["sampleprovider.greet.use"]
}
},
"capabilities": ["kv:own", "ui:surface"],
"provides": [
{
"service": "sampleprovider.greet",
"version": "1.2.0",
"permission": "sampleprovider.undeclared"
"service": "sample.thing",
"version": "1.0.0",
"permission": "sample-plugin.undeclared"
}
],
"sidecars": []
"contributes": {
"permissionGroup": {
"group": "sample-plugin",
"permissions": ["sample-plugin.use"]
}
}
}
@@ -0,0 +1,16 @@
{
"id": "sample-plugin",
"name": "Sample",
"version": "1.0.0",
"description": "A sample.",
"author": {
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": ["hosts.read"]
}
@@ -1,23 +1,16 @@
{
"id": "sample-plugin",
"name": "Sample Plugin",
"name": "Sample",
"version": "1.0.0",
"description": "Has a category that is not in the known category list.",
"description": "A sample.",
"author": {
"name": "Jane Doe"
"name": "Termix"
},
"license": "MIT",
"category": "Miscellaneous",
"category": "Nonsense",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": {
"backend": true,
"frontend": true,
"electron": false,
"platforms": ["linux"]
},
"permissions": [],
"sidecars": []
"capabilities": ["kv:own", "ui:surface"]
}
@@ -0,0 +1,17 @@
{
"id": "sample-plugin",
"name": "Sample",
"version": "1.0.0",
"description": "A sample.",
"author": {
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": ["kv:own", "ui:surface"],
"sudoEverything": true
}
@@ -1,23 +0,0 @@
{
"id": "sample-plugin",
"name": "Sample Plugin",
"version": "1.0.0",
"description": "Requests a permission that is not in the known capability list.",
"author": {
"name": "Jane Doe"
},
"license": "MIT",
"category": "Productivity",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": {
"backend": true,
"frontend": true,
"electron": false,
"platforms": ["linux"]
},
"permissions": ["credentials.read"],
"sidecars": []
}
@@ -1,10 +1,10 @@
{
"id": "sample-actions",
"name": "Sample Actions",
"id": "sample-plugin",
"name": "Sample",
"version": "1.0.0",
"description": "A plugin that contributes a UI action and offers a slot of its own.",
"description": "A sample.",
"author": {
"name": "Jane Doe"
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
@@ -12,41 +12,19 @@
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": {
"backend": false,
"frontend": true,
"electron": false,
"platforms": ["linux", "win32", "darwin"]
},
"permissions": ["ui.tab"],
"capabilities": ["kv:own", "ui:surface"],
"contributes": {
"permissionGroup": {
"group": "sampleactions",
"permissions": ["sampleactions.use"]
"group": "sample-plugin",
"permissions": ["sample-plugin.use"]
},
"actions": [
{
"id": "sampleactions.openWithContext",
"titleKey": "sampleActions.open",
"handler": "openWithContext",
"icon": "Sparkles",
"permission": "sampleactions.use",
"slot": "terminal.toolbar",
"kind": "button"
},
{
"id": "sampleactions.ungated",
"titleKey": "sampleActions.ungated",
"handler": "ungated"
}
],
"actionSlots": [
{
"id": "sampleactions.panel",
"accepts": ["button"],
"descriptionKey": "sampleActions.panelDescription"
"id": "sample.open",
"titleKey": "sample.open",
"handler": "open",
"permission": "sample-plugin.use"
}
]
},
"sidecars": []
}
}
@@ -1,10 +1,10 @@
{
"id": "sample-provider",
"name": "Sample Provider",
"id": "sample-plugin",
"name": "Sample",
"version": "1.0.0",
"description": "A plugin that both provides and requires a named service.",
"description": "A sample.",
"author": {
"name": "Jane Doe"
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
@@ -12,32 +12,18 @@
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": {
"backend": true,
"frontend": false,
"electron": false,
"platforms": ["linux", "win32", "darwin"]
},
"permissions": ["hosts.read"],
"contributes": {
"permissionGroup": {
"group": "sampleprovider",
"permissions": ["sampleprovider.greet.use"]
}
},
"capabilities": ["kv:own", "ui:surface"],
"provides": [
{
"service": "sampleprovider.greet",
"version": "1.2.0",
"permission": "sampleprovider.greet.use"
"service": "sample.thing",
"version": "1.0.0",
"permission": "sample-plugin.use"
}
],
"requires": [
{
"service": "other.thing",
"versionRange": "^1.0.0",
"optional": true
"contributes": {
"permissionGroup": {
"group": "sample-plugin",
"permissions": ["sample-plugin.use"]
}
],
"sidecars": []
}
}
+4 -33
View File
@@ -1,45 +1,16 @@
{
"id": "sample-plugin",
"name": "Sample Plugin",
"name": "Sample",
"version": "1.0.0",
"description": "A sample plugin used for schema validation tests.",
"description": "A sample.",
"author": {
"name": "Jane Doe",
"url": "https://example.com"
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/example/sample-plugin",
"category": "Productivity",
"icon": "Box",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": {
"backend": true,
"frontend": true,
"electron": false,
"platforms": ["linux", "win32", "darwin"]
},
"permissions": ["hosts.read", "ui.tab", "ui.rail"],
"contributes": {
"tabs": [
{
"id": "sample-plugin-tab",
"titleKey": "samplePlugin.tab.title",
"icon": "Box",
"openFrom": ["rail", "palette"]
}
],
"settingsPanel": {
"titleKey": "samplePlugin.settings.title"
},
"apiPrefix": "sample"
},
"dependencies": {
"plugins": {
"other-plugin": "^1.0.0"
}
},
"sidecars": []
"capabilities": ["kv:own", "ui:surface"]
}
-415
View File
@@ -1,415 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://termix.site/schemas/plugin-manifest.schema.json",
"title": "Termix Plugin Manifest",
"type": "object",
"additionalProperties": false,
"required": [
"id",
"name",
"version",
"description",
"author",
"license",
"engine",
"category",
"capabilities",
"permissions",
"sidecars"
],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z0-9-]+$",
"description": "Unique plugin id, lowercase alphanumeric and hyphens only."
},
"name": {
"type": "string",
"minLength": 1
},
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$",
"description": "Semver version string."
},
"description": {
"type": "string",
"minLength": 1
},
"author": {
"type": "object",
"additionalProperties": false,
"required": ["name"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"url": { "type": "string", "format": "uri" }
}
},
"license": {
"type": "string",
"minLength": 1
},
"repository": {
"type": "string",
"format": "uri"
},
"category": {
"type": "string",
"enum": [
"Terminal",
"Files & Transfer",
"Infrastructure",
"Monitoring",
"Networking",
"Access & Security",
"Productivity"
],
"description": "Store category used for browsing/filtering. Must match one of the categories the store UI filters by."
},
"icon": {
"type": "string",
"minLength": 1,
"description": "Either a Lucide icon name (same convention as contributes.tabs[].icon) or a relative path to an icon.svg shipped in the plugin package root. If both a Lucide name and an icon.svg are present, the registry-index build should prefer icon.svg."
},
"engine": {
"type": "object",
"additionalProperties": false,
"required": ["termix", "api"],
"properties": {
"termix": {
"type": "string",
"minLength": 1,
"description": "Informational-only Termix version range this plugin targets. Not mechanically enforced."
},
"api": {
"type": "string",
"pattern": "^[0-9]+$",
"description": "Plugin SDK major version, as a string. The real compatibility gate."
}
}
},
"capabilities": {
"type": "object",
"additionalProperties": false,
"required": ["backend", "frontend", "electron", "platforms"],
"properties": {
"backend": { "type": "boolean" },
"frontend": { "type": "boolean" },
"electron": { "type": "boolean" },
"platforms": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
},
"permissions": {
"type": "array",
"items": {
"type": "string",
"enum": [
"hosts.read",
"hosts.write",
"credentials.use",
"ssh.exec",
"ssh.sftp",
"storage.own",
"storage.secrets",
"network.outbound",
"events.read",
"notify.send",
"users.read",
"process.sidecar",
"process:transport-owner",
"ui.tab",
"ui.rail",
"ui.card",
"ui.settings",
"ui.palette"
]
}
},
"contributes": {
"type": "object",
"additionalProperties": false,
"properties": {
"tabs": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "titleKey", "icon", "openFrom"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"titleKey": { "type": "string", "minLength": 1 },
"icon": { "type": "string", "minLength": 1 },
"openFrom": {
"type": "array",
"items": {
"type": "string",
"enum": ["rail", "host-context-menu", "palette"]
},
"minItems": 1
}
}
}
},
"hostCapability": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"required": ["key", "labelKey", "editorTab"],
"properties": {
"key": { "type": "string", "minLength": 1 },
"labelKey": { "type": "string", "minLength": 1 },
"editorTab": { "type": "string", "minLength": 1 }
}
},
{
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["key", "labelKey", "editorTab"],
"properties": {
"key": { "type": "string", "minLength": 1 },
"labelKey": { "type": "string", "minLength": 1 },
"editorTab": { "type": "string", "minLength": 1 }
}
}
}
]
},
"permissionGroup": {
"type": "object",
"additionalProperties": false,
"required": ["group", "permissions"],
"properties": {
"group": { "type": "string", "minLength": 1 },
"permissions": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"minItems": 1
},
"defaultForRole": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
}
},
"settingsPanel": {
"type": "object",
"additionalProperties": false,
"required": ["titleKey"],
"properties": {
"titleKey": { "type": "string", "minLength": 1 }
}
},
"dashboardCards": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "titleKey"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"titleKey": { "type": "string", "minLength": 1 }
}
}
},
"apiPrefix": {
"type": "string",
"pattern": "^[a-z0-9-]+$",
"description": "Mounted at /plugin-api/<id>/<apiPrefix>/*."
},
"actions": {
"type": "array",
"description": "Named frontend actions this plugin exposes for other plugins to invoke.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "titleKey", "handler"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$",
"description": "Dotted, globally unique action name, e.g. \"ai.openWithContext\"."
},
"titleKey": { "type": "string", "minLength": 1 },
"handler": {
"type": "string",
"pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$",
"description": "Name of the frontend export implementing this action. The runtime binding is registerAction(id, fn)."
},
"icon": { "type": "string", "minLength": 1 },
"permission": {
"type": "string",
"minLength": 1,
"description": "Role permission a user needs. Must also appear in contributes.permissionGroup.permissions."
},
"slot": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$",
"description": "Slot this action is intended for. Advisory; binding happens at runtime."
},
"kind": { "type": "string", "enum": ["button"] }
}
}
},
"actionSlots": {
"type": "array",
"description": "Places this plugin offers other plugins to contribute a UI element.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "accepts"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$",
"description": "Dotted slot name, e.g. \"terminal.toolbar\"."
},
"accepts": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "enum": ["button"] },
"description": "Contribution kinds this slot will render. Checked in the frontend at registration time."
},
"descriptionKey": { "type": "string", "minLength": 1 }
}
}
}
}
},
"dependencies": {
"type": "object",
"additionalProperties": false,
"properties": {
"plugins": {
"type": "object",
"additionalProperties": {
"type": "string",
"minLength": 1,
"description": "Semver range string."
}
}
}
},
"provides": {
"type": "array",
"description": "Named services this plugin exposes to other plugins.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["service", "version", "permission"],
"properties": {
"service": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$",
"description": "Dotted service name, e.g. \"ssh.transport\"."
},
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$",
"description": "Semver version of this service contract."
},
"permission": {
"type": "string",
"minLength": 1,
"description": "Role permission a calling user needs. Must also appear in contributes.permissionGroup.permissions."
}
}
}
},
"requires": {
"type": "array",
"description": "Named services this plugin needs another active plugin to provide.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["service", "versionRange"],
"properties": {
"service": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$"
},
"versionRange": {
"type": "string",
"minLength": 1,
"description": "Semver range, e.g. \"^1.2.0\"."
},
"optional": {
"type": "boolean",
"description": "When true, an unsatisfied requirement is skipped instead of failing activation."
}
}
}
},
"providesSecret": {
"type": "array",
"description": "Secrets this plugin offers other plugins by reference. The value is never copied; a borrower re-reads it per call.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["key", "permission"],
"properties": {
"key": {
"type": "string",
"pattern": "^[a-z0-9-]+$",
"description": "Secret name within this plugin, e.g. \"api-key\"."
},
"permission": {
"type": "string",
"minLength": 1,
"description": "Role permission a borrowing user needs. Must also appear in contributes.permissionGroup.permissions."
},
"descriptionKey": {
"type": "string",
"minLength": 1,
"description": "i18n key describing the sharing, shown in the admin UI."
}
}
}
},
"requiresSecret": {
"type": "array",
"description": "Secrets this plugin borrows from another plugin. Reads resolve to null when the provider is absent or has nothing set.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["plugin", "key"],
"properties": {
"plugin": {
"type": "string",
"pattern": "^[a-z0-9-]+$",
"description": "Plugin id that offers the secret."
},
"key": {
"type": "string",
"pattern": "^[a-z0-9-]+$"
},
"optional": {
"type": "boolean",
"description": "When true, an absent provider is normal rather than reported as an unmet requirement."
}
}
}
},
"sidecars": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "binary"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"binary": { "type": "string", "minLength": 1 }
}
}
}
}
}
+67 -601
View File
@@ -1,622 +1,88 @@
/**
* Validates a plugin manifest.json against scripts/plugin-manifest.schema.json.
* Validates plugin manifests against the SDK contract.
*
* Hand-rolled rather than pulling in ajv: the schema is small and the checks
* that matter (required fields, id pattern, semver, known permissions) are a
* handful of straightforward comparisons.
* node scripts/validate-plugin-manifest.cjs # every bundled manifest
* node scripts/validate-plugin-manifest.cjs path/to/manifest.json
*
* Usage: node scripts/validate-plugin-manifest.cjs <path-to-manifest.json>
* The rules live in packages/plugin-sdk/src/manifest.ts, which the server
* uses too, so there is one implementation rather than a copy here that
* drifts. This file only finds the manifests and prints the result.
*/
const fs = require("fs");
const path = require("path");
const semver = require("semver");
const fs = require("node:fs");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const SCHEMA_PATH = path.join(__dirname, "plugin-manifest.schema.json");
const root = path.resolve(__dirname, "..");
const sdkEntry = path.join(root, "packages", "plugin-sdk", "dist", "manifest.js");
const ID_PATTERN = /^[a-z0-9-]+$/;
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
const API_VERSION_PATTERN = /^[0-9]+$/;
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/;
// Dotted like a service name, but segments may be camelCase: these name a
// frontend function ("ai.openWithContext"), not a lowercase service contract.
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"];
const ACTION_CONTRIBUTION_KINDS = ["button"];
function loadSchema() {
const raw = fs.readFileSync(SCHEMA_PATH, "utf8");
return JSON.parse(raw);
}
function knownPermissions(schema) {
return schema.properties.permissions.items.enum;
}
function knownCategories(schema) {
return schema.properties.category.enum;
}
function validateManifest(manifest, schema) {
const errors = [];
const requiredTopLevel = schema.required;
for (const field of requiredTopLevel) {
if (!(field in manifest)) {
errors.push(`Missing required field: "${field}"`);
}
}
if (typeof manifest.id === "string" && !ID_PATTERN.test(manifest.id)) {
errors.push(`Field "id" must match ${ID_PATTERN}, got: "${manifest.id}"`);
}
if (
typeof manifest.version === "string" &&
!SEMVER_PATTERN.test(manifest.version)
) {
errors.push(
`Field "version" must be valid semver, got: "${manifest.version}"`,
);
}
if (manifest.author && typeof manifest.author === "object") {
if (!manifest.author.name) {
errors.push('Field "author.name" is required');
}
}
if (typeof manifest.category === "string") {
const categories = knownCategories(schema);
if (!categories.includes(manifest.category)) {
errors.push(
`Field "category" must be one of: ${categories.join(", ")}, got: "${manifest.category}"`,
);
}
} else if ("category" in manifest) {
errors.push('Field "category" must be a string');
}
if ("icon" in manifest && typeof manifest.icon !== "string") {
errors.push('Field "icon" must be a string');
}
if (manifest.engine && typeof manifest.engine === "object") {
if (!manifest.engine.termix) {
errors.push('Field "engine.termix" is required');
}
if (!API_VERSION_PATTERN.test(String(manifest.engine.api))) {
errors.push(
`Field "engine.api" must be an integer-as-string, got: "${manifest.engine.api}"`,
);
}
}
if (manifest.capabilities && typeof manifest.capabilities === "object") {
for (const field of ["backend", "frontend", "electron"]) {
if (typeof manifest.capabilities[field] !== "boolean") {
errors.push(`Field "capabilities.${field}" must be a boolean`);
}
}
if (!Array.isArray(manifest.capabilities.platforms)) {
errors.push('Field "capabilities.platforms" must be an array');
}
}
const known = knownPermissions(schema);
if (Array.isArray(manifest.permissions)) {
for (const permission of manifest.permissions) {
if (!known.includes(permission)) {
errors.push(
`Unknown permission: "${permission}". Known values: ${known.join(", ")}`,
);
}
}
} else if ("permissions" in manifest) {
errors.push('Field "permissions" must be an array');
}
if (Array.isArray(manifest.sidecars)) {
manifest.sidecars.forEach((sidecar, index) => {
if (!sidecar || typeof sidecar !== "object") {
errors.push(`sidecars[${index}] must be an object`);
return;
}
if (!sidecar.id) errors.push(`sidecars[${index}].id is required`);
if (!sidecar.binary) errors.push(`sidecars[${index}].binary is required`);
});
} else if ("sidecars" in manifest) {
errors.push('Field "sidecars" must be an array');
}
errors.push(...validateProvides(manifest.provides));
errors.push(...validateRequires(manifest.requires));
errors.push(...validateProvidesSecret(manifest.providesSecret));
errors.push(...validateRequiresSecret(manifest.requiresSecret));
if (manifest.contributes && typeof manifest.contributes === "object") {
errors.push(...validateContributes(manifest.contributes));
}
// A service permission has to be one the plugin's own permissionGroup
// declares, or no admin could ever grant it.
const declared = manifest.contributes?.permissionGroup?.permissions;
if (Array.isArray(manifest.provides) && Array.isArray(declared)) {
for (const entry of manifest.provides) {
if (entry && entry.permission && !declared.includes(entry.permission)) {
errors.push(
`Service "${entry.service}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`,
);
}
}
}
// Same rule for a shared secret: sharing rides on the provider's own RBAC
// permission rather than a separate grant mechanism.
if (Array.isArray(manifest.providesSecret) && Array.isArray(declared)) {
for (const entry of manifest.providesSecret) {
if (entry && entry.permission && !declared.includes(entry.permission)) {
errors.push(
`Secret "${entry.key}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`,
);
}
}
}
if (Array.isArray(manifest.requiresSecret)) {
for (const entry of manifest.requiresSecret) {
if (entry && entry.plugin && entry.plugin === manifest.id) {
errors.push(
`requiresSecret entry for "${entry.key}" names this plugin itself; use ctx.secrets.get instead`,
);
}
}
}
// Same rule for UI actions: a permission the catalog never sees is one no
// admin can grant, so the action would be invisible rather than denied.
const actions = manifest.contributes?.actions;
if (Array.isArray(actions)) {
for (const action of actions) {
if (!action || !action.permission) continue;
if (!Array.isArray(declared) || !declared.includes(action.permission)) {
errors.push(
`Action "${action.id}" is gated by "${action.permission}", which is not declared in contributes.permissionGroup.permissions`,
);
}
}
}
return errors;
}
function validateProvides(provides) {
if (provides === undefined) return [];
if (!Array.isArray(provides)) return ['Field "provides" must be an array'];
const errors = [];
const seen = new Set();
provides.forEach((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`provides[${index}] must be an object`);
return;
}
if (
typeof entry.service !== "string" ||
!SERVICE_PATTERN.test(entry.service)
) {
errors.push(
`provides[${index}].service must match ${SERVICE_PATTERN}, got: "${entry.service}"`,
);
} else if (seen.has(entry.service)) {
errors.push(
`provides[${index}].service is a duplicate: "${entry.service}"`,
);
} else {
seen.add(entry.service);
}
if (
typeof entry.version !== "string" ||
!SEMVER_PATTERN.test(entry.version)
) {
errors.push(
`provides[${index}].version must be valid semver, got: "${entry.version}"`,
);
}
if (typeof entry.permission !== "string" || entry.permission.length === 0) {
errors.push(`provides[${index}].permission is required`);
}
});
return errors;
}
function validateRequires(requires) {
if (requires === undefined) return [];
if (!Array.isArray(requires)) return ['Field "requires" must be an array'];
const errors = [];
const seen = new Set();
requires.forEach((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`requires[${index}] must be an object`);
return;
}
if (
typeof entry.service !== "string" ||
!SERVICE_PATTERN.test(entry.service)
) {
errors.push(
`requires[${index}].service must match ${SERVICE_PATTERN}, got: "${entry.service}"`,
);
} else if (seen.has(entry.service)) {
errors.push(
`requires[${index}].service is a duplicate: "${entry.service}"`,
);
} else {
seen.add(entry.service);
}
if (
typeof entry.versionRange !== "string" ||
semver.validRange(entry.versionRange) === null
) {
errors.push(
`requires[${index}].versionRange must be a valid semver range, got: "${entry.versionRange}"`,
);
}
if ("optional" in entry && typeof entry.optional !== "boolean") {
errors.push(`requires[${index}].optional must be a boolean`);
}
});
return errors;
}
function validateProvidesSecret(providesSecret) {
if (providesSecret === undefined) return [];
if (!Array.isArray(providesSecret)) {
return ['Field "providesSecret" must be an array'];
}
const errors = [];
const seen = new Set();
providesSecret.forEach((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`providesSecret[${index}] must be an object`);
return;
}
if (typeof entry.key !== "string" || !SECRET_KEY_PATTERN.test(entry.key)) {
errors.push(
`providesSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${entry.key}"`,
);
} else if (seen.has(entry.key)) {
errors.push(
`providesSecret[${index}].key is a duplicate: "${entry.key}"`,
);
} else {
seen.add(entry.key);
}
if (typeof entry.permission !== "string" || entry.permission.length === 0) {
errors.push(`providesSecret[${index}].permission is required`);
}
if (
"descriptionKey" in entry &&
(typeof entry.descriptionKey !== "string" ||
entry.descriptionKey.length === 0)
) {
errors.push(`providesSecret[${index}].descriptionKey must be a string`);
}
});
return errors;
}
function validateRequiresSecret(requiresSecret) {
if (requiresSecret === undefined) return [];
if (!Array.isArray(requiresSecret)) {
return ['Field "requiresSecret" must be an array'];
}
const errors = [];
const seen = new Set();
requiresSecret.forEach((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`requiresSecret[${index}] must be an object`);
return;
}
if (typeof entry.plugin !== "string" || !ID_PATTERN.test(entry.plugin)) {
errors.push(
`requiresSecret[${index}].plugin must match ${ID_PATTERN}, got: "${entry.plugin}"`,
);
}
if (typeof entry.key !== "string" || !SECRET_KEY_PATTERN.test(entry.key)) {
errors.push(
`requiresSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${entry.key}"`,
);
}
if (typeof entry.plugin === "string" && typeof entry.key === "string") {
const id = `${entry.plugin}:${entry.key}`;
if (seen.has(id)) {
errors.push(`requiresSecret[${index}] is a duplicate: "${id}"`);
} else {
seen.add(id);
}
}
if ("optional" in entry && typeof entry.optional !== "boolean") {
errors.push(`requiresSecret[${index}].optional must be a boolean`);
}
});
return errors;
}
function validateContributes(contributes) {
const errors = [];
if ("tabs" in contributes) {
if (!Array.isArray(contributes.tabs)) {
errors.push('Field "contributes.tabs" must be an array');
} else {
contributes.tabs.forEach((tab, index) => {
for (const field of ["id", "titleKey", "icon", "openFrom"]) {
if (!(field in (tab || {}))) {
errors.push(`contributes.tabs[${index}].${field} is required`);
}
}
if (Array.isArray(tab?.openFrom)) {
for (const value of tab.openFrom) {
if (!OPEN_FROM_VALUES.includes(value)) {
errors.push(
`contributes.tabs[${index}].openFrom has unknown value: "${value}"`,
);
}
}
}
});
}
}
if ("hostCapability" in contributes) {
const raw = contributes.hostCapability;
const entries = Array.isArray(raw) ? raw : [raw];
const seenKeys = new Set();
entries.forEach((entry, index) => {
const hc = entry || {};
const prefix = Array.isArray(raw)
? `contributes.hostCapability[${index}]`
: "contributes.hostCapability";
for (const field of ["key", "labelKey", "editorTab"]) {
if (!(field in hc)) {
errors.push(`${prefix}.${field} is required`);
}
}
if (typeof hc.key === "string") {
if (seenKeys.has(hc.key)) {
errors.push(`${prefix}.key is a duplicate: "${hc.key}"`);
}
seenKeys.add(hc.key);
}
});
}
if ("permissionGroup" in contributes) {
const group = contributes.permissionGroup || {};
if (!group.group)
errors.push("contributes.permissionGroup.group is required");
if (!Array.isArray(group.permissions) || group.permissions.length === 0) {
errors.push(
"contributes.permissionGroup.permissions must be a non-empty array",
);
}
}
if ("settingsPanel" in contributes) {
if (!contributes.settingsPanel?.titleKey) {
errors.push("contributes.settingsPanel.titleKey is required");
}
}
if ("dashboardCards" in contributes) {
if (!Array.isArray(contributes.dashboardCards)) {
errors.push('Field "contributes.dashboardCards" must be an array');
} else {
contributes.dashboardCards.forEach((card, index) => {
for (const field of ["id", "titleKey"]) {
if (!(field in (card || {}))) {
errors.push(
`contributes.dashboardCards[${index}].${field} is required`,
);
}
}
});
}
}
if ("actions" in contributes) {
errors.push(...validateActions(contributes.actions));
}
if ("actionSlots" in contributes) {
errors.push(...validateActionSlots(contributes.actionSlots));
}
return errors;
}
function validateActions(actions) {
if (!Array.isArray(actions)) {
return ['Field "contributes.actions" must be an array'];
}
const errors = [];
const seen = new Set();
actions.forEach((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`contributes.actions[${index}] must be an object`);
return;
}
if (typeof entry.id !== "string" || !ACTION_ID_PATTERN.test(entry.id)) {
errors.push(
`contributes.actions[${index}].id must match ${ACTION_ID_PATTERN}, got: "${entry.id}"`,
);
} else if (seen.has(entry.id)) {
errors.push(
`contributes.actions[${index}].id is a duplicate: "${entry.id}"`,
);
} else {
seen.add(entry.id);
}
if (typeof entry.titleKey !== "string" || entry.titleKey.length === 0) {
errors.push(`contributes.actions[${index}].titleKey is required`);
}
if (
typeof entry.handler !== "string" ||
!HANDLER_PATTERN.test(entry.handler)
) {
errors.push(
`contributes.actions[${index}].handler must match ${HANDLER_PATTERN}, got: "${entry.handler}"`,
);
}
if ("icon" in entry && typeof entry.icon !== "string") {
errors.push(`contributes.actions[${index}].icon must be a string`);
}
if ("permission" in entry && typeof entry.permission !== "string") {
errors.push(`contributes.actions[${index}].permission must be a string`);
}
if (
"slot" in entry &&
(typeof entry.slot !== "string" || !ACTION_ID_PATTERN.test(entry.slot))
) {
errors.push(
`contributes.actions[${index}].slot must match ${ACTION_ID_PATTERN}, got: "${entry.slot}"`,
);
}
if ("kind" in entry && !ACTION_CONTRIBUTION_KINDS.includes(entry.kind)) {
errors.push(
`contributes.actions[${index}].kind must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}, got: "${entry.kind}"`,
);
}
});
return errors;
}
function validateActionSlots(slots) {
if (!Array.isArray(slots)) {
return ['Field "contributes.actionSlots" must be an array'];
}
const errors = [];
const seen = new Set();
slots.forEach((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`contributes.actionSlots[${index}] must be an object`);
return;
}
if (typeof entry.id !== "string" || !ACTION_ID_PATTERN.test(entry.id)) {
errors.push(
`contributes.actionSlots[${index}].id must match ${ACTION_ID_PATTERN}, got: "${entry.id}"`,
);
} else if (seen.has(entry.id)) {
errors.push(
`contributes.actionSlots[${index}].id is a duplicate: "${entry.id}"`,
);
} else {
seen.add(entry.id);
}
if (!Array.isArray(entry.accepts) || entry.accepts.length === 0) {
errors.push(
`contributes.actionSlots[${index}].accepts must be a non-empty array`,
);
} else {
for (const kind of entry.accepts) {
if (!ACTION_CONTRIBUTION_KINDS.includes(kind)) {
errors.push(
`contributes.actionSlots[${index}].accepts has unknown value: "${kind}". Known values: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`,
);
}
}
}
if ("descriptionKey" in entry && typeof entry.descriptionKey !== "string") {
errors.push(
`contributes.actionSlots[${index}].descriptionKey must be a string`,
);
}
});
return errors;
}
function main() {
const manifestPath = process.argv[2];
if (!manifestPath) {
async function loadValidator() {
if (!fs.existsSync(sdkEntry)) {
console.error(
"Usage: node scripts/validate-plugin-manifest.cjs <path-to-manifest.json>",
`The plugin SDK is not built yet (${path.relative(root, sdkEntry)} is missing).\n` +
`Run: npm run build:sdk`,
);
process.exit(1);
}
return import(pathToFileURL(sdkEntry).href);
}
const resolvedPath = path.resolve(manifestPath);
if (!fs.existsSync(resolvedPath)) {
console.error(`Manifest not found: ${resolvedPath}`);
process.exit(1);
function bundledManifests() {
const pluginsDir = path.join(root, "plugins");
if (!fs.existsSync(pluginsDir)) return [];
return fs
.readdirSync(pluginsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(pluginsDir, entry.name, "manifest.json"))
.filter((file) => fs.existsSync(file));
}
async function main() {
const { parseManifest } = await loadValidator();
const argument = process.argv[2];
const files = argument
? [path.resolve(process.cwd(), argument)]
: bundledManifests();
if (files.length === 0) {
console.log("No plugin manifests found.");
return;
}
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(resolvedPath, "utf8"));
} catch (err) {
console.error(`Manifest is not valid JSON: ${err.message}`);
process.exit(1);
}
let failed = 0;
const schema = loadSchema();
const errors = validateManifest(manifest, schema);
for (const file of files) {
const relative = path.relative(root, file);
if (errors.length > 0) {
console.error(`Invalid plugin manifest: ${resolvedPath}`);
for (const error of errors) {
console.error(` - ${error}`);
let raw;
try {
raw = JSON.parse(fs.readFileSync(file, "utf8"));
} catch (error) {
console.error(`${relative}: not valid JSON: ${error.message}`);
failed += 1;
continue;
}
process.exit(1);
const { errors } = parseManifest(raw);
if (errors.length > 0) {
console.error(`${relative}:`);
for (const error of errors) console.error(` - ${error}`);
failed += 1;
continue;
}
console.log(`ok ${relative}`);
}
console.log(`Valid plugin manifest: ${resolvedPath}`);
if (failed > 0) {
console.error(`\n${failed} manifest(s) failed validation.`);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = { validateManifest, loadSchema };
main().catch((error) => {
console.error(error);
process.exit(1);
});
+66 -45
View File
@@ -5,13 +5,12 @@ import path from "node:path";
const VALIDATOR = path.join(__dirname, "validate-plugin-manifest.cjs");
const FIXTURES = path.join(__dirname, "__fixtures__", "manifests");
function runValidator(fixture: string): { status: number; output: string } {
function runValidator(fixture?: string): { status: number; output: string } {
const args = fixture
? [VALIDATOR, path.join(FIXTURES, fixture)]
: [VALIDATOR];
try {
const output = execFileSync(
"node",
[VALIDATOR, path.join(FIXTURES, fixture)],
{ encoding: "utf8" },
);
const output = execFileSync("node", args, { encoding: "utf8" });
return { status: 0, output };
} catch (err) {
const error = err as { status: number; stdout: string; stderr: string };
@@ -23,54 +22,76 @@ describe("validate-plugin-manifest.cjs", () => {
it("accepts a valid manifest", () => {
const { status, output } = runValidator("valid.json");
expect(status).toBe(0);
expect(output).toContain("Valid plugin manifest");
expect(output).toContain("ok");
});
it("accepts a manifest declaring services", () => {
expect(runValidator("valid-services.json").status).toBe(0);
});
it("accepts a manifest declaring actions", () => {
expect(runValidator("valid-actions.json").status).toBe(0);
});
it("rejects a manifest missing a required field", () => {
const { status, output } = runValidator("invalid-missing-field.json");
expect(status).not.toBe(0);
expect(output).toContain('Missing required field: "description"');
expect(status).toBe(1);
expect(output).toContain("id");
});
it("rejects a manifest with a bad id pattern", () => {
it("rejects an id that is not a lowercase slug", () => {
const { status, output } = runValidator("invalid-bad-id.json");
expect(status).not.toBe(0);
expect(output).toMatch(/Field "id" must match/);
expect(status).toBe(1);
expect(output).toContain("id");
});
it("accepts a manifest declaring provides and requires", () => {
const { status, output } = runValidator("valid-services.json");
it("rejects an unknown category", () => {
expect(runValidator("invalid-unknown-category.json").status).toBe(1);
});
// The dotted spelling was the v1 form. Capabilities are colon-separated now
// so they can never be confused with RBAC permissions.
it("rejects a dotted capability name", () => {
const { status, output } = runValidator("invalid-unknown-capability.json");
expect(status).toBe(1);
expect(output).toContain("hosts.read");
});
it("rejects a service permission the plugin does not declare", () => {
expect(runValidator("invalid-service-permission.json").status).toBe(1);
});
it("rejects an action permission the plugin does not declare", () => {
expect(runValidator("invalid-action-permission.json").status).toBe(1);
});
it("rejects an unknown top-level field", () => {
const { status, output } = runValidator("invalid-unknown-field.json");
expect(status).toBe(1);
expect(output).toContain("sudoEverything");
});
it("rejects an unknown field nested inside contributes", () => {
const { status, output } = runValidator(
"invalid-nested-unknown-field.json",
);
expect(status).toBe(1);
expect(output).toContain("extra");
});
// A manifest must not be able to hand a role a permission the plugin does
// not own, which is how a plugin could otherwise grant itself admin rights.
it("rejects a role default outside the plugin's own namespace", () => {
const { status, output } = runValidator(
"invalid-role-default-escalation.json",
);
expect(status).toBe(1);
expect(output).toContain("admin.users.manage");
});
it("validates every bundled manifest when given no argument", () => {
const { status, output } = runValidator();
expect(status).toBe(0);
expect(output).toContain("Valid plugin manifest");
});
it("rejects a service gated by an undeclared permission", () => {
const { status, output } = runValidator("invalid-service-permission.json");
expect(status).not.toBe(0);
expect(output).toContain("sampleprovider.undeclared");
});
it("accepts a manifest declaring actions and actionSlots", () => {
const { status, output } = runValidator("valid-actions.json");
expect(status).toBe(0);
expect(output).toContain("Valid plugin manifest");
});
it("rejects an action gated by an undeclared permission", () => {
const { status, output } = runValidator("invalid-action-permission.json");
expect(status).not.toBe(0);
expect(output).toContain("samplebadaction.never_declared");
});
it("rejects a manifest with an unknown permission", () => {
const { status, output } = runValidator("invalid-unknown-permission.json");
expect(status).not.toBe(0);
expect(output).toContain('Unknown permission: "credentials.read"');
});
it("rejects a manifest with an unknown category", () => {
const { status, output } = runValidator("invalid-unknown-category.json");
expect(status).not.toBe(0);
expect(output).toMatch(/Field "category" must be one of/);
expect(output).toContain("ssh-terminal");
});
});
+71 -1
View File
@@ -689,6 +689,7 @@ async function initializeCompleteDatabase(): Promise<void> {
source TEXT NOT NULL DEFAULT 'community',
registry_id TEXT,
state TEXT NOT NULL DEFAULT 'disabled',
last_error TEXT,
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
auto_update INTEGER NOT NULL DEFAULT 0,
@@ -701,7 +702,9 @@ async function initializeCompleteDatabase(): Promise<void> {
plugin_id TEXT NOT NULL,
capability TEXT NOT NULL,
granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
granted_by TEXT NOT NULL,
-- Nullable: a bundled grant is made by the install, not by a user.
granted_by TEXT,
source TEXT NOT NULL DEFAULT 'admin',
UNIQUE (plugin_id, capability),
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
@@ -987,6 +990,63 @@ const addColumnIfNotExists = (
}
};
/**
* Drops the NOT NULL on plugin_permission_grants.granted_by.
*
* A bundled grant is made by the install rather than by a person, so there is
* no user id to record. SQLite cannot relax a constraint in place, so the
* table is rebuilt; it only ever holds a handful of rows.
*/
const relaxPluginGrantGrantedBy = () => {
try {
const sql = sqlite
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'plugin_permission_grants'`,
)
.get() as { sql?: string } | undefined;
if (!sql?.sql || !/granted_by\s+TEXT\s+NOT\s+NULL/i.test(sql.sql)) {
return;
}
sqlite.exec(`
PRAGMA foreign_keys=OFF;
BEGIN;
CREATE TABLE plugin_permission_grants_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
capability TEXT NOT NULL,
granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
granted_by TEXT,
source TEXT NOT NULL DEFAULT 'admin',
UNIQUE (plugin_id, capability),
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
);
INSERT INTO plugin_permission_grants_new
(id, plugin_id, capability, granted_at, granted_by, source)
SELECT id, plugin_id, capability, granted_at, granted_by, 'admin'
FROM plugin_permission_grants;
DROP TABLE plugin_permission_grants;
ALTER TABLE plugin_permission_grants_new RENAME TO plugin_permission_grants;
COMMIT;
PRAGMA foreign_keys=ON;
`);
databaseLogger.info(
"Relaxed plugin_permission_grants.granted_by to allow bundled grants",
{ operation: "schema_migration" },
);
} catch (error) {
databaseLogger.warn(
`Failed to relax plugin_permission_grants.granted_by: ${
error instanceof Error ? error.message : String(error)
}`,
{ operation: "schema_migration" },
);
}
};
const migrateSchema = () => {
addColumnIfNotExists(
"session_recordings",
@@ -2836,6 +2896,16 @@ const migrateSchema = () => {
}
addColumnIfNotExists("homepage_items", "sync_id", "TEXT");
// Plugin runtime: lastError reports why a plugin is blocked or failed, and
// grants record whether a capability came from an admin or from bundling.
addColumnIfNotExists("plugins", "last_error", "TEXT");
addColumnIfNotExists(
"plugin_permission_grants",
"source",
"TEXT NOT NULL DEFAULT 'admin'",
);
relaxPluginGrantGrantedBy();
const syncIdTables = [
"ssh_data",
"ssh_credentials",
+13 -3
View File
@@ -2142,7 +2142,10 @@ export const plugins = mysqlTable(
tier: text("tier").notNull().default("available"),
source: text("source").notNull().default("community"),
registryId: varchar("registry_id", { length: 255 }),
/** enabled | disabled | blocked | failed */
state: text("state").notNull().default("disabled"),
/** Why the plugin is blocked or failed, for the admin UI. */
lastError: text("last_error"),
installedAt: text("installed_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
@@ -2168,9 +2171,16 @@ export const pluginPermissionGrants = mysqlTable(
grantedAt: text("granted_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
/**
* Who granted it. Null for a bundled grant, which no user made: shipping
* in the install is the consent. Nullable also stops a user deletion from
* cascading a bundled plugin's capabilities away.
*/
grantedBy: varchar("granted_by", { length: 255 }).references(() => users.id, {
onDelete: "cascade",
}),
/** admin | bundled */
source: text("source").notNull().default("admin"),
},
// A plugin's grants are always read together, and re-granting the same
// capability should update the existing row rather than duplicate it.
+13 -3
View File
@@ -2143,7 +2143,10 @@ export const plugins = pgTable(
tier: text("tier").notNull().default("available"),
source: text("source").notNull().default("community"),
registryId: varchar("registry_id", { length: 255 }),
/** enabled | disabled | blocked | failed */
state: text("state").notNull().default("disabled"),
/** Why the plugin is blocked or failed, for the admin UI. */
lastError: text("last_error"),
installedAt: text("installed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -2169,9 +2172,16 @@ export const pluginPermissionGrants = pgTable(
grantedAt: text("granted_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
/**
* Who granted it. Null for a bundled grant, which no user made: shipping
* in the install is the consent. Nullable also stops a user deletion from
* cascading a bundled plugin's capabilities away.
*/
grantedBy: varchar("granted_by", { length: 255 }).references(() => users.id, {
onDelete: "cascade",
}),
/** admin | bundled */
source: text("source").notNull().default("admin"),
},
// A plugin's grants are always read together, and re-granting the same
// capability should update the existing row rather than duplicate it.
+13 -3
View File
@@ -2141,7 +2141,10 @@ export const plugins = sqliteTable(
tier: text("tier").notNull().default("available"),
source: text("source").notNull().default("community"),
registryId: text("registry_id"),
/** enabled | disabled | blocked | failed */
state: text("state").notNull().default("disabled"),
/** Why the plugin is blocked or failed, for the admin UI. */
lastError: text("last_error"),
installedAt: text("installed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -2167,9 +2170,16 @@ export const pluginPermissionGrants = sqliteTable(
grantedAt: text("granted_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
grantedBy: text("granted_by")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
/**
* Who granted it. Null for a bundled grant, which no user made: shipping
* in the install is the consent. Nullable also stops a user deletion from
* cascading a bundled plugin's capabilities away.
*/
grantedBy: text("granted_by").references(() => users.id, {
onDelete: "cascade",
}),
/** admin | bundled */
source: text("source").notNull().default("admin"),
},
// A plugin's grants are always read together, and re-granting the same
// capability should update the existing row rather than duplicate it.
@@ -10,7 +10,9 @@ export type PluginPermissionGrantRecord =
export interface PluginPermissionGrantCreateInput {
pluginId: string;
capability: string;
grantedBy: string;
/** Null for a bundled grant, which no user made. */
grantedBy: string | null;
source?: "admin" | "bundled";
}
export class PluginPermissionGrantRepository {
@@ -55,6 +57,7 @@ export class PluginPermissionGrantRepository {
pluginId: input.pluginId,
capability: input.capability,
grantedBy: input.grantedBy,
source: input.source ?? "admin",
grantedAt: now,
},
);
@@ -18,6 +18,7 @@ export interface PluginCreateInput {
source?: string;
registryId?: string | null;
state?: string;
lastError?: string | null;
autoUpdate?: boolean;
manifestJson: string;
}
@@ -29,6 +30,7 @@ export interface PluginUpdateInput {
source?: string;
registryId?: string | null;
state?: string;
lastError?: string | null;
autoUpdate?: boolean;
manifestJson?: string;
}
@@ -64,6 +66,7 @@ export class PluginRepository {
source: input.source ?? "community",
registryId: input.registryId ?? null,
state: input.state ?? "disabled",
lastError: input.lastError ?? null,
autoUpdate: input.autoUpdate ?? false,
manifestJson: input.manifestJson,
installedAt: now,
@@ -95,6 +98,8 @@ export class PluginRepository {
? existing.registryId
: input.registryId,
state: input.state ?? existing.state,
lastError:
input.lastError === undefined ? existing.lastError : input.lastError,
autoUpdate: input.autoUpdate ?? existing.autoUpdate,
manifestJson: input.manifestJson ?? existing.manifestJson,
updatedAt: now,
+154 -44
View File
@@ -31,61 +31,88 @@ const requireManagePlugins = permissionManager.requirePermission(
* summary: List installed plugins and their runtime state
* description: >
* Returns every plugin known to the database, merged with the loader's
* live state so the UI can tell "enabled but crashed" from "disabled".
* live state so the UI can tell "enabled but failed" from "disabled".
* Open to any authenticated user, not just admins: the app shell calls
* this on every session to decide which plugin-contributed tabs and
* rail items to register, so gating it behind admin.plugins.manage
* would break the shell for non-admin users. Only the mutating routes
* below (enable/disable, grant/revoke) require that permission.
* would break the shell for non-admin users.
*
* Non-admins get only what the shell needs. What a plugin is allowed to
* do, what has been granted to it and why it failed are operational
* details, so capabilities, grants and lastError are included only for
* holders of admin.plugins.manage.
* tags:
* - Plugins
* responses:
* 200:
* description: List of plugins.
*/
router.get("/", authenticateJWT, async (_req: Request, res: Response) => {
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
try {
const userId = (req as AuthenticatedRequest).userId as string;
const records = await createCurrentPluginRepository().listAll();
const { loader } = getPluginRuntime();
const live = new Map(loader.list().map((p) => [p.id, p]));
const grantRepository = createCurrentPluginPermissionGrantRepository();
const plugins = await Promise.all(
records.map(async (record) => {
const loaded = live.get(record.id);
let contributes: unknown = null;
let permissions: string[] = [];
try {
const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown;
permissions?: unknown;
};
contributes = manifest?.contributes ?? null;
permissions = Array.isArray(manifest?.permissions)
? (manifest.permissions as string[])
: [];
} catch {
contributes = null;
}
const grants = await grantRepository.listByPlugin(record.id);
return {
id: record.id,
name: record.name,
version: record.version,
tier: record.tier,
source: record.source,
enabled: record.state === "enabled",
runtimeState: loaded?.state ?? "stopped",
lastError: loaded?.lastError ?? null,
contributes,
permissions,
grantedCapabilities: grants.map((grant) => grant.capability),
};
}),
const canManage = await permissionManager.hasPermission(
userId,
"admin.plugins.manage",
);
// One query for every plugin rather than one per plugin.
const grantsByPlugin = new Map<string, string[]>();
if (canManage) {
const grantRepository = createCurrentPluginPermissionGrantRepository();
await Promise.all(
records.map(async (record) => {
const grants = await grantRepository.listByPlugin(record.id);
grantsByPlugin.set(
record.id,
grants.map((grant) => grant.capability),
);
}),
);
}
const plugins = records.map((record) => {
const loaded = live.get(record.id);
let contributes: unknown = null;
let capabilities: string[] = [];
try {
const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown;
capabilities?: unknown;
};
contributes = manifest?.contributes ?? null;
capabilities = Array.isArray(manifest?.capabilities)
? (manifest.capabilities as string[])
: [];
} catch {
contributes = null;
}
const summary = {
id: record.id,
name: record.name,
version: record.version,
enabled: record.state === "enabled",
state: loaded?.state ?? record.state,
contributes,
};
if (!canManage) return summary;
return {
...summary,
tier: record.tier,
source: record.source,
capabilities,
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
lastError: loaded?.lastError ?? record.lastError ?? null,
};
});
res.json(plugins);
} catch (error) {
databaseLogger.error(
@@ -135,7 +162,6 @@ router.patch(
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const pluginId = String(req.params.id);
const { enabled } = req.body ?? {};
@@ -157,12 +183,13 @@ router.patch(
// acts on it rather than silently reverting.
await repository.update(pluginId, {
state: enabled ? "enabled" : "disabled",
lastError: null,
});
const { activatePlugin, deactivatePlugin } =
await import("../../plugins/index.js");
if (enabled) await activatePlugin(pluginId, userId);
if (enabled) await activatePlugin(pluginId);
else await deactivatePlugin(pluginId);
databaseLogger.info(
@@ -182,6 +209,89 @@ router.patch(
},
);
/**
* @openapi
* /plugins/{id}/retry:
* post:
* summary: Start a plugin again after it failed
* description: >
* Clears the plugin's error budget and activates it. Use after fixing
* whatever made it fail, such as installing a missing dependency or
* freeing a port it needs.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The plugin's state after the attempt.
* 403:
* description: The caller lacks admin.plugins.manage.
* 404:
* description: No such plugin, or it is not on disk.
* 500:
* description: The plugin failed to start again.
*/
router.post(
"/:id/retry",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
try {
const repository = createCurrentPluginRepository();
const record = await repository.findById(pluginId);
if (!record) {
res.status(404).json({ error: "Plugin not found" });
return;
}
const { loader } = getPluginRuntime();
if (!loader.get(pluginId)) {
res.status(404).json({ error: "Plugin is not present on disk" });
return;
}
await repository.update(pluginId, {
state: "enabled",
lastError: null,
});
await loader.retry(pluginId);
databaseLogger.info(`Plugin ${pluginId} restarted`, {
operation: "plugin_retry",
pluginId,
});
res.json({ id: pluginId, state: loader.get(pluginId)?.state });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
databaseLogger.error(
`Failed to retry plugin ${pluginId}`,
error instanceof Error ? error : new Error(message),
{ operation: "plugin_retry" },
);
try {
await createCurrentPluginRepository().update(pluginId, {
state: "failed",
lastError: message,
});
} catch {
// Reporting the failure must not mask it.
}
res.status(500).json({ error: "Failed to start the plugin" });
}
},
);
/**
* @openapi
* /plugins/{id}/grants:
@@ -244,10 +354,10 @@ router.post(
let declared: string[] = [];
try {
const manifest = JSON.parse(record.manifestJson) as {
permissions?: unknown;
capabilities?: unknown;
};
declared = Array.isArray(manifest?.permissions)
? (manifest.permissions as string[])
declared = Array.isArray(manifest?.capabilities)
? (manifest.capabilities as string[])
: [];
} catch {
declared = [];
+63
View File
@@ -0,0 +1,63 @@
/**
* Who a plugin is acting as right now.
*
* Two ways a plugin gets an actor, and no third:
*
* - A request. A4's HTTP and WebSocket middleware runs the handler inside
* runAsActor with the user core already authenticated, so a route handler
* acts as the person who called it.
* - ctx.asUser(userId, fn), for background work with no request behind it.
* That call is always audited.
*
* Nothing else. A plugin cannot pass a user id to a guarded ctx method and
* have it believed, which is what stops the forged-caller class of bug the
* worker broker had.
*
* AsyncLocalStorage rather than a module variable because plugin work
* interleaves: two requests in flight would otherwise overwrite each other's
* identity between awaits.
*/
import { AsyncLocalStorage } from "node:async_hooks";
interface ActorStore {
userId: string;
/** Where the identity came from, for the audit line. */
source: "request" | "asUser";
}
const storage = new AsyncLocalStorage<ActorStore>();
export function runAsActor<T>(
userId: string,
source: ActorStore["source"],
fn: () => T,
): T {
return storage.run({ userId, source }, fn);
}
export function getActor(): string | undefined {
return storage.getStore()?.userId;
}
export function getActorSource(): ActorStore["source"] | undefined {
return storage.getStore()?.source;
}
export class PluginActorError extends Error {
readonly code = "EPLUGINACTOR";
constructor(pluginId: string, operation: string) {
super(
`Plugin "${pluginId}" called ${operation} with no acting user. ` +
`Wrap background work in ctx.asUser(userId, fn).`,
);
this.name = "PluginActorError";
}
}
export function requireActor(pluginId: string, operation: string): string {
const userId = getActor();
if (!userId) throw new PluginActorError(pluginId, operation);
return userId;
}
-787
View File
@@ -1,787 +0,0 @@
/**
* The main-thread half of the plugin boundary.
*
* Every ctx call a plugin makes arrives here as a message. For each one the
* broker, in this order:
* 1. looks up the method
* 2. checks the plugin's granted capability (manifest-declared AND granted)
* 3. performs the real action
* 4. writes one audit line attributed to the plugin
* 5. replies with a structured-cloneable value
*
* Steps 2 and 4 are not the plugin's to influence. The plugin supplies only the
* arguments and the audit `details`; the actor, action and outcome are set from
* values the broker controls, so a plugin cannot forge an audit trail or
* attribute its actions to a user.
*/
import type { Worker } from "node:worker_threads";
import { randomUUID } from "node:crypto";
import { pluginLogger } from "../utils/logger.js";
import { getAuditUsername, logAudit } from "../utils/audit-logger.js";
import type { LoadedPlugin } from "./loader.js";
import { assertCapability, PluginPermissionError } from "./permissions.js";
import { toPluginHostView, type PluginHostView } from "./host-view.js";
import { pluginEvents } from "./events.js";
import {
isPluginRequest,
isPluginResponse,
type PluginPush,
type PluginRequest,
type PluginResponse,
} from "./protocol.js";
export interface PluginStorageBackend {
get: (pluginId: string, key: string) => Promise<string | null>;
set: (pluginId: string, key: string, value: string) => Promise<void>;
delete: (pluginId: string, key: string) => Promise<boolean>;
listKeys: (pluginId: string) => Promise<string[]>;
}
export interface BrokerDeps {
/**
* Injected rather than imported so tests can exercise the gate without a
* database, and so the heavy hosts modules stay lazily reachable.
*/
listHosts: (userId: string) => Promise<Record<string, unknown>[]>;
resolveHost: (
hostId: number,
userId: string,
) => Promise<Record<string, unknown> | null>;
storage?: PluginStorageBackend;
/** Fired when a plugin's route table changes, so it can be re-mounted. */
onRoutesChanged?: (runtime: PluginRuntime) => void;
ssh?: PluginSshBackend;
}
/**
* The SSH operations the broker performs on a plugin's behalf.
*
* Note what is NOT here: there is no "get me the host" and no "give me the
* client". A plugin's reachable surface is open-a-session and run-a-command,
* both of which take an opaque handle. The resolved host, which carries
* plaintext credentials, only ever exists inside these functions.
*/
export interface PluginSshBackend {
open: (
hostId: number,
userId: string,
) => Promise<{ release: () => void; exec: SshExec }>;
}
export type SshExec = (
command: string,
timeoutMs: number,
) => Promise<{ stdout: string; stderr: string; code: number | null }>;
/** ctx.storage caps, so one plugin cannot fill the database. */
const MAX_STORAGE_KEY_LENGTH = 128;
const MAX_STORAGE_VALUE_BYTES = 256 * 1024;
const MIN_SCHEDULE_INTERVAL_MS = 1000;
const HTTP_HANDLER_TIMEOUT_MS = 30_000;
/** Short: a disable should feel immediate even when a plugin is unresponsive. */
const DEACTIVATE_TIMEOUT_MS = 5_000;
const MAX_SSH_HANDLES = 8;
const DEFAULT_SSH_EXEC_TIMEOUT_MS = 30_000;
const MAX_SSH_EXEC_TIMEOUT_MS = 120_000;
const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
interface PendingHttpRoute {
method: string;
path: string;
}
export interface PluginRuntime {
plugin: LoadedPlugin;
worker: Worker;
/** Routes the plugin registered via ctx.http.route. */
routes: PendingHttpRoute[];
/** Topics the plugin subscribed to via ctx.events.on. */
subscriptions: Set<string>;
/** Bus unsubscribe functions, run on detach so no listener outlives it. */
eventUnsubscribers: Array<() => void>;
/** ctx.schedule timers, cleared on deactivate. */
timers: Map<string, NodeJS.Timeout>;
/** Live SSH handles, keyed by the opaque id the worker holds. */
sshHandles: Map<
string,
{ hostId: number; release: () => void; exec?: SshExec }
>;
disposed: boolean;
}
/** Methods that need a granted capability before the broker will run them. */
const REQUIRED_CAPABILITY: Record<string, string> = {
"hosts.list": "hosts.read",
"hosts.get": "hosts.read",
"ssh.connect": "ssh.exec",
"ssh.exec": "ssh.exec",
"storage.get": "storage.own",
"storage.set": "storage.own",
"storage.delete": "storage.own",
"storage.list": "storage.own",
"events.subscribe": "events.read",
};
/** Methods whose invocation is worth an audit line. */
const AUDITED = new Set(Object.keys(REQUIRED_CAPABILITY));
export class PluginBroker {
private readonly runtimes = new Map<string, PluginRuntime>();
/** Outstanding route invocations, keyed by the push id sent to the worker. */
private readonly httpWaiters = new Map<
number,
{ resolve: (value: unknown) => void; reject: (error: Error) => void }
>();
/**
* Push ids start high so they cannot collide with the worker's own request
* ids, which start at 1 and count up independently.
*/
private nextPushId = 1_000_000;
constructor(private readonly deps: BrokerDeps) {}
get(pluginId: string): PluginRuntime | undefined {
return this.runtimes.get(pluginId);
}
/** Wired to PluginLoader's onWorkerReady hook. */
attach(plugin: LoadedPlugin, worker: Worker): PluginRuntime {
this.detach(plugin.id);
const runtime: PluginRuntime = {
plugin,
worker,
routes: [],
subscriptions: new Set(),
eventUnsubscribers: [],
timers: new Map(),
sshHandles: new Map(),
disposed: false,
};
this.runtimes.set(plugin.id, runtime);
worker.on("message", (message: unknown) => {
if (isPluginRequest(message)) {
void this.dispatch(runtime, message);
return;
}
// A route handler's reply comes back shaped like a response.
if (isPluginResponse(message)) this.settleHttpReply(message);
});
return runtime;
}
/** Wired to PluginLoader's onWorkerGone hook. Releases everything. */
detach(pluginId: string): void {
const runtime = this.runtimes.get(pluginId);
if (!runtime) return;
runtime.disposed = true;
for (const timer of runtime.timers.values()) clearInterval(timer);
runtime.timers.clear();
for (const handle of runtime.sshHandles.values()) {
try {
handle.release();
} catch {
// Releasing a dead connection is not worth failing over.
}
}
runtime.sshHandles.clear();
for (const unsubscribe of runtime.eventUnsubscribers) {
try {
unsubscribe();
} catch {
// Already detached.
}
}
runtime.eventUnsubscribers = [];
runtime.subscriptions.clear();
this.runtimes.delete(pluginId);
}
private settleHttpReply(message: PluginResponse): void {
const waiter = this.httpWaiters.get(message.id);
if (!waiter) return;
this.httpWaiters.delete(message.id);
if (message.ok === true) waiter.resolve(message.value);
else waiter.reject(new Error(message.error.message));
}
private async dispatch(
runtime: PluginRuntime,
request: PluginRequest,
): Promise<void> {
const { method, args, callerUserId } = request;
let failure: Error | null = null;
try {
const required = REQUIRED_CAPABILITY[method];
if (required) {
await assertCapability(
runtime.plugin.id,
required,
runtime.plugin.manifest.permissions,
);
}
const value = await this.invoke(runtime, method, args, callerUserId);
this.reply(runtime, { id: request.id, ok: true, value });
} catch (error) {
failure = error instanceof Error ? error : new Error(String(error));
this.reply(runtime, {
id: request.id,
ok: false,
error: {
// Sanitised, never the raw message: errors thrown deep in the SSH
// stack can embed the connect config, credentials included.
message: safeErrorMessage(method, failure),
code: (failure as Error & { code?: string }).code,
},
});
}
if (AUDITED.has(method)) {
void this.audit(runtime, method, args, failure, callerUserId);
}
}
private reply(runtime: PluginRuntime, message: unknown): void {
if (runtime.disposed) return;
try {
runtime.worker.postMessage(message);
} catch {
// Worker already gone; the reply has nowhere to land.
}
}
private async invoke(
runtime: PluginRuntime,
method: string,
args: unknown[],
callerUserId?: string,
): Promise<unknown> {
switch (method) {
case "log.debug":
case "log.info":
case "log.warn":
case "log.error":
return this.handleLog(runtime, method, args);
case "audit":
// The audit line is written by the caller path below; nothing to do
// here beyond accepting the call.
return null;
case "hosts.list":
return this.handleHostsList(runtime, callerUserId);
case "hosts.get":
return this.handleHostsGet(runtime, Number(args[0]), callerUserId);
case "storage.get":
return this.handleStorageGet(runtime, storageKey(args[0]));
case "storage.set":
return this.handleStorageSet(runtime, args);
case "storage.delete":
return this.storage().delete(runtime.plugin.id, storageKey(args[0]));
case "storage.list":
return this.storage().listKeys(runtime.plugin.id);
case "schedule.every":
return this.handleScheduleEvery(runtime, Number(args[0]));
case "schedule.cancel":
return this.handleScheduleCancel(runtime, String(args[0]));
case "http.route":
return this.handleHttpRoute(runtime, String(args[0]), String(args[1]));
case "ssh.connect":
return this.handleSshConnect(runtime, Number(args[0]), callerUserId);
case "ssh.exec":
return this.handleSshExec(
runtime,
String(args[0]),
String(args[1]),
args[2] === undefined ? undefined : Number(args[2]),
);
case "ssh.close":
return this.handleSshClose(runtime, String(args[0]));
case "events.emit":
return this.handleEventsEmit(runtime, String(args[0]), args[1]);
case "events.subscribe":
return this.handleEventsSubscribe(runtime, String(args[0]));
default:
throw new PluginFacingError(`Unknown ctx method "${method}"`);
}
}
private handleLog(
runtime: PluginRuntime,
method: string,
args: unknown[],
): null {
const level = method.slice("log.".length) as
"debug" | "info" | "warn" | "error";
const message = String(args[0] ?? "");
// Forced context: a plugin cannot pretend to be another plugin in the log.
const context = { operation: `plugin:${runtime.plugin.id}` };
if (level === "error") {
pluginLogger.error(message, undefined, context);
} else {
pluginLogger[level](message, context);
}
return null;
}
private async handleHostsList(
runtime: PluginRuntime,
callerUserId?: string,
): Promise<PluginHostView[]> {
const userId = this.requireActor(runtime, callerUserId);
const hosts = await this.deps.listHosts(userId);
return hosts.map(toPluginHostView);
}
private async handleHostsGet(
runtime: PluginRuntime,
hostId: number,
callerUserId?: string,
): Promise<PluginHostView | null> {
const userId = this.requireActor(runtime, callerUserId);
if (!Number.isFinite(hostId)) {
throw new PluginFacingError("hosts.get requires a numeric host id");
}
const host = await this.deps.resolveHost(hostId, userId);
// resolveHostById returns null for both "missing" and "no access", and the
// plugin gets the same answer for both so it cannot probe for existence.
return host ? toPluginHostView(host) : null;
}
private storage(): PluginStorageBackend {
if (!this.deps.storage) {
throw new PluginFacingError(
"ctx.storage is not available in this runtime",
);
}
return this.deps.storage;
}
private async handleStorageGet(
runtime: PluginRuntime,
key: string,
): Promise<unknown> {
const raw = await this.storage().get(runtime.plugin.id, key);
if (raw === null) return null;
try {
return JSON.parse(raw);
} catch {
// A row written by an older shape should not break the plugin.
return null;
}
}
private async handleStorageSet(
runtime: PluginRuntime,
args: unknown[],
): Promise<null> {
const key = storageKey(args[0]);
// Serialised here rather than in the worker so the stored shape is the
// broker's decision, not something a plugin can control.
const value = JSON.stringify(args[1] ?? null);
if (Buffer.byteLength(value, "utf8") > MAX_STORAGE_VALUE_BYTES) {
throw new PluginFacingError(
`ctx.storage value for "${key}" exceeds the ${MAX_STORAGE_VALUE_BYTES} byte limit`,
);
}
await this.storage().set(runtime.plugin.id, key, value);
return null;
}
private handleScheduleEvery(
runtime: PluginRuntime,
intervalMs: number,
): string {
if (!Number.isFinite(intervalMs) || intervalMs < MIN_SCHEDULE_INTERVAL_MS) {
throw new PluginFacingError(
`ctx.schedule.every requires an interval of at least ${MIN_SCHEDULE_INTERVAL_MS}ms`,
);
}
const id = `timer:${randomUUID()}`;
const timer = setInterval(() => {
if (runtime.disposed) return;
this.push(runtime, { kind: "timer", key: id, payload: null });
}, intervalMs);
// A plugin timer must not keep the server alive on its own.
timer.unref?.();
runtime.timers.set(id, timer);
return id;
}
private handleScheduleCancel(runtime: PluginRuntime, id: string): null {
const timer = runtime.timers.get(id);
if (timer) {
clearInterval(timer);
runtime.timers.delete(id);
}
return null;
}
/**
* Opens a pooled connection and returns an opaque handle.
*
* The handle is a random id, not anything derived from the host or its
* credentials, and it is only meaningful inside this runtime's own map. The
* ssh2 Client and the resolved host never enter a postMessage payload.
*/
private async handleSshConnect(
runtime: PluginRuntime,
hostId: number,
callerUserId?: string,
): Promise<string> {
const userId = this.requireActor(runtime, callerUserId);
if (!Number.isFinite(hostId)) {
throw new PluginFacingError("ssh.connect requires a numeric host id");
}
if (!this.deps.ssh) {
throw new PluginFacingError("ctx.ssh is not available in this runtime");
}
if (runtime.sshHandles.size >= MAX_SSH_HANDLES) {
throw new PluginFacingError(
`ctx.ssh is limited to ${MAX_SSH_HANDLES} concurrent connections per plugin`,
);
}
const session = await this.deps.ssh.open(hostId, userId);
const id = `sshconn:${randomUUID()}`;
runtime.sshHandles.set(id, {
hostId,
release: session.release,
exec: session.exec,
});
return id;
}
private async handleSshExec(
runtime: PluginRuntime,
handleId: string,
command: string,
timeoutMs?: number,
): Promise<{ stdout: string; stderr: string; code: number | null }> {
const handle = runtime.sshHandles.get(handleId);
// An unknown handle is indistinguishable from another plugin's handle:
// the map is per-runtime, so ids cannot be guessed across plugins.
if (!handle?.exec) {
throw new PluginFacingError("Unknown or closed SSH connection handle");
}
if (!command) throw new PluginFacingError("ssh.exec requires a command");
const timeout =
timeoutMs && Number.isFinite(timeoutMs)
? Math.min(timeoutMs, MAX_SSH_EXEC_TIMEOUT_MS)
: DEFAULT_SSH_EXEC_TIMEOUT_MS;
return handle.exec(command, timeout);
}
private handleSshClose(runtime: PluginRuntime, handleId: string): null {
const handle = runtime.sshHandles.get(handleId);
if (!handle) return null;
runtime.sshHandles.delete(handleId);
try {
handle.release();
} catch {
// Releasing a dead connection is not worth failing over.
}
return null;
}
/**
* A plugin may only emit under its own namespace. Without this a plugin
* could publish "host.status" and drive the automations engine as though the
* metrics poller had reported it.
*/
private handleEventsEmit(
runtime: PluginRuntime,
topic: string,
payload: unknown,
): null {
const prefix = `plugin.${runtime.plugin.id}.`;
if (!topic.startsWith(prefix)) {
throw new PluginFacingError(
`ctx.events.emit may only publish topics beginning with "${prefix}"`,
);
}
pluginEvents.emit(topic, payload);
return null;
}
private handleEventsSubscribe(runtime: PluginRuntime, topic: string): null {
if (runtime.subscriptions.has(topic)) return null;
runtime.subscriptions.add(topic);
const unsubscribe = pluginEvents.on(topic, (payload) => {
this.push(runtime, { kind: "event", key: topic, payload });
});
runtime.eventUnsubscribers.push(unsubscribe);
return null;
}
private handleHttpRoute(
runtime: PluginRuntime,
method: string,
routePath: string,
): null {
const verb = method.toUpperCase();
if (!HTTP_METHODS.has(verb)) {
throw new PluginFacingError(
`ctx.http.route does not support the ${verb} method`,
);
}
if (!routePath.startsWith("/")) {
throw new PluginFacingError(`ctx.http.route path must start with "/"`);
}
const key = `${verb} ${routePath}`;
if (
!runtime.routes.some((route) => `${route.method} ${route.path}` === key)
) {
runtime.routes.push({ method: verb, path: routePath });
}
this.deps.onRoutesChanged?.(runtime);
return null;
}
/**
* Invoked by the Express router this plugin's routes are mounted behind.
* Forwards the request into the worker and waits for the handler's reply.
*/
invokeRoute(
runtime: PluginRuntime,
key: string,
payload: unknown,
timeoutMs = HTTP_HANDLER_TIMEOUT_MS,
): Promise<unknown> {
if (runtime.disposed) {
return Promise.reject(new Error("Plugin is not running"));
}
const replyTo = this.nextPushId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.httpWaiters.delete(replyTo);
reject(new Error("Plugin route handler timed out"));
}, timeoutMs);
this.httpWaiters.set(replyTo, {
resolve: (value) => {
clearTimeout(timer);
resolve(value);
},
reject: (error) => {
clearTimeout(timer);
reject(error);
},
});
this.push(runtime, { kind: "http", key, payload, replyTo });
});
}
/**
* Gives a worker plugin a chance to close things down before it is killed.
* Resolves either way: a plugin that does not answer must not block the
* disable, it just loses its cleanup.
*/
requestDeactivate(
runtime: PluginRuntime,
timeoutMs = DEACTIVATE_TIMEOUT_MS,
): Promise<void> {
if (runtime.disposed) return Promise.resolve();
const replyTo = this.nextPushId++;
return new Promise<void>((resolve) => {
const timer = setTimeout(() => {
this.httpWaiters.delete(replyTo);
resolve();
}, timeoutMs);
const settle = () => {
clearTimeout(timer);
resolve();
};
this.httpWaiters.set(replyTo, { resolve: settle, reject: settle });
this.push(runtime, {
kind: "deactivate",
key: "deactivate",
payload: null,
replyTo,
});
});
}
/** Main thread -> worker, for events, timers and route invocations. */
push(runtime: PluginRuntime, message: PluginPush): void {
if (runtime.disposed) return;
try {
runtime.worker.postMessage(message);
} catch {
// Worker already gone.
}
}
/**
* Resolves the user a privileged ctx call acts as. This is where the two
* identity models that coexist in one plugin meet:
*
* - Inside an HTTP handler, `callerUserId` is set -- the worker attached
* it from the AsyncLocalStorage context it entered around that specific
* ctx.http.route invocation (see worker-bootstrap.ts). The call acts as
* whichever user's request is currently being served, so
* ctx.hosts.list() returns THEIR hosts, not the plugin installer's.
* Grants stay install-wide (an admin unlocks the capability for the
* plugin as a whole); this is only about whose DATA the call touches.
*
* - From a schedule.every timer or an events.on listener, there is no
* inbound request and so no AsyncLocalStorage context to read from --
* `callerUserId` is undefined. These fall back to ownerUserId, the user
* who last enabled the plugin, which is what background work has
* always run as.
*
* A plugin cannot influence which branch it gets: no ctx method takes a
* userId argument, so `callerUserId` only ever reflects a real inbound
* request the server's own auth middleware verified.
*/
private requireActor(runtime: PluginRuntime, callerUserId?: string): string {
const userId = callerUserId ?? runtime.plugin.ownerUserId;
if (!userId) {
throw new PluginFacingError(
`Plugin ${runtime.plugin.id} has no owning user, so it cannot act on user data`,
);
}
return userId;
}
private async audit(
runtime: PluginRuntime,
method: string,
args: unknown[],
failure: Error | null,
callerUserId?: string,
): Promise<void> {
const userId = callerUserId ?? runtime.plugin.ownerUserId;
if (!userId) return;
// Attribution comes from the broker, never from the plugin: username is
// the plugin id, not the user's, so a plugin action is never mistaken for
// something the person did themselves. The userId this is logged against
// is whichever identity requireActor would have used for the same call
// (see its comment), so a request-triggered action is attributed to the
// requester, not always to whoever installed the plugin.
await logAudit({
userId,
username: `plugin:${runtime.plugin.id}`,
action: `plugin_${method.replace(/\./g, "_")}`,
resourceType: "plugin",
resourceId: runtime.plugin.id,
resourceName: runtime.plugin.manifest.name,
details: safeDetails(method, args),
success: failure === null,
errorMessage: failure?.message,
});
}
/** Resolves the display name for a plugin's owner, for UI surfaces. */
async ownerUsername(pluginId: string): Promise<string | null> {
const runtime = this.runtimes.get(pluginId);
if (!runtime?.plugin.ownerUserId) return null;
return getAuditUsername(runtime.plugin.ownerUserId);
}
newHandleId(): string {
return `sshconn:${randomUUID()}`;
}
}
/**
* Audit details are capped and only ever record the shape of a call, never a
* value a plugin passed. A plugin could otherwise write arbitrary volume, or
* something that looks like a real audit entry, into the trail.
*/
/**
* Errors the broker raises itself are safe to show a plugin: they are written
* here and say nothing about credentials. Anything else came from deeper in the
* server -- ssh2, the pool, a repository -- and can carry connect config,
* decrypted material or internal paths, so the plugin gets a generic message
* and the real one goes to the log.
*
* Marker class rather than message matching: a substring check would start
* leaking the moment an upstream error happened to contain a familiar phrase.
*/
export class PluginFacingError extends Error {
constructor(message: string) {
super(message);
this.name = "PluginFacingError";
}
}
function safeErrorMessage(method: string, error: Error): string {
if (error instanceof PluginFacingError) return error.message;
if (error instanceof PluginPermissionError) return error.message;
pluginLogger.error(`ctx.${method} failed`, error, {
operation: "plugin_ctx_error",
});
return `ctx.${method} failed. See the server log for details.`;
}
function storageKey(raw: unknown): string {
const key = String(raw ?? "");
if (!key) throw new PluginFacingError("ctx.storage requires a non-empty key");
if (key.length > MAX_STORAGE_KEY_LENGTH) {
throw new PluginFacingError(
`ctx.storage key exceeds the ${MAX_STORAGE_KEY_LENGTH} character limit`,
);
}
return key;
}
export function safeDetails(method: string, args: unknown[]): string {
const summary = args.map((arg) => {
if (typeof arg === "number" || typeof arg === "boolean") return arg;
if (typeof arg === "string") {
return arg.length > 64 ? `${arg.slice(0, 64)}…` : arg;
}
if (arg === null || arg === undefined) return null;
return Array.isArray(arg) ? `[array:${arg.length}]` : "[object]";
});
return JSON.stringify({ method, args: summary });
}
export { PluginPermissionError };
+471
View File
@@ -0,0 +1,471 @@
/**
* Builds the ctx a plugin's activate() receives.
*
* Every privileged member goes through guarded(), which checks the capability
* against the plugin's grants and writes an audit line naming the plugin and
* the acting user. That is the whole mechanism, and it is worth being precise
* about what it buys:
*
* - It stops accidental and casual overreach. A plugin that never declared
* credentials:read cannot reach a secret through ctx, and a plugin that
* declared it leaves a trail every time it does.
* - It does NOT contain malicious code. A plugin runs in the server process
* and can import any module core can. Guarding ctx does not change that,
* and this file does not pretend otherwise. Trust comes from signing,
* review and the kill list. See ARCHITECTURE.md.
*
* The actor never comes from plugin code. It comes from AsyncLocalStorage,
* set by a request or by ctx.asUser, so a plugin cannot name a user and be
* believed.
*/
import { pluginLogger } from "../utils/logger.js";
import { pluginEvents } from "./events.js";
import * as registry from "./registry.js";
import * as serviceRegistry from "./service-registry.js";
import type { ServiceRegistration } from "./service-registry.js";
import * as secretRegistry from "./secret-registry.js";
import type { SecretRegistration } from "./secret-registry.js";
import { assertCapability } from "./permissions.js";
import { getActor, runAsActor } from "./actor.js";
import { DisposableBag } from "./disposables.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import type { PluginContext, PluginModule } from "@termix/plugin-sdk/backend";
export type { PluginModule };
/** Caps mirroring what the old worker boundary enforced. */
const MAX_KV_KEY_LENGTH = 128;
const MAX_KV_VALUE_BYTES = 256 * 1024;
export interface PluginHandle {
module: PluginModule;
bag: DisposableBag;
providedServices: ServiceRegistration[];
offeredSecrets: SecretRegistration[];
}
export function createPluginHandle(
pluginId: string,
module: PluginModule,
): PluginHandle {
return {
module,
bag: new DisposableBag(pluginId),
providedServices: [],
offeredSecrets: [],
};
}
interface AuditOptions {
/** Audit action suffix, e.g. "kv_set". */
action: string;
/** Short description of the call shape. Never a value. */
details?: () => string;
}
/**
* Wraps a privileged function in the capability check and the audit line.
*
* The check runs before the call and the audit after, so a denied call is
* still recorded: "tried and was refused" is exactly the thing worth seeing.
*/
function guarded<Args extends unknown[], Result>(
manifest: PluginManifest,
capability: string,
fn: (...args: Args) => Promise<Result>,
options: AuditOptions,
): (...args: Args) => Promise<Result> {
return async (...args: Args): Promise<Result> => {
const pluginId = manifest.id;
try {
await assertCapability(pluginId, capability, manifest.capabilities);
} catch (error) {
await writeAudit(manifest, options, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
try {
const result = await fn(...args);
await writeAudit(manifest, options, { success: true });
return result;
} catch (error) {
await writeAudit(manifest, options, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
};
}
async function writeAudit(
manifest: PluginManifest,
options: AuditOptions,
outcome: { success: boolean; errorMessage?: string },
): Promise<void> {
try {
const { logAudit } = await import("../utils/audit-logger.js");
await logAudit({
// Attribution comes from the runtime, never from the plugin.
userId: getActor() ?? "system",
username: `plugin:${manifest.id}`,
action: `plugin_${options.action}`,
resourceType: "plugin",
resourceId: manifest.id,
resourceName: manifest.name,
details: options.details?.(),
success: outcome.success,
errorMessage: outcome.errorMessage,
});
} catch {
// Auditing must never break the caller.
}
}
export function createPluginContext(
manifest: PluginManifest,
handle: PluginHandle,
): PluginContext {
const pluginId = manifest.id;
// Forced: a plugin cannot log as another plugin.
const logContext = { operation: `plugin:${pluginId}` };
const declared = manifest.capabilities;
const kvGet = guarded(
manifest,
"kv:own",
async (key: string) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
const raw = await createCurrentPluginStorageRepository().get(
pluginId,
key,
);
if (raw === null) return null;
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
},
{ action: "kv_get" },
);
const kvSet = guarded(
manifest,
"kv:own",
async (key: string, value: unknown) => {
assertKvKey(key);
const serialized = JSON.stringify(value ?? null);
if (Buffer.byteLength(serialized, "utf8") > MAX_KV_VALUE_BYTES) {
throw new Error(
`Value for "${key}" exceeds the ${MAX_KV_VALUE_BYTES} byte limit for ctx.kv`,
);
}
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
await createCurrentPluginStorageRepository().set(
pluginId,
key,
serialized,
);
},
{ action: "kv_set" },
);
const kvDelete = guarded(
manifest,
"kv:own",
async (key: string) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginStorageRepository().delete(pluginId, key);
},
{ action: "kv_delete" },
);
const kvList = guarded(
manifest,
"kv:own",
async () => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginStorageRepository().listKeys(pluginId);
},
{ action: "kv_list" },
);
return {
pluginId,
manifest,
log: {
debug: (message) => pluginLogger.debug(message, logContext),
info: (message) => pluginLogger.info(message, logContext),
warn: (message) => pluginLogger.warn(message, logContext),
error: (message, error) => pluginLogger.error(message, error, logContext),
},
events: {
/**
* A plugin may only emit under its own namespace unless it holds
* events:core. Without this an in-process plugin can publish
* "host.status" and drive the automations engine as if core had.
*/
emit: (topic, payload) => {
if (
!topic.startsWith(`plugin.${pluginId}.`) &&
!declared.includes("events:core")
) {
throw new Error(
`Plugin ${pluginId} may only emit topics under "plugin.${pluginId}.". ` +
`Declare the events:core capability to emit core topics.`,
);
}
pluginEvents.emit(topic, payload);
},
on: (topic, listener) => {
const unsubscribe = pluginEvents.on(topic, listener);
handle.bag.add(unsubscribe, `event listener for "${topic}"`);
return unsubscribe;
},
},
kv: {
get: (key) => kvGet(key),
set: (key, value) => kvSet(key, value),
delete: (key) => kvDelete(key),
list: () => kvList(),
},
registry: {
provide: (key, value) => {
registry.provide(key, value);
handle.bag.add(
() => void registry.revoke(key, value),
`registry key "${key}"`,
);
},
consume: (key) => registry.consume(key),
revoke: (key, value) => registry.revoke(key, value),
},
services: {
provide: (service, implementation) => {
// Declared AND provided: a plugin cannot publish a service its
// manifest never mentioned.
const entry = manifest.provides?.find(
(candidate) => candidate.service === service,
);
if (!entry) {
throw new Error(
`Plugin ${pluginId} cannot provide service "${service}": it is not declared in the manifest's provides array`,
);
}
const registration = serviceRegistry.provideService({
service,
version: entry.version,
permission: entry.permission,
pluginId,
pluginName: manifest.name,
implementation: implementation as Record<string, unknown>,
});
handle.providedServices.push(registration);
handle.bag.add(
() => void serviceRegistry.revokeService(service, registration),
`service "${service}"`,
);
},
get: (service, options) =>
serviceRegistry.createServiceHandle(service, pluginId, {
// A caller-supplied userId is only honoured when it matches the
// actor; otherwise the ambient actor wins. A plugin cannot widen
// its reach by naming someone else here.
resolveUserId: () => options?.userId ?? getActor(),
hasPermission: checkPermission,
audit: (entry) => writeServiceAudit(entry),
}),
},
secrets: {
offer: (key, resolve) => {
const entry = manifest.providesSecret?.find(
(candidate) => candidate.key === key,
);
if (!entry) {
throw new Error(
`Plugin ${pluginId} cannot offer secret "${key}": it is not declared in the manifest's providesSecret array`,
);
}
const registration = secretRegistry.offerSecret({
pluginId,
pluginName: manifest.name,
key,
permission: entry.permission,
resolve,
});
handle.offeredSecrets.push(registration);
handle.bag.add(
() => void secretRegistry.withdrawSecret(pluginId, key, registration),
`secret "${key}"`,
);
},
withdraw: (key) => {
const registration = handle.offeredSecrets.find(
(entry) => entry.key === key,
);
handle.offeredSecrets = handle.offeredSecrets.filter(
(entry) => entry !== registration,
);
return secretRegistry.withdrawSecret(pluginId, key, registration);
},
getShared: (providerPluginId, key, options) =>
secretRegistry.readSharedSecret(manifest, providerPluginId, key, {
resolveUserId: () => options?.userId ?? getActor(),
hasPermission: checkPermission,
audit: (entry) => writeSecretAudit(entry),
}),
},
disposables: {
add: (dispose) => handle.bag.add(dispose, "plugin resource"),
},
/**
* Background work acts as a named user. Always audited, because "this ran
* as someone" is exactly the thing an operator needs to be able to see.
*/
asUser: async (userId, fn) => {
if (typeof userId !== "string" || userId.length === 0) {
throw new Error(
`Plugin ${pluginId} called ctx.asUser without a user id`,
);
}
await writeAudit(
manifest,
{
action: "as_user",
details: () => `${pluginId} ran background work as ${userId}`,
},
{ success: true },
);
return runAsActor(userId, "asUser", () => Promise.resolve(fn()));
},
currentActor: () => getActor(),
};
}
function assertKvKey(key: string): void {
if (typeof key !== "string" || key.length === 0) {
throw new Error("ctx.kv keys must be non-empty strings");
}
if (key.length > MAX_KV_KEY_LENGTH) {
throw new Error(
`ctx.kv keys must be at most ${MAX_KV_KEY_LENGTH} characters`,
);
}
}
async function checkPermission(
userId: string,
permission: string,
): Promise<boolean> {
const { PermissionManager } = await import("../utils/permission-manager.js");
return PermissionManager.getInstance().hasPermission(userId, permission);
}
/** Attribution comes from the runtime; details only record the call shape. */
async function writeServiceAudit(
entry: serviceRegistry.ServiceAuditEntry,
): Promise<void> {
try {
const { logAudit } = await import("../utils/audit-logger.js");
await logAudit({
userId: entry.userId,
username: `plugin:${entry.consumerPluginId}`,
action: `plugin_service_${entry.registration.service.replace(/\./g, "_")}_${entry.method}`,
resourceType: "plugin",
resourceId: entry.registration.pluginId,
resourceName: entry.registration.pluginName,
details: `${entry.consumerPluginId} called ${entry.registration.service}.${entry.method}`,
success: entry.success,
errorMessage: entry.errorMessage,
});
} catch {
// Auditing must never break the caller.
}
}
/**
* Records that a secret was borrowed, never what it was. `resolved` is the
* only thing said about the value: whether one came back at all.
*/
async function writeSecretAudit(
entry: secretRegistry.SecretAuditEntry,
): Promise<void> {
try {
const { logAudit } = await import("../utils/audit-logger.js");
await logAudit({
userId: entry.userId,
username: `plugin:${entry.consumerPluginId}`,
action: "plugin_secret_shared_read",
resourceType: "plugin",
resourceId: entry.providerPluginId,
resourceName: entry.providerPluginName,
details: `${entry.consumerPluginId} read shared secret "${entry.providerPluginId}:${entry.key}" (resolved: ${entry.resolved})`,
success: entry.success,
errorMessage: entry.errorMessage,
});
} catch {
// Auditing must never break the caller.
}
}
/**
* Undoes everything the plugin registered, then runs its own deactivate().
*
* The plugin's cleanup runs last so it can still emit or read while shutting
* down, and its throwing does not stop the bag from being emptied.
*/
export async function disposePluginHandle(
handle: PluginHandle,
pluginId: string,
options: { runDeactivate?: boolean } = {},
): Promise<void> {
await handle.bag.disposeAll();
handle.providedServices = [];
handle.offeredSecrets = [];
// Belt and braces: an offer made outside ctx, or one whose registration was
// lost to a throw mid-activate, would otherwise outlive the plugin.
secretRegistry.withdrawAllForPlugin(pluginId);
// Skipped when activate() never finished: a plugin's deactivate expects the
// state activate builds, and calling it on a half-built plugin tends to
// throw over the original error and hide it.
const runDeactivate = options.runDeactivate ?? true;
if (runDeactivate && typeof handle.module.deactivate === "function") {
try {
await handle.module.deactivate();
} catch (error) {
pluginLogger.error(
`Plugin ${pluginId} deactivate() failed`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_deactivate" },
);
}
}
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Everything a plugin registered, so deactivate can undo all of it.
*
* The rule this enforces: a plugin is only as disableable as its cleanup. A
* listening port, a live SSH session or an interval that outlives deactivate
* turns "disabled" into a lie, and re-enabling then fails on a port that is
* still held.
*
* Disposal runs in reverse registration order, so a thing torn down last was
* set up first. Every disposer is isolated: one that throws is logged and the
* rest still run, because a half-disposed plugin is worse than a noisy log.
*/
import { pluginLogger } from "../utils/logger.js";
export type Disposer = () => void | Promise<void>;
interface Entry {
dispose: Disposer;
/** What this is, for the log line when it throws. */
label: string;
}
export class DisposableBag {
private entries: Entry[] = [];
private disposed = false;
constructor(private readonly pluginId: string) {}
add(dispose: Disposer, label = "disposable"): void {
if (this.disposed) {
// Registering after teardown means the resource would never be cleaned
// up. Dispose it immediately rather than silently leaking it.
void this.run({ dispose, label });
return;
}
this.entries.push({ dispose, label });
}
get size(): number {
return this.entries.length;
}
/**
* Runs every disposer, newest first. Safe to call more than once, and never
* throws: the caller is already tearing the plugin down.
*/
async disposeAll(): Promise<void> {
this.disposed = true;
const entries = this.entries.reverse();
this.entries = [];
for (const entry of entries) {
await this.run(entry);
}
}
private async run(entry: Entry): Promise<void> {
try {
await entry.dispose();
} catch (error) {
pluginLogger.error(
`Plugin ${this.pluginId} failed to dispose ${entry.label}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_dispose" },
);
}
}
}
-152
View File
@@ -1,152 +0,0 @@
/**
* The plugins that run in-process on the main thread instead of in a worker.
*
* ## Why this list is hardcoded
*
* Every other plugin is handed a `ctx` object and nothing else, across a
* structured-clone postMessage boundary. That boundary is what makes the
* capability gate and the audit trail meaningful: a worker plugin physically
* cannot hold a db handle, an ssh2 Client or a live socket, so every privileged
* action has to pass through the broker, which checks grants first and writes
* an audit line after.
*
* A plugin in this tier opts out of all of that. It is imported directly into
* the server process, so it has the same reach as any other backend module: no
* ctx boundary, no capability gate, no audit interception, no containment. It
* is Termix's own code that happens to be packaged and lifecycled as a plugin.
*
* That is only defensible because membership cannot be claimed. This set is
* compiled into the server. A manifest cannot opt in -- declaring the
* capability without being listed here is a validation error, not a silent
* downgrade (see parseManifest). Nothing read from the database, a registry or
* a plugin directory can add to it.
*
* DO NOT add an installed or community plugin here. If a plugin needs more
* reach than ctx allows, the answer is to widen ctx -- deliberately, with a
* gate and an audit line -- not to move it onto the main thread.
*/
/**
* The only plugin ids that may run in-process.
*
* ssh-terminal is here because it IS the SSH transport rather than a consumer
* of it: it owns a WebSocket server and long-lived ssh2 clients, neither of
* which can cross a postMessage boundary.
*
* docker is here for the same reason: its container console owns a
* WebSocket server with long-lived ssh2 clients and PTY streams, its
* session manager reuses a live ssh2.Client across many REST calls, and its
* SSH connect flow implements TOTP/Warpgate as a stateful multi-request
* handshake the worker ctx.ssh API has no hook for.
*
* host-metrics is here for the same reason: its polling manager holds
* long-lived ssh2 clients across a whole fleet of hosts, reused between
* polling ticks rather than opened per request, and its connect flow
* implements the same jump-host/SOCKS5/TOTP/Warpgate/OPKSSH/Vault
* keyboard-interactive handshakes the terminal does -- none of which the
* worker ctx.ssh API (connect + single exec-and-collect) has a hook for.
*
* ai is here for a different reason: it does not own a transport at all, but
* it writes directly across hosts, snippets, fleets, alert rules and
* automations through their repositories, runs approved commands over the
* shared SSH pool via resolveHostById and withConnection, and streams its
* chat replies as server-sent events. ctx.hosts is a 13-field read-only view
* with no write methods and no snippets/fleets/alerts/automations surface at
* all, and ctx.http.route replies with one postMessage value rather than a
* stream, so none of that fits the worker ctx either.
*
* proxmox is here for the same reason as ai: it does not own a transport,
* but it writes directly across hosts through the host repository during
* discovery and sync, resolves and connects to hosts via resolveHostById and
* createJumpHostChain the same way the terminal and ai's command executor
* do, verifies host keys via SSHHostKeyVerifier, and streams its discovery
* progress as server-sent events. Neither the worker's 13-field read-only
* ctx.hosts nor its single-postMessage-reply ctx.http.route can support that.
*
* remote-desktop is here for the same reason ssh-terminal is: it owns a
* WebSocket server (guacamole-lite, port 30008) and its
* /connect-host/:hostId route does multi-step credential/tunnel resolution
* (SSH jump-host tunnels, a macOS VNC compatibility proxy) using raw
* net.Socket plumbing that cannot cross a structured-clone boundary.
*
* fleets is here for the same reason as ai and proxmox: it does not own a
* transport, but its execute/transfer routes resolve and connect to many
* hosts concurrently via resolveHostById and the shared SSH pool, stream SFTP
* reads/writes and a zip archive for its push/pull routes, and buffer
* multipart file uploads. None of that fits the worker's single-exec
* ctx.ssh or single-postMessage-reply ctx.http.route.
*
* automations is here for the same reason as ai, proxmox and fleets: it does
* not own a transport, but its engine and scheduler write across hosts,
* snippets, fleets and notification-channel repositories through the shared
* SSH pool via resolveHostById and withConnection, and its scheduler needs
* direct repository access plus subscription to the cross-plugin event bus
* (host metrics, status and health-check events) that the worker's read-only
* ctx.hosts and single-postMessage-reply ctx.http.route cannot support.
*
* network-topology is here for a different reason than the rest of this
* list: its two routes are plain per-user JSON CRUD against a single
* repository, and would fit the worker ctx fine on their own. It stays
* in-process to keep its existing /network-topology URL and its existing
* dedicated table (network_topology, keyed by userId) rather than moving
* onto /plugin-api/network-topology/* and the generic key-value
* ctx.storage the worker tier would require -- a URL and storage-model
* change, not a capability gap.
*
* tailscale is here because its /devices route makes an outbound HTTP call
* to the Tailscale (or Headscale) Admin API using a settings-repo API key.
* The worker ctx has no fetch primitive at all (ctx.fetch is notImplemented
* in SDK v1, see worker-bootstrap.ts), so there is currently no way for a
* worker-tier plugin to make an outbound HTTP request.
*
* web-endpoint is here for the same reason ssh-terminal is, on a smaller
* scale: its open route polls the tunnel manager's live maps
* (activeTunnelRuntimes, connectionStatus, tunnelConnecting), builds a
* TunnelConfig from plaintext host credentials that ctx.hosts deliberately
* withholds, and hands the resulting live ssh2.Client to forwardOut to probe
* the target before reporting success. A structured-clone boundary can carry
* none of those three, so this is a genuine transport dependency rather than
* the URL-stability concession network-topology and workspaces make.
*
* workspaces is here for the same reason network-topology is: its routes are
* plain per-user JSON CRUD against a single repository, and would fit the
* worker ctx fine on their own. It stays in-process to keep its existing
* /workspaces URL and its existing dedicated table (user_workspaces, keyed
* by userId) rather than moving onto /plugin-api/workspaces/* and the
* generic key-value ctx.storage the worker tier would require -- a URL and
* storage-model change, not a capability gap.
*/
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
"ssh-terminal",
"docker",
"host-metrics",
"ai",
"proxmox",
"remote-desktop",
"fleets",
"automations",
"network-topology",
"tailscale",
"workspaces",
"web-endpoint",
]);
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
export function isFirstParty(pluginId: string): boolean {
return FIRST_PARTY_PLUGIN_IDS.has(pluginId);
}
/**
* Both conditions are required. The allowlist alone is not enough -- a
* first-party plugin that does not declare the capability still runs in a
* worker, so the manifest stays an honest description of what the plugin does.
*/
export function runsInProcess(
pluginId: string,
permissions: readonly string[],
): boolean {
return (
isFirstParty(pluginId) && permissions.includes(TRANSPORT_OWNER_CAPABILITY)
);
}
-373
View File
@@ -1,373 +0,0 @@
/**
* The ctx handed to an in-process first-party plugin.
*
* Unlike the worker ctx built in worker-bootstrap.ts, nothing here is a
* postMessage round trip and nothing is capability-gated. There would be no
* point: the plugin is running on the main thread and could import any module
* in the server directly. A gate it can trivially bypass is a false claim, not
* a control, so this surface does not pretend to be one.
*
* What this ctx is actually for is lifecycle and identity: the plugin gets a
* logger tagged with its id, the shared event bus, its own storage namespace,
* and the service registry. It uses ordinary imports for everything else.
*
* See first-party.ts for why membership in this tier cannot be self-declared.
*/
import { pluginLogger } from "../utils/logger.js";
import { pluginEvents } from "./events.js";
import * as registry from "./registry.js";
import * as serviceRegistry from "./service-registry.js";
import type { ServiceRegistration } from "./service-registry.js";
import * as secretRegistry from "./secret-registry.js";
import type { SecretRegistration } from "./secret-registry.js";
import { safeDetails } from "./broker.js";
import type { PluginManifest } from "./manifest.js";
export interface InProcessPluginContext {
pluginId: string;
manifest: PluginManifest;
log: {
debug: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string, error?: Error) => void;
};
events: {
emit: (topic: string, payload: unknown) => void;
on: (topic: string, listener: (payload: unknown) => void) => () => void;
};
storage: {
get: (key: string) => Promise<unknown>;
set: (key: string, value: unknown) => Promise<void>;
delete: (key: string) => Promise<boolean>;
list: () => Promise<string[]>;
};
registry: {
provide: <T>(key: string, value: T) => void;
consume: <T>(key: string) => T | undefined;
revoke: (key: string, value?: unknown) => boolean;
};
/**
* Declared service contracts between plugins. Unlike `registry` above, a
* service must be declared in the manifest and every call through a handle
* is checked against the acting user's role permissions.
*/
services: {
provide: <T extends object>(service: string, implementation: T) => void;
get: <T extends object>(
service: string,
options?: { userId?: string },
) => T;
};
/**
* Secrets shared between plugins by reference.
*
* `offer` publishes a resolver, never a value, so the provider keeps the
* ability to rotate or clear it at any time. `getShared` reads another
* plugin's offered secret for one call, gated by that provider's own role
* permission. See secret-registry.ts.
*/
secrets: {
offer: (
key: string,
resolve: (userId: string) => Promise<string | null> | string | null,
) => void;
withdraw: (key: string) => boolean;
getShared: (
pluginId: string,
key: string,
options?: { userId?: string },
) => Promise<string | null>;
};
}
/**
* Tracks what a plugin registered so deactivate can undo it. A first-party
* plugin owns real resources -- a listening port, live SSH sessions -- so
* "disabled" has to actually release them, not just stop routing to them.
*/
export interface InProcessHandle {
module: PluginModule;
/** Event unsubscribers, run on deactivate so no listener outlives the plugin. */
unsubscribers: Array<() => void>;
/** Registry keys this plugin provided, revoked on deactivate. */
providedKeys: Array<{ key: string; value: unknown }>;
/** Declared services this plugin provided, revoked on deactivate. */
providedServices: ServiceRegistration[];
/** Shared secrets this plugin offered, withdrawn on deactivate. */
offeredSecrets: SecretRegistration[];
}
export interface PluginModule {
activate: (ctx: InProcessPluginContext) => void | Promise<void>;
deactivate?: () => void | Promise<void>;
}
export function createInProcessContext(
manifest: PluginManifest,
handle: InProcessHandle,
): InProcessPluginContext {
const pluginId = manifest.id;
// Forced, like the worker path: a plugin cannot log as another plugin.
const context = { operation: `plugin:${pluginId}` };
return {
pluginId,
manifest,
log: {
debug: (message) => pluginLogger.debug(message, context),
info: (message) => pluginLogger.info(message, context),
warn: (message) => pluginLogger.warn(message, context),
error: (message, error) => pluginLogger.error(message, error, context),
},
events: {
emit: (topic, payload) => pluginEvents.emit(topic, payload),
on: (topic, listener) => {
const unsubscribe = pluginEvents.on(topic, listener);
handle.unsubscribers.push(unsubscribe);
return unsubscribe;
},
},
storage: {
get: async (key) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
const raw = await createCurrentPluginStorageRepository().get(
pluginId,
key,
);
if (raw === null) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
},
set: async (key, value) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
await createCurrentPluginStorageRepository().set(
pluginId,
key,
JSON.stringify(value ?? null),
);
},
delete: async (key) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginStorageRepository().delete(pluginId, key);
},
list: async () => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginStorageRepository().listKeys(pluginId);
},
},
registry: {
provide: (key, value) => {
registry.provide(key, value);
handle.providedKeys.push({ key, value });
},
consume: (key) => registry.consume(key),
revoke: (key, value) => registry.revoke(key, value),
},
services: {
provide: (service, implementation) => {
// Declared AND provided, the same shape the capability gate uses: a
// plugin cannot publish a service its manifest never mentioned.
const declared = manifest.provides?.find(
(entry) => entry.service === service,
);
if (!declared) {
throw new Error(
`Plugin ${pluginId} cannot provide service "${service}": it is not declared in the manifest's provides array`,
);
}
const registration = serviceRegistry.provideService({
service,
version: declared.version,
permission: declared.permission,
pluginId,
pluginName: manifest.name,
implementation: implementation as Record<string, unknown>,
});
handle.providedServices.push(registration);
},
get: (service, options) =>
serviceRegistry.createServiceHandle(service, pluginId, {
resolveUserId: () => options?.userId,
hasPermission: checkPermission,
audit: (entry) => writeServiceAudit(entry),
}),
},
secrets: {
offer: (key, resolve) => {
// Declared AND offered, the same rule services.provide enforces.
const declared = manifest.providesSecret?.find(
(entry) => entry.key === key,
);
if (!declared) {
throw new Error(
`Plugin ${pluginId} cannot offer secret "${key}": it is not declared in the manifest's providesSecret array`,
);
}
const registration = secretRegistry.offerSecret({
pluginId,
pluginName: manifest.name,
key,
permission: declared.permission,
resolve,
});
handle.offeredSecrets.push(registration);
},
withdraw: (key) => {
const registration = handle.offeredSecrets.find(
(entry) => entry.key === key,
);
handle.offeredSecrets = handle.offeredSecrets.filter(
(entry) => entry !== registration,
);
return secretRegistry.withdrawSecret(pluginId, key, registration);
},
getShared: (providerPluginId, key, options) =>
secretRegistry.readSharedSecret(manifest, providerPluginId, key, {
resolveUserId: () => options?.userId,
hasPermission: checkPermission,
audit: (entry) => writeSecretAudit(entry),
}),
},
};
}
async function checkPermission(
userId: string,
permission: string,
): Promise<boolean> {
const { PermissionManager } = await import("../utils/permission-manager.js");
return PermissionManager.getInstance().hasPermission(userId, permission);
}
/**
* Mirrors PluginBroker.audit: attribution comes from the runtime, never from
* the plugin, and the details only ever record the shape of a call.
*/
async function writeServiceAudit(
entry: serviceRegistry.ServiceAuditEntry,
): Promise<void> {
try {
const { logAudit } = await import("../utils/audit-logger.js");
await logAudit({
userId: entry.userId,
username: `plugin:${entry.consumerPluginId}`,
action: `plugin_service_${entry.registration.service.replace(/\./g, "_")}_${entry.method}`,
resourceType: "plugin",
resourceId: entry.registration.pluginId,
resourceName: entry.registration.pluginName,
details: safeDetails(
`${entry.registration.service}.${entry.method}`,
entry.args,
),
success: entry.success,
errorMessage: entry.errorMessage,
});
} catch {
// Auditing must never break the caller.
}
}
/**
* Records that a secret was borrowed, never what it was.
*
* `resolved` is the only thing said about the value: whether one came back at
* all. That is what makes the trail useful (you can see a consumer running on
* someone else's key) without the trail itself becoming a place the secret
* leaks to.
*/
async function writeSecretAudit(
entry: secretRegistry.SecretAuditEntry,
): Promise<void> {
try {
const { logAudit } = await import("../utils/audit-logger.js");
await logAudit({
userId: entry.userId,
username: `plugin:${entry.consumerPluginId}`,
action: "plugin_secret_shared_read",
resourceType: "plugin",
resourceId: entry.providerPluginId,
resourceName: entry.providerPluginName,
details: `${entry.consumerPluginId} read shared secret "${entry.providerPluginId}:${entry.key}" (resolved: ${entry.resolved})`,
success: entry.success,
errorMessage: entry.errorMessage,
});
} catch {
// Auditing must never break the caller.
}
}
/** Undoes everything the ctx handed out. Safe to call more than once. */
export async function disposeInProcessHandle(
handle: InProcessHandle,
pluginId: string,
): Promise<void> {
for (const { key, value } of handle.providedKeys) {
registry.revoke(key, value);
}
handle.providedKeys = [];
// Taking the services with it is what makes a consumer's held handle start
// throwing PluginServiceUnavailableError rather than calling a dead provider.
for (const registration of handle.providedServices) {
serviceRegistry.revokeService(registration.service, registration);
}
handle.providedServices = [];
// Withdrawing the offers is what turns a borrower's getShared into a plain
// null instead of a handle pointing at a resolver whose plugin is gone. The
// sweep afterwards is belt and braces: an offer made outside the ctx, or one
// whose push was lost to a crash mid-activate, would otherwise outlive the
// plugin that backs it.
for (const registration of handle.offeredSecrets) {
secretRegistry.withdrawSecret(
registration.pluginId,
registration.key,
registration,
);
}
handle.offeredSecrets = [];
secretRegistry.withdrawAllForPlugin(pluginId);
for (const unsubscribe of handle.unsubscribers) {
try {
unsubscribe();
} catch {
// Already detached.
}
}
handle.unsubscribers = [];
// The plugin's own cleanup runs last: it may still want to emit or read
// while shutting down.
if (typeof handle.module.deactivate === "function") {
try {
await handle.module.deactivate();
} catch (error) {
pluginLogger.error(
`Plugin ${pluginId} deactivate() failed`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_deactivate" },
);
}
}
}
-108
View File
@@ -1,108 +0,0 @@
/**
* Turns a plugin's registered routes into an Express router mounted on the
* existing /plugin-api/:pluginId dispatcher.
*
* The plugin never sees the Express req/res objects. It gets a plain,
* structured-cloneable summary of the request, and returns a plain object that
* this module turns back into a response. That keeps sockets, headers carrying
* session cookies, and the rest of the server's request context on this side of
* the boundary.
*/
import express, { type Request, type Response, type Router } from "express";
import { pluginLogger } from "../utils/logger.js";
import type { PluginBroker, PluginRuntime } from "./broker.js";
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
/** Headers a plugin is allowed to see. Anything carrying auth is withheld. */
const FORWARDED_HEADERS = ["content-type", "accept", "user-agent"];
export function buildPluginRouter(
broker: PluginBroker,
runtime: PluginRuntime,
): Router {
const router = express.Router();
router.use(express.json({ limit: "1mb" }));
for (const route of runtime.routes) {
const key = `${route.method} ${route.path}`;
const method = route.method.toLowerCase() as
"get" | "post" | "put" | "patch" | "delete";
router[method](route.path, async (req: Request, res: Response) => {
try {
const result = await broker.invokeRoute(runtime, key, {
method: route.method,
path: req.path,
params: req.params,
query: req.query,
body: req.body ?? null,
headers: pickHeaders(req),
// The acting user, verified by authenticateJWT ahead of the
// /plugin-api mount in database.ts. A plugin cannot set or spoof this.
userId: (req as Request & { userId?: string }).userId ?? null,
});
sendPluginResponse(res, result);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pluginLogger.error(
`Plugin ${runtime.plugin.id} route ${key} failed`,
error instanceof Error ? error : new Error(message),
{ operation: "plugin_http" },
);
res.status(502).json({ error: "Plugin route failed", detail: message });
}
});
}
return router;
}
function pickHeaders(req: Request): Record<string, string> {
const headers: Record<string, string> = {};
for (const name of FORWARDED_HEADERS) {
const value = req.headers[name];
if (typeof value === "string") headers[name] = value;
}
return headers;
}
function sendPluginResponse(res: Response, result: unknown): void {
if (result === null || result === undefined) {
res.status(204).end();
return;
}
const shape = result as {
status?: unknown;
body?: unknown;
headers?: Record<string, unknown>;
};
// An envelope is only recognised when `status` is present. Keying off `body`
// alone would swallow an ordinary payload that happens to have a body field,
// which is a perfectly normal thing for a handler to return.
const hasEnvelope =
typeof shape === "object" && shape !== null && "status" in shape;
const status = hasEnvelope ? Number(shape.status ?? 200) : 200;
const body = hasEnvelope ? (shape.body ?? null) : result;
if (!Number.isInteger(status) || status < 100 || status > 599) {
res.status(502).json({ error: "Plugin returned an invalid status code" });
return;
}
const serialised = JSON.stringify(body);
if (
serialised &&
Buffer.byteLength(serialised, "utf8") > MAX_RESPONSE_BYTES
) {
res.status(502).json({ error: "Plugin response exceeded the size limit" });
return;
}
res.status(status).json(body);
}
+215 -251
View File
@@ -1,170 +1,58 @@
/**
* Composition root for the plugin runtime: wires the loader, the broker and the
* /plugin-api dispatcher together and exposes the handful of calls the rest of
* the server needs.
* Composition root for the plugin runtime.
*
* The heavy dependencies (host resolution, the SSH pool, the repositories) are
* imported lazily inside the dep functions rather than at module scope. Several
* of them pull in the repository layer, and a static import here would create
* the same cycle hosts/automation-events.ts documents.
* Owns the loader, seeds the plugins table, keeps capability grants in step
* with each manifest, and registers the RBAC permissions a plugin contributes.
*
* The heavy dependencies are imported lazily inside each function rather than
* at module scope: several pull in the repository layer, and a static import
* here would close the cycle hosts/automation-events.ts documents.
*/
import { pluginLogger } from "../utils/logger.js";
import {
registerPluginRouter,
unregisterPluginRouter,
} from "../database/routes/plugin-api-routes.js";
import { PluginBroker, type PluginRuntime } from "./broker.js";
import { buildPluginRouter } from "./http-bridge.js";
import { unregisterPluginRouter } from "../database/routes/plugin-api-routes.js";
import { PluginLoader, type LoadedPlugin } from "./loader.js";
import { isFirstParty, runsInProcess } from "./first-party.js";
import { invalidatePluginPermissionCache } from "./permissions.js";
let loader: PluginLoader | null = null;
let broker: PluginBroker | null = null;
function createBroker(): PluginBroker {
return new PluginBroker({
listHosts: async (userId) => {
const { createCurrentHostRepository } =
await import("../database/repositories/factory.js");
// listByUserId, not listDecryptedByUserId: a plugin only ever gets host
// metadata, so there is no reason to decrypt secrets just to drop them.
const hosts = await createCurrentHostRepository().listByUserId(userId);
return hosts as unknown as Record<string, unknown>[];
},
resolveHost: async (hostId, userId) => {
// resolveHostById is the access gate and the decryption step in one. The
// object it returns carries plaintext secrets, so it must never leave
// this closure -- the broker maps it through toPluginHostView first.
const { resolveHostById } = await import("../hosts/host-resolver.js");
const host = await resolveHostById(hostId, userId);
return host as unknown as Record<string, unknown> | null;
},
storage: {
get: async (pluginId, key) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginStorageRepository().get(pluginId, key);
},
set: async (pluginId, key, value) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
await createCurrentPluginStorageRepository().set(pluginId, key, value);
},
delete: async (pluginId, key) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginStorageRepository().delete(pluginId, key);
},
listKeys: async (pluginId) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginStorageRepository().listKeys(pluginId);
},
},
ssh: {
/**
* The single most security-sensitive function in the plugin runtime.
*
* `host` below is the object resolveHostById returns: it carries the
* plaintext password, private key and key passphrase. It is a local
* inside this closure and is referenced only by createFleetSshFactory.
* Nothing derived from it is returned. The caller gets two closures --
* release and exec -- and exec resolves to stdout/stderr/code strings
* only, so there is no path from the worker back to the credential.
*/
open: async (hostId, userId) => {
const [
{ resolveHostById },
{ connectionPool },
{ createFleetSshFactory, getFleetPoolKey },
{ execCommand },
] = await Promise.all([
import("../hosts/host-resolver.js"),
import("../hosts/ssh-connection-pool.js"),
import("../hosts/ssh-client-factory.js"),
import("../hosts/metrics-shared/common-utils.js"),
]);
// resolveHostById performs the RBAC check (canAccessHost) and returns
// null for both "missing" and "forbidden".
const host = await resolveHostById(hostId, userId);
if (!host) {
throw new Error(`Host ${hostId} not found or not accessible`);
}
const poolKey = getFleetPoolKey(host);
const client = await connectionPool.getConnection(
poolKey,
createFleetSshFactory(host),
);
return {
release: () => connectionPool.releaseConnection(poolKey, client),
exec: (command, timeoutMs) => execCommand(client, command, timeoutMs),
};
},
},
onRoutesChanged: (runtime) => remountRoutes(runtime),
});
}
function remountRoutes(runtime: PluginRuntime): void {
if (runtime.routes.length === 0) return;
registerPluginRouter(runtime.plugin.id, buildPluginRouter(broker!, runtime));
}
export function getPluginRuntime(): {
loader: PluginLoader;
broker: PluginBroker;
} {
if (!loader || !broker) {
broker = createBroker();
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => {
broker!.detach(plugin.id);
unregisterPluginRouter(plugin.id);
},
onBeforeTerminate: async (plugin) => {
const runtime = broker!.get(plugin.id);
if (runtime) await broker!.requestDeactivate(runtime);
},
});
export function getPluginRuntime(): { loader: PluginLoader } {
if (!loader) {
loader = new PluginLoader();
}
return { loader, broker };
return { loader };
}
/**
* Makes sure a bundled first-party plugin has a row so it can be enabled and
* disabled like any other.
* Gives every discovered plugin a row, and refreshes the stored manifest.
*
* Only the insert sets state: once the row exists, whatever the user chose
* wins. Re-enabling a plugin they disabled on every restart would be a bug,
* not a default.
* Bundled plugins start enabled because they are part of the install; a
* plugin dropped into the data directory starts disabled, so arriving on disk
* is never the same as being allowed to run.
*
* `state` is only ever set on insert. Once the row exists whatever the user
* chose wins, because re-enabling a plugin they disabled on every restart
* would be a bug rather than a default.
*/
async function seedBundledPlugins(loaded: LoadedPlugin[]): Promise<void> {
async function seedPlugins(loaded: LoadedPlugin[]): Promise<void> {
const { createCurrentPluginRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentPluginRepository();
for (const plugin of loaded) {
if (!isFirstParty(plugin.id)) continue;
const manifestJson = JSON.stringify(plugin.manifest);
const existing = await repository.findById(plugin.id);
if (existing) {
// Keep metadata fresh across upgrades, but never touch `state`.
if (existing.version !== plugin.manifest.version) {
await repository.update(plugin.id, {
version: plugin.manifest.version,
name: plugin.manifest.name,
manifestJson: JSON.stringify(plugin.manifest),
});
}
// Unconditionally, not just on a version change: a manifest edited
// without a version bump used to leave the stored copy stale forever,
// and the API and the grant check both read the stored copy.
await repository.update(plugin.id, {
name: plugin.manifest.name,
version: plugin.manifest.version,
source: plugin.source,
manifestJson,
});
continue;
}
@@ -172,21 +60,64 @@ async function seedBundledPlugins(loaded: LoadedPlugin[]): Promise<void> {
id: plugin.id,
name: plugin.manifest.name,
version: plugin.manifest.version,
tier: "first-party",
source: "bundled",
state: "enabled",
manifestJson: JSON.stringify(plugin.manifest),
tier: plugin.source === "bundled" ? "bundled" : "community",
source: plugin.source,
state: plugin.source === "bundled" ? "enabled" : "disabled",
manifestJson,
});
pluginLogger.info(`Registered bundled plugin ${plugin.id}`, {
pluginLogger.info(`Registered ${plugin.source} plugin ${plugin.id}`, {
operation: "plugin_seed",
});
}
}
/**
* Loads every plugin on disk and activates the ones marked enabled in the
* database. Called once from the backend start-up sequence.
* Brings plugin_permission_grants in line with the manifests on disk.
*
* A capability the manifest no longer declares is revoked: leaving it granted
* would mean an upgrade silently kept a permission the new version never
* asked for. Bundled plugins have every declared capability granted
* automatically, since shipping in the install is the consent.
*/
async function syncCapabilityGrants(loaded: LoadedPlugin[]): Promise<void> {
const { createCurrentPluginPermissionGrantRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentPluginPermissionGrantRepository();
for (const plugin of loaded) {
const declared = new Set(plugin.manifest.capabilities);
const existing = await repository.listByPlugin(plugin.id);
for (const grant of existing) {
if (declared.has(grant.capability)) continue;
await repository.revoke(plugin.id, grant.capability);
pluginLogger.info(
`Revoked ${plugin.id} capability "${grant.capability}": the manifest no longer declares it`,
{ operation: "plugin_grants" },
);
}
if (plugin.source !== "bundled") continue;
const granted = new Set(existing.map((grant) => grant.capability));
for (const capability of declared) {
if (granted.has(capability)) continue;
await repository.grant({
pluginId: plugin.id,
capability,
grantedBy: null,
source: "bundled",
});
}
invalidatePluginPermissionCache(plugin.id);
}
}
/**
* Loads every plugin on disk and activates the ones marked enabled, in
* dependency order. Called once from the backend start-up sequence.
*/
export async function initializePlugins(): Promise<LoadedPlugin[]> {
const { loader: pluginLoader } = getPluginRuntime();
@@ -194,110 +125,134 @@ export async function initializePlugins(): Promise<LoadedPlugin[]> {
const loaded = await pluginLoader.loadAll();
if (loaded.length === 0) return [];
await seedBundledPlugins(loaded);
const {
createCurrentPluginRepository,
createCurrentPluginPermissionGrantRepository,
} = await import("../database/repositories/factory.js");
await seedPlugins(loaded);
await syncCapabilityGrants(loaded);
const { createCurrentPluginRepository } =
await import("../database/repositories/factory.js");
const records = await createCurrentPluginRepository().listAll();
const byId = new Map(records.map((record) => [record.id, record]));
const enabled = new Set(
records
.filter((record) => record.state === "enabled")
.map((record) => record.id),
);
for (const plugin of loaded) {
const record = byId.get(plugin.id);
if (!record || record.state !== "enabled") continue;
const candidates = loaded
.filter((plugin) => enabled.has(plugin.id))
.map((plugin) => plugin.id);
// An in-process first-party plugin never uses the gated ctx, so it has no
// owner to act as and must not be held to the grant requirement below.
if (runsInProcess(plugin.id, plugin.manifest.permissions)) {
try {
await activatePlugin(plugin.id);
} catch (error) {
pluginLogger.error(
`Failed to activate plugin ${plugin.id}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_activate" },
);
}
continue;
}
for (const pluginId of candidates) {
const plugin = pluginLoader.get(pluginId);
if (plugin) await registerPluginPermissions(plugin);
}
// The plugins table records no installer, so the plugin acts under the
// authority of whoever granted its capabilities. Without a grant there is
// no owner and nothing the plugin could do with one, so it stays stopped.
const grants =
await createCurrentPluginPermissionGrantRepository().listByPlugin(
plugin.id,
);
const ownerUserId = grants[0]?.grantedBy;
const result = await pluginLoader.activateAll(candidates);
await persistRuntimeState(loaded);
if (!ownerUserId) {
pluginLogger.warn(
`Plugin ${plugin.id} is enabled but has no capability grants, leaving it stopped`,
{ operation: "plugin_activate" },
);
continue;
}
try {
await activatePlugin(plugin.id, ownerUserId);
} catch (error) {
pluginLogger.error(
`Failed to activate plugin ${plugin.id}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_activate" },
);
}
if (result.blocked.size > 0 || result.failed.size > 0) {
pluginLogger.warn(
`${result.blocked.size} plugin(s) blocked, ${result.failed.size} failed to activate`,
{ operation: "plugin_init" },
);
}
return loaded;
}
/** Mirrors loader state into the database so the admin API can report it. */
async function persistRuntimeState(loaded: LoadedPlugin[]): Promise<void> {
const { createCurrentPluginRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentPluginRepository();
for (const plugin of loaded) {
if (plugin.state !== "blocked" && plugin.state !== "failed") continue;
try {
await repository.update(plugin.id, {
state: plugin.state,
lastError: plugin.lastError,
});
} catch {
// Reporting state must never stop the boot.
}
}
}
/**
* Puts a plugin's declared permissions into the role catalog.
*
* Until this ran, contributes.permissionGroup was validated but never
* consumed, so a plugin permission could not be granted: PUT /rbac/roles/:id
* rejects any string isValidPermission does not know. Registering here is what
* makes a plugin permission appear in the admin role editor exactly like
* hosts.view does.
* Until this runs a plugin permission cannot be granted at all, because
* PUT /rbac/roles/:id rejects any string isValidPermission does not know.
* Registering here is what makes one appear in the admin role editor exactly
* like hosts.view does.
*/
async function registerPluginPermissions(plugin: LoadedPlugin): Promise<void> {
const group = plugin.manifest.contributes?.permissionGroup;
if (!group) return;
const { registerPermissionGroup } =
await import("../utils/permission-catalog.js");
registerPermissionGroup({
group: group.group,
permissions: group.permissions,
});
try {
const { registerPermissionGroup } =
await import("../utils/permission-catalog.js");
registerPermissionGroup({
group: group.group,
permissions: group.permissions,
});
if (group.defaultForRole) {
await applyRoleDefaults(plugin.id, group.defaultForRole);
if (group.defaultForRole) {
await applyRoleDefaults(plugin.id, group);
}
} catch (error) {
pluginLogger.error(
`Failed to register permissions for ${plugin.id}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_permissions" },
);
}
}
/**
* Applies the plugin's declared role suggestion.
* Applies a plugin's declared role suggestion, once.
*
* Additive only, and only for a permission the role does not already list.
* An admin's later revoke must never be re-granted on the next restart, which
* is the same rule ensureSystemRoles follows for the built-in roles.
* Only permissions the plugin declares itself are eligible; the manifest
* validator enforces that, and this re-checks it because the stored manifest
* is data. Without it a manifest could add admin.* to any role at boot.
*
* Each default is applied at most once and the fact is recorded, so an admin
* who later revokes it does not get it handed back on the next restart. The
* old code claimed that behaviour in a comment but re-added the permission
* every time.
*/
async function applyRoleDefaults(
pluginId: string,
defaults: Record<string, string[]>,
group: {
group: string;
permissions: string[];
defaultForRole?: Record<string, string[]>;
},
): Promise<void> {
const { createCurrentRoleRepository } =
const { createCurrentRoleRepository, createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
const { PermissionManager } = await import("../utils/permission-manager.js");
const repository = createCurrentRoleRepository();
for (const [roleName, wanted] of Object.entries(defaults)) {
const roleRepository = createCurrentRoleRepository();
const storage = createCurrentPluginStorageRepository();
const APPLIED_KEY = "__role_defaults_applied";
let applied: string[] = [];
try {
const raw = await storage.get(pluginId, APPLIED_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (Array.isArray(parsed)) applied = parsed;
} catch {
applied = [];
}
const declared = new Set(group.permissions);
const appliedSet = new Set(applied);
for (const [roleName, wanted] of Object.entries(group.defaultForRole ?? {})) {
try {
const role = await repository.findRoleByName(roleName);
const role = await roleRepository.findRoleByName(roleName);
if (!role) continue;
let current: unknown;
@@ -308,19 +263,33 @@ async function applyRoleDefaults(
}
if (!Array.isArray(current)) continue;
// A wildcard the role already holds covers these, so adding them would
// only be noise in the editor.
const missing = wanted.filter(
(permission) => !coveredBy(current as string[], permission),
);
const missing = wanted.filter((permission) => {
if (!declared.has(permission)) {
pluginLogger.warn(
`Ignoring ${pluginId} role default "${permission}" for ${roleName}: the plugin does not declare it`,
{ operation: "plugin_permissions" },
);
return false;
}
// Applied before means the admin has had the chance to remove it, so
// its absence now is a decision rather than a gap.
if (appliedSet.has(`${roleName}:${permission}`)) return false;
return !coveredBy(current as string[], permission);
});
for (const permission of wanted) {
if (declared.has(permission)) {
appliedSet.add(`${roleName}:${permission}`);
}
}
if (missing.length === 0) continue;
await repository.updateRole(role.id, {
await roleRepository.updateRole(role.id, {
permissions: JSON.stringify([...(current as string[]), ...missing]),
});
const memberIds = await repository.listRoleUserIds(role.id);
for (const memberId of memberIds) {
for (const memberId of await roleRepository.listRoleUserIds(role.id)) {
PermissionManager.getInstance().invalidateUserPermissionCache(memberId);
}
} catch (error) {
@@ -332,6 +301,12 @@ async function applyRoleDefaults(
);
}
}
try {
await storage.set(pluginId, APPLIED_KEY, JSON.stringify([...appliedSet]));
} catch {
// Losing the record only means a default may be re-offered once.
}
}
function coveredBy(permissions: string[], permission: string): boolean {
@@ -345,31 +320,15 @@ function coveredBy(permissions: string[], permission: string): boolean {
return false;
}
export async function activatePlugin(
pluginId: string,
ownerUserId?: string,
): Promise<void> {
const { loader: pluginLoader, broker: pluginBroker } = getPluginRuntime();
export async function activatePlugin(pluginId: string): Promise<void> {
const { loader: pluginLoader } = getPluginRuntime();
// Before activate: a plugin's own activate() may already want to check one
// of its permissions.
const plugin = pluginLoader.get(pluginId);
if (plugin) {
try {
await registerPluginPermissions(plugin);
} catch (error) {
pluginLogger.error(
`Failed to register permissions for ${pluginId}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_permissions" },
);
}
}
if (plugin) await registerPluginPermissions(plugin);
await pluginLoader.activate(pluginId, ownerUserId);
const runtime = pluginBroker.get(pluginId);
if (runtime) remountRoutes(runtime);
await pluginLoader.activate(pluginId);
}
export async function deactivatePlugin(pluginId: string): Promise<void> {
@@ -392,3 +351,8 @@ export async function shutdownPlugins(): Promise<void> {
for (const plugin of loader.list()) unregisterPluginRouter(plugin.id);
await loader.shutdown();
}
/** Test seam. */
export function resetPluginRuntime(): void {
loader = null;
}
+325 -436
View File
@@ -1,64 +1,38 @@
/**
* Loads unpacked plugins and owns their worker lifecycle.
* Discovers plugins, resolves their dependency order, and owns their
* lifecycle.
*
* ## What the worker boundary actually buys you
* One tier. Every plugin, bundled or installed, is imported into the server
* process and handed a ctx built by ctx.ts. There is no worker boundary and
* no allowlist, because the worker tier had no users and the allowlist was a
* hardcoded set of twelve ids that every real plugin was already on.
*
* Be precise about this, because it is easy to overclaim.
*
* ENFORCED:
* - The plugin is handed a `ctx` object and nothing else. It gets no db
* handle, no fs module, no socket, and no credential. Everything it can do
* goes through a postMessage round trip to the broker, which checks the
* plugin's grants first and writes an audit line after.
* - Only structured-cloneable values cross the boundary, so a live object
* cannot leak by reference even by mistake.
* - The worker does not inherit process.env, so it cannot read secrets that
* were passed to the server that way. Only TMPDIR/TMP/TEMP are forwarded.
* - resourceLimits caps the worker heap, so a runaway allocation kills the
* worker instead of the server.
*
* NOT ENFORCED, and not claimed:
* - A worker_threads worker is NOT a security sandbox. It shares the process.
* A plugin that wants to can `import("node:fs")` and read whatever the
* server user can read. It can burn CPU. It can crash the process through
* native addons.
* - Node's permission model (--permission) is the only thing that would
* actually restrict builtins, and it is process-wide, not per-worker, so it
* cannot be turned on for plugin workers alone. execArgv on a Worker does
* not accept it.
*
* So the honest boundary is: plugins are code you chose to install, and this
* design guarantees they cannot QUIETLY reach data they were not granted --
* every privileged path is gated and audited. It does not guarantee
* containment. If containment is ever required, the answer is a child process
* with --permission, or a WASM/vm isolate, not worker_threads.
* What that means honestly: a plugin has the same reach as core. The
* capability gate on ctx makes privileged calls declared and auditable, not
* impossible. See ARCHITECTURE.md.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Worker } from "node:worker_threads";
import semver from "semver";
import { pluginLogger } from "../utils/logger.js";
import { parseManifest, type PluginManifest } from "./manifest.js";
import { parseManifest } from "./manifest.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import {
getBundledPluginsDir,
getPluginBackendEntry,
getPluginManifestPath,
getPluginsDir,
} from "./paths.js";
import type { PluginBootstrapData } from "./protocol.js";
import { runsInProcess } from "./first-party.js";
import {
createInProcessContext,
disposeInProcessHandle,
type InProcessHandle,
createPluginContext,
createPluginHandle,
disposePluginHandle,
type PluginHandle,
type PluginModule,
} from "./host-ctx.js";
} from "./ctx.js";
import { resolveRequirements } from "./service-registry.js";
import {
resolveSecretRequirements,
withdrawAllForPlugin,
} from "./secret-registry.js";
import { resolveSecretRequirements } from "./secret-registry.js";
export type PluginState =
| "loaded"
@@ -66,83 +40,40 @@ export type PluginState =
| "active"
| "stopping"
| "stopped"
| "crashed"
| "disabled";
| "blocked"
| "failed";
export type PluginSource = "bundled" | "user";
export const MAX_RESTART_ATTEMPTS = 3;
const RESTART_BACKOFF_MS = [1000, 5000, 15000];
const ACTIVATION_TIMEOUT_MS = 30_000;
const STABILITY_WINDOW_MS = 60_000;
const WORKER_MAX_OLD_HEAP_MB = 256;
/** Defaults for the runtime error budget. Both are configurable. */
export const DEFAULT_ERROR_THRESHOLD = 5;
export const DEFAULT_ERROR_WINDOW_MS = 60_000;
export interface LoadedPlugin {
id: string;
dir: string;
source: PluginSource;
manifest: PluginManifest;
state: PluginState;
worker: Worker | null;
/**
* Set instead of `worker` for first-party plugins that run on the main
* thread. Exactly one of the two is ever non-null.
*/
inProcess: InProcessHandle | null;
/** Consecutive crashes since the last successful activation. */
restartAttempts: number;
handle: PluginHandle | null;
lastError: string | null;
/**
* The user whose authority this plugin acts under. Set at activate time.
* Null for in-process plugins, which do not use the gated ctx and so have no
* owner to act as.
*/
ownerUserId: string | null;
/** Timestamps of recent runtime errors, trimmed to the window. */
errorTimestamps: number[];
}
export interface PluginLoaderOptions {
/**
* Called with each worker's port right after spawn so the broker can attach.
* Kept as a hook rather than a hard import so the loader stays testable on
* its own, before the broker exists.
*/
onWorkerReady?: (plugin: LoadedPlugin, worker: Worker) => void;
onWorkerGone?: (plugin: LoadedPlugin) => void;
/**
* Gives a worker plugin a chance to run its deactivate() before it is
* terminated. A hook rather than a broker import, same as the two above.
*/
onBeforeTerminate?: (plugin: LoadedPlugin) => Promise<void>;
/** Overridable so tests do not have to wait out the real backoff. */
restartBackoffMs?: number[];
/** How long a plugin must stay up before its crash counter resets. */
stabilityWindowMs?: number;
}
/**
* Resolved from this module's own location, never from process.cwd(): the build
* emits to dist/backend/backend/, and cwd is wherever the server was started
* from.
*
* Under vitest this module is the .ts source, so the sibling is
* worker-bootstrap.ts and the worker needs the tsx loader to run it. In a built
* server both are plain .js and no loader is involved.
*/
function resolveWorkerBootstrap(): { entry: string; execArgv?: string[] } {
const here = path.dirname(fileURLToPath(import.meta.url));
const compiled = path.join(here, "worker-bootstrap.js");
if (fs.existsSync(compiled)) return { entry: compiled };
const source = path.join(here, "worker-bootstrap.ts");
if (fs.existsSync(source)) {
return { entry: source, execArgv: ["--import", "tsx"] };
}
throw new Error(`Could not locate the plugin worker bootstrap near ${here}`);
errorThreshold?: number;
errorWindowMs?: number;
/** Called when a plugin trips the error budget and is torn down. */
onFailed?: (plugin: LoadedPlugin) => void;
}
export class PluginLoader {
private readonly plugins = new Map<string, LoadedPlugin>();
private readonly restartTimers = new Map<string, NodeJS.Timeout>();
private readonly stabilityTimers = new Map<string, NodeJS.Timeout>();
/** Activation order, so shutdown can run it backwards. */
private activationOrder: string[] = [];
constructor(private readonly options: PluginLoaderOptions = {}) {}
@@ -154,10 +85,8 @@ export class PluginLoader {
return this.plugins.get(pluginId);
}
/**
* Reads and validates a plugin directory. Does not start anything.
*/
async load(dir: string): Promise<LoadedPlugin> {
/** Reads and validates one plugin directory. Starts nothing. */
async load(dir: string, source: PluginSource): Promise<LoadedPlugin> {
const manifestPath = getPluginManifestPath(dir);
let raw: unknown;
@@ -184,25 +113,22 @@ export class PluginLoader {
);
}
if (manifest.capabilities.backend) {
const entry = getPluginBackendEntry(dir);
if (!fs.existsSync(entry)) {
throw new Error(
`Plugin ${manifest.id} declares a backend but ${entry} is missing`,
);
}
const entry = getPluginBackendEntry(dir, manifest);
if (!fs.existsSync(entry)) {
throw new Error(
`Plugin ${manifest.id} backend entry ${entry} is missing`,
);
}
const plugin: LoadedPlugin = {
id: manifest.id,
dir,
source,
manifest,
state: "loaded",
worker: null,
inProcess: null,
restartAttempts: 0,
handle: null,
lastError: null,
ownerUserId: null,
errorTimestamps: [],
};
this.plugins.set(manifest.id, plugin);
@@ -213,17 +139,24 @@ export class PluginLoader {
}
/**
* Scans for plugins, loading every valid one it finds.
* Scans both roots. A user-installed plugin can never shadow a bundled one:
* the collision is rejected with an error rather than silently skipped, so
* dropping an "ssh-terminal" folder into the data directory is visibly
* refused instead of quietly ignored.
*
* Bundled first-party plugins are scanned first and a user-installed
* directory can never shadow one: dropping an "ssh-terminal" folder into the
* data directory must not replace the real transport owner.
* The check is on the parsed manifest id, not the directory name, because a
* directory can be named anything.
*/
async loadAll(): Promise<LoadedPlugin[]> {
this.plugins.clear();
const loaded: LoadedPlugin[] = [];
const seen = new Set<string>();
for (const root of [getBundledPluginsDir(), getPluginsDir()]) {
const roots: Array<{ root: string; source: PluginSource }> = [
{ root: getBundledPluginsDir(), source: "bundled" },
{ root: getPluginsDir(), source: "user" },
];
for (const { root, source } of roots) {
if (!fs.existsSync(root)) continue;
const entries = await fs.promises.readdir(root, { withFileTypes: true });
@@ -231,21 +164,20 @@ export class PluginLoader {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (seen.has(entry.name)) {
pluginLogger.warn(
`Ignoring plugin directory ${entry.name} in ${root}: a bundled plugin already uses that id`,
{ operation: "plugin_load" },
);
continue;
}
const dir = path.join(root, entry.name);
try {
const plugin = await this.load(path.join(root, entry.name));
seen.add(plugin.id);
const existing = this.plugins.get(entry.name);
if (existing && source === "user") {
throw new Error(
`a bundled plugin already uses the id "${entry.name}"; a plugin in the data directory cannot replace it`,
);
}
const plugin = await this.load(dir, source);
loaded.push(plugin);
} catch (error) {
pluginLogger.error(
`Skipping plugin directory ${entry.name}`,
`Skipping plugin directory ${entry.name} in ${root}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_load" },
);
@@ -257,83 +189,113 @@ export class PluginLoader {
}
/**
* `ownerUserId` is optional only for in-process first-party plugins, which
* do not use the gated ctx and so never act as a user. A worker plugin
* without one cannot reach any user data outside of a per-request caller
* identity (see broker.requireActor), which only exists inside an HTTP
* handler invocation and is never a substitute for having an owner at all.
* Orders plugins so a dependency always activates before its dependents.
*
* Returns the order plus the plugins that cannot run: one with a missing
* hard dependency is blocked (recoverable, the dependency may be installed
* later), one in a cycle is failed (nothing to wait for).
*/
async activate(pluginId: string, ownerUserId?: string): Promise<void> {
resolveOrder(candidates: string[]): {
order: string[];
blocked: Map<string, string>;
cycles: Map<string, string>;
} {
const wanted = new Set(candidates);
const blocked = new Map<string, string>();
const cycles = new Map<string, string>();
const order: string[] = [];
const state = new Map<string, "visiting" | "done">();
const visit = (id: string, trail: string[]): boolean => {
if (state.get(id) === "done") return true;
if (state.get(id) === "visiting") {
const cycle = [...trail.slice(trail.indexOf(id)), id].join(" -> ");
for (const member of trail.slice(trail.indexOf(id))) {
cycles.set(member, `dependency cycle: ${cycle}`);
}
return false;
}
const plugin = this.plugins.get(id);
if (!plugin) return false;
state.set(id, "visiting");
for (const [dependencyId, range] of Object.entries(
plugin.manifest.dependencies ?? {},
)) {
const dependency = this.plugins.get(dependencyId);
if (!dependency) {
blocked.set(
id,
`requires plugin "${dependencyId}", which is not installed`,
);
state.set(id, "done");
return false;
}
if (!semver.satisfies(dependency.manifest.version, range)) {
blocked.set(
id,
`requires "${dependencyId}" ${range}, but ${dependency.manifest.version} is installed`,
);
state.set(id, "done");
return false;
}
if (!wanted.has(dependencyId)) {
blocked.set(
id,
`requires plugin "${dependencyId}", which is not enabled`,
);
state.set(id, "done");
return false;
}
if (!visit(dependencyId, [...trail, id])) {
if (!cycles.has(id)) {
blocked.set(
id,
`requires plugin "${dependencyId}", which could not start`,
);
}
state.set(id, "done");
return false;
}
}
// Optional dependencies only affect ordering. A missing one is normal,
// and the plugin must keep working without it.
for (const dependencyId of Object.keys(
plugin.manifest.optionalDependencies ?? {},
)) {
if (wanted.has(dependencyId) && this.plugins.has(dependencyId)) {
visit(dependencyId, [...trail, id]);
}
}
state.set(id, "done");
if (!blocked.has(id) && !cycles.has(id)) order.push(id);
return !blocked.has(id) && !cycles.has(id);
};
for (const id of candidates) visit(id, []);
return { order, blocked, cycles };
}
async activate(pluginId: string): Promise<void> {
const plugin = this.requirePlugin(pluginId);
if (plugin.state === "active" || plugin.state === "activating") return;
if (!plugin.manifest.capabilities.backend) {
throw new Error(`Plugin ${pluginId} has no backend to activate`);
}
plugin.ownerUserId = ownerUserId ?? null;
await this.spawn(plugin);
}
private async spawn(plugin: LoadedPlugin): Promise<void> {
plugin.state = "activating";
plugin.lastError = null;
if (runsInProcess(plugin.id, plugin.manifest.permissions)) {
await this.spawnInProcess(plugin);
return;
}
const bootstrap: PluginBootstrapData = {
pluginId: plugin.id,
pluginDir: plugin.dir,
// file:// so the worker's dynamic import works on Windows too.
entryPath: pathToFileUrlString(getPluginBackendEntry(plugin.dir)),
manifest: plugin.manifest,
};
const { entry, execArgv } = resolveWorkerBootstrap();
const worker = new Worker(entry, {
workerData: bootstrap,
// Deliberately not process.env: the worker must not inherit secrets
// passed to the server that way. Only the temp-dir vars are forwarded,
// because tooling that writes a cache falls back to the literal string
// "undefined" without them.
env: pickTempEnv(),
resourceLimits: { maxOldGenerationSizeMb: WORKER_MAX_OLD_HEAP_MB },
stdout: true,
stderr: true,
...(execArgv ? { execArgv } : {}),
});
plugin.worker = worker;
this.options.onWorkerReady?.(plugin, worker);
worker.on("error", (error: Error) => {
plugin.lastError = error.message;
pluginLogger.error(`Plugin ${plugin.id} worker error`, error, {
operation: "plugin_worker",
});
});
worker.on("exit", (code: number) => {
this.handleExit(plugin, code);
});
await this.awaitActivation(plugin, worker);
}
/**
* Starts a first-party plugin on the main thread.
*
* There is no worker, so there is no crash isolation: an exception thrown
* later by this plugin's own callbacks lands wherever it was thrown, exactly
* as it would from any other backend module. The restart accounting below
* therefore only covers activation failure, which is the one thing we can
* still observe from here. See first-party.ts.
*/
private async spawnInProcess(plugin: LoadedPlugin): Promise<void> {
const entry = pathToFileUrlString(getPluginBackendEntry(plugin.dir));
const entry = pathToFileUrlString(
getPluginBackendEntry(plugin.dir, plugin.manifest),
);
try {
const imported = (await import(entry)) as {
@@ -365,274 +327,210 @@ export class PluginLoader {
);
}
// Secret references never block activation, even when not optional. A
// borrowed secret is resolved per call and returns null when absent, so
// a provider installed later starts working with no restart -- refusing
// to start here would turn a recoverable gap into a hard ordering
// dependency between two installs.
const secretResolution = resolveSecretRequirements(plugin.manifest);
for (const reference of secretResolution.unavailable) {
// A borrowed secret resolves per call and returns null when absent, so a
// provider installed later starts working with no restart. Refusing to
// start here would turn a recoverable gap into an install ordering rule.
for (const reference of resolveSecretRequirements(plugin.manifest)
.unavailable) {
pluginLogger.info(
`Plugin ${plugin.id} shared secret "${reference}" is not currently offered; reads will resolve to null`,
{ operation: "plugin_activate" },
);
}
const handle: InProcessHandle = {
module: { activate, deactivate },
unsubscribers: [],
providedKeys: [],
providedServices: [],
offeredSecrets: [],
};
const handle = createPluginHandle(plugin.id, { activate, deactivate });
const ctx = createPluginContext(plugin.manifest, handle);
// Attached before activate runs, not after: a plugin that registers a
// few things and then throws still has to be cleaned up, and the catch
// below can only do that if it can reach the handle.
plugin.handle = handle;
const ctx = createInProcessContext(plugin.manifest, handle);
await withTimeout(
Promise.resolve(activate(ctx)),
ACTIVATION_TIMEOUT_MS,
`Plugin ${plugin.id} did not activate within ${ACTIVATION_TIMEOUT_MS}ms`,
);
plugin.inProcess = handle;
plugin.state = "active";
this.scheduleStabilityReset(plugin);
pluginLogger.success(`Activated plugin ${plugin.id} (in-process)`, {
plugin.errorTimestamps = [];
if (!this.activationOrder.includes(plugin.id)) {
this.activationOrder.push(plugin.id);
}
pluginLogger.success(`Activated plugin ${plugin.id}`, {
operation: "plugin_activate",
});
} catch (error) {
plugin.lastError = error instanceof Error ? error.message : String(error);
plugin.inProcess = null;
plugin.state = "crashed";
plugin.state = "failed";
// Activation may have registered things before it threw. Dispose them
// rather than leaving a half-started plugin holding resources, but do
// not call the plugin's own deactivate: it never finished starting.
if (plugin.handle) {
await disposePluginHandle(plugin.handle, plugin.id, {
runDeactivate: false,
});
plugin.handle = null;
}
throw error instanceof Error ? error : new Error(plugin.lastError);
}
}
private awaitActivation(plugin: LoadedPlugin, worker: Worker): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
void this.terminate(plugin);
reject(
new Error(
`Plugin ${plugin.id} did not activate within ${ACTIVATION_TIMEOUT_MS}ms`,
),
);
}, ACTIVATION_TIMEOUT_MS);
/** Activates a set of plugins in dependency order. */
async activateAll(pluginIds: string[]): Promise<{
activated: string[];
blocked: Map<string, string>;
failed: Map<string, string>;
}> {
const { order, blocked, cycles } = this.resolveOrder(pluginIds);
const activated: string[] = [];
const failed = new Map<string, string>(cycles);
const onMessage = (message: unknown) => {
const m = message as {
id?: number;
ok?: boolean;
error?: { message: string };
};
// id 0 is reserved for the bootstrap's activation result.
if (!m || m.id !== 0) return;
cleanup();
if (m.ok) {
plugin.state = "active";
this.scheduleStabilityReset(plugin);
pluginLogger.success(`Activated plugin ${plugin.id}`, {
operation: "plugin_activate",
});
resolve();
} else {
plugin.lastError = m.error?.message ?? "activation failed";
void this.terminate(plugin);
reject(new Error(plugin.lastError));
}
};
const onExit = () => {
cleanup();
reject(
new Error(
`Plugin ${plugin.id} worker exited before activating${
plugin.lastError ? `: ${plugin.lastError}` : ""
}`,
),
);
};
function cleanup() {
clearTimeout(timer);
worker.off("message", onMessage);
worker.off("exit", onExit);
for (const [id, reason] of blocked) {
const plugin = this.plugins.get(id);
if (plugin) {
plugin.state = "blocked";
plugin.lastError = reason;
}
worker.on("message", onMessage);
worker.on("exit", onExit);
});
}
/**
* Clears the crash counter only once a plugin has stayed up for a while.
* Resetting it on activation alone would let a plugin that activates cleanly
* and then dies restart forever, because each attempt would look like the
* first one.
*/
private scheduleStabilityReset(plugin: LoadedPlugin): void {
const existing = this.stabilityTimers.get(plugin.id);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
this.stabilityTimers.delete(plugin.id);
if (plugin.state === "active") plugin.restartAttempts = 0;
}, this.options.stabilityWindowMs ?? STABILITY_WINDOW_MS);
timer.unref?.();
this.stabilityTimers.set(plugin.id, timer);
}
private handleExit(plugin: LoadedPlugin, code: number): void {
const stabilityTimer = this.stabilityTimers.get(plugin.id);
if (stabilityTimer) {
clearTimeout(stabilityTimer);
this.stabilityTimers.delete(plugin.id);
}
plugin.worker = null;
this.options.onWorkerGone?.(plugin);
// A deliberate stop is not a crash.
if (plugin.state === "stopping" || plugin.state === "stopped") {
plugin.state = "stopped";
return;
}
if (plugin.state === "disabled") return;
plugin.state = "crashed";
plugin.restartAttempts += 1;
if (plugin.restartAttempts > MAX_RESTART_ATTEMPTS) {
plugin.state = "disabled";
pluginLogger.error(
`Plugin ${plugin.id} crashed ${MAX_RESTART_ATTEMPTS} times and has been disabled`,
new Error(plugin.lastError ?? `worker exited with code ${code}`),
{ operation: "plugin_crash" },
);
return;
}
const backoff = this.options.restartBackoffMs ?? RESTART_BACKOFF_MS;
const delay =
backoff[plugin.restartAttempts - 1] ?? backoff[backoff.length - 1];
pluginLogger.warn(
`Plugin ${plugin.id} crashed, restarting in ${delay}ms (attempt ${plugin.restartAttempts}/${MAX_RESTART_ATTEMPTS})`,
{ operation: "plugin_crash" },
);
const timer = setTimeout(() => {
this.restartTimers.delete(plugin.id);
if (plugin.state !== "crashed") return;
this.spawn(plugin).catch((error) => {
plugin.lastError =
error instanceof Error ? error.message : String(error);
pluginLogger.warn(`Plugin ${id} is blocked: ${reason}`, {
operation: "plugin_activate",
});
}, delay);
this.restartTimers.set(plugin.id, timer);
}
/**
* Clears the crash counter and starts the plugin again. This is what the
* Retry button calls once a plugin has been auto-disabled.
*/
async retry(pluginId: string): Promise<void> {
const plugin = this.requirePlugin(pluginId);
plugin.restartAttempts = 0;
plugin.state = "loaded";
plugin.lastError = null;
// In-process plugins legitimately have no owner, so only worker plugins
// are held to this.
if (
!plugin.ownerUserId &&
!runsInProcess(plugin.id, plugin.manifest.permissions)
) {
throw new Error(
`Plugin ${pluginId} has never been activated, so there is nothing to retry`,
);
}
await this.spawn(plugin);
for (const [id, reason] of cycles) {
const plugin = this.plugins.get(id);
if (plugin) {
plugin.state = "failed";
plugin.lastError = reason;
}
pluginLogger.error(`Plugin ${id} cannot start`, new Error(reason), {
operation: "plugin_activate",
});
}
for (const id of order) {
try {
await this.activate(id);
activated.push(id);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failed.set(id, message);
pluginLogger.error(
`Failed to activate plugin ${id}`,
error instanceof Error ? error : new Error(message),
{ operation: "plugin_activate" },
);
}
}
return { activated, blocked, failed };
}
async deactivate(pluginId: string): Promise<void> {
const plugin = this.requirePlugin(pluginId);
const timer = this.restartTimers.get(pluginId);
if (timer) {
clearTimeout(timer);
this.restartTimers.delete(pluginId);
}
const stabilityTimer = this.stabilityTimers.get(pluginId);
if (stabilityTimer) {
clearTimeout(stabilityTimer);
this.stabilityTimers.delete(pluginId);
}
this.activationOrder = this.activationOrder.filter((id) => id !== pluginId);
if (!plugin.worker && !plugin.inProcess) {
if (!plugin.handle) {
plugin.state = "stopped";
return;
}
plugin.state = "stopping";
await this.terminate(plugin);
const handle = plugin.handle;
plugin.handle = null;
// Disposal never throws, so a plugin whose deactivate() misbehaves still
// ends up stopped rather than stuck in "stopping" forever.
await disposePluginHandle(handle, plugin.id);
plugin.state = "stopped";
}
private async terminate(plugin: LoadedPlugin): Promise<void> {
// An in-process plugin owns real resources -- a listening port, live SSH
// sessions -- and there is no thread to kill, so its own cleanup is the
// only thing that releases them.
if (plugin.inProcess) {
const handle = plugin.inProcess;
plugin.inProcess = null;
this.options.onWorkerGone?.(plugin);
await disposeInProcessHandle(handle, plugin.id);
return;
}
/**
* Records a runtime error against a plugin. Past the threshold inside the
* window the plugin is marked failed and torn down, because a plugin
* throwing on every call is worse than one that is off.
*/
async reportError(pluginId: string, error: unknown): Promise<void> {
const plugin = this.plugins.get(pluginId);
if (!plugin) return;
// A worker plugin cannot offer a shared secret today, but terminate() is
// the one path every tier goes through, so the sweep belongs here rather
// than only in the in-process branch above.
withdrawAllForPlugin(plugin.id);
const message = error instanceof Error ? error.message : String(error);
plugin.lastError = message;
const worker = plugin.worker;
if (!worker) return;
pluginLogger.error(
`Plugin ${pluginId} threw at runtime`,
error instanceof Error ? error : new Error(message),
{ operation: "plugin_runtime" },
);
// Only on a deliberate stop. A crashed worker has nothing left to ask.
if (plugin.state === "stopping" && this.options.onBeforeTerminate) {
try {
await this.options.onBeforeTerminate(plugin);
} catch {
// Cleanup is best-effort; the worker is going away regardless.
}
}
const windowMs = this.options.errorWindowMs ?? DEFAULT_ERROR_WINDOW_MS;
const threshold = this.options.errorThreshold ?? DEFAULT_ERROR_THRESHOLD;
const now = Date.now();
plugin.worker = null;
try {
await worker.terminate();
} catch {
// Already gone.
}
plugin.errorTimestamps = [
...plugin.errorTimestamps.filter((at) => now - at < windowMs),
now,
];
if (plugin.errorTimestamps.length < threshold) return;
pluginLogger.error(
`Plugin ${pluginId} failed ${plugin.errorTimestamps.length} times in ${windowMs}ms and has been stopped`,
new Error(message),
{ operation: "plugin_runtime" },
);
await this.deactivate(pluginId);
plugin.state = "failed";
plugin.lastError = message;
plugin.errorTimestamps = [];
this.options.onFailed?.(plugin);
}
/** Stops everything. Called on shutdown and by tests. */
async shutdown(): Promise<void> {
for (const timer of this.restartTimers.values()) clearTimeout(timer);
this.restartTimers.clear();
for (const timer of this.stabilityTimers.values()) clearTimeout(timer);
this.stabilityTimers.clear();
/**
* Wraps a plugin callback so a throw is counted rather than escaping into
* whichever core call site invoked it.
*/
wrap<Args extends unknown[], Result>(
pluginId: string,
fn: (...args: Args) => Result | Promise<Result>,
): (...args: Args) => Promise<Result | undefined> {
return async (...args: Args) => {
try {
return await fn(...args);
} catch (error) {
await this.reportError(pluginId, error);
return undefined;
}
};
}
await Promise.all(
[...this.plugins.values()].map(async (plugin) => {
plugin.state = "stopping";
await this.terminate(plugin);
plugin.state = "stopped";
}),
);
/** Clears the error budget and starts the plugin again. */
async retry(pluginId: string): Promise<void> {
const plugin = this.requirePlugin(pluginId);
plugin.errorTimestamps = [];
plugin.lastError = null;
plugin.state = "loaded";
await this.activate(pluginId);
}
/** Stops everything, reverse activation order. */
async shutdown(): Promise<void> {
for (const id of [...this.activationOrder].reverse()) {
try {
await this.deactivate(id);
} catch (error) {
pluginLogger.error(
`Failed to deactivate plugin ${id} during shutdown`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_shutdown" },
);
}
}
this.activationOrder = [];
}
private requirePlugin(pluginId: string): LoadedPlugin {
@@ -642,15 +540,6 @@ export class PluginLoader {
}
}
function pickTempEnv(): Record<string, string> {
const env: Record<string, string> = {};
for (const key of ["TMPDIR", "TMP", "TEMP"]) {
const value = process.env[key];
if (value) env[key] = value;
}
return env;
}
function withTimeout<T>(
promise: Promise<T>,
ms: number,
+47 -844
View File
@@ -1,848 +1,51 @@
/**
* Runtime plugin manifest validation.
* Manifest validation, re-exported from the SDK.
*
* This is a TypeScript port of scripts/validate-plugin-manifest.cjs. The two
* are deliberately separate: the .cjs one is a build/authoring tool run from a
* checkout, this one runs inside the server, where scripts/ does not exist
* (the Docker image only ships dist/). Both enforce the same contract, which
* is written down in scripts/plugin-manifest.schema.json.
*
* The enums below are duplicated from that schema rather than read from it at
* runtime, for the same reason. If you change the schema, change both.
* The contract lives in @termix/plugin-sdk/manifest so the server, the
* authoring script and plugin authors all read the same rules. This file used
* to carry its own copy of the enums and validators, which meant three places
* had to be edited together and quietly drifted when they were not.
*/
import semver from "semver";
import { isFirstParty, TRANSPORT_OWNER_CAPABILITY } from "./first-party.js";
export const PLUGIN_PERMISSIONS = [
"hosts.read",
"hosts.write",
"credentials.use",
"ssh.exec",
"ssh.sftp",
"storage.own",
"storage.secrets",
"network.outbound",
"events.read",
"notify.send",
"users.read",
"process.sidecar",
// Reserved for first-party plugins. parseManifest refuses it for any id not
// on the hardcoded allowlist in first-party.ts, so declaring it does not
// grant it.
"process:transport-owner",
"ui.tab",
"ui.rail",
"ui.card",
"ui.settings",
"ui.palette",
] as const;
export type PluginPermission = (typeof PLUGIN_PERMISSIONS)[number];
export const PLUGIN_CATEGORIES = [
"Terminal",
"Files & Transfer",
"Infrastructure",
"Monitoring",
"Networking",
"Access & Security",
"Productivity",
] as const;
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"];
/**
* Contribution kinds a UI action slot can accept. Closed on purpose: a
* free-form list would validate nothing. Adding a kind is a one-line edit
* here and in the two other validators.
*/
export const ACTION_CONTRIBUTION_KINDS = ["button"] as const;
export type ActionContributionKind = (typeof ACTION_CONTRIBUTION_KINDS)[number];
const REQUIRED_TOP_LEVEL = [
"id",
"name",
"version",
"description",
"author",
"license",
"engine",
"category",
"capabilities",
"permissions",
"sidecars",
];
const ID_PATTERN = /^[a-z0-9-]+$/;
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
const API_VERSION_PATTERN = /^[0-9]+$/;
const API_PREFIX_PATTERN = /^[a-z0-9-]+$/;
/** Service names are dotted, e.g. "ssh.transport" or "host-metrics.stream". */
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
/**
* A shared secret key is a flat slug, e.g. "api-key". Not dotted: the plugin
* id is already a separate field, so a dotted key would only invite encoding
* the owner twice and disagreeing with itself.
*/
const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/;
/**
* Action and slot ids are dotted like service names, but each segment may be
* camelCase: these name a frontend function ("ai.openWithContext"), not a
* lowercase service contract, so SERVICE_PATTERN is too strict to reuse.
*/
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
/** A handler names a JS export, so it follows identifier rules, not dotted ones. */
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
/**
* The plugin SDK major version this build implements. A manifest asking for a
* different engine.api is refused rather than loaded and hoped for -- this is
* the real compatibility gate (engine.termix is informational only).
*/
export const SUPPORTED_PLUGIN_API_VERSION = "1";
export interface PluginManifest {
id: string;
name: string;
version: string;
description: string;
author: { name: string; url?: string };
license: string;
repository?: string;
category: string;
icon?: string;
engine: { termix: string; api: string };
capabilities: {
backend: boolean;
frontend: boolean;
electron: boolean;
platforms: string[];
};
permissions: string[];
contributes?: {
tabs?: Array<{
id: string;
titleKey: string;
icon: string;
openFrom: string[];
}>;
hostCapability?: HostCapabilityContribution | HostCapabilityContribution[];
permissionGroup?: {
group: string;
permissions: string[];
defaultForRole?: Record<string, string[]>;
};
settingsPanel?: { titleKey: string };
dashboardCards?: Array<{ id: string; titleKey: string }>;
apiPrefix?: string;
/** Named frontend actions this plugin exposes for other plugins to invoke. */
actions?: PluginActionContribution[];
/** Places this plugin offers other plugins to contribute a UI element. */
actionSlots?: PluginActionSlot[];
};
dependencies?: { plugins?: Record<string, string> };
/**
* Named services this plugin exposes to other plugins. Each one is gated by
* its own RBAC permission, which the plugin's own permissionGroup must
* declare -- see parseManifest.
*/
provides?: PluginServiceProvide[];
/** Named services this plugin needs another active plugin to provide. */
requires?: PluginServiceRequire[];
/**
* Secrets this plugin offers other plugins by reference. Gated by one of
* this plugin's own permissionGroup permissions -- see parseManifest.
*/
providesSecret?: PluginSecretProvide[];
/** Secrets this plugin wants to borrow from another plugin, by reference. */
requiresSecret?: PluginSecretRequire[];
sidecars: Array<{ id: string; binary: string }>;
}
/**
* A host-level checkbox this plugin contributes to the host editor (e.g.
* "Enable RDP"), backed by an existing boolean column on the host record.
* Most plugins declare one; a plugin covering several independent protocols
* on the same host (rdp/vnc/telnet) declares several -- see
* contributes.hostCapability, which accepts either shape.
*/
export interface HostCapabilityContribution {
key: string;
labelKey: string;
editorTab: string;
}
export interface PluginServiceProvide {
service: string;
version: string;
/** The role permission a calling user needs to reach this service. */
permission: string;
}
export interface PluginServiceRequire {
service: string;
versionRange: string;
/** When true, an unsatisfied requirement is skipped instead of failing activation. */
optional?: boolean;
}
export interface PluginSecretProvide {
/** The secret's name within this plugin, e.g. "api-key". */
key: string;
/** The role permission a borrowing user needs. */
permission: string;
/** Shown in the admin UI so the sharing is legible without reading code. */
descriptionKey?: string;
}
export interface PluginSecretRequire {
/** The plugin id that offers the secret. */
plugin: string;
/** The secret's name within that plugin. */
key: string;
/**
* When true, the provider being absent is normal and activation proceeds.
* A non-optional reference still activates -- the read just returns null --
* but resolveSecretRequirements reports it as an error for install feedback.
*/
optional?: boolean;
}
/**
* A named frontend action, declared here and bound at runtime.
*
* The split matters: this manifest entry is inert JSON the server can read and
* audit without running any plugin code, while the function itself is supplied
* in the browser by registerAction(id, fn). `handler` records which frontend
* export is meant to end up under `id`, so the contract is readable from the
* manifest alone and a future frontend loader can wire module[handler]
* automatically. Today nothing reads `handler` at runtime.
*/
export interface PluginActionContribution {
/** Dotted and globally unique, e.g. "ai.openWithContext". */
id: string;
titleKey: string;
/** Name of the frontend export implementing this action. */
handler: string;
/** Lucide icon name, same convention as contributes.tabs[].icon. */
icon?: string;
/**
* Role permission the acting user needs before the action is offered.
* Optional: an ungated action renders for everyone. When set, parseManifest
* requires it to be declared in this plugin's own permissionGroup.
*/
permission?: string;
/** Slot this action is intended for. Advisory; binding happens at runtime. */
slot?: string;
kind?: ActionContributionKind;
}
/**
* A place this plugin offers other plugins to contribute a UI element.
*
* A contribution's `kind` is checked against `accepts` in the frontend at
* registration time, not here: the slot is usually declared by a different
* plugin, and a manifest is validated on its own.
*/
export interface PluginActionSlot {
/** Dotted, e.g. "terminal.toolbar". */
id: string;
accepts: ActionContributionKind[];
descriptionKey?: string;
}
export function validateManifest(manifest: unknown): string[] {
const errors: string[] = [];
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
return ["Manifest must be a JSON object"];
}
const m = manifest as Record<string, unknown>;
for (const field of REQUIRED_TOP_LEVEL) {
if (!(field in m)) {
errors.push(`Missing required field: "${field}"`);
}
}
if (typeof m.id === "string" && !ID_PATTERN.test(m.id)) {
errors.push(`Field "id" must match ${ID_PATTERN}, got: "${m.id}"`);
}
if (typeof m.version === "string" && !SEMVER_PATTERN.test(m.version)) {
errors.push(`Field "version" must be valid semver, got: "${m.version}"`);
}
if (m.author && typeof m.author === "object") {
if (!(m.author as Record<string, unknown>).name) {
errors.push('Field "author.name" is required');
}
}
if (typeof m.category === "string") {
if (!PLUGIN_CATEGORIES.includes(m.category as never)) {
errors.push(
`Field "category" must be one of: ${PLUGIN_CATEGORIES.join(", ")}, got: "${m.category}"`,
);
}
} else if ("category" in m) {
errors.push('Field "category" must be a string');
}
if ("icon" in m && typeof m.icon !== "string") {
errors.push('Field "icon" must be a string');
}
if (m.engine && typeof m.engine === "object") {
const engine = m.engine as Record<string, unknown>;
if (!engine.termix) {
errors.push('Field "engine.termix" is required');
}
if (!API_VERSION_PATTERN.test(String(engine.api))) {
errors.push(
`Field "engine.api" must be an integer-as-string, got: "${engine.api}"`,
);
}
}
if (m.capabilities && typeof m.capabilities === "object") {
const caps = m.capabilities as Record<string, unknown>;
for (const field of ["backend", "frontend", "electron"]) {
if (typeof caps[field] !== "boolean") {
errors.push(`Field "capabilities.${field}" must be a boolean`);
}
}
if (!Array.isArray(caps.platforms)) {
errors.push('Field "capabilities.platforms" must be an array');
}
}
if (Array.isArray(m.permissions)) {
for (const permission of m.permissions) {
if (!PLUGIN_PERMISSIONS.includes(permission as never)) {
errors.push(
`Unknown permission: "${permission}". Known values: ${PLUGIN_PERMISSIONS.join(", ")}`,
);
}
}
} else if ("permissions" in m) {
errors.push('Field "permissions" must be an array');
}
if (Array.isArray(m.sidecars)) {
m.sidecars.forEach((sidecar: unknown, index: number) => {
if (!sidecar || typeof sidecar !== "object") {
errors.push(`sidecars[${index}] must be an object`);
return;
}
const s = sidecar as Record<string, unknown>;
if (!s.id) errors.push(`sidecars[${index}].id is required`);
if (!s.binary) errors.push(`sidecars[${index}].binary is required`);
});
} else if ("sidecars" in m) {
errors.push('Field "sidecars" must be an array');
}
errors.push(...validateProvides(m.provides));
errors.push(...validateRequires(m.requires));
errors.push(...validateProvidesSecret(m.providesSecret));
errors.push(...validateRequiresSecret(m.requiresSecret));
if (m.contributes && typeof m.contributes === "object") {
errors.push(
...validateContributes(m.contributes as Record<string, unknown>),
);
}
return errors;
}
function validateProvides(provides: unknown): string[] {
if (provides === undefined) return [];
if (!Array.isArray(provides)) return ['Field "provides" must be an array'];
const errors: string[] = [];
const seen = new Set<string>();
provides.forEach((entry: unknown, index: number) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`provides[${index}] must be an object`);
return;
}
const e = entry as Record<string, unknown>;
if (typeof e.service !== "string" || !SERVICE_PATTERN.test(e.service)) {
errors.push(
`provides[${index}].service must match ${SERVICE_PATTERN}, got: "${String(e.service)}"`,
);
} else if (seen.has(e.service)) {
errors.push(`provides[${index}].service is a duplicate: "${e.service}"`);
} else {
seen.add(e.service);
}
if (typeof e.version !== "string" || !SEMVER_PATTERN.test(e.version)) {
errors.push(
`provides[${index}].version must be valid semver, got: "${String(e.version)}"`,
);
}
if (typeof e.permission !== "string" || e.permission.length === 0) {
errors.push(`provides[${index}].permission is required`);
}
});
return errors;
}
function validateRequires(requires: unknown): string[] {
if (requires === undefined) return [];
if (!Array.isArray(requires)) return ['Field "requires" must be an array'];
const errors: string[] = [];
const seen = new Set<string>();
requires.forEach((entry: unknown, index: number) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`requires[${index}] must be an object`);
return;
}
const e = entry as Record<string, unknown>;
if (typeof e.service !== "string" || !SERVICE_PATTERN.test(e.service)) {
errors.push(
`requires[${index}].service must match ${SERVICE_PATTERN}, got: "${String(e.service)}"`,
);
} else if (seen.has(e.service)) {
errors.push(`requires[${index}].service is a duplicate: "${e.service}"`);
} else {
seen.add(e.service);
}
if (
typeof e.versionRange !== "string" ||
semver.validRange(e.versionRange) === null
) {
errors.push(
`requires[${index}].versionRange must be a valid semver range, got: "${String(e.versionRange)}"`,
);
}
if ("optional" in e && typeof e.optional !== "boolean") {
errors.push(`requires[${index}].optional must be a boolean`);
}
});
return errors;
}
function validateProvidesSecret(providesSecret: unknown): string[] {
if (providesSecret === undefined) return [];
if (!Array.isArray(providesSecret)) {
return ['Field "providesSecret" must be an array'];
}
const errors: string[] = [];
const seen = new Set<string>();
providesSecret.forEach((entry: unknown, index: number) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`providesSecret[${index}] must be an object`);
return;
}
const e = entry as Record<string, unknown>;
if (typeof e.key !== "string" || !SECRET_KEY_PATTERN.test(e.key)) {
errors.push(
`providesSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${String(e.key)}"`,
);
} else if (seen.has(e.key)) {
errors.push(`providesSecret[${index}].key is a duplicate: "${e.key}"`);
} else {
seen.add(e.key);
}
if (typeof e.permission !== "string" || e.permission.length === 0) {
errors.push(`providesSecret[${index}].permission is required`);
}
if (
"descriptionKey" in e &&
(typeof e.descriptionKey !== "string" || e.descriptionKey.length === 0)
) {
errors.push(`providesSecret[${index}].descriptionKey must be a string`);
}
});
return errors;
}
function validateRequiresSecret(requiresSecret: unknown): string[] {
if (requiresSecret === undefined) return [];
if (!Array.isArray(requiresSecret)) {
return ['Field "requiresSecret" must be an array'];
}
const errors: string[] = [];
const seen = new Set<string>();
requiresSecret.forEach((entry: unknown, index: number) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`requiresSecret[${index}] must be an object`);
return;
}
const e = entry as Record<string, unknown>;
if (typeof e.plugin !== "string" || !ID_PATTERN.test(e.plugin)) {
errors.push(
`requiresSecret[${index}].plugin must match ${ID_PATTERN}, got: "${String(e.plugin)}"`,
);
}
if (typeof e.key !== "string" || !SECRET_KEY_PATTERN.test(e.key)) {
errors.push(
`requiresSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${String(e.key)}"`,
);
}
if (typeof e.plugin === "string" && typeof e.key === "string") {
const id = `${e.plugin}:${e.key}`;
if (seen.has(id)) {
errors.push(`requiresSecret[${index}] is a duplicate: "${id}"`);
} else {
seen.add(id);
}
}
if ("optional" in e && typeof e.optional !== "boolean") {
errors.push(`requiresSecret[${index}].optional must be a boolean`);
}
});
return errors;
}
function validateContributes(contributes: Record<string, unknown>): string[] {
const errors: string[] = [];
if ("tabs" in contributes) {
if (!Array.isArray(contributes.tabs)) {
errors.push('Field "contributes.tabs" must be an array');
} else {
contributes.tabs.forEach((tab: unknown, index: number) => {
const t = (tab ?? {}) as Record<string, unknown>;
for (const field of ["id", "titleKey", "icon", "openFrom"]) {
if (!(field in t)) {
errors.push(`contributes.tabs[${index}].${field} is required`);
}
}
if (Array.isArray(t.openFrom)) {
for (const value of t.openFrom) {
if (!OPEN_FROM_VALUES.includes(value as string)) {
errors.push(
`contributes.tabs[${index}].openFrom has unknown value: "${value}"`,
);
}
}
}
});
}
}
if ("hostCapability" in contributes) {
const raw = contributes.hostCapability;
const entries = Array.isArray(raw) ? raw : [raw];
const seenKeys = new Set<string>();
entries.forEach((entry, index) => {
const hc = (entry ?? {}) as Record<string, unknown>;
const prefix = Array.isArray(raw)
? `contributes.hostCapability[${index}]`
: "contributes.hostCapability";
for (const field of ["key", "labelKey", "editorTab"]) {
if (!(field in hc)) {
errors.push(`${prefix}.${field} is required`);
}
}
if (typeof hc.key === "string") {
if (seenKeys.has(hc.key)) {
errors.push(`${prefix}.key is a duplicate: "${hc.key}"`);
}
seenKeys.add(hc.key);
}
});
}
if ("permissionGroup" in contributes) {
const group = (contributes.permissionGroup ?? {}) as Record<
string,
unknown
>;
if (!group.group) {
errors.push("contributes.permissionGroup.group is required");
}
if (!Array.isArray(group.permissions) || group.permissions.length === 0) {
errors.push(
"contributes.permissionGroup.permissions must be a non-empty array",
);
}
}
if ("settingsPanel" in contributes) {
const panel = (contributes.settingsPanel ?? {}) as Record<string, unknown>;
if (!panel.titleKey) {
errors.push("contributes.settingsPanel.titleKey is required");
}
}
if ("dashboardCards" in contributes) {
if (!Array.isArray(contributes.dashboardCards)) {
errors.push('Field "contributes.dashboardCards" must be an array');
} else {
contributes.dashboardCards.forEach((card: unknown, index: number) => {
const c = (card ?? {}) as Record<string, unknown>;
for (const field of ["id", "titleKey"]) {
if (!(field in c)) {
errors.push(
`contributes.dashboardCards[${index}].${field} is required`,
);
}
}
});
}
}
if ("apiPrefix" in contributes) {
if (
typeof contributes.apiPrefix !== "string" ||
!API_PREFIX_PATTERN.test(contributes.apiPrefix)
) {
errors.push(
`Field "contributes.apiPrefix" must match ${API_PREFIX_PATTERN}`,
);
}
}
if ("actions" in contributes) {
errors.push(...validateActions(contributes.actions));
}
if ("actionSlots" in contributes) {
errors.push(...validateActionSlots(contributes.actionSlots));
}
return errors;
}
function validateActions(actions: unknown): string[] {
if (!Array.isArray(actions)) {
return ['Field "contributes.actions" must be an array'];
}
const errors: string[] = [];
const seen = new Set<string>();
actions.forEach((entry: unknown, index: number) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`contributes.actions[${index}] must be an object`);
return;
}
const e = entry as Record<string, unknown>;
if (typeof e.id !== "string" || !ACTION_ID_PATTERN.test(e.id)) {
errors.push(
`contributes.actions[${index}].id must match ${ACTION_ID_PATTERN}, got: "${String(e.id)}"`,
);
} else if (seen.has(e.id)) {
errors.push(`contributes.actions[${index}].id is a duplicate: "${e.id}"`);
} else {
seen.add(e.id);
}
if (typeof e.titleKey !== "string" || e.titleKey.length === 0) {
errors.push(`contributes.actions[${index}].titleKey is required`);
}
if (typeof e.handler !== "string" || !HANDLER_PATTERN.test(e.handler)) {
errors.push(
`contributes.actions[${index}].handler must match ${HANDLER_PATTERN}, got: "${String(e.handler)}"`,
);
}
if ("icon" in e && (typeof e.icon !== "string" || e.icon.length === 0)) {
errors.push(`contributes.actions[${index}].icon must be a string`);
}
if (
"permission" in e &&
(typeof e.permission !== "string" || e.permission.length === 0)
) {
errors.push(`contributes.actions[${index}].permission must be a string`);
}
if (
"slot" in e &&
(typeof e.slot !== "string" || !ACTION_ID_PATTERN.test(e.slot))
) {
errors.push(
`contributes.actions[${index}].slot must match ${ACTION_ID_PATTERN}, got: "${String(e.slot)}"`,
);
}
if ("kind" in e && !ACTION_CONTRIBUTION_KINDS.includes(e.kind as never)) {
errors.push(
`contributes.actions[${index}].kind must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}, got: "${String(e.kind)}"`,
);
}
});
return errors;
}
function validateActionSlots(slots: unknown): string[] {
if (!Array.isArray(slots)) {
return ['Field "contributes.actionSlots" must be an array'];
}
const errors: string[] = [];
const seen = new Set<string>();
slots.forEach((entry: unknown, index: number) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`contributes.actionSlots[${index}] must be an object`);
return;
}
const e = entry as Record<string, unknown>;
if (typeof e.id !== "string" || !ACTION_ID_PATTERN.test(e.id)) {
errors.push(
`contributes.actionSlots[${index}].id must match ${ACTION_ID_PATTERN}, got: "${String(e.id)}"`,
);
} else if (seen.has(e.id)) {
errors.push(
`contributes.actionSlots[${index}].id is a duplicate: "${e.id}"`,
);
} else {
seen.add(e.id);
}
if (!Array.isArray(e.accepts) || e.accepts.length === 0) {
errors.push(
`contributes.actionSlots[${index}].accepts must be a non-empty array`,
);
} else {
for (const kind of e.accepts) {
if (!ACTION_CONTRIBUTION_KINDS.includes(kind as never)) {
errors.push(
`contributes.actionSlots[${index}].accepts has unknown value: "${String(kind)}". Known values: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`,
);
}
}
}
if (
"descriptionKey" in e &&
(typeof e.descriptionKey !== "string" || e.descriptionKey.length === 0)
) {
errors.push(
`contributes.actionSlots[${index}].descriptionKey must be a string`,
);
}
});
return errors;
}
/**
* Validates and narrows in one step. Unlike validateManifest, this also
* enforces the SDK compatibility gate, which only the runtime cares about --
* the authoring-time validator has no single Termix build to check against.
*/
export function parseManifest(raw: unknown): {
manifest?: PluginManifest;
errors: string[];
} {
const errors = validateManifest(raw);
if (errors.length > 0) return { errors };
const manifest = raw as PluginManifest;
if (manifest.engine.api !== SUPPORTED_PLUGIN_API_VERSION) {
return {
errors: [
`Plugin targets SDK api version "${manifest.engine.api}", this Termix build implements "${SUPPORTED_PLUGIN_API_VERSION}"`,
],
};
}
// A service permission has to be one the plugin's own permissionGroup
// declares, or it would never reach the catalog and every call through it
// would deny against a permission no admin can grant.
const declaredPermissions =
manifest.contributes?.permissionGroup?.permissions ?? [];
for (const entry of manifest.provides ?? []) {
if (!declaredPermissions.includes(entry.permission)) {
return {
errors: [
`Service "${entry.service}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`,
],
};
}
}
// Same rule for a shared secret. This is what keeps sharing on the existing
// RBAC grant path instead of becoming a separate mechanism: the permission
// gating a borrowed secret is an ordinary role permission in the provider's
// own group, editable per role or per user like any other.
for (const entry of manifest.providesSecret ?? []) {
if (!declaredPermissions.includes(entry.permission)) {
return {
errors: [
`Secret "${entry.key}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`,
],
};
}
}
// A plugin borrowing from itself would bypass nothing, but it is always a
// mistake: its own secrets are reachable through ctx.secrets directly.
for (const entry of manifest.requiresSecret ?? []) {
if (entry.plugin === manifest.id) {
return {
errors: [
`requiresSecret entry for "${entry.key}" names this plugin itself; use ctx.secrets.get instead`,
],
};
}
}
// Same rule for UI actions, for the same reason: a permission the catalog
// never sees is one no admin can grant, so the action would be invisible
// forever rather than merely denied.
for (const action of manifest.contributes?.actions ?? []) {
if (action.permission && !declaredPermissions.includes(action.permission)) {
return {
errors: [
`Action "${action.id}" is gated by "${action.permission}", which is not declared in contributes.permissionGroup.permissions`,
],
};
}
}
// Refused outright rather than ignored: a plugin that asked to run in-process
// and was quietly downgraded to a worker would fail later in a confusing way,
// and the manifest would still claim reach it does not have.
if (
manifest.permissions.includes(TRANSPORT_OWNER_CAPABILITY) &&
!isFirstParty(manifest.id)
) {
return {
errors: [
`Permission "${TRANSPORT_OWNER_CAPABILITY}" is reserved for first-party plugins and cannot be declared by "${manifest.id}"`,
],
};
}
return { manifest, errors: [] };
}
export {
validateManifest,
parseManifest,
SUPPORTED_PLUGIN_API_VERSION,
PLUGIN_CATEGORIES,
PLUGIN_PLATFORMS,
ACTION_CONTRIBUTION_KINDS,
DEFAULT_BACKEND_ENTRY,
DEFAULT_FRONTEND_ENTRY,
DEFAULT_LOCALES_DIR,
} from "@termix/plugin-sdk/manifest";
export type {
PluginManifest,
PluginAuthor,
PluginEngine,
PluginContributions,
PluginTabContribution,
PluginActionContribution,
PluginActionSlot,
PluginPermissionGroup,
PluginServiceProvide,
PluginServiceRequire,
PluginSecretProvide,
PluginSecretRequire,
HostCapabilityContribution,
ActionContributionKind,
ParsedManifest,
} from "@termix/plugin-sdk/manifest";
export {
CAPABILITY_CATALOG,
CAPABILITY_IDS,
isKnownCapability,
getCapabilityInfo,
} from "@termix/plugin-sdk/capabilities";
export type {
Capability,
CapabilityInfo,
CapabilityRisk,
} from "@termix/plugin-sdk/capabilities";
+29 -11
View File
@@ -1,26 +1,30 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
DEFAULT_BACKEND_ENTRY,
type PluginManifest,
} from "@termix/plugin-sdk/manifest";
/**
* Where unpacked plugins live. Resolved per call rather than captured at module
* scope: tests set DATA_DIR in beforeEach, and a module-scope constant would
* freeze whatever the first import saw.
* Where user-installed plugins live. Resolved per call rather than captured at
* module scope: tests set DATA_DIR in beforeEach, and a module-scope constant
* would freeze whatever the first import saw.
*/
export function getPluginsDir(): string {
return path.join(process.env.DATA_DIR || "./db/data", "plugins");
}
/**
* Where first-party plugins that ship with Termix live.
* Where the plugins that ship with Termix live.
*
* Separate from getPluginsDir() because that one is user data: a bundled
* plugin is part of the install and must not be deletable or shadowable by
* whatever happens to be in the data directory.
*
* Resolved from this module's location rather than cwd, same reasoning as the
* worker bootstrap: the build emits to dist/backend/backend/plugins/, and the
* bundled plugins sit beside dist/backend.
* Resolved from this module's location rather than cwd, because the build
* emits to dist/backend/backend/plugins/ and the bundled plugins sit beside
* dist/backend.
*/
export function getBundledPluginsDir(): string {
const override = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
@@ -30,9 +34,10 @@ export function getBundledPluginsDir(): string {
const candidates = [
// Built server: dist/backend/backend/plugins -> dist/plugins
// Source tree: src/backend/plugins -> plugins
// Both are three levels up, which is why there is one entry, not two.
path.resolve(here, "../../../plugins"),
// Running from source under vitest/tsx: src/backend/plugins -> plugins
path.resolve(here, "../../../plugins"),
// Built server invoked from a nested layout: .../dist/backend/backend/plugins
path.resolve(here, "../../../../plugins"),
];
@@ -46,8 +51,21 @@ export function getPluginDir(pluginId: string): string {
return path.join(getPluginsDir(), pluginId);
}
export function getPluginBackendEntry(pluginDir: string): string {
return path.join(pluginDir, "backend", "index.mjs");
/**
* The backend entry, from the manifest's own `backend` field.
*
* Bundled plugins still ship a hand-written backend/index.mjs, so that stays
* the fallback until A2 gives every plugin a real build.
*/
export function getPluginBackendEntry(
pluginDir: string,
manifest?: Pick<PluginManifest, "backend">,
): string {
const legacy = path.join(pluginDir, "backend", "index.mjs");
if (!manifest?.backend || manifest.backend === DEFAULT_BACKEND_ENTRY) {
if (fs.existsSync(legacy)) return legacy;
}
return path.join(pluginDir, manifest?.backend ?? DEFAULT_BACKEND_ENTRY);
}
export function getPluginManifestPath(pluginDir: string): string {
+17 -34
View File
@@ -1,40 +1,23 @@
/**
* The grant check every privileged ctx call goes through.
* The grant check every guarded ctx call goes through.
*
* Capability strings are the manifest's own permission enum, stored verbatim in
* plugin_permission_grants.capability -- no translation layer, so the manifest
* stays the single source of truth for what is grantable.
* Two things have to be true for a capability to count as granted: the
* manifest declares it (the plugin asked for it up front) and it is granted in
* plugin_permission_grants (someone, or the bundling process, said yes). A
* grant for something the manifest never declared is ignored rather than
* honoured, so widening a plugin's reach always requires a new manifest the
* user can see.
*
* Two separate things have to be true for a capability to count as granted:
* the manifest must declare it (the plugin asked for it up front) and an admin
* must have granted it (someone said yes). A grant for something the manifest
* never declared is ignored rather than honoured, so widening a plugin's reach
* always requires a new manifest the user can see.
* The cache is invalidated explicitly on every grant change rather than by
* TTL: a revoke that takes effect a minute later is not a revoke.
*/
import { createCurrentPluginPermissionGrantRepository } from "../database/repositories/factory.js";
import type { PluginPermission } from "./manifest.js";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
const CACHE_TTL_MS = 60_000;
export { PluginCapabilityError };
interface CacheEntry {
capabilities: Set<string>;
expiresAt: number;
}
const cache = new Map<string, CacheEntry>();
export class PluginPermissionError extends Error {
readonly code = "EPLUGINPERM";
constructor(pluginId: string, capability: string) {
super(
`Plugin "${pluginId}" is not granted the "${capability}" capability. ` +
`Declare it in the manifest's permissions array and grant it in the plugin's settings.`,
);
this.name = "PluginPermissionError";
}
}
const cache = new Map<string, Set<string>>();
export function invalidatePluginPermissionCache(pluginId?: string): void {
if (pluginId) cache.delete(pluginId);
@@ -43,19 +26,19 @@ export function invalidatePluginPermissionCache(pluginId?: string): void {
async function loadGrants(pluginId: string): Promise<Set<string>> {
const cached = cache.get(pluginId);
if (cached && cached.expiresAt > Date.now()) return cached.capabilities;
if (cached) return cached;
const rows =
await createCurrentPluginPermissionGrantRepository().listByPlugin(pluginId);
const capabilities = new Set(rows.map((row) => row.capability));
cache.set(pluginId, { capabilities, expiresAt: Date.now() + CACHE_TTL_MS });
cache.set(pluginId, capabilities);
return capabilities;
}
export async function hasCapability(
pluginId: string,
capability: PluginPermission | string,
capability: string,
declared: readonly string[],
): Promise<boolean> {
if (!declared.includes(capability)) return false;
@@ -64,10 +47,10 @@ export async function hasCapability(
export async function assertCapability(
pluginId: string,
capability: PluginPermission | string,
capability: string,
declared: readonly string[],
): Promise<void> {
if (!(await hasCapability(pluginId, capability, declared))) {
throw new PluginPermissionError(pluginId, capability);
throw new PluginCapabilityError(pluginId, capability);
}
}
-73
View File
@@ -1,73 +0,0 @@
/**
* Wire format between a plugin worker and the main-thread broker.
*
* Everything crossing this boundary must be structured-cloneable. That is not
* just a serialisation detail: it is the reason a plugin cannot be handed a db
* handle, an ssh2 Client or a live socket by accident. Live objects stay in the
* main thread and the worker refers to them by opaque handle strings.
*/
export interface PluginRequest {
/** Correlates a reply to its request. Worker-generated, monotonic. */
id: number;
/** Dotted ctx path, e.g. "hosts.get" or "storage.set". */
method: string;
args: unknown[];
/**
* The user whose HTTP request is currently executing inside the worker, if
* any. Set by the worker's own call() from its AsyncLocalStorage context,
* never by plugin code -- no ctx method accepts a userId argument, so a
* plugin has no way to set or forge this itself. Absent for a ctx call made
* from a timer or event listener, which has no inbound request to derive an
* actor from; the broker falls back to the plugin's ownerUserId then.
*/
callerUserId?: string;
}
export interface PluginResponseOk {
id: number;
ok: true;
value: unknown;
}
export interface PluginResponseErr {
id: number;
ok: false;
error: { message: string; code?: string };
}
export type PluginResponse = PluginResponseOk | PluginResponseErr;
/** Main thread -> worker, pushed rather than requested. */
export interface PluginPush {
kind: "event" | "http" | "timer" | "deactivate";
/** Event topic, route key, or timer id depending on kind. */
key: string;
payload: unknown;
/** Set when the push expects a reply (an HTTP route invocation). */
replyTo?: number;
}
/** First message the worker receives, before any plugin code runs. */
export interface PluginBootstrapData {
pluginId: string;
pluginDir: string;
entryPath: string;
manifest: unknown;
}
export function isPluginRequest(value: unknown): value is PluginRequest {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return (
typeof v.id === "number" &&
typeof v.method === "string" &&
Array.isArray(v.args)
);
}
export function isPluginResponse(value: unknown): value is PluginResponse {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return typeof v.id === "number" && typeof v.ok === "boolean";
}
-301
View File
@@ -1,301 +0,0 @@
/**
* The worker thread's entry point. Runs before any plugin code.
*
* Builds the ctx object, imports the plugin's backend/index.mjs, and calls its
* exported activate(ctx). The plugin gets ctx and nothing else from us -- no
* db, no fs, no net handle is ever passed in. See loader.ts for an honest
* account of what that does and does not guarantee.
*/
import { AsyncLocalStorage } from "node:async_hooks";
import { parentPort, workerData } from "node:worker_threads";
import type {
PluginBootstrapData,
PluginPush,
PluginResponse,
} from "./protocol.js";
import { isPluginResponse } from "./protocol.js";
const port = parentPort;
if (!port) {
throw new Error("Plugin worker started without a parent port");
}
const boot = workerData as PluginBootstrapData;
let nextRequestId = 1;
const pending = new Map<
number,
{ resolve: (value: unknown) => void; reject: (error: Error) => void }
>();
/** Handlers the plugin registered, keyed the same way the broker pushes them. */
const httpRoutes = new Map<string, (req: unknown) => unknown>();
const eventListeners = new Map<string, Set<(payload: unknown) => void>>();
const timers = new Map<string, () => void>();
/**
* Tracks which user's HTTP request is currently executing, so a ctx call made
* anywhere in that request's call stack -- including deep inside plugin code
* that has no idea this exists -- can be attributed to them without adding a
* userId parameter to every ctx method.
*
* Only entered around an ctx.http.route handler invocation (see handlePush
* below). A ctx call made from a schedule.every timer or an events.on
* listener runs outside any run() call, so getStore() correctly returns
* undefined for those and the broker falls back to the plugin's owner.
*/
const requestActor = new AsyncLocalStorage<{ userId: string }>();
function call(method: string, ...args: unknown[]): Promise<unknown> {
const id = nextRequestId++;
const callerUserId = requestActor.getStore()?.userId;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
port!.postMessage({
id,
method,
args,
...(callerUserId && { callerUserId }),
});
});
}
function handleResponse(message: PluginResponse): void {
const waiter = pending.get(message.id);
if (!waiter) return;
pending.delete(message.id);
if (message.ok === true) {
waiter.resolve(message.value);
return;
}
const failure = message.error;
const error = new Error(failure.message);
if (failure.code) {
(error as Error & { code?: string }).code = failure.code;
}
waiter.reject(error);
}
async function handlePush(push: PluginPush): Promise<void> {
if (push.kind === "deactivate") {
// Always acknowledge, even when the plugin has no hook or its hook threw:
// the main thread is waiting before it terminates us, and a missing reply
// would just stall that until its timeout.
try {
if (pluginDeactivate) await pluginDeactivate();
} catch {
// Nothing useful to do here; the worker is about to be terminated.
}
if (push.replyTo !== undefined) {
port!.postMessage({ id: push.replyTo, ok: true, value: null });
}
return;
}
if (push.kind === "event") {
const listeners = eventListeners.get(push.key);
if (!listeners) return;
for (const listener of listeners) {
try {
listener(push.payload);
} catch {
// A throwing listener must not take down the worker.
}
}
return;
}
if (push.kind === "timer") {
const handler = timers.get(push.key);
if (handler) {
try {
handler();
} catch {
// same
}
}
return;
}
if (push.kind === "http") {
const handler = httpRoutes.get(push.key);
if (push.replyTo === undefined) return;
if (!handler) {
port!.postMessage({
id: push.replyTo,
ok: false,
error: { message: `No handler registered for route ${push.key}` },
});
return;
}
const requestUserId =
(push.payload as { userId?: string | null } | undefined)?.userId ??
undefined;
try {
const value = await (requestUserId
? requestActor.run({ userId: requestUserId }, () =>
handler(push.payload),
)
: handler(push.payload));
port!.postMessage({ id: push.replyTo, ok: true, value });
} catch (error) {
port!.postMessage({
id: push.replyTo,
ok: false,
error: {
message: error instanceof Error ? error.message : String(error),
},
});
}
}
}
port.on("message", (message: unknown) => {
if (isPluginResponse(message)) {
handleResponse(message);
return;
}
const push = message as PluginPush;
if (push && typeof push.kind === "string") {
void handlePush(push);
}
});
function notImplemented(name: string) {
return () =>
Promise.reject(
new Error(
`ctx.${name} is not implemented in plugin SDK version 1. It is reserved and will be added in a later version.`,
),
);
}
const ctx = {
pluginId: boot.pluginId,
manifest: boot.manifest,
log: {
debug: (message: string, context?: unknown) =>
call("log.debug", message, context),
info: (message: string, context?: unknown) =>
call("log.info", message, context),
warn: (message: string, context?: unknown) =>
call("log.warn", message, context),
error: (message: string, context?: unknown) =>
call("log.error", message, context),
},
audit: (action: string, details?: unknown) => call("audit", action, details),
hosts: {
list: () => call("hosts.list"),
get: (hostId: number) => call("hosts.get", hostId),
},
ssh: {
connect: (hostId: number) => call("ssh.connect", hostId),
exec: (handle: string, command: string, timeoutMs?: number) =>
call("ssh.exec", handle, command, timeoutMs),
close: (handle: string) => call("ssh.close", handle),
},
storage: {
get: (key: string) => call("storage.get", key),
set: (key: string, value: unknown) => call("storage.set", key, value),
delete: (key: string) => call("storage.delete", key),
list: () => call("storage.list"),
},
http: {
route: (
method: string,
routePath: string,
handler: (req: unknown) => unknown,
) => {
const key = `${method.toUpperCase()} ${routePath}`;
httpRoutes.set(key, handler);
return call("http.route", method.toUpperCase(), routePath);
},
},
events: {
emit: (topic: string, payload: unknown) =>
call("events.emit", topic, payload),
on: async (topic: string, listener: (payload: unknown) => void) => {
let listeners = eventListeners.get(topic);
if (!listeners) {
listeners = new Set();
eventListeners.set(topic, listeners);
await call("events.subscribe", topic);
}
listeners.add(listener);
return () => {
listeners!.delete(listener);
};
},
},
schedule: {
every: async (intervalMs: number, handler: () => void) => {
const id = (await call("schedule.every", intervalMs)) as string;
timers.set(id, handler);
return id;
},
cancel: async (id: string) => {
timers.delete(id);
await call("schedule.cancel", id);
},
},
secrets: {
get: notImplemented("secrets.get"),
set: notImplemented("secrets.set"),
// Cross-plugin sharing is in-process only for now. Named here rather than
// left undefined so a worker plugin that tries it gets the same explicit
// error as any other unimplemented ctx call.
offer: notImplemented("secrets.offer"),
withdraw: notImplemented("secrets.withdraw"),
getShared: notImplemented("secrets.getShared"),
},
notify: notImplemented("notify"),
fetch: notImplemented("fetch"),
};
/**
* Held so a deactivate push can call it. A worker plugin is still terminated
* afterwards either way -- this only gives it a chance to close things
* cleanly first, which matters for anything holding a remote resource.
*/
let pluginDeactivate: (() => void | Promise<void>) | null = null;
async function start(): Promise<void> {
const module = await import(boot.entryPath);
const activate = module.activate ?? module.default?.activate;
const deactivate = module.deactivate ?? module.default?.deactivate;
if (typeof activate !== "function") {
throw new Error(
`Plugin ${boot.pluginId} backend entry does not export an activate(ctx) function`,
);
}
if (typeof deactivate === "function") pluginDeactivate = deactivate;
await activate(ctx);
port!.postMessage({ id: 0, ok: true, value: "activated" });
}
start().catch((error) => {
port!.postMessage({
id: 0,
ok: false,
error: {
message: error instanceof Error ? error.message : String(error),
},
});
});
@@ -1,20 +1,19 @@
/**
* database.ts mounts /plugin-api behind authenticateJWT, the same way every
* other router is gated. This exercises that exact wiring (rather than
* http-bridge.test.ts's bare app, which mounts the dispatcher with no auth
* in front of it on purpose, to test the dispatcher in isolation) so a
* regression here - an unauthenticated caller reaching a plugin route - is
* caught.
* /plugin-api is mounted behind authenticateJWT in database.ts, the same way
* every other router is gated, so an unauthenticated caller can never reach
* plugin code.
*
* The dispatcher itself has no routes registered in A1: the worker HTTP
* bridge was removed with the worker tier and A4 rebuilds routing on top of
* the SDK. What still has to hold, and what this covers, is that the auth
* gate sits in front of the mount and that an unknown plugin id is a 404
* rather than a crash.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import express from "express";
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
import {
createFixturePlugin,
type Fixture,
} from "../../plugins/fixture-plugin.js";
const state = vi.hoisted(() => ({
validToken: "valid-token",
@@ -22,13 +21,6 @@ const state = vi.hoisted(() => ({
}));
vi.mock("../../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
databaseLogger: {
debug: vi.fn(),
info: vi.fn(),
@@ -38,133 +30,108 @@ vi.mock("../../../utils/logger.js", () => ({
},
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(
req: Record<string, unknown> & { headers: Record<string, string> },
res: { status: (code: number) => { json: (body: unknown) => void } },
next: () => void,
) => {
const auth = req.headers["authorization"];
const token = auth?.startsWith("Bearer ") ? auth.slice(7) : null;
if (token !== state.validToken) {
res.status(401).json({ error: "Missing authentication token" });
return;
}
req.userId = state.userId;
next();
},
}),
},
}));
const { AuthManager } = await import("../../../utils/auth-manager.js");
const { PluginBroker } = await import("../../../plugins/broker.js");
const { PluginLoader } = await import("../../../plugins/loader.js");
const { buildPluginRouter } = await import("../../../plugins/http-bridge.js");
const pluginApi = await import("../../../database/routes/plugin-api-routes.js");
const WORKER_TIMEOUT = 30_000;
/** Stands in for AuthManager's middleware, with the same 401 behaviour. */
function authenticateJWT(
req: express.Request,
res: express.Response,
next: express.NextFunction,
): void {
const header = req.headers.authorization;
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
describe("/plugin-api behind the app's real auth middleware", () => {
const fixtures: Fixture[] = [];
let loader: InstanceType<typeof PluginLoader> | null = null;
let broker: InstanceType<typeof PluginBroker> | null = null;
let server: Server | null = null;
let baseUrl = "";
if (token !== state.validToken) {
res.status(401).json({ error: "Unauthorized" });
return;
}
beforeEach(async () => {
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
(req as express.Request & { userId?: string }).userId = state.userId;
next();
}
const app = express();
// Mirrors database.ts: app.use("/plugin-api", authenticateJWT, pluginApiRoutes).
app.use("/plugin-api", authenticateJWT, pluginApi.default);
let server: Server | null = null;
server = await new Promise<Server>((resolve) => {
const created = app.listen(0, "127.0.0.1", () => resolve(created));
async function startServer(): Promise<string> {
const app = express();
// The same order database.ts uses: the gate, then the dispatcher.
app.use("/plugin-api", authenticateJWT, pluginApi.default);
return new Promise((resolve) => {
server = app.listen(0, "127.0.0.1", () => {
const { port } = server!.address() as AddressInfo;
resolve(`http://127.0.0.1:${port}`);
});
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
const fixture = createFixturePlugin({
backendSource: `
export async function activate(ctx) {
await ctx.http.route("GET", "/whoami", async (req) => ({
userId: req.userId ?? null,
}));
}
`,
});
fixtures.push(fixture);
broker = new PluginBroker({
listHosts: async () => [],
resolveHost: async () => null,
onRoutesChanged: (runtime) => {
pluginApi.registerPluginRouter(
runtime.plugin.id,
buildPluginRouter(broker!, runtime),
);
},
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => {
broker!.detach(plugin.id);
pluginApi.unregisterPluginRouter(plugin.id);
},
});
await loader.load(fixture.dir);
await loader.activate("sample-plugin", "user-1");
});
}
afterEach(async () => {
await loader?.shutdown();
loader = null;
broker = null;
for (const id of pluginApi.getRegisteredPluginIds()) {
pluginApi.unregisterPluginRouter(id);
}
await new Promise<void>((resolve) => server?.close(() => resolve()));
afterEach(async () => {
if (server) {
await new Promise((resolve) => server!.close(() => resolve(undefined)));
server = null;
while (fixtures.length) fixtures.pop()!.cleanup();
}
for (const id of pluginApi.getRegisteredPluginIds()) {
pluginApi.unregisterPluginRouter(id);
}
});
describe("/plugin-api authentication", () => {
it("rejects a request with no token", async () => {
const base = await startServer();
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`);
expect(response.status).toBe(401);
});
it(
"rejects a request with no token before it reaches the plugin",
async () => {
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`);
expect(res.status).toBe(401);
},
WORKER_TIMEOUT,
);
it("rejects a request with the wrong token", async () => {
const base = await startServer();
it(
"rejects a request with an invalid token",
async () => {
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`, {
headers: { authorization: "Bearer not-the-right-token" },
});
expect(res.status).toBe(401);
},
WORKER_TIMEOUT,
);
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
headers: { Authorization: "Bearer nope" },
});
it(
"forwards the verified userId once authenticated",
async () => {
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`, {
headers: { authorization: `Bearer ${state.validToken}` },
expect(response.status).toBe(401);
});
// Past the gate, an id nothing registered is a plain 404. The auth check
// runs first either way, so a caller cannot use the difference between 401
// and 404 to find out which plugins are installed.
it("returns 404 for an unregistered plugin id when authenticated", async () => {
const base = await startServer();
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
headers: { Authorization: `Bearer ${state.validToken}` },
});
expect(response.status).toBe(404);
});
it("dispatches to a registered router only when authenticated", async () => {
const router = express.Router();
router.get("/thing", (req, res) => {
res.json({
ok: true,
userId: (req as express.Request & { userId?: string }).userId,
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ userId: state.userId });
},
WORKER_TIMEOUT,
);
});
pluginApi.registerPluginRouter("sample-plugin", router);
const base = await startServer();
const unauthorized = await fetch(`${base}/plugin-api/sample-plugin/thing`);
expect(unauthorized.status).toBe(401);
const authorized = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
headers: { Authorization: `Bearer ${state.validToken}` },
});
expect(authorized.status).toBe(200);
// The user id the plugin sees is the one the gate verified, never one the
// caller supplied.
expect(await authorized.json()).toEqual({
ok: true,
userId: state.userId,
});
});
});
@@ -17,12 +17,18 @@ interface PluginRow {
tier: string;
source: string;
state: string;
lastError?: string | null;
manifestJson: string;
}
const state = vi.hoisted(() => ({
plugins: new Map<string, PluginRow>(),
grants: [] as { pluginId: string; capability: string; grantedBy: string }[],
grants: [] as {
pluginId: string;
capability: string;
grantedBy: string | null;
source?: string;
}[],
// Which users have admin.plugins.manage, keyed by userId.
managers: new Set<string>(["admin-1"]),
}));
@@ -62,6 +68,9 @@ vi.mock("../../../utils/auth-manager.js", () => ({
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
// The list route asks directly rather than gating the whole mount, so
// it can return a narrower shape to a non-admin instead of a 403.
hasPermission: async (userId: string) => state.managers.has(userId),
requirePermission:
(_permission: string) =>
(
@@ -113,7 +122,8 @@ vi.mock("../../../database/repositories/factory.js", () => ({
grant: async (input: {
pluginId: string;
capability: string;
grantedBy: string;
grantedBy: string | null;
source?: string;
}) => {
state.grants.push(input);
return input;
@@ -140,7 +150,7 @@ function makePlugin(overrides: Partial<PluginRow> = {}): PluginRow {
source: "community",
state: "enabled",
manifestJson: JSON.stringify({
permissions: ["hosts.read", "storage.own"],
capabilities: ["hosts:read", "kv:own"],
}),
...overrides,
};
@@ -170,11 +180,11 @@ describe("plugins route", () => {
server = null;
});
it("lists a plugin's declared permissions and granted capabilities", async () => {
it("lists a plugin's declared and granted capabilities", async () => {
state.plugins.set("sample-plugin", makePlugin());
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
capability: "hosts:read",
grantedBy: "admin-1",
});
@@ -184,8 +194,8 @@ describe("plugins route", () => {
expect(body).toEqual([
expect.objectContaining({
id: "sample-plugin",
permissions: ["hosts.read", "storage.own"],
grantedCapabilities: ["hosts.read"],
capabilities: ["hosts:read", "kv:own"],
grantedCapabilities: ["hosts:read"],
}),
]);
});
@@ -196,19 +206,19 @@ describe("plugins route", () => {
const res = await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "hosts.read" }),
body: JSON.stringify({ capability: "hosts:read" }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: "sample-plugin",
capability: "hosts.read",
capability: "hosts:read",
granted: true,
});
expect(state.grants).toEqual([
{
pluginId: "sample-plugin",
capability: "hosts.read",
capability: "hosts:read",
grantedBy: "admin-1",
},
]);
@@ -231,7 +241,7 @@ describe("plugins route", () => {
const res = await fetch(`${baseUrl}/plugins/ghost/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "hosts.read" }),
body: JSON.stringify({ capability: "hosts:read" }),
});
expect(res.status).toBe(404);
@@ -243,12 +253,12 @@ describe("plugins route", () => {
await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "hosts.read" }),
body: JSON.stringify({ capability: "hosts:read" }),
});
await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "hosts.read" }),
body: JSON.stringify({ capability: "hosts:read" }),
});
expect(state.grants).toHaveLength(1);
@@ -258,19 +268,19 @@ describe("plugins route", () => {
state.plugins.set("sample-plugin", makePlugin());
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
capability: "hosts:read",
grantedBy: "admin-1",
});
const res = await fetch(
`${baseUrl}/plugins/sample-plugin/grants/hosts.read`,
`${baseUrl}/plugins/sample-plugin/grants/${encodeURIComponent("hosts:read")}`,
{ method: "DELETE" },
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: "sample-plugin",
capability: "hosts.read",
capability: "hosts:read",
granted: false,
});
expect(state.grants).toEqual([]);
@@ -280,7 +290,7 @@ describe("plugins route", () => {
state.plugins.set("sample-plugin", makePlugin());
const res = await fetch(
`${baseUrl}/plugins/sample-plugin/grants/hosts.read`,
`${baseUrl}/plugins/sample-plugin/grants/${encodeURIComponent("hosts:read")}`,
{ method: "DELETE" },
);
@@ -288,7 +298,7 @@ describe("plugins route", () => {
});
describe("admin.plugins.manage gate", () => {
it("a non-admin authenticated user cannot list plugins", async () => {
it("lets a non-admin list plugins", async () => {
// GET stays open to any authenticated user on purpose: the app shell
// calls it for every session to know which plugin tabs to register.
state.plugins.set("sample-plugin", makePlugin());
@@ -300,6 +310,58 @@ describe("plugins route", () => {
expect(res.status).toBe(200);
});
// What a plugin may do, what it has been granted and why it failed are
// operational details. The shell needs none of them to render a tab.
it("hides capabilities, grants and lastError from a non-admin", async () => {
state.plugins.set(
"sample-plugin",
makePlugin({ lastError: "port already in use" }),
);
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts:read",
grantedBy: "admin-1",
});
const res = await fetch(`${baseUrl}/plugins`, {
headers: { "x-test-user-id": "regular-user" },
});
const [plugin] = await res.json();
expect(plugin).toEqual({
id: "sample-plugin",
name: "Sample Plugin",
version: "1.0.0",
enabled: true,
state: "enabled",
contributes: null,
});
});
it("shows the full record to a holder of admin.plugins.manage", async () => {
state.plugins.set(
"sample-plugin",
makePlugin({ lastError: "port already in use" }),
);
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts:read",
grantedBy: "admin-1",
});
const res = await fetch(`${baseUrl}/plugins`, {
headers: { "x-test-user-id": "admin-1" },
});
const [plugin] = await res.json();
expect(plugin).toMatchObject({
id: "sample-plugin",
capabilities: ["hosts:read", "kv:own"],
grantedCapabilities: ["hosts:read"],
lastError: "port already in use",
});
});
it("403s a non-admin enabling or disabling a plugin", async () => {
state.plugins.set("sample-plugin", makePlugin());
@@ -325,7 +387,7 @@ describe("plugins route", () => {
"content-type": "application/json",
"x-test-user-id": "regular-user",
},
body: JSON.stringify({ capability: "hosts.read" }),
body: JSON.stringify({ capability: "hosts:read" }),
});
expect(res.status).toBe(403);
@@ -336,12 +398,12 @@ describe("plugins route", () => {
state.plugins.set("sample-plugin", makePlugin());
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
capability: "hosts:read",
grantedBy: "admin-1",
});
const res = await fetch(
`${baseUrl}/plugins/sample-plugin/grants/hosts.read`,
`${baseUrl}/plugins/sample-plugin/grants/${encodeURIComponent("hosts:read")}`,
{
method: "DELETE",
headers: { "x-test-user-id": "regular-user" },
-620
View File
@@ -1,620 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
const state = vi.hoisted(() => ({
grants: [] as { pluginId: string; capability: string }[],
auditEntries: [] as Record<string, unknown>[],
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async (params: Record<string, unknown>) => {
state.auditEntries.push(params);
}),
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
state.grants.filter((g) => g.pluginId === pluginId),
}),
}));
const { PluginBroker } = await import("../../plugins/broker.js");
const { PluginLoader } = await import("../../plugins/loader.js");
const { invalidatePluginPermissionCache } =
await import("../../plugins/permissions.js");
const WORKER_TIMEOUT = 30_000;
/**
* A host as resolveHostById would return it: secrets in plaintext. The tests
* assert none of this reaches the worker.
*/
const SECRET = "hunter2-plaintext-secret";
const RAW_HOST = {
id: 42,
name: "prod-box",
ip: "10.0.0.5",
port: 22,
username: "root",
folder: "Production",
tags: ["prod"],
authType: "password",
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
// None of these may ever cross the boundary.
password: SECRET,
key: SECRET,
keyPassword: SECRET,
certPublicKey: SECRET,
sudoPassword: SECRET,
autostartPassword: SECRET,
};
describe("PluginBroker", () => {
const fixtures: Fixture[] = [];
let loader: InstanceType<typeof PluginLoader> | null = null;
let broker: InstanceType<typeof PluginBroker> | null = null;
beforeEach(() => {
state.grants = [];
state.auditEntries = [];
storage = memoryStorage();
invalidatePluginPermissionCache();
});
afterEach(async () => {
await loader?.shutdown();
loader = null;
broker = null;
// The bus is a module singleton, so a leftover listener would leak between
// tests.
const { pluginEvents } = await import("../../plugins/events.js");
pluginEvents.clear();
while (fixtures.length) fixtures.pop()!.cleanup();
});
function grant(capability: string, pluginId = "sample-plugin") {
state.grants.push({ pluginId, capability });
invalidatePluginPermissionCache(pluginId);
}
/**
* Boots a real worker whose activate() runs `body` and reports the outcome
* back through a ctx.log call the test can read.
*/
/** In-memory stand-in for PluginStorageRepository, scoped per plugin. */
function memoryStorage() {
const rows = new Map<string, string>();
const at = (pluginId: string, key: string) => `${pluginId}\u0000${key}`;
return {
rows,
backend: {
get: async (pluginId: string, key: string) =>
rows.get(at(pluginId, key)) ?? null,
set: async (pluginId: string, key: string, value: string) => {
rows.set(at(pluginId, key), value);
},
delete: async (pluginId: string, key: string) =>
rows.delete(at(pluginId, key)),
listKeys: async (pluginId: string) =>
[...rows.keys()]
.filter((k) => k.startsWith(`${pluginId}\u0000`))
.map((k) => k.split("\u0000")[1]),
},
};
}
let storage = memoryStorage();
async function boot(body: string, permissions?: string[]) {
const fixture = createFixturePlugin({
permissions: permissions ?? ["hosts.read", "storage.own"],
backendSource: `
export async function activate(ctx) {
globalThis.__result = undefined;
try {
globalThis.__result = { ok: true, value: await (async () => { ${body} })() };
} catch (error) {
globalThis.__result = { ok: false, error: error.message };
}
await ctx.log.info(JSON.stringify(globalThis.__result));
}
`,
});
fixtures.push(fixture);
broker = new PluginBroker({
listHosts: async () => [RAW_HOST],
resolveHost: async (hostId) => (hostId === 42 ? RAW_HOST : null),
storage: storage.backend,
});
const logged: string[] = [];
const { pluginLogger } = await import("../../utils/logger.js");
vi.mocked(pluginLogger.info).mockImplementation((message: string) => {
logged.push(message);
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => broker!.detach(plugin.id),
});
await loader.load(fixture.dir);
await loader.activate("sample-plugin", "user-1");
const line = logged.find((entry) => entry.startsWith("{"));
return line ? JSON.parse(line) : { ok: false, error: "no result reported" };
}
describe("permission gate", () => {
it(
"allows a call whose capability is declared and granted",
async () => {
grant("hosts.read");
const result = await boot(`return await ctx.hosts.get(42);`);
expect(result.ok).toBe(true);
expect(result.value).toMatchObject({ id: 42, name: "prod-box" });
},
WORKER_TIMEOUT,
);
it(
"denies a declared capability that was never granted",
async () => {
const result = await boot(`return await ctx.hosts.get(42);`);
expect(result.ok).toBe(false);
expect(result.error).toContain('is not granted the "hosts.read"');
},
WORKER_TIMEOUT,
);
it(
"denies a granted capability the manifest never declared",
async () => {
// Granted in the database, but absent from the manifest: widening a
// plugin's reach must require a new manifest, not just a grant.
grant("hosts.read");
const result = await boot(`return await ctx.hosts.get(42);`, [
"storage.own",
]);
expect(result.ok).toBe(false);
expect(result.error).toContain('is not granted the "hosts.read"');
},
WORKER_TIMEOUT,
);
it(
"rejects an unknown ctx method",
async () => {
const result = await boot(
`return await new Promise((resolve, reject) => {
ctx.hosts.__proto__; resolve("noop");
});`,
);
expect(result.ok).toBe(true);
},
WORKER_TIMEOUT,
);
});
describe("credential isolation", () => {
it(
"never sends credential material to the worker",
async () => {
grant("hosts.read");
const result = await boot(`
const host = await ctx.hosts.get(42);
const list = await ctx.hosts.list();
const blob = JSON.stringify({ host, list });
if (blob.includes(${JSON.stringify(SECRET)})) {
throw new Error("SECRET LEAKED: " + blob);
}
return { keys: Object.keys(host).sort(), listLength: list.length };
`);
expect(result.ok).toBe(true);
expect(result.value.listLength).toBe(1);
expect(result.value.keys).not.toContain("password");
expect(result.value.keys).not.toContain("key");
expect(result.value.keys).not.toContain("keyPassword");
expect(result.value.keys).not.toContain("certPublicKey");
expect(result.value.keys).not.toContain("sudoPassword");
expect(result.value.keys).not.toContain("autostartPassword");
},
WORKER_TIMEOUT,
);
it(
"returns null rather than distinguishing missing from forbidden",
async () => {
grant("hosts.read");
const result = await boot(`return await ctx.hosts.get(999);`);
expect(result.ok).toBe(true);
expect(result.value).toBeNull();
},
WORKER_TIMEOUT,
);
});
describe("audit", () => {
it(
"writes one plugin-attributed line per permitted call",
async () => {
grant("hosts.read");
await boot(`return await ctx.hosts.get(42);`);
const entry = state.auditEntries.find(
(e) => e.action === "plugin_hosts_get",
);
expect(entry).toBeDefined();
expect(entry).toMatchObject({
userId: "user-1",
username: "plugin:sample-plugin",
resourceType: "plugin",
resourceId: "sample-plugin",
success: true,
});
},
WORKER_TIMEOUT,
);
it(
"audits a denied call as a failure",
async () => {
await boot(`return await ctx.hosts.get(42);`);
const entry = state.auditEntries.find(
(e) => e.action === "plugin_hosts_get",
);
expect(entry).toMatchObject({
username: "plugin:sample-plugin",
success: false,
});
expect(String(entry?.errorMessage)).toContain("is not granted");
},
WORKER_TIMEOUT,
);
it(
"does not let a plugin forge the actor or smuggle bulk details",
async () => {
grant("hosts.read");
// ctx.log takes a context object, so this exercises the summariser on
// an argument a plugin genuinely controls.
await boot(`
await ctx.log.info("hello", { username: "admin", userId: "user-999" });
return await ctx.hosts.get(42);
`);
const entry = state.auditEntries.find(
(e) => e.action === "plugin_hosts_get",
);
// Attribution is broker-set regardless of what the plugin passed.
expect(entry?.username).toBe("plugin:sample-plugin");
expect(entry?.userId).toBe("user-1");
// No audit entry anywhere claims the user the plugin named.
expect(
state.auditEntries.some(
(e) => e.userId === "user-999" || e.username === "admin",
),
).toBe(false);
},
WORKER_TIMEOUT,
);
it(
"summarises rather than records a long string argument",
async () => {
grant("hosts.read");
await boot(`
try { await ctx.hosts.get("${"x".repeat(500)}"); } catch {}
return "done";
`);
const entry = state.auditEntries.find(
(e) => e.action === "plugin_hosts_get",
);
const details = String(entry?.details);
// Capped, so a plugin cannot write bulk data into the audit trail.
expect(details.length).toBeLessThan(200);
expect(details).not.toContain("x".repeat(200));
},
WORKER_TIMEOUT,
);
});
describe("ctx.storage", () => {
it(
"round-trips a structured value",
async () => {
grant("storage.own");
const result = await boot(`
await ctx.storage.set("prefs", { theme: "dark", count: 3 });
const read = await ctx.storage.get("prefs");
const keys = await ctx.storage.list();
return { read, keys };
`);
expect(result.ok).toBe(true);
expect(result.value.read).toEqual({ theme: "dark", count: 3 });
expect(result.value.keys).toEqual(["prefs"]);
},
WORKER_TIMEOUT,
);
it(
"returns null for a key that was never set",
async () => {
grant("storage.own");
const result = await boot(`return await ctx.storage.get("nope");`);
expect(result.ok).toBe(true);
expect(result.value).toBeNull();
},
WORKER_TIMEOUT,
);
it(
"deletes a key",
async () => {
grant("storage.own");
const result = await boot(`
await ctx.storage.set("temp", 1);
await ctx.storage.delete("temp");
return await ctx.storage.list();
`);
expect(result.ok).toBe(true);
expect(result.value).toEqual([]);
},
WORKER_TIMEOUT,
);
it(
"scopes writes to the calling plugin, which cannot name another scope",
async () => {
grant("storage.own");
// A plugin only ever supplies a key; the plugin id is added by the
// broker, so there is no argument through which to reach another
// plugin's rows.
await boot(`await ctx.storage.set("owned", "value");`);
const keys = [...storage.rows.keys()];
expect(keys).toEqual(["sample-plugin\u0000owned"]);
},
WORKER_TIMEOUT,
);
it(
"denies storage without the granted capability",
async () => {
const result = await boot(`return await ctx.storage.get("prefs");`);
expect(result.ok).toBe(false);
expect(result.error).toContain('is not granted the "storage.own"');
},
WORKER_TIMEOUT,
);
it(
"rejects an empty key and an oversized value",
async () => {
grant("storage.own");
const result = await boot(`
const errors = [];
try { await ctx.storage.set("", 1); } catch (e) { errors.push(e.message); }
try {
await ctx.storage.set("big", "x".repeat(300000));
} catch (e) { errors.push(e.message); }
return errors;
`);
expect(result.ok).toBe(true);
expect(result.value[0]).toContain("non-empty key");
expect(result.value[1]).toContain("byte limit");
},
WORKER_TIMEOUT,
);
});
describe("ctx.schedule", () => {
it(
"registers a timer and fires the plugin's handler",
async () => {
grant("storage.own");
const result = await boot(`
await new Promise(async (resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timer never fired")), 8000);
await ctx.schedule.every(1000, async () => {
clearTimeout(timer);
resolve();
});
});
return "fired";
`);
expect(result.ok).toBe(true);
expect(result.value).toBe("fired");
},
WORKER_TIMEOUT,
);
it(
"rejects an interval below the floor",
async () => {
grant("storage.own");
const result = await boot(
`return await ctx.schedule.every(5, () => {});`,
);
expect(result.ok).toBe(false);
expect(result.error).toContain("at least 1000ms");
},
WORKER_TIMEOUT,
);
it(
"cancel stops the timer and drops it from the runtime",
async () => {
grant("storage.own");
await boot(`
const id = await ctx.schedule.every(1000, () => {});
await ctx.schedule.cancel(id);
`);
expect(broker!.get("sample-plugin")!.timers.size).toBe(0);
},
WORKER_TIMEOUT,
);
});
describe("ctx.events", () => {
it(
"pushes a subscribed topic through to the plugin's listener",
async () => {
grant("events.read");
const { pluginEvents } = await import("../../plugins/events.js");
// The plugin subscribes during activate, then reports what it sees on
// the next event. boot() resolves once activate returns, so the emit
// below happens with the subscription already live.
await boot(
`
await ctx.events.on("host.status", async (payload) => {
await ctx.log.info("EVENT:" + JSON.stringify(payload));
});
`,
["events.read"],
);
const runtime = broker!.get("sample-plugin")!;
expect(runtime.subscriptions.has("host.status")).toBe(true);
expect(pluginEvents.listenerCount("host.status")).toBe(1);
const pushed: unknown[] = [];
const original = runtime.worker.postMessage.bind(runtime.worker);
runtime.worker.postMessage = ((message: unknown) => {
pushed.push(message);
return original(message);
}) as typeof runtime.worker.postMessage;
pluginEvents.emit("host.status", { hostId: 42, online: true });
expect(pushed).toContainEqual({
kind: "event",
key: "host.status",
payload: { hostId: 42, online: true },
});
},
WORKER_TIMEOUT,
);
it(
"refuses to emit outside the plugin's own namespace",
async () => {
grant("events.read");
const result = await boot(
`
const errors = [];
try {
await ctx.events.emit("host.status", { hostId: 1, online: false });
} catch (e) { errors.push(e.message); }
await ctx.events.emit("plugin.sample-plugin.ready", { ok: true });
return errors;
`,
["events.read"],
);
expect(result.ok).toBe(true);
expect(result.value[0]).toContain(
'may only publish topics beginning with "plugin.sample-plugin."',
);
},
WORKER_TIMEOUT,
);
it(
"denies subscribing without the events.read capability",
async () => {
const result = await boot(
`return await ctx.events.on("host.status", () => {});`,
["events.read"],
);
expect(result.ok).toBe(false);
expect(result.error).toContain('is not granted the "events.read"');
},
WORKER_TIMEOUT,
);
it(
"drops bus subscriptions when the plugin detaches",
async () => {
grant("events.read");
const { pluginEvents } = await import("../../plugins/events.js");
await boot(`await ctx.events.on("host.status", () => {});`, [
"events.read",
]);
expect(pluginEvents.listenerCount("host.status")).toBe(1);
broker!.detach("sample-plugin");
expect(pluginEvents.listenerCount("host.status")).toBe(0);
},
WORKER_TIMEOUT,
);
});
describe("detach", () => {
it(
"releases ssh handles and timers when the worker goes away",
async () => {
grant("hosts.read");
await boot(`return await ctx.hosts.get(42);`);
const runtime = broker!.get("sample-plugin");
expect(runtime).toBeDefined();
let released = false;
runtime!.sshHandles.set("sshconn:test", {
hostId: 42,
release: () => {
released = true;
},
});
runtime!.timers.set(
"t1",
setInterval(() => {}, 10_000),
);
broker!.detach("sample-plugin");
expect(released).toBe(true);
expect(broker!.get("sample-plugin")).toBeUndefined();
},
WORKER_TIMEOUT,
);
});
});
+93 -414
View File
@@ -1,8 +1,9 @@
/**
* The real bundled ssh-terminal plugin, as it ships.
* The bundled plugins, as they ship.
*
* This reads plugins/ in the repo rather than a fixture: the manifest is a
* checked-in artifact, and a typo in it would otherwise only show up at boot.
* This reads plugins/ in the repo rather than a fixture: the manifests are
* checked-in artifacts, and a typo in one would otherwise only show up when
* the server tried to boot.
*/
import { describe, expect, it, vi } from "vitest";
@@ -10,11 +11,7 @@ import fs from "node:fs";
import path from "node:path";
import { getBundledPluginsDir } from "../../plugins/paths.js";
import { parseManifest } from "../../plugins/manifest.js";
import {
isFirstParty,
runsInProcess,
TRANSPORT_OWNER_CAPABILITY,
} from "../../plugins/first-party.js";
import { isKnownCapability } from "@termix/plugin-sdk/capabilities";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
@@ -26,443 +23,125 @@ vi.mock("../../utils/logger.js", () => ({
},
}));
function readBundledManifest(pluginId: string): Record<string, unknown> {
const file = path.join(getBundledPluginsDir(), pluginId, "manifest.json");
return JSON.parse(fs.readFileSync(file, "utf8"));
const BUNDLED_DIR = getBundledPluginsDir();
function bundledIds(): string[] {
return fs
.readdirSync(BUNDLED_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((id) => fs.existsSync(path.join(BUNDLED_DIR, id, "manifest.json")));
}
describe("bundled ssh-terminal plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "ssh-terminal");
function readManifest(pluginId: string): Record<string, unknown> {
return JSON.parse(
fs.readFileSync(path.join(BUNDLED_DIR, pluginId, "manifest.json"), "utf8"),
);
}
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
const ids = bundledIds();
describe("bundled plugins", () => {
it("finds the bundled plugin directory", () => {
expect(ids.length).toBeGreaterThan(0);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("ssh-terminal"),
);
it.each(ids)("%s has a manifest that parses", (id) => {
const { manifest, errors } = parseManifest(readManifest(id));
expect(errors).toEqual([]);
expect(manifest?.id).toBe("ssh-terminal");
expect(manifest?.category).toBe("Terminal");
expect(manifest?.id).toBe(id);
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("ssh-terminal"));
it.each(ids)("%s declares only catalog capabilities", (id) => {
const { manifest } = parseManifest(readManifest(id));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("ssh-terminal")).toBe(true);
expect(runsInProcess("ssh-terminal", manifest!.permissions)).toBe(true);
for (const capability of manifest?.capabilities ?? []) {
expect(isKnownCapability(capability)).toBe(true);
}
});
it("contributes the terminal tab the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("ssh-terminal"));
const tab = manifest?.contributes?.tabs?.[0];
it.each(ids)("%s ships the entry points its manifest names", (id) => {
const { manifest } = parseManifest(readManifest(id));
const dir = path.join(BUNDLED_DIR, id);
// The shell keys tab content off this id, so it has to stay "terminal".
expect(tab?.id).toBe("terminal");
expect(tab?.openFrom).toContain("rail");
expect(fs.existsSync(path.join(dir, manifest!.backend!))).toBe(true);
expect(fs.existsSync(path.join(dir, manifest!.frontend!))).toBe(true);
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"ssh-terminal",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
// process:transport-owner was the v1 marker for the in-process tier. There
// is one tier now, so nothing should still be asking for it.
it.each(ids)("%s does not declare a v1 permission", (id) => {
const raw = readManifest(id);
// A first-party plugin owns a listening port, so deactivate is not
// optional here the way it is for an ordinary plugin.
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
expect(raw.permissions).toBeUndefined();
expect(raw.sidecars).toBeUndefined();
expect(JSON.stringify(raw)).not.toContain("transport-owner");
});
});
describe("bundled docker plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "docker");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
describe("declared dependencies", () => {
it("names a plugin that is actually bundled", () => {
for (const id of ids) {
const { manifest } = parseManifest(readManifest(id));
for (const dependency of Object.keys(manifest?.dependencies ?? {})) {
expect(ids).toContain(dependency);
}
}
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(readBundledManifest("docker"));
// plugins/ai/backend/tools/executor.ts imports automations' routes
// directly, so the loader has to start automations first. B18 replaces the
// import with a service call and this becomes optional.
it("records ai's dependency on automations", () => {
if (!ids.includes("ai")) return;
expect(errors).toEqual([]);
expect(manifest?.id).toBe("docker");
expect(manifest?.category).toBe("Infrastructure");
});
const { manifest } = parseManifest(readManifest("ai"));
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("docker"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("docker")).toBe(true);
expect(runsInProcess("docker", manifest!.permissions)).toBe(true);
});
it("contributes the docker tab the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("docker"));
const tab = manifest?.contributes?.tabs?.[0];
// The shell keys tab content off this id, so it has to stay "docker".
expect(tab?.id).toBe("docker");
expect(tab?.openFrom).toContain("host-context-menu");
});
it("declares the enableDocker host capability", () => {
const { manifest } = parseManifest(readBundledManifest("docker"));
expect(manifest?.contributes?.hostCapability?.key).toBe("enableDocker");
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"docker",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
expect(manifest?.dependencies?.automations).toBeTruthy();
});
});
describe("bundled host-metrics plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "host-metrics");
describe("plugins that own a port", () => {
// Every one of these had its server created at module scope at some point.
// The rule is that it belongs inside activate, or disable leaves the port
// held and re-enable fails.
it.each(["ssh-terminal", "docker", "host-metrics", "remote-desktop"])(
"%s creates no listener at module scope",
(id) => {
if (!ids.includes(id)) return;
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
const entry = path.join(BUNDLED_DIR, id, "backend", "index.mjs");
const source = fs.readFileSync(entry, "utf8");
const moduleScope = source.slice(
0,
source.indexOf("export async function activate"),
);
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("host-metrics"),
);
expect(moduleScope).not.toMatch(/\.listen\(/);
expect(moduleScope).not.toMatch(/new WebSocketServer/);
},
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("host-metrics");
expect(manifest?.category).toBe("Monitoring");
});
it("docker does not register a process signal handler", () => {
if (!ids.includes("docker")) return;
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("host-metrics"));
// Core owns shutdown: gracefulShutdown calls shutdownPlugins, which runs
// every plugin's deactivate. A plugin calling process.exit() itself
// skipped the rest of that sequence.
for (const file of ["index.mjs", "console.ts"]) {
const candidate = path.join(BUNDLED_DIR, "docker", "backend", file);
if (!fs.existsSync(candidate)) continue;
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("host-metrics")).toBe(true);
expect(runsInProcess("host-metrics", manifest!.permissions)).toBe(true);
});
// Comments are stripped first: the rule is worth explaining in prose
// right where it applies, and that prose names the thing it forbids.
const code = fs
.readFileSync(candidate, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\/\/.*$/gm, "");
it("contributes the host-metrics tab the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("host-metrics"));
const tab = manifest?.contributes?.tabs?.[0];
// The shell keys tab content off this id, so it has to stay "host-metrics".
expect(tab?.id).toBe("host-metrics");
expect(tab?.openFrom).toContain("host-context-menu");
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"host-metrics",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled remote-desktop plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "remote-desktop");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("remote-desktop"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("remote-desktop");
expect(manifest?.category).toBe("Terminal");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("remote-desktop"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("remote-desktop")).toBe(true);
expect(runsInProcess("remote-desktop", manifest!.permissions)).toBe(true);
});
it("contributes the rdp/vnc/telnet tabs the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("remote-desktop"));
const tabIds = manifest?.contributes?.tabs?.map((tab) => tab.id);
expect(tabIds).toEqual(["rdp", "vnc", "telnet"]);
});
it("declares three host capabilities as an array", () => {
const { manifest } = parseManifest(readBundledManifest("remote-desktop"));
const hostCapability = manifest?.contributes?.hostCapability;
expect(Array.isArray(hostCapability)).toBe(true);
expect(
(hostCapability as Array<{ key: string }>).map((entry) => entry.key),
).toEqual(["enableRdp", "enableVnc", "enableTelnet"]);
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"remote-desktop",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled fleets plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "fleets");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(readBundledManifest("fleets"));
expect(errors).toEqual([]);
expect(manifest?.id).toBe("fleets");
expect(manifest?.category).toBe("Infrastructure");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("fleets"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("fleets")).toBe(true);
expect(runsInProcess("fleets", manifest!.permissions)).toBe(true);
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"fleets",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled automations plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "automations");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("automations"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("automations");
expect(manifest?.category).toBe("Infrastructure");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("automations"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("automations")).toBe(true);
expect(runsInProcess("automations", manifest!.permissions)).toBe(true);
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"automations",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled network-topology plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "network-topology");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("network-topology"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("network-topology");
expect(manifest?.category).toBe("Infrastructure");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("network-topology"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("network-topology")).toBe(true);
expect(runsInProcess("network-topology", manifest!.permissions)).toBe(true);
});
it("contributes the network_graph tab the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("network-topology"));
const tab = manifest?.contributes?.tabs?.[0];
// The shell keys tab content off this id, so it has to stay "network_graph".
expect(tab?.id).toBe("network_graph");
expect(tab?.openFrom).toContain("rail");
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"network-topology",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled workspaces plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "workspaces");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("workspaces"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("workspaces");
expect(manifest?.category).toBe("Productivity");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("workspaces"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("workspaces")).toBe(true);
expect(runsInProcess("workspaces", manifest!.permissions)).toBe(true);
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"workspaces",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled web-endpoint plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "web-endpoint");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("web-endpoint"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("web-endpoint");
expect(manifest?.category).toBe("Networking");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("web-endpoint"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("web-endpoint")).toBe(true);
expect(runsInProcess("web-endpoint", manifest!.permissions)).toBe(true);
});
// The column this points at stays on core's ssh_data table, so a typo here
// would silently detach the host-editor checkbox from its storage.
it("contributes the enableWebUi host capability", () => {
const { manifest } = parseManifest(readBundledManifest("web-endpoint"));
expect(manifest?.contributes?.hostCapability).toMatchObject({
key: "enableWebUi",
});
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"web-endpoint",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
expect(code).not.toMatch(/process\.on\(\s*["']SIG/);
expect(code).not.toMatch(/process\.exit\(/);
}
});
});
@@ -0,0 +1,234 @@
/**
* The capability gate.
*
* Two conditions, both required: the manifest declares it and it is granted.
* Either missing is a refusal, and both the refusal and the allowed call are
* audited with the plugin id and the acting user, because "who did this"
* being answerable is the point of the gate.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
const kvStore = new Map<string, string>();
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
createCurrentPluginStorageRepository: () => ({
get: async (pluginId: string, key: string) =>
kvStore.get(`${pluginId}:${key}`) ?? null,
set: async (pluginId: string, key: string, value: string) => {
kvStore.set(`${pluginId}:${key}`, value);
},
delete: async (pluginId: string, key: string) =>
kvStore.delete(`${pluginId}:${key}`),
listKeys: async () => [...kvStore.keys()],
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
const { createPluginContext, createPluginHandle } =
await import("../../plugins/ctx.js");
const { invalidatePluginPermissionCache } =
await import("../../plugins/permissions.js");
const { runAsActor } = await import("../../plugins/actor.js");
function manifest(capabilities: string[]) {
return {
id: "sample-plugin",
name: "Sample Plugin",
version: "1.0.0",
description: "",
author: { name: "Termix Tests" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities,
} as never;
}
function context(declared: string[]) {
const value = manifest(declared);
const handle = createPluginHandle("sample-plugin", {
activate: () => {},
});
return createPluginContext(value, handle);
}
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
kvStore.clear();
invalidatePluginPermissionCache();
});
describe("capability gate", () => {
it("denies a capability the manifest never declared", async () => {
// Granted in the database, but absent from the manifest. Widening a
// plugin's reach must always require a new manifest the user can see.
grants.set("sample-plugin", ["kv:own"]);
const ctx = context([]);
await expect(ctx.kv.get("anything")).rejects.toThrow(
/not granted the "kv:own" capability/,
);
});
it("denies a declared capability that has not been granted", async () => {
const ctx = context(["kv:own"]);
await expect(ctx.kv.get("anything")).rejects.toThrow(
/not granted the "kv:own" capability/,
);
});
it("allows a capability that is both declared and granted", async () => {
grants.set("sample-plugin", ["kv:own"]);
const ctx = context(["kv:own"]);
await ctx.kv.set("colour", "blue");
await expect(ctx.kv.get("colour")).resolves.toBe("blue");
});
it("audits an allowed call with the plugin id and the actor", async () => {
grants.set("sample-plugin", ["kv:own"]);
const ctx = context(["kv:own"]);
await runAsActor("user-42", "request", () => ctx.kv.set("k", 1));
const entry = auditEntries.find((e) => e.action === "plugin_kv_set");
expect(entry).toMatchObject({
userId: "user-42",
username: "plugin:sample-plugin",
resourceType: "plugin",
resourceId: "sample-plugin",
success: true,
});
});
// A refused call is exactly the thing an operator wants to find later.
it("audits a denied call as a failure", async () => {
const ctx = context(["kv:own"]);
await expect(
runAsActor("user-42", "request", () => ctx.kv.get("k")),
).rejects.toThrow();
const entry = auditEntries.find((e) => e.action === "plugin_kv_get");
expect(entry).toMatchObject({ userId: "user-42", success: false });
expect(String(entry?.errorMessage)).toMatch(/not granted/);
});
it("picks up a grant made after the first denial", async () => {
const ctx = context(["kv:own"]);
await expect(ctx.kv.get("k")).rejects.toThrow();
grants.set("sample-plugin", ["kv:own"]);
invalidatePluginPermissionCache("sample-plugin");
await expect(ctx.kv.get("k")).resolves.toBeNull();
});
});
describe("ctx.asUser", () => {
it("makes the named user the actor and audits the switch", async () => {
grants.set("sample-plugin", ["kv:own"]);
const ctx = context(["kv:own"]);
let seen: string | undefined;
await ctx.asUser("user-7", async () => {
seen = ctx.currentActor();
await ctx.kv.set("k", 1);
});
expect(seen).toBe("user-7");
expect(
auditEntries.find((e) => e.action === "plugin_as_user"),
).toMatchObject({ resourceId: "sample-plugin", success: true });
expect(
auditEntries.find((e) => e.action === "plugin_kv_set"),
).toMatchObject({ userId: "user-7" });
});
it("restores the previous actor afterwards", async () => {
const ctx = context([]);
await runAsActor("outer-user", "request", async () => {
await ctx.asUser("inner-user", async () => {
expect(ctx.currentActor()).toBe("inner-user");
});
expect(ctx.currentActor()).toBe("outer-user");
});
});
it("refuses an empty user id", async () => {
const ctx = context([]);
await expect(ctx.asUser("", async () => {})).rejects.toThrow(
/without a user id/,
);
});
});
describe("ctx.events namespacing", () => {
it("refuses a core topic without events:core", () => {
const ctx = context(["kv:own"]);
// Without this a plugin could publish host.status and drive the
// automations engine as though core had.
expect(() => ctx.events.emit("host.status", {})).toThrow(
/may only emit topics under/,
);
});
it("allows the plugin's own namespace", () => {
const ctx = context(["kv:own"]);
expect(() =>
ctx.events.emit("plugin.sample-plugin.thing", {}),
).not.toThrow();
});
it("allows a core topic when events:core is declared", () => {
const ctx = context(["events:core"]);
expect(() => ctx.events.emit("host.status", {})).not.toThrow();
});
});
describe("ctx.kv limits", () => {
beforeEach(() => {
grants.set("sample-plugin", ["kv:own"]);
});
it("rejects an over-long key", async () => {
const ctx = context(["kv:own"]);
await expect(ctx.kv.set("k".repeat(129), 1)).rejects.toThrow(
/at most 128 characters/,
);
});
it("rejects a value over the size cap", async () => {
const ctx = context(["kv:own"]);
await expect(ctx.kv.set("big", "x".repeat(300_000))).rejects.toThrow(
/byte limit/,
);
});
});
@@ -1,365 +0,0 @@
/**
* The security test for the plugin runtime.
*
* The claim under test: a plugin worker cannot obtain plaintext credential
* material through ANY ctx method. Rather than assert field-by-field, this
* plants a unique sentinel string in every secret-bearing field of the resolved
* host and then asserts that the sentinel appears in nothing the worker ever
* receives -- return values, thrown error messages, or the ctx object itself.
*
* Every message crossing the boundary is recorded, so the assertion covers the
* wire, not just what the plugin happened to look at.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
const SENTINEL = "SENT1NEL-plaintext-credential-do-not-leak";
const state = vi.hoisted(() => ({
grants: [] as { pluginId: string; capability: string }[],
/** Every message the main thread sent to a worker. */
outbound: [] as unknown[],
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async () => {}),
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
state.grants.filter((g) => g.pluginId === pluginId),
}),
}));
const { PluginBroker } = await import("../../plugins/broker.js");
const { PluginLoader } = await import("../../plugins/loader.js");
const { invalidatePluginPermissionCache } =
await import("../../plugins/permissions.js");
const WORKER_TIMEOUT = 30_000;
/** A host exactly as resolveHostById hands it over: secrets in the clear. */
const RESOLVED_HOST = {
id: 42,
name: "prod-box",
ip: "10.0.0.5",
port: 22,
username: "root",
folder: "Production",
tags: ["prod"],
authType: "password",
enableTerminal: true,
enableTunnel: true,
enableFileManager: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
password: SENTINEL,
key: SENTINEL,
keyPassword: SENTINEL,
certPublicKey: SENTINEL,
sudoPassword: SENTINEL,
autostartPassword: SENTINEL,
autostartKey: SENTINEL,
autostartKeyPassword: SENTINEL,
credentialId: 7,
vaultProfileId: 3,
};
function deepContains(value: unknown, needle: string): boolean {
if (typeof value === "string") return value.includes(needle);
if (value === null || typeof value !== "object") return false;
return Object.values(value as Record<string, unknown>).some((inner) =>
deepContains(inner, needle),
);
}
describe("plugin credential isolation", () => {
const fixtures: Fixture[] = [];
let loader: InstanceType<typeof PluginLoader> | null = null;
let broker: InstanceType<typeof PluginBroker> | null = null;
beforeEach(() => {
state.grants = [
{ pluginId: "sample-plugin", capability: "hosts.read" },
{ pluginId: "sample-plugin", capability: "ssh.exec" },
{ pluginId: "sample-plugin", capability: "storage.own" },
];
state.outbound = [];
invalidatePluginPermissionCache();
});
afterEach(async () => {
await loader?.shutdown();
loader = null;
broker = null;
while (fixtures.length) fixtures.pop()!.cleanup();
});
/** Boots a plugin whose activate() runs `body` and reports the result. */
async function attack(body: string, sshOpenFails = false) {
const fixture = createFixturePlugin({
permissions: ["hosts.read", "ssh.exec", "storage.own"],
backendSource: `
export async function activate(ctx) {
let result;
try {
result = { ok: true, value: await (async () => { ${body} })() };
} catch (error) {
result = { ok: false, error: String(error && error.message) };
}
await ctx.log.info(JSON.stringify(result));
}
`,
});
fixtures.push(fixture);
broker = new PluginBroker({
listHosts: async () => [RESOLVED_HOST],
resolveHost: async (hostId) => (hostId === 42 ? RESOLVED_HOST : null),
ssh: {
open: async () => {
if (sshOpenFails) {
// A failing connect must not spill the credential into the error
// message either: this mirrors ssh2 embedding config on failure.
throw new Error(
`Authentication failed for root using password ${SENTINEL}`,
);
}
return {
release: () => {},
exec: async () => ({
stdout: "uptime: 4 days",
stderr: "",
code: 0,
}),
};
},
},
});
const logged: string[] = [];
const { pluginLogger } = await import("../../utils/logger.js");
vi.mocked(pluginLogger.info).mockImplementation((message: string) => {
logged.push(message);
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => {
// Record everything the main thread pushes at the worker.
const original = worker.postMessage.bind(worker);
worker.postMessage = ((message: unknown) => {
state.outbound.push(message);
return original(message);
}) as typeof worker.postMessage;
broker!.attach(plugin, worker);
},
onWorkerGone: (plugin) => broker!.detach(plugin.id),
});
await loader.load(fixture.dir);
await loader.activate("sample-plugin", "user-1");
const line = logged.find((entry) => entry.startsWith("{"));
return line ? JSON.parse(line) : { ok: false, error: "no result" };
}
it(
"ctx.hosts.get returns no credential material",
async () => {
const result = await attack(`return await ctx.hosts.get(42);`);
expect(result.ok).toBe(true);
expect(deepContains(result.value, SENTINEL)).toBe(false);
for (const field of [
"password",
"key",
"keyPassword",
"certPublicKey",
"sudoPassword",
"autostartPassword",
"autostartKey",
"autostartKeyPassword",
"credentialId",
"vaultProfileId",
]) {
expect(Object.keys(result.value)).not.toContain(field);
}
},
WORKER_TIMEOUT,
);
it(
"ctx.hosts.list returns no credential material",
async () => {
const result = await attack(`return await ctx.hosts.list();`);
expect(result.ok).toBe(true);
expect(deepContains(result.value, SENTINEL)).toBe(false);
},
WORKER_TIMEOUT,
);
it(
"ctx.ssh.connect returns an opaque handle, not a connection",
async () => {
const result = await attack(`
const handle = await ctx.ssh.connect(42);
return {
handle,
type: typeof handle,
// Anything reachable from the handle, if it were an object.
reachable: typeof handle === "object" ? Object.keys(handle) : null,
};
`);
expect(result.ok).toBe(true);
expect(result.value.type).toBe("string");
expect(result.value.handle).toMatch(/^sshconn:/);
expect(result.value.handle).not.toContain(SENTINEL);
// The handle is random, so it cannot encode the host or its secrets.
expect(result.value.handle).not.toContain("10.0.0.5");
expect(result.value.handle).not.toContain("root");
},
WORKER_TIMEOUT,
);
it(
"ctx.ssh.exec returns only stdout, stderr and code",
async () => {
const result = await attack(`
const handle = await ctx.ssh.connect(42);
return await ctx.ssh.exec(handle, "uptime");
`);
expect(result.ok).toBe(true);
expect(Object.keys(result.value).sort()).toEqual([
"code",
"stderr",
"stdout",
]);
expect(deepContains(result.value, SENTINEL)).toBe(false);
},
WORKER_TIMEOUT,
);
it(
"a failing ctx.ssh.connect does not leak the credential in its error",
async () => {
const result = await attack(`return await ctx.ssh.connect(42);`, true);
expect(result.ok).toBe(false);
expect(result.error).not.toContain(SENTINEL);
},
WORKER_TIMEOUT,
);
it(
"a plugin cannot walk ctx itself to a credential",
async () => {
const result = await attack(`
const seen = new Set();
const found = [];
(function walk(value, path, depth) {
if (depth > 6 || value === null || seen.has(value)) return;
if (typeof value === "string") {
if (value.includes(${JSON.stringify(SENTINEL)})) found.push(path);
return;
}
if (typeof value !== "object" && typeof value !== "function") return;
seen.add(value);
for (const key of Object.getOwnPropertyNames(value)) {
let inner;
try { inner = value[key]; } catch { continue; }
walk(inner, path + "." + key, depth + 1);
}
})(ctx, "ctx", 0);
return found;
`);
expect(result.ok).toBe(true);
expect(result.value).toEqual([]);
},
WORKER_TIMEOUT,
);
it(
"no message sent to the worker ever contains credential material",
async () => {
await attack(`
const host = await ctx.hosts.get(42);
const list = await ctx.hosts.list();
const handle = await ctx.ssh.connect(42);
await ctx.ssh.exec(handle, "uptime");
await ctx.storage.set("cache", { host, list });
return await ctx.storage.get("cache");
`);
expect(state.outbound.length).toBeGreaterThan(0);
const leaking = state.outbound.filter((message) =>
deepContains(message, SENTINEL),
);
expect(leaking).toEqual([]);
},
WORKER_TIMEOUT,
);
it(
"a plugin cannot exec on a handle it did not open",
async () => {
const result = await attack(`
return await ctx.ssh.exec("sshconn:00000000-0000-0000-0000-000000000000", "id");
`);
expect(result.ok).toBe(false);
expect(result.error).toContain("Unknown or closed SSH connection handle");
},
WORKER_TIMEOUT,
);
it(
"a closed handle stops working",
async () => {
const result = await attack(`
const handle = await ctx.ssh.connect(42);
await ctx.ssh.close(handle);
try {
await ctx.ssh.exec(handle, "id");
return "still worked";
} catch (error) {
return error.message;
}
`);
expect(result.value).toContain("Unknown or closed SSH connection handle");
},
WORKER_TIMEOUT,
);
it(
"ssh.connect is denied without the granted capability",
async () => {
state.grants = [{ pluginId: "sample-plugin", capability: "hosts.read" }];
invalidatePluginPermissionCache();
const result = await attack(`return await ctx.ssh.connect(42);`);
expect(result.ok).toBe(false);
expect(result.error).toContain('is not granted the "ssh.exec"');
},
WORKER_TIMEOUT,
);
});
@@ -1,17 +1,18 @@
/**
* The central promise of shipping the terminal as a plugin: disabling it
* releases what it owns, and nothing else notices.
* Disabling a plugin releases what it owns.
*
* The SSH connection pool deliberately stayed in core, so a worker plugin's
* ctx.ssh keeps working with the terminal disabled. If the pool had moved into
* the terminal plugin, these tests would fail, which is the point of them.
* A plugin that keeps a port after deactivate makes "disabled" a lie and
* makes re-enabling fail on a port that is still held, which is exactly the
* bug the docker console had: its WebSocket server was created at module
* scope, so it survived deactivate and the second activate got nothing.
*
* These use a real listening socket rather than a mock, because the thing
* under test is whether the OS still has the port.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import net from "node:net";
import { PluginLoader } from "../../plugins/loader.js";
import { PluginBroker } from "../../plugins/broker.js";
import { TRANSPORT_OWNER_CAPABILITY } from "../../plugins/first-party.js";
import { clearRegistry } from "../../plugins/registry.js";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
@@ -28,7 +29,7 @@ vi.mock("../../utils/logger.js", () => ({
vi.mock("../../plugins/permissions.js", () => ({
assertCapability: vi.fn().mockResolvedValue(undefined),
hasCapability: vi.fn().mockResolvedValue(true),
PluginPermissionError: class extends Error {},
PluginCapabilityError: class extends Error {},
invalidatePluginPermissionCache: vi.fn(),
}));
@@ -45,136 +46,83 @@ function isListening(port: number): Promise<boolean> {
});
}
/** Stands in for the terminal: a first-party plugin that owns a real port. */
function terminalLikeSource(port: number): string {
/**
* A plugin that owns a real port, the way ssh-terminal and docker do.
*
* The server is created inside activate and closed through a disposable, not
* at module scope, which is the rule the runtime documents.
*/
function portOwningSource(port: number): string {
return `
import net from "node:net";
let server = null;
export async function activate(ctx) {
server = net.createServer();
await new Promise((resolve) => server.listen(${port}, "127.0.0.1", resolve));
ctx.registry.provide("terminal.sessions", { live: true });
}
export async function deactivate() {
if (!server) return;
await new Promise((resolve) => server.close(resolve));
server = null;
}
`;
import net from "node:net";
export async function activate(ctx) {
const server = net.createServer();
await new Promise((resolve) => server.listen(${port}, "127.0.0.1", resolve));
ctx.disposables.add(
() => new Promise((resolve) => server.close(() => resolve())),
);
}
`;
}
describe("disabling the terminal plugin", () => {
const fixtures: Fixture[] = [];
let loader: PluginLoader | null = null;
let fixture: Fixture | null = null;
let loader: PluginLoader | null = null;
afterEach(async () => {
await loader?.shutdown();
loader = null;
for (const fixture of fixtures.splice(0)) fixture.cleanup();
clearRegistry();
});
afterEach(async () => {
await loader?.shutdown();
loader = null;
fixture?.cleanup();
fixture = null;
clearRegistry();
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
delete process.env.DATA_DIR;
});
it("frees its port and leaves another plugin's ctx.ssh working", async () => {
const terminal = createFixturePlugin({
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
manifestOverrides: { category: "Terminal" },
backendSource: terminalLikeSource(TEST_PORT),
describe("disabling a plugin releases its resources", () => {
async function start(): Promise<PluginLoader> {
fixture = createFixturePlugin({
backendSource: portOwningSource(TEST_PORT),
});
fixtures.push(terminal);
process.env.TERMIX_BUNDLED_PLUGINS_DIR = fixture.root;
process.env.DATA_DIR = fixture.root;
const other = createFixturePlugin({
id: "other-plugin",
permissions: ["ssh.exec"],
// Exposes an HTTP route the test invokes after the terminal is
// disabled, so the ctx.ssh round trip genuinely happens afterwards and
// travels the real worker boundary.
backendSource: `
export async function activate(ctx) {
await ctx.http.route("GET", "/run", async () => {
const handle = await ctx.ssh.connect(1);
const result = await ctx.ssh.exec(handle, "uptime");
await ctx.ssh.close(handle);
return result;
});
}
`,
});
fixtures.push(other);
// Stands in for the core pool. The terminal plugin does not provide it,
// so disabling the terminal must not affect it.
const open = vi.fn().mockResolvedValue({
release: vi.fn(),
exec: vi
.fn()
.mockResolvedValue({ stdout: "up 3 days", stderr: "", code: 0 }),
});
const broker = new PluginBroker({
listHosts: async () => [],
resolveHost: async () => null,
ssh: { open },
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker.attach(plugin, worker),
onWorkerGone: (plugin) => broker.detach(plugin.id),
});
await loader.load(terminal.dir);
await loader.load(other.dir);
await loader.activate("ssh-terminal");
await loader.activate("other-plugin", "user-1");
const instance = new PluginLoader();
await instance.loadAll();
await instance.activate(fixture.id);
loader = instance;
return instance;
}
it("frees the port the plugin was listening on", async () => {
const instance = await start();
expect(await isListening(TEST_PORT)).toBe(true);
await loader.deactivate("ssh-terminal");
await instance.deactivate(fixture!.id);
// What the user sees: the terminal's port is gone.
expect(await isListening(TEST_PORT)).toBe(false);
expect(loader.get("ssh-terminal")?.state).toBe("stopped");
// And the other plugin is untouched, still running off the core pool.
const otherPlugin = loader.get("other-plugin");
expect(otherPlugin?.state).toBe("active");
// The real test: a ctx.ssh round trip completed AFTER the terminal was
// disabled, off the core pool, across the worker boundary.
const runtime = broker.get("other-plugin")!;
const result = (await broker.invokeRoute(runtime, "GET /run", {
method: "GET",
path: "/run",
params: {},
query: {},
body: null,
headers: {},
userId: "user-1",
})) as { stdout: string };
expect(open).toHaveBeenCalled();
expect(result.stdout).toBe("up 3 days");
});
it("can be re-enabled after being disabled", async () => {
const terminal = createFixturePlugin({
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
manifestOverrides: { category: "Terminal" },
backendSource: terminalLikeSource(TEST_PORT + 1),
});
fixtures.push(terminal);
// The regression the docker console hit: the module stays in the ESM cache,
// so the second activate runs against it and has to be able to bind again.
it("can take the port again after being re-enabled", async () => {
const instance = await start();
loader = new PluginLoader();
await loader.load(terminal.dir);
await instance.deactivate(fixture!.id);
expect(await isListening(TEST_PORT)).toBe(false);
await loader.activate("ssh-terminal");
expect(await isListening(TEST_PORT + 1)).toBe(true);
await instance.activate(fixture!.id);
await loader.deactivate("ssh-terminal");
expect(await isListening(TEST_PORT + 1)).toBe(false);
expect(await isListening(TEST_PORT)).toBe(true);
expect(instance.get(fixture!.id)?.state).toBe("active");
});
await loader.activate("ssh-terminal");
expect(await isListening(TEST_PORT + 1)).toBe(true);
it("frees the port on shutdown too", async () => {
const instance = await start();
await instance.shutdown();
loader = null;
expect(await isListening(TEST_PORT)).toBe(false);
});
});
@@ -1,279 +0,0 @@
/**
* The first-party tier is the one place in the plugin system where code runs
* without the worker boundary, so the thing most worth testing is that it
* cannot be entered by a plugin that says it should be.
*/
import { describe, expect, it } from "vitest";
import {
FIRST_PARTY_PLUGIN_IDS,
isFirstParty,
runsInProcess,
TRANSPORT_OWNER_CAPABILITY,
} from "../../plugins/first-party.js";
import {
parseManifest,
SUPPORTED_PLUGIN_API_VERSION,
} from "../../plugins/manifest.js";
function manifest(overrides: Record<string, unknown> = {}) {
return {
id: "sample-plugin",
name: "Sample Plugin",
version: "1.0.0",
description: "A plugin.",
author: { name: "Someone" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION },
capabilities: {
backend: true,
frontend: false,
electron: false,
platforms: ["linux"],
},
permissions: [] as string[],
sidecars: [],
...overrides,
};
}
describe("first-party allowlist", () => {
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale, workspaces and web-endpoint", () => {
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
"ai",
"automations",
"docker",
"fleets",
"host-metrics",
"network-topology",
"proxmox",
"remote-desktop",
"ssh-terminal",
"tailscale",
"web-endpoint",
"workspaces",
]);
});
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale, workspaces and web-endpoint and nothing else", () => {
expect(isFirstParty("ssh-terminal")).toBe(true);
expect(isFirstParty("docker")).toBe(true);
expect(isFirstParty("host-metrics")).toBe(true);
expect(isFirstParty("ai")).toBe(true);
expect(isFirstParty("proxmox")).toBe(true);
expect(isFirstParty("remote-desktop")).toBe(true);
expect(isFirstParty("fleets")).toBe(true);
expect(isFirstParty("automations")).toBe(true);
expect(isFirstParty("network-topology")).toBe(true);
expect(isFirstParty("tailscale")).toBe(true);
expect(isFirstParty("workspaces")).toBe(true);
expect(isFirstParty("web-endpoint")).toBe(true);
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
expect(isFirstParty("community-plugin")).toBe(false);
expect(isFirstParty("")).toBe(false);
});
});
describe("runsInProcess", () => {
it("requires both the allowlist and the declared capability", () => {
expect(runsInProcess("ssh-terminal", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
expect(runsInProcess("docker", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("host-metrics", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
expect(runsInProcess("ai", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("proxmox", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("remote-desktop", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
expect(runsInProcess("fleets", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("automations", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
expect(
runsInProcess("network-topology", [TRANSPORT_OWNER_CAPABILITY]),
).toBe(true);
expect(runsInProcess("tailscale", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("workspaces", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
expect(runsInProcess("web-endpoint", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
// On the list but not asking for it: stays in a worker, so the manifest
// remains an honest description of what the plugin does.
expect(runsInProcess("ssh-terminal", ["hosts.read"])).toBe(false);
expect(runsInProcess("docker", ["hosts.read"])).toBe(false);
expect(runsInProcess("host-metrics", ["hosts.read"])).toBe(false);
expect(runsInProcess("ai", ["hosts.read"])).toBe(false);
expect(runsInProcess("proxmox", ["hosts.read"])).toBe(false);
expect(runsInProcess("remote-desktop", ["hosts.read"])).toBe(false);
expect(runsInProcess("fleets", ["hosts.read"])).toBe(false);
expect(runsInProcess("automations", ["hosts.read"])).toBe(false);
expect(runsInProcess("network-topology", ["hosts.read"])).toBe(false);
expect(runsInProcess("tailscale", ["hosts.read"])).toBe(false);
expect(runsInProcess("workspaces", ["hosts.read"])).toBe(false);
expect(runsInProcess("web-endpoint", ["hosts.read"])).toBe(false);
// Asking for it but not on the list.
expect(
runsInProcess("community-plugin", [TRANSPORT_OWNER_CAPABILITY]),
).toBe(false);
expect(runsInProcess("community-plugin", ["hosts.read"])).toBe(false);
});
});
describe("manifest gate on the reserved capability", () => {
it("refuses the capability for a plugin that is not first-party", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({ permissions: [TRANSPORT_OWNER_CAPABILITY] }),
);
expect(parsed).toBeUndefined();
expect(errors).toHaveLength(1);
expect(errors[0]).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(errors[0]).toContain("reserved for first-party plugins");
});
it("refuses it even when mixed in with ordinary permissions", () => {
const { manifest: parsed } = parseManifest(
manifest({
permissions: ["hosts.read", TRANSPORT_OWNER_CAPABILITY, "storage.own"],
}),
);
expect(parsed).toBeUndefined();
});
it("allows it for ssh-terminal", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("ssh-terminal");
});
it("allows it for docker", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "docker",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("docker");
});
it("allows it for host-metrics", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "host-metrics",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("host-metrics");
});
it("allows it for ai", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "ai",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("ai");
});
it("allows it for proxmox", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "proxmox",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("proxmox");
});
it("allows it for automations", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "automations",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("automations");
});
it("allows it for network-topology", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "network-topology",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("network-topology");
});
it("allows it for tailscale", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "tailscale",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("tailscale");
});
it("allows it for workspaces", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "workspaces",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("workspaces");
});
it("allows it for web-endpoint", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "web-endpoint",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("web-endpoint");
});
it("still accepts an ordinary manifest that never mentions it", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({ permissions: ["hosts.read"] }),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("sample-plugin");
});
});
+11 -13
View File
@@ -1,7 +1,7 @@
/**
* Builds a real unpacked plugin on disk for the loader/broker tests.
* Builds a real unpacked plugin on disk for the runtime tests.
*
* The backend entry has to be a genuine .mjs file the worker can import, and
* The backend entry has to be a genuine .mjs file the loader can import, and
* tsc only emits .ts, so these are written at test time rather than committed.
* Same mkdtemp convention the other disk-touching backend tests use.
*/
@@ -13,12 +13,15 @@ import { SUPPORTED_PLUGIN_API_VERSION } from "../../plugins/manifest.js";
export interface FixtureOptions {
id?: string;
permissions?: string[];
/** Catalog capability ids the manifest declares. */
capabilities?: string[];
/** Body of backend/index.mjs. Must export activate(ctx). */
backendSource?: string;
/** Omit the backend entry file even though the manifest declares one. */
omitBackendEntry?: boolean;
manifestOverrides?: Record<string, unknown>;
/** Write into this root instead of a fresh temp dir. */
root?: string;
}
export interface Fixture {
@@ -30,13 +33,14 @@ export interface Fixture {
const DEFAULT_BACKEND = `
export async function activate(ctx) {
ctx.__ready = true;
ctx.log.info("fixture activated");
}
`;
export function createFixturePlugin(options: FixtureOptions = {}): Fixture {
const id = options.id ?? "sample-plugin";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-plugins-"));
const root =
options.root ?? fs.mkdtempSync(path.join(os.tmpdir(), "termix-plugins-"));
const dir = path.join(root, id);
fs.mkdirSync(path.join(dir, "backend"), { recursive: true });
@@ -50,14 +54,8 @@ export function createFixturePlugin(options: FixtureOptions = {}): Fixture {
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION },
capabilities: {
backend: true,
frontend: false,
electron: false,
platforms: ["linux", "win32", "darwin"],
},
permissions: options.permissions ?? ["hosts.read", "storage.own"],
sidecars: [],
capabilities: options.capabilities ?? ["hosts:read", "kv:own"],
backend: "backend/index.mjs",
...options.manifestOverrides,
};
@@ -1,480 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
const state = vi.hoisted(() => ({
grants: [] as { pluginId: string; capability: string }[],
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
databaseLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async () => {}),
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
state.grants.filter((g) => g.pluginId === pluginId),
}),
}));
const { PluginBroker } = await import("../../plugins/broker.js");
const { PluginLoader } = await import("../../plugins/loader.js");
const { buildPluginRouter } = await import("../../plugins/http-bridge.js");
const pluginApi = await import("../../database/routes/plugin-api-routes.js");
const { invalidatePluginPermissionCache } =
await import("../../plugins/permissions.js");
const WORKER_TIMEOUT = 30_000;
describe("plugin HTTP bridge", () => {
const fixtures: Fixture[] = [];
let loader: InstanceType<typeof PluginLoader> | null = null;
let broker: InstanceType<typeof PluginBroker> | null = null;
let server: Server | null = null;
let baseUrl = "";
beforeEach(async () => {
state.grants = [];
const app = express();
app.use("/plugin-api", pluginApi.default);
server = await new Promise<Server>((resolve) => {
const created = app.listen(0, "127.0.0.1", () => resolve(created));
});
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await loader?.shutdown();
loader = null;
broker = null;
for (const id of pluginApi.getRegisteredPluginIds()) {
pluginApi.unregisterPluginRouter(id);
}
await new Promise<void>((resolve) => server?.close(() => resolve()));
server = null;
while (fixtures.length) fixtures.pop()!.cleanup();
});
async function activate(backendSource: string) {
const fixture = createFixturePlugin({ backendSource });
fixtures.push(fixture);
broker = new PluginBroker({
listHosts: async () => [],
resolveHost: async () => null,
onRoutesChanged: (runtime) => {
pluginApi.registerPluginRouter(
runtime.plugin.id,
buildPluginRouter(broker!, runtime),
);
},
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => {
broker!.detach(plugin.id);
pluginApi.unregisterPluginRouter(plugin.id);
},
});
await loader.load(fixture.dir);
await loader.activate("sample-plugin", "user-1");
}
it("404s for a plugin that is not running", async () => {
const res = await fetch(`${baseUrl}/plugin-api/ghost/anything`);
expect(res.status).toBe(404);
expect(await res.json()).toEqual({
error: "Plugin not installed or not running",
});
});
it(
"dispatches a GET to an active plugin's handler",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("GET", "/ping", async () => ({ pong: true }));
}
`);
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/ping`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ pong: true });
},
WORKER_TIMEOUT,
);
it(
"passes the body, query and params through to the handler",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("POST", "/echo/:id", async (req) => ({
id: req.params.id,
q: req.query.q,
body: req.body,
method: req.method,
}));
}
`);
const res = await fetch(
`${baseUrl}/plugin-api/sample-plugin/echo/7?q=hello`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "termix" }),
},
);
expect(await res.json()).toEqual({
id: "7",
q: "hello",
body: { name: "termix" },
method: "POST",
});
},
WORKER_TIMEOUT,
);
it(
"honours an explicit status/body envelope",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("GET", "/missing", async () => ({
status: 404,
body: { error: "nope" },
}));
}
`);
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/missing`);
expect(res.status).toBe(404);
expect(await res.json()).toEqual({ error: "nope" });
},
WORKER_TIMEOUT,
);
it(
"treats a payload with a body field as the body, not an envelope",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("GET", "/doc", async () => ({
body: "the article text",
title: "A post",
}));
}
`);
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/doc`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
body: "the article text",
title: "A post",
});
},
WORKER_TIMEOUT,
);
it(
"returns 502 when the plugin handler throws",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("GET", "/boom", async () => {
throw new Error("handler exploded");
});
}
`);
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/boom`);
expect(res.status).toBe(502);
expect((await res.json()).detail).toContain("handler exploded");
},
WORKER_TIMEOUT,
);
it(
"does not forward authorization headers or cookies to the plugin",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("GET", "/headers", async (req) => ({
seen: Object.keys(req.headers).sort(),
}));
}
`);
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/headers`, {
headers: {
authorization: "Bearer super-secret-token",
cookie: "session=secret",
"content-type": "application/json",
},
});
const seen = (await res.json()).seen as string[];
expect(seen).not.toContain("authorization");
expect(seen).not.toContain("cookie");
expect(seen).toContain("content-type");
},
WORKER_TIMEOUT,
);
it(
"rejects an unsupported method and a path without a leading slash",
async () => {
const fixture = createFixturePlugin({
backendSource: `
export async function activate(ctx) {
const errors = [];
try {
await ctx.http.route("TRACE", "/x", () => null);
} catch (e) { errors.push(e.message); }
try {
await ctx.http.route("GET", "no-slash", () => null);
} catch (e) { errors.push(e.message); }
if (errors.length !== 2) throw new Error("expected two rejections");
await ctx.http.route("GET", "/ok", async () => ({ errors }));
}
`,
});
fixtures.push(fixture);
broker = new PluginBroker({
listHosts: async () => [],
resolveHost: async () => null,
onRoutesChanged: (runtime) => {
pluginApi.registerPluginRouter(
runtime.plugin.id,
buildPluginRouter(broker!, runtime),
);
},
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => broker!.detach(plugin.id),
});
await loader.load(fixture.dir);
await loader.activate("sample-plugin", "user-1");
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/ok`);
const errors = (await res.json()).errors as string[];
expect(errors[0]).toContain("does not support the TRACE method");
expect(errors[1]).toContain('must start with "/"');
},
WORKER_TIMEOUT,
);
it(
"404s again once the plugin is deactivated",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("GET", "/ping", async () => ({ pong: true }));
}
`);
expect(
(await fetch(`${baseUrl}/plugin-api/sample-plugin/ping`)).status,
).toBe(200);
await loader!.deactivate("sample-plugin");
pluginApi.unregisterPluginRouter("sample-plugin");
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/ping`);
expect(res.status).toBe(404);
},
WORKER_TIMEOUT,
);
describe("per-request actor identity", () => {
/**
* Exercises the real worker end to end: the fixture's activate() calls
* ctx.hosts.list() from inside a genuine ctx.http.route handler, so the
* AsyncLocalStorage context worker-bootstrap.ts enters around that
* invocation is the thing under test, not a mock of it. Two concurrent
* requests as two different users must each see only their own hosts,
* even though both are in flight on the very same worker at once.
*/
async function activateWithHostsRoute() {
const fixture = createFixturePlugin({
backendSource: `
export async function activate(ctx) {
await ctx.http.route("GET", "/my-hosts", async () => {
const hosts = await ctx.hosts.list();
return { hosts };
});
}
`,
});
fixtures.push(fixture);
const hostsByUser: Record<string, { id: number; name: string }[]> = {
alice: [{ id: 1, name: "alice-host" }],
bob: [{ id: 2, name: "bob-host" }],
};
broker = new PluginBroker({
listHosts: async (userId) => hostsByUser[userId] ?? [],
resolveHost: async () => null,
onRoutesChanged: (runtime) => {
pluginApi.registerPluginRouter(
runtime.plugin.id,
buildPluginRouter(broker!, runtime),
);
},
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => {
broker!.detach(plugin.id);
pluginApi.unregisterPluginRouter(plugin.id);
},
});
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
});
invalidatePluginPermissionCache("sample-plugin");
await loader.load(fixture.dir);
// Activated under "install-owner", a third identity distinct from
// alice and bob, so a test that accidentally fell back to ownerUserId
// instead of the per-request actor would show up as a clear mismatch
// rather than an accidental pass.
await loader.activate("sample-plugin", "install-owner");
}
/**
* Mimics authenticateJWT setting req.userId ahead of the dispatcher, the
* way database.ts really wires it (see plugin-api-routes-auth.test.ts for
* that wiring itself). This file's own app has no auth in front of
* pluginApi.default on purpose, to test the dispatcher in isolation, so
* the per-user identity is injected the same minimal way here.
*/
function fetchAsUser(path: string, userId: string) {
return fetch(`${baseUrl}${path}`, {
headers: { "x-test-user-id": userId },
});
}
it(
"two concurrent requests as different users each see only their own hosts",
async () => {
await activateWithHostsRoute();
// Reopen the server with a stand-in auth middleware that reads the
// test header, since the beforeEach server has none.
await new Promise<void>((resolve) => server?.close(() => resolve()));
const app = express();
app.use((req, _res, next) => {
const userId = req.headers["x-test-user-id"];
if (typeof userId === "string") {
(req as typeof req & { userId?: string }).userId = userId;
}
next();
});
app.use("/plugin-api", pluginApi.default);
server = await new Promise((resolve) => {
const created = app.listen(0, "127.0.0.1", () => resolve(created));
});
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
const [aliceRes, bobRes] = await Promise.all([
fetchAsUser("/plugin-api/sample-plugin/my-hosts", "alice"),
fetchAsUser("/plugin-api/sample-plugin/my-hosts", "bob"),
]);
expect(await aliceRes.json()).toMatchObject({
hosts: [{ id: 1, name: "alice-host" }],
});
expect(await bobRes.json()).toMatchObject({
hosts: [{ id: 2, name: "bob-host" }],
});
},
WORKER_TIMEOUT,
);
it(
"falls back to the install owner when a call has no inbound request",
async () => {
const fixture = createFixturePlugin({
backendSource: `
export async function activate(ctx) {
const hosts = await ctx.hosts.list();
await ctx.log.info(JSON.stringify({ hosts }));
}
`,
});
fixtures.push(fixture);
const hostsByUser: Record<string, { id: number; name: string }[]> = {
"install-owner": [{ id: 9, name: "owner-host" }],
};
broker = new PluginBroker({
listHosts: async (userId) => hostsByUser[userId] ?? [],
resolveHost: async () => null,
});
const logged: string[] = [];
const { pluginLogger } = await import("../../utils/logger.js");
vi.mocked(pluginLogger.info).mockImplementation((message: string) => {
logged.push(message);
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => broker!.detach(plugin.id),
});
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
});
invalidatePluginPermissionCache("sample-plugin");
await loader.load(fixture.dir);
await loader.activate("sample-plugin", "install-owner");
const line = logged.find((entry) => entry.startsWith("{"));
expect(line && JSON.parse(line)).toMatchObject({
hosts: [{ id: 9, name: "owner-host" }],
});
},
WORKER_TIMEOUT,
);
});
});
@@ -1,200 +0,0 @@
/**
* The in-process branch of the loader: a first-party plugin runs on the main
* thread, with no worker, and its deactivate() actually gets awaited.
*
* These use the real ssh-terminal id because the allowlist is hardcoded and
* that is the point -- there is deliberately no way to fake membership.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { PluginLoader } from "../../plugins/loader.js";
import { TRANSPORT_OWNER_CAPABILITY } from "../../plugins/first-party.js";
import { clearRegistry, consume } from "../../plugins/registry.js";
import { pluginEvents } from "../../plugins/events.js";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const FIRST_PARTY_OPTIONS = {
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
manifestOverrides: { category: "Terminal" },
};
/**
* Writes a marker file rather than using a module-level variable: the plugin
* is imported by URL into the same process, but ESM caching means a second
* fixture at a different path is a different module instance.
*/
function backendWritingMarker(markerPath: string, extra = ""): string {
const literal = JSON.stringify(markerPath);
return `
import fs from "node:fs";
export async function activate(ctx) {
fs.writeFileSync(${literal}, "activated");
${extra}
}
export async function deactivate() {
fs.appendFileSync(${literal}, ":deactivated");
}
`;
}
describe("in-process first-party plugins", () => {
let fixture: Fixture | null = null;
let loader: PluginLoader | null = null;
beforeEach(() => {
clearRegistry();
pluginEvents.clear();
});
afterEach(async () => {
await loader?.shutdown();
loader = null;
fixture?.cleanup();
fixture = null;
clearRegistry();
pluginEvents.clear();
});
it("activates on the main thread with no worker and no owner", async () => {
fixture = createFixturePlugin(FIRST_PARTY_OPTIONS);
const marker = path.join(fixture.root, "marker.txt");
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
backendWritingMarker(marker),
);
const onWorkerReady = vi.fn();
loader = new PluginLoader({ onWorkerReady });
const plugin = await loader.load(fixture.dir);
// No ownerUserId: an in-process plugin never acts as a user.
await loader.activate(plugin.id);
expect(plugin.state).toBe("active");
expect(plugin.worker).toBeNull();
expect(plugin.inProcess).not.toBeNull();
expect(plugin.ownerUserId).toBeNull();
// The broker attaches via this hook; an in-process plugin bypasses it.
expect(onWorkerReady).not.toHaveBeenCalled();
expect(fs.readFileSync(marker, "utf8")).toBe("activated");
});
it("awaits deactivate() on disable and clears the handle", async () => {
fixture = createFixturePlugin(FIRST_PARTY_OPTIONS);
const marker = path.join(fixture.root, "marker.txt");
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
backendWritingMarker(marker),
);
loader = new PluginLoader();
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id);
await loader.deactivate(plugin.id);
expect(plugin.state).toBe("stopped");
expect(plugin.inProcess).toBeNull();
expect(fs.readFileSync(marker, "utf8")).toBe("activated:deactivated");
});
it("revokes registry entries the plugin provided", async () => {
fixture = createFixturePlugin(FIRST_PARTY_OPTIONS);
const marker = path.join(fixture.root, "marker.txt");
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
backendWritingMarker(
marker,
`ctx.registry.provide("terminal.sessions", { live: true });`,
),
);
loader = new PluginLoader();
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id);
expect(consume("terminal.sessions")).toEqual({ live: true });
await loader.deactivate(plugin.id);
// Disabling the plugin must take its services with it, or a consumer
// would keep resolving a dead provider.
expect(consume("terminal.sessions")).toBeUndefined();
});
it("detaches event listeners the plugin registered", async () => {
fixture = createFixturePlugin(FIRST_PARTY_OPTIONS);
const marker = path.join(fixture.root, "marker.txt");
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
backendWritingMarker(marker, `ctx.events.on("host.status", () => {});`),
);
loader = new PluginLoader();
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id);
expect(pluginEvents.listenerCount("host.status")).toBe(1);
await loader.deactivate(plugin.id);
expect(pluginEvents.listenerCount("host.status")).toBe(0);
});
it("reports an activation failure instead of leaving a half-live plugin", async () => {
fixture = createFixturePlugin(FIRST_PARTY_OPTIONS);
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
`export async function activate() { throw new Error("boom"); }`,
);
loader = new PluginLoader();
const plugin = await loader.load(fixture.dir);
await expect(loader.activate(plugin.id)).rejects.toThrow("boom");
expect(plugin.state).toBe("crashed");
expect(plugin.inProcess).toBeNull();
expect(plugin.lastError).toContain("boom");
});
it("rejects an entry with no activate export", async () => {
fixture = createFixturePlugin(FIRST_PARTY_OPTIONS);
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
`export const nothing = true;`,
);
loader = new PluginLoader();
const plugin = await loader.load(fixture.dir);
await expect(loader.activate(plugin.id)).rejects.toThrow(
/does not export an activate/,
);
});
it("runs a first-party plugin in a worker when it does not ask for the tier", async () => {
fixture = createFixturePlugin({
id: "ssh-terminal",
permissions: ["hosts.read"],
manifestOverrides: { category: "Terminal" },
});
loader = new PluginLoader();
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id, "user-1");
expect(plugin.state).toBe("active");
expect(plugin.worker).not.toBeNull();
expect(plugin.inProcess).toBeNull();
});
});
+469 -320
View File
@@ -1,350 +1,499 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginLoader, MAX_RESTART_ATTEMPTS } from "../../plugins/loader.js";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
/**
* The single-tier loader: discovery, dependency order and lifecycle.
*
* These drive the real PluginLoader against real plugin directories written
* to a temp dir, because the things worth testing here are exactly the ones a
* mock would paper over: does a manifest on disk parse, does an id collision
* get refused, does a plugin's own deactivate actually run.
*/
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { PluginLoader } from "../../plugins/loader.js";
import { createFixturePlugin } from "./fixture-plugin.js";
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginStorageRepository: () => ({
get: async () => null,
set: async () => {},
delete: async () => false,
listKeys: async () => [],
}),
}));
// Worker spawn plus crash backoff does not fit the 5s backend default.
const WORKER_TIMEOUT = 30_000;
const cleanups: Array<() => void> = [];
describe("PluginLoader", () => {
const fixtures: Fixture[] = [];
let loader: PluginLoader | null = null;
function tempRoot(prefix: string): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
cleanups.push(() => fs.rmSync(root, { recursive: true, force: true }));
return root;
}
function fixture(...args: Parameters<typeof createFixturePlugin>) {
const created = createFixturePlugin(...args);
fixtures.push(created);
return created;
}
/** Points the loader at `bundled` and `user` as its two discovery roots. */
function setRoots(bundled: string, user: string): void {
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
process.env.DATA_DIR = user;
}
afterEach(async () => {
await loader?.shutdown();
loader = null;
while (fixtures.length) fixtures.pop()!.cleanup();
beforeEach(() => {
vi.unstubAllEnvs();
});
afterEach(() => {
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
delete process.env.DATA_DIR;
while (cleanups.length) cleanups.pop()?.();
});
describe("PluginLoader discovery", () => {
it("loads a valid plugin directory", async () => {
const fixture = createFixturePlugin();
cleanups.push(fixture.cleanup);
const loader = new PluginLoader();
const plugin = await loader.load(fixture.dir, "bundled");
expect(plugin.id).toBe("sample-plugin");
expect(plugin.state).toBe("loaded");
expect(plugin.manifest.capabilities).toEqual(["hosts:read", "kv:own"]);
});
describe("load", () => {
it("loads a valid plugin directory", async () => {
const plugin = fixture();
loader = new PluginLoader();
const loaded = await loader.load(plugin.dir);
expect(loaded.id).toBe("sample-plugin");
expect(loaded.state).toBe("loaded");
expect(loaded.worker).toBeNull();
expect(loader.get("sample-plugin")).toBe(loaded);
it("rejects a directory whose name does not match the manifest id", async () => {
const fixture = createFixturePlugin({
manifestOverrides: { id: "something-else" },
});
cleanups.push(fixture.cleanup);
it("rejects a manifest that fails validation", async () => {
const plugin = fixture({ manifestOverrides: { version: "nope" } });
loader = new PluginLoader();
await expect(loader.load(plugin.dir)).rejects.toThrow(
/Invalid plugin manifest/,
);
});
it("rejects a directory whose name does not match the manifest id", async () => {
const plugin = fixture({ manifestOverrides: { id: "other-id" } });
loader = new PluginLoader();
await expect(loader.load(plugin.dir)).rejects.toThrow(
/does not match manifest id/,
);
});
it("rejects a declared backend with no entry file", async () => {
const plugin = fixture({ omitBackendEntry: true });
loader = new PluginLoader();
await expect(loader.load(plugin.dir)).rejects.toThrow(
/declares a backend but/,
);
});
it("rejects an unreadable manifest", async () => {
loader = new PluginLoader();
await expect(loader.load("/nonexistent/plugin-dir")).rejects.toThrow(
/Could not read plugin manifest/,
);
});
const loader = new PluginLoader();
await expect(loader.load(fixture.dir, "bundled")).rejects.toThrow(
/does not match manifest id/,
);
});
describe("activate and deactivate", () => {
it(
"activates a plugin and runs its activate(ctx)",
async () => {
const plugin = fixture();
loader = new PluginLoader();
await loader.load(plugin.dir);
it("rejects a manifest whose backend entry is missing", async () => {
const fixture = createFixturePlugin({ omitBackendEntry: true });
cleanups.push(fixture.cleanup);
await loader.activate("sample-plugin", "user-1");
const loaded = loader.get("sample-plugin")!;
expect(loaded.state).toBe("active");
expect(loaded.worker).not.toBeNull();
expect(loaded.ownerUserId).toBe("user-1");
},
WORKER_TIMEOUT,
const loader = new PluginLoader();
await expect(loader.load(fixture.dir, "bundled")).rejects.toThrow(
/backend entry .* is missing/,
);
it(
"surfaces an error thrown inside activate(ctx)",
async () => {
const plugin = fixture({
backendSource: `
export async function activate() {
throw new Error("boom from plugin");
}
`,
});
loader = new PluginLoader();
await loader.load(plugin.dir);
await expect(
loader.activate("sample-plugin", "user-1"),
).rejects.toThrow(/boom from plugin/);
},
WORKER_TIMEOUT,
);
it(
"rejects a backend entry with no activate export",
async () => {
const plugin = fixture({
backendSource: `export const nothing = true;`,
});
loader = new PluginLoader();
await loader.load(plugin.dir);
await expect(
loader.activate("sample-plugin", "user-1"),
).rejects.toThrow(/does not export an activate\(ctx\) function/);
},
WORKER_TIMEOUT,
);
it(
"deactivate terminates the worker and does not count as a crash",
async () => {
const plugin = fixture();
loader = new PluginLoader();
await loader.load(plugin.dir);
await loader.activate("sample-plugin", "user-1");
await loader.deactivate("sample-plugin");
const loaded = loader.get("sample-plugin")!;
expect(loaded.state).toBe("stopped");
expect(loaded.worker).toBeNull();
expect(loaded.restartAttempts).toBe(0);
},
WORKER_TIMEOUT,
);
it(
"does not leak the server's environment into the worker",
async () => {
process.env.TERMIX_TEST_SECRET = "super-secret-value";
const plugin = fixture({
backendSource: `
export async function activate() {
if (process.env.TERMIX_TEST_SECRET) {
throw new Error("leaked:" + process.env.TERMIX_TEST_SECRET);
}
}
`,
});
loader = new PluginLoader();
await loader.load(plugin.dir);
await loader.activate("sample-plugin", "user-1");
expect(loader.get("sample-plugin")!.state).toBe("active");
delete process.env.TERMIX_TEST_SECRET;
},
WORKER_TIMEOUT,
);
it("deactivating a never-started plugin is a no-op", async () => {
const plugin = fixture();
loader = new PluginLoader();
await loader.load(plugin.dir);
await loader.deactivate("sample-plugin");
expect(loader.get("sample-plugin")!.state).toBe("stopped");
});
it("activating an unknown plugin throws", async () => {
loader = new PluginLoader();
await expect(loader.activate("ghost", "user-1")).rejects.toThrow(
/Plugin ghost is not loaded/,
);
});
});
describe("crash restart", () => {
it(
"restarts a crashed plugin, then disables it after the retry budget",
async () => {
// Exits on a timer so activation succeeds first, then the worker dies.
const plugin = fixture({
backendSource: `
export async function activate() {
setTimeout(() => process.exit(1), 10);
}
`,
});
it("skips an invalid directory and loads the rest", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
loader = new PluginLoader({ restartBackoffMs: [10, 10, 10] });
await loader.load(plugin.dir);
await loader.activate("sample-plugin", "user-1");
createFixturePlugin({ id: "good-plugin", root: bundled });
const broken = path.join(bundled, "broken-plugin");
fs.mkdirSync(broken, { recursive: true });
fs.writeFileSync(path.join(broken, "manifest.json"), "{ not json");
const loaded = loader.get("sample-plugin")!;
await vi.waitFor(
() => {
expect(loaded.state).toBe("disabled");
},
{ timeout: 25_000, interval: 50 },
);
setRoots(bundled, user);
expect(loaded.restartAttempts).toBeGreaterThan(MAX_RESTART_ATTEMPTS);
expect(loaded.worker).toBeNull();
},
WORKER_TIMEOUT,
);
const loader = new PluginLoader();
const loaded = await loader.loadAll();
it(
"retry clears the crash counter and starts the plugin again",
async () => {
const plugin = fixture();
loader = new PluginLoader();
await loader.load(plugin.dir);
await loader.activate("sample-plugin", "user-1");
const loaded = loader.get("sample-plugin")!;
// Simulate having been auto-disabled.
await loader.deactivate("sample-plugin");
loaded.state = "disabled";
loaded.restartAttempts = MAX_RESTART_ATTEMPTS + 1;
await loader.retry("sample-plugin");
expect(loaded.state).toBe("active");
// retry() clears the counter up front, before the stability window.
expect(loaded.restartAttempts).toBe(0);
},
WORKER_TIMEOUT,
);
it(
"clears the crash counter once a plugin stays up past the stability window",
async () => {
const plugin = fixture();
loader = new PluginLoader({ stabilityWindowMs: 50 });
await loader.load(plugin.dir);
const loaded = loader.get("sample-plugin")!;
loaded.restartAttempts = 2;
await loader.activate("sample-plugin", "user-1");
await vi.waitFor(
() => {
expect(loaded.restartAttempts).toBe(0);
},
{ timeout: 5_000, interval: 25 },
);
},
WORKER_TIMEOUT,
);
it("retry on a plugin that never activated throws", async () => {
const plugin = fixture();
loader = new PluginLoader();
await loader.load(plugin.dir);
await expect(loader.retry("sample-plugin")).rejects.toThrow(
/never been activated/,
);
});
expect(loaded.map((plugin) => plugin.id)).toEqual(["good-plugin"]);
});
describe("loadAll", () => {
// loadAll scans bundled plugins as well as user-installed ones, and the
// repo really does ship one now. These tests are about the user directory,
// so they point the bundled dir somewhere empty.
let previousBundled: string | undefined;
// A plugin dropped into the data directory must never be able to take over
// an id the install already ships, or "ssh-terminal" becomes whatever the
// last person to write that folder wanted it to be.
it("refuses a user plugin that shadows a bundled id", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
const userPlugins = path.join(user, "plugins");
fs.mkdirSync(userPlugins, { recursive: true });
beforeEach(() => {
previousBundled = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
process.env.TERMIX_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled-plugins";
createFixturePlugin({ id: "ssh-terminal", root: bundled });
createFixturePlugin({
id: "ssh-terminal",
root: userPlugins,
manifestOverrides: { name: "Impostor" },
});
afterEach(() => {
if (previousBundled === undefined) {
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
} else {
process.env.TERMIX_BUNDLED_PLUGINS_DIR = previousBundled;
}
});
setRoots(bundled, user);
it("returns an empty list when the plugins dir does not exist", async () => {
const previous = process.env.DATA_DIR;
process.env.DATA_DIR = "/nonexistent/data-dir";
loader = new PluginLoader();
const loader = new PluginLoader();
const loaded = await loader.loadAll();
expect(await loader.loadAll()).toEqual([]);
if (previous === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = previous;
});
it("skips invalid directories and loads the rest", async () => {
const good = fixture({ id: "good-plugin" });
// Share the same root so both sit under one plugins dir.
const bad = createFixturePlugin({
id: "bad-plugin",
manifestOverrides: { version: "nope" },
});
fixtures.push(bad);
const fs = await import("node:fs");
const path = await import("node:path");
fs.cpSync(bad.dir, path.join(good.root, "bad-plugin"), {
recursive: true,
});
const previous = process.env.DATA_DIR;
// getPluginsDir() appends "plugins", so point DATA_DIR at the parent.
const dataDir = path.join(good.root, "..");
const pluginsDir = path.join(dataDir, "plugins");
fs.mkdirSync(pluginsDir, { recursive: true });
fs.cpSync(good.dir, path.join(pluginsDir, "good-plugin"), {
recursive: true,
});
fs.cpSync(bad.dir, path.join(pluginsDir, "bad-plugin"), {
recursive: true,
});
process.env.DATA_DIR = dataDir;
loader = new PluginLoader();
const loaded = await loader.loadAll();
expect(loaded.map((p) => p.id)).toEqual(["good-plugin"]);
fs.rmSync(pluginsDir, { recursive: true, force: true });
if (previous === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = previous;
expect(loaded).toHaveLength(1);
expect(loaded[0].source).toBe("bundled");
expect(loaded[0].manifest.name).toBe("Sample Plugin");
});
});
describe("PluginLoader dependency resolution", () => {
async function loadAll(
specs: Array<{ id: string; manifestOverrides?: Record<string, unknown> }>,
): Promise<PluginLoader> {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
for (const spec of specs) {
createFixturePlugin({ ...spec, root: bundled });
}
setRoots(bundled, user);
const loader = new PluginLoader();
await loader.loadAll();
return loader;
}
it("orders a dependency before its dependent", async () => {
const loader = await loadAll([
{ id: "alpha", manifestOverrides: { dependencies: { beta: "^1.0.0" } } },
{ id: "beta" },
]);
const { order, blocked, cycles } = loader.resolveOrder(["alpha", "beta"]);
expect(cycles.size).toBe(0);
expect(blocked.size).toBe(0);
expect(order.indexOf("beta")).toBeLessThan(order.indexOf("alpha"));
});
it("blocks a plugin whose hard dependency is not installed", async () => {
const loader = await loadAll([
{
id: "alpha",
manifestOverrides: { dependencies: { missing: "^1.0.0" } },
},
]);
const { order, blocked } = loader.resolveOrder(["alpha"]);
expect(order).toEqual([]);
expect(blocked.get("alpha")).toMatch(/not installed/);
});
it("blocks a plugin whose dependency is present at the wrong version", async () => {
const loader = await loadAll([
{ id: "alpha", manifestOverrides: { dependencies: { beta: "^2.0.0" } } },
{ id: "beta" },
]);
const { blocked } = loader.resolveOrder(["alpha", "beta"]);
expect(blocked.get("alpha")).toMatch(/1\.0\.0 is installed/);
});
it("blocks a plugin whose dependency is installed but disabled", async () => {
const loader = await loadAll([
{ id: "alpha", manifestOverrides: { dependencies: { beta: "^1.0.0" } } },
{ id: "beta" },
]);
const { blocked } = loader.resolveOrder(["alpha"]);
expect(blocked.get("alpha")).toMatch(/not enabled/);
});
// Nothing to wait for, so this is a failure rather than a block.
it("fails every plugin in a dependency cycle", async () => {
const loader = await loadAll([
{ id: "alpha", manifestOverrides: { dependencies: { beta: "^1.0.0" } } },
{ id: "beta", manifestOverrides: { dependencies: { alpha: "^1.0.0" } } },
]);
const { order, cycles } = loader.resolveOrder(["alpha", "beta"]);
expect(order).toEqual([]);
expect(cycles.get("alpha")).toMatch(/cycle/);
expect(cycles.get("beta")).toMatch(/cycle/);
});
it("activates in dependency order and reports blocked plugins", async () => {
const marker = path.join(tempRoot("termix-order-"), "order.txt");
const record = (id: string) => `
import fs from "node:fs";
export async function activate() {
fs.appendFileSync(${JSON.stringify(marker)}, "${id}\\n");
}
`;
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
id: "alpha",
root: bundled,
backendSource: record("alpha"),
manifestOverrides: { dependencies: { beta: "^1.0.0" } },
});
createFixturePlugin({
id: "beta",
root: bundled,
backendSource: record("beta"),
});
createFixturePlugin({
id: "gamma",
root: bundled,
manifestOverrides: { dependencies: { nowhere: "^1.0.0" } },
});
setRoots(bundled, user);
const loader = new PluginLoader();
await loader.loadAll();
const result = await loader.activateAll(["alpha", "beta", "gamma"]);
expect(result.activated).toEqual(["beta", "alpha"]);
expect(fs.readFileSync(marker, "utf8").trim().split("\n")).toEqual([
"beta",
"alpha",
]);
expect(loader.get("gamma")?.state).toBe("blocked");
expect(loader.get("gamma")?.lastError).toMatch(/not installed/);
});
});
describe("PluginLoader lifecycle", () => {
async function single(
backendSource: string,
id = "sample-plugin",
): Promise<PluginLoader> {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({ id, root: bundled, backendSource });
setRoots(bundled, user);
const loader = new PluginLoader();
await loader.loadAll();
return loader;
}
it("runs activate and then deactivate", async () => {
const marker = path.join(tempRoot("termix-life-"), "life.txt");
const loader = await single(`
import fs from "node:fs";
export async function activate() {
fs.appendFileSync(${JSON.stringify(marker)}, "up\\n");
}
export async function deactivate() {
fs.appendFileSync(${JSON.stringify(marker)}, "down\\n");
}
`);
await loader.activate("sample-plugin");
expect(loader.get("sample-plugin")?.state).toBe("active");
await loader.deactivate("sample-plugin");
expect(loader.get("sample-plugin")?.state).toBe("stopped");
expect(fs.readFileSync(marker, "utf8")).toBe("up\ndown\n");
});
it("marks a plugin failed when activate throws", async () => {
const loader = await single(`
export async function activate() {
throw new Error("no good");
}
`);
await expect(loader.activate("sample-plugin")).rejects.toThrow("no good");
expect(loader.get("sample-plugin")?.state).toBe("failed");
expect(loader.get("sample-plugin")?.lastError).toBe("no good");
});
it("rejects a backend entry with no activate export", async () => {
const loader = await single(`export const nothing = true;`);
await expect(loader.activate("sample-plugin")).rejects.toThrow(
/does not export an activate/,
);
});
// The whole point of the bag: cleanup cannot be conditional on the plugin
// behaving well on the way out.
it("disposes everything even when deactivate throws", async () => {
const marker = path.join(tempRoot("termix-dispose-"), "disposed.txt");
const loader = await single(`
import fs from "node:fs";
export async function activate(ctx) {
ctx.disposables.add(() => {
fs.appendFileSync(${JSON.stringify(marker)}, "disposed\\n");
});
}
export async function deactivate() {
throw new Error("deactivate blew up");
}
`);
await loader.activate("sample-plugin");
await loader.deactivate("sample-plugin");
expect(loader.get("sample-plugin")?.state).toBe("stopped");
expect(fs.readFileSync(marker, "utf8")).toBe("disposed\n");
});
// ESM caches the module, so the second activate runs against the same
// module object the first one did. Anything created at module scope would
// already be torn down by then.
it("supports enable, disable, enable without a restart", async () => {
const marker = path.join(tempRoot("termix-cycle-"), "cycle.txt");
const loader = await single(`
import fs from "node:fs";
let timer = null;
let server = null;
export async function activate(ctx) {
server = { closed: false };
timer = setInterval(() => {}, 1000);
ctx.disposables.add(() => {
clearInterval(timer);
server.closed = true;
fs.appendFileSync(${JSON.stringify(marker)}, "closed\\n");
});
fs.appendFileSync(${JSON.stringify(marker)}, "started\\n");
}
`);
await loader.activate("sample-plugin");
await loader.deactivate("sample-plugin");
await loader.activate("sample-plugin");
expect(loader.get("sample-plugin")?.state).toBe("active");
expect(fs.readFileSync(marker, "utf8")).toBe("started\nclosed\nstarted\n");
await loader.deactivate("sample-plugin");
});
it("disposes registrations made before activate threw", async () => {
const marker = path.join(tempRoot("termix-partial-"), "partial.txt");
const loader = await single(`
import fs from "node:fs";
export async function activate(ctx) {
ctx.disposables.add(() => {
fs.appendFileSync(${JSON.stringify(marker)}, "cleaned\\n");
});
throw new Error("halfway");
}
`);
await expect(loader.activate("sample-plugin")).rejects.toThrow("halfway");
expect(fs.readFileSync(marker, "utf8")).toBe("cleaned\n");
});
it("deactivating a plugin that never started is a no-op", async () => {
const loader = await single(`export async function activate() {}`);
await loader.deactivate("sample-plugin");
expect(loader.get("sample-plugin")?.state).toBe("stopped");
});
it("shuts plugins down in reverse activation order", async () => {
const marker = path.join(tempRoot("termix-shutdown-"), "shutdown.txt");
const record = (id: string) => `
import fs from "node:fs";
export async function activate() {}
export async function deactivate() {
fs.appendFileSync(${JSON.stringify(marker)}, "${id}\\n");
}
`;
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
id: "alpha",
root: bundled,
backendSource: record("alpha"),
manifestOverrides: { dependencies: { beta: "^1.0.0" } },
});
createFixturePlugin({
id: "beta",
root: bundled,
backendSource: record("beta"),
});
setRoots(bundled, user);
const loader = new PluginLoader();
await loader.loadAll();
await loader.activateAll(["alpha", "beta"]);
await loader.shutdown();
// beta started first, so it stops last: a dependency outlives its
// dependents.
expect(fs.readFileSync(marker, "utf8").trim().split("\n")).toEqual([
"alpha",
"beta",
]);
});
});
describe("PluginLoader error budget", () => {
async function failing(threshold: number): Promise<PluginLoader> {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
root: bundled,
backendSource: `export async function activate() {}`,
});
setRoots(bundled, user);
const loader = new PluginLoader({
errorThreshold: threshold,
errorWindowMs: 60_000,
});
await loader.loadAll();
await loader.activate("sample-plugin");
return loader;
}
it("marks a plugin failed once it trips the threshold", async () => {
const loader = await failing(3);
await loader.reportError("sample-plugin", new Error("one"));
await loader.reportError("sample-plugin", new Error("two"));
expect(loader.get("sample-plugin")?.state).toBe("active");
await loader.reportError("sample-plugin", new Error("three"));
expect(loader.get("sample-plugin")?.state).toBe("failed");
expect(loader.get("sample-plugin")?.lastError).toBe("three");
});
it("forgets errors that fall outside the window", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
root: bundled,
backendSource: `export async function activate() {}`,
});
setRoots(bundled, user);
const loader = new PluginLoader({ errorThreshold: 2, errorWindowMs: 10 });
await loader.loadAll();
await loader.activate("sample-plugin");
await loader.reportError("sample-plugin", new Error("one"));
await new Promise((resolve) => setTimeout(resolve, 25));
await loader.reportError("sample-plugin", new Error("two"));
expect(loader.get("sample-plugin")?.state).toBe("active");
});
it("wrap() counts a throwing callback instead of letting it escape", async () => {
const loader = await failing(2);
const wrapped = loader.wrap("sample-plugin", () => {
throw new Error("callback failed");
});
await expect(wrapped()).resolves.toBeUndefined();
await wrapped();
expect(loader.get("sample-plugin")?.state).toBe("failed");
});
it("retry clears the budget and starts the plugin again", async () => {
const loader = await failing(1);
await loader.reportError("sample-plugin", new Error("down"));
expect(loader.get("sample-plugin")?.state).toBe("failed");
await loader.retry("sample-plugin");
expect(loader.get("sample-plugin")?.state).toBe("active");
expect(loader.get("sample-plugin")?.lastError).toBeNull();
});
});
+394 -438
View File
@@ -1,3 +1,11 @@
/**
* Manifest v2 validation.
*
* The rules live in the SDK so the server, the authoring script and plugin
* authors share one implementation; these tests drive it through the server's
* re-export, which is the path the loader actually takes.
*/
import { describe, expect, it } from "vitest";
import {
parseManifest,
@@ -5,484 +13,432 @@ import {
SUPPORTED_PLUGIN_API_VERSION,
} from "../../plugins/manifest.js";
function validManifest(overrides: Record<string, unknown> = {}) {
function base(overrides: Record<string, unknown> = {}) {
return {
id: "sample-plugin",
name: "Sample Plugin",
version: "1.0.0",
description: "A sample plugin.",
author: { name: "Jane Doe" },
description: "A fixture.",
author: { name: "Termix Tests" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION },
capabilities: {
backend: true,
frontend: false,
electron: false,
platforms: ["linux"],
},
permissions: ["hosts.read", "storage.own"],
sidecars: [],
capabilities: ["kv:own"],
...overrides,
};
}
describe("plugin manifest validation", () => {
it("accepts a valid manifest", () => {
expect(validateManifest(validManifest())).toEqual([]);
describe("manifest v2 validation", () => {
it("accepts a minimal valid manifest", () => {
expect(validateManifest(base())).toEqual([]);
});
it("reports every missing required field", () => {
const errors = validateManifest({ id: "x" });
expect(errors).toContain('Missing required field: "description"');
expect(errors).toContain('Missing required field: "engine"');
expect(errors).toContain('Missing required field: "sidecars"');
it("reports every missing required field at once", () => {
const errors = validateManifest({ id: "sample-plugin" });
expect(errors.some((e) => e.includes('"name"'))).toBe(true);
expect(errors.some((e) => e.includes('"version"'))).toBe(true);
expect(errors.some((e) => e.includes('"capabilities"'))).toBe(true);
});
it("rejects a bad id pattern", () => {
const errors = validateManifest(validManifest({ id: "Bad_Id" }));
expect(errors.some((e) => e.startsWith('Field "id" must match'))).toBe(
true,
it("rejects an id that is not a lowercase slug", () => {
expect(validateManifest(base({ id: "Sample_Plugin" })).join()).toMatch(
/"id" must match/,
);
});
it("rejects an id that starts with a digit", () => {
expect(validateManifest(base({ id: "1plugin" })).join()).toMatch(
/"id" must match/,
);
});
it("rejects a non-semver version", () => {
const errors = validateManifest(validManifest({ version: "1.0" }));
expect(errors).toContain(
'Field "version" must be valid semver, got: "1.0"',
expect(validateManifest(base({ version: "1.0" })).join()).toMatch(
/valid semver/,
);
});
describe("provides / requires", () => {
const permissionGroup = {
group: "testplugin",
permissions: ["testplugin.greet.use"],
};
it("accepts a declared service and requirement", () => {
const errors = validateManifest(
validManifest({
provides: [
{
service: "testplugin.greet",
version: "1.0.0",
permission: "testplugin.greet.use",
},
],
requires: [
{ service: "other.thing", versionRange: "^1.0.0", optional: true },
],
contributes: { permissionGroup },
}),
);
expect(errors).toEqual([]);
});
it("rejects a service name that is not dotted", () => {
const errors = validateManifest(
validManifest({
provides: [{ service: "greet", version: "1.0.0", permission: "x.y" }],
}),
);
expect(errors.some((e) => e.includes("provides[0].service"))).toBe(true);
});
it("rejects a non-semver service version", () => {
const errors = validateManifest(
validManifest({
provides: [{ service: "a.b", version: "1.0", permission: "x.y" }],
}),
);
expect(errors.some((e) => e.includes("provides[0].version"))).toBe(true);
});
it("rejects a duplicate provided service", () => {
const errors = validateManifest(
validManifest({
provides: [
{ service: "a.b", version: "1.0.0", permission: "x.y" },
{ service: "a.b", version: "2.0.0", permission: "x.y" },
],
}),
);
expect(errors.some((e) => e.includes("duplicate"))).toBe(true);
});
it("rejects an invalid semver range", () => {
const errors = validateManifest(
validManifest({
requires: [{ service: "a.b", versionRange: "not a range" }],
}),
);
expect(errors.some((e) => e.includes("versionRange"))).toBe(true);
});
it("rejects a non-boolean optional", () => {
const errors = validateManifest(
validManifest({
requires: [
{ service: "a.b", versionRange: "^1.0.0", optional: "yes" },
],
}),
);
expect(errors.some((e) => e.includes("optional"))).toBe(true);
});
it("refuses a service gated by a permission the group never declares", () => {
// Otherwise every call through it would deny against a permission no
// admin could ever grant.
const { manifest, errors } = parseManifest(
validManifest({
provides: [
{
service: "testplugin.greet",
version: "1.0.0",
permission: "testplugin.undeclared",
},
],
contributes: { permissionGroup },
}),
);
expect(manifest).toBeUndefined();
expect(errors[0]).toContain("testplugin.undeclared");
expect(errors[0]).toContain("permissionGroup");
});
});
describe("providesSecret / requiresSecret", () => {
const permissionGroup = {
group: "testplugin",
permissions: ["testplugin.secrets.share"],
};
it("accepts a well-formed pair", () => {
expect(
validateManifest(
validManifest({
providesSecret: [
{ key: "api-key", permission: "testplugin.secrets.share" },
],
requiresSecret: [
{ plugin: "ai-assistant", key: "api-key", optional: true },
],
contributes: { permissionGroup },
}),
),
).toEqual([]);
});
it("rejects a dotted secret key", () => {
// The owning plugin is already its own field, so a dotted key would only
// encode the owner twice and invite the two disagreeing.
const errors = validateManifest(
validManifest({
providesSecret: [
{ key: "ai.api-key", permission: "testplugin.secrets.share" },
],
}),
);
expect(errors.some((e) => e.includes("providesSecret[0].key"))).toBe(
true,
);
});
it("rejects a duplicate offer of the same key", () => {
const errors = validateManifest(
validManifest({
providesSecret: [
{ key: "api-key", permission: "testplugin.secrets.share" },
{ key: "api-key", permission: "testplugin.secrets.share" },
],
}),
);
expect(errors.some((e) => e.includes("duplicate"))).toBe(true);
});
it("requires a permission on an offer", () => {
const errors = validateManifest(
validManifest({ providesSecret: [{ key: "api-key" }] }),
);
expect(
errors.some((e) => e.includes("providesSecret[0].permission")),
).toBe(true);
});
it("rejects a reference with a bad plugin id", () => {
const errors = validateManifest(
validManifest({
requiresSecret: [{ plugin: "Bad_Id", key: "api-key" }],
}),
);
expect(errors.some((e) => e.includes("requiresSecret[0].plugin"))).toBe(
true,
);
});
it("rejects a duplicate reference to the same plugin and key", () => {
const errors = validateManifest(
validManifest({
requiresSecret: [
{ plugin: "ai-assistant", key: "api-key" },
{ plugin: "ai-assistant", key: "api-key" },
],
}),
);
expect(errors.some((e) => e.includes("duplicate"))).toBe(true);
});
it("refuses a shared secret gated by a permission the group never declares", () => {
// Same rule as a service: sharing rides on an ordinary role permission,
// so it has to be one an admin can actually grant.
const { manifest, errors } = parseManifest(
validManifest({
providesSecret: [
{ key: "api-key", permission: "testplugin.undeclared" },
],
contributes: { permissionGroup },
}),
);
expect(manifest).toBeUndefined();
expect(errors[0]).toContain("testplugin.undeclared");
expect(errors[0]).toContain("permissionGroup");
});
it("refuses a plugin borrowing from itself", () => {
const { manifest, errors } = parseManifest(
validManifest({
requiresSecret: [{ plugin: "sample-plugin", key: "api-key" }],
}),
);
expect(manifest).toBeUndefined();
expect(errors[0]).toContain("names this plugin itself");
});
});
it("rejects an unknown permission", () => {
const errors = validateManifest(
validManifest({ permissions: ["credentials.read"] }),
);
expect(
errors.some((e) => e.includes('Unknown permission: "credentials.read"')),
).toBe(true);
});
it("rejects an unknown category", () => {
const errors = validateManifest(validManifest({ category: "Games" }));
expect(validateManifest(base({ category: "Nonsense" })).join()).toMatch(
/must be one of/,
);
});
// Capabilities were dotted in v1. They are colon-separated now so they can
// never be confused with the dotted RBAC permissions.
it("rejects a dotted capability name", () => {
expect(
errors.some((e) => e.startsWith('Field "category" must be one of')),
).toBe(true);
validateManifest(base({ capabilities: ["hosts.read"] })).join(),
).toMatch(/not a known capability/);
});
it("rejects a non-object manifest", () => {
expect(validateManifest(null)).toEqual(["Manifest must be a JSON object"]);
expect(validateManifest([])).toEqual(["Manifest must be a JSON object"]);
it("rejects a capability outside the catalog", () => {
expect(
validateManifest(base({ capabilities: ["hosts:obliterate"] })).join(),
).toMatch(/not a known capability/);
});
it("validates contributes sub-shapes", () => {
it("rejects a duplicated capability", () => {
expect(
validateManifest(base({ capabilities: ["kv:own", "kv:own"] })).join(),
).toMatch(/duplicates/);
});
it("refuses a manifest targeting another SDK api version", () => {
const { manifest, errors } = parseManifest(
base({ engine: { termix: ">=2.9.0", api: "2" } }),
);
expect(manifest).toBeUndefined();
expect(errors.join()).toMatch(/SDK API version/);
});
it("fills in the entry point defaults", () => {
const { manifest } = parseManifest(base());
expect(manifest?.backend).toBe("dist/backend.js");
expect(manifest?.frontend).toBe("dist/frontend.js");
expect(manifest?.locales).toBe("locales");
});
});
// v1 accepted unknown fields silently, so a typo'd "contribute" validated
// clean and was then dropped. A manifest has to mean what it says.
describe("unknown field rejection", () => {
it("rejects an unknown top-level field", () => {
expect(validateManifest(base({ sudoEverything: true })).join()).toMatch(
/Unknown field "sudoEverything"/,
);
});
it("rejects a near-miss of a real field name", () => {
expect(validateManifest(base({ contribute: {} })).join()).toMatch(
/Unknown field "contribute"/,
);
});
it("rejects an unknown field inside contributes", () => {
const errors = validateManifest(
validManifest({
base({ contributes: { dashboardCards: [] } }),
);
expect(errors.join()).toMatch(/Unknown field "dashboardCards"/);
});
it("rejects an unknown field inside a tab", () => {
const errors = validateManifest(
base({
contributes: {
tabs: [{ id: "t", titleKey: "k", icon: "Box", openFrom: ["nope"] }],
apiPrefix: "Bad Prefix",
tabs: [
{
id: "t",
titleKey: "k",
icon: "i",
openFrom: ["rail"],
surprise: 1,
},
],
},
}),
);
expect(
errors.some((e) => e.includes('openFrom has unknown value: "nope"')),
).toBe(true);
expect(
errors.some((e) => e.includes('"contributes.apiPrefix" must match')),
).toBe(true);
expect(errors.join()).toMatch(/Unknown field "surprise"/);
});
describe("actions / actionSlots", () => {
function withActions(contributes: Record<string, unknown>) {
return validateManifest(validManifest({ contributes }));
}
it("rejects an unknown field inside author and engine", () => {
expect(
validateManifest(base({ author: { name: "x", role: "admin" } })).join(),
).toMatch(/Unknown field "role"/);
it("accepts a valid actions and actionSlots pair", () => {
const errors = withActions({
actions: [
{
id: "ai.openWithContext",
titleKey: "ai.assistant",
handler: "openWithContext",
icon: "Bot",
slot: "terminal.toolbar",
kind: "button",
},
],
actionSlots: [{ id: "terminal.toolbar", accepts: ["button"] }],
});
expect(errors).toEqual([]);
});
it("allows a camelCase segment in an action id", () => {
const errors = withActions({
actions: [
{
id: "ai.openWithContext",
titleKey: "k",
handler: "openWithContext",
},
],
});
expect(errors).toEqual([]);
});
it("rejects an action id that is not dotted", () => {
const errors = withActions({
actions: [{ id: "notdotted", titleKey: "k", handler: "h" }],
});
expect(errors.some((e) => e.includes("actions[0].id must match"))).toBe(
true,
);
});
it("rejects a duplicate action id", () => {
const errors = withActions({
actions: [
{ id: "a.one", titleKey: "k", handler: "h" },
{ id: "a.one", titleKey: "k", handler: "h" },
],
});
expect(
errors.some((e) => e.includes('actions[1].id is a duplicate: "a.one"')),
).toBe(true);
});
it("requires titleKey and a valid handler", () => {
const errors = withActions({
actions: [{ id: "a.one", handler: "not a identifier" }],
});
expect(errors.some((e) => e.includes("actions[0].titleKey"))).toBe(true);
expect(errors.some((e) => e.includes("actions[0].handler"))).toBe(true);
});
it("rejects an unknown contribution kind", () => {
const errors = withActions({
actions: [
{ id: "a.one", titleKey: "k", handler: "h", kind: "dropdown" },
],
});
expect(
errors.some((e) => e.includes("actions[0].kind must be one of")),
).toBe(true);
});
it("rejects an unknown value in a slot's accepts", () => {
const errors = withActions({
actionSlots: [{ id: "terminal.toolbar", accepts: ["menu"] }],
});
expect(
errors.some((e) =>
e.includes('actionSlots[0].accepts has unknown value: "menu"'),
),
).toBe(true);
});
it("rejects an empty accepts array", () => {
const errors = withActions({
actionSlots: [{ id: "terminal.toolbar", accepts: [] }],
});
expect(
errors.some((e) =>
e.includes("actionSlots[0].accepts must be a non-empty array"),
),
).toBe(true);
});
it("rejects a duplicate slot id", () => {
const errors = withActions({
actionSlots: [
{ id: "terminal.toolbar", accepts: ["button"] },
{ id: "terminal.toolbar", accepts: ["button"] },
],
});
expect(
errors.some((e) => e.includes("actionSlots[1].id is a duplicate")),
).toBe(true);
});
expect(
validateManifest(
base({ engine: { termix: ">=2.9.0", api: "1", unsafe: true } }),
).join(),
).toMatch(/Unknown field "unsafe"/);
});
describe("parseManifest", () => {
it("refuses an action gated by an undeclared permission", () => {
const { manifest, errors } = parseManifest(
validManifest({
contributes: {
permissionGroup: { group: "ai", permissions: ["ai.use"] },
actions: [
{
id: "ai.openWithContext",
titleKey: "k",
handler: "h",
permission: "ai.services.use",
},
],
it("rejects an unknown field inside a provides entry", () => {
const errors = validateManifest(
base({
provides: [
{
service: "sample.thing",
version: "1.0.0",
permission: "sample-plugin.use",
elevated: true,
},
}),
);
expect(manifest).toBeUndefined();
expect(errors[0]).toContain(
'Action "ai.openWithContext" is gated by "ai.services.use"',
);
});
it("allows an action gated by a declared permission", () => {
const { manifest, errors } = parseManifest(
validManifest({
contributes: {
permissionGroup: {
group: "ai",
permissions: ["ai.services.use"],
},
actions: [
{
id: "ai.openWithContext",
titleKey: "k",
handler: "h",
permission: "ai.services.use",
},
],
],
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
}),
);
expect(errors).toEqual([]);
expect(manifest?.contributes?.actions?.[0].id).toBe("ai.openWithContext");
});
},
}),
);
it("allows an ungated action with no permissionGroup at all", () => {
const { errors } = parseManifest(
validManifest({
contributes: {
actions: [{ id: "a.one", titleKey: "k", handler: "h" }],
},
}),
);
expect(errors).toEqual([]);
});
it("narrows a valid manifest", () => {
const { manifest, errors } = parseManifest(validManifest());
expect(errors).toEqual([]);
expect(manifest?.id).toBe("sample-plugin");
});
it("refuses a manifest targeting a different SDK major", () => {
const { manifest, errors } = parseManifest(
validManifest({ engine: { termix: ">=2.9.0", api: "99" } }),
);
expect(manifest).toBeUndefined();
expect(errors[0]).toContain('targets SDK api version "99"');
});
it("does not narrow when validation failed", () => {
const { manifest, errors } = parseManifest({ id: "x" });
expect(manifest).toBeUndefined();
expect(errors.length).toBeGreaterThan(0);
});
expect(errors.join()).toMatch(/Unknown field "elevated"/);
});
});
describe("dependencies", () => {
it("accepts a well-formed dependency map", () => {
expect(
validateManifest(base({ dependencies: { automations: "^1.0.0" } })),
).toEqual([]);
});
it("rejects a range that is not valid semver", () => {
expect(
validateManifest(
base({ dependencies: { automations: "latest!" } }),
).join(),
).toMatch(/valid semver range/);
});
it("refuses a plugin depending on itself", () => {
const { errors } = parseManifest(
base({ dependencies: { "sample-plugin": "^1.0.0" } }),
);
expect(errors.join()).toMatch(/points at this plugin/);
});
it("refuses the same plugin in both dependency maps", () => {
const { errors } = parseManifest(
base({
dependencies: { automations: "^1.0.0" },
optionalDependencies: { automations: "^1.0.0" },
}),
);
expect(errors.join()).toMatch(/both dependencies and optionalDependencies/);
});
});
describe("permission groups", () => {
it("accepts a group and its declared permissions", () => {
expect(
validateManifest(
base({
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
},
}),
),
).toEqual([]);
});
// Without this a manifest could hand any role admin.* at boot.
it("refuses a role default outside the plugin's own permissions", () => {
const errors = validateManifest(
base({
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
defaultForRole: { user: ["admin.users.manage"] },
},
},
}),
);
expect(errors.join()).toMatch(/does not declare/);
});
it("accepts a role default the plugin does declare", () => {
expect(
validateManifest(
base({
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
defaultForRole: { admin: ["sample-plugin.use"] },
},
},
}),
),
).toEqual([]);
});
});
describe("services, secrets and actions", () => {
it("refuses a service gated by a permission the group never declares", () => {
const { errors } = parseManifest(
base({
provides: [
{
service: "sample.thing",
version: "1.0.0",
permission: "sample-plugin.missing",
},
],
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
},
}),
);
expect(errors.join()).toMatch(/not declared in contributes/);
});
it("refuses a shared secret gated by an undeclared permission", () => {
const { errors } = parseManifest(
base({
providesSecret: [
{ key: "api-key", permission: "sample-plugin.missing" },
],
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
},
}),
);
expect(errors.join()).toMatch(/not declared in contributes/);
});
it("refuses a plugin borrowing a secret from itself", () => {
const { errors } = parseManifest(
base({ requiresSecret: [{ plugin: "sample-plugin", key: "api-key" }] }),
);
expect(errors.join()).toMatch(/points at this plugin/);
});
it("refuses an action gated by an undeclared permission", () => {
const { errors } = parseManifest(
base({
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
actions: [
{
id: "sample.open",
titleKey: "k",
handler: "open",
permission: "sample-plugin.missing",
},
],
},
}),
);
expect(errors.join()).toMatch(/not declared in contributes/);
});
it("allows a camelCase segment in an action id", () => {
expect(
validateManifest(
base({
contributes: {
actions: [
{ id: "ai.openWithContext", titleKey: "k", handler: "open" },
],
},
}),
),
).toEqual([]);
});
it("rejects an action id that is not dotted", () => {
expect(
validateManifest(
base({
contributes: {
actions: [{ id: "open", titleKey: "k", handler: "open" }],
},
}),
).join(),
).toMatch(/dotted action id/);
});
it("rejects a handler that is not an identifier", () => {
expect(
validateManifest(
base({
contributes: {
actions: [
{ id: "sample.open", titleKey: "k", handler: "not valid!" },
],
},
}),
).join(),
).toMatch(/JavaScript identifier/);
});
});
describe("tabs and host capabilities", () => {
it("rejects an unknown openFrom value", () => {
expect(
validateManifest(
base({
contributes: {
tabs: [
{ id: "t", titleKey: "k", icon: "i", openFrom: ["telepathy"] },
],
},
}),
).join(),
).toMatch(/openFrom\[0\] must be one of/);
});
it("rejects duplicate tab ids", () => {
expect(
validateManifest(
base({
contributes: {
tabs: [
{ id: "t", titleKey: "k", icon: "i", openFrom: ["rail"] },
{ id: "t", titleKey: "k2", icon: "i", openFrom: ["rail"] },
],
},
}),
).join(),
).toMatch(/duplicates/);
});
it("accepts hostCapability as a single object or an array", () => {
const one = {
key: "enableThing",
labelKey: "k",
editorTab: "general",
};
expect(
validateManifest(base({ contributes: { hostCapability: one } })),
).toEqual([]);
expect(
validateManifest(
base({
contributes: {
hostCapability: [one, { ...one, key: "enableOther" }],
},
}),
),
).toEqual([]);
});
it("rejects duplicate hostCapability keys", () => {
const one = { key: "enableThing", labelKey: "k", editorTab: "general" };
expect(
validateManifest(
base({ contributes: { hostCapability: [one, one] } }),
).join(),
).toMatch(/duplicates/);
});
});
@@ -2,11 +2,9 @@
* Two real plugins on disk, one offering a secret and one borrowing it, driven
* through the actual PluginLoader.
*
* Same borrowed-id trick as service-contract.test.ts: the in-process allowlist
* is hardcoded and deliberately unfakeable, so the fixtures use real
* first-party ids to reach that tier. The secret ("api-key") and the
* permission gating it ("testplugin.secrets.share") are fixture-owned, so
* nothing about the shipped ssh-terminal or docker plugins is under test.
* The secret ("api-key") and the permission gating it
* ("testplugin.secrets.share") are fixture-owned, so nothing about the
* shipped plugins is under test even though the fixtures borrow their ids.
*
* What the unit test cannot show, and this does: that the manifest declaration
* is enforced by the real ctx, and that a genuine uninstall through
@@ -53,8 +51,6 @@ vi.mock("../../utils/permission-manager.js", () => ({
}));
const { PluginLoader } = await import("../../plugins/loader.js");
const { TRANSPORT_OWNER_CAPABILITY } =
await import("../../plugins/first-party.js");
const {
clearSecretRegistry,
getSecretRegistration,
@@ -107,7 +103,7 @@ export async function activate(ctx) {
function providerFixture(): Fixture {
return createFixturePlugin({
id: PROVIDER_ID,
permissions: [TRANSPORT_OWNER_CAPABILITY],
capabilities: ["kv:own"],
manifestOverrides: {
category: "Terminal",
providesSecret: [{ key: KEY, permission: PERMISSION }],
@@ -125,7 +121,7 @@ function consumerFixture(
): Fixture {
return createFixturePlugin({
id: CONSUMER_ID,
permissions: [TRANSPORT_OWNER_CAPABILITY],
capabilities: ["kv:own"],
backendSource: CONSUMER_SOURCE,
manifestOverrides: { category: "Infrastructure", requiresSecret },
});
@@ -286,7 +282,7 @@ describe("cross-plugin secret references", () => {
it("refuses to offer a secret the provider's manifest never declared", async () => {
provider = createFixturePlugin({
id: PROVIDER_ID,
permissions: [TRANSPORT_OWNER_CAPABILITY],
capabilities: ["kv:own"],
backendSource: `
export async function activate(ctx) {
ctx.secrets.offer("undeclared-key", () => "nope");
@@ -2,12 +2,9 @@
* Two real plugins on disk, one providing a service and one requiring it,
* driven through the actual PluginLoader.
*
* The ids are the real first-party ones because the in-process allowlist is
* hardcoded and deliberately unfakeable -- the same thing in-process-loader
* does. The service ("testplugin.greet") and the permission gating it
* The service ("testplugin.greet") and the permission gating it
* ("testplugin.greet.use") are fixture-owned, so nothing about the shipped
* ssh-terminal or docker plugins is under test here; only their ids are
* borrowed to reach the in-process tier.
* plugins is under test here even though the fixtures borrow their ids.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -51,8 +48,6 @@ vi.mock("../../utils/permission-manager.js", () => ({
}));
const { PluginLoader } = await import("../../plugins/loader.js");
const { TRANSPORT_OWNER_CAPABILITY } =
await import("../../plugins/first-party.js");
const {
clearServiceRegistry,
getRegistration,
@@ -99,7 +94,7 @@ export async function activate(ctx) {
function providerFixture(): Fixture {
return createFixturePlugin({
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
capabilities: ["kv:own"],
manifestOverrides: {
category: "Terminal",
provides: [
@@ -119,7 +114,7 @@ function consumerFixture(
): Fixture {
return createFixturePlugin({
id: "docker",
permissions: [TRANSPORT_OWNER_CAPABILITY],
capabilities: ["kv:own"],
backendSource: CONSUMER_SOURCE,
manifestOverrides: { category: "Infrastructure", requires },
});
@@ -212,7 +207,7 @@ describe("plugin service contracts", () => {
await expect(loader.activate(plugin.id)).rejects.toThrow(
/no active plugin provides|provides 1\.2\.0/,
);
expect(plugin.state).toBe("crashed");
expect(plugin.state).toBe("failed");
});
it("activates anyway when an unsatisfied requirement is optional", async () => {
@@ -323,7 +318,7 @@ describe("plugin service contracts", () => {
it("refuses to provide a service the manifest never declared", async () => {
provider = createFixturePlugin({
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
capabilities: ["kv:own"],
backendSource: `
export async function activate(ctx) {
ctx.services.provide("testplugin.undeclared", { hello: async () => "hi" });
+146
View File
@@ -0,0 +1,146 @@
/**
* Core owns shutdown.
*
* gracefulShutdown in starter.ts calls shutdownPlugins(), which runs every
* plugin's deactivate and disposes what it registered. A plugin does not need
* its own process signal handler, and must not have one: the docker console
* used to install a SIGTERM listener that called process.exit(0), which
* skipped the rest of the sequence including other plugins and the database
* flush.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { PluginLoader } from "../../plugins/loader.js";
import { createFixturePlugin } from "./fixture-plugin.js";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../plugins/permissions.js", () => ({
assertCapability: vi.fn().mockResolvedValue(undefined),
hasCapability: vi.fn().mockResolvedValue(true),
PluginCapabilityError: class extends Error {},
invalidatePluginPermissionCache: vi.fn(),
}));
const CONSOLE_PORT = 39109;
const cleanups: Array<() => void> = [];
function tempRoot(prefix: string): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
cleanups.push(() => fs.rmSync(root, { recursive: true, force: true }));
return root;
}
function isListening(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.connect({ port, host: "127.0.0.1" });
socket.once("connect", () => {
socket.destroy();
resolve(true);
});
socket.once("error", () => resolve(false));
});
}
afterEach(() => {
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
delete process.env.DATA_DIR;
while (cleanups.length) cleanups.pop()?.();
});
/**
* Shaped like the docker plugin: a REST server and a console WebSocket
* server, both started inside activate and closed through disposables.
*/
const DOCKER_LIKE = `
import net from "node:net";
export async function activate(ctx) {
const console = net.createServer();
await new Promise((resolve) =>
console.listen(${CONSOLE_PORT}, "127.0.0.1", resolve),
);
ctx.disposables.add(
() => new Promise((resolve) => console.close(() => resolve())),
);
}
`;
describe("shutdownPlugins closes what plugins own", () => {
it("closes a console-style WebSocket server on shutdown", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
id: "docker",
root: bundled,
backendSource: DOCKER_LIKE,
});
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
process.env.DATA_DIR = user;
const loader = new PluginLoader();
await loader.loadAll();
await loader.activate("docker");
expect(await isListening(CONSOLE_PORT)).toBe(true);
await loader.shutdown();
expect(await isListening(CONSOLE_PORT)).toBe(false);
});
// One plugin failing to shut down cleanly must not strand the others,
// which is the other half of why core drives this rather than each plugin.
it("keeps going when one plugin's deactivate throws", async () => {
const marker = path.join(tempRoot("termix-marker-"), "stopped.txt");
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
id: "alpha",
root: bundled,
backendSource: `
export async function activate() {}
export async function deactivate() {
throw new Error("alpha refuses to stop");
}
`,
});
createFixturePlugin({
id: "beta",
root: bundled,
backendSource: `
import fs from "node:fs";
export async function activate() {}
export async function deactivate() {
fs.appendFileSync(${JSON.stringify(marker)}, "beta stopped\\n");
}
`,
});
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
process.env.DATA_DIR = user;
const loader = new PluginLoader();
await loader.loadAll();
await loader.activateAll(["alpha", "beta"]);
await loader.shutdown();
expect(loader.get("alpha")?.state).toBe("stopped");
expect(loader.get("beta")?.state).toBe("stopped");
expect(fs.readFileSync(marker, "utf8")).toBe("beta stopped\n");
});
});
@@ -1,121 +0,0 @@
/**
* Worker plugins gained an optional deactivate() hook. It is best-effort by
* design: a plugin that ignores it or hangs must not be able to block a
* disable, it just loses its cleanup.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { PluginLoader } from "../../plugins/loader.js";
import { PluginBroker } from "../../plugins/broker.js";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
function buildLoader(broker: PluginBroker): PluginLoader {
return new PluginLoader({
onWorkerReady: (plugin, worker) => broker.attach(plugin, worker),
onWorkerGone: (plugin) => broker.detach(plugin.id),
onBeforeTerminate: async (plugin) => {
const runtime = broker.get(plugin.id);
if (runtime) await broker.requestDeactivate(runtime, 2000);
},
});
}
function newBroker(): PluginBroker {
return new PluginBroker({
listHosts: async () => [],
resolveHost: async () => null,
});
}
describe("worker deactivate hook", () => {
let fixture: Fixture | null = null;
let loader: PluginLoader | null = null;
afterEach(async () => {
await loader?.shutdown();
loader = null;
fixture?.cleanup();
fixture = null;
});
it("runs the plugin's deactivate() before the worker is terminated", async () => {
fixture = createFixturePlugin();
const marker = path.join(fixture.root, "marker.txt");
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
`
import fs from "node:fs";
export async function activate() {}
export async function deactivate() {
fs.writeFileSync(${JSON.stringify(marker)}, "cleaned");
}
`,
);
loader = buildLoader(newBroker());
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id, "user-1");
await loader.deactivate(plugin.id);
expect(plugin.state).toBe("stopped");
expect(fs.readFileSync(marker, "utf8")).toBe("cleaned");
});
it("still stops a plugin that exports no deactivate", async () => {
fixture = createFixturePlugin();
loader = buildLoader(newBroker());
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id, "user-1");
await loader.deactivate(plugin.id);
expect(plugin.state).toBe("stopped");
expect(plugin.worker).toBeNull();
});
it("does not let a hanging deactivate block the disable", async () => {
fixture = createFixturePlugin();
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
`
export async function activate() {}
export function deactivate() {
return new Promise(() => {});
}
`,
);
const broker = newBroker();
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker.attach(plugin, worker),
onWorkerGone: (plugin) => broker.detach(plugin.id),
onBeforeTerminate: async (plugin) => {
const runtime = broker.get(plugin.id);
// Short timeout so the test does not wait out the real one.
if (runtime) await broker.requestDeactivate(runtime, 150);
},
});
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id, "user-1");
const started = Date.now();
await loader.deactivate(plugin.id);
expect(plugin.state).toBe("stopped");
expect(Date.now() - started).toBeLessThan(2000);
});
});
+18 -8
View File
@@ -11,20 +11,30 @@ export interface PluginContributions {
tabs?: PluginTabContribution[];
}
/**
* What GET /plugins returns.
*
* The admin-only fields are absent for a caller without
* admin.plugins.manage: what a plugin may do, what it has been granted and
* why it failed are operational details the shell does not need.
*/
export interface PluginSummary {
id: string;
name: string;
version: string;
tier: string;
source: string;
enabled: boolean;
runtimeState: string;
lastError: string | null;
/** enabled | disabled | blocked | failed, or the loader's live state. */
state: string;
contributes: PluginContributions | null;
/** Capabilities this plugin's manifest declares it may ask for. */
permissions: string[];
/** The subset of `permissions` an admin has actually granted. */
grantedCapabilities: string[];
tier?: string;
source?: string;
/** Capabilities this plugin's manifest declares. Admins only. */
capabilities?: string[];
/** The subset of `capabilities` actually granted. Admins only. */
grantedCapabilities?: string[];
/** Why the plugin is blocked or failed. Admins only. */
lastError?: string | null;
}
export async function getPlugins(): Promise<PluginSummary[]> {
+89 -25
View File
@@ -3988,31 +3988,7 @@
"pluginPermissionGranted": "Granted",
"pluginPermissionNotGranted": "Not granted",
"pluginGrantFailed": "Failed to grant the capability",
"pluginRevokeFailed": "Failed to revoke the capability",
"pluginPermissionHostsRead": "Read hosts",
"pluginPermissionHostsReadDesc": "See the list of your hosts and their non-secret details.",
"pluginPermissionHostsWrite": "Modify hosts",
"pluginPermissionHostsWriteDesc": "Create, edit or delete hosts on your behalf.",
"pluginPermissionCredentialsUse": "Use stored credentials",
"pluginPermissionCredentialsUseDesc": "Connect using a saved credential without you retyping it.",
"pluginPermissionSshExec": "Run commands over SSH",
"pluginPermissionSshExecDesc": "Open a connection to a host and run commands on it.",
"pluginPermissionSshSftp": "Transfer files over SFTP",
"pluginPermissionSshSftpDesc": "Read or write files on a host over SFTP.",
"pluginPermissionStorageOwn": "Store its own data",
"pluginPermissionStorageOwnDesc": "Save and read back its own settings and data.",
"pluginPermissionStorageSecrets": "Store secrets",
"pluginPermissionStorageSecretsDesc": "Save and read back sensitive values it manages itself.",
"pluginPermissionNetworkOutbound": "Make outbound network requests",
"pluginPermissionNetworkOutboundDesc": "Contact services outside this server.",
"pluginPermissionEventsRead": "Subscribe to server events",
"pluginPermissionEventsReadDesc": "React to things happening elsewhere in Termix.",
"pluginPermissionNotifySend": "Send notifications",
"pluginPermissionNotifySendDesc": "Deliver a notification through your configured channels.",
"pluginPermissionUsersRead": "Read user accounts",
"pluginPermissionUsersReadDesc": "See basic details about accounts on this server.",
"pluginPermissionProcessSidecar": "Run a background process",
"pluginPermissionProcessSidecarDesc": "Start and manage its own helper process."
"pluginRevokeFailed": "Failed to revoke the capability"
},
"newUi": {
"sidebar": {
@@ -5136,5 +5112,93 @@
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"plugins": {
"capabilities": {
"credentials:read": {
"title": "See your passwords and keys",
"consequence": "It can read the stored secret for any host you can reach."
},
"ssh:connect": {
"title": "Run commands on your servers",
"consequence": "Any command, on hosts you connect it to, with your access."
},
"process:spawn": {
"title": "Run programs on this machine",
"consequence": "Programs run on the Termix server itself, not on your hosts."
},
"users:write": {
"title": "Create and change user accounts",
"consequence": "It can add users and change their roles."
},
"auth:provide": {
"title": "Handle sign in",
"consequence": "It can add a login method or a second factor."
},
"system:tls": {
"title": "Manage certificates",
"consequence": "It can request and replace the server certificate."
},
"hosts:write": {
"title": "Create and change hosts",
"consequence": "It can add hosts and edit the ones you already have."
},
"credentials:use": {
"title": "Connect to your servers",
"consequence": "It cannot see your passwords or keys."
},
"network:outbound": {
"title": "Reach the internet",
"consequence": "It can send requests to outside services."
},
"network:serve": {
"title": "Accept connections",
"consequence": "It opens a port on the Termix server."
},
"users:read": {
"title": "See usernames and roles",
"consequence": "Never password hashes, never two factor state."
},
"events:core": {
"title": "Watch everything happening",
"consequence": "Including activity from other users, not just yours."
},
"notify:send": {
"title": "Send notifications",
"consequence": "Through the channels you have already set up."
},
"audit:read": {
"title": "Read the audit log",
"consequence": "It can see who did what and when."
},
"desktop:window": {
"title": "Open desktop windows",
"consequence": "Only in the Termix desktop app."
},
"hosts:read": {
"title": "See your host list",
"consequence": "Names and addresses for hosts you can already see."
},
"db:own": {
"title": "Store its own data",
"consequence": "Kept in its own tables, separate from other plugins."
},
"kv:own": {
"title": "Remember small settings",
"consequence": "Kept separate from other plugins."
},
"secrets:own": {
"title": "Store its own secrets",
"consequence": "Encrypted by Termix. The plugin never holds the key."
},
"settings:read-core": {
"title": "Read Termix settings",
"consequence": "Server settings, not your hosts or credentials."
},
"ui:surface": {
"title": "Add its own screens",
"consequence": "Tabs, panels and settings you can hide later."
}
}
}
}
+3 -5
View File
@@ -81,18 +81,16 @@ export function AdminPluginsSection({
key={plugin.id}
label={plugin.name}
badge={
plugin.tier === "first-party"
? t("admin.pluginBuiltIn")
: undefined
plugin.tier === "bundled" ? t("admin.pluginBuiltIn") : undefined
}
description={
plugin.lastError
? plugin.lastError
: `v${plugin.version} · ${plugin.runtimeState}`
: `v${plugin.version} · ${plugin.state}`
}
>
<div className="flex items-center gap-2">
{plugin.permissions.length > 0 && (
{(plugin.capabilities?.length ?? 0) > 0 && (
<Button
variant="outline"
size="sm"
+12 -76
View File
@@ -15,83 +15,18 @@ import {
type PluginSummary,
} from "@/api/plugins-api";
import { AdminToggle } from "./AdminSettingsShared";
import { CAPABILITY_CATALOG } from "@termix/plugin-sdk/capabilities";
/**
* Every permission a plugin's manifest can declare, with the label and
* one-line description shown here. process:transport-owner is left out on
* purpose: it is reserved for first-party plugins and enforced by a hardcoded
* allowlist, not something an admin grants, so showing a toggle for it would
* be misleading. ui.* permissions describe what a plugin contributes to the
* shell rather than a capability the broker gates, but they are still shown
* here since the manifest can declare them and a plugin's grant list should
* not silently hide part of what it asked for.
* The catalog is the list. It is ordered worst first, so the riskiest thing a
* plugin asked for is the first row an admin reads rather than something they
* have to scroll for.
*/
const LABELED_PERMISSIONS: Array<{
capability: string;
labelKey: string;
descriptionKey: string;
}> = [
{
capability: "hosts.read",
labelKey: "admin.pluginPermissionHostsRead",
descriptionKey: "admin.pluginPermissionHostsReadDesc",
},
{
capability: "hosts.write",
labelKey: "admin.pluginPermissionHostsWrite",
descriptionKey: "admin.pluginPermissionHostsWriteDesc",
},
{
capability: "credentials.use",
labelKey: "admin.pluginPermissionCredentialsUse",
descriptionKey: "admin.pluginPermissionCredentialsUseDesc",
},
{
capability: "ssh.exec",
labelKey: "admin.pluginPermissionSshExec",
descriptionKey: "admin.pluginPermissionSshExecDesc",
},
{
capability: "ssh.sftp",
labelKey: "admin.pluginPermissionSshSftp",
descriptionKey: "admin.pluginPermissionSshSftpDesc",
},
{
capability: "storage.own",
labelKey: "admin.pluginPermissionStorageOwn",
descriptionKey: "admin.pluginPermissionStorageOwnDesc",
},
{
capability: "storage.secrets",
labelKey: "admin.pluginPermissionStorageSecrets",
descriptionKey: "admin.pluginPermissionStorageSecretsDesc",
},
{
capability: "network.outbound",
labelKey: "admin.pluginPermissionNetworkOutbound",
descriptionKey: "admin.pluginPermissionNetworkOutboundDesc",
},
{
capability: "events.read",
labelKey: "admin.pluginPermissionEventsRead",
descriptionKey: "admin.pluginPermissionEventsReadDesc",
},
{
capability: "notify.send",
labelKey: "admin.pluginPermissionNotifySend",
descriptionKey: "admin.pluginPermissionNotifySendDesc",
},
{
capability: "users.read",
labelKey: "admin.pluginPermissionUsersRead",
descriptionKey: "admin.pluginPermissionUsersReadDesc",
},
{
capability: "process.sidecar",
labelKey: "admin.pluginPermissionProcessSidecar",
descriptionKey: "admin.pluginPermissionProcessSidecarDesc",
},
];
const LABELED_PERMISSIONS = CAPABILITY_CATALOG.map((capability) => ({
capability: capability.id,
labelKey: capability.titleKey,
descriptionKey: capability.consequenceKey,
}));
export function PluginPermissionsDialog({
plugin,
@@ -109,10 +44,11 @@ export function PluginPermissionsDialog({
if (!plugin) return null;
const declared = plugin.capabilities ?? [];
const rows = LABELED_PERMISSIONS.filter((row) =>
plugin.permissions.includes(row.capability),
declared.includes(row.capability),
);
const granted = new Set(plugin.grantedCapabilities);
const granted = new Set(plugin.grantedCapabilities ?? []);
const toggle = async (capability: string, isGranted: boolean) => {
setBusy(capability);
+3 -3
View File
@@ -28,10 +28,10 @@ function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
id: "ssh-terminal",
name: "SSH Terminal",
version: "1.0.0",
tier: "first-party",
tier: "bundled",
source: "bundled",
enabled: true,
runtimeState: "active",
state: "active",
lastError: null,
contributes: {
tabs: [
@@ -43,7 +43,7 @@ function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
},
],
},
permissions: [],
capabilities: [],
grantedCapabilities: [],
...overrides,
};
+2 -1
View File
@@ -36,7 +36,8 @@
"plugins/automations/backend/**/*.ts",
"plugins/network-topology/backend/**/*.ts",
"plugins/workspaces/backend/**/*.ts",
"plugins/web-endpoint/backend/**/*.ts"
"plugins/web-endpoint/backend/**/*.ts",
"plugins/tailscale/backend/**/*.ts"
],
"exclude": ["src/**/*.test.ts"]
}