mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: collapse plugin runtime to one tier behind a plugin SDK
This commit is contained in:
1 parent
6e058f6e9e
commit
4758016f41
96 files changed
+34631
-8551
No files matched your search
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
Generated
+15
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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";
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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)`);
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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";
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user