From da8dbdc8b3987906dc641da277caf440d955eb85 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <213159723+propr-dev[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:40:30 +0000 Subject: [PATCH 01/26] I've implemented #3542: the plugin manifest schema in `@propr/shared`, a new `@propr/plugins` registry workspace with `example-pack`, `npm run plugins:check` wired into CI, tests and a docs page. As the issue asked, there are no tables, routes or UI. All 48 tests in the new files and the existing extension library tests pass. The CI workflow and classifier tests also pass, and so do `release:verify` and the typechecks for both packages. Schema (`packages/shared/src/plugins.ts`, exported from the shared index) - It covers every manifest field in the issue. The permission list is closed: `events.read`, `tasks.read`/`create`, `issues.read`/`create`/`comment`, `pull_requests.read`/`comment`, `repositories.read` and `notifications.send`. Commands may only use permissions the plugin requests, and event subscriptions require `events.read`. - Remote and package servers reuse the extension library's schemas rather than redefining them. On top of those, the manifest must record the `integrity` value itself, and each `secretEnv` entry maps a variable to a connection. Version ranges, tags, paths, images, commands and unknown fields are refused. - Cross-reference checks: a server or webhook must point at a declared connection of the right type, names must be unique in each list, and setting defaults must fit their own limits. - `publicPluginManifest()` is the public view. It leaves out the integrity value, launch arguments and environment values. Registry (`packages/plugins`) - `registry.json` lists plugins in order and marks development-only ones. `officialPlugins()` and `getOfficialPlugin()` return each manifest with its asset paths resolved. `example-pack` only appears when `PROPR_PLUGINS_DEVELOPMENT=1` is set. - `example-pack` has one remote OAuth server, one package server, one skill and one automation template. The package server points at the existing `propr-fixture-mcp@1.0.0` test package. I computed its integrity value with the existing npm registry test helper, and two runs gave the same value. - The package is added to the root `test:prepare` build, and `package-lock.json` gains only the new workspace entries. `plugins:check` (`scripts/check-plugins.mjs`, runs through `tsx` so no build is needed) - It runs the schema, then checks that `registry.json` and the plugin folders match one-to-one, that ids match folder names, and that command names are unique across plugins. It also checks that each plugin has a README and its icon, that each skill folder has a `SKILL.md` with a matching name, and that each automation template passes the existing template checks. - It reports every problem in one run and exits 1 if there are any. - `--online [--registry ]` compares recorded integrity values with the npm registry. Run against public npm it reports `example-pack`'s package as not found, because the test package isn't published there. - CI: there is no standalone CI step for `automation-templates:check` (it only runs through the test suite). So I added a "Plugins: Manifest Check" section to the Run Checks step in `pr-build-check.yml` instead. It runs on every pull request. Tests - `packages/shared/test/plugins.test.ts` covers every invalid case in the acceptance criteria, plus settings rules and the public view. - `test/checkPlugins.test.mjs` runs 11 broken fixture manifests (in `test/fixtures/plugins-check/`), each expecting a specific message. It also tests missing files, registry mismatches, duplicate commands across plugins, the command-line exit codes, and `--online` against a fake registry. - `packages/plugins/test/registry.test.ts` covers the development flag and asset paths. Docs: `docs/docs/features/plugins.md` explains the concept, the folder layout, the manifest and the check, with a link from the agent extensions page. It isn't in the sidebar, which matches the existing agent extensions page. Two choices the issue left open: - Development flag: the development marking lives in `registry.json`, not in the manifest. - Folder references: skills and automation templates are referenced by folder path inside the plugin. Templates use the existing `template.json` + `prompt.md` layout. No previews were made, since nothing here is visible in the UI. --- .github/workflows/pr-build-check.yml | 17 + docs/docs/features/agent-extensions.md | 1 + docs/docs/features/plugins.md | 168 +++++++++ package-lock.json | 19 + package.json | 3 +- packages/plugins/example-pack/README.md | 21 ++ .../example-weekly-digest/prompt.md | 3 + .../example-weekly-digest/template.json | 15 + packages/plugins/example-pack/icon.svg | 1 + packages/plugins/example-pack/manifest.json | 68 ++++ .../skills/example-notes/SKILL.md | 10 + packages/plugins/package.json | 22 ++ packages/plugins/registry.json | 6 + packages/plugins/src/index.ts | 111 ++++++ packages/plugins/test/registry.test.ts | 69 ++++ packages/plugins/tsconfig.json | 18 + packages/shared/src/index.ts | 1 + packages/shared/src/plugins.ts | 325 ++++++++++++++++++ packages/shared/test/plugins.test.ts | 162 +++++++++ scripts/check-plugins.mjs | 181 ++++++++++ test/checkPlugins.test.mjs | 140 ++++++++ .../plugins-check/manifests/bad-id.json | 88 +++++ .../manifests/duplicate-server-name.json | 88 +++++ .../manifests/host-not-hostname.json | 88 +++++ .../manifests/missing-connection.json | 88 +++++ .../manifests/package-command.json | 89 +++++ .../manifests/package-missing-integrity.json | 87 +++++ .../plugins-check/manifests/package-path.json | 88 +++++ .../manifests/package-range.json | 88 +++++ .../plugins-check/manifests/package-tag.json | 88 +++++ .../manifests/secret-env-not-token.json | 88 +++++ .../manifests/unknown-permission.json | 89 +++++ .../valid/fixture-pack/README.md | 3 + .../plugins-check/valid/fixture-pack/icon.svg | 1 + .../valid/fixture-pack/manifest.json | 26 ++ .../skills/fixture-skill/SKILL.md | 5 + .../templates/fixture-report/prompt.md | 1 + .../templates/fixture-report/template.json | 15 + .../plugins-check/valid/registry.json | 1 + 39 files changed, 2381 insertions(+), 1 deletion(-) create mode 100644 docs/docs/features/plugins.md create mode 100644 packages/plugins/example-pack/README.md create mode 100644 packages/plugins/example-pack/automation-templates/example-weekly-digest/prompt.md create mode 100644 packages/plugins/example-pack/automation-templates/example-weekly-digest/template.json create mode 100644 packages/plugins/example-pack/icon.svg create mode 100644 packages/plugins/example-pack/manifest.json create mode 100644 packages/plugins/example-pack/skills/example-notes/SKILL.md create mode 100644 packages/plugins/package.json create mode 100644 packages/plugins/registry.json create mode 100644 packages/plugins/src/index.ts create mode 100644 packages/plugins/test/registry.test.ts create mode 100644 packages/plugins/tsconfig.json create mode 100644 packages/shared/src/plugins.ts create mode 100644 packages/shared/test/plugins.test.ts create mode 100644 scripts/check-plugins.mjs create mode 100644 test/checkPlugins.test.mjs create mode 100644 test/fixtures/plugins-check/manifests/bad-id.json create mode 100644 test/fixtures/plugins-check/manifests/duplicate-server-name.json create mode 100644 test/fixtures/plugins-check/manifests/host-not-hostname.json create mode 100644 test/fixtures/plugins-check/manifests/missing-connection.json create mode 100644 test/fixtures/plugins-check/manifests/package-command.json create mode 100644 test/fixtures/plugins-check/manifests/package-missing-integrity.json create mode 100644 test/fixtures/plugins-check/manifests/package-path.json create mode 100644 test/fixtures/plugins-check/manifests/package-range.json create mode 100644 test/fixtures/plugins-check/manifests/package-tag.json create mode 100644 test/fixtures/plugins-check/manifests/secret-env-not-token.json create mode 100644 test/fixtures/plugins-check/manifests/unknown-permission.json create mode 100644 test/fixtures/plugins-check/valid/fixture-pack/README.md create mode 100644 test/fixtures/plugins-check/valid/fixture-pack/icon.svg create mode 100644 test/fixtures/plugins-check/valid/fixture-pack/manifest.json create mode 100644 test/fixtures/plugins-check/valid/fixture-pack/skills/fixture-skill/SKILL.md create mode 100644 test/fixtures/plugins-check/valid/fixture-pack/templates/fixture-report/prompt.md create mode 100644 test/fixtures/plugins-check/valid/fixture-pack/templates/fixture-report/template.json create mode 100644 test/fixtures/plugins-check/valid/registry.json diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index fddc7817f6..d8a47e57c7 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -1053,6 +1053,23 @@ jobs: fi + # --- PLUGINS --- + # Official plugin manifests (packages/plugins) are validated against + # the @propr/shared schema on every pull request, like the automation + # template catalog: the check reads TypeScript sources through tsx + # and needs no build. `--online` is for local use only. + echo -e "\n--- Plugins: Manifest Check ---" >> build_log.txt + PLUGINS_FAILED=0 + PLUGINS_OUTPUT=$(npm run plugins:check 2>&1) || { + echo "❌ Plugin manifest check FAILED (run npm run plugins:check)" >> build_log.txt + echo "$PLUGINS_OUTPUT" >> build_log.txt + PLUGINS_FAILED=1 + EXIT_CODE=1 + } + if [ $PLUGINS_FAILED -eq 0 ]; then + echo "✅ Plugin manifests are valid" >> build_log.txt + fi + # --- API REFERENCE --- # The OpenAPI spec is generated from the API route registry, published # by the docs site and consumed by @propr/client, so a change to any diff --git a/docs/docs/features/agent-extensions.md b/docs/docs/features/agent-extensions.md index 2fa30416f7..21741b0c27 100644 --- a/docs/docs/features/agent-extensions.md +++ b/docs/docs/features/agent-extensions.md @@ -633,3 +633,4 @@ Skill-bearing runs require a Linux worker with `/proc/self/fd` available for sec - [MCP Connections and Operator Tools](/docs/features/mcp) - [Agents and Models](/docs/features/agents-and-models) +- [Plugins](/docs/features/plugins): official integrations that bundle MCP servers, skills and automation templates diff --git a/docs/docs/features/plugins.md b/docs/docs/features/plugins.md new file mode 100644 index 0000000000..9d8e0f979e --- /dev/null +++ b/docs/docs/features/plugins.md @@ -0,0 +1,168 @@ +--- +sidebar_label: "Plugins" +title: "Plugins: the manifest format" +description: "What a ProPR plugin is, where official plugins live, and the manifest that describes their permissions, connections, settings and contributions." +--- +# Plugins + +A **plugin** packages an integration with another service (a chat tool, an +issue tracker, an observability or deployment platform) as one unit that an +administrator can review and install. Official plugins are trusted code shipped +with ProPR. Nothing is downloaded when a plugin is installed, except the pinned +npm package of a local MCP server, which goes through the same package cache and +integrity check as the [extension library's local package servers](./agent-extensions.md#local-package-servers). + +:::note Work in progress +This page describes the plugin **manifest** and the official plugin registry. +Installation, the Plugins page, applying contributions to runs, handlers, +webhooks and commands are added by later releases; until then a manifest is +validated but has no effect on ProPR. +::: + +## Where plugins live + +Each official plugin is a folder `packages/plugins//` in the ProPR +repository: + +| File | Purpose | +| --- | --- | +| `manifest.json` | Everything ProPR shows about the plugin, as pure data. ProPR can list a plugin, its permissions and its contributions without loading any of its code. | +| `README.md` | What the plugin does and how to set it up. Required. | +| icon | An `.svg` or `.png` file named by the manifest's `icon`. | +| skill folders | Skill bundle sources, each with a `SKILL.md`, named by `contributes.skills`. | +| automation template folders | Templates in the shipped automation template format (`template.json` and `prompt.md`), named by `contributes.automationTemplates`. | + +`packages/plugins/registry.json` lists the plugins in catalog order. The +`@propr/plugins` workspace package exports that list with each manifest and the +resolved paths of its assets. + +Development plugins are marked `"development": true` in `registry.json` and are +left out of the list unless `PROPR_PLUGINS_DEVELOPMENT=1` is set. The +`example-pack` plugin is one: it contributes one remote MCP server, one local +package server, one skill and one automation template, and is used by ProPR's +own tests and as a reference for plugin authors. + +## The manifest + +The schema is defined in `@propr/shared` (`packages/shared/src/plugins.ts`). + +| Field | Description | +| --- | --- | +| `manifestVersion` | Currently `1`. | +| `id` | Lowercase slug such as `acme-chat`, unique among plugins and equal to the folder name. | +| `name`, `description` | Shown in the catalog. | +| `version` | Semantic version of the plugin, such as `1.2.0`. | +| `category` | One of `messaging`, `issue-tracking`, `observability`, `deployment`, `context`, `other`. | +| `homepage` | An `https://` URL. | +| `icon` | Path of an `.svg` or `.png` file inside the plugin folder. | +| `permissions` | What the plugin's handlers may do, from a closed list (below). Plugins that only contribute servers, skills and templates usually need none. | +| `connections` | Named credentials the plugin needs (below). | +| `settings` | The plugin's settings, in a JSON Schema subset (below). | +| `outboundHosts` | Hostnames, such as `api.example.com`, that the plugin's handlers may call. No scheme, port, path, wildcard or IP address. | +| `contributes` | What the plugin adds to ProPR (below). | + +Unknown fields are refused everywhere, so a misspelled field is an error rather +than silently ignored. + +### Permissions + +`events.read`, `tasks.read`, `tasks.create`, `issues.read`, `issues.create`, +`issues.comment`, `pull_requests.read`, `pull_requests.comment`, +`repositories.read` and `notifications.send`. A command can only use +permissions the plugin requests, and event subscriptions need `events.read`. + +### Connections + +Each connection has a `name`, a `type`, a `label`, `help` text and a `helpUrl` +pointing to where the credential is obtained. + +| Type | What the administrator provides | +| --- | --- | +| `oauth` | A sign-in, using the same flow as [OAuth library servers](./agent-extensions.md#oauth-servers). `registrations` limits the client registration methods (`metadata_document`, `dynamic`, `preregistered`; all three by default) and `scopes` lists the scopes to request. | +| `token` | A secret pasted by the administrator, such as an API token. | +| `signing_secret` | The shared secret that verifies inbound webhooks. | + +### Settings + +`settings` is an object schema: `{ "type": "object", "properties": { ... }, "required": [ ... ] }`. +Every property has a `label`, optional `help` and an optional `default`, and is +one of: + +| Shape | Meaning | +| --- | --- | +| `{ "type": "string" }` | Text, with optional `maxLength`. With `enum`, one of a fixed list of values. | +| `{ "type": "number" }` | A number, with optional `minimum`, `maximum` and `integer`. | +| `{ "type": "boolean" }` | A switch. | +| `{ "type": "array", "items": { "type": "string" } }` | A list of strings, with optional `maxItems`. | +| `{ "type": "object", "format": "repository-map", "additionalProperties": { "type": "string" } }` | A value per repository, keyed by `owner/repo`. | + +Defaults must satisfy their own constraints, and every `required` key must be +defined. + +### Contributions + +| Field | Contents | +| --- | --- | +| `mcpServers` | MCP servers, in the [extension library](./agent-extensions.md)'s item shapes (below). | +| `skills` | `{ "name", "path" }`: a skill bundle folder inside the plugin folder. Its `SKILL.md` front matter must use the same `name`. | +| `automationTemplates` | `{ "id", "path" }`: a template folder in the automation template format. It must pass the same checks as the shipped templates. | +| `commands` | `{ "name", "description", "permissions" }`. Accepted and validated; not used yet. | +| `subscriptions` | `{ "event" }`, a dotted event name such as `task.completed`. Accepted and validated; not used yet. | +| `webhooks` | `{ "name", "description", "connection" }`, where the connection is a `signing_secret`. Accepted and validated; not used yet. | + +Names are unique within each list, and command names are unique across all +plugins. + +#### Remote servers + +```json +{ + "kind": "https", "name": "acme", "url": "https://mcp.acme.example.com/mcp", + "tools": ["search"], "capability": "read", + "authentication": "oauth", "connection": "workspace" +} +``` + +`authentication` is `none`, `bearer` or `oauth`. A `bearer` server names a +`token` connection, an `oauth` server names an `oauth` connection, and a `none` +server names no connection. The endpoint must be `https://`. + +#### Local package servers + +```json +{ + "kind": "package", "name": "acme-local", + "ecosystem": "npm", "package": "@acme/mcp-server", "version": "2.0.1", + "integrity": "sha512-…", + "bin": "acme-mcp", "args": ["--read-only"], + "env": [{ "name": "ACME_REGION", "value": "eu" }], + "secretEnv": [{ "name": "ACME_TOKEN", "connection": "api-token" }], + "tools": ["post"], "capability": "write" +} +``` + +The rules are those of the extension library's local package servers: an npm +package with an exact `version` (no ranges such as `^2.0.0` and no tags such as +`latest`), and no paths, images or free-form commands. Unlike a library item, +the manifest records the `integrity` value of the pinned version itself, and +each `secretEnv` variable names one of the plugin's `token` connections instead +of holding a value. + +## Checking manifests + +```bash +npm run plugins:check +``` + +validates every plugin in `packages/plugins`: the manifest schema and its +cross-references, that `registry.json` lists each plugin folder exactly once, +that ids match their folders, that command names are unique, and that the +README, icon, skill folders and automation templates exist and are valid. CI +runs it on every pull request. + +```bash +npm run plugins:check -- --online [--registry https://registry.npmjs.org] +``` + +also asks the npm registry for each package server's version and compares the +recorded `integrity` value. It needs network access and is not run in CI. diff --git a/package-lock.json b/package-lock.json index e12c67ca50..2aedc6bc14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3101,6 +3101,10 @@ "resolved": "packages/local-setup", "link": true }, + "node_modules/@propr/plugins": { + "resolved": "packages/plugins", + "link": true + }, "node_modules/@propr/shared": { "resolved": "packages/shared", "link": true @@ -16016,6 +16020,21 @@ "node": ">=22" } }, + "packages/plugins": { + "name": "@propr/plugins", + "version": "0.9.1", + "license": "Apache-2.0", + "dependencies": { + "@propr/shared": "^0.9.1" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22" + } + }, "packages/shared": { "name": "@propr/shared", "version": "0.9.1", diff --git a/package.json b/package.json index 5cde9cfbf0..980f8819b6 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "lint": "eslint src/", "typecheck": "tsc --noEmit", "test": "node --test", - "test:prepare": "npm run build --workspace=packages/shared && npm run build --workspace=packages/client && npm run build --workspace=packages/core && npm run build --workspace=packages/local-setup && npm run build --workspace=packages/cli", + "test:prepare": "npm run build --workspace=packages/shared && npm run build --workspace=packages/plugins && npm run build --workspace=packages/client && npm run build --workspace=packages/core && npm run build --workspace=packages/local-setup && npm run build --workspace=packages/cli", "test:server": "node scripts/run-test-suite.mjs", "test:full:prepared": "npm run test:server", "test:full": "npm run test:prepare && npm run test:full:prepared", @@ -117,6 +117,7 @@ "tips:check": "node scripts/generate-usage-tips.mjs --check", "automation-templates:generate": "tsx scripts/generate-automation-templates.ts", "automation-templates:check": "tsx scripts/generate-automation-templates.ts --check", + "plugins:check": "tsx scripts/check-plugins.mjs", "docs:check-serving": "node scripts/check-docs-static-serving.mjs docs/build" }, "keywords": [], diff --git a/packages/plugins/example-pack/README.md b/packages/plugins/example-pack/README.md new file mode 100644 index 0000000000..48c0a8e741 --- /dev/null +++ b/packages/plugins/example-pack/README.md @@ -0,0 +1,21 @@ +# Example pack + +A development plugin used by ProPR's own tests and as a reference for plugin +authors. It is hidden from the plugin catalog unless `PROPR_PLUGINS_DEVELOPMENT=1` +is set. + +It contributes: + +- **example-remote**: a remote MCP server at `https://mcp.example.com/mcp`, + signed in through the `example-account` OAuth connection. The endpoint does + not exist; it only exercises the manifest shape. +- **example-local**: a local MCP server from the pinned npm package + `propr-fixture-mcp@1.0.0`, the package ProPR's package server tests publish to + a local registry. Its `FIXTURE_TOKEN` variable comes from the `example-token` + connection. The package is not on the public npm registry, so + `npm run plugins:check -- --online` reports it as not found unless + `--registry` points at the test registry. +- **example-notes**: a skill that tells the agent how to write short notes. +- **example-weekly-digest**: a report-only automation template. + +See [Plugins](../../../docs/docs/features/plugins.md) for the manifest format. diff --git a/packages/plugins/example-pack/automation-templates/example-weekly-digest/prompt.md b/packages/plugins/example-pack/automation-templates/example-weekly-digest/prompt.md new file mode 100644 index 0000000000..b02821eaa6 --- /dev/null +++ b/packages/plugins/example-pack/automation-templates/example-weekly-digest/prompt.md @@ -0,0 +1,3 @@ +Write a weekly digest for each repository in scope. Do not change anything: do not edit files, commit, comment or create tasks. This run only reports. + +List the pull requests merged into the default branch in the last 7 days, newest first, each with its number, title and one sentence on what it changed. End with the number of pull requests merged and the number still open. If nothing merged, say so in one line. diff --git a/packages/plugins/example-pack/automation-templates/example-weekly-digest/template.json b/packages/plugins/example-pack/automation-templates/example-weekly-digest/template.json new file mode 100644 index 0000000000..9603e37ddc --- /dev/null +++ b/packages/plugins/example-pack/automation-templates/example-weekly-digest/template.json @@ -0,0 +1,15 @@ +{ + "id": "example-weekly-digest", + "name": "Example weekly digest", + "description": "Each week, list the pull requests merged in the last 7 days with one line on what each changed.", + "category": "reporting", + "capabilities": ["repository_read"], + "schedule": "0 9 * * 1", + "autonomy": "dry_run", + "previousReportCount": 1, + "reviewBeforeSaving": [ + { "field": "repositories", "note": "Choose the repositories to summarise. Without one the run has nothing to read." }, + { "field": "schedule", "note": "Runs at 09:00 UTC every Monday. Change the time or turn the schedule off to run it only on demand." }, + { "field": "autonomy", "note": "Starts as Dry run, so a run only writes the digest." } + ] +} diff --git a/packages/plugins/example-pack/icon.svg b/packages/plugins/example-pack/icon.svg new file mode 100644 index 0000000000..5d40b38a46 --- /dev/null +++ b/packages/plugins/example-pack/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/plugins/example-pack/manifest.json b/packages/plugins/example-pack/manifest.json new file mode 100644 index 0000000000..5859ba8acb --- /dev/null +++ b/packages/plugins/example-pack/manifest.json @@ -0,0 +1,68 @@ +{ + "manifestVersion": 1, + "id": "example-pack", + "name": "Example pack", + "description": "A small development plugin that exercises every manifest section: one remote MCP server, one pinned package server, one skill and one automation template. Hidden from the catalog unless the development flag is set.", + "version": "0.1.0", + "category": "other", + "homepage": "https://github.com/integry/propr/tree/main/packages/plugins/example-pack", + "icon": "icon.svg", + "permissions": ["events.read", "tasks.read"], + "connections": [ + { + "type": "oauth", + "name": "example-account", + "label": "Example account", + "help": "Sign in with the example service account that the remote MCP server should act as.", + "helpUrl": "https://example.com/docs/oauth" + }, + { + "type": "token", + "name": "example-token", + "label": "Example API token", + "help": "Create a read-only API token in the example service settings and paste it here.", + "helpUrl": "https://example.com/docs/tokens" + } + ], + "settings": { + "type": "object", + "properties": { + "greeting": { "type": "string", "label": "Greeting", "help": "Text the local example server prefixes to every echo.", "default": "hello" }, + "verbosity": { "type": "string", "label": "Verbosity", "enum": ["quiet", "normal", "detailed"], "default": "normal" }, + "maxItems": { "type": "number", "label": "Maximum items", "minimum": 1, "maximum": 50, "integer": true, "default": 10 }, + "includeDrafts": { "type": "boolean", "label": "Include draft pull requests", "default": false }, + "labels": { "type": "array", "items": { "type": "string" }, "label": "Labels to watch", "default": [] }, + "channels": { "type": "object", "format": "repository-map", "additionalProperties": { "type": "string" }, "label": "Channel per repository", "help": "Maps owner/repo to a channel name." } + }, + "required": ["verbosity"] + }, + "outboundHosts": ["api.example.com"], + "contributes": { + "mcpServers": [ + { + "kind": "https", + "name": "example-remote", + "url": "https://mcp.example.com/mcp", + "tools": ["search", "fetch"], + "capability": "read", + "authentication": "oauth", + "connection": "example-account" + }, + { + "kind": "package", + "name": "example-local", + "ecosystem": "npm", + "package": "propr-fixture-mcp", + "version": "1.0.0", + "integrity": "sha512-Y5GxTexuXT5IDhwgPcZokvnPvvRorDMdNHS83U59b5wquM5QTMmIhfeQ1y7AEbvbSmiJgjcJrok2q2mbzPRTvw==", + "bin": "propr-fixture-mcp", + "env": [{ "name": "FIXTURE_GREETING", "value": "hello" }], + "secretEnv": [{ "name": "FIXTURE_TOKEN", "connection": "example-token" }], + "tools": ["echo", "secret_length"], + "capability": "read" + } + ], + "skills": [{ "name": "example-notes", "path": "skills/example-notes" }], + "automationTemplates": [{ "id": "example-weekly-digest", "path": "automation-templates/example-weekly-digest" }] + } +} diff --git a/packages/plugins/example-pack/skills/example-notes/SKILL.md b/packages/plugins/example-pack/skills/example-notes/SKILL.md new file mode 100644 index 0000000000..45112df4e3 --- /dev/null +++ b/packages/plugins/example-pack/skills/example-notes/SKILL.md @@ -0,0 +1,10 @@ +--- +name: example-notes +description: Write a short, factual note summarising what a run changed and why. +requirements: [] +--- +# Example notes + +When the task asks for a note, write at most five bullet points: what changed, +why, what was verified, what was not, and anything a reviewer should look at +first. Name files with their paths. Do not repeat the task description. diff --git a/packages/plugins/package.json b/packages/plugins/package.json new file mode 100644 index 0000000000..6656db4827 --- /dev/null +++ b/packages/plugins/package.json @@ -0,0 +1,22 @@ +{ + "name": "@propr/plugins", + "version": "0.9.1", + "private": true, + "description": "Registry of the official ProPR plugins: manifests and their assets", + "license": "Apache-2.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "engines": { "node": ">=22" }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@propr/shared": "^0.9.1" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.9.3" + } +} diff --git a/packages/plugins/registry.json b/packages/plugins/registry.json new file mode 100644 index 0000000000..5a406d0d62 --- /dev/null +++ b/packages/plugins/registry.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "plugins": [ + { "id": "example-pack", "development": true } + ] +} diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts new file mode 100644 index 0000000000..c1c096e0f6 --- /dev/null +++ b/packages/plugins/src/index.ts @@ -0,0 +1,111 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { formatPluginManifestIssues, pluginIdSchema, pluginManifestSchema, type PluginManifest } from '@propr/shared'; + +/** + * The official plugins shipped with ProPR. Each plugin is a folder + * `packages/plugins//` with a `manifest.json`, a `README.md` and the + * assets its manifest names. `registry.json` lists the plugins in catalog + * order; development plugins (examples and test fixtures) are left out + * unless the development flag is set. + */ +export const PLUGIN_REGISTRY_VERSION = 1; +/** Set to `1` or `true` to include development plugins such as `example-pack`. */ +export const PLUGIN_DEVELOPMENT_FLAG = 'PROPR_PLUGINS_DEVELOPMENT'; +/** The registry package folder: the parent of `src/` and of `dist/`. */ +export const PLUGINS_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +export interface PluginRegistryEntry { + id: string; + /** Hidden from the catalog unless the development flag is set. */ + development: boolean; +} + +/** Absolute paths of the files a manifest names. */ +export interface PluginAssets { + manifest: string; + readme: string; + icon: string; + skills: { name: string; path: string }[]; + automationTemplates: { id: string; path: string }[]; +} + +export interface OfficialPlugin { + manifest: PluginManifest; + development: boolean; + /** The plugin folder. */ + directory: string; + assets: PluginAssets; +} + +/** The ordered plugin list of a registry root. Throws on a malformed `registry.json`. */ +export function readPluginRegistryIndex(root = PLUGINS_ROOT): PluginRegistryEntry[] { + const file = path.join(root, 'registry.json'); + const document = JSON.parse(readFileSync(file, 'utf8')) as { version?: unknown; plugins?: unknown }; + if (document.version !== PLUGIN_REGISTRY_VERSION) throw new Error(`registry.json: version must be ${PLUGIN_REGISTRY_VERSION}`); + if (!Array.isArray(document.plugins)) throw new Error('registry.json: plugins must be a list'); + return document.plugins.map((entry: unknown, index) => { + const value = entry as { id?: unknown; development?: unknown } | null; + const keys = value && typeof value === 'object' ? Object.keys(value) : []; + if (!value || keys.some(key => key !== 'id' && key !== 'development')) throw new Error(`registry.json: plugins.${index} must be { id, development? }`); + const id = pluginIdSchema.safeParse(value.id); + if (!id.success) throw new Error(`registry.json: plugins.${index}.id is not a plugin id`); + if (value.development !== undefined && typeof value.development !== 'boolean') throw new Error(`registry.json: plugins.${index}.development must be true or false`); + return { id: id.data, development: value.development === true }; + }); +} + +/** Absolute asset paths of a plugin folder; nothing is checked for existence. */ +export function resolvePluginAssets(directory: string, manifest: PluginManifest): PluginAssets { + const at = (relative: string) => path.join(directory, ...relative.split('/')); + return { + manifest: path.join(directory, 'manifest.json'), + readme: path.join(directory, 'README.md'), + icon: at(manifest.icon), + skills: manifest.contributes.skills.map(skill => ({ name: skill.name, path: at(skill.path) })), + automationTemplates: manifest.contributes.automationTemplates.map(template => ({ id: template.id, path: at(template.path) })), + }; +} + +/** Read and validate one plugin folder. Throws with every manifest problem. */ +export function loadPlugin(root: string, entry: PluginRegistryEntry): OfficialPlugin { + const directory = path.join(root, entry.id); + const file = path.join(directory, 'manifest.json'); + if (!existsSync(file)) throw new Error(`${entry.id}: manifest.json is missing`); + const result = pluginManifestSchema.safeParse(JSON.parse(readFileSync(file, 'utf8'))); + if (!result.success) throw new Error(`${entry.id}/manifest.json: ${formatPluginManifestIssues(result.error).join('; ')}`); + if (result.data.id !== entry.id) throw new Error(`${entry.id}/manifest.json: id ${result.data.id} must match the folder name`); + return { manifest: result.data, development: entry.development, directory, assets: resolvePluginAssets(directory, result.data) }; +} + +export function pluginDevelopmentEnabled(env: Record = process.env): boolean { + const value = env[PLUGIN_DEVELOPMENT_FLAG]?.trim().toLowerCase(); + return value === '1' || value === 'true'; +} + +export interface OfficialPluginOptions { + /** Include development plugins; defaults to the development flag in the environment. */ + includeDevelopment?: boolean; + /** A registry root other than this package, for tests. */ + root?: string; +} + +const cache = new Map(); + +/** The official plugins in catalog order. Manifests are read once per root. */ +export function officialPlugins(options: OfficialPluginOptions = {}): OfficialPlugin[] { + const root = options.root ?? PLUGINS_ROOT; + let plugins = cache.get(root); + if (!plugins) { + plugins = readPluginRegistryIndex(root).map(entry => loadPlugin(root, entry)); + cache.set(root, plugins); + } + const includeDevelopment = options.includeDevelopment ?? pluginDevelopmentEnabled(); + return plugins.filter(plugin => includeDevelopment || !plugin.development); +} + +/** One official plugin by id, subject to the same development filter as the list. */ +export function getOfficialPlugin(id: string, options: OfficialPluginOptions = {}): OfficialPlugin | undefined { + return officialPlugins(options).find(plugin => plugin.manifest.id === id); +} diff --git a/packages/plugins/test/registry.test.ts b/packages/plugins/test/registry.test.ts new file mode 100644 index 0000000000..41433d7eeb --- /dev/null +++ b/packages/plugins/test/registry.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { cp, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { after, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { + PLUGIN_DEVELOPMENT_FLAG, PLUGINS_ROOT, getOfficialPlugin, officialPlugins, pluginDevelopmentEnabled, readPluginRegistryIndex, +} from '../src/index.js'; + +const scratch = await mkdtemp(path.join(tmpdir(), 'propr-plugins-registry-')); +after(() => rm(scratch, { recursive: true, force: true })); + +test('the registry root is the package folder', () => { + assert.equal(PLUGINS_ROOT, path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')); + assert.deepEqual(readPluginRegistryIndex(), [{ id: 'example-pack', development: true }]); +}); + +test('example-pack is exported only when the development flag is set', () => { + assert.deepEqual(officialPlugins({ includeDevelopment: false }).map(p => p.manifest.id), []); + assert.equal(getOfficialPlugin('example-pack', { includeDevelopment: false }), undefined); + const example = getOfficialPlugin('example-pack', { includeDevelopment: true }); + assert.ok(example); + assert.equal(example.development, true); + assert.equal(example.manifest.name, 'Example pack'); +}); + +test('the development flag is read from the environment', () => { + assert.equal(pluginDevelopmentEnabled({}), false); + assert.equal(pluginDevelopmentEnabled({ [PLUGIN_DEVELOPMENT_FLAG]: '0' }), false); + assert.equal(pluginDevelopmentEnabled({ [PLUGIN_DEVELOPMENT_FLAG]: '1' }), true); + assert.equal(pluginDevelopmentEnabled({ [PLUGIN_DEVELOPMENT_FLAG]: 'TRUE' }), true); + const previous = process.env[PLUGIN_DEVELOPMENT_FLAG]; + try { + process.env[PLUGIN_DEVELOPMENT_FLAG] = '1'; + assert.deepEqual(officialPlugins().map(p => p.manifest.id), ['example-pack']); + delete process.env[PLUGIN_DEVELOPMENT_FLAG]; + assert.deepEqual(officialPlugins(), []); + } finally { + if (previous === undefined) delete process.env[PLUGIN_DEVELOPMENT_FLAG]; + else process.env[PLUGIN_DEVELOPMENT_FLAG] = previous; + } +}); + +test('example-pack assets resolve to files in its folder', () => { + const { assets, directory, manifest } = getOfficialPlugin('example-pack', { includeDevelopment: true })!; + assert.equal(directory, path.join(PLUGINS_ROOT, 'example-pack')); + for (const file of [assets.manifest, assets.readme, assets.icon]) assert.ok(existsSync(file), file); + assert.deepEqual(assets.skills.map(s => s.name), ['example-notes']); + assert.ok(existsSync(path.join(assets.skills[0].path, 'SKILL.md'))); + assert.ok(existsSync(path.join(assets.automationTemplates[0].path, 'template.json'))); + const kinds = manifest.contributes.mcpServers.map(s => s.kind).sort(); + assert.deepEqual(kinds, ['https', 'package']); + const local = manifest.contributes.mcpServers.find(s => s.kind === 'package'); + assert.equal(local?.kind === 'package' && `${local.package}@${local.version}`, 'propr-fixture-mcp@1.0.0'); +}); + +test('released plugins are listed in registry order and an invalid manifest throws', async () => { + const root = path.join(scratch, 'root'); + await cp(path.join(PLUGINS_ROOT, 'example-pack'), path.join(root, 'example-pack'), { recursive: true }); + await writeFile(path.join(root, 'registry.json'), JSON.stringify({ version: 1, plugins: [{ id: 'example-pack' }] })); + assert.deepEqual(officialPlugins({ root, includeDevelopment: false }).map(p => p.manifest.id), ['example-pack']); + + const broken = path.join(scratch, 'broken'); + await cp(root, broken, { recursive: true }); + await writeFile(path.join(broken, 'example-pack', 'manifest.json'), JSON.stringify({ manifestVersion: 1, id: 'example-pack' })); + assert.throws(() => officialPlugins({ root: broken }), /example-pack\/manifest\.json: name:/); +}); diff --git a/packages/plugins/tsconfig.json b/packages/plugins/tsconfig.json new file mode 100644 index 0000000000..26ba901ab0 --- /dev/null +++ b/packages/plugins/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3da2b5cf63..867df5fb20 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -591,6 +591,7 @@ export * from './extensionProfiles.js'; export * from './extensionLibrary.js'; export * from './extensionPackages.js'; export * from './extensionOAuth.js'; +export * from './plugins.js'; export { sqliteTimestampMs } from './sqliteTimestamp.js'; export { AGENT_FAILURE_LINE_MAX_CHARS, agentFailureLine } from './agentFailureLine.js'; diff --git a/packages/shared/src/plugins.ts b/packages/shared/src/plugins.ts new file mode 100644 index 0000000000..51edc0c739 --- /dev/null +++ b/packages/shared/src/plugins.ts @@ -0,0 +1,325 @@ +import { z } from 'zod'; +import { EXTENSION_OAUTH_REGISTRATIONS } from './extensionOAuth.js'; +import { extensionServerNameSchema, remoteMcpSchema } from './extensionProfiles.js'; +import { extensionPackageEnvNameSchema, extensionPackageIntegritySchema, packageServerFields, EXACT_VERSION_PATTERN } from './extensionPackages.js'; + +/** + * Plugins as data. An official plugin is a folder `packages/plugins//` + * whose `manifest.json` describes everything ProPR can show without loading + * the plugin's code: what it is, the permissions and credentials it needs, + * its settings and what it contributes (MCP servers, skills, automation + * templates, commands, event subscriptions and webhooks). + * + * MCP server contributions reuse the extension library's item shapes, so a + * plugin can never declare a server the library itself would refuse. + */ +export const PLUGIN_MANIFEST_VERSION = 1 as const; +export const PLUGIN_CATEGORIES = ['messaging', 'issue-tracking', 'observability', 'deployment', 'context', 'other'] as const; +export type PluginCategory = typeof PLUGIN_CATEGORIES[number]; + +/** What a plugin's handlers may do. A closed set: anything not listed here cannot be requested. */ +export const PLUGIN_PERMISSIONS = [ + 'events.read', + 'tasks.read', 'tasks.create', + 'issues.read', 'issues.create', 'issues.comment', + 'pull_requests.read', 'pull_requests.comment', + 'repositories.read', + 'notifications.send', +] as const; +export type PluginPermission = typeof PLUGIN_PERMISSIONS[number]; +export const PLUGIN_PERMISSION_DESCRIPTIONS: Record = { + 'events.read': 'Receive ProPR task, pull request and automation events', + 'tasks.read': 'Read tasks and their status', + 'tasks.create': 'Start tasks', + 'issues.read': 'Read issues of the repositories it is enabled for', + 'issues.create': 'Create issues', + 'issues.comment': 'Comment on issues', + 'pull_requests.read': 'Read pull requests', + 'pull_requests.comment': 'Comment on pull requests', + 'repositories.read': 'List repositories ProPR manages', + 'notifications.send': 'Send ProPR notifications', +}; + +export const PLUGIN_CONNECTION_TYPES = ['oauth', 'token', 'signing_secret'] as const; +export type PluginConnectionType = typeof PLUGIN_CONNECTION_TYPES[number]; +export const PLUGIN_SETTING_TYPES = ['string', 'number', 'boolean', 'array', 'object'] as const; + +export const PLUGIN_LIMITS = { + permissions: PLUGIN_PERMISSIONS.length, connections: 16, settings: 64, outboundHosts: 32, + mcpServers: 16, skills: 16, automationTemplates: 16, commands: 32, subscriptions: 64, webhooks: 16, + enumValues: 64, listItems: 256, text: 500, +} as const; + +/** Folders of the registry package that can never be plugin ids. */ +export const RESERVED_PLUGIN_IDS = ['src', 'dist', 'test', 'node_modules', 'propr'] as const; +export const pluginIdSchema = z.string().max(48).regex(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/, 'Plugin ids are lowercase slugs such as my-plugin') + .refine(id => !(RESERVED_PLUGIN_IDS as readonly string[]).includes(id), 'This plugin id is reserved'); +/** Names of connections, settings, commands and webhooks inside one plugin. */ +const keySchema = z.string().regex(/^[a-z][a-z0-9_-]{0,47}$/, 'Use lowercase letters, digits, _ and -'); +/** Setting keys, the property names of the settings object. */ +const settingKeySchema = z.string().regex(/^[A-Za-z][A-Za-z0-9_]{0,47}$/, 'Use letters, digits and _, starting with a letter'); +const textSchema = (max: number = PLUGIN_LIMITS.text) => z.string().trim().min(1).max(max); +const httpsUrlSchema = z.url({ protocol: /^https$/, error: 'Enter an https:// URL' }).max(2048); +/** A file or folder inside the plugin folder: relative, forward slashes, no `.` or `..` segments. */ +export const pluginAssetPathSchema = z.string().max(256) + .regex(/^(?!\/)[A-Za-z0-9_.@-]+(?:\/[A-Za-z0-9_.@-]+)*$/, 'Use a relative path inside the plugin folder') + .refine(p => p.split('/').every(segment => segment !== '.' && segment !== '..'), 'Use a relative path inside the plugin folder'); +/** A DNS hostname: lowercase labels, at least two, no scheme, port, path, wildcard or IP literal. */ +export const pluginHostnameSchema = z.string().max(253) + .regex(/^(?=.*[a-z])(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/, 'Enter a hostname such as api.example.com'); + +const connectionBase = { name: keySchema, label: textSchema(100), help: textSchema(), helpUrl: httpsUrlSchema }; +export const pluginConnectionSchema = z.discriminatedUnion('type', [ + z.strictObject({ + type: z.literal('oauth'), ...connectionBase, + /** Registration methods allowed, in the order the extension OAuth sign-in tries them. */ + registrations: z.array(z.enum(EXTENSION_OAUTH_REGISTRATIONS)).min(1).max(EXTENSION_OAUTH_REGISTRATIONS.length).default([...EXTENSION_OAUTH_REGISTRATIONS]), + scopes: remoteMcpSchema.shape.scopes, + }), + z.strictObject({ type: z.literal('token'), ...connectionBase }), + z.strictObject({ type: z.literal('signing_secret'), ...connectionBase }), +]); +export type PluginConnection = z.infer; + +const settingBase = { label: textSchema(100), help: textSchema().optional() }; +const stringList = z.array(z.string().max(PLUGIN_LIMITS.text)).max(PLUGIN_LIMITS.listItems); +/** + * One setting, in a JSON Schema subset: a string (optionally an enum), a + * number, a boolean, a string list (`array` of `string`) or a repository map + * (`object` with `format: repository-map`, keyed by `owner/repo`). + */ +export const pluginSettingSchema = z.discriminatedUnion('type', [ + z.strictObject({ + type: z.literal('string'), ...settingBase, default: z.string().max(PLUGIN_LIMITS.text).optional(), + enum: z.array(z.string().min(1).max(100)).min(1).max(PLUGIN_LIMITS.enumValues).optional(), + maxLength: z.number().int().positive().max(PLUGIN_LIMITS.text).optional(), + }), + z.strictObject({ + type: z.literal('number'), ...settingBase, default: z.number().finite().optional(), + minimum: z.number().finite().optional(), maximum: z.number().finite().optional(), integer: z.boolean().optional(), + }), + z.strictObject({ type: z.literal('boolean'), ...settingBase, default: z.boolean().optional() }), + z.strictObject({ + type: z.literal('array'), ...settingBase, items: z.strictObject({ type: z.literal('string') }), + default: stringList.optional(), maxItems: z.number().int().positive().max(PLUGIN_LIMITS.listItems).optional(), + }), + z.strictObject({ + type: z.literal('object'), ...settingBase, format: z.literal('repository-map'), + additionalProperties: z.strictObject({ type: z.literal('string') }), + default: z.record(z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/), z.string().max(PLUGIN_LIMITS.text)).optional(), + }), +]); +export type PluginSetting = z.infer; +export const pluginSettingsSchema = z.strictObject({ + type: z.literal('object'), + properties: z.record(settingKeySchema, pluginSettingSchema), + required: z.array(settingKeySchema).default([]), +}); +export type PluginSettings = z.infer; + +/** + * A remote MCP server: the extension library's remote item without the + * per-instance fields. Its credential is one of the plugin's connections. + */ +export const pluginRemoteServerSchema = remoteMcpSchema.omit({ enabled: true, scopes: true, clientId: true }).extend({ + url: httpsUrlSchema, + /** Required unless `authentication` is `none`: a `token` connection for `bearer`, an `oauth` connection for `oauth`. */ + connection: keySchema.optional(), +}); +/** + * A local server from a pinned npm package: the extension library's package + * item, with the integrity value recorded in the manifest and each secret + * variable mapped to one of the plugin's `token` connections. + */ +export const pluginPackageServerSchema = z.strictObject({ + name: extensionServerNameSchema, tools: remoteMcpSchema.shape.tools, capability: z.enum(['read', 'write']), + ...packageServerFields, + integrity: z.string({ error: issue => (issue.input === undefined ? 'Record the integrity value (sha512-…) of the pinned package version' : undefined) }) + .pipe(extensionPackageIntegritySchema), + secretEnv: z.array(z.strictObject({ name: extensionPackageEnvNameSchema, connection: keySchema })).max(16).default([]), +}); +export const pluginMcpServerSchema = z.discriminatedUnion('kind', [pluginRemoteServerSchema, pluginPackageServerSchema]); +export type PluginMcpServer = z.infer; +export type PluginPackageServer = z.infer; + +/** A skill bundle source: a folder inside the plugin folder with a `SKILL.md`. */ +export const pluginSkillSchema = z.strictObject({ name: keySchema, path: pluginAssetPathSchema }); +/** An automation template folder in the shipped template format (`template.json` and `prompt.md`). */ +export const pluginAutomationTemplateSchema = z.strictObject({ id: keySchema, path: pluginAssetPathSchema }); +/** Accepted and validated now; ignored at runtime until commands land. */ +export const pluginCommandSchema = z.strictObject({ name: keySchema, description: textSchema(200), permissions: z.array(z.enum(PLUGIN_PERMISSIONS)).max(PLUGIN_LIMITS.permissions).default([]) }); +/** Accepted and validated now; ignored at runtime until subscriptions land. */ +export const pluginSubscriptionSchema = z.strictObject({ event: z.string().regex(/^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/, 'Use a dotted event name such as task.completed') }); +/** Accepted and validated now; ignored at runtime until webhooks land. Each webhook is verified with a `signing_secret` connection. */ +export const pluginWebhookSchema = z.strictObject({ name: keySchema, description: textSchema(200), connection: keySchema }); + +export const pluginContributesSchema = z.strictObject({ + mcpServers: z.array(pluginMcpServerSchema).max(PLUGIN_LIMITS.mcpServers).default([]), + skills: z.array(pluginSkillSchema).max(PLUGIN_LIMITS.skills).default([]), + automationTemplates: z.array(pluginAutomationTemplateSchema).max(PLUGIN_LIMITS.automationTemplates).default([]), + commands: z.array(pluginCommandSchema).max(PLUGIN_LIMITS.commands).default([]), + subscriptions: z.array(pluginSubscriptionSchema).max(PLUGIN_LIMITS.subscriptions).default([]), + webhooks: z.array(pluginWebhookSchema).max(PLUGIN_LIMITS.webhooks).default([]), +}); +export type PluginContributes = z.infer; + +const duplicates = (values: readonly string[]) => [...new Set(values.filter((value, index) => values.indexOf(value) !== index))]; +type Issue = (message: string, path: (string | number)[]) => void; + +function checkConnectionReferences(manifest: z.infer, issue: Issue) { + const connections = new Map(manifest.connections.map(c => [c.name, c.type])); + const expect = (name: string | undefined, type: PluginConnectionType, path: (string | number)[], what: string) => { + if (name === undefined) return issue(`${what} needs a ${type} connection`, path); + const actual = connections.get(name); + if (!actual) issue(`${what} references connection ${name}, which the plugin does not declare`, path); + else if (actual !== type) issue(`${what} references connection ${name}, which is ${actual}, not ${type}`, path); + }; + manifest.contributes.mcpServers.forEach((server, index) => { + const at = ['contributes', 'mcpServers', index]; + if (server.kind === 'https') { + if (server.authentication === 'none') { + if (server.connection !== undefined) issue(`Server ${server.name} has no authentication and takes no connection`, [...at, 'connection']); + } else expect(server.connection, server.authentication === 'oauth' ? 'oauth' : 'token', [...at, 'connection'], `Server ${server.name}`); + return; + } + server.secretEnv.forEach((secret, i) => expect(secret.connection, 'token', [...at, 'secretEnv', i, 'connection'], `Variable ${secret.name} of server ${server.name}`)); + const names = [...server.env.map(e => e.name), ...server.secretEnv.map(e => e.name)]; + for (const name of duplicates(names)) issue(`Variable ${name} of server ${server.name} is listed more than once`, [...at, 'env']); + }); + manifest.contributes.webhooks.forEach((webhook, index) => expect(webhook.connection, 'signing_secret', ['contributes', 'webhooks', index, 'connection'], `Webhook ${webhook.name}`)); +} + +type SettingProblem = { message: string; field?: string }; +type SettingOf = Extract; +const outOfRange = (value: number, min?: number, max?: number) => (min !== undefined && value < min) || (max !== undefined && value > max); + +function stringSettingProblems(key: string, setting: SettingOf<'string'>): SettingProblem[] { + const problems: SettingProblem[] = []; + if (setting.enum && duplicates(setting.enum).length) problems.push({ message: `Setting ${key} lists an enum value more than once`, field: 'enum' }); + if (setting.enum && setting.default !== undefined && !setting.enum.includes(setting.default)) problems.push({ message: `Default of setting ${key} is not one of its enum values`, field: 'default' }); + if (setting.maxLength !== undefined && (setting.default?.length ?? 0) > setting.maxLength) problems.push({ message: `Default of setting ${key} is longer than maxLength`, field: 'default' }); + return problems; +} + +function numberSettingProblems(key: string, setting: SettingOf<'number'>): SettingProblem[] { + const problems: SettingProblem[] = []; + if (setting.minimum !== undefined && setting.maximum !== undefined && setting.minimum > setting.maximum) problems.push({ message: `Setting ${key} has a minimum above its maximum` }); + const value = setting.default; + if (value !== undefined && (outOfRange(value, setting.minimum, setting.maximum) || (setting.integer && !Number.isInteger(value)))) problems.push({ message: `Default of setting ${key} is outside its range`, field: 'default' }); + return problems; +} + +/** Problems of one setting's own constraints and default. */ +function settingProblems(key: string, setting: PluginSetting): SettingProblem[] { + if (setting.type === 'string') return stringSettingProblems(key, setting); + if (setting.type === 'number') return numberSettingProblems(key, setting); + if (setting.type === 'array' && setting.maxItems !== undefined && (setting.default?.length ?? 0) > setting.maxItems) return [{ message: `Default of setting ${key} has more than maxItems entries`, field: 'default' }]; + return []; +} + +function checkSettings(settings: PluginSettings, issue: Issue) { + if (Object.keys(settings.properties).length > PLUGIN_LIMITS.settings) issue(`A plugin can define at most ${PLUGIN_LIMITS.settings} settings`, ['settings', 'properties']); + for (const key of settings.required) if (!Object.hasOwn(settings.properties, key)) issue(`Required setting ${key} is not defined`, ['settings', 'required']); + for (const key of duplicates(settings.required)) issue(`Setting ${key} is required more than once`, ['settings', 'required']); + for (const [key, setting] of Object.entries(settings.properties)) { + for (const { message, field } of settingProblems(key, setting)) issue(message, ['settings', 'properties', key, ...(field ? [field] : [])]); + } +} + +const pluginManifestObjectSchema = z.strictObject({ + manifestVersion: z.literal(PLUGIN_MANIFEST_VERSION), + id: pluginIdSchema, + name: textSchema(80), + description: textSchema(), + version: z.string().regex(EXACT_VERSION_PATTERN, 'Enter a semantic version such as 1.0.0'), + category: z.enum(PLUGIN_CATEGORIES), + homepage: httpsUrlSchema, + /** An image file in the plugin folder. */ + icon: pluginAssetPathSchema.regex(/\.(svg|png)$/, 'The icon must be an .svg or .png file'), + permissions: z.array(z.enum(PLUGIN_PERMISSIONS)).max(PLUGIN_LIMITS.permissions).default([]), + connections: z.array(pluginConnectionSchema).max(PLUGIN_LIMITS.connections).default([]), + settings: pluginSettingsSchema.default({ type: 'object', properties: {}, required: [] }), + /** Hostnames the plugin's handlers may call. */ + outboundHosts: z.array(pluginHostnameSchema).max(PLUGIN_LIMITS.outboundHosts).default([]), + contributes: pluginContributesSchema.default({ mcpServers: [], skills: [], automationTemplates: [], commands: [], subscriptions: [], webhooks: [] }), +}); + +/** The manifest of one plugin, with every cross-reference checked. */ +export const pluginManifestSchema = pluginManifestObjectSchema.superRefine((manifest, ctx) => { + const issue: Issue = (message, path) => ctx.addIssue({ code: 'custom', message, path }); + const unique = (values: string[], what: string, path: (string | number)[]) => { + for (const value of duplicates(values)) issue(`Duplicate ${what} ${value}`, path); + }; + unique(manifest.permissions, 'permission', ['permissions']); + unique(manifest.connections.map(c => c.name), 'connection name', ['connections']); + unique(manifest.outboundHosts, 'outbound host', ['outboundHosts']); + const { contributes } = manifest; + unique(contributes.mcpServers.map(s => s.name), 'server name', ['contributes', 'mcpServers']); + unique(contributes.skills.map(s => s.name), 'skill name', ['contributes', 'skills']); + unique(contributes.automationTemplates.map(t => t.id), 'automation template id', ['contributes', 'automationTemplates']); + unique(contributes.commands.map(c => c.name), 'command name', ['contributes', 'commands']); + unique(contributes.subscriptions.map(s => s.event), 'subscription', ['contributes', 'subscriptions']); + unique(contributes.webhooks.map(w => w.name), 'webhook name', ['contributes', 'webhooks']); + checkConnectionReferences(manifest, issue); + checkSettings(manifest.settings, issue); + contributes.commands.forEach((command, index) => { + const missing = command.permissions.filter(p => !manifest.permissions.includes(p)); + if (missing.length) issue(`Command ${command.name} uses permissions the plugin does not request: ${missing.join(', ')}`, ['contributes', 'commands', index, 'permissions']); + }); + if (contributes.subscriptions.length && !manifest.permissions.includes('events.read')) issue('Event subscriptions need the events.read permission', ['contributes', 'subscriptions']); +}); +export type PluginManifest = z.infer; +export type PluginManifestInput = z.input; + +/** Readable one-line messages for a failed manifest parse: `path: message`. */ +export function formatPluginManifestIssues(error: z.ZodError): string[] { + return error.issues.map(issue => (issue.path.length ? `${issue.path.join('.')}: ${issue.message}` : issue.message)); +} + +/** Validate a manifest: the parsed value, or readable messages. */ +export function parsePluginManifest(value: unknown): { ok: true; manifest: PluginManifest } | { ok: false; errors: string[] } { + const result = pluginManifestSchema.safeParse(value); + return result.success ? { ok: true, manifest: result.data } : { ok: false, errors: formatPluginManifestIssues(result.error) }; +} + +/** What the plugin page and API show about a contributed server: never launch arguments or environment values. */ +export type PublicPluginMcpServer = + | { kind: 'https'; name: string; url: string; tools: string[]; capability: 'read' | 'write'; authentication: 'none' | 'bearer' | 'oauth'; connection?: string } + | { kind: 'package'; name: string; ecosystem: string; package: string; version: string; tools: string[]; capability: 'read' | 'write'; secretEnv: { name: string; connection: string }[] }; +export interface PublicPluginManifest { + id: string; name: string; description: string; version: string; category: PluginCategory; homepage: string; + permissions: { name: PluginPermission; description: string }[]; + connections: PluginConnection[]; + settings: PluginSettings; + outboundHosts: string[]; + contributes: { + mcpServers: PublicPluginMcpServer[]; + skills: string[]; + automationTemplates: string[]; + commands: { name: string; description: string }[]; + subscriptions: string[]; + webhooks: { name: string; description: string }[]; + }; +} + +/** The public projection of a manifest: safe to show any signed-in user, independent of the plugin folder layout. */ +export function publicPluginManifest(manifest: PluginManifest): PublicPluginManifest { + const { contributes } = manifest; + return { + id: manifest.id, name: manifest.name, description: manifest.description, version: manifest.version, category: manifest.category, homepage: manifest.homepage, + permissions: manifest.permissions.map(name => ({ name, description: PLUGIN_PERMISSION_DESCRIPTIONS[name] })), + connections: manifest.connections.map(connection => ({ ...connection })), + settings: manifest.settings, + outboundHosts: [...manifest.outboundHosts], + contributes: { + mcpServers: contributes.mcpServers.map((server): PublicPluginMcpServer => (server.kind === 'https' + ? { kind: 'https', name: server.name, url: server.url, tools: [...server.tools], capability: server.capability, authentication: server.authentication, ...(server.connection ? { connection: server.connection } : {}) } + : { kind: 'package', name: server.name, ecosystem: server.ecosystem, package: server.package, version: server.version, tools: [...server.tools], capability: server.capability, secretEnv: server.secretEnv.map(s => ({ ...s })) })), + skills: contributes.skills.map(s => s.name), + automationTemplates: contributes.automationTemplates.map(t => t.id), + commands: contributes.commands.map(c => ({ name: c.name, description: c.description })), + subscriptions: contributes.subscriptions.map(s => s.event), + webhooks: contributes.webhooks.map(w => ({ name: w.name, description: w.description })), + }, + }; +} diff --git a/packages/shared/test/plugins.test.ts b/packages/shared/test/plugins.test.ts new file mode 100644 index 0000000000..5b0799460c --- /dev/null +++ b/packages/shared/test/plugins.test.ts @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { + PLUGIN_PERMISSIONS, parsePluginManifest, pluginManifestSchema, publicPluginManifest, + type PluginManifestInput, +} from '../src/plugins.js'; + +const INTEGRITY = `sha512-${'A'.repeat(86)}==`; +const valid = (): PluginManifestInput => ({ + manifestVersion: 1, id: 'acme-chat', name: 'Acme Chat', description: 'Posts ProPR updates to Acme Chat.', version: '1.2.0', + category: 'messaging', homepage: 'https://acme.example.com/propr', icon: 'icon.svg', + permissions: ['events.read', 'pull_requests.read'], + connections: [ + { type: 'oauth', name: 'workspace', label: 'Acme workspace', help: 'Sign in to the workspace.', helpUrl: 'https://acme.example.com/oauth' }, + { type: 'token', name: 'bot-token', label: 'Bot token', help: 'Create a bot token.', helpUrl: 'https://acme.example.com/tokens' }, + { type: 'signing_secret', name: 'events-secret', label: 'Signing secret', help: 'Copy the signing secret.', helpUrl: 'https://acme.example.com/apps' }, + ], + settings: { + type: 'object', + properties: { + channel: { type: 'string', label: 'Channel', default: 'general' }, + mode: { type: 'string', label: 'Mode', enum: ['all', 'failures'], default: 'all' }, + limit: { type: 'number', label: 'Limit', minimum: 1, maximum: 10, default: 5 }, + quiet: { type: 'boolean', label: 'Quiet', default: false }, + labels: { type: 'array', items: { type: 'string' }, label: 'Labels', default: ['bug'] }, + channels: { type: 'object', format: 'repository-map', additionalProperties: { type: 'string' }, label: 'Channels', default: { 'team/repo': 'dev' } }, + }, + required: ['channel'], + }, + outboundHosts: ['api.acme.example.com'], + contributes: { + mcpServers: [ + { kind: 'https', name: 'acme', url: 'https://mcp.acme.example.com/mcp', tools: ['search'], capability: 'read', authentication: 'oauth', connection: 'workspace' }, + { kind: 'https', name: 'acme-public', url: 'https://mcp.acme.example.com/public', tools: [], capability: 'read', authentication: 'none' }, + { kind: 'package', name: 'acme-local', ecosystem: 'npm', package: '@acme/mcp', version: '2.0.1', integrity: INTEGRITY, tools: ['post'], capability: 'write', + env: [{ name: 'ACME_REGION', value: 'eu' }], secretEnv: [{ name: 'ACME_TOKEN', connection: 'bot-token' }] }, + ], + skills: [{ name: 'acme-style', path: 'skills/acme-style' }], + automationTemplates: [{ id: 'acme-digest', path: 'templates/acme-digest' }], + commands: [{ name: 'notify', description: 'Post a message', permissions: ['pull_requests.read'] }], + subscriptions: [{ event: 'task.completed' }], + webhooks: [{ name: 'events', description: 'Acme event callbacks', connection: 'events-secret' }], + }, +}); +type Mutable = Record; +const variant = (change: (manifest: Mutable) => void) => { const manifest = structuredClone(valid()) as Mutable; change(manifest); return manifest; }; +const errors = (value: unknown) => { const result = parsePluginManifest(value); return result.ok ? [] : result.errors; }; +const rejects = (value: unknown, pattern: RegExp) => { + const messages = errors(value); + assert.ok(messages.some(m => pattern.test(m)), `expected ${pattern} in ${JSON.stringify(messages)}`); +}; + +test('a complete manifest parses, with defaults for omitted sections', () => { + const result = parsePluginManifest(valid()); + assert.ok(result.ok, JSON.stringify(result)); + assert.deepEqual(result.manifest.connections[0].type === 'oauth' && result.manifest.connections[0].registrations, ['metadata_document', 'dynamic', 'preregistered']); + const minimal = pluginManifestSchema.parse({ manifestVersion: 1, id: 'tiny', name: 'Tiny', description: 'Nothing.', version: '0.1.0', category: 'other', homepage: 'https://example.com', icon: 'icon.png' }); + assert.deepEqual(minimal.permissions, []); + assert.deepEqual(minimal.contributes.mcpServers, []); + assert.deepEqual(minimal.settings, { type: 'object', properties: {}, required: [] }); +}); + +test('the example-pack manifest in the registry parses', () => { + const manifest = JSON.parse(readFileSync(new URL('../../plugins/example-pack/manifest.json', import.meta.url), 'utf8')); + assert.deepEqual(errors(manifest), []); +}); + +test('bad ids, versions, categories and unknown fields are refused', () => { + for (const id of ['Acme', 'acme_chat', '-acme', 'acme--chat', 'a'.repeat(49), 'src', 'dist', '']) rejects(variant(m => { m.id = id; }), /^id:/); + rejects(variant(m => { m.version = '1.0'; }), /^version:/); + rejects(variant(m => { m.manifestVersion = 2; }), /^manifestVersion:/); + rejects(variant(m => { m.category = 'chat'; }), /^category:/); + rejects(variant(m => { m.homepage = 'http://acme.example.com'; }), /^homepage:/); + rejects(variant(m => { m.icon = '../icon.svg'; }), /^icon:/); + rejects(variant(m => { m.icon = 'icon.exe'; }), /^icon:/); + rejects(variant(m => { m.extra = true; }), /extra/); +}); + +test('permissions come from the closed list and are unique', () => { + assert.ok(PLUGIN_PERMISSIONS.includes('tasks.create')); + rejects(variant(m => { m.permissions.push('repositories.delete'); }), /^permissions\.2:/); + rejects(variant(m => { m.permissions.push('events.read'); }), /Duplicate permission events\.read/); + rejects(variant(m => { m.contributes.commands[0].permissions = ['issues.create']; }), /Command notify uses permissions the plugin does not request: issues\.create/); + rejects(variant(m => { m.permissions = ['pull_requests.read']; }), /Event subscriptions need the events\.read permission/); +}); + +test('contributions must reference declared connections of the right type', () => { + rejects(variant(m => { m.contributes.mcpServers[0].connection = 'missing'; }), /Server acme references connection missing, which the plugin does not declare/); + rejects(variant(m => { delete m.contributes.mcpServers[0].connection; }), /Server acme needs a oauth connection/); + rejects(variant(m => { m.contributes.mcpServers[0].connection = 'bot-token'; }), /which is token, not oauth/); + rejects(variant(m => { m.contributes.mcpServers[0].authentication = 'bearer'; }), /which is oauth, not token/); + rejects(variant(m => { m.contributes.mcpServers[1].connection = 'bot-token'; }), /no authentication and takes no connection/); + rejects(variant(m => { m.contributes.webhooks[0].connection = 'bot-token'; }), /Webhook events references connection bot-token, which is token, not signing_secret/); + rejects(variant(m => { m.contributes.mcpServers[2].secretEnv[0].connection = 'nowhere'; }), /Variable ACME_TOKEN of server acme-local references connection nowhere/); +}); + +test('secretEnv must map to a token connection', () => { + rejects(variant(m => { m.contributes.mcpServers[2].secretEnv[0].connection = 'workspace'; }), /Variable ACME_TOKEN of server acme-local references connection workspace, which is oauth, not token/); + rejects(variant(m => { m.contributes.mcpServers[2].secretEnv[0].connection = 'events-secret'; }), /which is signing_secret, not token/); + rejects(variant(m => { m.contributes.mcpServers[2].secretEnv = ['ACME_TOKEN']; }), /secretEnv/); + rejects(variant(m => { m.contributes.mcpServers[2].secretEnv.push({ name: 'ACME_REGION', connection: 'bot-token' }); }), /Variable ACME_REGION of server acme-local is listed more than once/); +}); + +test('duplicate names are refused in every section', () => { + rejects(variant(m => { m.connections.push({ ...m.connections[1] }); }), /Duplicate connection name bot-token/); + rejects(variant(m => { m.contributes.mcpServers[1].name = 'acme'; }), /Duplicate server name acme/); + rejects(variant(m => { m.contributes.skills.push({ name: 'acme-style', path: 'skills/other' }); }), /Duplicate skill name acme-style/); + rejects(variant(m => { m.contributes.automationTemplates.push({ id: 'acme-digest', path: 'x' }); }), /Duplicate automation template id acme-digest/); + rejects(variant(m => { m.contributes.commands.push({ name: 'notify', description: 'Again' }); }), /Duplicate command name notify/); + rejects(variant(m => { m.contributes.webhooks.push({ ...m.contributes.webhooks[0] }); }), /Duplicate webhook name events/); + rejects(variant(m => { m.contributes.subscriptions.push({ event: 'task.completed' }); }), /Duplicate subscription task\.completed/); + rejects(variant(m => { m.outboundHosts.push('api.acme.example.com'); }), /Duplicate outbound host/); +}); + +test('outbound hosts must be hostnames', () => { + for (const host of ['https://api.acme.example.com', 'api.acme.example.com:443', 'api.acme.example.com/path', '*.acme.example.com', '10.0.0.1', 'localhost', 'API.example.com', 'a..b.com', '-a.example.com']) { + rejects(variant(m => { m.outboundHosts = [host]; }), /^outboundHosts\.0: Enter a hostname/); + } +}); + +test('package servers need an exact version and a recorded integrity value', () => { + const pkg = (change: (server: Mutable) => void) => variant(m => change(m.contributes.mcpServers[2])); + for (const version of ['^2.0.0', '~2.0.1', '2.x', '>=2.0.0', '2.0.1 - 2.1.0', '*']) rejects(pkg(s => { s.version = version; }), /^contributes\.mcpServers\.2\.version: Enter an exact version/); + for (const version of ['latest', 'next', 'beta']) rejects(pkg(s => { s.version = version; }), /^contributes\.mcpServers\.2\.version: Enter an exact version/); + rejects(pkg(s => { delete s.integrity; }), /^contributes\.mcpServers\.2\.integrity: Record the integrity value/); + rejects(pkg(s => { s.integrity = 'sha1-abc'; }), /^contributes\.mcpServers\.2\.integrity:/); + for (const name of ['./server', '../mcp', '/opt/mcp', 'file:mcp.tgz', 'git+https://example.com/mcp.git', 'https://example.com/mcp.tgz']) rejects(pkg(s => { s.package = name; }), /^contributes\.mcpServers\.2\.package: Enter an npm package name/); + rejects(pkg(s => { s.command = 'node server.js'; }), /command/); + rejects(pkg(s => { s.path = '/opt/server'; }), /path/); + rejects(pkg(s => { s.image = 'acme/mcp:latest'; }), /image/); + rejects(pkg(s => { s.ecosystem = 'pypi'; }), /^contributes\.mcpServers\.2\.ecosystem:/); + rejects(pkg(s => { s.env = [{ name: 'PATH', value: '/tmp' }]; }), /reserved/); +}); + +test('remote servers need an https endpoint', () => { + rejects(variant(m => { m.contributes.mcpServers[0].url = 'http://mcp.acme.example.com/mcp'; }), /^contributes\.mcpServers\.0\.url: Enter an https:\/\/ URL/); + rejects(variant(m => { m.contributes.mcpServers[0].kind = 'catalog'; }), /^contributes\.mcpServers\.0/); +}); + +test('settings follow the JSON Schema subset', () => { + rejects(variant(m => { m.settings.required.push('missing'); }), /Required setting missing is not defined/); + rejects(variant(m => { m.settings.properties.mode.default = 'some'; }), /Default of setting mode is not one of its enum values/); + rejects(variant(m => { m.settings.properties.limit.default = 11; }), /Default of setting limit is outside its range/); + rejects(variant(m => { m.settings.properties.limit.minimum = 20; }), /minimum above its maximum/); + rejects(variant(m => { m.settings.properties.quiet.default = 'no'; }), /settings\.properties\.quiet\.default/); + rejects(variant(m => { m.settings.properties.labels.items = { type: 'number' }; }), /settings\.properties\.labels\.items/); + rejects(variant(m => { m.settings.properties.channels.default = { 'not a repo': 'x' }; }), /settings\.properties\.channels\.default/); + rejects(variant(m => { m.settings.properties.when = { type: 'date', label: 'When' }; }), /settings\.properties\.when/); +}); + +test('the public projection leaves out package launch details', () => { + const projection = publicPluginManifest(pluginManifestSchema.parse(valid())); + assert.deepEqual(projection.permissions.map(p => p.name), ['events.read', 'pull_requests.read']); + assert.ok(projection.permissions.every(p => p.description.length > 0)); + const local = projection.contributes.mcpServers.find(s => s.name === 'acme-local')!; + assert.deepEqual(local, { kind: 'package', name: 'acme-local', ecosystem: 'npm', package: '@acme/mcp', version: '2.0.1', tools: ['post'], capability: 'write', secretEnv: [{ name: 'ACME_TOKEN', connection: 'bot-token' }] }); + assert.equal(JSON.stringify(projection).includes(INTEGRITY), false); + assert.equal(JSON.stringify(projection).includes('ACME_REGION'), false); + assert.deepEqual(projection.contributes.skills, ['acme-style']); + assert.deepEqual(projection.contributes.commands, [{ name: 'notify', description: 'Post a message' }]); +}); diff --git a/scripts/check-plugins.mjs b/scripts/check-plugins.mjs new file mode 100644 index 0000000000..3ce3e99fd7 --- /dev/null +++ b/scripts/check-plugins.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env tsx +/** + * Validates the official plugin registry in packages/plugins: + * + * - registry.json lists every plugin folder exactly once, and only those; + * - every manifest.json passes the plugin manifest schema of @propr/shared + * (which also checks connection references and exact package pins) and + * its id matches its folder; + * - command names are unique across all plugins; + * - the README, the icon, every skill folder (with a SKILL.md naming the + * skill) and every automation template folder exist, and the templates + * pass the automation template contract. + * + * `npm run plugins:check` runs it; CI runs it on every pull request. + * `--online` (never run in CI) also compares each package server's recorded + * integrity value with the npm registry (`--registry ` or + * `npm_config_registry`, default https://registry.npmjs.org). `--root ` + * checks another registry folder, for the script's own tests. + * + * Runs under tsx: it imports the TypeScript sources so no build is needed. + */ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { RESERVED_PLUGIN_IDS, parsePluginManifest, pluginIdSchema } from '../packages/shared/src/plugins.js'; +import { validateAutomationTemplate } from '../packages/shared/src/automationTemplateContract.js'; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +export const PLUGINS_ROOT = path.join(repositoryRoot, 'packages/plugins'); +export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'; + +const isFile = file => existsSync(file) && statSync(file).isFile(); +const isDirectory = file => existsSync(file) && statSync(file).isDirectory(); +const readJson = file => JSON.parse(readFileSync(file, 'utf8')); +const asset = (directory, relative) => path.join(directory, ...relative.split('/')); + +/** Entries of registry.json, or the errors that make it unusable. */ +function readIndex(root, errors) { + const file = path.join(root, 'registry.json'); + if (!isFile(file)) { errors.push('registry.json is missing'); return []; } + let document; + try { document = readJson(file); } catch (error) { errors.push(`registry.json is not valid JSON: ${error.message}`); return []; } + if (document?.version !== 1) errors.push('registry.json: version must be 1'); + if (!Array.isArray(document?.plugins)) { errors.push('registry.json: plugins must be a list'); return []; } + const entries = []; + document.plugins.forEach((entry, index) => { + const keys = entry && typeof entry === 'object' ? Object.keys(entry) : null; + if (!keys || keys.some(key => key !== 'id' && key !== 'development') || !pluginIdSchema.safeParse(entry.id).success + || (entry.development !== undefined && typeof entry.development !== 'boolean')) { + errors.push(`registry.json: plugins.${index} must be { "id": "", "development"?: true | false }`); + return; + } + if (entries.some(e => e.id === entry.id)) errors.push(`registry.json: plugin ${entry.id} is listed more than once`); + else entries.push({ id: entry.id, development: entry.development === true }); + }); + return entries; +} + +/** The `name:` of a SKILL.md front matter, or null without one. */ +function skillName(markdown) { + const front = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(markdown); + if (!front) return null; + const name = /^name:\s*["']?([^"'\r\n]+?)["']?\s*$/m.exec(front[1]); + return name && /^description:\s*\S/m.test(front[1]) ? name[1] : null; +} + +function checkAssets(id, directory, manifest, errors) { + if (!isFile(asset(directory, manifest.icon))) errors.push(`${id}: icon ${manifest.icon} does not exist in the plugin folder`); + for (const skill of manifest.contributes.skills) { + const folder = asset(directory, skill.path); + if (!isDirectory(folder)) { errors.push(`${id}: skill ${skill.name}: folder ${skill.path} does not exist`); continue; } + const file = path.join(folder, 'SKILL.md'); + if (!isFile(file)) { errors.push(`${id}: skill ${skill.name}: ${skill.path}/SKILL.md does not exist`); continue; } + const name = skillName(readFileSync(file, 'utf8')); + if (name === null) errors.push(`${id}: skill ${skill.name}: ${skill.path}/SKILL.md needs front matter with name and description`); + else if (name !== skill.name) errors.push(`${id}: skill ${skill.name}: SKILL.md is named ${name}`); + } + for (const template of manifest.contributes.automationTemplates) { + const folder = asset(directory, template.path); + const metadataFile = path.join(folder, 'template.json'); + const promptFile = path.join(folder, 'prompt.md'); + if (!isFile(metadataFile) || !isFile(promptFile)) { errors.push(`${id}: automation template ${template.id}: ${template.path} needs template.json and prompt.md`); continue; } + let metadata; + try { metadata = readJson(metadataFile); } catch (error) { errors.push(`${id}: automation template ${template.id}: template.json is not valid JSON: ${error.message}`); continue; } + if (metadata && typeof metadata === 'object' && 'prompt' in metadata) { errors.push(`${id}: automation template ${template.id}: the prompt belongs in prompt.md`); continue; } + if (metadata?.id !== template.id) errors.push(`${id}: automation template ${template.id}: template.json id must be ${template.id}`); + const error = validateAutomationTemplate({ ...metadata, prompt: readFileSync(promptFile, 'utf8').trim() }); + if (error) errors.push(`${id}: automation template ${template.id}: ${error}`); + } +} + +/** + * Check a registry folder. Returns every problem found, so one run reports + * them all, and the plugins whose manifests parsed. + */ +export function checkPlugins({ root = PLUGINS_ROOT } = {}) { + const errors = []; + const entries = readIndex(root, errors); + const folders = existsSync(root) ? readdirSync(root, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && !RESERVED_PLUGIN_IDS.includes(entry.name)) + .map(entry => entry.name) : []; + for (const folder of folders) if (!entries.some(e => e.id === folder)) errors.push(`${folder}: folder is not listed in registry.json`); + + const plugins = []; + const commands = new Map(); + for (const entry of entries) { + const directory = path.join(root, entry.id); + const readme = path.join(directory, 'README.md'); + if (isDirectory(directory) && (!isFile(readme) || !readFileSync(readme, 'utf8').trim())) errors.push(`${entry.id}: README.md is missing or empty`); + const file = path.join(directory, 'manifest.json'); + if (!isFile(file)) { errors.push(`${entry.id}: manifest.json does not exist`); continue; } + let value; + try { value = readJson(file); } catch (error) { errors.push(`${entry.id}/manifest.json is not valid JSON: ${error.message}`); continue; } + const result = parsePluginManifest(value); + if (!result.ok) { for (const message of result.errors) errors.push(`${entry.id}/manifest.json: ${message}`); continue; } + const { manifest } = result; + if (manifest.id !== entry.id) errors.push(`${entry.id}/manifest.json: id ${manifest.id} must match the folder name ${entry.id}`); + checkAssets(entry.id, directory, manifest, errors); + for (const command of manifest.contributes.commands) { + const owner = commands.get(command.name); + if (owner && owner !== entry.id) errors.push(`${entry.id}: command ${command.name} is also contributed by ${owner}`); + else commands.set(command.name, entry.id); + } + plugins.push({ ...entry, directory, manifest }); + } + return { errors, plugins }; +} + +/** Compare each package server's recorded integrity with the registry's. Returns problems. */ +export async function checkPackageIntegrity(plugins, { registry = DEFAULT_NPM_REGISTRY, fetch: fetchImpl = globalThis.fetch } = {}) { + const errors = []; + for (const plugin of plugins) { + for (const server of plugin.manifest.contributes.mcpServers) { + if (server.kind !== 'package') continue; + const label = `${plugin.id}: server ${server.name} (${server.package}@${server.version})`; + const url = `${registry.replace(/\/+$/, '')}/${server.package.replace('/', '%2f')}/${encodeURIComponent(server.version)}`; + try { + const response = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(15_000) }); + if (response.status === 404) { errors.push(`${label}: not found on ${registry}`); continue; } + if (!response.ok) { errors.push(`${label}: registry answered ${response.status}`); continue; } + const integrity = (await response.json())?.dist?.integrity; + if (integrity !== server.integrity) errors.push(`${label}: recorded integrity does not match the registry (${integrity ?? 'none'})`); + } catch (error) { + errors.push(`${label}: registry request failed: ${error.message}`); + } + } + } + return errors; +} + +export function parseArguments(argv) { + const options = { root: PLUGINS_ROOT, online: false, registry: process.env.npm_config_registry || DEFAULT_NPM_REGISTRY }; + for (let i = 0; i < argv.length; i++) { + const argument = argv[i]; + const value = () => { + const next = argv[++i]; + if (next === undefined) throw new Error(`${argument} needs a value`); + return next; + }; + if (argument === '--online') options.online = true; + else if (argument === '--root') options.root = path.resolve(value()); + else if (argument === '--registry') options.registry = value(); + else throw new Error(`Unknown option ${argument}`); + } + return options; +} + +async function main(argv) { + const options = parseArguments(argv); + const { errors, plugins } = checkPlugins({ root: options.root }); + if (options.online && !errors.length) errors.push(...await checkPackageIntegrity(plugins, { registry: options.registry })); + if (errors.length) { + console.error(`Plugin check failed (${errors.length} problem${errors.length === 1 ? '' : 's'}):`); + for (const error of errors) console.error(` - ${error}`); + process.exitCode = 1; + return; + } + console.log(`${plugins.length} plugin${plugins.length === 1 ? '' : 's'} checked${options.online ? ' (with registry integrity)' : ''}`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(process.argv.slice(2)); diff --git a/test/checkPlugins.test.mjs b/test/checkPlugins.test.mjs new file mode 100644 index 0000000000..105450e03f --- /dev/null +++ b/test/checkPlugins.test.mjs @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { cp, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { checkPackageIntegrity, checkPlugins, parseArguments } from '../scripts/check-plugins.mjs'; + +const run = promisify(execFile); +const FIXTURES = fileURLToPath(new URL('./fixtures/plugins-check/', import.meta.url)); +const SCRIPT = fileURLToPath(new URL('../scripts/check-plugins.mjs', import.meta.url)); +const scratch = await mkdtemp(join(tmpdir(), 'propr-plugins-check-')); +after(() => rm(scratch, { recursive: true, force: true })); + +let counter = 0; +/** A fresh copy of the valid fixture registry, optionally changed. */ +async function fixtureRoot(change = async () => {}) { + const root = join(scratch, `root-${++counter}`); + await cp(join(FIXTURES, 'valid'), root, { recursive: true }); + await change(root, join(root, 'fixture-pack')); + return root; +} +const manifestPath = plugin => join(plugin, 'manifest.json'); +const editManifest = async (plugin, edit) => { + const manifest = JSON.parse(await readFile(manifestPath(plugin), 'utf8')); + edit(manifest); + await writeFile(manifestPath(plugin), JSON.stringify(manifest)); +}; +const expectError = (root, pattern) => { + const { errors } = checkPlugins({ root }); + assert.ok(errors.some(error => pattern.test(error)), `expected ${pattern} in ${JSON.stringify(errors)}`); +}; + +test('the official registry passes', () => { + const { errors, plugins } = checkPlugins(); + assert.deepEqual(errors, []); + assert.ok(plugins.some(plugin => plugin.id === 'example-pack' && plugin.development)); +}); + +test('the valid fixture registry passes', async () => { + assert.deepEqual(checkPlugins({ root: await fixtureRoot() }).errors, []); +}); + +const MANIFEST_CASES = { + 'bad-id': /fixture-pack\/manifest\.json: id: Plugin ids are lowercase slugs/, + 'unknown-permission': /fixture-pack\/manifest\.json: permissions\.1: /, + 'missing-connection': /Server remote references connection nowhere, which the plugin does not declare/, + 'duplicate-server-name': /Duplicate server name remote/, + 'host-not-hostname': /outboundHosts\.0: Enter a hostname such as api\.example\.com/, + 'package-range': /contributes\.mcpServers\.1\.version: Enter an exact version such as 1\.2\.3; ranges and tags are not accepted/, + 'package-tag': /contributes\.mcpServers\.1\.version: Enter an exact version/, + 'package-missing-integrity': /contributes\.mcpServers\.1\.integrity: Record the integrity value/, + 'package-path': /contributes\.mcpServers\.1\.package: Enter an npm package name/, + 'package-command': /contributes\.mcpServers\.1: .*command/, + 'secret-env-not-token': /Variable FIXTURE_TOKEN of server local references connection account, which is oauth, not token/, +}; + +test('every broken fixture manifest has an expected message', async () => { + const files = (await readdir(join(FIXTURES, 'manifests'))).map(file => file.replace(/\.json$/, '')).sort(); + assert.deepEqual(files, Object.keys(MANIFEST_CASES).sort()); +}); + +for (const [name, pattern] of Object.entries(MANIFEST_CASES)) { + test(`broken manifest ${name} fails with a clear message`, async () => { + const root = await fixtureRoot((_, plugin) => cp(join(FIXTURES, 'manifests', `${name}.json`), manifestPath(plugin))); + expectError(root, pattern); + }); +} + +test('missing README, icon, skill and template files are reported', async () => { + expectError(await fixtureRoot((_, plugin) => rm(join(plugin, 'README.md'))), /^fixture-pack: README\.md is missing or empty$/); + expectError(await fixtureRoot((_, plugin) => rm(join(plugin, 'icon.svg'))), /^fixture-pack: icon icon\.svg does not exist in the plugin folder$/); + expectError(await fixtureRoot((_, plugin) => rm(join(plugin, 'skills'), { recursive: true })), /skill fixture-skill: folder skills\/fixture-skill does not exist/); + expectError(await fixtureRoot((_, plugin) => rm(join(plugin, 'skills/fixture-skill/SKILL.md'))), /skills\/fixture-skill\/SKILL\.md does not exist/); + expectError(await fixtureRoot((_, plugin) => writeFile(join(plugin, 'skills/fixture-skill/SKILL.md'), '---\nname: other\ndescription: x\n---\n')), /skill fixture-skill: SKILL\.md is named other/); + expectError(await fixtureRoot((_, plugin) => rm(join(plugin, 'templates/fixture-report/prompt.md'))), /automation template fixture-report: templates\/fixture-report needs template\.json and prompt\.md/); + expectError(await fixtureRoot(async (_, plugin) => { + const file = join(plugin, 'templates/fixture-report/template.json'); + await writeFile(file, (await readFile(file, 'utf8')).replace('"dry_run"', '"auto"')); + }), /automation template fixture-report: fixture-report: autonomy must be dry_run/); +}); + +test('registry and folder mismatches are reported', async () => { + expectError(await fixtureRoot(root => cp(join(root, 'fixture-pack'), join(root, 'stray-pack'), { recursive: true })), /^stray-pack: folder is not listed in registry\.json$/); + expectError(await fixtureRoot(root => writeFile(join(root, 'registry.json'), JSON.stringify({ version: 1, plugins: [{ id: 'fixture-pack' }, { id: 'absent' }] }))), /^absent: manifest\.json does not exist$/); + expectError(await fixtureRoot(root => writeFile(join(root, 'registry.json'), JSON.stringify({ version: 1, plugins: [{ id: 'fixture-pack' }, { id: 'fixture-pack' }] }))), /plugin fixture-pack is listed more than once/); + expectError(await fixtureRoot(root => writeFile(join(root, 'registry.json'), JSON.stringify({ version: 1, plugins: [{ id: 'fixture-pack', hidden: true }] }))), /plugins\.0 must be/); + expectError(await fixtureRoot((_, plugin) => editManifest(plugin, m => { m.id = 'other-pack'; })), /id other-pack must match the folder name fixture-pack/); +}); + +test('command names must be unique across plugins', async () => { + const root = await fixtureRoot(async root => { + await cp(join(root, 'fixture-pack'), join(root, 'second-pack'), { recursive: true }); + await editManifest(join(root, 'second-pack'), m => { m.id = 'second-pack'; }); + await writeFile(join(root, 'registry.json'), JSON.stringify({ version: 1, plugins: [{ id: 'fixture-pack' }, { id: 'second-pack' }] })); + }); + expectError(root, /^second-pack: command status is also contributed by fixture-pack$/); +}); + +test('the command line exits 0 on success and 1 with every problem listed', async () => { + const ok = await run(process.execPath, ['--import', 'tsx', SCRIPT, '--root', await fixtureRoot()]); + assert.match(ok.stdout, /1 plugin checked/); + const broken = await fixtureRoot(async (_, plugin) => { + await rm(join(plugin, 'README.md')); + await cp(join(FIXTURES, 'manifests', 'package-range.json'), manifestPath(plugin)); + }); + await assert.rejects(run(process.execPath, ['--import', 'tsx', SCRIPT, '--root', broken]), error => { + assert.equal(error.code, 1); + assert.match(error.stderr, /Plugin check failed \(2 problems\)/); + assert.match(error.stderr, /fixture-pack: README\.md is missing or empty/); + assert.match(error.stderr, /ranges and tags are not accepted/); + return true; + }); +}); + +test('--online compares recorded integrity values with the registry', async () => { + const { plugins } = checkPlugins({ root: await fixtureRoot() }); + const recorded = plugins[0].manifest.contributes.mcpServers[1].integrity; + const requests = []; + const registry = answers => async url => { + requests.push(url); + const answer = answers.shift(); + return new Response(answer === 404 ? '{}' : JSON.stringify({ dist: { integrity: answer } }), { status: answer === 404 ? 404 : 200 }); + }; + assert.deepEqual(await checkPackageIntegrity(plugins, { registry: 'https://registry.test/', fetch: registry([recorded]) }), []); + assert.deepEqual(requests, ['https://registry.test/propr-fixture-mcp/1.0.0']); + const [mismatch] = await checkPackageIntegrity(plugins, { registry: 'https://registry.test', fetch: registry([`sha512-${'B'.repeat(86)}==`]) }); + assert.match(mismatch, /^fixture-pack: server local \(propr-fixture-mcp@1\.0\.0\): recorded integrity does not match the registry/); + const [missing] = await checkPackageIntegrity(plugins, { registry: 'https://registry.test', fetch: registry([404]) }); + assert.match(missing, /not found on https:\/\/registry\.test/); +}); + +test('arguments', () => { + assert.equal(parseArguments(['--online']).online, true); + assert.equal(parseArguments(['--registry', 'https://r.test']).registry, 'https://r.test'); + assert.throws(() => parseArguments(['--bogus']), /Unknown option --bogus/); + assert.throws(() => parseArguments(['--root']), /--root needs a value/); +}); diff --git a/test/fixtures/plugins-check/manifests/bad-id.json b/test/fixtures/plugins-check/manifests/bad-id.json new file mode 100644 index 0000000000..62b7211e2a --- /dev/null +++ b/test/fixtures/plugins-check/manifests/bad-id.json @@ -0,0 +1,88 @@ +{ + "manifestVersion": 1, + "id": "Fixture_Pack", + "name": "Fixture pack", + "description": "A valid plugin the plugins:check tests break one way at a time.", + "version": "1.0.0", + "category": "other", + "homepage": "https://example.com/fixture-pack", + "icon": "icon.svg", + "permissions": [ + "events.read" + ], + "connections": [ + { + "type": "oauth", + "name": "account", + "label": "Account", + "help": "Sign in.", + "helpUrl": "https://example.com/oauth" + }, + { + "type": "token", + "name": "api-token", + "label": "API token", + "help": "Paste a token.", + "helpUrl": "https://example.com/tokens" + } + ], + "outboundHosts": [ + "api.example.com" + ], + "contributes": { + "mcpServers": [ + { + "kind": "https", + "name": "remote", + "url": "https://mcp.example.com/mcp", + "tools": [ + "search" + ], + "capability": "read", + "authentication": "oauth", + "connection": "account" + }, + { + "kind": "package", + "name": "local", + "ecosystem": "npm", + "package": "propr-fixture-mcp", + "version": "1.0.0", + "integrity": "sha512-Y5GxTexuXT5IDhwgPcZokvnPvvRorDMdNHS83U59b5wquM5QTMmIhfeQ1y7AEbvbSmiJgjcJrok2q2mbzPRTvw==", + "secretEnv": [ + { + "name": "FIXTURE_TOKEN", + "connection": "api-token" + } + ], + "tools": [ + "echo" + ], + "capability": "read" + } + ], + "skills": [ + { + "name": "fixture-skill", + "path": "skills/fixture-skill" + } + ], + "automationTemplates": [ + { + "id": "fixture-report", + "path": "templates/fixture-report" + } + ], + "commands": [ + { + "name": "status", + "description": "Show the status" + } + ], + "subscriptions": [ + { + "event": "task.completed" + } + ] + } +} diff --git a/test/fixtures/plugins-check/manifests/duplicate-server-name.json b/test/fixtures/plugins-check/manifests/duplicate-server-name.json new file mode 100644 index 0000000000..9cabe673db --- /dev/null +++ b/test/fixtures/plugins-check/manifests/duplicate-server-name.json @@ -0,0 +1,88 @@ +{ + "manifestVersion": 1, + "id": "fixture-pack", + "name": "Fixture pack", + "description": "A valid plugin the plugins:check tests break one way at a time.", + "version": "1.0.0", + "category": "other", + "homepage": "https://example.com/fixture-pack", + "icon": "icon.svg", + "permissions": [ + "events.read" + ], + "connections": [ + { + "type": "oauth", + "name": "account", + "label": "Account", + "help": "Sign in.", + "helpUrl": "https://example.com/oauth" + }, + { + "type": "token", + "name": "api-token", + "label": "API token", + "help": "Paste a token.", + "helpUrl": "https://example.com/tokens" + } + ], + "outboundHosts": [ + "api.example.com" + ], + "contributes": { + "mcpServers": [ + { + "kind": "https", + "name": "remote", + "url": "https://mcp.example.com/mcp", + "tools": [ + "search" + ], + "capability": "read", + "authentication": "oauth", + "connection": "account" + }, + { + "kind": "package", + "name": "remote", + "ecosystem": "npm", + "package": "propr-fixture-mcp", + "version": "1.0.0", + "integrity": "sha512-Y5GxTexuXT5IDhwgPcZokvnPvvRorDMdNHS83U59b5wquM5QTMmIhfeQ1y7AEbvbSmiJgjcJrok2q2mbzPRTvw==", + "secretEnv": [ + { + "name": "FIXTURE_TOKEN", + "connection": "api-token" + } + ], + "tools": [ + "echo" + ], + "capability": "read" + } + ], + "skills": [ + { + "name": "fixture-skill", + "path": "skills/fixture-skill" + } + ], + "automationTemplates": [ + { + "id": "fixture-report", + "path": "templates/fixture-report" + } + ], + "commands": [ + { + "name": "status", + "description": "Show the status" + } + ], + "subscriptions": [ + { + "event": "task.completed" + } + ] + } +} diff --git a/test/fixtures/plugins-check/manifests/host-not-hostname.json b/test/fixtures/plugins-check/manifests/host-not-hostname.json new file mode 100644 index 0000000000..95542f8d45 --- /dev/null +++ b/test/fixtures/plugins-check/manifests/host-not-hostname.json @@ -0,0 +1,88 @@ +{ + "manifestVersion": 1, + "id": "fixture-pack", + "name": "Fixture pack", + "description": "A valid plugin the plugins:check tests break one way at a time.", + "version": "1.0.0", + "category": "other", + "homepage": "https://example.com/fixture-pack", + "icon": "icon.svg", + "permissions": [ + "events.read" + ], + "connections": [ + { + "type": "oauth", + "name": "account", + "label": "Account", + "help": "Sign in.", + "helpUrl": "https://example.com/oauth" + }, + { + "type": "token", + "name": "api-token", + "label": "API token", + "help": "Paste a token.", + "helpUrl": "https://example.com/tokens" + } + ], + "outboundHosts": [ + "https://api.example.com/v1" + ], + "contributes": { + "mcpServers": [ + { + "kind": "https", + "name": "remote", + "url": "https://mcp.example.com/mcp", + "tools": [ + "search" + ], + "capability": "read", + "authentication": "oauth", + "connection": "account" + }, + { + "kind": "package", + "name": "local", + "ecosystem": "npm", + "package": "propr-fixture-mcp", + "version": "1.0.0", + "integrity": "sha512-Y5GxTexuXT5IDhwgPcZokvnPvvRorDMdNHS83U59b5wquM5QTMmIhfeQ1y7AEbvbSmiJgjcJrok2q2mbzPRTvw==", + "secretEnv": [ + { + "name": "FIXTURE_TOKEN", + "connection": "api-token" + } + ], + "tools": [ + "echo" + ], + "capability": "read" + } + ], + "skills": [ + { + "name": "fixture-skill", + "path": "skills/fixture-skill" + } + ], + "automationTemplates": [ + { + "id": "fixture-report", + "path": "templates/fixture-report" + } + ], + "commands": [ + { + "name": "status", + "description": "Show the status" + } + ], + "subscriptions": [ + { + "event": "task.completed" + } + ] + } +} diff --git a/test/fixtures/plugins-check/manifests/missing-connection.json b/test/fixtures/plugins-check/manifests/missing-connection.json new file mode 100644 index 0000000000..654a7e11d2 --- /dev/null +++ b/test/fixtures/plugins-check/manifests/missing-connection.json @@ -0,0 +1,88 @@ +{ + "manifestVersion": 1, + "id": "fixture-pack", + "name": "Fixture pack", + "description": "A valid plugin the plugins:check tests break one way at a time.", + "version": "1.0.0", + "category": "other", + "homepage": "https://example.com/fixture-pack", + "icon": "icon.svg", + "permissions": [ + "events.read" + ], + "connections": [ + { + "type": "oauth", + "name": "account", + "label": "Account", + "help": "Sign in.", + "helpUrl": "https://example.com/oauth" + }, + { + "type": "token", + "name": "api-token", + "label": "API token", + "help": "Paste a token.", + "helpUrl": "https://example.com/tokens" + } + ], + "outboundHosts": [ + "api.example.com" + ], + "contributes": { + "mcpServers": [ + { + "kind": "https", + "name": "remote", + "url": "https://mcp.example.com/mcp", + "tools": [ + "search" + ], + "capability": "read", + "authentication": "oauth", + "connection": "nowhere" + }, + { + "kind": "package", + "name": "local", + "ecosystem": "npm", + "package": "propr-fixture-mcp", + "version": "1.0.0", + "integrity": "sha512-Y5GxTexuXT5IDhwgPcZokvnPvvRorDMdNHS83U59b5wquM5QTMmIhfeQ1y7AEbvbSmiJgjcJrok2q2mbzPRTvw==", + "secretEnv": [ + { + "name": "FIXTURE_TOKEN", + "connection": "api-token" + } + ], + "tools": [ + "echo" + ], + "capability": "read" + } + ], + "skills": [ + { + "name": "fixture-skill", + "path": "skills/fixture-skill" + } + ], + "automationTemplates": [ + { + "id": "fixture-report", + "path": "templates/fixture-report" + } + ], + "commands": [ + { + "name": "status", + "description": "Show the status" + } + ], + "subscriptions": [ + { + "event": "task.completed" + } + ] + } +} diff --git a/test/fixtures/plugins-check/manifests/package-command.json b/test/fixtures/plugins-check/manifests/package-command.json new file mode 100644 index 0000000000..5b3b02855d --- /dev/null +++ b/test/fixtures/plugins-check/manifests/package-command.json @@ -0,0 +1,89 @@ +{ + "manifestVersion": 1, + "id": "fixture-pack", + "name": "Fixture pack", + "description": "A valid plugin the plugins:check tests break one way at a time.", + "version": "1.0.0", + "category": "other", + "homepage": "https://example.com/fixture-pack", + "icon": "icon.svg", + "permissions": [ + "events.read" + ], + "connections": [ + { + "type": "oauth", + "name": "account", + "label": "Account", + "help": "Sign in.", + "helpUrl": "https://example.com/oauth" + }, + { + "type": "token", + "name": "api-token", + "label": "API token", + "help": "Paste a token.", + "helpUrl": "https://example.com/tokens" + } + ], + "outboundHosts": [ + "api.example.com" + ], + "contributes": { + "mcpServers": [ + { + "kind": "https", + "name": "remote", + "url": "https://mcp.example.com/mcp", + "tools": [ + "search" + ], + "capability": "read", + "authentication": "oauth", + "connection": "account" + }, + { + "kind": "package", + "name": "local", + "ecosystem": "npm", + "package": "propr-fixture-mcp", + "version": "1.0.0", + "integrity": "sha512-Y5GxTexuXT5IDhwgPcZokvnPvvRorDMdNHS83U59b5wquM5QTMmIhfeQ1y7AEbvbSmiJgjcJrok2q2mbzPRTvw==", + "secretEnv": [ + { + "name": "FIXTURE_TOKEN", + "connection": "api-token" + } + ], + "tools": [ + "echo" + ], + "capability": "read", + "command": "node server.js" + } + ], + "skills": [ + { + "name": "fixture-skill", + "path": "skills/fixture-skill" + } + ], + "automationTemplates": [ + { + "id": "fixture-report", + "path": "templates/fixture-report" + } + ], + "commands": [ + { + "name": "status", + "description": "Show the status" + } + ], + "subscriptions": [ + { + "event": "task.completed" + } + ] + } +} diff --git a/test/fixtures/plugins-check/manifests/package-missing-integrity.json b/test/fixtures/plugins-check/manifests/package-missing-integrity.json new file mode 100644 index 0000000000..b8f59b5288 --- /dev/null +++ b/test/fixtures/plugins-check/manifests/package-missing-integrity.json @@ -0,0 +1,87 @@ +{ + "manifestVersion": 1, + "id": "fixture-pack", + "name": "Fixture pack", + "description": "A valid plugin the plugins:check tests break one way at a time.", + "version": "1.0.0", + "category": "other", + "homepage": "https://example.com/fixture-pack", + "icon": "icon.svg", + "permissions": [ + "events.read" + ], + "connections": [ + { + "type": "oauth", + "name": "account", + "label": "Account", + "help": "Sign in.", + "helpUrl": "https://example.com/oauth" + }, + { + "type": "token", + "name": "api-token", + "label": "API token", + "help": "Paste a token.", + "helpUrl": "https://example.com/tokens" + } + ], + "outboundHosts": [ + "api.example.com" + ], + "contributes": { + "mcpServers": [ + { + "kind": "https", + "name": "remote", + "url": "https://mcp.example.com/mcp", + "tools": [ + "search" + ], + "capability": "read", + "authentication": "oauth", + "connection": "account" + }, + { + "kind": "package", + "name": "local", + "ecosystem": "npm", + "package": "propr-fixture-mcp", + "version": "1.0.0", + "secretEnv": [ + { + "name": "FIXTURE_TOKEN", + "connection": "api-token" + } + ], + "tools": [ + "echo" + ], + "capability": "read" + } + ], + "skills": [ + { + "name": "fixture-skill", + "path": "skills/fixture-skill" + } + ], + "automationTemplates": [ + { + "id": "fixture-report", + "path": "templates/fixture-report" + } + ], + "commands": [ + { + "name": "status", + "description": "Show the status" + } + ], + "subscriptions": [ + { + "event": "task.completed" + } + ] + } +} diff --git a/test/fixtures/plugins-check/manifests/package-path.json b/test/fixtures/plugins-check/manifests/package-path.json new file mode 100644 index 0000000000..7b7a2da28a --- /dev/null +++ b/test/fixtures/plugins-check/manifests/package-path.json @@ -0,0 +1,88 @@ +{ + "manifestVersion": 1, + "id": "fixture-pack", + "name": "Fixture pack", + "description": "A valid plugin the plugins:check tests break one way at a time.", + "version": "1.0.0", + "category": "other", + "homepage": "https://example.com/fixture-pack", + "icon": "icon.svg", + "permissions": [ + "events.read" + ], + "connections": [ + { + "type": "oauth", + "name": "account", + "label": "Account", + "help": "Sign in.", + "helpUrl": "https://example.com/oauth" + }, + { + "type": "token", + "name": "api-token", + "label": "API token", + "help": "Paste a token.", + "helpUrl": "https://example.com/tokens" + } + ], + "outboundHosts": [ + "api.example.com" + ], + "contributes": { + "mcpServers": [ + { + "kind": "https", + "name": "remote", + "url": "https://mcp.example.com/mcp", + "tools": [ + "search" + ], + "capability": "read", + "authentication": "oauth", + "connection": "account" + }, + { + "kind": "package", + "name": "local", + "ecosystem": "npm", + "package": "./server", + "version": "1.0.0", + "integrity": "sha512-Y5GxTexuXT5IDhwgPcZokvnPvvRorDMdNHS83U59b5wquM5QTMmIhfeQ1y7AEbvbSmiJgjcJrok2q2mbzPRTvw==", + "secretEnv": [ + { + "name": "FIXTURE_TOKEN", + "connection": "api-token" + } + ], + "tools": [ + "echo" + ], + "capability": "read" + } + ], + "skills": [ + { + "name": "fixture-skill", + "path": "skills/fixture-skill" + } + ], + "automationTemplates": [ + { + "id": "fixture-report", + "path": "templates/fixture-report" + } + ], + "commands": [ + { + "name": "status", + "description": "Show the status" + } + ], + "subscriptions": [ + { + "event": "task.completed" + } + ] + } +} diff --git a/test/fixtures/plugins-check/manifests/package-range.json b/test/fixtures/plugins-check/manifests/package-range.json new file mode 100644 index 0000000000..e944005897 --- /dev/null +++ b/test/fixtures/plugins-check/manifests/package-range.json @@ -0,0 +1,88 @@ +{ + "manifestVersion": 1, + "id": "fixture-pack", + "name": "Fixture pack", + "description": "A valid plugin the plugins:check tests break one way at a time.", + "version": "1.0.0", + "category": "other", + "homepage": "https://example.com/fixture-pack", + "icon": "icon.svg", + "permissions": [ + "events.read" + ], + "connections": [ + { + "type": "oauth", + "name": "account", + "label": "Account", + "help": "Sign in.", + "helpUrl": "https://example.com/oauth" + }, + { + "type": "token", + "name": "api-token", + "label": "API token", + "help": "Paste a token.", + "helpUrl": "https://example.com/tokens" + } + ], + "outboundHosts": [ + "api.example.com" + ], + "contributes": { + "mcpServers": [ + { + "kind": "https", + "name": "remote", + "url": "https://mcp.example.com/mcp", + "tools": [ + "search" + ], + "capability": "read", + "authentication": "oauth", + "connection": "account" + }, + { + "kind": "package", + "name": "local", + "ecosystem": "npm", + "package": "propr-fixture-mcp", + "version": "^1.0.0", + "integrity": "sha512-Y5GxTexuXT5IDhwgPcZokvnPvvRorDMdNHS83U59b5wquM5QTMmIhfeQ1y7AEbvbSmiJgjcJrok2q2mbzPRTvw==", + "secretEnv": [ + { + "name": "FIXTURE_TOKEN", + "connection": "api-token" + } + ], + "tools": [ + "echo" + ], + "capability": "read" + } + ], + "skills": [ + { + "name": "fixture-skill", + "path": "skills/fixture-skill" + } + ], + "automationTemplates": [ + { + "id": "fixture-report", + "path": "templates/fixture-report" + } + ], + "commands": [ + { + "name": "status", + "description": "Show the status" + } + ], + "subscriptions": [ + { + "event": "task.completed" + } + ] + } +} diff --git a/test/fixtures/plugins-check/manifests/package-tag.json b/test/fixtures/plugins-check/manifests/package-tag.json new file mode 100644 index 0000000000..768c311a57 --- /dev/null +++ b/test/fixtures/plugins-check/manifests/package-tag.json @@ -0,0 +1,88 @@ +{ + "manifestVersion": 1, + "id": "fixture-pack", + "name": "Fixture pack", + "description": "A valid plugin the plugins:check tests break one way at a time.", + "version": "1.0.0", + "category": "other", + "homepage": "https://example.com/fixture-pack", + "icon": "icon.svg", + "permissions": [ + "events.read" + ], + "connections": [ + { + "type": "oauth", + "name": "account", + "label": "Account", + "help": "Sign in.", + "helpUrl": "https://example.com/oauth" + }, + { + "type": "token", + "name": "api-token", + "label": "API token", + "help": "Paste a token.", + "helpUrl": "https://example.com/tokens" + } + ], + "outboundHosts": [ + "api.example.com" + ], + "contributes": { + "mcpServers": [ + { + "kind": "https", + "name": "remote", + "url": "https://mcp.example.com/mcp", + "tools": [ + "search" + ], + "capability": "read", + "authentication": "oauth", + "connection": "account" + }, + { + "kind": "package", + "name": "local", + "ecosystem": "npm", + "package": "propr-fixture-mcp", + "version": "latest", + "integrity": "sha512-Y5GxTexuXT5IDhwgPcZokvnPvvRorDMdNHS83U59b5wquM5QTMmIhfeQ1y7AEbvbSmiJgjcJrok2q2mbzPRTvw==", + "secretEnv": [ + { + "name": "FIXTURE_TOKEN", + "connection": "api-token" + } + ], + "tools": [ + "echo" + ], + "capability": "read" + } + ], + "skills": [ + { + "name": "fixture-skill", + "path": "skills/fixture-skill" + } + ], + "automationTemplates": [ + { + "id": "fixture-report", + "path": "templates/fixture-report" + } + ], + "commands": [ + { + "name": "status", + "description": "Show the status" + } + ], + "subscriptions": [ + { + "event": "task.completed" + } + ] + } +} diff --git a/test/fixtures/plugins-check/manifests/secret-env-not-token.json b/test/fixtures/plugins-check/manifests/secret-env-not-token.json new file mode 100644 index 0000000000..87b7619e21 --- /dev/null +++ b/test/fixtures/plugins-check/manifests/secret-env-not-token.json @@ -0,0 +1,88 @@ +{ + "manifestVersion": 1, + "id": "fixture-pack", + "name": "Fixture pack", + "description": "A valid plugin the plugins:check tests break one way at a time.", + "version": "1.0.0", + "category": "other", + "homepage": "https://example.com/fixture-pack", + "icon": "icon.svg", + "permissions": [ + "events.read" + ], + "connections": [ + { + "type": "oauth", + "name": "account", + "label": "Account", + "help": "Sign in.", + "helpUrl": "https://example.com/oauth" + }, + { + "type": "token", + "name": "api-token", + "label": "API token", + "help": "Paste a token.", + "helpUrl": "https://example.com/tokens" + } + ], + "outboundHosts": [ + "api.example.com" + ], + "contributes": { + "mcpServers": [ + { + "kind": "https", + "name": "remote", + "url": "https://mcp.example.com/mcp", + "tools": [ + "search" + ], + "capability": "read", + "authentication": "oauth", + "connection": "account" + }, + { + "kind": "package", + "name": "local", + "ecosystem": "npm", + "package": "propr-fixture-mcp", + "version": "1.0.0", + "integrity": "sha512-Y5GxTexuXT5IDhwgPcZokvnPvvRorDMdNHS83U59b5wquM5QTMmIhfeQ1y7AEbvbSmiJgjcJrok2q2mbzPRTvw==", + "secretEnv": [ + { + "name": "FIXTURE_TOKEN", + "connection": "account" + } + ], + "tools": [ + "echo" + ], + "capability": "read" + } + ], + "skills": [ + { + "name": "fixture-skill", + "path": "skills/fixture-skill" + } + ], + "automationTemplates": [ + { + "id": "fixture-report", + "path": "templates/fixture-report" + } + ], + "commands": [ + { + "name": "status", + "description": "Show the status" + } + ], + "subscriptions": [ + { + "event": "task.completed" + } + ] + } +} diff --git a/test/fixtures/plugins-check/manifests/unknown-permission.json b/test/fixtures/plugins-check/manifests/unknown-permission.json new file mode 100644 index 0000000000..faffa6705c --- /dev/null +++ b/test/fixtures/plugins-check/manifests/unknown-permission.json @@ -0,0 +1,89 @@ +{ + "manifestVersion": 1, + "id": "fixture-pack", + "name": "Fixture pack", + "description": "A valid plugin the plugins:check tests break one way at a time.", + "version": "1.0.0", + "category": "other", + "homepage": "https://example.com/fixture-pack", + "icon": "icon.svg", + "permissions": [ + "events.read", + "repositories.delete" + ], + "connections": [ + { + "type": "oauth", + "name": "account", + "label": "Account", + "help": "Sign in.", + "helpUrl": "https://example.com/oauth" + }, + { + "type": "token", + "name": "api-token", + "label": "API token", + "help": "Paste a token.", + "helpUrl": "https://example.com/tokens" + } + ], + "outboundHosts": [ + "api.example.com" + ], + "contributes": { + "mcpServers": [ + { + "kind": "https", + "name": "remote", + "url": "https://mcp.example.com/mcp", + "tools": [ + "search" + ], + "capability": "read", + "authentication": "oauth", + "connection": "account" + }, + { + "kind": "package", + "name": "local", + "ecosystem": "npm", + "package": "propr-fixture-mcp", + "version": "1.0.0", + "integrity": "sha512-Y5GxTexuXT5IDhwgPcZokvnPvvRorDMdNHS83U59b5wquM5QTMmIhfeQ1y7AEbvbSmiJgjcJrok2q2mbzPRTvw==", + "secretEnv": [ + { + "name": "FIXTURE_TOKEN", + "connection": "api-token" + } + ], + "tools": [ + "echo" + ], + "capability": "read" + } + ], + "skills": [ + { + "name": "fixture-skill", + "path": "skills/fixture-skill" + } + ], + "automationTemplates": [ + { + "id": "fixture-report", + "path": "templates/fixture-report" + } + ], + "commands": [ + { + "name": "status", + "description": "Show the status" + } + ], + "subscriptions": [ + { + "event": "task.completed" + } + ] + } +} diff --git a/test/fixtures/plugins-check/valid/fixture-pack/README.md b/test/fixtures/plugins-check/valid/fixture-pack/README.md new file mode 100644 index 0000000000..84838366d3 --- /dev/null +++ b/test/fixtures/plugins-check/valid/fixture-pack/README.md @@ -0,0 +1,3 @@ +# Fixture pack + +A plugins:check test fixture. diff --git a/test/fixtures/plugins-check/valid/fixture-pack/icon.svg b/test/fixtures/plugins-check/valid/fixture-pack/icon.svg new file mode 100644 index 0000000000..5d40b38a46 --- /dev/null +++ b/test/fixtures/plugins-check/valid/fixture-pack/icon.svg @@ -0,0 +1 @@ + diff --git a/test/fixtures/plugins-check/valid/fixture-pack/manifest.json b/test/fixtures/plugins-check/valid/fixture-pack/manifest.json new file mode 100644 index 0000000000..32d94d84c1 --- /dev/null +++ b/test/fixtures/plugins-check/valid/fixture-pack/manifest.json @@ -0,0 +1,26 @@ +{ + "manifestVersion": 1, + "id": "fixture-pack", + "name": "Fixture pack", + "description": "A valid plugin the plugins:check tests break one way at a time.", + "version": "1.0.0", + "category": "other", + "homepage": "https://example.com/fixture-pack", + "icon": "icon.svg", + "permissions": ["events.read"], + "connections": [ + { "type": "oauth", "name": "account", "label": "Account", "help": "Sign in.", "helpUrl": "https://example.com/oauth" }, + { "type": "token", "name": "api-token", "label": "API token", "help": "Paste a token.", "helpUrl": "https://example.com/tokens" } + ], + "outboundHosts": ["api.example.com"], + "contributes": { + "mcpServers": [ + { "kind": "https", "name": "remote", "url": "https://mcp.example.com/mcp", "tools": ["search"], "capability": "read", "authentication": "oauth", "connection": "account" }, + { "kind": "package", "name": "local", "ecosystem": "npm", "package": "propr-fixture-mcp", "version": "1.0.0", "integrity": "sha512-Y5GxTexuXT5IDhwgPcZokvnPvvRorDMdNHS83U59b5wquM5QTMmIhfeQ1y7AEbvbSmiJgjcJrok2q2mbzPRTvw==", "secretEnv": [{ "name": "FIXTURE_TOKEN", "connection": "api-token" }], "tools": ["echo"], "capability": "read" } + ], + "skills": [{ "name": "fixture-skill", "path": "skills/fixture-skill" }], + "automationTemplates": [{ "id": "fixture-report", "path": "templates/fixture-report" }], + "commands": [{ "name": "status", "description": "Show the status" }], + "subscriptions": [{ "event": "task.completed" }] + } +} diff --git a/test/fixtures/plugins-check/valid/fixture-pack/skills/fixture-skill/SKILL.md b/test/fixtures/plugins-check/valid/fixture-pack/skills/fixture-skill/SKILL.md new file mode 100644 index 0000000000..0b12d37f5f --- /dev/null +++ b/test/fixtures/plugins-check/valid/fixture-pack/skills/fixture-skill/SKILL.md @@ -0,0 +1,5 @@ +--- +name: fixture-skill +description: A fixture skill. +--- +# Fixture skill diff --git a/test/fixtures/plugins-check/valid/fixture-pack/templates/fixture-report/prompt.md b/test/fixtures/plugins-check/valid/fixture-pack/templates/fixture-report/prompt.md new file mode 100644 index 0000000000..6f402563fa --- /dev/null +++ b/test/fixtures/plugins-check/valid/fixture-pack/templates/fixture-report/prompt.md @@ -0,0 +1 @@ +Write a short report. Do not change anything. diff --git a/test/fixtures/plugins-check/valid/fixture-pack/templates/fixture-report/template.json b/test/fixtures/plugins-check/valid/fixture-pack/templates/fixture-report/template.json new file mode 100644 index 0000000000..c1caf5fd63 --- /dev/null +++ b/test/fixtures/plugins-check/valid/fixture-pack/templates/fixture-report/template.json @@ -0,0 +1,15 @@ +{ + "id": "fixture-report", + "name": "Fixture report", + "description": "A fixture automation template.", + "category": "reporting", + "capabilities": ["repository_read"], + "schedule": "0 9 * * 1", + "autonomy": "dry_run", + "previousReportCount": 1, + "reviewBeforeSaving": [ + { "field": "repositories", "note": "Choose repositories." }, + { "field": "schedule", "note": "Weekly." }, + { "field": "autonomy", "note": "Dry run." } + ] +} diff --git a/test/fixtures/plugins-check/valid/registry.json b/test/fixtures/plugins-check/valid/registry.json new file mode 100644 index 0000000000..7a0b5d780f --- /dev/null +++ b/test/fixtures/plugins-check/valid/registry.json @@ -0,0 +1 @@ +{ "version": 1, "plugins": [{ "id": "fixture-pack" }] } From 7691cf459e1818fd07ae30ac3b574e98f010603b Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:51:07 +0000 Subject: [PATCH 02/26] feat(ai): I fixed the CodeQL warning in `scripts/check-plugins.mjs:136`. When the script builds the npm registry URL for a package server, it now swaps every `/` in the package name for `%2f` (`replaceAll`), not just the first one. I fixed the CodeQL warning in `scripts/check-plugins.mjs:136`. When the script builds the npm registry URL for a package server, it now swaps every `/` in the package name for `%2f` (`replaceAll`), not just the first one. For real npm package names the result is the same, since a scoped name like `@scope/pkg` has only one `/`. The change makes the encoding correct for any input and stops CodeQL flagging it. All 20 tests in `test/checkPlugins.test.mjs` pass, and `npm run plugins:check` passes. This isn't a visible change, so there are no previews. PR: #3563 Comment by: @github-advanced-security[bot] (ID: 4571542316) Model: Claude Opus 5.5 --- scripts/check-plugins.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-plugins.mjs b/scripts/check-plugins.mjs index 3ce3e99fd7..e66ebe4c70 100644 --- a/scripts/check-plugins.mjs +++ b/scripts/check-plugins.mjs @@ -133,7 +133,7 @@ export async function checkPackageIntegrity(plugins, { registry = DEFAULT_NPM_RE for (const server of plugin.manifest.contributes.mcpServers) { if (server.kind !== 'package') continue; const label = `${plugin.id}: server ${server.name} (${server.package}@${server.version})`; - const url = `${registry.replace(/\/+$/, '')}/${server.package.replace('/', '%2f')}/${encodeURIComponent(server.version)}`; + const url = `${registry.replace(/\/+$/, '')}/${server.package.replaceAll('/', '%2f')}/${encodeURIComponent(server.version)}`; try { const response = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(15_000) }); if (response.status === 404) { errors.push(`${label}: not found on ${registry}`); continue; } From d683491f5f50a7919e55b488c932b20dd91bf808 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <213159723+propr-dev[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 06:04:58 +0000 Subject: [PATCH 03/26] I've implemented #3543. An administrator can now install a plugin and manage its state through `/api/instance/plugins/v1`, and MCP has read-only `list_plugins` and `get_plugin` tools. As the issue asked, nothing is applied to runs yet. Tests: the new Core test (10 cases) and the API route test pass, run directly and through the suite runner. So do the affected existing suites: OpenAPI coverage and conformance, MCP observable surface, docs, input budget, operator surface and integration, route authorization, database export, extension OAuth routes and the plugin manifest tests. `gen:openapi:check`, the typechecks for shared, core, api, client and plugins, and zero-warning lint on core and api also pass. I didn't run the full repository suite. What it does - State: a new migration adds `plugin_installations`, `plugin_secrets` and an append-only `plugin_audit`. The store is `packages/core/src/plugins/pluginStore.ts`. - A plugin installs disabled, with its default settings and the manifest's permissions granted. - Disabling keeps settings and connections. Uninstalling deletes them, but the audit trail stays. - Settings: checked against the manifest schema on save (unknown keys, types, limits, repository maps). Required settings can't be removed while the plugin is enabled. - Connections: `token` and `signing_secret` values are sealed with the existing private-secret mechanism, tied to this instance, plugin and connection. Reads only say connected or not connected, who set it and when. Native exports erase them. `oauth` connections report `not_connected`. - Enable refused: with a 409 and the reasons (also returned as `enableBlockers`) while a required setting, a required token connection or a package-server secret acknowledgement is missing. - Upgrades: a newer manifest that adds a permission flags the plugin Needs approval, and it keeps its old grant until approved. Changing a package server's package, version or secret variable names also asks for the acknowledgement again. Install requires that acknowledgement up front. - Access and audit: every route, reads included, needs `instance.manage_settings` and a current durable administrator. That is checked before the request body is read, and again inside each write. Every change is recorded in the audit trail. - API wiring: routes are registered from a new `packages/api/pluginRouteRegistry.ts`, so `routeRegistry.ts` doesn't grow. OpenAPI schemas and route docs are added, and the spec and client types are regenerated. - MCP: `list_plugins` returns compact rows and `get_plugin` the full read. Both are documented in `docs/mcp.md` and `docs/mcp-coverage.md`. - Docs: `docs/docs/features/plugins.md` has a new "Installing a plugin" section covering the install flow, connections, permissions and upgrades. Choices the issue left open - Required token connection: a `token` connection counts as required when a contributed MCP server uses it (a bearer server's connection or a package server's secret variable). The manifest has no "required" flag for connections, so I needed a rule. - Mutations: they need JSON plus an `X-ProPR-Plugins: 1` header, like the extension library. Clearing a connection is a `PUT` with `{ "value": null }`. Uninstalling is a `POST` that must repeat the plugin id as confirmation. - Errors: routes return `{ error }` like the extension routes, not the newer error envelope. The shared permission guard already answers in that shape. - Installed version: after an upgrade that needs no approval, the installed version moves to the new manifest on the next change. Decision for you: the production Docker images don't include the `packages/plugins` folder, and I didn't add `@propr/plugins` as a declared dependency, to avoid lockfile churn. Core loads the registry at runtime instead, and where it's missing the list is empty with one warning. To ship real plugins, add `packages/plugins` to the images and declare the dependency; that can be this PR or the next issue. No previews, since nothing here shows in the UI. --- docs/docs/features/plugins.md | 97 +- docs/mcp-coverage.md | 1 + docs/mcp.md | 13 + docs/static/openapi/propr-api.yaml | 1715 +++++++++++++++-- packages/api/mcp/tools.ts | 2 + packages/api/mcp/toolsPlugins.ts | 37 + packages/api/openapi/registeredRoutes.ts | 2 + packages/api/openapi/routeDocs.ts | 2 + packages/api/openapi/routeDocsPlugins.ts | 36 + packages/api/openapi/schemasPlugins.ts | 63 + packages/api/pluginRouteRegistry.ts | 20 + packages/api/routes/pluginRoutes.ts | 48 + packages/api/server.ts | 3 + packages/api/test/pluginRoutes.test.ts | 159 ++ packages/client/src/generated/apiTypes.ts | 263 +++ .../20261114000000_plugin_installations.js | 45 + packages/core/src/plugins/pluginCatalog.ts | 22 + packages/core/src/plugins/pluginState.ts | 111 ++ packages/core/src/plugins/pluginStore.ts | 241 +++ .../core/src/storage/databaseExportWorker.ts | 4 +- packages/core/test/databaseExport.test.ts | 11 + packages/core/test/pluginStore.test.ts | 265 +++ packages/shared/src/index.ts | 1 + packages/shared/src/pluginInstallations.ts | 148 ++ 24 files changed, 3091 insertions(+), 218 deletions(-) create mode 100644 packages/api/mcp/toolsPlugins.ts create mode 100644 packages/api/openapi/routeDocsPlugins.ts create mode 100644 packages/api/openapi/schemasPlugins.ts create mode 100644 packages/api/pluginRouteRegistry.ts create mode 100644 packages/api/routes/pluginRoutes.ts create mode 100644 packages/api/test/pluginRoutes.test.ts create mode 100644 packages/core/src/db/migrations/20261114000000_plugin_installations.js create mode 100644 packages/core/src/plugins/pluginCatalog.ts create mode 100644 packages/core/src/plugins/pluginState.ts create mode 100644 packages/core/src/plugins/pluginStore.ts create mode 100644 packages/core/test/pluginStore.test.ts create mode 100644 packages/shared/src/pluginInstallations.ts diff --git a/docs/docs/features/plugins.md b/docs/docs/features/plugins.md index 9d8e0f979e..73042753a7 100644 --- a/docs/docs/features/plugins.md +++ b/docs/docs/features/plugins.md @@ -13,10 +13,11 @@ npm package of a local MCP server, which goes through the same package cache and integrity check as the [extension library's local package servers](./agent-extensions.md#local-package-servers). :::note Work in progress -This page describes the plugin **manifest** and the official plugin registry. -Installation, the Plugins page, applying contributions to runs, handlers, -webhooks and commands are added by later releases; until then a manifest is -validated but has no effect on ProPR. +This page describes the plugin **manifest**, the official plugin registry and +how an administrator **installs** a plugin through the API. The Plugins page, +applying contributions to runs, OAuth connections, handlers, webhooks and +commands are added by later releases; until then an installed plugin's state +is stored and served but has no effect on runs. ::: ## Where plugins live @@ -166,3 +167,91 @@ npm run plugins:check -- --online [--registry https://registry.npmjs.org] also asks the npm registry for each package server's version and compares the recorded `integrity` value. It needs network access and is not run in CI. + +## Installing a plugin + +A plugin is **available** when it is in this ProPR's registry, **installed** +once an administrator installed it on the instance, and then **enabled** or +disabled. Disabling keeps its settings and connections; uninstalling deletes +them. + +Only durable instance administrators with `instance.manage_settings` can read +or change plugin state, and every change (install, settings, repository limit, +connection set or cleared, enable, disable, approve, uninstall) is recorded in +the plugin audit trail with who and when. The audit trail stays after an +uninstall. Changes need `Content-Type: application/json` and the header +`X-ProPR-Plugins: 1`. + +| Step | Request | +| --- | --- | +| List plugins and their state | `GET /api/instance/plugins/v1` | +| Read one plugin | `GET /api/instance/plugins/v1/`: the manifest, settings, repository limit, connection states, permissions and acknowledgements awaiting approval, why enabling is refused, and the newest 50 audit events. | +| Install | `POST /api/instance/plugins/v1//install` with `{ "acknowledgements": ["", …] }` | +| Change settings | `PUT /api/instance/plugins/v1//settings` with `{ "settings": { … } }`, the complete settings object | +| Limit to repositories | `PUT /api/instance/plugins/v1//repositories` with `{ "repositories": ["owner/repo"] }`; `null` or `[]` for all repositories | +| Set or clear a connection | `PUT /api/instance/plugins/v1//connections/` with `{ "value": "…" }` or `{ "value": null }` | +| Enable or disable | `PUT /api/instance/plugins/v1//enabled` with `{ "enabled": true }` | +| Approve after an upgrade | `POST /api/instance/plugins/v1//approve` with `{ "acknowledgements": [ … ] }` when needed | +| Uninstall | `POST /api/instance/plugins/v1//uninstall` with `{ "confirm": "" }` | + +The [API reference](../operations/api-reference.md) has the request and response schemas. MCP +clients can read the same state with the read-only `list_plugins` and +`get_plugin` tools. + +### Install flow + +1. Installing records the manifest version, the manifest's permissions as the + granted set, the settings defaults, who installed it and when. The plugin + starts disabled and applies to all repositories. +2. If the plugin contributes a [local package server](#local-package-servers) + with `secretEnv`, installing needs the same acknowledgement the extension + library asks for: the secret is readable by the agent CLI, its shell tools + and repository setup scripts inside the task container. Name each such + server in `acknowledgements`; the acknowledgement is recorded on the + installation with who and when. +3. Settings are validated against the manifest's settings schema on save: + unknown keys, wrong types, values outside `enum`, `minimum`/`maximum`, + `maxLength` or `maxItems`, and malformed repository maps are refused. +4. Set the connections the plugin needs, then enable it. + +Enabling is refused with HTTP 409 and the reasons (also listed as +`enableBlockers` when the plugin is read) while: + +- a `required` setting has no value, +- a required `token` connection is not set (a `token` connection is required + when a contributed MCP server uses it, as a `bearer` server's connection or a + package server's secret variable), or +- a package server's secret acknowledgement is missing. + +While a plugin is enabled, its required settings and required connections +cannot be removed; disable it first. + +### Connections + +`token` and `signing_secret` connections are write-only secrets. They are +sealed with the same private-secret mechanism as the extension library's +credentials, bound to the instance, plugin and connection, and never returned +by the API, MCP tools or logs; a read reports only `connected` or +`not_connected`, who set it and when. Native database exports erase them, so a +restored instance has its plugins installed with the same settings but every +connection not connected. `oauth` connections report `not_connected` until +OAuth sign-in for plugins is available. + +### Permissions and upgrades + +The granted permissions are a set recorded at install. When a ProPR upgrade +ships a newer manifest that asks for a permission that was not granted, the +plugin keeps running with its old grant and is flagged **Needs approval** +(`needsApproval`, with the new permissions in `pendingPermissions`) until an +administrator approves the new set. Approving grants the current manifest's +permissions and records its version as the installed version. + +A package server's acknowledgement is tied to its package, version and secret +variable names. When an upgrade changes any of them, the plugin also needs +approval (`pendingAcknowledgements`): it cannot be enabled until the approval +names that server in `acknowledgements` again. + +An upgrade that changes neither permissions nor acknowledged packages needs no +approval; the installed version follows the new manifest on the next change. +A plugin that an upgrade removed from the registry stays listed as installed +but unavailable; it can only be disabled or uninstalled. diff --git a/docs/mcp-coverage.md b/docs/mcp-coverage.md index 45d573feff..42cd0bb679 100644 --- a/docs/mcp-coverage.md +++ b/docs/mcp-coverage.md @@ -73,6 +73,7 @@ configuration. Live provider and chat-host acceptance are separate from this cat | Runtime package configuration/build | `get_runtime_configuration`, `update_runtime_configuration` | | Held follow-up handoffs (maintenance) | `list_followup_handoffs`, `get_followup_handoff`, `resume_followup_handoff`, `release_followup_handoff`; the operator decisions behind `/api/admin/followup-handoffs` and the Settings → Maintenance → Held follow-ups section, under `instance.manage_runtime` | | Instance membership administration | `list_instance_members`, `add_instance_member`, `set_instance_member_role`, `remove_instance_member`, `get_instance_role_audit` manage directly assigned instance roles; existing last-admin guards. Trigger whitelist changes stay separate: follow up with `update_trigger_access_configuration`, as the web UI offers after an add or remove | +| Plugin state (read-only) | `list_plugins`, `get_plugin` (`instance.manage_settings`): installation state, Needs approval, enable blockers, connection states, settings and recent audit events; never connection secrets. Install, configure and enable through `/api/instance/plugins/v1` | | Shared repository chat history | `get_repository_chat`, `save_repository_chat_message`, `delete_repository_chat_message` | | MCP access observability | Durable `mcp_access_log` row per tool call, resource read, prompt fetch and authentication failure; `GET /api/admin/mcp/logs` and `GET /api/admin/mcp/logs/stats`, both behind the existing `instance.manage_settings` permission; last-used and 24-hour request counts per connected app on `/mcp/apps`. Only names, identifiers, counts, sizes and outcomes are stored, and no MCP tool reads the log | | GitHub credentials, provider login, agent secrets, push subscription | Browser settings/login links from connection/setup; never collect secrets through tools | diff --git a/docs/mcp.md b/docs/mcp.md index a16b657672..1542e32262 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -357,6 +357,19 @@ branch is freed, files and commits are never deleted; a worktree that still holds unpublished work is released only with force set to true). The same decisions are in Settings → Maintenance → Held follow-ups. +Instance administrators with `instance.manage_settings` can read plugin state +with `list_plugins` and `get_plugin`. `list_plugins` returns one compact row +per plugin of this ProPR's registry (and per installed plugin an upgrade +removed from it): its state (available, installed or enabled), available and +installed manifest versions, whether it needs approval, the permissions waiting +for approval, why enabling is refused right now, and each connection's state. +`get_plugin` adds the public manifest, settings, repository limit, granted +permissions, package-server secret acknowledgements and the newest management +events of the plugin audit trail. Both are read-only: installing, configuring, +connecting, enabling, approving and uninstalling go through the +[plugin REST API](docs/features/plugins.md#installing-a-plugin). Connection +secrets are write-only and never appear in either result. + Start with `get_connection`, `list_repositories`, `list_models` and `resolve_reference`. Resolve ambiguity before mutating. `create_plan` creates a draft (with plan items it opens in Studio at Review, so its issues can be diff --git a/docs/static/openapi/propr-api.yaml b/docs/static/openapi/propr-api.yaml index ca5e891f95..f1c6a021a1 100644 --- a/docs/static/openapi/propr-api.yaml +++ b/docs/static/openapi/propr-api.yaml @@ -29,8 +29,8 @@ info: name: Apache-2.0 identifier: Apache-2.0 x-route-coverage: - total: 323 - documented: 85 + total: 332 + documented: 94 undocumented: 238 servers: - url: "{origin}" @@ -4832,23 +4832,26 @@ paths: x-propr-auth: member x-propr-permission: instance.manage_settings x-legacy-error: true - /api/instance/storage/v1/capabilities: + /api/instance/plugins/v1: get: - operationId: getInstanceStorageCapabilities - summary: Negotiate native instance storage capabilities and process generation. - description: Requires the `instance.manage_settings` instance permission. + operationId: listPlugins + summary: List the plugins of this ProPR's registry, and installed plugins an upgrade removed from it, with their installation state. + description: |- + Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned. + + Requires the `instance.manage_settings` instance permission. tags: - - System + - Configuration security: - sessionCookie: [] - bearerAuth: [] responses: "200": - description: Instance administrator capabilities; no Connect subscription required. + description: Plugins in catalog order. content: application/json: schema: - $ref: "#/components/schemas/InstanceStorageCapabilities" + $ref: "#/components/schemas/PluginList" "401": $ref: "#/components/responses/LegacyUnauthorized" "403": @@ -4858,21 +4861,63 @@ paths: x-propr-auth: member x-propr-permission: instance.manage_settings x-legacy-error: true - /api/instance/storage/v1/cleanup/execute: + /api/instance/plugins/v1/{pluginId}: + get: + operationId: getPlugin + summary: "Read one plugin: manifest, settings, connection states, pending approvals and acknowledgements, and its newest audit events." + description: |- + Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned. + + Requires the `instance.manage_settings` instance permission. + tags: + - Configuration + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: pluginId + in: path + required: true + description: Plugin id, for example `example-pack`. + schema: + type: string + responses: + "200": + description: The plugin. + content: + application/json: + schema: + $ref: "#/components/schemas/PluginDetailResponse" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "403": + $ref: "#/components/responses/LegacyForbidden" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member + x-propr-permission: instance.manage_settings + x-legacy-error: true + /api/instance/plugins/v1/{pluginId}/approve: post: - operationId: executeInstanceStorageCleanup - summary: Consume a principal-bound confirmation and revalidate before deleting any candidate. + operationId: approvePlugin + summary: Approve the permission set of the current manifest after an upgrade, with the secret acknowledgements it needs again. description: |- - Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero. + Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned. Requires the `instance.manage_settings` instance permission. tags: - - System + - Configuration security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: X-ProPR-Storage + - name: pluginId + in: path + required: true + description: Plugin id, for example `example-pack`. + schema: + type: string + - name: X-ProPR-Plugins in: header required: true description: Must equal 1; browser calls require an authorized CORS preflight. @@ -4883,14 +4928,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/InstanceStorageExecuteRequest" + $ref: "#/components/schemas/PluginAcknowledgementRequest" responses: "200": - description: Versioned instance storage response. + description: The plugin's new state. content: application/json: schema: - $ref: "#/components/schemas/InstanceStorageResult" + $ref: "#/components/schemas/PluginResponse" "401": $ref: "#/components/responses/LegacyUnauthorized" "403": @@ -4900,62 +4945,81 @@ paths: x-propr-auth: member x-propr-permission: instance.manage_settings x-legacy-error: true - /api/instance/storage/v1/cleanup/operations/{operationId}: - get: - operationId: getInstanceStorageCleanupResult - summary: Read the initiating principal’s durable cleanup result. + /api/instance/plugins/v1/{pluginId}/connections/{connectionName}: + put: + operationId: setPluginConnection + summary: Set or clear a `token` or `signing_secret` connection. The secret is sealed and write-only. description: |- - Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero. + Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned. Requires the `instance.manage_settings` instance permission. tags: - - System + - Configuration security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: operationId + - name: pluginId in: path required: true + description: Plugin id, for example `example-pack`. schema: type: string - - name: instanceId - in: query + - name: connectionName + in: path + required: true + description: Connection name from the manifest. + schema: + type: string + - name: X-ProPR-Plugins + in: header required: true + description: Must equal 1; browser calls require an authorized CORS preflight. schema: type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PluginConnectionRequest" responses: "200": - description: Versioned instance storage response. + description: The plugin's new state. content: application/json: schema: - $ref: "#/components/schemas/InstanceStorageResult" + $ref: "#/components/schemas/PluginResponse" "401": $ref: "#/components/responses/LegacyUnauthorized" "403": $ref: "#/components/responses/LegacyForbidden" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-query-type: InstanceStorageResultQuery x-propr-auth: member x-propr-permission: instance.manage_settings x-legacy-error: true - /api/instance/storage/v1/cleanup/preview: - post: - operationId: previewInstanceStorageCleanup - summary: Preview an explicit instance/category selection; worktrees includes only proven generated payloads; terminal task history requires explicit taskIds. + /api/instance/plugins/v1/{pluginId}/enabled: + put: + operationId: setPluginEnabled + summary: Enable or disable. Enabling is refused (409, with the reasons) while a required setting, a required token connection or a secret acknowledgement is missing. Disabling keeps settings and connections. description: |- - Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero. + Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned. Requires the `instance.manage_settings` instance permission. tags: - - System + - Configuration security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: X-ProPR-Storage + - name: pluginId + in: path + required: true + description: Plugin id, for example `example-pack`. + schema: + type: string + - name: X-ProPR-Plugins in: header required: true description: Must equal 1; browser calls require an authorized CORS preflight. @@ -4966,14 +5030,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/InstanceStoragePreviewRequest" + $ref: "#/components/schemas/PluginEnabledRequest" responses: "200": - description: Versioned instance storage response. + description: The plugin's new state. content: application/json: schema: - $ref: "#/components/schemas/InstanceStoragePreview" + $ref: "#/components/schemas/PluginResponse" "401": $ref: "#/components/responses/LegacyUnauthorized" "403": @@ -4983,21 +5047,30 @@ paths: x-propr-auth: member x-propr-permission: instance.manage_settings x-legacy-error: true - /api/instance/storage/v1/exports: + /api/instance/plugins/v1/{pluginId}/install: post: - operationId: startInstanceDatabaseExport - summary: Start a private asynchronous customer database snapshot. - description: Requires the `instance.manage_settings` instance permission. + operationId: installPlugin + summary: Install a registry plugin, disabled, with its default settings; grants the manifest's permissions. A package server with secret variables must be acknowledged. + description: |- + Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned. + + Requires the `instance.manage_settings` instance permission. tags: - - System + - Configuration security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: X-ProPR-Storage + - name: pluginId + in: path + required: true + description: Plugin id, for example `example-pack`. + schema: + type: string + - name: X-ProPR-Plugins in: header required: true - description: Must equal 1. + description: Must equal 1; browser calls require an authorized CORS preflight. schema: type: string requestBody: @@ -5005,14 +5078,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/InstanceDatabaseExportRequest" + $ref: "#/components/schemas/PluginAcknowledgementRequest" responses: - "202": - description: Actor-bound expiring export job. + "200": + description: The plugin's new state. content: application/json: schema: - $ref: "#/components/schemas/InstanceDatabaseExport" + $ref: "#/components/schemas/PluginResponse" "401": $ref: "#/components/responses/LegacyUnauthorized" "403": @@ -5022,71 +5095,78 @@ paths: x-propr-auth: member x-propr-permission: instance.manage_settings x-legacy-error: true - /api/instance/storage/v1/exports/{operationId}: - get: - operationId: getInstanceDatabaseExport - summary: Read the initiating administrator’s export status. - description: Requires the `instance.manage_settings` instance permission. + /api/instance/plugins/v1/{pluginId}/repositories: + put: + operationId: setPluginRepositories + summary: Limit the plugin to repositories, or clear the limit (all repositories). + description: |- + Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned. + + Requires the `instance.manage_settings` instance permission. tags: - - System + - Configuration security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: operationId + - name: pluginId in: path required: true + description: Plugin id, for example `example-pack`. schema: type: string - - name: instanceId - in: query - required: true - schema: - type: string - - name: generation - in: query + - name: X-ProPR-Plugins + in: header required: true + description: Must equal 1; browser calls require an authorized CORS preflight. schema: type: string - format: uuid - pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PluginRepositoriesRequest" responses: "200": - description: Export state, digest, size and schema version. + description: The plugin's new state. content: application/json: schema: - $ref: "#/components/schemas/InstanceDatabaseExport" + $ref: "#/components/schemas/PluginResponse" "401": $ref: "#/components/responses/LegacyUnauthorized" "403": $ref: "#/components/responses/LegacyForbidden" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-query-type: InstanceDatabaseExportQuery x-propr-auth: member x-propr-permission: instance.manage_settings x-legacy-error: true - /api/instance/storage/v1/exports/{operationId}/cancel: - post: - operationId: cancelInstanceDatabaseExport - summary: Abort and discard an administrator’s private export. - description: Requires the `instance.manage_settings` instance permission. + /api/instance/plugins/v1/{pluginId}/settings: + put: + operationId: updatePluginSettings + summary: Replace the settings, validated against the manifest's settings schema. Required settings cannot be removed while the plugin is enabled. + description: |- + Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned. + + Requires the `instance.manage_settings` instance permission. tags: - - System + - Configuration security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: operationId + - name: pluginId in: path required: true + description: Plugin id, for example `example-pack`. schema: type: string - - name: X-ProPR-Storage + - name: X-ProPR-Plugins in: header required: true - description: Must equal 1. + description: Must equal 1; browser calls require an authorized CORS preflight. schema: type: string requestBody: @@ -5094,14 +5174,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/InstanceDatabaseExportRequest" + $ref: "#/components/schemas/PluginSettingsRequest" responses: "200": - description: Export discarded. + description: The plugin's new state. content: application/json: schema: - $ref: "#/components/schemas/JsonObject" + $ref: "#/components/schemas/PluginResponse" "401": $ref: "#/components/responses/LegacyUnauthorized" "403": @@ -5111,26 +5191,30 @@ paths: x-propr-auth: member x-propr-permission: instance.manage_settings x-legacy-error: true - /api/instance/storage/v1/exports/{operationId}/download: + /api/instance/plugins/v1/{pluginId}/uninstall: post: - operationId: downloadInstanceDatabaseExport - summary: Redeem an export once using current instance-admin authority. - description: Requires the `instance.manage_settings` instance permission. + operationId: uninstallPlugin + summary: "Uninstall: deletes the installation, its settings and its connections. The audit trail stays." + description: |- + Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned. + + Requires the `instance.manage_settings` instance permission. tags: - - System + - Configuration security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: operationId + - name: pluginId in: path required: true + description: Plugin id, for example `example-pack`. schema: type: string - - name: X-ProPR-Storage + - name: X-ProPR-Plugins in: header required: true - description: Must equal 1. + description: Must equal 1; browser calls require an authorized CORS preflight. schema: type: string requestBody: @@ -5138,14 +5222,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/InstanceDatabaseExportRequest" + $ref: "#/components/schemas/PluginUninstallRequest" responses: "200": - description: Integrity-checked SQLite snapshot; attachment, no-store. + description: The plugin's new state. content: - application/vnd.sqlite3: + application/json: schema: - type: string + $ref: "#/components/schemas/PluginResponse" "401": $ref: "#/components/responses/LegacyUnauthorized" "403": @@ -5155,14 +5239,11 @@ paths: x-propr-auth: member x-propr-permission: instance.manage_settings x-legacy-error: true - /api/instance/storage/v1/policy: + /api/instance/storage/v1/capabilities: get: - operationId: getInstanceStoragePolicy - summary: Get opt-in repository cleanup policy (disabled, 24 idle hours by default). - description: |- - Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero. - - Requires the `instance.manage_settings` instance permission. + operationId: getInstanceStorageCapabilities + summary: Negotiate native instance storage capabilities and process generation. + description: Requires the `instance.manage_settings` instance permission. tags: - System security: @@ -5170,11 +5251,11 @@ paths: - bearerAuth: [] responses: "200": - description: Versioned instance storage response. + description: Instance administrator capabilities; no Connect subscription required. content: application/json: schema: - $ref: "#/components/schemas/InstanceStoragePolicy" + $ref: "#/components/schemas/InstanceStorageCapabilities" "401": $ref: "#/components/responses/LegacyUnauthorized" "403": @@ -5184,9 +5265,10 @@ paths: x-propr-auth: member x-propr-permission: instance.manage_settings x-legacy-error: true - put: - operationId: updateInstanceStoragePolicy - summary: Persist the explicitly selected instance cleanup policy. + /api/instance/storage/v1/cleanup/execute: + post: + operationId: executeInstanceStorageCleanup + summary: Consume a principal-bound confirmation and revalidate before deleting any candidate. description: |- Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero. @@ -5208,14 +5290,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/InstanceStoragePolicyRequest" + $ref: "#/components/schemas/InstanceStorageExecuteRequest" responses: "200": description: Versioned instance storage response. content: application/json: schema: - $ref: "#/components/schemas/InstanceStoragePolicy" + $ref: "#/components/schemas/InstanceStorageResult" "401": $ref: "#/components/responses/LegacyUnauthorized" "403": @@ -5225,36 +5307,51 @@ paths: x-propr-auth: member x-propr-permission: instance.manage_settings x-legacy-error: true - /api/instance/storage/v1/repositories: + /api/instance/storage/v1/cleanup/operations/{operationId}: get: - operationId: getInstanceRepositoryInventory - summary: Read a bounded partial repository storage inventory with protection reasons. - description: Requires the `instance.manage_settings` instance permission. + operationId: getInstanceStorageCleanupResult + summary: Read the initiating principal’s durable cleanup result. + description: |- + Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero. + + Requires the `instance.manage_settings` instance permission. tags: - System security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: operationId + in: path + required: true + schema: + type: string + - name: instanceId + in: query + required: true + schema: + type: string responses: "200": - description: Safe identifiers and sizes; no host paths or contents. + description: Versioned instance storage response. content: application/json: schema: - $ref: "#/components/schemas/InstanceRepositoryInventory" + $ref: "#/components/schemas/InstanceStorageResult" "401": $ref: "#/components/responses/LegacyUnauthorized" "403": $ref: "#/components/responses/LegacyForbidden" default: $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-query-type: InstanceStorageResultQuery x-propr-auth: member x-propr-permission: instance.manage_settings x-legacy-error: true - /api/instance/storage/v1/usage: - get: - operationId: getInstanceStorageUsage - summary: Get bounded asynchronous per-instance storage observations. + /api/instance/storage/v1/cleanup/preview: + post: + operationId: previewInstanceStorageCleanup + summary: Preview an explicit instance/category selection; worktrees includes only proven generated payloads; terminal task history requires explicit taskIds. description: |- Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero. @@ -5264,13 +5361,26 @@ paths: security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: X-ProPR-Storage + in: header + required: true + description: Must equal 1; browser calls require an authorized CORS preflight. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceStoragePreviewRequest" responses: "200": description: Versioned instance storage response. content: application/json: schema: - $ref: "#/components/schemas/InstanceStorageUsage" + $ref: "#/components/schemas/InstanceStoragePreview" "401": $ref: "#/components/responses/LegacyUnauthorized" "403": @@ -5280,166 +5390,463 @@ paths: x-propr-auth: member x-propr-permission: instance.manage_settings x-legacy-error: true - /api/internal/agent-runs/{runId}/mcp-grants: + /api/instance/storage/v1/exports: post: - operationId: postApiInternalAgentRunsRunIdMcpGrants - summary: POST /api/internal/agent-runs/{runId}/mcp-grants - description: This route is registered but not documented yet. + operationId: startInstanceDatabaseExport + summary: Start a private asynchronous customer database snapshot. + description: Requires the `instance.manage_settings` instance permission. tags: - - Automations - security: [] + - System + security: + - sessionCookie: [] + - bearerAuth: [] parameters: - - name: runId - in: path + - name: X-ProPR-Storage + in: header required: true + description: Must equal 1. schema: type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceDatabaseExportRequest" responses: - "200": - description: Success. The response body is not documented yet. + "202": + description: Actor-bound expiring export job. + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceDatabaseExport" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "403": + $ref: "#/components/responses/LegacyForbidden" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: public - x-undocumented: true + x-propr-auth: member + x-propr-permission: instance.manage_settings x-legacy-error: true - /api/internal/agent-runs/{runId}/mcp-grants/revoke: - post: - operationId: postApiInternalAgentRunsRunIdMcpGrantsRevoke - summary: POST /api/internal/agent-runs/{runId}/mcp-grants/revoke - description: This route is registered but not documented yet. + /api/instance/storage/v1/exports/{operationId}: + get: + operationId: getInstanceDatabaseExport + summary: Read the initiating administrator’s export status. + description: Requires the `instance.manage_settings` instance permission. tags: - - Automations - security: [] + - System + security: + - sessionCookie: [] + - bearerAuth: [] parameters: - - name: runId + - name: operationId in: path required: true schema: type: string + - name: instanceId + in: query + required: true + schema: + type: string + - name: generation + in: query + required: true + schema: + type: string + format: uuid + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ responses: "200": - description: Success. The response body is not documented yet. - default: - $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: public - x-undocumented: true - x-legacy-error: true - /api/internal/hosted/bootstrap: - get: - operationId: getApiInternalHostedBootstrap - summary: GET /api/internal/hosted/bootstrap - description: This route is registered but not documented yet. - tags: - - System - security: - - fleetSecret: [] - responses: - "200": - description: Success. The response body is not documented yet. + description: Export state, digest, size and schema version. + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceDatabaseExport" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "403": + $ref: "#/components/responses/LegacyForbidden" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: fleetSecret - x-undocumented: true + x-propr-query-type: InstanceDatabaseExportQuery + x-propr-auth: member + x-propr-permission: instance.manage_settings x-legacy-error: true - /api/internal/hosted/queue: - get: - operationId: getApiInternalHostedQueue - summary: GET /api/internal/hosted/queue - description: This route is registered but not documented yet. + /api/instance/storage/v1/exports/{operationId}/cancel: + post: + operationId: cancelInstanceDatabaseExport + summary: Abort and discard an administrator’s private export. + description: Requires the `instance.manage_settings` instance permission. tags: - System security: - - fleetSecret: [] + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: operationId + in: path + required: true + schema: + type: string + - name: X-ProPR-Storage + in: header + required: true + description: Must equal 1. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceDatabaseExportRequest" responses: "200": - description: Success. The response body is not documented yet. + description: Export discarded. + content: + application/json: + schema: + $ref: "#/components/schemas/JsonObject" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "403": + $ref: "#/components/responses/LegacyForbidden" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: fleetSecret - x-undocumented: true + x-propr-auth: member + x-propr-permission: instance.manage_settings x-legacy-error: true - /api/internal/hosted/status: - get: - operationId: getApiInternalHostedStatus - summary: GET /api/internal/hosted/status - description: This route is registered but not documented yet. + /api/instance/storage/v1/exports/{operationId}/download: + post: + operationId: downloadInstanceDatabaseExport + summary: Redeem an export once using current instance-admin authority. + description: Requires the `instance.manage_settings` instance permission. tags: - System - security: - - fleetSecret: [] - responses: - "200": - description: Success. The response body is not documented yet. - default: - $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: fleetSecret - x-undocumented: true - x-legacy-error: true - /api/llm-logs: - get: - operationId: getApiLlmLogs - summary: GET /api/llm-logs - description: This route is registered but not documented yet. - tags: - - Observability security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: operationId + in: path + required: true + schema: + type: string + - name: X-ProPR-Storage + in: header + required: true + description: Must equal 1. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceDatabaseExportRequest" responses: "200": - description: Success. The response body is not documented yet. + description: Integrity-checked SQLite snapshot; attachment, no-store. + content: + application/vnd.sqlite3: + schema: + type: string "401": $ref: "#/components/responses/LegacyUnauthorized" + "403": + $ref: "#/components/responses/LegacyForbidden" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member - x-undocumented: true + x-propr-permission: instance.manage_settings x-legacy-error: true - /api/llm-metrics: + /api/instance/storage/v1/policy: get: - operationId: getApiLlmMetrics - summary: GET /api/llm-metrics - description: This route is registered but not documented yet. + operationId: getInstanceStoragePolicy + summary: Get opt-in repository cleanup policy (disabled, 24 idle hours by default). + description: |- + Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero. + + Requires the `instance.manage_settings` instance permission. tags: - - Observability + - System security: - sessionCookie: [] - bearerAuth: [] responses: "200": - description: Success. The response body is not documented yet. + description: Versioned instance storage response. + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceStoragePolicy" "401": $ref: "#/components/responses/LegacyUnauthorized" + "403": + $ref: "#/components/responses/LegacyForbidden" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member - x-undocumented: true + x-propr-permission: instance.manage_settings x-legacy-error: true - /api/llm-metrics/{correlationId}: - get: - operationId: getApiLlmMetricsCorrelationId - summary: GET /api/llm-metrics/{correlationId} + put: + operationId: updateInstanceStoragePolicy + summary: Persist the explicitly selected instance cleanup policy. description: |- - This route is registered but not documented yet. + Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero. - When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead. + Requires the `instance.manage_settings` instance permission. tags: - - Observability + - System security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: correlationId - in: path + - name: X-ProPR-Storage + in: header required: true + description: Must equal 1; browser calls require an authorized CORS preflight. schema: type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceStoragePolicyRequest" responses: "200": - description: Success. The response body is not documented yet. - "401": - $ref: "#/components/responses/LegacyUnauthorized" - "404": + description: Versioned instance storage response. + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceStoragePolicy" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "403": + $ref: "#/components/responses/LegacyForbidden" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member + x-propr-permission: instance.manage_settings + x-legacy-error: true + /api/instance/storage/v1/repositories: + get: + operationId: getInstanceRepositoryInventory + summary: Read a bounded partial repository storage inventory with protection reasons. + description: Requires the `instance.manage_settings` instance permission. + tags: + - System + security: + - sessionCookie: [] + - bearerAuth: [] + responses: + "200": + description: Safe identifiers and sizes; no host paths or contents. + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceRepositoryInventory" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "403": + $ref: "#/components/responses/LegacyForbidden" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member + x-propr-permission: instance.manage_settings + x-legacy-error: true + /api/instance/storage/v1/usage: + get: + operationId: getInstanceStorageUsage + summary: Get bounded asynchronous per-instance storage observations. + description: |- + Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero. + + Requires the `instance.manage_settings` instance permission. + tags: + - System + security: + - sessionCookie: [] + - bearerAuth: [] + responses: + "200": + description: Versioned instance storage response. + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceStorageUsage" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "403": + $ref: "#/components/responses/LegacyForbidden" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member + x-propr-permission: instance.manage_settings + x-legacy-error: true + /api/internal/agent-runs/{runId}/mcp-grants: + post: + operationId: postApiInternalAgentRunsRunIdMcpGrants + summary: POST /api/internal/agent-runs/{runId}/mcp-grants + description: This route is registered but not documented yet. + tags: + - Automations + security: [] + parameters: + - name: runId + in: path + required: true + schema: + type: string + responses: + "200": + description: Success. The response body is not documented yet. + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: public + x-undocumented: true + x-legacy-error: true + /api/internal/agent-runs/{runId}/mcp-grants/revoke: + post: + operationId: postApiInternalAgentRunsRunIdMcpGrantsRevoke + summary: POST /api/internal/agent-runs/{runId}/mcp-grants/revoke + description: This route is registered but not documented yet. + tags: + - Automations + security: [] + parameters: + - name: runId + in: path + required: true + schema: + type: string + responses: + "200": + description: Success. The response body is not documented yet. + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: public + x-undocumented: true + x-legacy-error: true + /api/internal/hosted/bootstrap: + get: + operationId: getApiInternalHostedBootstrap + summary: GET /api/internal/hosted/bootstrap + description: This route is registered but not documented yet. + tags: + - System + security: + - fleetSecret: [] + responses: + "200": + description: Success. The response body is not documented yet. + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: fleetSecret + x-undocumented: true + x-legacy-error: true + /api/internal/hosted/queue: + get: + operationId: getApiInternalHostedQueue + summary: GET /api/internal/hosted/queue + description: This route is registered but not documented yet. + tags: + - System + security: + - fleetSecret: [] + responses: + "200": + description: Success. The response body is not documented yet. + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: fleetSecret + x-undocumented: true + x-legacy-error: true + /api/internal/hosted/status: + get: + operationId: getApiInternalHostedStatus + summary: GET /api/internal/hosted/status + description: This route is registered but not documented yet. + tags: + - System + security: + - fleetSecret: [] + responses: + "200": + description: Success. The response body is not documented yet. + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: fleetSecret + x-undocumented: true + x-legacy-error: true + /api/llm-logs: + get: + operationId: getApiLlmLogs + summary: GET /api/llm-logs + description: This route is registered but not documented yet. + tags: + - Observability + security: + - sessionCookie: [] + - bearerAuth: [] + responses: + "200": + description: Success. The response body is not documented yet. + "401": + $ref: "#/components/responses/LegacyUnauthorized" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member + x-undocumented: true + x-legacy-error: true + /api/llm-metrics: + get: + operationId: getApiLlmMetrics + summary: GET /api/llm-metrics + description: This route is registered but not documented yet. + tags: + - Observability + security: + - sessionCookie: [] + - bearerAuth: [] + responses: + "200": + description: Success. The response body is not documented yet. + "401": + $ref: "#/components/responses/LegacyUnauthorized" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member + x-undocumented: true + x-legacy-error: true + /api/llm-metrics/{correlationId}: + get: + operationId: getApiLlmMetricsCorrelationId + summary: GET /api/llm-metrics/{correlationId} + description: |- + This route is registered but not documented yet. + + When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead. + tags: + - Observability + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: correlationId + in: path + required: true + schema: + type: string + responses: + "200": + description: Success. The response body is not documented yet. + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "404": description: Not found, or the task belongs to another user's goal. content: application/json: @@ -11858,6 +12265,890 @@ components: required: - unreadCount additionalProperties: false + PluginAcknowledgementRequest: + type: object + properties: + acknowledgements: + description: Package servers whose secret variables the administrator confirms are readable by the agent CLI, its shell tools and repository setup scripts in the task container. + type: array + items: + type: string + additionalProperties: false + PluginConnectionRequest: + type: object + properties: + value: + anyOf: + - type: string + maxLength: 4096 + - type: "null" + description: The secret to store (write-only), or null to clear it. + required: + - value + additionalProperties: false + PluginDetailResponse: + type: object + properties: + plugin: + type: object + properties: + id: + type: string + available: + type: boolean + description: The plugin is in this ProPR version's registry. + manifest: + anyOf: + - type: object + properties: + id: + type: string + name: + type: string + description: + type: string + version: + type: string + category: + type: string + enum: + - messaging + - issue-tracking + - observability + - deployment + - context + - other + homepage: + type: string + permissions: + type: array + items: + type: object + properties: + name: + type: string + enum: + - events.read + - tasks.read + - tasks.create + - issues.read + - issues.create + - issues.comment + - pull_requests.read + - pull_requests.comment + - repositories.read + - notifications.send + description: + type: string + required: + - name + - description + additionalProperties: false + connections: + type: array + items: + type: object + properties: + type: + type: string + enum: + - oauth + - token + - signing_secret + name: + type: string + label: + type: string + help: + type: string + helpUrl: + type: string + required: + - type + - name + - label + - help + - helpUrl + additionalProperties: {} + settings: + type: object + properties: + type: + type: string + const: object + properties: + type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: {} + additionalProperties: {} + required: + type: array + items: + type: string + required: + - type + - properties + - required + additionalProperties: false + description: "The settings schema: a JSON Schema subset (string, number, boolean, string list, repository map)." + outboundHosts: + type: array + items: + type: string + contributes: + type: object + properties: + mcpServers: + type: array + items: + type: object + properties: {} + additionalProperties: {} + description: Contributed MCP servers without launch arguments or environment values. + skills: + type: array + items: + type: string + automationTemplates: + type: array + items: + type: string + commands: + type: array + items: + type: object + properties: + name: + type: string + description: + type: string + required: + - name + - description + additionalProperties: false + subscriptions: + type: array + items: + type: string + webhooks: + type: array + items: + type: object + properties: + name: + type: string + description: + type: string + required: + - name + - description + additionalProperties: false + required: + - mcpServers + - skills + - automationTemplates + - commands + - subscriptions + - webhooks + additionalProperties: false + required: + - id + - name + - description + - version + - category + - homepage + - permissions + - connections + - settings + - outboundHosts + - contributes + additionalProperties: false + description: The public projection of the plugin manifest. + - type: "null" + state: + type: string + enum: + - available + - installed + - enabled + installation: + anyOf: + - type: object + properties: + installedVersion: + type: string + description: The manifest version installed or last approved. + enabled: + type: boolean + settings: + type: object + propertyNames: + type: string + additionalProperties: + anyOf: + - type: string + - type: number + - type: boolean + - type: array + items: + type: string + - type: object + propertyNames: + type: string + additionalProperties: + type: string + description: Setting values; never secrets, which are connections. + repositories: + anyOf: + - type: array + items: + type: string + - type: "null" + description: Repositories (`owner/repo`) the plugin is limited to; null for all repositories. + grantedPermissions: + type: array + items: + type: string + enum: + - events.read + - tasks.read + - tasks.create + - issues.read + - issues.create + - issues.comment + - pull_requests.read + - pull_requests.comment + - repositories.read + - notifications.send + acknowledgements: + type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + actor: + type: string + at: + type: number + package: + type: string + version: + type: string + secretEnv: + type: array + items: + type: string + required: + - actor + - at + - package + - version + - secretEnv + additionalProperties: false + description: "Per package server: who acknowledged that its secret variables are readable inside the task container, and for which package, version and variable names." + installedBy: + type: string + installedAt: + type: number + updatedBy: + type: string + updatedAt: + type: number + required: + - installedVersion + - enabled + - settings + - repositories + - grantedPermissions + - acknowledgements + - installedBy + - installedAt + - updatedBy + - updatedAt + additionalProperties: false + - type: "null" + connections: + type: array + items: + type: object + properties: + name: + type: string + type: + type: string + enum: + - oauth + - token + - signing_secret + label: + type: string + state: + type: string + enum: + - connected + - not_connected + description: "`oauth` connections report not_connected until OAuth sign-in for plugins is available." + required: + type: boolean + description: A `token` connection a contributed MCP server uses; enabling waits for it. + setBy: + type: string + setAt: + type: number + required: + - name + - type + - label + - state + - required + additionalProperties: false + needsApproval: + type: boolean + description: The current manifest asks for permissions or secret acknowledgements not yet approved; the plugin keeps its old grant. + pendingPermissions: + type: array + items: + type: string + enum: + - events.read + - tasks.read + - tasks.create + - issues.read + - issues.create + - issues.comment + - pull_requests.read + - pull_requests.comment + - repositories.read + - notifications.send + pendingAcknowledgements: + type: array + items: + type: object + properties: + server: + type: string + package: + type: string + version: + type: string + secretEnv: + type: array + items: + type: string + required: + - server + - package + - version + - secretEnv + additionalProperties: false + enableBlockers: + type: array + items: + type: string + description: Why enabling is refused right now; empty when it can be enabled. + audit: + type: array + items: + type: object + properties: + action: + type: string + enum: + - install + - update_settings + - set_repositories + - set_connection + - clear_connection + - enable + - disable + - approve + - uninstall + actor: + type: string + at: + type: number + detail: + type: object + properties: {} + additionalProperties: {} + required: + - action + - actor + - at + - detail + additionalProperties: false + description: The newest 50 management actions, newest first. + required: + - id + - available + - manifest + - state + - installation + - connections + - needsApproval + - pendingPermissions + - pendingAcknowledgements + - enableBlockers + - audit + additionalProperties: false + required: + - plugin + additionalProperties: false + PluginEnabledRequest: + type: object + properties: + enabled: + type: boolean + required: + - enabled + additionalProperties: false + PluginList: + type: object + properties: + plugins: + type: array + items: + $ref: "#/components/schemas/PluginView" + required: + - plugins + additionalProperties: false + PluginRepositoriesRequest: + type: object + properties: + repositories: + anyOf: + - maxItems: 256 + type: array + items: + type: string + - type: "null" + description: owner/repo names; null or an empty list for all repositories. + required: + - repositories + additionalProperties: false + PluginResponse: + type: object + properties: + plugin: + $ref: "#/components/schemas/PluginView" + required: + - plugin + additionalProperties: false + PluginSettingsRequest: + type: object + properties: + settings: + type: object + propertyNames: + type: string + additionalProperties: + anyOf: + - anyOf: + - type: string + - type: number + - type: boolean + - type: array + items: + type: string + - type: object + propertyNames: + type: string + additionalProperties: + type: string + - type: "null" + description: The complete settings; validated against the manifest's settings schema. Null values are removed. + required: + - settings + additionalProperties: false + PluginUninstallRequest: + type: object + properties: + confirm: + type: string + description: Must repeat the plugin id. + required: + - confirm + additionalProperties: false + PluginView: + type: object + properties: + id: + type: string + available: + type: boolean + description: The plugin is in this ProPR version's registry. + manifest: + anyOf: + - type: object + properties: + id: + type: string + name: + type: string + description: + type: string + version: + type: string + category: + type: string + enum: + - messaging + - issue-tracking + - observability + - deployment + - context + - other + homepage: + type: string + permissions: + type: array + items: + type: object + properties: + name: + type: string + enum: + - events.read + - tasks.read + - tasks.create + - issues.read + - issues.create + - issues.comment + - pull_requests.read + - pull_requests.comment + - repositories.read + - notifications.send + description: + type: string + required: + - name + - description + additionalProperties: false + connections: + type: array + items: + type: object + properties: + type: + type: string + enum: + - oauth + - token + - signing_secret + name: + type: string + label: + type: string + help: + type: string + helpUrl: + type: string + required: + - type + - name + - label + - help + - helpUrl + additionalProperties: {} + settings: + type: object + properties: + type: + type: string + const: object + properties: + type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: {} + additionalProperties: {} + required: + type: array + items: + type: string + required: + - type + - properties + - required + additionalProperties: false + description: "The settings schema: a JSON Schema subset (string, number, boolean, string list, repository map)." + outboundHosts: + type: array + items: + type: string + contributes: + type: object + properties: + mcpServers: + type: array + items: + type: object + properties: {} + additionalProperties: {} + description: Contributed MCP servers without launch arguments or environment values. + skills: + type: array + items: + type: string + automationTemplates: + type: array + items: + type: string + commands: + type: array + items: + type: object + properties: + name: + type: string + description: + type: string + required: + - name + - description + additionalProperties: false + subscriptions: + type: array + items: + type: string + webhooks: + type: array + items: + type: object + properties: + name: + type: string + description: + type: string + required: + - name + - description + additionalProperties: false + required: + - mcpServers + - skills + - automationTemplates + - commands + - subscriptions + - webhooks + additionalProperties: false + required: + - id + - name + - description + - version + - category + - homepage + - permissions + - connections + - settings + - outboundHosts + - contributes + additionalProperties: false + description: The public projection of the plugin manifest. + - type: "null" + state: + type: string + enum: + - available + - installed + - enabled + installation: + anyOf: + - type: object + properties: + installedVersion: + type: string + description: The manifest version installed or last approved. + enabled: + type: boolean + settings: + type: object + propertyNames: + type: string + additionalProperties: + anyOf: + - type: string + - type: number + - type: boolean + - type: array + items: + type: string + - type: object + propertyNames: + type: string + additionalProperties: + type: string + description: Setting values; never secrets, which are connections. + repositories: + anyOf: + - type: array + items: + type: string + - type: "null" + description: Repositories (`owner/repo`) the plugin is limited to; null for all repositories. + grantedPermissions: + type: array + items: + type: string + enum: + - events.read + - tasks.read + - tasks.create + - issues.read + - issues.create + - issues.comment + - pull_requests.read + - pull_requests.comment + - repositories.read + - notifications.send + acknowledgements: + type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + actor: + type: string + at: + type: number + package: + type: string + version: + type: string + secretEnv: + type: array + items: + type: string + required: + - actor + - at + - package + - version + - secretEnv + additionalProperties: false + description: "Per package server: who acknowledged that its secret variables are readable inside the task container, and for which package, version and variable names." + installedBy: + type: string + installedAt: + type: number + updatedBy: + type: string + updatedAt: + type: number + required: + - installedVersion + - enabled + - settings + - repositories + - grantedPermissions + - acknowledgements + - installedBy + - installedAt + - updatedBy + - updatedAt + additionalProperties: false + - type: "null" + connections: + type: array + items: + type: object + properties: + name: + type: string + type: + type: string + enum: + - oauth + - token + - signing_secret + label: + type: string + state: + type: string + enum: + - connected + - not_connected + description: "`oauth` connections report not_connected until OAuth sign-in for plugins is available." + required: + type: boolean + description: A `token` connection a contributed MCP server uses; enabling waits for it. + setBy: + type: string + setAt: + type: number + required: + - name + - type + - label + - state + - required + additionalProperties: false + needsApproval: + type: boolean + description: The current manifest asks for permissions or secret acknowledgements not yet approved; the plugin keeps its old grant. + pendingPermissions: + type: array + items: + type: string + enum: + - events.read + - tasks.read + - tasks.create + - issues.read + - issues.create + - issues.comment + - pull_requests.read + - pull_requests.comment + - repositories.read + - notifications.send + pendingAcknowledgements: + type: array + items: + type: object + properties: + server: + type: string + package: + type: string + version: + type: string + secretEnv: + type: array + items: + type: string + required: + - server + - package + - version + - secretEnv + additionalProperties: false + enableBlockers: + type: array + items: + type: string + description: Why enabling is refused right now; empty when it can be enabled. + required: + - id + - available + - manifest + - state + - installation + - connections + - needsApproval + - pendingPermissions + - pendingAcknowledgements + - enableBlockers + additionalProperties: false + description: A plugin with its installation state on this instance. PullRequestReadiness: type: object properties: diff --git a/packages/api/mcp/tools.ts b/packages/api/mcp/tools.ts index 24dd99db5e..0bf9363907 100644 --- a/packages/api/mcp/tools.ts +++ b/packages/api/mcp/tools.ts @@ -35,6 +35,7 @@ import { addPlanningTools, planEpicDispatch } from './toolsPlanning.js'; import { addPullRequestTools } from './toolsPullRequests.js'; import { addContextTools } from './toolsContext.js'; import { addAdministrationTools } from './toolsAdministration.js'; +import { addPluginTools } from './toolsPlugins.js'; import { addMaintenanceTools } from './toolsMaintenance.js'; import { addArtifactTools } from './toolsArtifacts.js'; import { addManagementTools } from './toolsManagement.js'; @@ -193,6 +194,7 @@ export function createToolCatalog(deps: ToolDeps): McpTool[] { addRepositoryEventTools(tools, deps); addContextTools(tools, deps); addManagementTools(tools, deps, { todos, config, runtime }); + addPluginTools(tools, deps); addNotificationTools(tools, deps, notifications); addActivityTools(tools, deps); addAdmissionTools(tools, deps); diff --git a/packages/api/mcp/toolsPlugins.ts b/packages/api/mcp/toolsPlugins.ts new file mode 100644 index 0000000000..93f42f135f --- /dev/null +++ b/packages/api/mcp/toolsPlugins.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import type { PluginView } from '@propr/shared'; +import { PluginError, PluginStore } from '../../core/src/plugins/pluginStore.js'; +import { McpError } from './config.js'; +import { type McpTool, type ToolDeps, ok } from './tools.js'; + +/** The compact list row: state and what needs the administrator, without settings or the manifest body. */ +const summary = (plugin: PluginView) => ({ + id: plugin.id, name: plugin.manifest?.name ?? plugin.id, category: plugin.manifest?.category ?? null, + availableVersion: plugin.manifest?.version ?? null, installedVersion: plugin.installation?.installedVersion ?? null, + available: plugin.available, state: plugin.state, needsApproval: plugin.needsApproval, + pendingPermissions: plugin.pendingPermissions, enableBlockers: plugin.enableBlockers, + connections: plugin.connections.map(c => ({ name: c.name, type: c.type, state: c.state, required: c.required })), +}); + +async function read(run: () => Promise): Promise { + try { return await run(); } catch (error) { + if (!(error instanceof PluginError)) throw error; + throw new McpError(error.status === 404 ? 'NOT_FOUND' : error.status === 403 ? 'FORBIDDEN' : 'PRECONDITION_FAILED', error.message, error.status); + } +} + +/** Read-only plugin state for instance settings administrators. Install and configure in Settings or through the REST API. */ +export function addPluginTools(tools: McpTool[], deps: ToolDeps): void { + const store = new PluginStore(deps.db); + const permission = 'instance.manage_settings' as const; + tools.push({ + name: 'list_plugins', description: 'List the plugins this ProPR offers and their installation state: installed, enabled, Needs approval, why enabling is refused and connection states. Never returns secrets.', + scope: 'manage', permission, readOnly: true, schema: z.object({}).strict(), + run: async ({ principal }) => ok({ plugins: (await read(() => store.list(principal.user.id))).map(summary) }), + }); + tools.push({ + name: 'get_plugin', description: 'Read one plugin: its manifest, settings, repository limit, granted and pending permissions, connection states, pending secret acknowledgements and recent audit events. Never returns secrets.', + scope: 'manage', permission, readOnly: true, schema: z.object({ pluginId: z.string().min(1).max(48).describe('Plugin id, for example example-pack.') }).strict(), + run: async ({ principal, args }) => ok({ plugin: await read(() => store.get(principal.user.id, args.pluginId)) }), + }); +} diff --git a/packages/api/openapi/registeredRoutes.ts b/packages/api/openapi/registeredRoutes.ts index aa0a109425..09a378d1bb 100644 --- a/packages/api/openapi/registeredRoutes.ts +++ b/packages/api/openapi/registeredRoutes.ts @@ -1,4 +1,5 @@ import { createStorageRouteEntries } from '../instanceStorageRouteRegistry.js'; +import { createPluginRouteEntries } from '../pluginRouteRegistry.js'; import type { RequestHandler } from 'express'; import type { InstancePermission } from '@propr/shared'; import { @@ -42,6 +43,7 @@ export function listRegistryRouteEntries(): RouteEntry[] { return [ ...createOperationalRouteEntries(placeholderDeps()), ...createStorageRouteEntries(placeholderDeps()), + ...createPluginRouteEntries(placeholderDeps()), ...createMemberCatalogRouteEntries(placeholderDeps()), ...createManagementRouteEntries(placeholderDeps()), ]; diff --git a/packages/api/openapi/routeDocs.ts b/packages/api/openapi/routeDocs.ts index a327cc001b..97823cea77 100644 --- a/packages/api/openapi/routeDocs.ts +++ b/packages/api/openapi/routeDocs.ts @@ -3,6 +3,7 @@ import { GOAL_ROUTE_DOCS } from './routeDocsGoals.js'; import { STATS_ROUTE_DOCS } from './routeDocsStats.js'; import { SYSTEM_ROUTE_DOCS } from './routeDocsSystem.js'; import { EXTENSION_OAUTH_ROUTE_DOCS } from './routeDocsExtensionOAuth.js'; +import { PLUGIN_ROUTE_DOCS } from './routeDocsPlugins.js'; import { TASK_ROUTE_DOCS } from './routeDocsTasks.js'; import type { RouteDoc } from './types.js'; @@ -17,5 +18,6 @@ export const ROUTE_DOCS: Readonly> = { ...STATS_ROUTE_DOCS, ...SYSTEM_ROUTE_DOCS, ...EXTENSION_OAUTH_ROUTE_DOCS, + ...PLUGIN_ROUTE_DOCS, ...TASK_ROUTE_DOCS, }; diff --git a/packages/api/openapi/routeDocsPlugins.ts b/packages/api/openapi/routeDocsPlugins.ts new file mode 100644 index 0000000000..a7880c2366 --- /dev/null +++ b/packages/api/openapi/routeDocsPlugins.ts @@ -0,0 +1,36 @@ +import type { z } from 'zod'; +import { + PluginAcknowledgementRequest, PluginConnectionRequest, PluginDetailResponse, PluginEnabledRequest, PluginList, PluginRepositoriesRequest, PluginResponse, + PluginSettingsRequest, PluginUninstallRequest, +} from './schemasPlugins.js'; +import type { RouteDoc } from './types.js'; + +/** Plugin installation routes (docs/features/plugins#installing-a-plugin). */ +const BASE = '/api/instance/plugins/v1'; +const description = 'Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.'; +const headers = [{ name: 'X-ProPR-Plugins', description: 'Must equal 1; browser calls require an authorized CORS preflight.', required: true }]; +const pluginId = { pluginId: 'Plugin id, for example `example-pack`.' }; +const changed = { 200: { description: 'The plugin\'s new state.', schema: PluginResponse } }; +const mutation = (operationId: string, summary: string, schema: z.ZodType, extra: Partial = {}): RouteDoc => ({ + operationId, summary, description, tags: ['Configuration'], pathParams: pluginId, headers, requestBody: { schema }, responses: changed, ...extra, +}); + +export const PLUGIN_ROUTE_DOCS: Record = { + [`GET ${BASE}`]: { + operationId: 'listPlugins', summary: 'List the plugins of this ProPR\'s registry, and installed plugins an upgrade removed from it, with their installation state.', description, tags: ['Configuration'], + responses: { 200: { description: 'Plugins in catalog order.', schema: PluginList } }, + }, + [`GET ${BASE}/:pluginId`]: { + operationId: 'getPlugin', summary: 'Read one plugin: manifest, settings, connection states, pending approvals and acknowledgements, and its newest audit events.', description, tags: ['Configuration'], + pathParams: pluginId, responses: { 200: { description: 'The plugin.', schema: PluginDetailResponse } }, + }, + [`POST ${BASE}/:pluginId/install`]: mutation('installPlugin', 'Install a registry plugin, disabled, with its default settings; grants the manifest\'s permissions. A package server with secret variables must be acknowledged.', PluginAcknowledgementRequest), + [`PUT ${BASE}/:pluginId/settings`]: mutation('updatePluginSettings', 'Replace the settings, validated against the manifest\'s settings schema. Required settings cannot be removed while the plugin is enabled.', PluginSettingsRequest), + [`PUT ${BASE}/:pluginId/repositories`]: mutation('setPluginRepositories', 'Limit the plugin to repositories, or clear the limit (all repositories).', PluginRepositoriesRequest), + [`PUT ${BASE}/:pluginId/connections/:connectionName`]: mutation('setPluginConnection', 'Set or clear a `token` or `signing_secret` connection. The secret is sealed and write-only.', PluginConnectionRequest, { + pathParams: { ...pluginId, connectionName: 'Connection name from the manifest.' }, + }), + [`PUT ${BASE}/:pluginId/enabled`]: mutation('setPluginEnabled', 'Enable or disable. Enabling is refused (409, with the reasons) while a required setting, a required token connection or a secret acknowledgement is missing. Disabling keeps settings and connections.', PluginEnabledRequest), + [`POST ${BASE}/:pluginId/approve`]: mutation('approvePlugin', 'Approve the permission set of the current manifest after an upgrade, with the secret acknowledgements it needs again.', PluginAcknowledgementRequest), + [`POST ${BASE}/:pluginId/uninstall`]: mutation('uninstallPlugin', 'Uninstall: deletes the installation, its settings and its connections. The audit trail stays.', PluginUninstallRequest), +}; diff --git a/packages/api/openapi/schemasPlugins.ts b/packages/api/openapi/schemasPlugins.ts new file mode 100644 index 0000000000..3eae1d30d2 --- /dev/null +++ b/packages/api/openapi/schemasPlugins.ts @@ -0,0 +1,63 @@ +import { z } from 'zod'; +import { PLUGIN_AUDIT_ACTIONS, PLUGIN_CATEGORIES, PLUGIN_CONNECTION_TYPES, PLUGIN_PERMISSIONS } from '@propr/shared'; +import { component } from './schemas.js'; + +/** OpenAPI schemas of the plugin installation routes. Connection secrets never appear in any response. */ +const permission = z.enum(PLUGIN_PERMISSIONS); +const settingValue = z.union([z.string(), z.number(), z.boolean(), z.array(z.string()), z.record(z.string(), z.string())]); +const PluginManifestSummary = z.object({ + id: z.string(), name: z.string(), description: z.string(), version: z.string(), category: z.enum(PLUGIN_CATEGORIES), homepage: z.string(), + permissions: z.array(z.object({ name: permission, description: z.string() })), + connections: z.array(z.object({ type: z.enum(PLUGIN_CONNECTION_TYPES), name: z.string(), label: z.string(), help: z.string(), helpUrl: z.string() }).loose()), + settings: z.object({ type: z.literal('object'), properties: z.record(z.string(), z.object({}).loose()), required: z.array(z.string()) }).describe('The settings schema: a JSON Schema subset (string, number, boolean, string list, repository map).'), + outboundHosts: z.array(z.string()), + contributes: z.object({ + mcpServers: z.array(z.object({}).loose()).describe('Contributed MCP servers without launch arguments or environment values.'), + skills: z.array(z.string()), automationTemplates: z.array(z.string()), + commands: z.array(z.object({ name: z.string(), description: z.string() })), subscriptions: z.array(z.string()), + webhooks: z.array(z.object({ name: z.string(), description: z.string() })), + }), +}).describe('The public projection of the plugin manifest.'); +const acknowledgement = z.object({ actor: z.string(), at: z.number(), package: z.string(), version: z.string(), secretEnv: z.array(z.string()) }); +const PluginInstallation = z.object({ + installedVersion: z.string().describe('The manifest version installed or last approved.'), + enabled: z.boolean(), + settings: z.record(z.string(), settingValue).describe('Setting values; never secrets, which are connections.'), + repositories: z.array(z.string()).nullable().describe('Repositories (`owner/repo`) the plugin is limited to; null for all repositories.'), + grantedPermissions: z.array(permission), + acknowledgements: z.record(z.string(), acknowledgement).describe('Per package server: who acknowledged that its secret variables are readable inside the task container, and for which package, version and variable names.'), + installedBy: z.string(), installedAt: z.number(), updatedBy: z.string(), updatedAt: z.number(), +}); +const PluginConnectionStatus = z.object({ + name: z.string(), type: z.enum(PLUGIN_CONNECTION_TYPES), label: z.string(), + state: z.enum(['connected', 'not_connected']).describe('`oauth` connections report not_connected until OAuth sign-in for plugins is available.'), + required: z.boolean().describe('A `token` connection a contributed MCP server uses; enabling waits for it.'), + setBy: z.string().optional(), setAt: z.number().optional(), +}); +const pluginViewShape = { + id: z.string(), + available: z.boolean().describe('The plugin is in this ProPR version\'s registry.'), + manifest: PluginManifestSummary.nullable(), + state: z.enum(['available', 'installed', 'enabled']), + installation: PluginInstallation.nullable(), + connections: z.array(PluginConnectionStatus), + needsApproval: z.boolean().describe('The current manifest asks for permissions or secret acknowledgements not yet approved; the plugin keeps its old grant.'), + pendingPermissions: z.array(permission), + pendingAcknowledgements: z.array(z.object({ server: z.string(), package: z.string(), version: z.string(), secretEnv: z.array(z.string()) })), + enableBlockers: z.array(z.string()).describe('Why enabling is refused right now; empty when it can be enabled.'), +}; +export const PluginView = component('PluginView', z.object(pluginViewShape), 'A plugin with its installation state on this instance.'); +export const PluginList = component('PluginList', z.object({ plugins: z.array(PluginView) })); +export const PluginDetailResponse = component('PluginDetailResponse', z.object({ plugin: z.object({ + ...pluginViewShape, + audit: z.array(z.object({ action: z.enum(PLUGIN_AUDIT_ACTIONS), actor: z.string(), at: z.number(), detail: z.object({}).loose() })).describe('The newest 50 management actions, newest first.'), +}) })); +export const PluginResponse = component('PluginResponse', z.object({ plugin: PluginView })); +export const PluginAcknowledgementRequest = component('PluginAcknowledgementRequest', z.strictObject({ + acknowledgements: z.array(z.string()).optional().describe('Package servers whose secret variables the administrator confirms are readable by the agent CLI, its shell tools and repository setup scripts in the task container.'), +})); +export const PluginSettingsRequest = component('PluginSettingsRequest', z.strictObject({ settings: z.record(z.string(), settingValue.nullable()).describe('The complete settings; validated against the manifest\'s settings schema. Null values are removed.') })); +export const PluginRepositoriesRequest = component('PluginRepositoriesRequest', z.strictObject({ repositories: z.array(z.string()).max(256).nullable().describe('owner/repo names; null or an empty list for all repositories.') })); +export const PluginConnectionRequest = component('PluginConnectionRequest', z.strictObject({ value: z.string().max(4096).nullable().describe('The secret to store (write-only), or null to clear it.') })); +export const PluginEnabledRequest = component('PluginEnabledRequest', z.strictObject({ enabled: z.boolean() })); +export const PluginUninstallRequest = component('PluginUninstallRequest', z.strictObject({ confirm: z.string().describe('Must repeat the plugin id.') })); diff --git a/packages/api/pluginRouteRegistry.ts b/packages/api/pluginRouteRegistry.ts new file mode 100644 index 0000000000..9d23c7181d --- /dev/null +++ b/packages/api/pluginRouteRegistry.ts @@ -0,0 +1,20 @@ +import type { createPluginRoutes } from './routes/pluginRoutes.js'; +import { requireManageSettings } from './permissionGuards.js'; +import type { RouteEntry } from './routeRegistry.js'; +import { PLUGIN_API_BASE } from '@propr/shared'; + +/** Plugin installation routes; kept out of `routeRegistry.ts`, which is at its size limit. */ +export function createPluginRouteEntries(plugins: ReturnType): RouteEntry[] { + const base = PLUGIN_API_BASE; + return [ + ['get', base, requireManageSettings, plugins.list], + ['get', `${base}/:pluginId`, requireManageSettings, plugins.get], + ['post', `${base}/:pluginId/install`, requireManageSettings, plugins.install], + ['put', `${base}/:pluginId/settings`, requireManageSettings, plugins.updateSettings], + ['put', `${base}/:pluginId/repositories`, requireManageSettings, plugins.setRepositories], + ['put', `${base}/:pluginId/connections/:connectionName`, requireManageSettings, plugins.setConnection], + ['put', `${base}/:pluginId/enabled`, requireManageSettings, plugins.setEnabled], + ['post', `${base}/:pluginId/approve`, requireManageSettings, plugins.approve], + ['post', `${base}/:pluginId/uninstall`, requireManageSettings, plugins.uninstall], + ]; +} diff --git a/packages/api/routes/pluginRoutes.ts b/packages/api/routes/pluginRoutes.ts new file mode 100644 index 0000000000..02df7e5107 --- /dev/null +++ b/packages/api/routes/pluginRoutes.ts @@ -0,0 +1,48 @@ +import type { Request, RequestHandler } from 'express'; +import { db } from '@propr/core'; +import { PluginError, PluginStore } from '../../core/src/plugins/pluginStore.js'; +import { isDemoViewer } from '../demoMode.js'; +import { refuseDemoViewer } from '../permissionGuards.js'; + +/** + * Plugin installation state under `/api/instance/plugins/v1`. Every route, + * reads included, needs a current durable administrator with + * `instance.manage_settings`; mutations additionally need the + * preflight-protected JSON contract (`X-ProPR-Plugins: 1`). Connection + * secrets are write-only: no response carries them. + */ +export function createPluginRoutes(store = new PluginStore(db)) { + const handle = (run: (req: Request, actor: string) => Promise): RequestHandler => async (req, res, next) => { + // Plugins hold sealed per-installation secrets; the demo explains instead of reading them. + if (isDemoViewer(req.authorization)) return refuseDemoViewer(req, res, next); + try { + if (!req.user?.id) throw new PluginError(401, 'Authentication required'); + if (req.authorization?.role !== 'admin' || !req.authorization.permissions.includes('instance.manage_settings')) throw new PluginError(403, 'Instance settings administration is required'); + if (req.method !== 'GET' && (req.get('X-ProPR-Plugins') !== '1' || !req.is('application/json'))) throw new PluginError(403, 'Plugin changes require the preflight-protected JSON contract'); + // Durable authority before any input is read; the store checks it again inside each write. + await store.authorize(req.user.id); + res.set('Cache-Control', 'no-store').json(await run(req, req.user.id)); + } catch (error) { + res.status(error instanceof PluginError ? error.status : 400).json({ error: error instanceof PluginError ? error.message : 'Plugin operation unavailable or invalid' }); + } + }; + const body = (req: Request, keys: string[], required: string[] = keys): Record => { + const value = req.body as unknown; + if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !keys.includes(k)) || required.some(k => !Object.hasOwn(value, k))) throw new PluginError(400, 'Invalid plugin request'); + return value as Record; + }; + const id = (req: Request) => String(req.params.pluginId); + return { + list: handle(async (_req, actor) => ({ plugins: await store.list(actor) })), + get: handle(async (req, actor) => ({ plugin: await store.get(actor, id(req)) })), + /** `acknowledgements` names the package servers whose secret variables the administrator confirmed are readable in the task container. */ + install: handle(async (req, actor) => ({ plugin: await store.install(actor, id(req), body(req, ['acknowledgements'], []).acknowledgements) })), + updateSettings: handle(async (req, actor) => ({ plugin: await store.updateSettings(actor, id(req), body(req, ['settings']).settings) })), + setRepositories: handle(async (req, actor) => ({ plugin: await store.setRepositories(actor, id(req), body(req, ['repositories']).repositories) })), + /** `value` sets the secret; `null` clears it. */ + setConnection: handle(async (req, actor) => ({ plugin: await store.setConnection(actor, id(req), String(req.params.connectionName), body(req, ['value']).value) })), + setEnabled: handle(async (req, actor) => ({ plugin: await store.setEnabled(actor, id(req), body(req, ['enabled']).enabled) })), + approve: handle(async (req, actor) => ({ plugin: await store.approve(actor, id(req), body(req, ['acknowledgements'], []).acknowledgements) })), + uninstall: handle(async (req, actor) => ({ plugin: await store.uninstall(actor, id(req), body(req, ['confirm']).confirm) })), + }; +} diff --git a/packages/api/server.ts b/packages/api/server.ts index 246625d34f..98b2f55db6 100644 --- a/packages/api/server.ts +++ b/packages/api/server.ts @@ -1,6 +1,8 @@ import { createExtensionLibraryRoutes } from './routes/extensionProfileRoutes.js'; import { startConfiguredFleetData, refusePublicFleetData } from './fleetDataListener.js'; import { createStorageRouteEntries } from './instanceStorageRouteRegistry.js'; +import { createPluginRouteEntries } from './pluginRouteRegistry.js'; +import { createPluginRoutes } from './routes/pluginRoutes.js'; import { protectStorageQueue } from '../core/src/storage/lifecycle.js'; import { createDefaultStorageService, createInstanceStorageRoutes } from './routes/instanceStorageRoutes.js'; import { startDashboardReadService, type DashboardReadService } from './services/dashboardReadService.js'; @@ -512,6 +514,7 @@ export function setupRoutes(): void { const routes = [ ...operationalRoutes, ...createStorageRouteEntries(storageRoutes), + ...createPluginRouteEntries(createPluginRoutes()), ...createMemberCatalogRouteEntries({ instanceCatalogRoutes }), ...createManagementRouteEntries({ adminRoutes, diff --git a/packages/api/test/pluginRoutes.test.ts b/packages/api/test/pluginRoutes.test.ts new file mode 100644 index 0000000000..8aaeb117cb --- /dev/null +++ b/packages/api/test/pluginRoutes.test.ts @@ -0,0 +1,159 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import { rateLimit } from 'express-rate-limit'; +import knex from 'knex'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { AddressInfo } from 'node:net'; +import { z } from 'zod'; +import { pluginManifestSchema, type PluginManifest } from '@propr/shared'; +import { getOfficialPlugin } from '../../plugins/src/index.js'; +import { PluginStore } from '../../core/src/plugins/pluginStore.js'; +import { up as identityMigration } from '../../core/src/db/migrations/20261013210000_extension_profiles.js'; +import { up as pluginMigration } from '../../core/src/db/migrations/20261114000000_plugin_installations.js'; + +const SENTINEL = 'plugin-api-token-sentinel-41c7'; +const ID = 'example-pack'; +const BASE = '/api/instance/plugins/v1'; + +function assertConforms(schema: z.ZodType, body: unknown, label: string): void { + const result = schema.safeParse(body); + assert.ok(result.success, `${label} does not match its schema:\n${result.success ? '' : z.prettifyError(result.error)}`); + assert.deepEqual(result.data, body, `${label} sends fields its schema does not allow`); +} + +test('plugin REST API and MCP read tools: administrators only, audited, secret-free and schema-conformant', async t => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'plugin-api-')); + process.env.DB_FILENAME = path.join(root, 'fixture.sqlite'); + const { createPluginRoutes } = await import('../routes/pluginRoutes.js'); + const { createPluginRouteEntries } = await import('../pluginRouteRegistry.js'); + const { registerRouteEntries } = await import('../routeRegistry.js'); + const { addPluginTools } = await import('../mcp/toolsPlugins.js'); + const schemas = await import('../openapi/schemasPlugins.js'); + const { ROUTE_DOCS } = await import('../openapi/routeDocs.js'); + + const database = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await database.schema.createTable('instance_members', c => { c.string('github_user_id').primary(); c.string('role'); }); + await database.schema.createTable('agent_definitions', c => { c.string('id').primary(); c.string('name'); }); + await identityMigration(database); await pluginMigration(database); + // 3 holds the admin role in the session but is no longer a durable administrator. + await database('instance_members').insert([{ github_user_id: '1', role: 'admin' }, { github_user_id: '2', role: 'member' }, { github_user_id: '3', role: 'member' }]); + const example = getOfficialPlugin(ID, { includeDevelopment: true })!.manifest; + let catalog: PluginManifest[] = [example]; + const store = new PluginStore(database, { SYSTEM_TASK_SECRET: 'plugin-api-custody' }, async () => catalog); + + const app = express(); + app.use(rateLimit({ windowMs: 60_000, limit: 1000 })); + app.use(express.json()); + app.use((req, _res, next) => { + const actor = req.get('x-test-actor'); if (actor) req.user = { id: actor, username: 'fixture' } as typeof req.user; + const member = req.get('x-test-role') === 'member'; + req.authorization = { role: member ? 'member' : 'admin', permissions: member ? [] : ['instance.manage_settings'], source: 'local' }; next(); + }); + const entries = createPluginRouteEntries(createPluginRoutes(store)); + registerRouteEntries(app, entries); + const server = app.listen(0, '127.0.0.1'); await new Promise(r => server.once('listening', r)); + t.after(async () => { await new Promise(r => server.close(() => r())); await database.destroy(); await (await import('@propr/core')).closeConnection(); await fs.rm(root, { recursive: true, force: true }); }); + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + const bodies: string[] = []; + const admin = { 'x-test-actor': '1', 'content-type': 'application/json', 'x-propr-plugins': '1' }; + const call = async (method: string, route: string, body?: unknown, headers: Record = admin) => { + const response = await fetch(origin + BASE + route, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); + const text = await response.text(); bodies.push(text); + return { status: response.status, body: JSON.parse(text), cache: response.headers.get('cache-control') }; + }; + + // Every route is documented. + for (const [method, route] of entries) assert.ok(ROUTE_DOCS[`${method.toUpperCase()} ${route}`], `${method} ${route} has an OpenAPI entry`); + + const list = await call('GET', ''); + assert.equal(list.status, 200); + assert.equal(list.cache, 'no-store'); + assertConforms(schemas.PluginList, list.body, 'GET plugins'); + assert.equal(list.body.plugins[0].state, 'available'); + + // Install needs the package server acknowledgement. + const unacknowledged = await call('POST', `/${ID}/install`, {}); + assert.equal(unacknowledged.status, 400); + assert.match(unacknowledged.body.error, /example-local/); + const installed = await call('POST', `/${ID}/install`, { acknowledgements: ['example-local'] }); + assert.equal(installed.status, 200); + assertConforms(schemas.PluginResponse, installed.body, 'install'); + assert.equal(installed.body.plugin.state, 'installed'); + + const refusedEnable = await call('PUT', `/${ID}/enabled`, { enabled: true }); + assert.equal(refusedEnable.status, 409); + assert.match(refusedEnable.body.error, /Connection Example API token \(example-token\) is not set/); + assert.equal((await call('PUT', `/${ID}/settings`, { settings: { verbosity: 'loud' } })).status, 400); + assert.equal((await call('PUT', `/${ID}/settings`, { settings: { verbosity: 'quiet', maxItems: 3 } })).status, 200); + assert.equal((await call('PUT', `/${ID}/repositories`, { repositories: ['acme/app'] })).body.plugin.installation.repositories[0], 'acme/app'); + const connected = await call('PUT', `/${ID}/connections/example-token`, { value: SENTINEL }); + assert.equal(connected.status, 200); + assert.equal(connected.body.plugin.connections.find((c: { name: string }) => c.name === 'example-token').state, 'connected'); + assert.equal((await call('PUT', `/${ID}/connections/example-account`, { value: SENTINEL })).status, 400); + assert.equal((await call('PUT', `/${ID}/connections/missing`, { value: SENTINEL })).status, 404); + assert.equal((await call('PUT', `/${ID}/enabled`, { enabled: true })).body.plugin.state, 'enabled'); + assert.equal((await call('PUT', `/${ID}/enabled`, { enabled: false })).body.plugin.state, 'installed'); + const reenabled = await call('PUT', `/${ID}/enabled`, { enabled: true }); + assert.equal(reenabled.body.plugin.state, 'enabled'); + assert.equal(reenabled.body.plugin.installation.settings.verbosity, 'quiet', 'settings survive disable'); + + // An upgraded manifest that adds a permission: Needs approval, old grant kept until approved. + catalog = [pluginManifestSchema.parse({ ...structuredClone(example), version: '0.2.0', permissions: [...example.permissions, 'pull_requests.read'] })]; + const pending = await call('GET', `/${ID}`); + assertConforms(schemas.PluginDetailResponse, pending.body, 'GET plugin'); + assert.equal(pending.body.plugin.needsApproval, true); + assert.deepEqual(pending.body.plugin.pendingPermissions, ['pull_requests.read']); + assert.deepEqual(pending.body.plugin.installation.grantedPermissions, ['events.read', 'tasks.read']); + const approved = await call('POST', `/${ID}/approve`, {}); + assert.equal(approved.body.plugin.needsApproval, false); + assert.equal(approved.body.plugin.installation.installedVersion, '0.2.0'); + + // Non-administrators are refused on every route, reads included; mutations need the preflight-protected JSON contract. + const routes = entries.map(([method, route]) => [method.toUpperCase(), route.replace(BASE, '').replace(':pluginId', ID).replace(':connectionName', 'example-token')] as const); + for (const [method, route] of routes) { + const body = method === 'GET' ? undefined : {}; + for (const headers of [ + { ...admin, 'x-test-role': 'member' }, + { ...admin, 'x-test-actor': '3' }, + { ...admin, 'x-test-actor': '2', 'x-test-role': 'admin' }, + { 'content-type': 'application/json', 'x-propr-plugins': '1' }, + ]) { + const response = await call(method, route, body, headers); + assert.ok([401, 403].includes(response.status), `${method} ${route} as ${JSON.stringify(headers)} answered ${response.status}`); + } + if (method !== 'GET') assert.equal((await call(method, route, body, { 'x-test-actor': '1', 'content-type': 'application/json' })).status, 403, `${method} ${route} without X-ProPR-Plugins`); + } + + // MCP read-only tools project the same state, without secrets. + const tools: { name: string; readOnly?: boolean; permission?: string; run: (input: { principal: unknown; args: Record }) => Promise<{ data: unknown }> }[] = []; + addPluginTools(tools as never, { db: database } as never); + assert.deepEqual(tools.map(tool => [tool.name, tool.readOnly, tool.permission]), [['list_plugins', true, 'instance.manage_settings'], ['get_plugin', true, 'instance.manage_settings']]); + const principal = { user: { id: '1' } }; + // The MCP tools use the default official catalog, which includes the example plugin only with the development flag. + process.env.PROPR_PLUGINS_DEVELOPMENT = '1'; + t.after(() => { delete process.env.PROPR_PLUGINS_DEVELOPMENT; }); + const listed = await tools[0].run({ principal, args: {} }) as { data: { plugins: { id: string; state: string; connections: { name: string; state: string }[] }[] } }; + const row = listed.data.plugins.find(p => p.id === ID)!; + assert.equal(row.state, 'enabled'); + assert.equal(row.connections.find(c => c.name === 'example-token')?.state, 'connected'); + const detail = await tools[1].run({ principal, args: { pluginId: ID } }) as { data: { plugin: { audit: { action: string }[] } } }; + assert.ok(detail.data.plugin.audit.some(event => event.action === 'set_connection')); + await assert.rejects(tools[1].run({ principal: { user: { id: '2' } }, args: { pluginId: ID } }), /administrator/); + await assert.rejects(tools[1].run({ principal, args: { pluginId: 'no-such-plugin' } }), (error: { code?: string }) => error.code === 'NOT_FOUND'); + bodies.push(JSON.stringify(listed), JSON.stringify(detail)); + + // Uninstall needs the confirmation, deletes settings and connections, and is audited. + assert.equal((await call('POST', `/${ID}/uninstall`, { confirm: 'nope' })).status, 400); + const disabledBeforeUninstall = await call('POST', `/${ID}/uninstall`, { confirm: ID }); + assert.equal(disabledBeforeUninstall.body.plugin.state, 'available'); + assert.equal((await database('plugin_secrets').count({ n: '*' }))[0].n, 0); + const audit = await call('GET', `/${ID}`); + assert.deepEqual(audit.body.plugin.audit.map((e: { action: string }) => e.action).reverse(), [ + 'install', 'update_settings', 'set_repositories', 'set_connection', 'enable', 'disable', 'enable', 'approve', 'uninstall', + ]); + + for (const text of bodies) assert.ok(!text.includes(SENTINEL), 'no response carries a connection secret'); +}); diff --git a/packages/client/src/generated/apiTypes.ts b/packages/client/src/generated/apiTypes.ts index b7c8035195..640d163928 100644 --- a/packages/client/src/generated/apiTypes.ts +++ b/packages/client/src/generated/apiTypes.ts @@ -827,6 +827,269 @@ export interface NotificationUnreadCount { unreadCount: number; } +export interface PluginAcknowledgementRequest { + /** Package servers whose secret variables the administrator confirms are readable by the agent CLI, its shell tools and repository setup scripts in the task container. */ + acknowledgements?: string[]; +} + +export interface PluginConnectionRequest { + /** The secret to store (write-only), or null to clear it. */ + value: string | null; +} + +export interface PluginDetailResponse { + plugin: { + id: string; + /** The plugin is in this ProPR version's registry. */ + available: boolean; + manifest: { + id: string; + name: string; + description: string; + version: string; + category: "messaging" | "issue-tracking" | "observability" | "deployment" | "context" | "other"; + homepage: string; + permissions: Array<{ + name: "events.read" | "tasks.read" | "tasks.create" | "issues.read" | "issues.create" | "issues.comment" | "pull_requests.read" | "pull_requests.comment" | "repositories.read" | "notifications.send"; + description: string; + }>; + connections: Array<{ + type: "oauth" | "token" | "signing_secret"; + name: string; + label: string; + help: string; + helpUrl: string; + [key: string]: unknown; + }>; + /** The settings schema: a JSON Schema subset (string, number, boolean, string list, repository map). */ + settings: { + type: "object"; + properties: { + [key: string]: { + [key: string]: unknown; + }; + }; + required: string[]; + }; + outboundHosts: string[]; + contributes: { + /** Contributed MCP servers without launch arguments or environment values. */ + mcpServers: { + [key: string]: unknown; + }[]; + skills: string[]; + automationTemplates: string[]; + commands: { + name: string; + description: string; + }[]; + subscriptions: string[]; + webhooks: { + name: string; + description: string; + }[]; + }; + } | null; + state: "available" | "installed" | "enabled"; + installation: { + /** The manifest version installed or last approved. */ + installedVersion: string; + enabled: boolean; + /** Setting values; never secrets, which are connections. */ + settings: { + [key: string]: string | number | boolean | string[] | { + [key: string]: string; + }; + }; + /** Repositories (`owner/repo`) the plugin is limited to; null for all repositories. */ + repositories: string[] | null; + grantedPermissions: Array<"events.read" | "tasks.read" | "tasks.create" | "issues.read" | "issues.create" | "issues.comment" | "pull_requests.read" | "pull_requests.comment" | "repositories.read" | "notifications.send">; + /** Per package server: who acknowledged that its secret variables are readable inside the task container, and for which package, version and variable names. */ + acknowledgements: { + [key: string]: { + actor: string; + at: number; + package: string; + version: string; + secretEnv: string[]; + }; + }; + installedBy: string; + installedAt: number; + updatedBy: string; + updatedAt: number; + } | null; + connections: Array<{ + name: string; + type: "oauth" | "token" | "signing_secret"; + label: string; + /** `oauth` connections report not_connected until OAuth sign-in for plugins is available. */ + state: "connected" | "not_connected"; + /** A `token` connection a contributed MCP server uses; enabling waits for it. */ + required: boolean; + setBy?: string; + setAt?: number; + }>; + /** The current manifest asks for permissions or secret acknowledgements not yet approved; the plugin keeps its old grant. */ + needsApproval: boolean; + pendingPermissions: Array<"events.read" | "tasks.read" | "tasks.create" | "issues.read" | "issues.create" | "issues.comment" | "pull_requests.read" | "pull_requests.comment" | "repositories.read" | "notifications.send">; + pendingAcknowledgements: { + server: string; + package: string; + version: string; + secretEnv: string[]; + }[]; + /** Why enabling is refused right now; empty when it can be enabled. */ + enableBlockers: string[]; + /** The newest 50 management actions, newest first. */ + audit: Array<{ + action: "install" | "update_settings" | "set_repositories" | "set_connection" | "clear_connection" | "enable" | "disable" | "approve" | "uninstall"; + actor: string; + at: number; + detail: { + [key: string]: unknown; + }; + }>; + }; +} + +export interface PluginEnabledRequest { + enabled: boolean; +} + +export interface PluginList { + plugins: PluginView[]; +} + +export interface PluginRepositoriesRequest { + /** owner/repo names; null or an empty list for all repositories. */ + repositories: string[] | null; +} + +export interface PluginResponse { + plugin: PluginView; +} + +export interface PluginSettingsRequest { + /** The complete settings; validated against the manifest's settings schema. Null values are removed. */ + settings: { + [key: string]: string | number | boolean | string[] | { + [key: string]: string; + } | null; + }; +} + +export interface PluginUninstallRequest { + /** Must repeat the plugin id. */ + confirm: string; +} + +/** A plugin with its installation state on this instance. */ +export interface PluginView { + id: string; + /** The plugin is in this ProPR version's registry. */ + available: boolean; + manifest: { + id: string; + name: string; + description: string; + version: string; + category: "messaging" | "issue-tracking" | "observability" | "deployment" | "context" | "other"; + homepage: string; + permissions: Array<{ + name: "events.read" | "tasks.read" | "tasks.create" | "issues.read" | "issues.create" | "issues.comment" | "pull_requests.read" | "pull_requests.comment" | "repositories.read" | "notifications.send"; + description: string; + }>; + connections: Array<{ + type: "oauth" | "token" | "signing_secret"; + name: string; + label: string; + help: string; + helpUrl: string; + [key: string]: unknown; + }>; + /** The settings schema: a JSON Schema subset (string, number, boolean, string list, repository map). */ + settings: { + type: "object"; + properties: { + [key: string]: { + [key: string]: unknown; + }; + }; + required: string[]; + }; + outboundHosts: string[]; + contributes: { + /** Contributed MCP servers without launch arguments or environment values. */ + mcpServers: { + [key: string]: unknown; + }[]; + skills: string[]; + automationTemplates: string[]; + commands: { + name: string; + description: string; + }[]; + subscriptions: string[]; + webhooks: { + name: string; + description: string; + }[]; + }; + } | null; + state: "available" | "installed" | "enabled"; + installation: { + /** The manifest version installed or last approved. */ + installedVersion: string; + enabled: boolean; + /** Setting values; never secrets, which are connections. */ + settings: { + [key: string]: string | number | boolean | string[] | { + [key: string]: string; + }; + }; + /** Repositories (`owner/repo`) the plugin is limited to; null for all repositories. */ + repositories: string[] | null; + grantedPermissions: Array<"events.read" | "tasks.read" | "tasks.create" | "issues.read" | "issues.create" | "issues.comment" | "pull_requests.read" | "pull_requests.comment" | "repositories.read" | "notifications.send">; + /** Per package server: who acknowledged that its secret variables are readable inside the task container, and for which package, version and variable names. */ + acknowledgements: { + [key: string]: { + actor: string; + at: number; + package: string; + version: string; + secretEnv: string[]; + }; + }; + installedBy: string; + installedAt: number; + updatedBy: string; + updatedAt: number; + } | null; + connections: Array<{ + name: string; + type: "oauth" | "token" | "signing_secret"; + label: string; + /** `oauth` connections report not_connected until OAuth sign-in for plugins is available. */ + state: "connected" | "not_connected"; + /** A `token` connection a contributed MCP server uses; enabling waits for it. */ + required: boolean; + setBy?: string; + setAt?: number; + }>; + /** The current manifest asks for permissions or secret acknowledgements not yet approved; the plugin keeps its old grant. */ + needsApproval: boolean; + pendingPermissions: Array<"events.read" | "tasks.read" | "tasks.create" | "issues.read" | "issues.create" | "issues.comment" | "pull_requests.read" | "pull_requests.comment" | "repositories.read" | "notifications.send">; + pendingAcknowledgements: { + server: string; + package: string; + version: string; + secretEnv: string[]; + }[]; + /** Why enabling is refused right now; empty when it can be enabled. */ + enableBlockers: string[]; +} + /** Read-only readiness verdict of one pull request. Evaluating it never writes to GitHub or Redis, never updates the branch or merges, and never changes auto-merge state. */ export interface PullRequestReadiness { repository: string; diff --git a/packages/core/src/db/migrations/20261114000000_plugin_installations.js b/packages/core/src/db/migrations/20261114000000_plugin_installations.js new file mode 100644 index 0000000000..152a43acc7 --- /dev/null +++ b/packages/core/src/db/migrations/20261114000000_plugin_installations.js @@ -0,0 +1,45 @@ +// Plugin installation state: one row per plugin an administrator installed on +// this instance, with its enabled flag, settings, repository limit, granted +// permissions and package-server secret acknowledgements. Token and signing +// secret connections are sealed with the extension private-secret mechanism in +// their own table and erased from native exports. Management actions are kept +// in an append-only audit table that outlives an uninstall. + +export async function up(knex) { + if (!await knex.schema.hasTable('plugin_installations')) { + await knex.schema.createTable('plugin_installations', t => { + t.string('plugin_id').primary(); + // The manifest version installed or last approved. + t.string('manifest_version').notNullable(); + t.boolean('enabled').notNullable().defaultTo(false); + t.text('settings').notNullable().defaultTo('{}'); + // JSON list of owner/repo; NULL means all repositories. + t.text('repositories'); + // JSON set of granted permissions. + t.text('granted_permissions').notNullable().defaultTo('[]'); + // JSON map of package server name -> { actor, at, package, version, secretEnv }. + t.text('acknowledgements').notNullable().defaultTo('{}'); + t.string('installed_by').notNullable(); t.bigInteger('installed_at').notNullable(); + t.string('updated_by').notNullable(); t.bigInteger('updated_at').notNullable(); + }); + } + if (!await knex.schema.hasTable('plugin_secrets')) { + await knex.schema.createTable('plugin_secrets', t => { + t.string('plugin_id').notNullable(); t.string('connection_name').notNullable(); + t.text('sealed').notNullable(); + t.string('set_by').notNullable(); t.bigInteger('set_at').notNullable(); + t.primary(['plugin_id', 'connection_name']); + }); + } + if (!await knex.schema.hasTable('plugin_audit')) { + await knex.schema.createTable('plugin_audit', t => { + t.increments('id').primary(); t.string('plugin_id').notNullable(); + t.string('action').notNullable(); t.string('actor').notNullable(); t.bigInteger('at').notNullable(); + t.text('detail').notNullable().defaultTo('{}'); + t.index(['plugin_id', 'id']); + }); + } +} + +// Installations are customer configuration and stay on rollback; older versions ignore them. +export async function down() {} diff --git a/packages/core/src/plugins/pluginCatalog.ts b/packages/core/src/plugins/pluginCatalog.ts new file mode 100644 index 0000000000..0eb76f4519 --- /dev/null +++ b/packages/core/src/plugins/pluginCatalog.ts @@ -0,0 +1,22 @@ +import type { PluginManifest } from '@propr/shared'; +import logger from '../utils/logger.js'; + +/** The plugins this ProPR offers, in catalog order. */ +export type PluginCatalogSource = () => Promise; + +interface PluginRegistryModule { officialPlugins(): { manifest: PluginManifest }[] } +// Not a literal import: Core stays buildable and runnable where the registry +// package is not part of the image; the catalog is then empty. +const REGISTRY_MODULE = '@propr/plugins'; +let warned = false; + +/** The official plugin registry (`@propr/plugins`), subject to its development flag. */ +export const officialPluginCatalog: PluginCatalogSource = async () => { + try { + const registry = await import(REGISTRY_MODULE) as PluginRegistryModule; + return registry.officialPlugins().map(plugin => plugin.manifest); + } catch (error) { + if (!warned) { warned = true; logger.warn({ error: error instanceof Error ? error.message : String(error) }, '[plugins] Official plugin registry unavailable; no plugins are offered'); } + return []; + } +}; diff --git a/packages/core/src/plugins/pluginState.ts b/packages/core/src/plugins/pluginState.ts new file mode 100644 index 0000000000..98202b2ce0 --- /dev/null +++ b/packages/core/src/plugins/pluginState.ts @@ -0,0 +1,111 @@ +import { + missingRequiredPluginSettings, pluginPackageSecretServers, publicPluginManifest, requiredPluginConnections, + type PluginConnectionStatus, type PluginInstallationRecord, type PluginManifest, type PluginPendingAcknowledgement, type PluginPermission, + type PluginSecretAcknowledgement, type PluginSettingValues, type PluginView, +} from '@propr/shared'; + +export class PluginError extends Error { constructor(readonly status: number, message: string) { super(message); } } + +/** A `plugin_installations` row as stored. */ +export interface PluginInstallationRow { + plugin_id: string; manifest_version: string; enabled: number | boolean; settings: string; repositories: string | null; + granted_permissions: string; acknowledgements: string; + installed_by: string; installed_at: number | string; updated_by: string; updated_at: number | string; +} +export interface PluginSecretRow { plugin_id: string; connection_name: string; set_by: string; set_at: number | string } + +const json = (value: string | null, fallback: T): T => { + if (value === null) return fallback; + try { return JSON.parse(value) as T; } catch { return fallback; } +}; +export function installationRecord(row: PluginInstallationRow): PluginInstallationRecord { + return { + installedVersion: row.manifest_version, enabled: Boolean(row.enabled), + settings: json(row.settings, {}), repositories: json(row.repositories, null), + grantedPermissions: json(row.granted_permissions, []), + acknowledgements: json>(row.acknowledgements, {}), + installedBy: row.installed_by, installedAt: Number(row.installed_at), updatedBy: row.updated_by, updatedAt: Number(row.updated_at), + }; +} + +const binding = (value: { package: string; version: string; secretEnv: string[] }) => JSON.stringify([value.package, value.version, [...value.secretEnv].sort()]); +const secretNames = (server: ReturnType[number]) => server.secretEnv.map(secret => secret.name); + +/** Package servers whose secret variables have no acknowledgement for their current package, version and variable names. */ +export function pendingAcknowledgements(manifest: PluginManifest, acknowledgements: Record): PluginPendingAcknowledgement[] { + return pluginPackageSecretServers(manifest).flatMap(server => { + const current = { package: server.package, version: server.version, secretEnv: secretNames(server) }; + const recorded = Object.hasOwn(acknowledgements, server.name) ? acknowledgements[server.name] : undefined; + return recorded && binding(recorded) === binding(current) ? [] : [{ server: server.name, ...current }]; + }); +} + +/** + * The acknowledgements to record: those still matching the manifest are kept, + * every pending one must be named in `acknowledged`. Acknowledgements of + * servers the manifest no longer has are dropped. + */ +// eslint-disable-next-line max-params -- the manifest, recorded acknowledgements, names acknowledged now, actor and time are explicit inputs +export function nextAcknowledgements(manifest: PluginManifest, recorded: Record, acknowledged: string[], actor: string, now: number): Record { + const pending = pendingAcknowledgements(manifest, recorded); + const missing = pending.filter(item => !acknowledged.includes(item.server)); + if (missing.length) throw new PluginError(400, `Confirm that the secret variables of ${missing.map(m => m.server).join(', ')} are readable by the agent CLI, its shell tools and repository setup scripts in the task container`); + const result: Record = {}; + for (const server of pluginPackageSecretServers(manifest)) { + const item = pending.find(p => p.server === server.name); + result[server.name] = item ? { actor, at: now, package: item.package, version: item.version, secretEnv: item.secretEnv } : recorded[server.name]; + } + return result; +} + +/** Validate the server names an install or approval acknowledges. */ +export function parseAcknowledgements(value: unknown, manifest: PluginManifest): string[] { + if (value === undefined) return []; + const names = pluginPackageSecretServers(manifest).map(s => s.name); + if (!Array.isArray(value) || value.length > names.length || value.some(name => typeof name !== 'string' || !names.includes(name))) throw new PluginError(400, 'Invalid secret acknowledgement'); + return [...new Set(value as string[])]; +} + +export function pendingPermissions(manifest: PluginManifest, granted: readonly string[]): PluginPermission[] { + return manifest.permissions.filter(permission => !granted.includes(permission)); +} + +export function connectionStatuses(manifest: PluginManifest, secrets: readonly PluginSecretRow[]): PluginConnectionStatus[] { + const required = requiredPluginConnections(manifest); + return manifest.connections.map(connection => { + const row = connection.type === 'oauth' ? undefined : secrets.find(s => s.connection_name === connection.name); + return { + name: connection.name, type: connection.type, label: connection.label, required: required.includes(connection.name), + state: row ? 'connected' : 'not_connected', ...(row ? { setBy: row.set_by, setAt: Number(row.set_at) } : {}), + }; + }); +} + +/** Why the plugin cannot be enabled now. Pending permissions do not block: the plugin keeps its old grant. */ +export function enableBlockers(manifest: PluginManifest | null, record: PluginInstallationRecord, secrets: readonly PluginSecretRow[]): string[] { + if (!manifest) return ['The plugin is no longer in this ProPR version\'s registry']; + const blockers: string[] = []; + for (const key of missingRequiredPluginSettings(manifest.settings, record.settings)) blockers.push(`Required setting ${manifest.settings.properties[key]?.label ?? key} (${key}) is missing`); + for (const status of connectionStatuses(manifest, secrets)) { + if (status.required && status.state !== 'connected') blockers.push(`Connection ${status.label} (${status.name}) is not set`); + } + for (const item of pendingAcknowledgements(manifest, record.acknowledgements)) blockers.push(`The secret variables of package server ${item.server} (${item.secretEnv.join(', ')}) need an administrator acknowledgement`); + return blockers; +} + +/** The state view of one plugin; never a secret. */ +export function pluginView(id: string, manifest: PluginManifest | null, row: PluginInstallationRow | undefined, secrets: readonly PluginSecretRow[]): PluginView { + const installation = row ? installationRecord(row) : null; + const permissions = manifest && installation ? pendingPermissions(manifest, installation.grantedPermissions) : []; + const acknowledgements = manifest && installation ? pendingAcknowledgements(manifest, installation.acknowledgements) : []; + const blockers = installation ? enableBlockers(manifest, installation, secrets) : []; + return { + id, available: Boolean(manifest), manifest: manifest ? publicPluginManifest(manifest) : null, + state: !installation ? 'available' : installation.enabled ? 'enabled' : 'installed', + installation, + connections: manifest ? (installation ? connectionStatuses(manifest, secrets) : connectionStatuses(manifest, [])) : [], + needsApproval: permissions.length > 0 || acknowledgements.length > 0, + pendingPermissions: permissions, pendingAcknowledgements: acknowledgements, + enableBlockers: blockers, + }; +} diff --git a/packages/core/src/plugins/pluginStore.ts b/packages/core/src/plugins/pluginStore.ts new file mode 100644 index 0000000000..6a52d364df --- /dev/null +++ b/packages/core/src/plugins/pluginStore.ts @@ -0,0 +1,241 @@ +import type { Knex } from 'knex'; +import { + PLUGIN_REPOSITORY_LIMIT, PLUGIN_REPOSITORY_PATTERN, PLUGIN_SECRET_PATTERN, defaultPluginSettings, missingRequiredPluginSettings, pluginIdSchema, + requiredPluginConnections, validatePluginSettings, + type PluginAuditAction, type PluginAuditEvent, type PluginDetail, type PluginManifest, type PluginView, +} from '@propr/shared'; +import logger from '../utils/logger.js'; +import { sealExtensionSecrets, unsealExtensionSecrets } from '../extensions/privateSecrets.js'; +import { officialPluginCatalog, type PluginCatalogSource } from './pluginCatalog.js'; +import { + PluginError, enableBlockers, installationRecord, nextAcknowledgements, parseAcknowledgements, pendingAcknowledgements, pendingPermissions, pluginView, + type PluginInstallationRow, type PluginSecretRow, +} from './pluginState.js'; + +export { PluginError } from './pluginState.js'; + +/** Authenticated binding of a sealed connection secret: it opens only for this instance, plugin and connection. */ +export const pluginSecretIdentity = (instanceId: string, pluginId: string, connection: string) => `plugin/${instanceId}/${pluginId}/${connection}`; +const AUDIT_LIMIT = 50; +const SECRET_COLUMNS = ['plugin_id', 'connection_name', 'set_by', 'set_at'] as const; + +interface Loaded { manifest: PluginManifest | null; row: PluginInstallationRow | undefined; secrets: PluginSecretRow[] } + +/** + * Installation state of plugins on this instance. Every method authorizes a + * current durable instance administrator; connection secrets are sealed with + * the extension private-secret mechanism and never returned. Every management + * action is appended to `plugin_audit`. + */ +export class PluginStore { + constructor( + readonly database: Knex, + readonly environment: NodeJS.ProcessEnv = process.env, + readonly catalog: PluginCatalogSource = officialPluginCatalog, + readonly now: () => number = Date.now, + ) {} + + async authorize(actor: string, database: Knex = this.database): Promise { + // Durable, uncached instance authority; no session or repository fallback. + if (!/^[1-9][0-9]*$/.test(actor) || !(await database('instance_members').where({ github_user_id: actor, role: 'admin' }).first('github_user_id'))) throw new PluginError(403, 'Current durable instance administrator access is required'); + } + private async instanceId(database: Knex): Promise { + const row = await database('extension_instance_identity').where({ id: 1 }).first('instance_id'); + if (!row) throw new PluginError(503, 'Instance identity unavailable'); + return row.instance_id as string; + } + private async manifests(): Promise { return this.catalog(); } + private static pluginId(id: unknown): string { + const parsed = pluginIdSchema.safeParse(id); + if (!parsed.success) throw new PluginError(404, 'Plugin not found'); + return parsed.data; + } + private async load(id: string, database: Knex, allowMissing = false): Promise { + const manifest = (await this.manifests()).find(m => m.id === id) ?? null; + const row = await database('plugin_installations').where({ plugin_id: id }).first() as PluginInstallationRow | undefined; + const secrets = row ? await database('plugin_secrets').where({ plugin_id: id }).select(...SECRET_COLUMNS) as PluginSecretRow[] : []; + if (!manifest && !row && !allowMissing) throw new PluginError(404, 'Plugin not found'); + return { manifest, row, secrets }; + } + private static installed(loaded: Loaded): PluginInstallationRow { + if (!loaded.row) throw new PluginError(409, 'The plugin is not installed'); + return loaded.row; + } + private static current(loaded: Loaded): PluginManifest { + if (!loaded.manifest) throw new PluginError(409, 'The plugin is no longer in this ProPR version\'s registry; it can only be disabled or uninstalled'); + return loaded.manifest; + } + // eslint-disable-next-line max-params -- the transaction, plugin, action, actor and detail are explicit audit inputs + private async audit(trx: Knex, pluginId: string, action: PluginAuditAction, actor: string, detail: Record = {}): Promise { + await trx('plugin_audit').insert({ plugin_id: pluginId, action, actor, at: this.now(), detail: JSON.stringify(detail) }); + logger.info({ pluginId, action, actor }, '[plugins] Plugin management action'); + } + /** + * One management write: authorizes inside the transaction, runs `change`, + * records the audit event and returns the plugin's new state. + */ + private async write(actor: string, id: unknown, change: (trx: Knex.Transaction, loaded: Loaded, pluginId: string) => Promise<{ action: PluginAuditAction; detail?: Record }>): Promise { + const pluginId = PluginStore.pluginId(id); + await this.database.transaction(async trx => { + await this.authorize(actor, trx); + const { action, detail } = await change(trx, await this.load(pluginId, trx), pluginId); + await this.audit(trx, pluginId, action, actor, detail); + }); + const after = await this.load(pluginId, this.database, true); + return pluginView(pluginId, after.manifest, after.row, after.secrets); + } + /** Columns every update sets. A write with nothing awaiting approval also moves the installed version to the current manifest. */ + private touched(actor: string, loaded: Loaded): Record { + const update: Record = { updated_by: actor, updated_at: this.now() }; + if (loaded.manifest && loaded.row) { + const record = installationRecord(loaded.row); + if (!pendingPermissions(loaded.manifest, record.grantedPermissions).length && !pendingAcknowledgements(loaded.manifest, record.acknowledgements).length) update.manifest_version = loaded.manifest.version; + } + return update; + } + + /** Every plugin of the registry plus installed plugins an upgrade removed from it, with state. */ + async list(actor: string): Promise { + await this.authorize(actor); + const manifests = await this.manifests(); + const rows = await this.database('plugin_installations').select() as PluginInstallationRow[]; + const secrets = await this.database('plugin_secrets').select(...SECRET_COLUMNS) as PluginSecretRow[]; + const ids = [...manifests.map(m => m.id), ...rows.map(r => r.plugin_id).filter(id => !manifests.some(m => m.id === id)).sort()]; + const views = ids.map(id => pluginView(id, manifests.find(m => m.id === id) ?? null, rows.find(r => r.plugin_id === id), secrets.filter(s => s.plugin_id === id))); + await this.authorize(actor); + return views; + } + /** One plugin: manifest, state, settings, connection states, pending approvals and its newest audit events. */ + async get(actor: string, id: unknown): Promise { + await this.authorize(actor); + const pluginId = PluginStore.pluginId(id); + const { manifest, row, secrets } = await this.load(pluginId, this.database); + const audit = await this.auditEvents(pluginId); + await this.authorize(actor); + return { ...pluginView(pluginId, manifest, row, secrets), audit }; + } + private async auditEvents(pluginId: string): Promise { + const rows = await this.database('plugin_audit').where({ plugin_id: pluginId }).orderBy('id', 'desc').limit(AUDIT_LIMIT).select('action', 'actor', 'at', 'detail'); + return rows.map(row => ({ action: row.action, actor: row.actor, at: Number(row.at), detail: JSON.parse(row.detail || '{}') })); + } + + /** Install a registry plugin, disabled, with its default settings and its manifest's permissions granted. */ + async install(actor: string, id: unknown, acknowledgementsInput?: unknown): Promise { + return this.write(actor, id, async (trx, loaded, pluginId) => { + const manifest = PluginStore.current(loaded); + if (loaded.row) throw new PluginError(409, 'The plugin is already installed'); + const acknowledged = parseAcknowledgements(acknowledgementsInput, manifest); + const now = this.now(); + const acknowledgements = nextAcknowledgements(manifest, {}, acknowledged, actor, now); + await trx('plugin_installations').insert({ + plugin_id: pluginId, manifest_version: manifest.version, enabled: false, + settings: JSON.stringify(defaultPluginSettings(manifest.settings)), repositories: null, + granted_permissions: JSON.stringify([...manifest.permissions]), acknowledgements: JSON.stringify(acknowledgements), + installed_by: actor, installed_at: now, updated_by: actor, updated_at: now, + }); + return { action: 'install', detail: { version: manifest.version, permissions: [...manifest.permissions], acknowledged: Object.keys(acknowledgements) } }; + }); + } + + /** Replace the settings, validated against the current manifest's settings schema. */ + async updateSettings(actor: string, id: unknown, settings: unknown): Promise { + return this.write(actor, id, async (trx, loaded, pluginId) => { + const row = PluginStore.installed(loaded); const manifest = PluginStore.current(loaded); + const result = validatePluginSettings(manifest.settings, settings); + if (!result.ok) throw new PluginError(400, result.errors.join('; ')); + const missing = missingRequiredPluginSettings(manifest.settings, result.settings); + if (row.enabled && missing.length) throw new PluginError(409, `Required settings cannot be removed while the plugin is enabled: ${missing.join(', ')}`); + const previous = installationRecord(row).settings; + const changed = [...new Set([...Object.keys(previous), ...Object.keys(result.settings)])].filter(key => JSON.stringify(previous[key]) !== JSON.stringify(result.settings[key])).sort(); + await trx('plugin_installations').where({ plugin_id: pluginId }).update({ settings: JSON.stringify(result.settings), ...this.touched(actor, loaded) }); + return { action: 'update_settings', detail: { changed } }; + }); + } + + /** Limit the plugin to repositories (`owner/repo`); `null` or an empty list means all repositories. */ + async setRepositories(actor: string, id: unknown, repositories: unknown): Promise { + const limit = parseRepositories(repositories); + return this.write(actor, id, async (trx, loaded, pluginId) => { + PluginStore.installed(loaded); + await trx('plugin_installations').where({ plugin_id: pluginId }).update({ repositories: limit ? JSON.stringify(limit) : null, ...this.touched(actor, loaded) }); + return { action: 'set_repositories', detail: { repositories: limit } }; + }); + } + + /** Set (a string) or clear (`null`) a `token` or `signing_secret` connection. The value is sealed and never returned. */ + async setConnection(actor: string, id: unknown, name: unknown, value: unknown): Promise { + if (value !== null && (typeof value !== 'string' || !PLUGIN_SECRET_PATTERN.test(value))) throw new PluginError(400, 'A connection secret is 1 to 4096 printable characters without spaces'); + return this.write(actor, id, async (trx, loaded, pluginId) => { + const row = PluginStore.installed(loaded); const manifest = PluginStore.current(loaded); + const connection = manifest.connections.find(c => c.name === name); + if (!connection) throw new PluginError(404, 'Connection not found'); + if (connection.type === 'oauth') throw new PluginError(400, 'OAuth connections are not available yet'); + if (value === null) { + if (row.enabled && requiredPluginConnections(manifest).includes(connection.name)) throw new PluginError(409, `Connection ${connection.name} is required while the plugin is enabled; disable it first`); + await trx('plugin_secrets').where({ plugin_id: pluginId, connection_name: connection.name }).delete(); + await trx('plugin_installations').where({ plugin_id: pluginId }).update(this.touched(actor, loaded)); + return { action: 'clear_connection', detail: { connection: connection.name } }; + } + const sealed = sealExtensionSecrets({ value }, pluginSecretIdentity(await this.instanceId(trx), pluginId, connection.name), this.environment); + await trx('plugin_secrets').insert({ plugin_id: pluginId, connection_name: connection.name, sealed, set_by: actor, set_at: this.now() }) + .onConflict(['plugin_id', 'connection_name']).merge(); + await trx('plugin_installations').where({ plugin_id: pluginId }).update(this.touched(actor, loaded)); + return { action: 'set_connection', detail: { connection: connection.name, type: connection.type } }; + }); + } + + /** Enable (refused, with the reasons, while something required is missing) or disable. Disabling keeps settings and connections. */ + async setEnabled(actor: string, id: unknown, enabled: unknown): Promise { + if (typeof enabled !== 'boolean') throw new PluginError(400, 'enabled must be true or false'); + return this.write(actor, id, async (trx, loaded, pluginId) => { + const row = PluginStore.installed(loaded); + if (enabled) { + const blockers = enableBlockers(loaded.manifest, installationRecord(row), loaded.secrets); + if (blockers.length) throw new PluginError(409, `The plugin cannot be enabled: ${blockers.join('; ')}`); + } + await trx('plugin_installations').where({ plugin_id: pluginId }).update({ enabled, ...this.touched(actor, loaded) }); + return { action: enabled ? 'enable' : 'disable' }; + }); + } + + /** Grant the current manifest's permissions and record the secret acknowledgements it needs again. */ + async approve(actor: string, id: unknown, acknowledgementsInput?: unknown): Promise { + return this.write(actor, id, async (trx, loaded, pluginId) => { + const row = PluginStore.installed(loaded); const manifest = PluginStore.current(loaded); + const record = installationRecord(row); + const permissions = pendingPermissions(manifest, record.grantedPermissions); + if (!permissions.length && !pendingAcknowledgements(manifest, record.acknowledgements).length) throw new PluginError(409, 'Nothing awaits approval'); + const acknowledgements = nextAcknowledgements(manifest, record.acknowledgements, parseAcknowledgements(acknowledgementsInput, manifest), actor, this.now()); + await trx('plugin_installations').where({ plugin_id: pluginId }).update({ + granted_permissions: JSON.stringify([...manifest.permissions]), acknowledgements: JSON.stringify(acknowledgements), + manifest_version: manifest.version, updated_by: actor, updated_at: this.now(), + }); + return { action: 'approve', detail: { version: manifest.version, permissions: [...manifest.permissions], added: permissions, acknowledged: Object.keys(acknowledgements) } }; + }); + } + + /** Delete the installation, its settings and its connections. `confirm` must repeat the plugin id. The audit trail stays. */ + async uninstall(actor: string, id: unknown, confirm: unknown): Promise { + return this.write(actor, id, async (trx, loaded, pluginId) => { + const row = PluginStore.installed(loaded); + if (confirm !== pluginId) throw new PluginError(400, 'Confirm the uninstall by repeating the plugin id; settings and connections are deleted'); + await trx('plugin_secrets').where({ plugin_id: pluginId }).delete(); + await trx('plugin_installations').where({ plugin_id: pluginId }).delete(); + return { action: 'uninstall', detail: { version: row.manifest_version } }; + }); + } + + /** Core-internal: the stored secret of a connection, for applying contributions. Never exposed through the API. */ + async connectionSecret(pluginId: string, connection: string): Promise { + const row = await this.database('plugin_secrets').where({ plugin_id: pluginId, connection_name: connection }).first('sealed'); + if (!row) return null; + return unsealExtensionSecrets(row.sealed, pluginSecretIdentity(await this.instanceId(this.database), pluginId, connection), this.environment).value ?? null; + } +} + +function parseRepositories(value: unknown): string[] | null { + if (value === null) return null; + if (!Array.isArray(value) || value.length > PLUGIN_REPOSITORY_LIMIT || value.some(item => typeof item !== 'string' || !PLUGIN_REPOSITORY_PATTERN.test(item))) throw new PluginError(400, `repositories must be null or up to ${PLUGIN_REPOSITORY_LIMIT} owner/repo names`); + const unique = [...new Set(value as string[])]; + return unique.length ? unique : null; +} diff --git a/packages/core/src/storage/databaseExportWorker.ts b/packages/core/src/storage/databaseExportWorker.ts index 1971961c06..8fae17ec19 100644 --- a/packages/core/src/storage/databaseExportWorker.ts +++ b/packages/core/src/storage/databaseExportWorker.ts @@ -101,8 +101,8 @@ const SQLite = require(d.sqliteModule); } if (copy.prepare("SELECT 1 FROM pragma_table_info('task_submissions') WHERE name='extension_authorization'").get()) copy.exec("UPDATE task_submissions SET extension_authorization = NULL"); if (copy.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='extension_run_grants'").get()) copy.exec('DELETE FROM extension_run_grants'); - // OAuth connections and pending sign-ins are credential custody: erased, so a restore starts Not connected. The audit trail stays. - for (const table of ['extension_oauth_connections', 'extension_oauth_pending']) { + // OAuth connections, pending sign-ins and plugin connection secrets are credential custody: erased, so a restore starts Not connected. The audit trails stay. + for (const table of ['extension_oauth_connections', 'extension_oauth_pending', 'plugin_secrets']) { if (copy.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table)) copy.exec('DELETE FROM ' + table); } // Rebuild pages so removed fields/free pages cannot leak in the downloadable file. diff --git a/packages/core/test/databaseExport.test.ts b/packages/core/test/databaseExport.test.ts index 2fe98f6f26..70510bc9e8 100644 --- a/packages/core/test/databaseExport.test.ts +++ b/packages/core/test/databaseExport.test.ts @@ -26,6 +26,9 @@ async function fixture(t: test.TestContext) { CREATE TABLE extension_oauth_connections(instance_id TEXT, server_name TEXT, sealed TEXT); CREATE TABLE extension_oauth_pending(state_hash TEXT, sealed TEXT); CREATE TABLE extension_oauth_audit(id INTEGER PRIMARY KEY, server_name TEXT, action TEXT, actor TEXT); + CREATE TABLE plugin_installations(plugin_id TEXT PRIMARY KEY, settings TEXT); + CREATE TABLE plugin_secrets(plugin_id TEXT, connection_name TEXT, sealed TEXT); + CREATE TABLE plugin_audit(id INTEGER PRIMARY KEY, plugin_id TEXT, action TEXT, actor TEXT); CREATE TABLE task_submissions(id TEXT, payload TEXT, extension_authorization TEXT); CREATE TABLE system_configs(key TEXT PRIMARY KEY, value TEXT); CREATE TABLE knex_migrations(id INTEGER PRIMARY KEY, name TEXT); @@ -42,6 +45,9 @@ async function fixture(t: test.TestContext) { db.prepare('INSERT INTO extension_oauth_connections VALUES(?,?,?)').run('extension-instance', 'documentation', 'oauth-access-refresh-token-canary'); db.prepare('INSERT INTO extension_oauth_pending VALUES(?,?)').run('pending-state-hash', 'oauth-pkce-verifier-canary'); db.prepare('INSERT INTO extension_oauth_audit(server_name, action, actor) VALUES(?,?,?)').run('documentation', 'connect', '1'); + db.prepare('INSERT INTO plugin_installations VALUES(?,?)').run('example-pack', JSON.stringify({ verbosity: 'quiet' })); + db.prepare('INSERT INTO plugin_secrets VALUES(?,?,?)').run('example-pack', 'example-token', 'plugin-connection-secret-canary'); + db.prepare('INSERT INTO plugin_audit(plugin_id, action, actor) VALUES(?,?,?)').run('example-pack', 'install', '1'); db.prepare('INSERT INTO system_configs VALUES(?,?)').run('agents', JSON.stringify([{ id: 'agent', alias: 'My agent', configPath: '/provider-home-canary', envVars: { API_KEY: 'operator-env-canary' } }])); db.prepare('INSERT INTO system_configs VALUES(?,?)').run('operator_extension_catalog', JSON.stringify({ executable: '/usr/bin/env', args: ['CATALOG_ARGUMENT_SENTINEL', '/opt/propr/CATALOG_PATH_SENTINEL'], secretEnv: { API_KEY: 'OPERATOR_DOCS_KEY' } })); db.prepare('INSERT INTO system_configs VALUES(?,?)').run('fleet_control_secret', JSON.stringify('fleet-secret-canary')); @@ -119,6 +125,11 @@ test('real WAL writer: coherent restorable customer database, integrity, metadat assert.equal(db.prepare('SELECT COUNT(*) AS n FROM extension_oauth_pending').get().n, 0); assert.equal(db.prepare('SELECT action FROM extension_oauth_audit').get().action, 'connect'); for (const canary of ['oauth-access-refresh-token-canary', 'oauth-pkce-verifier-canary']) assert.equal(data.includes(Buffer.from(canary)), false, canary); + // Plugin connection secrets are erased; the installation (settings) and its audit trail are kept. + assert.equal(db.prepare('SELECT COUNT(*) AS n FROM plugin_secrets').get().n, 0); + assert.equal(JSON.parse(db.prepare('SELECT settings FROM plugin_installations').get().settings).verbosity, 'quiet'); + assert.equal(db.prepare('SELECT action FROM plugin_audit').get().action, 'install'); + assert.equal(data.includes(Buffer.from('plugin-connection-secret-canary')), false); assert.deepEqual(JSON.parse(db.prepare("SELECT value FROM system_configs WHERE key='agents'").get().value), [{ id: 'agent', alias: 'My agent' }]); db.prepare('INSERT INTO customer(body) VALUES(?)').run('restored write'); } finally { db.close(); } diff --git a/packages/core/test/pluginStore.test.ts b/packages/core/test/pluginStore.test.ts new file mode 100644 index 0000000000..175f9b0882 --- /dev/null +++ b/packages/core/test/pluginStore.test.ts @@ -0,0 +1,265 @@ +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { pluginManifestSchema, validatePluginSettings, type PluginManifest } from '@propr/shared'; +import { getOfficialPlugin } from '../../plugins/src/index.js'; +import { extensionTestResources } from './helpers/extensionTestResources.js'; +import { up as identityMigration } from '../src/db/migrations/20261013210000_extension_profiles.js'; +import { up as pluginMigration } from '../src/db/migrations/20261114000000_plugin_installations.js'; +import { PluginError, PluginStore } from '../src/plugins/pluginStore.js'; +import { officialPluginCatalog } from '../src/plugins/pluginCatalog.js'; +import logger from '../src/utils/logger.js'; + +const ID = 'example-pack'; +const SENTINEL = 'plugin-token-sentinel-6b1f0c'; +const SIGNING_SENTINEL = 'plugin-signing-sentinel-29de'; +const example = getOfficialPlugin(ID, { includeDevelopment: true })!.manifest; + +/** The example manifest after a ProPR upgrade: one more permission, a new package version, and a webhook. */ +function upgraded(change: { permissions?: boolean; packageVersion?: string } = {}): PluginManifest { + const input = structuredClone(example) as Record; // eslint-disable-line @typescript-eslint/no-explicit-any + input.version = '0.2.0'; + if (change.permissions !== false) input.permissions = [...example.permissions, 'issues.comment']; + if (change.packageVersion) input.contributes.mcpServers[1].version = change.packageVersion; + return pluginManifestSchema.parse(input); +} +const withSigningSecret = (): PluginManifest => pluginManifestSchema.parse({ + ...structuredClone(example), + connections: [...example.connections, { type: 'signing_secret', name: 'example-signing', label: 'Signing secret', help: 'The webhook signing secret.', helpUrl: 'https://example.com/docs/webhooks' }], + contributes: { ...structuredClone(example.contributes), webhooks: [{ name: 'events', description: 'Example events', connection: 'example-signing' }] }, +}); + +async function fixture(t: TestContext, initial: PluginManifest = example) { + const database = extensionTestResources(t).database(); + await database.schema.createTable('instance_members', c => { c.string('github_user_id').primary(); c.string('role'); }); + await database('instance_members').insert([{ github_user_id: '1', role: 'admin' }, { github_user_id: '2', role: 'member' }]); + await database.schema.createTable('agent_definitions', c => { c.string('id').primary(); c.string('name'); }); + await identityMigration(database); await pluginMigration(database); + let manifests: PluginManifest[] = [initial]; + let clock = 1_700_000_000_000; + const store = new PluginStore(database, { SYSTEM_TASK_SECRET: 'plugin-store-test-root' }, async () => manifests, () => clock); + return { database, store, setCatalog: (next: PluginManifest[]) => { manifests = next; }, tick: () => { clock += 1000; } }; +} + +const refused = (status: number, pattern?: RegExp) => (error: unknown) => { + assert.ok(error instanceof PluginError, String(error)); + assert.equal(error.status, status, error.message); + if (pattern) assert.match(error.message, pattern); + return true; +}; + +test('the official catalog offers the example plugin only with the development flag', async () => { + const previous = process.env.PROPR_PLUGINS_DEVELOPMENT; + try { + process.env.PROPR_PLUGINS_DEVELOPMENT = '1'; + assert.ok((await officialPluginCatalog()).some(m => m.id === ID)); + delete process.env.PROPR_PLUGINS_DEVELOPMENT; + assert.ok(!(await officialPluginCatalog()).some(m => m.id === ID)); + } finally { if (previous === undefined) delete process.env.PROPR_PLUGINS_DEVELOPMENT; else process.env.PROPR_PLUGINS_DEVELOPMENT = previous; } +}); + +test('install, configure, enable, disable, re-enable and uninstall the example plugin', async t => { + const f = await fixture(t); + const [available] = await f.store.list('1'); + assert.equal(available.state, 'available'); + assert.equal(available.installation, null); + + // The package server hands FIXTURE_TOKEN to the task container: install needs the acknowledgement. + await assert.rejects(f.store.install('1', ID), refused(400, /example-local/)); + const installed = await f.store.install('1', ID, ['example-local']); + assert.equal(installed.state, 'installed'); + assert.equal(installed.installation?.installedVersion, '0.1.0'); + assert.deepEqual(installed.installation?.grantedPermissions, ['events.read', 'tasks.read']); + assert.deepEqual(installed.installation?.settings, { greeting: 'hello', verbosity: 'normal', maxItems: 10, includeDrafts: false, labels: [] }); + assert.equal(installed.installation?.repositories, null); + assert.equal(installed.installation?.installedBy, '1'); + assert.deepEqual(installed.installation?.acknowledgements['example-local'], { actor: '1', at: 1_700_000_000_000, package: 'propr-fixture-mcp', version: '1.0.0', secretEnv: ['FIXTURE_TOKEN'] }); + assert.equal(installed.needsApproval, false); + await assert.rejects(f.store.install('1', ID, ['example-local']), refused(409)); + + // The required token connection is missing. + assert.deepEqual(installed.enableBlockers, ['Connection Example API token (example-token) is not set']); + await assert.rejects(f.store.setEnabled('1', ID, true), refused(409, /Example API token/)); + const oauth = installed.connections.find(c => c.name === 'example-account')!; + assert.deepEqual({ state: oauth.state, required: oauth.required }, { state: 'not_connected', required: false }); + await assert.rejects(f.store.setConnection('1', ID, 'example-account', SENTINEL), refused(400, /OAuth/)); + + const settings = await f.store.updateSettings('1', ID, { verbosity: 'detailed', maxItems: 5, labels: ['bug'], channels: { 'acme/app': '#eng' } }); + assert.deepEqual(settings.installation?.settings, { verbosity: 'detailed', maxItems: 5, labels: ['bug'], channels: { 'acme/app': '#eng' } }); + await assert.rejects(f.store.updateSettings('1', ID, { verbosity: 'loud' }), refused(400, /verbosity must be one of/)); + await assert.rejects(f.store.updateSettings('1', ID, { unknown: 1 }), refused(400, /not a setting/)); + await assert.rejects(f.store.updateSettings('1', ID, { maxItems: 51 }), refused(400, /at most 50/)); + const limited = await f.store.setRepositories('1', ID, ['acme/app', 'acme/app']); + assert.deepEqual(limited.installation?.repositories, ['acme/app']); + assert.equal((await f.store.setRepositories('1', ID, [])).installation?.repositories, null); + await f.store.setRepositories('1', ID, ['acme/app']); + await assert.rejects(f.store.setRepositories('1', ID, ['not a repository']), refused(400)); + + const connected = await f.store.setConnection('1', ID, 'example-token', SENTINEL); + assert.equal(connected.connections.find(c => c.name === 'example-token')?.state, 'connected'); + assert.equal(await f.store.connectionSecret(ID, 'example-token'), SENTINEL); + const sealed = (await f.database('plugin_secrets').first('sealed')).sealed as string; + assert.ok(!sealed.includes(SENTINEL)); + assert.deepEqual(connected.enableBlockers, []); + + assert.equal((await f.store.setEnabled('1', ID, true)).state, 'enabled'); + await assert.rejects(f.store.setConnection('1', ID, 'example-token', null), refused(409, /disable it first/)); + await assert.rejects(f.store.updateSettings('1', ID, {}), refused(409, /verbosity/)); + + // Disabling keeps settings and connections. + const disabled = await f.store.setEnabled('1', ID, false); + assert.equal(disabled.state, 'installed'); + assert.equal(disabled.installation?.settings.verbosity, 'detailed'); + assert.deepEqual(disabled.installation?.repositories, ['acme/app']); + assert.equal(disabled.connections.find(c => c.name === 'example-token')?.state, 'connected'); + assert.equal((await f.store.setEnabled('1', ID, true)).state, 'enabled'); + + await assert.rejects(f.store.uninstall('1', ID, 'yes'), refused(400, /repeating the plugin id/)); + const removed = await f.store.uninstall('1', ID, ID); + assert.equal(removed.state, 'available'); + assert.equal((await f.database('plugin_secrets').count({ n: '*' }))[0].n, 0); + assert.equal((await f.database('plugin_installations').count({ n: '*' }))[0].n, 0); + // A reinstall starts from the defaults, not connected. + const again = await f.store.install('1', ID, ['example-local']); + assert.equal(again.installation?.settings.verbosity, 'normal'); + assert.equal(again.connections.find(c => c.name === 'example-token')?.state, 'not_connected'); + + const detail = await f.store.get('1', ID); + assert.deepEqual(detail.audit.map(e => e.action).reverse(), [ + 'install', 'update_settings', 'set_repositories', 'set_repositories', 'set_repositories', 'set_connection', 'enable', 'disable', 'enable', 'uninstall', 'install', + ]); + assert.ok(detail.audit.every(e => e.actor === '1')); + assert.deepEqual(detail.audit.find(e => e.action === 'update_settings')?.detail, { changed: ['channels', 'greeting', 'includeDrafts', 'labels', 'maxItems', 'verbosity'] }); +}); + +test('enabling is refused while a required setting is missing', async t => { + const f = await fixture(t); + await f.store.install('1', ID, ['example-local']); + await f.store.setConnection('1', ID, 'example-token', SENTINEL); + const view = await f.store.updateSettings('1', ID, { greeting: 'hi' }); + assert.deepEqual(view.enableBlockers, ['Required setting Verbosity (verbosity) is missing']); + await assert.rejects(f.store.setEnabled('1', ID, true), refused(409, /Required setting Verbosity/)); + await f.store.updateSettings('1', ID, { greeting: 'hi', verbosity: 'quiet' }); + assert.equal((await f.store.setEnabled('1', ID, true)).state, 'enabled'); +}); + +test('a manifest that adds a permission needs approval and keeps the old grant until approved', async t => { + const f = await fixture(t); + await f.store.install('1', ID, ['example-local']); + await f.store.setConnection('1', ID, 'example-token', SENTINEL); + await f.store.setEnabled('1', ID, true); + f.setCatalog([upgraded()]); + + const view = await f.store.get('1', ID); + assert.equal(view.needsApproval, true); + assert.deepEqual(view.pendingPermissions, ['issues.comment']); + assert.deepEqual(view.installation?.grantedPermissions, ['events.read', 'tasks.read']); + assert.equal(view.installation?.installedVersion, '0.1.0'); + assert.equal(view.state, 'enabled', 'the plugin keeps running with its old grant'); + // Other writes keep the old grant and version. + await f.store.setRepositories('1', ID, ['acme/app']); + assert.equal((await f.store.get('1', ID)).installation?.installedVersion, '0.1.0'); + await assert.rejects(f.store.approve('2', ID), refused(403)); + + const approved = await f.store.approve('1', ID); + assert.equal(approved.needsApproval, false); + assert.deepEqual(approved.installation?.grantedPermissions, ['events.read', 'tasks.read', 'issues.comment']); + assert.equal(approved.installation?.installedVersion, '0.2.0'); + await assert.rejects(f.store.approve('1', ID), refused(409, /Nothing awaits approval/)); + const event = (await f.store.get('1', ID)).audit[0]; + assert.equal(event.action, 'approve'); + assert.deepEqual(event.detail.added, ['issues.comment']); +}); + +test('a manifest upgrade without new permissions advances the installed version on the next write', async t => { + const f = await fixture(t); + await f.store.install('1', ID, ['example-local']); + f.setCatalog([upgraded({ permissions: false })]); + assert.equal((await f.store.get('1', ID)).needsApproval, false); + assert.equal((await f.store.setRepositories('1', ID, null)).installation?.installedVersion, '0.2.0'); +}); + +test('a changed package version needs the secret acknowledgement again before enabling', async t => { + const f = await fixture(t); + await f.store.install('1', ID, ['example-local']); + await f.store.setConnection('1', ID, 'example-token', SENTINEL); + f.setCatalog([upgraded({ permissions: false, packageVersion: '1.0.1' })]); + const view = await f.store.get('1', ID); + assert.equal(view.needsApproval, true); + assert.deepEqual(view.pendingAcknowledgements, [{ server: 'example-local', package: 'propr-fixture-mcp', version: '1.0.1', secretEnv: ['FIXTURE_TOKEN'] }]); + await assert.rejects(f.store.setEnabled('1', ID, true), refused(409, /example-local/)); + await assert.rejects(f.store.approve('1', ID), refused(400, /example-local/)); + f.tick(); + const approved = await f.store.approve('1', ID, ['example-local']); + assert.equal(approved.installation?.acknowledgements['example-local'].version, '1.0.1'); + assert.equal(approved.installation?.acknowledgements['example-local'].at, 1_700_000_001_000); + assert.equal((await f.store.setEnabled('1', ID, true)).state, 'enabled'); +}); + +test('a plugin an upgrade removed from the registry can only be disabled or uninstalled', async t => { + const f = await fixture(t); + await f.store.install('1', ID, ['example-local']); + f.setCatalog([]); + const [view] = await f.store.list('1'); + assert.deepEqual({ id: view.id, available: view.available, manifest: view.manifest, state: view.state }, { id: ID, available: false, manifest: null, state: 'installed' }); + await assert.rejects(f.store.setEnabled('1', ID, true), refused(409, /no longer/)); + await assert.rejects(f.store.updateSettings('1', ID, {}), refused(409, /no longer/)); + assert.equal((await f.store.setEnabled('1', ID, false)).state, 'installed'); + assert.equal((await f.store.uninstall('1', ID, ID)).state, 'available'); + assert.deepEqual(await f.store.list('1'), []); + await assert.rejects(f.store.get('1', ID), refused(404)); +}); + +test('signing secrets are write-only and every management route needs a durable administrator', async t => { + const f = await fixture(t, withSigningSecret()); + await assert.rejects(f.store.list('2'), refused(403)); + await assert.rejects(f.store.get('2', ID), refused(403)); + await assert.rejects(f.store.install('2', ID, ['example-local']), refused(403)); + await f.store.install('1', ID, ['example-local']); + for (const call of [ + () => f.store.updateSettings('2', ID, {}), () => f.store.setRepositories('2', ID, null), () => f.store.setConnection('2', ID, 'example-token', SENTINEL), + () => f.store.setEnabled('2', ID, false), () => f.store.approve('2', ID), () => f.store.uninstall('2', ID, ID), () => f.store.list('not-a-user'), + ]) await assert.rejects(call(), refused(403)); + await assert.rejects(f.store.get('1', '../etc'), refused(404)); + + const view = await f.store.setConnection('1', ID, 'example-signing', SIGNING_SENTINEL); + const signing = view.connections.find(c => c.name === 'example-signing')!; + assert.deepEqual(signing, { name: 'example-signing', type: 'signing_secret', label: 'Signing secret', required: false, state: 'connected', setBy: '1', setAt: 1_700_000_000_000 }); + await assert.rejects(f.store.setConnection('1', ID, 'example-token', 'has spaces'), refused(400)); + const cleared = await f.store.setConnection('1', ID, 'example-signing', null); + assert.equal(cleared.connections.find(c => c.name === 'example-signing')?.state, 'not_connected'); + assert.equal((await f.store.get('1', ID)).audit[0].action, 'clear_connection'); +}); + +test('secrets never appear in views, audit, logs or with another instance identity', async t => { + const logged: string[] = []; + for (const level of ['info', 'warn', 'error', 'debug'] as const) { + const original = logger[level]; + (logger as unknown as Record)[level] = (...args: unknown[]) => { logged.push(JSON.stringify(args)); return (original as (...a: unknown[]) => void).apply(logger, args); }; + t.after(() => { (logger as unknown as Record)[level] = original; }); + } + const f = await fixture(t, withSigningSecret()); + const outputs: unknown[] = []; + outputs.push(await f.store.install('1', ID, ['example-local'])); + outputs.push(await f.store.setConnection('1', ID, 'example-token', SENTINEL)); + outputs.push(await f.store.setConnection('1', ID, 'example-signing', SIGNING_SENTINEL)); + outputs.push(await f.store.setEnabled('1', ID, true)); + outputs.push(await f.store.list('1'), await f.store.get('1', ID)); + const audit = await f.database('plugin_audit').select(); + for (const text of [JSON.stringify(outputs), JSON.stringify(audit), logged.join('\n')]) { + assert.ok(!text.includes(SENTINEL) && !text.includes(SIGNING_SENTINEL)); + } + assert.ok(logged.some(line => line.includes('Plugin management action'))); + // The sealed secret is bound to this instance, plugin and connection. + await f.database('extension_instance_identity').update({ instance_id: '00000000-0000-4000-8000-000000000000' }); + await assert.rejects(f.store.connectionSecret(ID, 'example-token'), /unavailable/); +}); + +test('settings validation covers every setting type', () => { + const { settings } = example; + assert.deepEqual(validatePluginSettings(settings, { includeDrafts: 'yes' }), { ok: false, errors: ['includeDrafts must be true or false'] }); + assert.deepEqual(validatePluginSettings(settings, { maxItems: 2.5 }), { ok: false, errors: ['maxItems must be a whole number'] }); + assert.deepEqual(validatePluginSettings(settings, { labels: [1] }), { ok: false, errors: ['labels must be a list of text values'] }); + assert.deepEqual(validatePluginSettings(settings, { channels: { bad: 'x' } }), { ok: false, errors: ['channels must map owner/repo to text'] }); + assert.deepEqual(validatePluginSettings(settings, []), { ok: false, errors: ['Settings must be an object'] }); + assert.deepEqual(validatePluginSettings(settings, { greeting: null, verbosity: 'quiet' }), { ok: true, settings: { verbosity: 'quiet' } }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 867df5fb20..450466563c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -592,6 +592,7 @@ export * from './extensionLibrary.js'; export * from './extensionPackages.js'; export * from './extensionOAuth.js'; export * from './plugins.js'; +export * from './pluginInstallations.js'; export { sqliteTimestampMs } from './sqliteTimestamp.js'; export { AGENT_FAILURE_LINE_MAX_CHARS, agentFailureLine } from './agentFailureLine.js'; diff --git a/packages/shared/src/pluginInstallations.ts b/packages/shared/src/pluginInstallations.ts new file mode 100644 index 0000000000..73dd26059e --- /dev/null +++ b/packages/shared/src/pluginInstallations.ts @@ -0,0 +1,148 @@ +import type { PluginConnectionType, PluginManifest, PluginPermission, PluginSetting, PluginSettings, PublicPluginManifest } from './plugins.js'; + +/** + * Installation state of a plugin on one ProPR instance: whether an + * administrator installed it, whether it is enabled, its settings, the + * repositories it is limited to, the permissions granted and the secret + * acknowledgements of its package servers. Connection secrets are write-only + * and never part of anything here; only their state is. + */ +export const PLUGIN_API_BASE = '/api/instance/plugins/v1' as const; +export const PLUGIN_REPOSITORY_LIMIT = 256; +export const PLUGIN_SECRET_PATTERN = /^[\x21-\x7e]{1,4096}$/; +export const PLUGIN_REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; + +export type PluginSettingValue = string | number | boolean | string[] | Record; +export type PluginSettingValues = Record; + +/** `connected` once a token or signing secret is stored. OAuth connections report `not_connected` until OAuth sign-in for plugins lands. */ +export type PluginConnectionState = 'connected' | 'not_connected'; +export interface PluginConnectionStatus { + name: string; type: PluginConnectionType; label: string; + state: PluginConnectionState; + /** A `token` connection a contributed MCP server uses; enabling waits for it. */ + required: boolean; + setBy?: string; setAt?: number; +} + +/** The administrator's acknowledgement that a package server's secret variables are readable inside the task container. */ +export interface PluginSecretAcknowledgement { actor: string; at: number; package: string; version: string; secretEnv: string[] } +/** A package server whose secret variables need (again) an acknowledgement. */ +export interface PluginPendingAcknowledgement { server: string; package: string; version: string; secretEnv: string[] } + +export type PluginInstallationState = 'available' | 'installed' | 'enabled'; +export interface PluginInstallationRecord { + /** The manifest version the administrator installed or last approved. */ + installedVersion: string; + enabled: boolean; + settings: PluginSettingValues; + /** Repositories (`owner/repo`) the plugin is limited to; `null` for all repositories. */ + repositories: string[] | null; + grantedPermissions: PluginPermission[]; + acknowledgements: Record; + installedBy: string; installedAt: number; updatedBy: string; updatedAt: number; +} +export interface PluginView { + id: string; + /** The plugin is in this ProPR's registry. An installed plugin removed by an upgrade can only be disabled or uninstalled. */ + available: boolean; + manifest: PublicPluginManifest | null; + state: PluginInstallationState; + installation: PluginInstallationRecord | null; + connections: PluginConnectionStatus[]; + /** The current manifest asks for permissions that were not granted: the plugin keeps its old grant until approved. */ + needsApproval: boolean; + pendingPermissions: PluginPermission[]; + pendingAcknowledgements: PluginPendingAcknowledgement[]; + /** Why enabling is refused right now; empty when the plugin can be enabled. */ + enableBlockers: string[]; +} +export interface PluginAuditEvent { action: PluginAuditAction; actor: string; at: number; detail: Record } +export const PLUGIN_AUDIT_ACTIONS = ['install', 'update_settings', 'set_repositories', 'set_connection', 'clear_connection', 'enable', 'disable', 'approve', 'uninstall'] as const; +export type PluginAuditAction = typeof PLUGIN_AUDIT_ACTIONS[number]; +export interface PluginDetail extends PluginView { audit: PluginAuditEvent[] } + +/** The package servers of a manifest that hand secret variables to the task container. */ +export function pluginPackageSecretServers(manifest: PluginManifest) { + return manifest.contributes.mcpServers.flatMap(server => (server.kind === 'package' && server.secretEnv.length ? [server] : [])); +} +/** `token` connections a contributed MCP server needs: a bearer server's connection or a package server's secret variable. */ +export function requiredPluginConnections(manifest: PluginManifest): string[] { + const names = new Set(); + for (const server of manifest.contributes.mcpServers) { + if (server.kind === 'https' && server.authentication === 'bearer' && server.connection) names.add(server.connection); + if (server.kind === 'package') for (const secret of server.secretEnv) names.add(secret.connection); + } + return manifest.connections.filter(c => c.type === 'token' && names.has(c.name)).map(c => c.name); +} + +/** The settings a fresh installation starts with: every default the manifest declares. */ +export function defaultPluginSettings(settings: PluginSettings): PluginSettingValues { + const values: PluginSettingValues = {}; + for (const [key, setting] of Object.entries(settings.properties)) { + if (setting.default !== undefined) values[key] = structuredClone(setting.default) as PluginSettingValue; + } + return values; +} + +const isRecord = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +function stringProblem(key: string, setting: Extract, value: unknown): string | null { + if (typeof value !== 'string') return `${key} must be text`; + if (value.length > (setting.maxLength ?? 500)) return `${key} is longer than ${setting.maxLength ?? 500} characters`; + if (setting.enum && !setting.enum.includes(value)) return `${key} must be one of ${setting.enum.join(', ')}`; + return null; +} +function numberProblem(key: string, setting: Extract, value: unknown): string | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return `${key} must be a number`; + if (setting.integer && !Number.isInteger(value)) return `${key} must be a whole number`; + if (setting.minimum !== undefined && value < setting.minimum) return `${key} must be at least ${setting.minimum}`; + if (setting.maximum !== undefined && value > setting.maximum) return `${key} must be at most ${setting.maximum}`; + return null; +} +function arrayProblem(key: string, setting: Extract, value: unknown): string | null { + const max = setting.maxItems ?? 256; + if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.length > 500)) return `${key} must be a list of text values`; + return value.length > max ? `${key} has more than ${max} entries` : null; +} +function repositoryMapProblem(key: string, value: unknown): string | null { + if (!isRecord(value)) return `${key} must map owner/repo to text`; + const entries = Object.entries(value); + if (entries.length > 256) return `${key} has more than 256 entries`; + if (entries.some(([repository, text]) => !PLUGIN_REPOSITORY_PATTERN.test(repository) || typeof text !== 'string' || text.length > 500)) return `${key} must map owner/repo to text`; + return null; +} +/** Why one value does not fit its setting, or null. */ +export function pluginSettingProblem(key: string, setting: PluginSetting, value: unknown): string | null { + switch (setting.type) { + case 'string': return stringProblem(key, setting, value); + case 'number': return numberProblem(key, setting, value); + case 'boolean': return typeof value === 'boolean' ? null : `${key} must be true or false`; + case 'array': return arrayProblem(key, setting, value); + case 'object': return repositoryMapProblem(key, value); + } +} + +/** + * Validate a complete settings object against a manifest's settings schema. + * Unknown keys are refused; a missing required setting is not an error here + * (see `missingRequiredPluginSettings`), it only blocks enabling. + */ +export function validatePluginSettings(settings: PluginSettings, value: unknown): { ok: true; settings: PluginSettingValues } | { ok: false; errors: string[] } { + if (!isRecord(value)) return { ok: false, errors: ['Settings must be an object'] }; + const errors: string[] = []; + for (const [key, item] of Object.entries(value)) { + const setting = Object.hasOwn(settings.properties, key) ? settings.properties[key] : undefined; + if (!setting) { errors.push(`${key} is not a setting of this plugin`); continue; } + if (item === null || item === undefined) continue; + const problem = pluginSettingProblem(key, setting, item); + if (problem) errors.push(problem); + } + if (errors.length) return { ok: false, errors }; + return { ok: true, settings: Object.fromEntries(Object.entries(value).filter(([, item]) => item !== null && item !== undefined)) as PluginSettingValues }; +} + +/** Required settings that have no value (an empty text counts as missing). */ +export function missingRequiredPluginSettings(settings: PluginSettings, values: PluginSettingValues): string[] { + return settings.required.filter(key => !Object.hasOwn(values, key) || values[key] === ''); +} From 4e4052ce69c2490c6925fe697c2255756fa2a92f Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <213159723+propr-dev[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:19:18 +0000 Subject: [PATCH 04/26] I've implemented #3544. An enabled plugin now adds its MCP servers and skill to the extension library as managed items, and its template to the template catalog. Its OAuth connection signs in through the extension OAuth module. Runs pick these items up through the normal library resolution. Tests: I ran the new Core test (5 cases), the updated plugin store and plugin API route tests, and the extension, OAuth, automation, MCP, OpenAPI and settings-UI suites that touch this code; all pass. `gen:openapi:check`, `plugins:check`, the typechecks and zero-warning lint for core and api also pass. I didn't run the full repository suite. Most suites need `PROPR_DEMO_MODE=true` locally. `extensionServerMount`, `mcpObservableSurface` and `mcpOperatorSurface` fail under that setting and pass with a placeholder GitHub relay configured. How it works - Managed items: library items carry a `managedBy` marker (plugin id, manifest name, `active`). An item applies to runs only while its plugin is enabled and nothing awaits an acknowledgement. They default to automatic apply on, limited to the plugin's repository limit. - Edit guard: a library save may change only enabled, the repository and agent limits, and automatic apply on a managed item. Changing anything else, removing it, adding a forged one or replacing its credential is refused. For a package server that covers package, version, integrity value, arguments and variables. - Sync: every plugin change brings the library in line inside the same database transaction (new code in `packages/core/src/plugins/pluginContributions.ts`). Credentials come from the plugin's connections; nothing is written when nothing changed. - Disabling deactivates the items and keeps their settings and connections, so re-enabling restores them. - Uninstalling removes the items. It is refused while an automation adds one of them explicitly, and the message names the automation. - A name clash with an existing library item gets the `-` prefix. - Package servers: they use the existing install path. The version and integrity value come from the manifest, the package is installed once without lifecycle scripts, verified at launch, mounted read-only and run through the stdio bridge. With its secret missing the server is skipped with `secret_not_configured` and the run continues. Without a current acknowledgement it is never installed or launched. - OAuth: three new routes start, complete and disconnect an OAuth connection: `POST /api/instance/plugins/v1//connections//oauth/{start,complete,disconnect}`. The extension callback also completes the sign-in. A plugin read now reports `connected`, `not_connected` or `needs_sign_in`. - Templates: `GET /api/automation-definitions/templates` and the `list_automation_templates` MCP tool include enabled plugins' templates, each with a `plugin` field naming it. - Read model: a plugin read now includes `contributions`, which links each server and skill to its library item. The OpenAPI spec and client types are regenerated. - UI: managed rows in Settings show a "Plugin: " badge and an "Inactive" badge when they don't apply, and have no Remove button. - Docs: `plugins.md` has a new "What an enabled plugin adds" section, and `agent-extensions.md` and `docs/mcp.md` are updated. Choices the issue left open - Created at install: managed items exist from install, inactive, so the OAuth connection can be signed in before enabling. They still apply only while the plugin is enabled. - Repository limit: changing the plugin's limit resets its managed items' limit to it, overriding per-item edits. - Shared OAuth connection: when several servers use one connection, it shows connected only when all of them are signed in. - Changed skill files: after an upgrade, a changed skill becomes a new bundle. An automation that added the old bundle then reports it as not found. - Template ids: a template keeps its id unless a shipped template or another plugin uses it; then it gets the `-` prefix. Decision for you: the new-automation form in the web UI still uses the template list built into the app, so plugin templates show up only through the API and MCP. Making them appear in the form means having the UI fetch the catalog; I left that out to keep this change focused. Two preview images of the Settings extension list with managed items are attached; they use mocked API data. --- docs/docs/features/agent-extensions.md | 20 +- docs/docs/features/plugins.md | 126 +++- docs/mcp.md | 5 +- docs/static/openapi/propr-api.yaml | 634 +++++++++++++++++- packages/api/mcp/toolsAutomations.ts | 7 +- packages/api/openapi/routeDocsPlugins.ts | 13 +- packages/api/openapi/schemas.ts | 2 +- packages/api/openapi/schemasPlugins.ts | 26 +- packages/api/pluginRouteRegistry.ts | 4 + .../api/routes/automationDefinitionRoutes.ts | 6 +- packages/api/routes/pluginRoutes.ts | 18 +- packages/api/test/pluginRoutes.test.ts | 73 +- packages/client/src/generated/apiTypes.ts | 147 +++- .../src/extensions/extensionManagedItems.ts | 52 ++ .../core/src/extensions/extensionOAuth.ts | 21 +- .../src/extensions/extensionOAuthState.ts | 19 + .../src/extensions/extensionOAuthStore.ts | 5 +- .../src/extensions/extensionPackageLibrary.ts | 17 +- .../src/extensions/extensionProfileStore.ts | 27 +- packages/core/src/plugins/pluginAssets.ts | 68 ++ .../core/src/plugins/pluginContributions.ts | 267 ++++++++ packages/core/src/plugins/pluginState.ts | 5 +- packages/core/src/plugins/pluginStore.ts | 193 +++++- packages/core/src/plugins/pluginTemplates.ts | 64 ++ .../test/extensionLibraryMigration.test.ts | 8 +- .../core/test/pluginContributions.test.ts | 558 +++++++++++++++ packages/core/test/pluginStore.test.ts | 5 +- .../shared/src/automationTemplateContract.ts | 2 + packages/shared/src/extensionLibrary.ts | 33 +- packages/shared/src/extensionProfiles.ts | 4 +- packages/shared/src/pluginInstallations.ts | 30 +- .../extensions/ExtensionItemRow.tsx | 13 +- .../extensions/ExtensionServersList.tsx | 2 +- .../extensions/ExtensionSkillForm.tsx | 2 +- .../extensions/ExtensionSkillImport.tsx | 4 +- .../extensions/ExtensionSkillsList.tsx | 4 +- 36 files changed, 2378 insertions(+), 106 deletions(-) create mode 100644 packages/core/src/extensions/extensionManagedItems.ts create mode 100644 packages/core/src/plugins/pluginAssets.ts create mode 100644 packages/core/src/plugins/pluginContributions.ts create mode 100644 packages/core/src/plugins/pluginTemplates.ts create mode 100644 packages/core/test/pluginContributions.test.ts diff --git a/docs/docs/features/agent-extensions.md b/docs/docs/features/agent-extensions.md index 21741b0c27..5cc211eda7 100644 --- a/docs/docs/features/agent-extensions.md +++ b/docs/docs/features/agent-extensions.md @@ -80,6 +80,24 @@ revision. Enabling or disabling an item carries the sealed credentials into the revision unchanged, without decrypting them. Removing or renaming an item that an automation adds explicitly is refused with a message naming those automations; remove it from them first. +### Managed items + +An installed [plugin](./plugins.md#what-an-enabled-plugin-adds) adds its MCP +servers and skills to the library as **managed items**, tagged with the plugin +id (`managedBy: { plugin, item, active }`, plus `registrations` on an OAuth +server: the client registration methods its sign-in may use; shown as a Plugin badge in the +settings screen). Their definition comes from the plugin manifest and is +read-only: a save may change only `enabled`, `repositories`, `agents` and +`autoApply`. Saves that change anything else on a managed item (for a package +server, also its package, version, integrity value, arguments and variables), +remove it or add an item with `managedBy` are refused, and so is replacing its +credential: a managed item's credential or secret variables come from the +plugin's connections. The plugin's own state wins over the item's: while the +plugin is disabled, or a package server waits for a new acknowledgement after +an upgrade, the item is inactive (`active: false`) and applies to no run +whatever its enabled switch says. Uninstalling the plugin removes its managed +items. + ## Which runs use extensions At launch, Core resolves the run's extension set against the **current** library @@ -594,7 +612,7 @@ safe to re-run, and never blocks startup): marked not configured and logs a warning; replace it in Settings. The library holds at most 32 servers and 32 skill bundles, and an item at most -64 repositories and 64 agents. If the previous configuration does not fit (for +256 repositories and 64 agents. If the previous configuration does not fit (for example five bundles of eight distinct servers each), nothing is dropped: the copy is not committed, the startup log lists the items that do not fit, and it retries at the next start. Until it completes, automations that selected a diff --git a/docs/docs/features/plugins.md b/docs/docs/features/plugins.md index 73042753a7..dde1d20ced 100644 --- a/docs/docs/features/plugins.md +++ b/docs/docs/features/plugins.md @@ -173,11 +173,13 @@ recorded `integrity` value. It needs network access and is not run in CI. A plugin is **available** when it is in this ProPR's registry, **installed** once an administrator installed it on the instance, and then **enabled** or disabled. Disabling keeps its settings and connections; uninstalling deletes -them. +them. What an enabled plugin adds to runs is described in +[What an enabled plugin adds](#what-an-enabled-plugin-adds). Only durable instance administrators with `instance.manage_settings` can read or change plugin state, and every change (install, settings, repository limit, -connection set or cleared, enable, disable, approve, uninstall) is recorded in +connection set or cleared, OAuth client configured or cleared, enable, +disable, approve, uninstall) is recorded in the plugin audit trail with who and when. The audit trail stays after an uninstall. Changes need `Content-Type: application/json` and the header `X-ProPR-Plugins: 1`. @@ -193,6 +195,10 @@ uninstall. Changes need `Content-Type: application/json` and the header | Enable or disable | `PUT /api/instance/plugins/v1//enabled` with `{ "enabled": true }` | | Approve after an upgrade | `POST /api/instance/plugins/v1//approve` with `{ "acknowledgements": [ … ] }` when needed | | Uninstall | `POST /api/instance/plugins/v1//uninstall` with `{ "confirm": "" }` | +| Configure a pre-registered OAuth client | `PUT /api/instance/plugins/v1//connections//oauth/client` with `{ "client": { "clientId": "…", "clientSecret": "…" } }` (`clientSecret` optional) or `{ "client": null }` | +| Start an OAuth sign-in | `POST /api/instance/plugins/v1//connections//oauth/start` with `{}`: returns `authorizationUrl` | +| Complete it | `POST /api/instance/plugins/v1//connections//oauth/complete` with the `state` and `code` the provider returned (the extension OAuth callback completes it too) | +| Disconnect it | `POST /api/instance/plugins/v1//connections//oauth/disconnect` with `{}` | The [API reference](../operations/api-reference.md) has the request and response schemas. MCP clients can read the same state with the read-only `list_plugins` and @@ -234,8 +240,33 @@ credentials, bound to the instance, plugin and connection, and never returned by the API, MCP tools or logs; a read reports only `connected` or `not_connected`, who set it and when. Native database exports erase them, so a restored instance has its plugins installed with the same settings but every -connection not connected. `oauth` connections report `not_connected` until -OAuth sign-in for plugins is available. +connection not connected. + +An `oauth` connection is signed in through the +[extension OAuth sign-in](./agent-extensions.md#oauth-servers): start it, open +the returned `authorizationUrl` in the same browser session, and sign in at the +provider. ProPR identifies itself as it does for library servers (Client ID +Metadata Document, then dynamic registration), but only through the methods the +connection's `registrations` permit, in that order; when the provider supports +none of them, the sign-in is refused with HTTP 409. When `registrations` +permits `preregistered`, an administrator can configure the client registered +with the provider for this instance on the connection (`oauth/client`): the +client id becomes the managed servers' client id and the optional client +secret is sealed like a `token` connection and never returned; a read reports +who configured it in `setBy` and `setAt`. While a client is configured, sign-in +always uses it, wherever `preregistered` appears in `registrations`. Changing or clearing the client, +including replacing only its secret, discards the connection made with the +previous one and any sign-in still in progress. A connection that does not +permit `preregistered` refuses a client with HTTP 409. The managed server carries +the permitted methods in its `managedBy.registrations`, so a sign-in started +from the extension library is limited the same way. The provider redirects to +the extension OAuth callback, which completes the sign-in. The connection +belongs to the plugin's managed server that uses it, so it needs a public HTTPS +base URL for the instance and is available once the plugin is installed. A read +reports `connected` once every managed server using the connection is signed +in, `needs_sign_in` after a failed token refresh, and otherwise +`not_connected`. Disconnecting revokes the tokens where the provider supports it +and deletes them. Tokens are never returned. ### Permissions and upgrades @@ -255,3 +286,90 @@ An upgrade that changes neither permissions nor acknowledged packages needs no approval; the installed version follows the new manifest on the next change. A plugin that an upgrade removed from the registry stays listed as installed but unavailable; it can only be disabled or uninstalled. + +## What an enabled plugin adds + +### Managed library items + +A plugin's MCP servers and skills become **managed items** of the +[extension library](./agent-extensions.md#managed-items), tagged with the plugin +id (`managedBy`). They are created when the plugin is installed, inactive, so +that its OAuth connection can be signed in before enabling, and they apply to +runs only while the plugin is enabled. Runs resolve them like every other +library item: the same limits, opt-out labels, run limits, write authorization +and launch checks. + +- **Definition:** read-only; it comes from the manifest. The administrator can + still use the item's enabled checkbox, its repository and agent limits, and + its automatic-apply switch. Every other change through the library API, + removing the item and replacing its credential are refused. +- **Defaults:** automatic apply on, enabled, limited to the plugin's repository + limit (all repositories when it has none). Changing the plugin's repository + limit sets the managed items' repository limit to it again. +- **Names:** a managed server keeps its manifest name unless the library + already has a server of that name; it is then named `-`. +- **Disabling** the plugin deactivates its managed items: they stay in the + library as they are, with their settings and connections, and apply to no + run. A disable never applies an upgraded manifest, reads skill folders or + decrypts credentials (the library's sealed credentials carry over unchanged), + so it succeeds even when the next plugin change would be refused, including + after a restore under another encryption root. Re-enabling + restores them, applying any upgrade. +- **Uninstalling** removes the managed items and their OAuth connections. It is + refused, naming the automations, while an automation adds one of them + explicitly; remove it from those automations first. An upgrade that drops an + item, or changes the files of a skill an automation adds by its bundle id, is + refused the same way when the plugin is next changed (other than disabling + it). +- The plugin read (`GET /api/instance/plugins/v1/`) lists its contributions + in `contributions`, each with the library item it became (`managed`, with its + library name or bundle id, its own enabled switch and whether the plugin keeps + it `active`). + +### Remote servers + +A managed remote server's credential is the plugin connection it names. A +`bearer` server sends the `token` connection; an `oauth` server is left out of +runs, with the reason `oauth_not_connected`, until its `oauth` connection is +connected, exactly like any other OAuth library server, and disconnecting +removes it from later runs. + +### Local servers contributed by plugins + +A managed package server is installed and launched through the extension +library's [local package server](./agent-extensions.md#local-package-servers) +path, with nothing relaxed: + +- the pinned version and the integrity value recorded in the manifest (ProPR + does not ask the registry for another one); +- installed once per integrity value without lifecycle scripts, verified + again at launch, mounted read-only and run through the stdio bridge in the + task container; +- a write-capability server needs explicit write authorization for the run. + +Its `secretEnv` values come from the plugin's `token` connections. When a +value is missing, the server is skipped for that run with the reason +`secret_not_configured` and the run proceeds. The acknowledgement recorded at +install (or the latest approval) satisfies the library's acknowledgement +requirement; while an upgrade waits for a new acknowledgement the server is +inactive and is never installed or launched. + +### Skills and automation templates + +A plugin skill becomes a skill bundle whose source is the plugin, its version +and the folder. When an upgrade changes the skill's files, the next plugin +change stores a new bundle for it and keeps the item's settings. Automations +add skills by bundle id, so that change is refused while an automation adds the +old bundle explicitly; remove the skill from the automation, apply the change, +then add the new bundle. + +An enabled plugin's automation templates appear in the template catalog +(`GET /api/automation-definitions/templates` and the `list_automation_templates` +MCP tool) after the shipped templates, with `plugin` naming the plugin's id and +name. A template keeps its id unless a shipped template or another plugin uses +it; it is then `-` (`catalogId` in the plugin read). When that +id is itself another template's id or was already given to another template, +it gets a `-2`, `-3`, … suffix, so catalog ids are always unique. Ids depend +only on the registry, not on which plugins are enabled. Disabling or +uninstalling the plugin hides its templates; automations created from them are +ordinary automations and are not changed. diff --git a/docs/mcp.md b/docs/mcp.md index 1542e32262..de52c77e01 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -117,8 +117,9 @@ produce a free-form report (see the [Automations guide](docs/features/agents.md) `repository` filter) and `get_automation_definition` show your automations, including the schedule, next run, capabilities and autonomy mode; `get_automation_definition_contract` returns the shared field contract, and -`list_automation_templates` returns the shipped template catalog (for example daily -issue triage). A template creates nothing; it is a starting point for a new automation. +`list_automation_templates` returns the template catalog: the shipped templates (for +example daily issue triage) and those of enabled plugins, marked with the plugin's id and +name. A template creates nothing; it is a starting point for a new automation. `list_automation_runs` lists a definition's runs newest first without report bodies, and `get_automation_run` returns one run with its report. A report over 200 KB is truncated with `reportTruncated: true`; the full report is at the run's `url`. diff --git a/docs/static/openapi/propr-api.yaml b/docs/static/openapi/propr-api.yaml index f1c6a021a1..9c9502dc10 100644 --- a/docs/static/openapi/propr-api.yaml +++ b/docs/static/openapi/propr-api.yaml @@ -29,8 +29,8 @@ info: name: Apache-2.0 identifier: Apache-2.0 x-route-coverage: - total: 332 - documented: 94 + total: 336 + documented: 98 undocumented: 238 servers: - url: "{origin}" @@ -4999,10 +4999,226 @@ paths: x-propr-auth: member x-propr-permission: instance.manage_settings x-legacy-error: true + /api/instance/plugins/v1/{pluginId}/connections/{connectionName}/oauth/client: + put: + operationId: setPluginOAuthClient + summary: Configure or clear the pre-registered OAuth client of an `oauth` connection whose `registrations` permit `preregistered` (409 otherwise). The client id becomes the managed MCP servers' client id and the secret is sealed and write-only; changing the client discards their OAuth connections. + description: |- + Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned. + + Requires the `instance.manage_settings` instance permission. + tags: + - Configuration + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: pluginId + in: path + required: true + description: Plugin id, for example `example-pack`. + schema: + type: string + - name: connectionName + in: path + required: true + description: An `oauth` connection name from the manifest. + schema: + type: string + - name: X-ProPR-Plugins + in: header + required: true + description: Must equal 1; browser calls require an authorized CORS preflight. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PluginOAuthClientRequest" + responses: + "200": + description: The plugin's new state. + content: + application/json: + schema: + $ref: "#/components/schemas/PluginResponse" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "403": + $ref: "#/components/responses/LegacyForbidden" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member + x-propr-permission: instance.manage_settings + x-legacy-error: true + /api/instance/plugins/v1/{pluginId}/connections/{connectionName}/oauth/complete: + post: + operationId: completePluginOAuth + summary: Complete a sign-in of this connection with the state and code the provider returned, in the session that started it. The extension OAuth callback completes it too. + description: |- + Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned. + + Requires the `instance.manage_settings` instance permission. + tags: + - Configuration + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: pluginId + in: path + required: true + description: Plugin id, for example `example-pack`. + schema: + type: string + - name: connectionName + in: path + required: true + description: An `oauth` connection name from the manifest. + schema: + type: string + - name: X-ProPR-Plugins + in: header + required: true + description: Must equal 1; browser calls require an authorized CORS preflight. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PluginOAuthCompleteRequest" + responses: + "200": + description: The plugin's new state. + content: + application/json: + schema: + $ref: "#/components/schemas/PluginResponse" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "403": + $ref: "#/components/responses/LegacyForbidden" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member + x-propr-permission: instance.manage_settings + x-legacy-error: true + /api/instance/plugins/v1/{pluginId}/connections/{connectionName}/oauth/disconnect: + post: + operationId: disconnectPluginOAuth + summary: "Disconnect an `oauth` connection: its managed servers' tokens are revoked where possible and deleted, and later runs leave the servers out." + description: |- + Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned. + + Requires the `instance.manage_settings` instance permission. + tags: + - Configuration + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: pluginId + in: path + required: true + description: Plugin id, for example `example-pack`. + schema: + type: string + - name: connectionName + in: path + required: true + description: An `oauth` connection name from the manifest. + schema: + type: string + - name: X-ProPR-Plugins + in: header + required: true + description: Must equal 1; browser calls require an authorized CORS preflight. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PluginEmptyRequest" + responses: + "200": + description: The plugin's new state. + content: + application/json: + schema: + $ref: "#/components/schemas/PluginResponse" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "403": + $ref: "#/components/responses/LegacyForbidden" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member + x-propr-permission: instance.manage_settings + x-legacy-error: true + /api/instance/plugins/v1/{pluginId}/connections/{connectionName}/oauth/start: + post: + operationId: startPluginOAuth + summary: Start the extension OAuth sign-in of an `oauth` connection for its managed MCP server, bound to this browser session. ProPR identifies itself as it does for library servers. + description: |- + Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned. + + Requires the `instance.manage_settings` instance permission. + tags: + - Configuration + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: pluginId + in: path + required: true + description: Plugin id, for example `example-pack`. + schema: + type: string + - name: connectionName + in: path + required: true + description: An `oauth` connection name from the manifest. + schema: + type: string + - name: X-ProPR-Plugins + in: header + required: true + description: Must equal 1; browser calls require an authorized CORS preflight. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PluginEmptyRequest" + responses: + "200": + description: The authorization URL to open. + content: + application/json: + schema: + $ref: "#/components/schemas/PluginOAuthStartResponse" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "403": + $ref: "#/components/responses/LegacyForbidden" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member + x-propr-permission: instance.manage_settings + x-legacy-error: true /api/instance/plugins/v1/{pluginId}/enabled: put: operationId: setPluginEnabled - summary: Enable or disable. Enabling is refused (409, with the reasons) while a required setting, a required token connection or a secret acknowledgement is missing. Disabling keeps settings and connections. + summary: Enable or disable. Enabling is refused (409, with the reasons) while a required setting, a required token connection or a secret acknowledgement is missing. Enabling activates the plugin's managed extension library items and lists its automation templates; disabling deactivates and hides them and keeps settings and connections. description: |- Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned. @@ -5194,7 +5410,7 @@ paths: /api/instance/plugins/v1/{pluginId}/uninstall: post: operationId: uninstallPlugin - summary: "Uninstall: deletes the installation, its settings and its connections. The audit trail stays." + summary: "Uninstall: deletes the installation, its settings, its connections and its managed extension library items. Refused (409, naming them) while an automation adds a managed item explicitly. The audit trail stays." description: |- Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned. @@ -10821,7 +11037,7 @@ components: pattern: ^[\x21-\x7e]{1,512}$ repositories: default: [] - maxItems: 64 + maxItems: 256 type: array items: type: string @@ -10836,6 +11052,33 @@ components: autoApply: default: true type: boolean + managedBy: + type: object + properties: + plugin: + type: string + maxLength: 48 + pattern: ^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$ + item: + type: string + pattern: ^[a-z][a-z0-9_-]{0,47}$ + active: + type: boolean + registrations: + minItems: 1 + maxItems: 3 + type: array + items: + type: string + enum: + - metadata_document + - dynamic + - preregistered + required: + - plugin + - item + - active + additionalProperties: false required: - kind - name @@ -10877,7 +11120,7 @@ components: - write repositories: default: [] - maxItems: 64 + maxItems: 256 type: array items: type: string @@ -10892,6 +11135,33 @@ components: autoApply: default: true type: boolean + managedBy: + type: object + properties: + plugin: + type: string + maxLength: 48 + pattern: ^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$ + item: + type: string + pattern: ^[a-z][a-z0-9_-]{0,47}$ + active: + type: boolean + registrations: + minItems: 1 + maxItems: 3 + type: array + items: + type: string + enum: + - metadata_document + - dynamic + - preregistered + required: + - plugin + - item + - active + additionalProperties: false required: - kind - name @@ -10978,7 +11248,7 @@ components: pattern: ^[A-Z_][A-Z0-9_]{0,99}$ repositories: default: [] - maxItems: 64 + maxItems: 256 type: array items: type: string @@ -10993,6 +11263,33 @@ components: autoApply: default: true type: boolean + managedBy: + type: object + properties: + plugin: + type: string + maxLength: 48 + pattern: ^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$ + item: + type: string + pattern: ^[a-z][a-z0-9_-]{0,47}$ + active: + type: boolean + registrations: + minItems: 1 + maxItems: 3 + type: array + items: + type: string + enum: + - metadata_document + - dynamic + - preregistered + required: + - plugin + - item + - active + additionalProperties: false required: - name - enabled @@ -11023,7 +11320,7 @@ components: type: boolean repositories: default: [] - maxItems: 64 + maxItems: 256 type: array items: type: string @@ -11038,6 +11335,33 @@ components: autoApply: default: true type: boolean + managedBy: + type: object + properties: + plugin: + type: string + maxLength: 48 + pattern: ^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$ + item: + type: string + pattern: ^[a-z][a-z0-9_-]{0,47}$ + active: + type: boolean + registrations: + minItems: 1 + maxItems: 3 + type: array + items: + type: string + enum: + - metadata_document + - dynamic + - preregistered + required: + - plugin + - item + - active + additionalProperties: false required: - bundleId - enabled @@ -11050,7 +11374,7 @@ components: - servers - skills additionalProperties: false - description: "Strict shared v1 library contract: up to 32 servers and 32 skill bundle references. Each item has enabled, optional repositories (owner/repo; empty means every repository) and agents (aliases; empty means every agent) limits, and autoApply (default true; false applies the item only where an automation adds it). Operator catalog descriptors accept only immutable catalogId/digest and selected tools/capability; customer commands, arguments, environment and mount paths are rejected." + description: "Strict shared v1 library contract: up to 32 servers and 32 skill bundle references. Each item has enabled, optional repositories (owner/repo; empty means every repository) and agents (aliases; empty means every agent) limits, and autoApply (default true; false applies the item only where an automation adds it). Items an installed plugin contributes carry managedBy (plugin id, manifest item name, active, and for OAuth servers the client registration methods the plugin permits at sign-in); their definition is read-only and only enabled, the limits and autoApply can change, and they cannot be added or removed through this route. Operator catalog descriptors accept only immutable catalogId/digest and selected tools/capability; customer commands, arguments, environment and mount paths are rejected." ExtensionLibraryRead: type: object properties: @@ -12592,11 +12916,13 @@ components: enum: - connected - not_connected - description: "`oauth` connections report not_connected until OAuth sign-in for plugins is available." + - needs_sign_in + description: An `oauth` connection is connected once every managed server using it is signed in through the extension OAuth sign-in, and needs_sign_in after a failed refresh. required: type: boolean description: A `token` connection a contributed MCP server uses; enabling waits for it. setBy: + description: Who set the value; for an `oauth` connection, who configured its pre-registered client. type: string setAt: type: number @@ -12651,6 +12977,117 @@ components: items: type: string description: Why enabling is refused right now; empty when it can be enabled. + contributions: + type: object + properties: + mcpServers: + type: array + items: + type: object + properties: + name: + type: string + description: The server name in the manifest. + kind: + type: string + enum: + - https + - package + managed: + anyOf: + - type: object + properties: + kind: + type: string + const: server + name: + type: string + description: The library item name; prefixed with the plugin id after a name collision. + enabled: + type: boolean + description: The library item's own enabled switch. + active: + type: boolean + description: "False while the plugin is disabled or a secret acknowledgement is pending: the item then never applies." + required: + - kind + - name + - enabled + - active + additionalProperties: false + - type: "null" + href: + type: string + description: The extension library route that lists and edits the item. + required: + - name + - kind + - managed + - href + additionalProperties: false + skills: + type: array + items: + type: object + properties: + name: + type: string + managed: + anyOf: + - type: object + properties: + kind: + type: string + const: skill + bundleId: + type: string + enabled: + type: boolean + description: The library item's own enabled switch. + active: + type: boolean + description: "False while the plugin is disabled or a secret acknowledgement is pending: the item then never applies." + required: + - kind + - bundleId + - enabled + - active + additionalProperties: false + - type: "null" + href: + type: string + required: + - name + - managed + - href + additionalProperties: false + automationTemplates: + type: array + items: + type: object + properties: + id: + type: string + catalogId: + type: string + description: The template id in the automation template catalog. + available: + type: boolean + description: "Listed in the catalog: the plugin is enabled." + href: + type: string + required: + - id + - catalogId + - available + - href + additionalProperties: false + required: + - mcpServers + - skills + - automationTemplates + additionalProperties: false + description: The managed extension library items and automation templates the plugin contributes; items are null until the plugin is installed. audit: type: array items: @@ -12668,6 +13105,11 @@ components: - disable - approve - uninstall + - start_oauth + - complete_oauth + - disconnect_oauth + - set_oauth_client + - clear_oauth_client actor: type: string at: @@ -12699,6 +13141,10 @@ components: required: - plugin additionalProperties: false + PluginEmptyRequest: + type: object + properties: {} + additionalProperties: false PluginEnabledRequest: type: object properties: @@ -12717,6 +13163,59 @@ components: required: - plugins additionalProperties: false + PluginOAuthClientRequest: + type: object + properties: + client: + anyOf: + - type: object + properties: + clientId: + type: string + pattern: ^[\x21-\x7e]{1,512}$ + description: The client id registered with the provider for this instance. + clientSecret: + description: The client secret (write-only); leave out for a public client. + anyOf: + - type: string + maxLength: 4096 + - type: "null" + required: + - clientId + additionalProperties: false + - type: "null" + description: The pre-registered client, or null to clear it. + required: + - client + additionalProperties: false + PluginOAuthCompleteRequest: + type: object + properties: + state: + type: string + description: The `state` the provider returned to the redirect URI. + code: + type: string + error: + type: string + iss: + type: string + required: + - state + additionalProperties: false + PluginOAuthStartResponse: + type: object + properties: + authorizationUrl: + type: string + description: Open this URL to sign in; the provider redirects to the extension OAuth callback. + server: + type: string + description: The managed library server being signed in. + required: + - authorizationUrl + - server + additionalProperties: false PluginRepositoriesRequest: type: object properties: @@ -13077,11 +13576,13 @@ components: enum: - connected - not_connected - description: "`oauth` connections report not_connected until OAuth sign-in for plugins is available." + - needs_sign_in + description: An `oauth` connection is connected once every managed server using it is signed in through the extension OAuth sign-in, and needs_sign_in after a failed refresh. required: type: boolean description: A `token` connection a contributed MCP server uses; enabling waits for it. setBy: + description: Who set the value; for an `oauth` connection, who configured its pre-registered client. type: string setAt: type: number @@ -13136,6 +13637,117 @@ components: items: type: string description: Why enabling is refused right now; empty when it can be enabled. + contributions: + type: object + properties: + mcpServers: + type: array + items: + type: object + properties: + name: + type: string + description: The server name in the manifest. + kind: + type: string + enum: + - https + - package + managed: + anyOf: + - type: object + properties: + kind: + type: string + const: server + name: + type: string + description: The library item name; prefixed with the plugin id after a name collision. + enabled: + type: boolean + description: The library item's own enabled switch. + active: + type: boolean + description: "False while the plugin is disabled or a secret acknowledgement is pending: the item then never applies." + required: + - kind + - name + - enabled + - active + additionalProperties: false + - type: "null" + href: + type: string + description: The extension library route that lists and edits the item. + required: + - name + - kind + - managed + - href + additionalProperties: false + skills: + type: array + items: + type: object + properties: + name: + type: string + managed: + anyOf: + - type: object + properties: + kind: + type: string + const: skill + bundleId: + type: string + enabled: + type: boolean + description: The library item's own enabled switch. + active: + type: boolean + description: "False while the plugin is disabled or a secret acknowledgement is pending: the item then never applies." + required: + - kind + - bundleId + - enabled + - active + additionalProperties: false + - type: "null" + href: + type: string + required: + - name + - managed + - href + additionalProperties: false + automationTemplates: + type: array + items: + type: object + properties: + id: + type: string + catalogId: + type: string + description: The template id in the automation template catalog. + available: + type: boolean + description: "Listed in the catalog: the plugin is enabled." + href: + type: string + required: + - id + - catalogId + - available + - href + additionalProperties: false + required: + - mcpServers + - skills + - automationTemplates + additionalProperties: false + description: The managed extension library items and automation templates the plugin contributes; items are null until the plugin is installed. required: - id - available diff --git a/packages/api/mcp/toolsAutomations.ts b/packages/api/mcp/toolsAutomations.ts index 66bc28d7f3..f4968077e2 100644 --- a/packages/api/mcp/toolsAutomations.ts +++ b/packages/api/mcp/toolsAutomations.ts @@ -16,7 +16,8 @@ import { type TriggerAgentRunInput, type TriggerAgentRunResult, } from '@propr/core'; -import { AUTOMATION_ACTION_OPERATOR_NOTE_MAX_CHARS, AUTOMATION_DEFINITION_CONTRACT, AUTOMATION_TEMPLATE_CATALOG, type AutomationRunState } from '@propr/shared'; +import { AUTOMATION_ACTION_OPERATOR_NOTE_MAX_CHARS, AUTOMATION_DEFINITION_CONTRACT, type AutomationRunState } from '@propr/shared'; +import { automationTemplateCatalog } from '../../core/src/plugins/pluginTemplates.js'; import { createAutomationDefinitionRoutes, publicAutomationDefinition, publicAutomationRun } from '../routes/automationDefinitionRoutes.js'; import { callWorkflow, type WorkflowHandler } from './adapter.js'; import { AGENT_RUN_MCP_CLIENT_ID } from './agentRunGrants.js'; @@ -253,8 +254,8 @@ export function addAutomationRunTools(tools: McpTool[], deps: ToolDeps): void { tools.push({ name: 'get_automation_definition_contract', description: 'Read the automation definition contract shared by the API, MCP, UI and CLI: capabilities, autonomy modes, run triggers and states, field limits and schedule rules.', scope: 'read', readOnly: true, schema: z.object({}).strict(), run: async () => ok(AUTOMATION_DEFINITION_CONTRACT) }); - tools.push({ name: 'list_automation_templates', description: 'List the shipped automation templates (for example daily issue triage): id, name, description, category, prompt, capabilities, suggested schedule, autonomy (always dry_run) and what to review before saving. A template creates nothing; the user starts one from New automation in the Web UI and saves it.', scope: 'read', readOnly: true, - schema: z.object({}).strict(), run: async () => ok(AUTOMATION_TEMPLATE_CATALOG) }); + tools.push({ name: 'list_automation_templates', description: 'List the automation templates (for example daily issue triage), shipped ones and those of enabled plugins (marked with plugin id and name): id, name, description, category, prompt, capabilities, suggested schedule, autonomy (always dry_run) and what to review before saving. A template creates nothing; the user starts one from New automation in the Web UI and saves it.', scope: 'read', readOnly: true, + schema: z.object({}).strict(), run: async () => ok(await automationTemplateCatalog(deps.db)) }); tools.push({ name: 'list_automation_runs', description: `List the runs of one of your automations newest first: trigger, state, timing and skip or failure reasons. Report bodies are omitted; read one with get_automation_run. ${NO_CHANGE_NOTE}`, scope: 'read', readOnly: true, schema: z.object({ ...definitionShape, offset: z.number().int().min(0).max(100000).default(0), limit: z.number().int().min(1).max(100).default(20) }).strict(), diff --git a/packages/api/openapi/routeDocsPlugins.ts b/packages/api/openapi/routeDocsPlugins.ts index a7880c2366..055bc14cb8 100644 --- a/packages/api/openapi/routeDocsPlugins.ts +++ b/packages/api/openapi/routeDocsPlugins.ts @@ -1,7 +1,7 @@ import type { z } from 'zod'; import { PluginAcknowledgementRequest, PluginConnectionRequest, PluginDetailResponse, PluginEnabledRequest, PluginList, PluginRepositoriesRequest, PluginResponse, - PluginSettingsRequest, PluginUninstallRequest, + PluginSettingsRequest, PluginUninstallRequest, PluginEmptyRequest, PluginOAuthClientRequest, PluginOAuthCompleteRequest, PluginOAuthStartResponse, } from './schemasPlugins.js'; import type { RouteDoc } from './types.js'; @@ -10,6 +10,7 @@ const BASE = '/api/instance/plugins/v1'; const description = 'Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.'; const headers = [{ name: 'X-ProPR-Plugins', description: 'Must equal 1; browser calls require an authorized CORS preflight.', required: true }]; const pluginId = { pluginId: 'Plugin id, for example `example-pack`.' }; +const oauthParams = { ...pluginId, connectionName: 'An `oauth` connection name from the manifest.' }; const changed = { 200: { description: 'The plugin\'s new state.', schema: PluginResponse } }; const mutation = (operationId: string, summary: string, schema: z.ZodType, extra: Partial = {}): RouteDoc => ({ operationId, summary, description, tags: ['Configuration'], pathParams: pluginId, headers, requestBody: { schema }, responses: changed, ...extra, @@ -30,7 +31,13 @@ export const PLUGIN_ROUTE_DOCS: Record = { [`PUT ${BASE}/:pluginId/connections/:connectionName`]: mutation('setPluginConnection', 'Set or clear a `token` or `signing_secret` connection. The secret is sealed and write-only.', PluginConnectionRequest, { pathParams: { ...pluginId, connectionName: 'Connection name from the manifest.' }, }), - [`PUT ${BASE}/:pluginId/enabled`]: mutation('setPluginEnabled', 'Enable or disable. Enabling is refused (409, with the reasons) while a required setting, a required token connection or a secret acknowledgement is missing. Disabling keeps settings and connections.', PluginEnabledRequest), + [`PUT ${BASE}/:pluginId/enabled`]: mutation('setPluginEnabled', 'Enable or disable. Enabling is refused (409, with the reasons) while a required setting, a required token connection or a secret acknowledgement is missing. Enabling activates the plugin\'s managed extension library items and lists its automation templates; disabling deactivates and hides them and keeps settings and connections.', PluginEnabledRequest), [`POST ${BASE}/:pluginId/approve`]: mutation('approvePlugin', 'Approve the permission set of the current manifest after an upgrade, with the secret acknowledgements it needs again.', PluginAcknowledgementRequest), - [`POST ${BASE}/:pluginId/uninstall`]: mutation('uninstallPlugin', 'Uninstall: deletes the installation, its settings and its connections. The audit trail stays.', PluginUninstallRequest), + [`POST ${BASE}/:pluginId/uninstall`]: mutation('uninstallPlugin', 'Uninstall: deletes the installation, its settings, its connections and its managed extension library items. Refused (409, naming them) while an automation adds a managed item explicitly. The audit trail stays.', PluginUninstallRequest), + [`PUT ${BASE}/:pluginId/connections/:connectionName/oauth/client`]: mutation('setPluginOAuthClient', 'Configure or clear the pre-registered OAuth client of an `oauth` connection whose `registrations` permit `preregistered` (409 otherwise). The client id becomes the managed MCP servers\' client id and the secret is sealed and write-only; changing the client discards their OAuth connections.', PluginOAuthClientRequest, { pathParams: oauthParams }), + [`POST ${BASE}/:pluginId/connections/:connectionName/oauth/start`]: mutation('startPluginOAuth', 'Start the extension OAuth sign-in of an `oauth` connection for its managed MCP server, bound to this browser session. ProPR identifies itself as it does for library servers.', PluginEmptyRequest, { + pathParams: oauthParams, responses: { 200: { description: 'The authorization URL to open.', schema: PluginOAuthStartResponse } }, + }), + [`POST ${BASE}/:pluginId/connections/:connectionName/oauth/complete`]: mutation('completePluginOAuth', 'Complete a sign-in of this connection with the state and code the provider returned, in the session that started it. The extension OAuth callback completes it too.', PluginOAuthCompleteRequest, { pathParams: oauthParams }), + [`POST ${BASE}/:pluginId/connections/:connectionName/oauth/disconnect`]: mutation('disconnectPluginOAuth', 'Disconnect an `oauth` connection: its managed servers\' tokens are revoked where possible and deleted, and later runs leave the servers out.', PluginEmptyRequest, { pathParams: oauthParams }), }; diff --git a/packages/api/openapi/schemas.ts b/packages/api/openapi/schemas.ts index 09f6e6d2a8..4bfc2f6cf0 100644 --- a/packages/api/openapi/schemas.ts +++ b/packages/api/openapi/schemas.ts @@ -185,7 +185,7 @@ export const TaskPage = component('TaskPage', z.object({ totalRuns: z.number().int().optional().describe('With `groupBy=task`: matching runs across all tasks.'), }), 'A page of task runs.'); -export const ExtensionLibraryInput = component('ExtensionLibraryInput', extensionLibraryInputSchema as unknown as z.ZodType, 'Strict shared v1 library contract: up to 32 servers and 32 skill bundle references. Each item has enabled, optional repositories (owner/repo; empty means every repository) and agents (aliases; empty means every agent) limits, and autoApply (default true; false applies the item only where an automation adds it). Operator catalog descriptors accept only immutable catalogId/digest and selected tools/capability; customer commands, arguments, environment and mount paths are rejected.'); +export const ExtensionLibraryInput = component('ExtensionLibraryInput', extensionLibraryInputSchema as unknown as z.ZodType, 'Strict shared v1 library contract: up to 32 servers and 32 skill bundle references. Each item has enabled, optional repositories (owner/repo; empty means every repository) and agents (aliases; empty means every agent) limits, and autoApply (default true; false applies the item only where an automation adds it). Items an installed plugin contributes carry managedBy (plugin id, manifest item name, active, and for OAuth servers the client registration methods the plugin permits at sign-in); their definition is read-only and only enabled, the limits and autoApply can change, and they cannot be added or removed through this route. Operator catalog descriptors accept only immutable catalogId/digest and selected tools/capability; customer commands, arguments, environment and mount paths are rejected.'); export const ExtensionLibrarySave = component('ExtensionLibrarySave', z.strictObject({ input: ExtensionLibraryInput, expectedRevision: z.number().int().min(0), secrets: z.record(z.string(), z.string().nullable()).optional(), acknowledgements: z.array(z.string()).optional().describe('Names of local package servers whose secret variables the administrator confirms are readable inside the task container. Required for a package server with secret variables whenever its package, version or secret variable names are new or changed; an unchanged server keeps its recorded acknowledgement. Only package servers with secret variables may be listed.') })); export const ExtensionItemEnabledRequest = component('ExtensionItemEnabledRequest', z.strictObject({ item: z.union([z.strictObject({ kind: z.literal('server'), name: z.string() }), z.strictObject({ kind: z.literal('skill'), bundleId: z.uuid() })]), enabled: z.boolean() })); export const ExtensionSecretReplaceRequest = component('ExtensionSecretReplaceRequest', z.strictObject({ name: z.string(), secret: z.string().nullable(), expectedRevision: z.number().int().min(0).optional() })); diff --git a/packages/api/openapi/schemasPlugins.ts b/packages/api/openapi/schemasPlugins.ts index 3eae1d30d2..187e224b52 100644 --- a/packages/api/openapi/schemasPlugins.ts +++ b/packages/api/openapi/schemasPlugins.ts @@ -30,10 +30,20 @@ const PluginInstallation = z.object({ }); const PluginConnectionStatus = z.object({ name: z.string(), type: z.enum(PLUGIN_CONNECTION_TYPES), label: z.string(), - state: z.enum(['connected', 'not_connected']).describe('`oauth` connections report not_connected until OAuth sign-in for plugins is available.'), + state: z.enum(['connected', 'not_connected', 'needs_sign_in']).describe('An `oauth` connection is connected once every managed server using it is signed in through the extension OAuth sign-in, and needs_sign_in after a failed refresh.'), required: z.boolean().describe('A `token` connection a contributed MCP server uses; enabling waits for it.'), - setBy: z.string().optional(), setAt: z.number().optional(), + setBy: z.string().optional().describe('Who set the value; for an `oauth` connection, who configured its pre-registered client.'), setAt: z.number().optional(), }); +const managedState = { enabled: z.boolean().describe('The library item\'s own enabled switch.'), active: z.boolean().describe('False while the plugin is disabled or a secret acknowledgement is pending: the item then never applies.') }; +const PluginContributions = z.object({ + mcpServers: z.array(z.object({ + name: z.string().describe('The server name in the manifest.'), kind: z.enum(['https', 'package']), + managed: z.object({ kind: z.literal('server'), name: z.string().describe('The library item name; prefixed with the plugin id after a name collision.'), ...managedState }).nullable(), + href: z.string().describe('The extension library route that lists and edits the item.'), + })), + skills: z.array(z.object({ name: z.string(), managed: z.object({ kind: z.literal('skill'), bundleId: z.string(), ...managedState }).nullable(), href: z.string() })), + automationTemplates: z.array(z.object({ id: z.string(), catalogId: z.string().describe('The template id in the automation template catalog.'), available: z.boolean().describe('Listed in the catalog: the plugin is enabled.'), href: z.string() })), +}).describe('The managed extension library items and automation templates the plugin contributes; items are null until the plugin is installed.'); const pluginViewShape = { id: z.string(), available: z.boolean().describe('The plugin is in this ProPR version\'s registry.'), @@ -45,6 +55,7 @@ const pluginViewShape = { pendingPermissions: z.array(permission), pendingAcknowledgements: z.array(z.object({ server: z.string(), package: z.string(), version: z.string(), secretEnv: z.array(z.string()) })), enableBlockers: z.array(z.string()).describe('Why enabling is refused right now; empty when it can be enabled.'), + contributions: PluginContributions.optional(), }; export const PluginView = component('PluginView', z.object(pluginViewShape), 'A plugin with its installation state on this instance.'); export const PluginList = component('PluginList', z.object({ plugins: z.array(PluginView) })); @@ -58,6 +69,17 @@ export const PluginAcknowledgementRequest = component('PluginAcknowledgementRequ })); export const PluginSettingsRequest = component('PluginSettingsRequest', z.strictObject({ settings: z.record(z.string(), settingValue.nullable()).describe('The complete settings; validated against the manifest\'s settings schema. Null values are removed.') })); export const PluginRepositoriesRequest = component('PluginRepositoriesRequest', z.strictObject({ repositories: z.array(z.string()).max(256).nullable().describe('owner/repo names; null or an empty list for all repositories.') })); +export const PluginOAuthClientRequest = component('PluginOAuthClientRequest', z.strictObject({ + client: z.strictObject({ + clientId: z.string().regex(/^[\x21-\x7e]{1,512}$/).describe('The client id registered with the provider for this instance.'), + clientSecret: z.string().max(4096).nullable().optional().describe('The client secret (write-only); leave out for a public client.'), + }).nullable().describe('The pre-registered client, or null to clear it.'), +})); export const PluginConnectionRequest = component('PluginConnectionRequest', z.strictObject({ value: z.string().max(4096).nullable().describe('The secret to store (write-only), or null to clear it.') })); export const PluginEnabledRequest = component('PluginEnabledRequest', z.strictObject({ enabled: z.boolean() })); export const PluginUninstallRequest = component('PluginUninstallRequest', z.strictObject({ confirm: z.string().describe('Must repeat the plugin id.') })); +export const PluginEmptyRequest = component('PluginEmptyRequest', z.strictObject({})); +export const PluginOAuthCompleteRequest = component('PluginOAuthCompleteRequest', z.strictObject({ + state: z.string().describe('The `state` the provider returned to the redirect URI.'), code: z.string().optional(), error: z.string().optional(), iss: z.string().optional(), +})); +export const PluginOAuthStartResponse = component('PluginOAuthStartResponse', z.object({ authorizationUrl: z.string().describe('Open this URL to sign in; the provider redirects to the extension OAuth callback.'), server: z.string().describe('The managed library server being signed in.') })); diff --git a/packages/api/pluginRouteRegistry.ts b/packages/api/pluginRouteRegistry.ts index 9d23c7181d..3437bdfd6b 100644 --- a/packages/api/pluginRouteRegistry.ts +++ b/packages/api/pluginRouteRegistry.ts @@ -16,5 +16,9 @@ export function createPluginRouteEntries(plugins: ReturnType { requireOwner(req); - res.json(AUTOMATION_TEMPLATE_CATALOG); + res.json(await automationTemplateCatalog(database)); }); const create = handler('Failed to create automation definition', async (req, res) => { diff --git a/packages/api/routes/pluginRoutes.ts b/packages/api/routes/pluginRoutes.ts index 02df7e5107..a499fdffed 100644 --- a/packages/api/routes/pluginRoutes.ts +++ b/packages/api/routes/pluginRoutes.ts @@ -3,15 +3,18 @@ import { db } from '@propr/core'; import { PluginError, PluginStore } from '../../core/src/plugins/pluginStore.js'; import { isDemoViewer } from '../demoMode.js'; import { refuseDemoViewer } from '../permissionGuards.js'; +import { extensionOAuthUrls } from './extensionOAuthRoutes.js'; /** * Plugin installation state under `/api/instance/plugins/v1`. Every route, * reads included, needs a current durable administrator with * `instance.manage_settings`; mutations additionally need the * preflight-protected JSON contract (`X-ProPR-Plugins: 1`). Connection - * secrets are write-only: no response carries them. + * secrets are write-only: no response carries them. `oauth` connections are + * signed in through the extension OAuth module: the provider redirects to the + * extension callback, which completes the sign-in as `oauth/complete` does. */ -export function createPluginRoutes(store = new PluginStore(db)) { +export function createPluginRoutes(store = new PluginStore(db), environment: NodeJS.ProcessEnv = process.env) { const handle = (run: (req: Request, actor: string) => Promise): RequestHandler => async (req, res, next) => { // Plugins hold sealed per-installation secrets; the demo explains instead of reading them. if (isDemoViewer(req.authorization)) return refuseDemoViewer(req, res, next); @@ -32,6 +35,7 @@ export function createPluginRoutes(store = new PluginStore(db)) { return value as Record; }; const id = (req: Request) => String(req.params.pluginId); + const connection = (req: Request) => String(req.params.connectionName); return { list: handle(async (_req, actor) => ({ plugins: await store.list(actor) })), get: handle(async (req, actor) => ({ plugin: await store.get(actor, id(req)) })), @@ -44,5 +48,15 @@ export function createPluginRoutes(store = new PluginStore(db)) { setEnabled: handle(async (req, actor) => ({ plugin: await store.setEnabled(actor, id(req), body(req, ['enabled']).enabled) })), approve: handle(async (req, actor) => ({ plugin: await store.approve(actor, id(req), body(req, ['acknowledgements'], []).acknowledgements) })), uninstall: handle(async (req, actor) => ({ plugin: await store.uninstall(actor, id(req), body(req, ['confirm']).confirm) })), + /** Start the sign-in of an `oauth` connection, bound to this browser session; returns the provider's authorization URL. */ + startOAuth: handle(async (req, actor) => { body(req, [], []); return store.startOAuth(actor, id(req), connection(req), req.sessionID, extensionOAuthUrls(environment)); }), + /** Complete it with the `state` and `code` the provider returned, in the session that started it. */ + completeOAuth: handle(async (req, actor) => { + const query = body(req, ['state', 'code', 'error', 'iss'], ['state']); + return { plugin: await store.completeOAuth(actor, id(req), connection(req), req.sessionID, query) }; + }), + /** Configure (`{ clientId, clientSecret? }`) or clear (`null`) the pre-registered client of an `oauth` connection; the secret is write-only. */ + setOAuthClient: handle(async (req, actor) => ({ plugin: await store.setOAuthClient(actor, id(req), connection(req), body(req, ['client']).client) })), + disconnectOAuth: handle(async (req, actor) => { body(req, [], []); return { plugin: await store.disconnectOAuth(actor, id(req), connection(req)) }; }), }; } diff --git a/packages/api/test/pluginRoutes.test.ts b/packages/api/test/pluginRoutes.test.ts index 8aaeb117cb..5dacd149a1 100644 --- a/packages/api/test/pluginRoutes.test.ts +++ b/packages/api/test/pluginRoutes.test.ts @@ -13,6 +13,11 @@ import { getOfficialPlugin } from '../../plugins/src/index.js'; import { PluginStore } from '../../core/src/plugins/pluginStore.js'; import { up as identityMigration } from '../../core/src/db/migrations/20261013210000_extension_profiles.js'; import { up as pluginMigration } from '../../core/src/db/migrations/20261114000000_plugin_installations.js'; +import { up as grantMigration } from '../../core/src/db/migrations/20261013220000_extension_grant_network.js'; +import { up as libraryMigration } from '../../core/src/db/migrations/20261113000000_extension_library.js'; +import { up as oauthMigration } from '../../core/src/db/migrations/20261113120000_extension_oauth.js'; +import { ExtensionOAuthStore, setExtensionOAuth } from '../../core/src/extensions/extensionOAuthStore.js'; +import { OAUTH_MCP_URL, syntheticOAuthProvider } from '../../../test/fixtures/extensionOAuthFixture.js'; const SENTINEL = 'plugin-api-token-sentinel-41c7'; const ID = 'example-pack'; @@ -37,12 +42,17 @@ test('plugin REST API and MCP read tools: administrators only, audited, secret-f const database = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); await database.schema.createTable('instance_members', c => { c.string('github_user_id').primary(); c.string('role'); }); await database.schema.createTable('agent_definitions', c => { c.string('id').primary(); c.string('name'); }); - await identityMigration(database); await pluginMigration(database); + await identityMigration(database); await grantMigration(database); await libraryMigration(database); await oauthMigration(database); await pluginMigration(database); // 3 holds the admin role in the session but is no longer a durable administrator. await database('instance_members').insert([{ github_user_id: '1', role: 'admin' }, { github_user_id: '2', role: 'member' }, { github_user_id: '3', role: 'member' }]); - const example = getOfficialPlugin(ID, { includeDevelopment: true })!.manifest; + // The remote server at the synthetic OAuth provider's address literal: no DNS in tests. + const official = structuredClone(getOfficialPlugin(ID, { includeDevelopment: true })!.manifest); + official.contributes.mcpServers[0] = { ...official.contributes.mcpServers[0], url: OAUTH_MCP_URL } as typeof official.contributes.mcpServers[0]; + const example = pluginManifestSchema.parse(official); let catalog: PluginManifest[] = [example]; const store = new PluginStore(database, { SYSTEM_TASK_SECRET: 'plugin-api-custody' }, async () => catalog); + const synthetic = syntheticOAuthProvider({ advertise: { dynamic: true } }); + setExtensionOAuth(store.library, new ExtensionOAuthStore(store.library, { transport: synthetic.transport, retryDelayMs: 1 })); const app = express(); app.use(rateLimit({ windowMs: 60_000, limit: 1000 })); @@ -50,9 +60,10 @@ test('plugin REST API and MCP read tools: administrators only, audited, secret-f app.use((req, _res, next) => { const actor = req.get('x-test-actor'); if (actor) req.user = { id: actor, username: 'fixture' } as typeof req.user; const member = req.get('x-test-role') === 'member'; - req.authorization = { role: member ? 'member' : 'admin', permissions: member ? [] : ['instance.manage_settings'], source: 'local' }; next(); + req.authorization = { role: member ? 'member' : 'admin', permissions: member ? [] : ['instance.manage_settings'], source: 'local' }; + (req as { sessionID?: string }).sessionID = req.get('x-test-session') ?? 'session-a'; next(); }); - const entries = createPluginRouteEntries(createPluginRoutes(store)); + const entries = createPluginRouteEntries(createPluginRoutes(store, { API_PUBLIC_URL: 'https://propr.example.com' })); registerRouteEntries(app, entries); const server = app.listen(0, '127.0.0.1'); await new Promise(r => server.once('listening', r)); t.after(async () => { await new Promise(r => server.close(() => r())); await database.destroy(); await (await import('@propr/core')).closeConnection(); await fs.rm(root, { recursive: true, force: true }); }); @@ -94,8 +105,53 @@ test('plugin REST API and MCP read tools: administrators only, audited, secret-f assert.equal(connected.body.plugin.connections.find((c: { name: string }) => c.name === 'example-token').state, 'connected'); assert.equal((await call('PUT', `/${ID}/connections/example-account`, { value: SENTINEL })).status, 400); assert.equal((await call('PUT', `/${ID}/connections/missing`, { value: SENTINEL })).status, 404); - assert.equal((await call('PUT', `/${ID}/enabled`, { enabled: true })).body.plugin.state, 'enabled'); - assert.equal((await call('PUT', `/${ID}/enabled`, { enabled: false })).body.plugin.state, 'installed'); + const enabled = await call('PUT', `/${ID}/enabled`, { enabled: true }); + assert.equal(enabled.body.plugin.state, 'enabled'); + assertConforms(schemas.PluginResponse, enabled.body, 'enable'); + // The read model lists the contributions with their managed library items; the plugin's repository limit applies to them. + const contributions = enabled.body.plugin.contributions; + assert.deepEqual(contributions.mcpServers.map((s: { name: string; managed: unknown }) => [s.name, s.managed]), [ + ['example-remote', { kind: 'server', name: 'example-remote', enabled: true, active: true }], + ['example-local', { kind: 'server', name: 'example-local', enabled: true, active: true }], + ]); + assert.equal(contributions.skills[0].managed.kind, 'skill'); + assert.equal(contributions.mcpServers[0].href, '/api/instance/extensions/v1'); + assert.deepEqual(contributions.automationTemplates.map((t: { catalogId: string; available: boolean }) => [t.catalogId, t.available]), [['example-weekly-digest', true]]); + const libraryItems = (await store.library.readLibrary('1')); + assert.deepEqual([...libraryItems.servers, ...libraryItems.skills].map(item => item.repositories), [['acme/app'], ['acme/app'], ['acme/app']]); + + // OAuth connections: start, complete (as the provider's redirect would) and disconnect. + assert.equal(enabled.body.plugin.connections.find((c: { name: string }) => c.name === 'example-account').state, 'not_connected'); + assert.equal((await call('POST', `/${ID}/connections/example-token/oauth/start`, {})).status, 404); + assert.equal((await call('POST', `/${ID}/connections/example-account/oauth/start`, { extra: 1 })).status, 400); + const started = await call('POST', `/${ID}/connections/example-account/oauth/start`, {}); + assert.equal(started.status, 200); + assertConforms(schemas.PluginOAuthStartResponse, started.body, 'start OAuth'); + assert.equal(started.body.server, 'example-remote'); + const params = synthetic.authorize(started.body.authorizationUrl); + assert.equal((await call('POST', `/${ID}/connections/example-account/oauth/complete`, params, { ...admin, 'x-test-session': 'session-b' })).status, 403, 'only the session that started it completes it'); + const second = await call('POST', `/${ID}/connections/example-account/oauth/start`, {}); + const oauthDone = await call('POST', `/${ID}/connections/example-account/oauth/complete`, synthetic.authorize(second.body.authorizationUrl)); + assert.equal(oauthDone.status, 200, JSON.stringify(oauthDone.body)); + assertConforms(schemas.PluginResponse, oauthDone.body, 'complete OAuth'); + assert.equal(oauthDone.body.plugin.connections.find((c: { name: string }) => c.name === 'example-account').state, 'connected'); + const oauthOff = await call('POST', `/${ID}/connections/example-account/oauth/disconnect`, {}); + assert.equal(oauthOff.body.plugin.connections.find((c: { name: string }) => c.name === 'example-account').state, 'not_connected'); + // A pre-registered client of the OAuth connection: configured and cleared through the plugin; the secret is write-only. + assert.equal((await call('PUT', `/${ID}/connections/example-account/oauth/client`, { client: { clientId: 'propr-client', extra: 1 } })).status, 400); + assert.equal((await call('PUT', `/${ID}/connections/example-token/oauth/client`, { client: { clientId: 'propr-client' } })).status, 404); + const client = await call('PUT', `/${ID}/connections/example-account/oauth/client`, { client: { clientId: 'propr-client', clientSecret: SENTINEL } }); + assert.equal(client.status, 200, JSON.stringify(client.body)); + assertConforms(schemas.PluginResponse, client.body, 'set OAuth client'); + assert.equal(client.body.plugin.connections.find((c: { name: string }) => c.name === 'example-account').setBy, '1'); + const remote = (await store.library.readLibrary('1')).servers.find(s => s.name === 'example-remote'); + assert.equal(remote?.kind === 'https' && remote.clientId, 'propr-client'); + assert.equal((await call('PUT', `/${ID}/connections/example-account/oauth/client`, { client: null })).status, 200); + + const disabled = await call('PUT', `/${ID}/enabled`, { enabled: false }); + assert.equal(disabled.body.plugin.state, 'installed'); + assert.deepEqual(disabled.body.plugin.contributions.mcpServers.map((s: { managed: { active: boolean } }) => s.managed.active), [false, false]); + assert.equal(disabled.body.plugin.contributions.automationTemplates[0].available, false); const reenabled = await call('PUT', `/${ID}/enabled`, { enabled: true }); assert.equal(reenabled.body.plugin.state, 'enabled'); assert.equal(reenabled.body.plugin.installation.settings.verbosity, 'quiet', 'settings survive disable'); @@ -150,9 +206,12 @@ test('plugin REST API and MCP read tools: administrators only, audited, secret-f const disabledBeforeUninstall = await call('POST', `/${ID}/uninstall`, { confirm: ID }); assert.equal(disabledBeforeUninstall.body.plugin.state, 'available'); assert.equal((await database('plugin_secrets').count({ n: '*' }))[0].n, 0); + const emptied = await store.library.readLibrary('1'); + assert.deepEqual([emptied.servers, emptied.skills], [[], []], 'uninstall removes the managed items'); + bodies.push(JSON.stringify(await store.library.history('1'))); const audit = await call('GET', `/${ID}`); assert.deepEqual(audit.body.plugin.audit.map((e: { action: string }) => e.action).reverse(), [ - 'install', 'update_settings', 'set_repositories', 'set_connection', 'enable', 'disable', 'enable', 'approve', 'uninstall', + 'install', 'update_settings', 'set_repositories', 'set_connection', 'enable', 'start_oauth', 'start_oauth', 'complete_oauth', 'disconnect_oauth', 'set_oauth_client', 'clear_oauth_client', 'disable', 'enable', 'approve', 'uninstall', ]); for (const text of bodies) assert.ok(!text.includes(SENTINEL), 'no response carries a connection secret'); diff --git a/packages/client/src/generated/apiTypes.ts b/packages/client/src/generated/apiTypes.ts index 640d163928..83325f5825 100644 --- a/packages/client/src/generated/apiTypes.ts +++ b/packages/client/src/generated/apiTypes.ts @@ -371,7 +371,7 @@ export interface ExtensionItemEnabledRequest { enabled: boolean; } -/** Strict shared v1 library contract: up to 32 servers and 32 skill bundle references. Each item has enabled, optional repositories (owner/repo; empty means every repository) and agents (aliases; empty means every agent) limits, and autoApply (default true; false applies the item only where an automation adds it). Operator catalog descriptors accept only immutable catalogId/digest and selected tools/capability; customer commands, arguments, environment and mount paths are rejected. */ +/** Strict shared v1 library contract: up to 32 servers and 32 skill bundle references. Each item has enabled, optional repositories (owner/repo; empty means every repository) and agents (aliases; empty means every agent) limits, and autoApply (default true; false applies the item only where an automation adds it). Items an installed plugin contributes carry managedBy (plugin id, manifest item name, active, and for OAuth servers the client registration methods the plugin permits at sign-in); their definition is read-only and only enabled, the limits and autoApply can change, and they cannot be added or removed through this route. Operator catalog descriptors accept only immutable catalogId/digest and selected tools/capability; customer commands, arguments, environment and mount paths are rejected. */ export interface ExtensionLibraryInput { version: 1; servers: Array<{ @@ -387,6 +387,12 @@ export interface ExtensionLibraryInput { repositories: string[]; agents: string[]; autoApply: boolean; + managedBy?: { + plugin: string; + item: string; + active: boolean; + registrations?: Array<"metadata_document" | "dynamic" | "preregistered">; + }; } | { kind: "catalog"; name: string; @@ -398,6 +404,12 @@ export interface ExtensionLibraryInput { repositories: string[]; agents: string[]; autoApply: boolean; + managedBy?: { + plugin: string; + item: string; + active: boolean; + registrations?: Array<"metadata_document" | "dynamic" | "preregistered">; + }; } | { name: string; enabled: boolean; @@ -418,14 +430,26 @@ export interface ExtensionLibraryInput { repositories: string[]; agents: string[]; autoApply: boolean; + managedBy?: { + plugin: string; + item: string; + active: boolean; + registrations?: Array<"metadata_document" | "dynamic" | "preregistered">; + }; }>; - skills: { + skills: Array<{ bundleId: string; enabled: boolean; repositories: string[]; agents: string[]; autoApply: boolean; - }[]; + managedBy?: { + plugin: string; + item: string; + active: boolean; + registrations?: Array<"metadata_document" | "dynamic" | "preregistered">; + }; + }>; } export interface ExtensionLibraryRead { @@ -923,10 +947,11 @@ export interface PluginDetailResponse { name: string; type: "oauth" | "token" | "signing_secret"; label: string; - /** `oauth` connections report not_connected until OAuth sign-in for plugins is available. */ - state: "connected" | "not_connected"; + /** An `oauth` connection is connected once every managed server using it is signed in through the extension OAuth sign-in, and needs_sign_in after a failed refresh. */ + state: "connected" | "not_connected" | "needs_sign_in"; /** A `token` connection a contributed MCP server uses; enabling waits for it. */ required: boolean; + /** Who set the value; for an `oauth` connection, who configured its pre-registered client. */ setBy?: string; setAt?: number; }>; @@ -941,9 +966,48 @@ export interface PluginDetailResponse { }[]; /** Why enabling is refused right now; empty when it can be enabled. */ enableBlockers: string[]; + /** The managed extension library items and automation templates the plugin contributes; items are null until the plugin is installed. */ + contributions?: { + mcpServers: Array<{ + /** The server name in the manifest. */ + name: string; + kind: "https" | "package"; + managed: { + kind: "server"; + /** The library item name; prefixed with the plugin id after a name collision. */ + name: string; + /** The library item's own enabled switch. */ + enabled: boolean; + /** False while the plugin is disabled or a secret acknowledgement is pending: the item then never applies. */ + active: boolean; + } | null; + /** The extension library route that lists and edits the item. */ + href: string; + }>; + skills: Array<{ + name: string; + managed: { + kind: "skill"; + bundleId: string; + /** The library item's own enabled switch. */ + enabled: boolean; + /** False while the plugin is disabled or a secret acknowledgement is pending: the item then never applies. */ + active: boolean; + } | null; + href: string; + }>; + automationTemplates: { + id: string; + /** The template id in the automation template catalog. */ + catalogId: string; + /** Listed in the catalog: the plugin is enabled. */ + available: boolean; + href: string; + }[]; + }; /** The newest 50 management actions, newest first. */ audit: Array<{ - action: "install" | "update_settings" | "set_repositories" | "set_connection" | "clear_connection" | "enable" | "disable" | "approve" | "uninstall"; + action: "install" | "update_settings" | "set_repositories" | "set_connection" | "clear_connection" | "enable" | "disable" | "approve" | "uninstall" | "start_oauth" | "complete_oauth" | "disconnect_oauth" | "set_oauth_client" | "clear_oauth_client"; actor: string; at: number; detail: { @@ -953,6 +1017,8 @@ export interface PluginDetailResponse { }; } +export type PluginEmptyRequest = Record; + export interface PluginEnabledRequest { enabled: boolean; } @@ -961,6 +1027,31 @@ export interface PluginList { plugins: PluginView[]; } +export interface PluginOAuthClientRequest { + /** The pre-registered client, or null to clear it. */ + client: { + /** The client id registered with the provider for this instance. */ + clientId: string; + /** The client secret (write-only); leave out for a public client. */ + clientSecret?: string | null; + } | null; +} + +export interface PluginOAuthCompleteRequest { + /** The `state` the provider returned to the redirect URI. */ + state: string; + code?: string; + error?: string; + iss?: string; +} + +export interface PluginOAuthStartResponse { + /** Open this URL to sign in; the provider redirects to the extension OAuth callback. */ + authorizationUrl: string; + /** The managed library server being signed in. */ + server: string; +} + export interface PluginRepositoriesRequest { /** owner/repo names; null or an empty list for all repositories. */ repositories: string[] | null; @@ -1070,10 +1161,11 @@ export interface PluginView { name: string; type: "oauth" | "token" | "signing_secret"; label: string; - /** `oauth` connections report not_connected until OAuth sign-in for plugins is available. */ - state: "connected" | "not_connected"; + /** An `oauth` connection is connected once every managed server using it is signed in through the extension OAuth sign-in, and needs_sign_in after a failed refresh. */ + state: "connected" | "not_connected" | "needs_sign_in"; /** A `token` connection a contributed MCP server uses; enabling waits for it. */ required: boolean; + /** Who set the value; for an `oauth` connection, who configured its pre-registered client. */ setBy?: string; setAt?: number; }>; @@ -1088,6 +1180,45 @@ export interface PluginView { }[]; /** Why enabling is refused right now; empty when it can be enabled. */ enableBlockers: string[]; + /** The managed extension library items and automation templates the plugin contributes; items are null until the plugin is installed. */ + contributions?: { + mcpServers: Array<{ + /** The server name in the manifest. */ + name: string; + kind: "https" | "package"; + managed: { + kind: "server"; + /** The library item name; prefixed with the plugin id after a name collision. */ + name: string; + /** The library item's own enabled switch. */ + enabled: boolean; + /** False while the plugin is disabled or a secret acknowledgement is pending: the item then never applies. */ + active: boolean; + } | null; + /** The extension library route that lists and edits the item. */ + href: string; + }>; + skills: Array<{ + name: string; + managed: { + kind: "skill"; + bundleId: string; + /** The library item's own enabled switch. */ + enabled: boolean; + /** False while the plugin is disabled or a secret acknowledgement is pending: the item then never applies. */ + active: boolean; + } | null; + href: string; + }>; + automationTemplates: { + id: string; + /** The template id in the automation template catalog. */ + catalogId: string; + /** Listed in the catalog: the plugin is enabled. */ + available: boolean; + href: string; + }[]; + }; } /** Read-only readiness verdict of one pull request. Evaluating it never writes to GitHub or Redis, never updates the branch or merges, and never changes auto-merge state. */ diff --git a/packages/core/src/extensions/extensionManagedItems.ts b/packages/core/src/extensions/extensionManagedItems.ts new file mode 100644 index 0000000000..4a59c40510 --- /dev/null +++ b/packages/core/src/extensions/extensionManagedItems.ts @@ -0,0 +1,52 @@ +import { EXTENSION_MANAGED_EDITABLE_FIELDS, type ExtensionLibraryItems, type ExtensionLibraryServer, type ExtensionLibrarySkill } from '@propr/shared'; +import { ExtensionError } from './extensionProfileStore.js'; +import { credentialOwner } from './extensionCredentials.js'; + +type Item = ExtensionLibraryServer | ExtensionLibrarySkill; +const key = (item: Item) => ('kind' in item ? `server:${item.name}` : `skill:${item.bundleId}`); +const label = (item: Item) => ('kind' in item ? item.name : item.bundleId); +/** The read-only part of an item: everything except the fields an administrator may change. */ +function definition(item: Item, fallback?: Item): Record { + const rest: Record = { ...item }; + for (const field of EXTENSION_MANAGED_EDITABLE_FIELDS) delete rest[field]; + // A save request may leave out the integrity value; ProPR fills it from the saved revision. + if ('kind' in item && item.kind === 'package' && rest.integrity === undefined && fallback && 'kind' in fallback && fallback.kind === 'package') rest.integrity = fallback.integrity; + return Object.fromEntries(Object.entries(rest).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); +} + +/** + * Refuse a library save that edits, removes or invents a plugin-managed item. + * Managed items keep their definition, marker and stored credentials; only + * `enabled`, `repositories`, `agents` and `autoApply` may change. They are + * removed by uninstalling the plugin, never through the library. + */ +export function assertManagedItemsUnchanged(previous: Pick, next: Pick, replacements: Record = {}): void { + const before = new Map([...previous.servers, ...previous.skills].map(item => [key(item), item])); + const after = new Map([...next.servers, ...next.skills].map(item => [key(item), item])); + for (const [itemKey, item] of before) { + if (!item.managedBy) continue; + const current = after.get(itemKey); + if (!current) throw new ExtensionError(409, `${label(item)} is managed by plugin ${item.managedBy.plugin}; uninstall the plugin to remove it`); + if (JSON.stringify(definition(current, item)) !== JSON.stringify(definition(item))) throw new ExtensionError(409, `${label(item)} is managed by plugin ${item.managedBy.plugin}; only enabled, repository and agent limits and automatic apply can be changed`); + } + for (const [itemKey, item] of after) { + if (item.managedBy && !before.get(itemKey)?.managedBy) throw new ExtensionError(400, 'Only ProPR adds plugin-managed items'); + } + for (const name of Object.keys(replacements)) { + if (isManagedCredential(previous.servers, name)) throw new ExtensionError(409, `The credential of ${credentialOwner(name)} comes from its plugin's connections`); + } +} + +/** + * The items of a save request with the marker of each saved managed item + * filled in where the request left it out (a client that rebuilds items from + * their editable fields). Every other field is compared as sent. + */ +export function withManagedMarkers>(previous: Pick, next: T): T { + const marker = (item: Item) => (item.managedBy ? item : [...previous.servers, ...previous.skills].find(saved => saved.managedBy && key(saved) === key(item))?.managedBy); + const fill = (item: I): I => { const found = marker(item); return found && found !== item ? { ...item, managedBy: found } : item; }; + return { ...next, servers: next.servers.map(fill), skills: next.skills.map(fill) }; +} + +/** Whether a sealed-credential key (`server` or `server/VARIABLE`) belongs to a managed server. */ +export const isManagedCredential = (servers: readonly ExtensionLibraryServer[], name: string) => servers.some(s => s.managedBy && s.name === credentialOwner(name)); diff --git a/packages/core/src/extensions/extensionOAuth.ts b/packages/core/src/extensions/extensionOAuth.ts index 1d85e70776..0465b1ee7f 100644 --- a/packages/core/src/extensions/extensionOAuth.ts +++ b/packages/core/src/extensions/extensionOAuth.ts @@ -131,13 +131,22 @@ export async function discoverOAuth(transport: OAuthTransport, serverUrl: string * The registration method a sign-in uses. A client id the administrator * entered is explicit configuration and is used as given; otherwise a Client * ID Metadata Document when the server supports it and this instance has a - * public URL, then Dynamic Client Registration. Null when none is possible. + * public URL, then Dynamic Client Registration. With `allowed` (a plugin + * connection's permitted methods) only those are considered, in that order, + * except that a configured client id always selects pre-registration (or + * nothing when pre-registration is not permitted). Null when none is possible. */ -export function chooseRegistration(metadata: AuthorizationServerMetadata, options: { clientId?: string; clientMetadataUrl: string | null }): ExtensionOAuthRegistration | null { - if (options.clientId) return 'preregistered'; - if (metadata.client_id_metadata_document_supported && options.clientMetadataUrl) return 'metadata_document'; - if (metadata.registration_endpoint) return 'dynamic'; - return null; +export function chooseRegistration(metadata: AuthorizationServerMetadata, options: { clientId?: string; clientMetadataUrl: string | null; allowed?: readonly ExtensionOAuthRegistration[] }): ExtensionOAuthRegistration | null { + // A configured client id binds the server's connection to that client (see connectionBelongsTo), so no automatic + // registration can produce a usable connection for it: pre-registration is the only method, whatever the order. + if (options.clientId) return !options.allowed || options.allowed.includes('preregistered') ? 'preregistered' : null; + const available: Record = { + preregistered: false, + metadata_document: Boolean(metadata.client_id_metadata_document_supported && options.clientMetadataUrl), + dynamic: Boolean(metadata.registration_endpoint), + }; + const order: readonly ExtensionOAuthRegistration[] = options.allowed ?? ['preregistered', 'metadata_document', 'dynamic']; + return order.find(method => available[method]) ?? null; } /** ProPR's own client metadata, served at `clientMetadataUrl`, which is also the client id. */ diff --git a/packages/core/src/extensions/extensionOAuthState.ts b/packages/core/src/extensions/extensionOAuthState.ts index 69b4b405d0..5e13fb22cb 100644 --- a/packages/core/src/extensions/extensionOAuthState.ts +++ b/packages/core/src/extensions/extensionOAuthState.ts @@ -74,6 +74,25 @@ export async function discardStaleOAuthConnections(trx: Knex.Transaction, instan return stale; } +/** + * Inside a change that replaces the OAuth client of `serverNames` (a client + * secret rotation keeps everything `connectionBelongsTo` compares): delete + * their pending sign-ins, then their connections. The pending rows go first: + * a callback still exchanging a code under the old client then finds its + * sign-in cancelled, and one that already committed has its connection + * deleted by the second statement. + */ +export async function discardOAuthConnectionsOf(trx: Knex.Transaction, instanceId: string, serverNames: readonly string[], actor: string): Promise { + if (!serverNames.length || !await hasOAuthTables(trx)) return []; + await trx(OAUTH_PENDING).where({ instance_id: instanceId }).whereIn('server_name', serverNames).delete(); + const discarded = (await trx(OAUTH_CONNECTIONS).where({ instance_id: instanceId }).whereIn('server_name', serverNames).select('server_name')).map(row => row.server_name as string); + if (!discarded.length) return []; + await trx(OAUTH_CONNECTIONS).where({ instance_id: instanceId }).whereIn('server_name', discarded).delete(); + const now = Date.now(); + await trx(OAUTH_AUDIT).insert(discarded.map(name => ({ instance_id: instanceId, server_name: name, action: 'discarded', actor, at: now }))); + return discarded; +} + /** The library view with the current connection state of its enabled OAuth servers, for resolution. */ export async function withOAuthStates(database: Knex, snapshot: T): Promise }> { return { ...snapshot, oauth: await oauthConnectionStates(database, snapshot.instanceId, snapshot.servers.filter(s => s.enabled)) }; diff --git a/packages/core/src/extensions/extensionOAuthStore.ts b/packages/core/src/extensions/extensionOAuthStore.ts index 7ddd5e7bad..70b3e18509 100644 --- a/packages/core/src/extensions/extensionOAuthStore.ts +++ b/packages/core/src/extensions/extensionOAuthStore.ts @@ -148,11 +148,14 @@ export class ExtensionOAuthStore { try { discovery = await discoverOAuth(this.transport, server.url, server.scopes); } catch (error) { throw new ExtensionError(502, error instanceof OAuthProtocolError || error instanceof OAuthNetworkError ? error.message : 'OAuth discovery failed'); } if (!discovery) throw new ExtensionError(409, 'The server answered without requiring authorization'); - const registration = chooseRegistration(discovery.metadata, { clientId: server.clientId, clientMetadataUrl: urls.clientMetadataUrl }); + // A plugin-managed server signs in only through the registration methods its plugin connection permits. + const allowed = server.managedBy?.registrations; + const registration = chooseRegistration(discovery.metadata, { clientId: server.clientId, clientMetadataUrl: urls.clientMetadataUrl, ...(allowed ? { allowed } : {}) }); let client: OAuthClient; if (registration === 'preregistered') client = preregisteredClient(discovery.metadata, server.clientId!, secret); else if (registration === 'metadata_document') client = { registration, clientId: urls.clientMetadataUrl!, authMethod: 'none' }; else if (registration === 'dynamic') client = await this.dynamicClient(instanceId, server, discovery.metadata, urls.redirectUri); + else if (allowed) throw new ExtensionError(409, `The authorization server supports none of the client registration methods the plugin permits (${allowed.join(', ')})`); else throw new ExtensionError(409, 'The authorization server supports no client registration ProPR can use. Enter a pre-registered client id, or use a Bearer token.'); const pkce = createPkce(); const { clientSecret, ...publicClient } = client; diff --git a/packages/core/src/extensions/extensionPackageLibrary.ts b/packages/core/src/extensions/extensionPackageLibrary.ts index eee9117448..7efbbde72f 100644 --- a/packages/core/src/extensions/extensionPackageLibrary.ts +++ b/packages/core/src/extensions/extensionPackageLibrary.ts @@ -20,6 +20,9 @@ export async function pinPackageServers(servers: ExtensionLibraryServer[], saved return pinned; } +/** Acknowledgements of plugin-managed package servers, from their plugin; a name mapped to undefined has none. */ +export type ManagedAcknowledgements = Record; + const acknowledgementBinding = (value: { package: string; version: string; secretEnv: string[] }) => JSON.stringify([value.package, value.version, [...value.secretEnv].sort()]); /** Validate the names a save acknowledges: package servers with secret variables only. */ @@ -36,12 +39,22 @@ export function parseAcknowledgements(value: unknown, servers: ExtensionLibraryS * version and secret variable names are unchanged, otherwise given by this * save (`acknowledged`) and recorded with the actor and time. With `carry` * (a change that alters no server) existing ones carry over unchanged. + * + * A plugin-managed server's acknowledgement is the one recorded when its + * plugin was installed or approved, passed in `managed` by the plugin sync; + * a library save never acknowledges it. Without one the server is recorded + * without an acknowledgement and its marker keeps it inactive. */ -// eslint-disable-next-line max-params -- the base revision, new servers, acknowledged names, actor and time are explicit inputs -export function secretAcknowledgements(previous: ExtensionLibraryRevision, servers: ExtensionLibraryServer[], acknowledged: readonly string[] | 'carry', actor: string, now: number): Record { +// eslint-disable-next-line max-params -- the base revision, new servers, acknowledged names, actor, time and managed acknowledgements are explicit inputs +export function secretAcknowledgements(previous: ExtensionLibraryRevision, servers: ExtensionLibraryServer[], acknowledged: readonly string[] | 'carry', actor: string, now: number, managed: ManagedAcknowledgements = {}): Record { const result: Record = {}; for (const server of packageSecretServers(servers)) { const carried = previous.acknowledgements && Object.hasOwn(previous.acknowledgements, server.name) ? previous.acknowledgements[server.name] : undefined; + if (server.managedBy) { + const recorded = Object.hasOwn(managed, server.name) ? managed[server.name] : carried; + if (recorded && acknowledgementBinding(recorded) === acknowledgementBinding(server)) result[server.name] = recorded; + continue; + } if (carried && acknowledgementBinding(carried) === acknowledgementBinding(server)) result[server.name] = carried; else if (acknowledged !== 'carry' && acknowledged.includes(server.name)) result[server.name] = { actor, at: now, package: server.package, version: server.version, secretEnv: [...server.secretEnv] }; else if (acknowledged !== 'carry') throw new ExtensionError(400, `Confirm that the secret variables of ${server.name} are readable by the agent and scripts in the task container before saving`); diff --git a/packages/core/src/extensions/extensionProfileStore.ts b/packages/core/src/extensions/extensionProfileStore.ts index 98719558b1..391054c8d6 100644 --- a/packages/core/src/extensions/extensionProfileStore.ts +++ b/packages/core/src/extensions/extensionProfileStore.ts @@ -17,7 +17,8 @@ import { discardStaleOAuthConnections, withOAuthStates } from './extensionOAuthS import { bearerServers, credentialBindings, credentialField, credentialOwner, credentialServers, isCredentialKey, nextCredentials, packageSecretKeys, packageSecretServers, packageSecretStatus, packageSecretValues, revisionSecretStatuses, } from './extensionCredentials.js'; -import { parseAcknowledgements, pinPackageServers, secretAcknowledgements } from './extensionPackageLibrary.js'; +import { parseAcknowledgements, pinPackageServers, secretAcknowledgements, type ManagedAcknowledgements } from './extensionPackageLibrary.js'; +import { assertManagedItemsUnchanged, isManagedCredential, withManagedMarkers } from './extensionManagedItems.js'; export interface ExtensionGrantNetwork { mode: 'open' | 'restricted'; allow: string[]; instanceAllow: string[] } export class ExtensionError extends Error { constructor(readonly status: number, message: string) { super(message); } } @@ -41,7 +42,7 @@ export function unsealLibrarySecrets(encrypted: string, instanceId: string, revi /** A revision's sealed credentials as a later revision carries them, naming the revision that sealed them. */ const carriedSecrets = (head: Head) => (head.secretsEncrypted === '' || carriedSecretsPattern.test(head.secretsEncrypted) ? head.secretsEncrypted : `${head.snapshot.revision}:${head.secretsEncrypted}`); /** Passed instead of credentials by a change that alters none: the base revision's sealed credentials carry over. */ -const UNCHANGED_CREDENTIALS = Symbol('unchanged library credentials'); +export const UNCHANGED_CREDENTIALS = Symbol('unchanged library credentials'); const legacyIdentity = (s: { instanceId: string; profileId: string; revision: number }) => `${s.instanceId}/${s.profileId}/${s.revision}`; const tokenPattern = /^[\x21-\x7e]{1,4096}$/; @@ -61,7 +62,7 @@ export interface GrantSnapshot { snapshot: { instanceId: string; revision: number; servers: ExtensionLibraryServer[]; skillBundles: ExtensionSkillBundle[] }; secrets: Record; network: ExtensionGrantNetwork; taskId: string; allowWrite: boolean; } -interface Head { snapshot: ExtensionLibraryRevision; secretsEncrypted: string } +export interface Head { snapshot: ExtensionLibraryRevision; secretsEncrypted: string } const emptyLibrary = (instanceId: string): ExtensionLibraryRevision => ({ version: EXTENSION_LIBRARY_VERSION, instanceId, revision: 0, createdAt: 0, updatedAt: 0, auditActor: '', @@ -107,7 +108,7 @@ export class ExtensionLibraryStore { if (row.secrets_encrypted === '') for (const server of [...credentialServers(snapshot.servers), ...packageSecretServers(snapshot.servers)]) snapshot.secrets[server.name] = server.kind === 'package' ? packageSecretStatus(server, {}) : { fields: credentialField(server), configured: false }; return { snapshot: publicExtensionProfile(snapshot), secretsEncrypted: row.secrets_encrypted as string }; } - private unseal(head: Head): Record { + unseal(head: Head): Record { return unsealLibrarySecrets(head.secretsEncrypted, head.snapshot.instanceId, head.snapshot.revision, this.environment); } @@ -195,7 +196,7 @@ export class ExtensionLibraryStore { * bound to the same Bearer servers and endpoints. */ // eslint-disable-next-line max-params -- the transaction, actor, base head, items, credentials, catalog status and acknowledgements are explicit revision inputs - private async writeRevision(trx: Knex.Transaction, actor: string, head: Head, items: ExtensionLibraryItems, secrets: Record | typeof UNCHANGED_CREDENTIALS, catalogSecrets: Map, acknowledged: readonly string[] | 'carry' = 'carry'): Promise { + async writeRevision(trx: Knex.Transaction, actor: string, head: Head, items: ExtensionLibraryItems, secrets: Record | typeof UNCHANGED_CREDENTIALS, catalogSecrets: Map, acknowledged: readonly string[] | 'carry' = 'carry', managed?: ManagedAcknowledgements): Promise { const { instanceId } = head.snapshot; const previous = head.snapshot; const revision = previous.revision + 1; const now = Date.now(); const bundleIds = items.skills.map(s => s.bundleId); const bundleRows = bundleIds.length ? await trx('extension_skill_bundles').where({ instance_id: instanceId }).whereIn('id', bundleIds) : []; @@ -209,7 +210,7 @@ export class ExtensionLibraryStore { version: EXTENSION_LIBRARY_VERSION, instanceId, revision, createdAt: previous.revision ? previous.createdAt : now, updatedAt: now, auditActor: actor, servers: items.servers, skills: items.skills, secrets: statuses, skillBundles: bundleIds.map(id => descriptors.get(id)!), }; - const acknowledgements = secretAcknowledgements(previous, items.servers, acknowledged, actor, now); + const acknowledgements = secretAcknowledgements(previous, items.servers, acknowledged, actor, now, managed); if (Object.keys(acknowledgements).length) snapshot.acknowledgements = acknowledgements; const sealed = unchanged ? carriedSecrets(head) : sealExtensionSecrets(Object.fromEntries(Object.entries(secrets).filter(([name, token]) => token && Object.hasOwn(statuses, credentialOwner(name)))), libraryIdentity(instanceId, revision), this.environment); if (previous.revision === 0 && !await trx('extension_library').where({ instance_id: instanceId }).first()) await trx('extension_library').insert({ instance_id: instanceId, revision }); @@ -219,7 +220,7 @@ export class ExtensionLibraryStore { } /** Items removed by this change that automations still add explicitly are refused, naming the automations. */ - private async refuseReferencedRemoval(trx: Knex, previous: ExtensionLibraryRevision, next: ExtensionLibraryItems): Promise { + async refuseReferencedRemoval(trx: Knex, previous: ExtensionLibraryRevision, next: ExtensionLibraryItems): Promise { const servers = previous.servers.map(s => s.name).filter(name => !next.servers.some(s => s.name === name)); const skills = previous.skills.map(s => s.bundleId).filter(id => !next.skills.some(s => s.bundleId === id)); const using = await this.automationsAdding({ servers, skills }, trx); @@ -233,14 +234,15 @@ export class ExtensionLibraryStore { if (!replacements || typeof replacements !== 'object' || Array.isArray(replacements) || Object.entries(replacements).some(([name, token]) => !isCredentialKey(parsed.servers, name) || (token !== null && (typeof token !== 'string' || !tokenPattern.test(token))))) throw new ExtensionError(400, 'Invalid secret replacement'); const acknowledged = parseAcknowledgements(acknowledgedInput, parsed.servers); await this.authorize(actor); + const saved = (await this.head()).snapshot; parsed = withManagedMarkers(saved, parsed); assertManagedItemsUnchanged(saved, parsed, replacements); // Package versions resolve to the registry's integrity value; nothing is installed or run here. - parsed = { ...parsed, servers: await pinPackageServers(parsed.servers, (await this.head()).snapshot.servers, this.environment) }; + parsed = { ...parsed, servers: await pinPackageServers(parsed.servers, saved.servers, this.environment) }; const catalogSecrets = await this.validateServers(parsed.servers); return this.database.transaction(async trx => { await this.authorize(actor, trx); const head = await this.head(trx); if (head.snapshot.revision !== expectedRevision) throw new ExtensionError(409, 'Library revision changed; refresh before saving'); - await this.refuseReferencedRemoval(trx, head.snapshot, parsed); + assertManagedItemsUnchanged(head.snapshot, parsed, replacements); await this.refuseReferencedRemoval(trx, head.snapshot, parsed); const secrets = this.unseal(head); // Checked before credentials are cleared, dropped with their server or // withheld from a changed endpoint: none may become a public field. @@ -261,6 +263,7 @@ export class ExtensionLibraryStore { const head = await this.head(trx); if (expectedRevision !== undefined && head.snapshot.revision !== expectedRevision) throw new ExtensionError(409, 'Library revision changed; refresh before saving'); if (!isCredentialKey(head.snapshot.servers, name)) throw new ExtensionError(404, 'Bearer-authenticated, pre-registered OAuth or package secret variable unavailable'); + if (isManagedCredential(head.snapshot.servers, name)) throw new ExtensionError(409, 'The credential of a plugin-managed server comes from its plugin\'s connections'); const secrets = { ...this.unseal(head) }; if (token) secrets[name] = token; else delete secrets[name]; return this.writeRevision(trx, actor, head, { version: EXTENSION_LIBRARY_VERSION, servers: head.snapshot.servers, skills: head.snapshot.skills }, secrets, new Map()); @@ -298,7 +301,8 @@ export class ExtensionLibraryStore { return { servers: head.snapshot.servers.filter(s => s.enabled), secrets: this.unseal(head) }; } - async importSkill(actor: string, files: SkillFile[], source: ExtensionSkillBundle['source']): Promise { + /** Import an uploaded or GitHub skill. Plugin skills are written by the plugin sync, never imported here. */ + async importSkill(actor: string, files: SkillFile[], source: Exclude): Promise { await this.authorize(actor); const validated = validateSkillFiles(files); if (source.kind === 'github' && !/^[0-9a-f]{40}$/.test(source.commit)) throw new ExtensionError(400, 'Skill source must pin an exact Git commit'); // Project provenance explicitly; TypeScript structural types cannot exclude extra runtime fields. @@ -508,5 +512,4 @@ export class ExtensionLibraryStore { } } /** @deprecated Extension profiles were replaced by the library; use ExtensionLibraryStore. */ -export const ExtensionProfileStore = ExtensionLibraryStore; -export type ExtensionProfileStore = ExtensionLibraryStore; +export const ExtensionProfileStore = ExtensionLibraryStore; export type ExtensionProfileStore = ExtensionLibraryStore; diff --git a/packages/core/src/plugins/pluginAssets.ts b/packages/core/src/plugins/pluginAssets.ts new file mode 100644 index 0000000000..bee98fe79d --- /dev/null +++ b/packages/core/src/plugins/pluginAssets.ts @@ -0,0 +1,68 @@ +import { lstat, readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { EXTENSION_LIMITS, validateAutomationTemplate, type AutomationTemplate, type PluginManifest } from '@propr/shared'; +import type { SkillFile } from '../extensions/skillBundles.js'; + +/** + * Files an official plugin ships next to its manifest: skill folders and + * automation templates. Read from the plugin folder of the registry package; + * nothing in them is executed. + */ +export interface PluginAssetSource { + /** The files of a skill folder (`path` from the manifest), base64-encoded, relative to the folder. */ + skillFiles(manifest: PluginManifest, skillPath: string): Promise; + /** One automation template in the shipped format (`template.json` + `prompt.md`), validated. */ + automationTemplate(manifest: PluginManifest, templatePath: string): Promise; +} + +interface PluginRegistryModule { officialPlugins(options?: { includeDevelopment?: boolean }): { manifest: PluginManifest; directory: string }[] } +const REGISTRY_MODULE = '@propr/plugins'; + +/** A file or folder inside `directory`; manifest paths are already relative without `..`, checked again here. */ +function inside(directory: string, relative: string): string { + const target = path.resolve(directory, ...relative.split('/')); + if (target !== directory && !target.startsWith(directory + path.sep)) throw new Error('Plugin asset outside its folder'); + return target; +} + +async function listFiles(root: string, prefix = ''): Promise { + const files: string[] = []; + for (const entry of (await readdir(path.join(root, prefix), { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, 'en'))) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + // Links are never followed: a skill is the regular files of its folder. + if (entry.isDirectory()) files.push(...await listFiles(root, relative)); + else if (entry.isFile()) files.push(relative); + if (files.length > EXTENSION_LIMITS.files) throw new Error('Plugin skill folder has too many files'); + } + return files; +} + +/** Assets of plugin folders found by `directoryOf`. */ +export function pluginFolderAssets(directoryOf: (manifest: PluginManifest) => Promise): PluginAssetSource { + return { + async skillFiles(manifest, skillPath) { + const folder = inside(await directoryOf(manifest), skillPath); + if (!(await lstat(folder)).isDirectory()) throw new Error('Plugin skill folder unavailable'); + const files: SkillFile[] = []; + for (const relative of await listFiles(folder)) files.push({ path: relative, content: (await readFile(path.join(folder, ...relative.split('/')))).toString('base64') }); + return files; + }, + async automationTemplate(manifest, templatePath) { + const folder = inside(await directoryOf(manifest), templatePath); + const metadata = JSON.parse(await readFile(path.join(folder, 'template.json'), 'utf8')) as Record; + const template = { ...metadata, prompt: (await readFile(path.join(folder, 'prompt.md'), 'utf8')).trim() }; + const error = 'prompt' in metadata ? 'the prompt belongs in prompt.md' : validateAutomationTemplate(template); + if (error) throw new Error(`Plugin automation template invalid: ${error}`); + return template as AutomationTemplate; + }, + }; +} + +/** Assets of the official registry (`@propr/plugins`): the folder of the registry plugin with the manifest's id. */ +export const officialPluginAssets: PluginAssetSource = pluginFolderAssets(async manifest => { + const registry = await import(REGISTRY_MODULE) as PluginRegistryModule; + // Assets are read only for plugins the catalog offered; the development filter applied there. + const plugin = registry.officialPlugins({ includeDevelopment: true }).find(p => p.manifest.id === manifest.id); + if (!plugin) throw new Error('Plugin folder unavailable'); + return plugin.directory; +}); diff --git a/packages/core/src/plugins/pluginContributions.ts b/packages/core/src/plugins/pluginContributions.ts new file mode 100644 index 0000000000..7d80799ec0 --- /dev/null +++ b/packages/core/src/plugins/pluginContributions.ts @@ -0,0 +1,267 @@ +import { randomUUID } from 'node:crypto'; +import type { Knex } from 'knex'; +import { + EXTENSION_LIBRARY_LIMITS, EXTENSION_LIBRARY_VERSION, extensionLibraryInputSchema, extensionPackageSecretKey, PLUGIN_LIBRARY_HREF, PLUGIN_TEMPLATE_CATALOG_HREF, + type ExtensionLibraryItems, type ExtensionLibraryRevision, type ExtensionLibraryServer, type ExtensionLibrarySkill, type ExtensionManagedBy, + type ExtensionSkillBundle, type PluginContributionViews, type PluginInstallationRecord, type PluginManifest, type PluginMcpServer, +} from '@propr/shared'; +import { ExtensionError, UNCHANGED_CREDENTIALS, type ExtensionLibraryStore } from '../extensions/extensionProfileStore.js'; +import { credentialOwner } from '../extensions/extensionCredentials.js'; +import { discardStaleOAuthConnections } from '../extensions/extensionOAuthState.js'; +import type { ManagedAcknowledgements } from '../extensions/extensionPackageLibrary.js'; +import { validateSkillFiles } from '../extensions/skillBundles.js'; +import { officialPluginAssets, type PluginAssetSource } from './pluginAssets.js'; +import { PluginError, pendingAcknowledgements } from './pluginState.js'; + +/** What the sync applies: the installation (null once uninstalled) and the values of its token connections. */ +export interface PluginSyncInput { + pluginId: string; + manifest: PluginManifest | null; + record: PluginInstallationRecord | null; + /** Stored `token` connection values by connection name. Core-internal; never returned. */ + connections: Record; + /** Pre-registered OAuth clients by `oauth` connection name. The secret is core-internal; never returned. */ + oauthClients?: Record; + actor: string; + /** The plugin's repository limit changed: managed items take it as their repository limit. */ + resetRepositories?: boolean; + /** + * Only deactivate the existing managed items (a disable): the manifest is + * not applied, no asset is read and nothing is added or removed, so a + * disable never depends on an upgrade being applicable. + */ + deactivateOnly?: boolean; +} + +/** The pre-registered client an administrator configured for a plugin's `oauth` connection. */ +export interface PluginOAuthClient { clientId: string; clientSecret?: string } + +type Item = ExtensionLibraryServer | ExtensionLibrarySkill; +const ownedBy = (pluginId: string) => (item: Item): boolean => item.managedBy?.plugin === pluginId; +const MAX_NAME = 48; + +/** + * Managed items of a plugin as library items: what an installed plugin + * contributes (MCP servers and skills) kept in sync with its manifest and + * installation. Items are written through the extension library's own + * revisions, so resolution, the launch fence, package installation and + * verification, write authorization and the gateway treat them exactly like + * any other library item. Their credentials are the plugin's connections, + * sealed into the library revision like administrator-entered ones. + */ +export class PluginContributions { + constructor(readonly library: ExtensionLibraryStore, readonly assets: PluginAssetSource = officialPluginAssets) {} + + /** + * Bring the library in line with one plugin inside `trx`: create, update, + * activate or deactivate its managed items, or remove them on uninstall. + * Writes nothing when nothing changed. An uninstall or a manifest that drops + * an item (a server, or a skill whose files changed and so gets a new + * bundle) is refused while an automation adds that item explicitly. A + * disable (`deactivateOnly`) only deactivates the items as they are. + */ + async sync(trx: Knex.Transaction, input: PluginSyncInput): Promise { + const head = await this.library.head(trx); + const { snapshot } = head; + const owned = ownedBy(input.pluginId); + const previous = { servers: snapshot.servers.filter(owned), skills: snapshot.skills.filter(owned) }; + if (!input.record && !previous.servers.length && !previous.skills.length) return; + const applies = Boolean(input.record && input.manifest && !input.deactivateOnly); + const desired = applies + ? await this.desired(trx, snapshot, input as PluginSyncInput & { manifest: PluginManifest; record: PluginInstallationRecord }) + // Disabled, or removed from the registry by an upgrade: keep the items as they are, never apply them. + : input.record ? { servers: previous.servers.map(deactivate), skills: previous.skills.map(deactivate), secrets: null, acknowledgements: {} } : { servers: [], skills: [], secrets: {}, acknowledgements: {} }; + await this.refuseReferencedRemoval(trx, input, previous, desired); + const items = composeItems(snapshot, input.pluginId, desired); + let secrets: Record | typeof UNCHANGED_CREDENTIALS; + if (desired.secrets === null) { + // Deactivating keeps every credential: the sealed bytes carry over without + // decryption, so a disable works even when they cannot be decrypted (a raw + // restore under another encryption root). + if (unchanged(snapshot, items)) return; + secrets = UNCHANGED_CREDENTIALS; + } else { + const stored = this.library.unseal(head); + secrets = { ...withoutManaged(stored, previous.servers), ...desired.secrets }; + if (unchanged(snapshot, items, { stored, secrets }, desired.acknowledgements)) return; + } + let written: ExtensionLibraryRevision; + try { written = await this.library.writeRevision(trx, input.actor, head, items, secrets, new Map(), 'carry', desired.acknowledgements); } + catch (error) { throw error instanceof ExtensionError ? new PluginError(error.status, error.message) : error; } + await discardStaleOAuthConnections(trx, written.instanceId, written.servers, input.actor); + } + + private async desired(trx: Knex.Transaction, snapshot: ExtensionLibraryRevision, input: PluginSyncInput & { manifest: PluginManifest; record: PluginInstallationRecord }) { + const { manifest, record, pluginId } = input; + const owned = ownedBy(pluginId); + const pending = new Set(pendingAcknowledgements(manifest, record.acknowledgements).map(item => item.server)); + const names = new ManagedNames(pluginId, snapshot.servers); + const limits = (existing: Item | undefined) => ({ + enabled: existing?.enabled ?? true, + repositories: !existing || input.resetRepositories ? [...(record.repositories ?? [])] : existing.repositories, + agents: existing?.agents ?? [], autoApply: existing?.autoApply ?? true, + }); + const servers: ExtensionLibraryServer[] = []; const secrets: Record = {}; const acknowledgements: ManagedAcknowledgements = {}; + for (const server of manifest.contributes.mcpServers) { + const existing = snapshot.servers.find(s => owned(s) && s.managedBy!.item === server.name); + const name = existing?.name ?? names.allocate(server.name); + const active = record.enabled && !pending.has(server.name); + const managedBy: ExtensionManagedBy = { plugin: pluginId, item: server.name, active }; + const client = preregisteredClient(manifest, server, input.oauthClients ?? {}); + servers.push(libraryServer(manifest, server, name, { ...limits(existing), managedBy: { ...managedBy, ...oauthRegistrations(manifest, server) } }, client)); + for (const [key, connection] of credentialConnections(server, name)) if (input.connections[connection]) secrets[key] = input.connections[connection]; + if (client?.clientSecret) secrets[name] = client.clientSecret; + if (server.kind === 'package' && server.secretEnv.length) { + const recorded = Object.hasOwn(record.acknowledgements, server.name) && !pending.has(server.name) ? record.acknowledgements[server.name] : undefined; + acknowledgements[name] = recorded ? { actor: recorded.actor, at: recorded.at, package: recorded.package, version: recorded.version, secretEnv: [...recorded.secretEnv] } : undefined; + } + } + const skills: ExtensionLibrarySkill[] = []; + for (const skill of manifest.contributes.skills) { + const existing = snapshot.skills.find(s => owned(s) && s.managedBy!.item === skill.name); + const bundleId = await this.bundleFor(trx, snapshot, input, skill, existing); + skills.push({ bundleId, ...limits(existing), managedBy: { plugin: pluginId, item: skill.name, active: record.enabled } }); + } + return { servers, skills, secrets, acknowledgements }; + } + + /** The skill bundle of a plugin skill: the current one while its files are unchanged, otherwise a new bundle. */ + // eslint-disable-next-line max-params -- the transaction, library, sync input, skill and existing item are explicit inputs + private async bundleFor(trx: Knex.Transaction, snapshot: ExtensionLibraryRevision, input: PluginSyncInput & { manifest: PluginManifest }, skill: { name: string; path: string }, existing: ExtensionLibrarySkill | undefined): Promise { + let validated: ReturnType; + try { validated = validateSkillFiles(await this.assets.skillFiles(input.manifest, skill.path)); } + catch { throw new PluginError(409, `Skill ${skill.name} of the plugin is unavailable or invalid`); } + const current = existing && snapshot.skillBundles.find(b => b.id === existing.bundleId); + if (current && current.digest === validated.digest) return current.id; + const descriptor: ExtensionSkillBundle = { + id: randomUUID(), instanceId: snapshot.instanceId, digest: validated.digest, name: validated.name, files: validated.files.length, bytes: validated.bytes, + source: { kind: 'plugin', plugin: input.pluginId, version: input.manifest.version, path: skill.path }, requirements: validated.requirements, createdAt: Date.now(), auditActor: input.actor, + }; + await trx('extension_skill_bundles').insert({ id: descriptor.id, instance_id: descriptor.instanceId, descriptor: JSON.stringify(descriptor), files_json: JSON.stringify(validated.files) }); + return descriptor.id; + } + + /** Managed items the change drops while an automation still adds them explicitly refuse it, naming the automations. */ + private async refuseReferencedRemoval(trx: Knex.Transaction, input: PluginSyncInput, previous: { servers: ExtensionLibraryServer[]; skills: ExtensionLibrarySkill[] }, desired: { servers: ExtensionLibraryServer[]; skills: ExtensionLibrarySkill[] }): Promise { + // Automations reference skills by bundle id: a skill whose files changed gets a new bundle, so its old bundle id is removed too. + const servers = previous.servers.filter(s => !desired.servers.some(d => d.name === s.name)).map(s => s.name); + const skills = previous.skills.filter(s => !desired.skills.some(d => d.bundleId === s.bundleId)).map(s => s.bundleId); + const using = await this.library.automationsAdding({ servers, skills }, trx); + if (!using.length) return; + const what = input.record ? 'before upgrading it' : 'before uninstalling it'; + throw new PluginError(409, `Automations add items of plugin ${input.pluginId} explicitly; remove them from ${using.map(a => `"${a.name}" (${[...a.servers, ...a.skills].join(', ')})`).join(', ')} ${what}`); + } +} + +/** Library names for new managed servers: the manifest name, or `-` when that is taken. */ +class ManagedNames { + private readonly taken: Set; + constructor(private readonly pluginId: string, servers: readonly ExtensionLibraryServer[]) { this.taken = new Set(servers.map(s => s.name)); } + allocate(name: string): string { + const prefixed = `${this.pluginId}-${name}`.slice(0, MAX_NAME); + const chosen = !this.taken.has(name) ? name : !this.taken.has(prefixed) ? prefixed : null; + if (!chosen) throw new PluginError(409, `The extension library already has servers named ${name} and ${prefixed}; rename one before enabling this plugin`); + this.taken.add(chosen); + return chosen; + } +} + +type Limits = Pick & { managedBy: ExtensionManagedBy }; +/** The library item of a manifest server under its library name, with the pre-registered OAuth client configured for its connection. */ +// eslint-disable-next-line max-params -- the manifest, server, library name, limits and configured client are explicit inputs +function libraryServer(manifest: PluginManifest, server: PluginMcpServer, name: string, limits: Limits, client: PluginOAuthClient | null): ExtensionLibraryServer { + if (server.kind === 'https') { + const connection = server.authentication === 'oauth' ? manifest.connections.find(c => c.name === server.connection) : undefined; + const scopes = connection?.type === 'oauth' ? connection.scopes : undefined; + return { kind: 'https', name, url: server.url, enabled: limits.enabled, tools: [...server.tools], capability: server.capability, authentication: server.authentication, ...(scopes ? { scopes: [...scopes] } : {}), ...(client ? { clientId: client.clientId } : {}), repositories: limits.repositories, agents: limits.agents, autoApply: limits.autoApply, managedBy: limits.managedBy }; + } + return { + name, enabled: limits.enabled, tools: [...server.tools], capability: server.capability, kind: 'package', ecosystem: server.ecosystem, package: server.package, version: server.version, + integrity: server.integrity, ...(server.bin ? { bin: server.bin } : {}), args: [...server.args], env: server.env.map(e => ({ ...e })), secretEnv: server.secretEnv.map(e => e.name), + repositories: limits.repositories, agents: limits.agents, autoApply: limits.autoApply, managedBy: limits.managedBy, + }; +} + +/** Sealed-credential keys of a managed server and the plugin connection each comes from. */ +function credentialConnections(server: PluginMcpServer, name: string): [string, string][] { + if (server.kind === 'package') return server.secretEnv.map(secret => [extensionPackageSecretKey(name, secret.name), secret.connection]); + return server.authentication === 'bearer' && server.connection ? [[name, server.connection]] : []; +} + +/** The pre-registered client of an `oauth` server's connection, when one is configured and the connection permits pre-registration. */ +function preregisteredClient(manifest: PluginManifest, server: PluginMcpServer, clients: Record): PluginOAuthClient | null { + if (server.kind !== 'https' || server.authentication !== 'oauth' || !server.connection || !Object.hasOwn(clients, server.connection)) return null; + const connection = manifest.connections.find(c => c.name === server.connection); + return connection?.type === 'oauth' && connection.registrations.includes('preregistered') ? clients[server.connection] : null; +} + +/** The registration methods an `oauth` server's plugin connection permits, carried on its marker for sign-in. */ +function oauthRegistrations(manifest: PluginManifest, server: PluginMcpServer): Pick { + if (server.kind !== 'https' || server.authentication !== 'oauth') return {}; + const connection = manifest.connections.find(c => c.name === server.connection); + return connection?.type === 'oauth' ? { registrations: [...connection.registrations] } : {}; +} + +const deactivate = (item: T): T => (item.managedBy ? { ...item, managedBy: { ...item.managedBy, active: false } } : item); +const withoutManaged = (secrets: Record, servers: readonly ExtensionLibraryServer[]) => Object.fromEntries(Object.entries(secrets).filter(([key]) => !servers.some(s => s.name === credentialOwner(key)))); + +/** The next library items: other items unchanged and in place, the plugin's items replaced in place, new ones appended. */ +function composeItems(snapshot: ExtensionLibraryRevision, pluginId: string, desired: { servers: ExtensionLibraryServer[]; skills: ExtensionLibrarySkill[] }): ExtensionLibraryItems { + const owned = ownedBy(pluginId); + const place = (current: readonly T[], next: readonly T[], same: (a: T, b: T) => boolean): T[] => { + const result: T[] = []; + for (const item of current) { + if (!owned(item)) { result.push(item); continue; } + const replacement = next.find(n => same(n, item)); + if (replacement) result.push(replacement); + } + return [...result, ...next.filter(n => !result.includes(n))]; + }; + const parsed = extensionLibraryInputSchema.safeParse({ + version: EXTENSION_LIBRARY_VERSION, + servers: place(snapshot.servers, desired.servers, (a, b) => a.managedBy?.item === b.managedBy?.item), + skills: place(snapshot.skills, desired.skills, (a, b) => a.managedBy?.item === b.managedBy?.item), + }); + if (!parsed.success) throw new PluginError(409, libraryProblem(parsed.error.issues)); + return parsed.data; +} + +/** Why the next library items do not validate: the failing item field, or the library's capacity. */ +function libraryProblem(issues: readonly { path: readonly PropertyKey[]; message: string }[]): string { + const item = issues.find(issue => issue.path.length > 2); + if (item) return `The extension library cannot hold this plugin's items: ${item.path.map(String).join('.')}: ${item.message}`; + return `The extension library cannot hold this plugin's items; it holds at most ${EXTENSION_LIBRARY_LIMITS.servers} servers and ${EXTENSION_LIBRARY_LIMITS.bundles} skills with unique names`; +} + +/** Whether the next items, and the next credentials when they are compared, equal the base revision's. */ +function unchanged(snapshot: ExtensionLibraryRevision, items: ExtensionLibraryItems, credentials?: { stored: Record; secrets: Record }, acknowledgements: ManagedAcknowledgements = {}): boolean { + const sorted = (value: Record) => JSON.stringify(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : 1))); + if (JSON.stringify({ servers: snapshot.servers, skills: snapshot.skills }) !== JSON.stringify({ servers: items.servers, skills: items.skills })) return false; + if (credentials && sorted(credentials.stored) !== sorted(credentials.secrets)) return false; + return Object.entries(acknowledgements).every(([name, value]) => JSON.stringify(value) === JSON.stringify(snapshot.acknowledgements?.[name])); +} + +/** What a plugin contributes and where its managed items are in the library. */ +// eslint-disable-next-line max-params -- the manifest, plugin id, library, template ids and availability are explicit inputs +export function pluginContributionViews(manifest: PluginManifest | null, pluginId: string, library: Pick | null, templateIds: Map, templatesAvailable: boolean): PluginContributionViews { + const owned = ownedBy(pluginId); + const servers = library?.servers.filter(owned) ?? []; const skills = library?.skills.filter(owned) ?? []; + const active = (item: Item) => item.managedBy?.active === true; + return { + mcpServers: (manifest?.contributes.mcpServers ?? []).map(server => { + const item = servers.find(s => s.managedBy?.item === server.name); + return { name: server.name, kind: server.kind, managed: item ? { kind: 'server', name: item.name, enabled: item.enabled, active: active(item) } : null, href: PLUGIN_LIBRARY_HREF }; + }), + skills: (manifest?.contributes.skills ?? []).map(skill => { + const item = skills.find(s => s.managedBy?.item === skill.name); + return { name: skill.name, managed: item ? { kind: 'skill', bundleId: item.bundleId, enabled: item.enabled, active: active(item) } : null, href: PLUGIN_LIBRARY_HREF }; + }), + automationTemplates: (manifest?.contributes.automationTemplates ?? []).map(template => ({ id: template.id, catalogId: templateIds.get(template.id) ?? template.id, available: templatesAvailable, href: PLUGIN_TEMPLATE_CATALOG_HREF })), + }; +} + +/** The managed servers of a plugin that use one of its `oauth` connections, by library name. */ +export function oauthServersOf(manifest: PluginManifest, pluginId: string, library: Pick, connection: string): ExtensionLibraryServer[] { + const items = manifest.contributes.mcpServers.filter(s => s.kind === 'https' && s.authentication === 'oauth' && s.connection === connection).map(s => s.name); + return library.servers.filter(s => s.managedBy?.plugin === pluginId && items.includes(s.managedBy.item) && s.kind === 'https' && s.authentication === 'oauth'); +} diff --git a/packages/core/src/plugins/pluginState.ts b/packages/core/src/plugins/pluginState.ts index 98202b2ce0..a8b953b1b8 100644 --- a/packages/core/src/plugins/pluginState.ts +++ b/packages/core/src/plugins/pluginState.ts @@ -73,10 +73,11 @@ export function pendingPermissions(manifest: PluginManifest, granted: readonly s export function connectionStatuses(manifest: PluginManifest, secrets: readonly PluginSecretRow[]): PluginConnectionStatus[] { const required = requiredPluginConnections(manifest); return manifest.connections.map(connection => { - const row = connection.type === 'oauth' ? undefined : secrets.find(s => s.connection_name === connection.name); + // An `oauth` connection's row is its pre-registered client: configuration, not the connection itself. + const row = secrets.find(s => s.connection_name === connection.name); return { name: connection.name, type: connection.type, label: connection.label, required: required.includes(connection.name), - state: row ? 'connected' : 'not_connected', ...(row ? { setBy: row.set_by, setAt: Number(row.set_at) } : {}), + state: row && connection.type !== 'oauth' ? 'connected' : 'not_connected', ...(row ? { setBy: row.set_by, setAt: Number(row.set_at) } : {}), }; }); } diff --git a/packages/core/src/plugins/pluginStore.ts b/packages/core/src/plugins/pluginStore.ts index 6a52d364df..16134a669b 100644 --- a/packages/core/src/plugins/pluginStore.ts +++ b/packages/core/src/plugins/pluginStore.ts @@ -1,12 +1,18 @@ +import { createHash } from 'node:crypto'; import type { Knex } from 'knex'; import { PLUGIN_REPOSITORY_LIMIT, PLUGIN_REPOSITORY_PATTERN, PLUGIN_SECRET_PATTERN, defaultPluginSettings, missingRequiredPluginSettings, pluginIdSchema, requiredPluginConnections, validatePluginSettings, - type PluginAuditAction, type PluginAuditEvent, type PluginDetail, type PluginManifest, type PluginView, + type PluginAuditAction, type PluginAuditEvent, type PluginConnectionState, type PluginDetail, type PluginManifest, type PluginView, } from '@propr/shared'; import logger from '../utils/logger.js'; import { sealExtensionSecrets, unsealExtensionSecrets } from '../extensions/privateSecrets.js'; import { officialPluginCatalog, type PluginCatalogSource } from './pluginCatalog.js'; +import { ExtensionError, ExtensionLibraryStore } from '../extensions/extensionProfileStore.js'; +import { extensionOAuth, type ExtensionOAuthUrls } from '../extensions/extensionOAuthStore.js'; +import { OAUTH_PENDING, discardOAuthConnectionsOf, hasOAuthTables, oauthConnectionStates } from '../extensions/extensionOAuthState.js'; +import { PluginContributions, oauthServersOf, pluginContributionViews, type PluginOAuthClient } from './pluginContributions.js'; +import { pluginTemplateIds } from './pluginTemplates.js'; import { PluginError, enableBlockers, installationRecord, nextAcknowledgements, parseAcknowledgements, pendingAcknowledgements, pendingPermissions, pluginView, type PluginInstallationRow, type PluginSecretRow, @@ -18,22 +24,34 @@ export { PluginError } from './pluginState.js'; export const pluginSecretIdentity = (instanceId: string, pluginId: string, connection: string) => `plugin/${instanceId}/${pluginId}/${connection}`; const AUDIT_LIMIT = 50; const SECRET_COLUMNS = ['plugin_id', 'connection_name', 'set_by', 'set_at'] as const; +/** A pre-registered OAuth client id, as the extension library accepts it. */ +const CLIENT_ID_PATTERN = /^[\x21-\x7e]{1,512}$/; interface Loaded { manifest: PluginManifest | null; row: PluginInstallationRow | undefined; secrets: PluginSecretRow[] } +type Change = { action: PluginAuditAction; detail?: Record; resetRepositories?: boolean; deactivateOnly?: boolean }; +/** Run a library or OAuth operation; its refusals become plugin refusals with the same status and message. */ +async function viaLibrary(run: () => Promise): Promise { + try { return await run(); } catch (error) { throw error instanceof ExtensionError ? new PluginError(error.status, error.message) : error; } +} /** * Installation state of plugins on this instance. Every method authorizes a * current durable instance administrator; connection secrets are sealed with * the extension private-secret mechanism and never returned. Every management - * action is appended to `plugin_audit`. + * action is appended to `plugin_audit`. Every write also brings the plugin's + * managed extension library items in line with it (`PluginContributions`), + * in the same transaction. */ export class PluginStore { + // eslint-disable-next-line max-params -- the database, environment, catalog, clock and contribution sync are injectable dependencies constructor( readonly database: Knex, readonly environment: NodeJS.ProcessEnv = process.env, readonly catalog: PluginCatalogSource = officialPluginCatalog, readonly now: () => number = Date.now, + readonly contributions: PluginContributions = new PluginContributions(new ExtensionLibraryStore(database, environment)), ) {} + get library(): ExtensionLibraryStore { return this.contributions.library; } async authorize(actor: string, database: Knex = this.database): Promise { // Durable, uncached instance authority; no session or repository fallback. @@ -50,7 +68,7 @@ export class PluginStore { if (!parsed.success) throw new PluginError(404, 'Plugin not found'); return parsed.data; } - private async load(id: string, database: Knex, allowMissing = false): Promise { + async load(id: string, database: Knex, allowMissing = false): Promise { const manifest = (await this.manifests()).find(m => m.id === id) ?? null; const row = await database('plugin_installations').where({ plugin_id: id }).first() as PluginInstallationRow | undefined; const secrets = row ? await database('plugin_secrets').where({ plugin_id: id }).select(...SECRET_COLUMNS) as PluginSecretRow[] : []; @@ -74,15 +92,52 @@ export class PluginStore { * One management write: authorizes inside the transaction, runs `change`, * records the audit event and returns the plugin's new state. */ - private async write(actor: string, id: unknown, change: (trx: Knex.Transaction, loaded: Loaded, pluginId: string) => Promise<{ action: PluginAuditAction; detail?: Record }>): Promise { + private async write(actor: string, id: unknown, change: (trx: Knex.Transaction, loaded: Loaded, pluginId: string) => Promise): Promise { const pluginId = PluginStore.pluginId(id); await this.database.transaction(async trx => { await this.authorize(actor, trx); - const { action, detail } = await change(trx, await this.load(pluginId, trx), pluginId); + const { action, detail, resetRepositories, deactivateOnly } = await change(trx, await this.load(pluginId, trx), pluginId); + await this.applyContributions(trx, pluginId, actor, { resetRepositories, deactivateOnly }); await this.audit(trx, pluginId, action, actor, detail); }); - const after = await this.load(pluginId, this.database, true); - return pluginView(pluginId, after.manifest, after.row, after.secrets); + return this.view(pluginId, await this.load(pluginId, this.database, true)); + } + /** Sync the plugin's managed library items with its installation as it is now inside `trx`. */ + private async applyContributions(trx: Knex.Transaction, pluginId: string, actor: string, options: { resetRepositories?: boolean; deactivateOnly?: boolean } = {}): Promise { + const loaded = await this.load(pluginId, trx, true); + const connections: Record = {}; const oauthClients: Record = {}; + // A disable keeps the managed items' credentials as they are and decrypts nothing, so it works under a mismatched encryption root. + if (!options.deactivateOnly) { + const types = new Map(loaded.manifest?.connections.map(c => [c.name, c.type]) ?? []); + for (const secret of loaded.secrets) { + const type = types.get(secret.connection_name); + if (type === 'token') { + const value = await this.connectionSecret(pluginId, secret.connection_name, trx); + if (value) connections[secret.connection_name] = value; + } else if (type === 'oauth') { + const client = await this.oauthClient(pluginId, secret.connection_name, trx); + if (client) oauthClients[secret.connection_name] = client; + } + } + } + await viaLibrary(() => this.contributions.sync(trx, { pluginId, manifest: loaded.manifest, record: loaded.row ? installationRecord(loaded.row) : null, connections, oauthClients, actor, resetRepositories: options.resetRepositories ?? false, deactivateOnly: options.deactivateOnly ?? false })); + } + /** The state view with OAuth connection states and the managed items and templates the plugin contributes. */ + private async view(pluginId: string, loaded: Loaded, library?: Awaited>['snapshot'], templateIds?: Map>): Promise { + const base = pluginView(pluginId, loaded.manifest, loaded.row, loaded.secrets); + const { manifest, row } = loaded; + if (!manifest) return base; + const snapshot = library ?? (await this.library.head()).snapshot; + const ids = templateIds ?? pluginTemplateIds(await this.manifests()); + const connections = []; + for (const status of base.connections) { + const servers = status.type === 'oauth' && row ? oauthServersOf(manifest, pluginId, snapshot, status.name) : []; + if (!servers.length) { connections.push(status); continue; } + const states = Object.values(await oauthConnectionStates(this.database, snapshot.instanceId, servers)); + const state: PluginConnectionState = states.every(s => s === 'connected') ? 'connected' : states.includes('needs_sign_in') ? 'needs_sign_in' : 'not_connected'; + connections.push({ ...status, state }); + } + return { ...base, connections, contributions: pluginContributionViews(manifest, pluginId, row ? snapshot : null, ids.get(pluginId) ?? new Map(), Boolean(row?.enabled)) }; } /** Columns every update sets. A write with nothing awaiting approval also moves the installed version to the current manifest. */ private touched(actor: string, loaded: Loaded): Record { @@ -101,7 +156,9 @@ export class PluginStore { const rows = await this.database('plugin_installations').select() as PluginInstallationRow[]; const secrets = await this.database('plugin_secrets').select(...SECRET_COLUMNS) as PluginSecretRow[]; const ids = [...manifests.map(m => m.id), ...rows.map(r => r.plugin_id).filter(id => !manifests.some(m => m.id === id)).sort()]; - const views = ids.map(id => pluginView(id, manifests.find(m => m.id === id) ?? null, rows.find(r => r.plugin_id === id), secrets.filter(s => s.plugin_id === id))); + const { snapshot } = await this.library.head(); const templateIds = pluginTemplateIds(manifests); + const views: PluginView[] = []; + for (const id of ids) views.push(await this.view(id, { manifest: manifests.find(m => m.id === id) ?? null, row: rows.find(r => r.plugin_id === id), secrets: secrets.filter(s => s.plugin_id === id) }, snapshot, templateIds)); await this.authorize(actor); return views; } @@ -109,10 +166,10 @@ export class PluginStore { async get(actor: string, id: unknown): Promise { await this.authorize(actor); const pluginId = PluginStore.pluginId(id); - const { manifest, row, secrets } = await this.load(pluginId, this.database); + const view = await this.view(pluginId, await this.load(pluginId, this.database)); const audit = await this.auditEvents(pluginId); await this.authorize(actor); - return { ...pluginView(pluginId, manifest, row, secrets), audit }; + return { ...view, audit }; } private async auditEvents(pluginId: string): Promise { const rows = await this.database('plugin_audit').where({ plugin_id: pluginId }).orderBy('id', 'desc').limit(AUDIT_LIMIT).select('action', 'actor', 'at', 'detail'); @@ -158,7 +215,7 @@ export class PluginStore { return this.write(actor, id, async (trx, loaded, pluginId) => { PluginStore.installed(loaded); await trx('plugin_installations').where({ plugin_id: pluginId }).update({ repositories: limit ? JSON.stringify(limit) : null, ...this.touched(actor, loaded) }); - return { action: 'set_repositories', detail: { repositories: limit } }; + return { action: 'set_repositories', detail: { repositories: limit }, resetRepositories: true }; }); } @@ -169,7 +226,7 @@ export class PluginStore { const row = PluginStore.installed(loaded); const manifest = PluginStore.current(loaded); const connection = manifest.connections.find(c => c.name === name); if (!connection) throw new PluginError(404, 'Connection not found'); - if (connection.type === 'oauth') throw new PluginError(400, 'OAuth connections are not available yet'); + if (connection.type === 'oauth') throw new PluginError(400, 'An OAuth connection is connected through its sign-in, not with a value'); if (value === null) { if (row.enabled && requiredPluginConnections(manifest).includes(connection.name)) throw new PluginError(409, `Connection ${connection.name} is required while the plugin is enabled; disable it first`); await trx('plugin_secrets').where({ plugin_id: pluginId, connection_name: connection.name }).delete(); @@ -184,6 +241,40 @@ export class PluginStore { }); } + /** + * Configure (`{ clientId, clientSecret? }`) or clear (`null`) the + * pre-registered OAuth client of an `oauth` connection whose `registrations` + * permit `preregistered`. The client id becomes the managed servers' + * `clientId`, the secret their sealed client secret; both are sealed here + * and never returned. Changing the client, its secret alone included, + * discards the servers' OAuth connections and pending sign-ins. + */ + async setOAuthClient(actor: string, id: unknown, name: unknown, client: unknown): Promise { + const parsed = parseOAuthClient(client); + return this.write(actor, id, async (trx, loaded, pluginId) => { + PluginStore.installed(loaded); const manifest = PluginStore.current(loaded); + const connection = manifest.connections.find(c => c.name === name && c.type === 'oauth'); + if (!connection || connection.type !== 'oauth') throw new PluginError(404, 'OAuth connection not found'); + if (parsed && !connection.registrations.includes('preregistered')) throw new PluginError(409, `Connection ${connection.name} does not permit a pre-registered client`); + // Only a permitted client is projected onto the servers; one that cannot be decrypted counts as changed. + const previous = connection.registrations.includes('preregistered') ? await this.oauthClient(pluginId, connection.name, trx).catch(() => undefined) : null; + if (JSON.stringify(previous) !== JSON.stringify(parsed)) { + const { snapshot } = await this.library.head(trx); + await discardOAuthConnectionsOf(trx, snapshot.instanceId, oauthServersOf(manifest, pluginId, snapshot, connection.name).map(s => s.name), actor); + } + if (parsed === null) { + await trx('plugin_secrets').where({ plugin_id: pluginId, connection_name: connection.name }).delete(); + await trx('plugin_installations').where({ plugin_id: pluginId }).update(this.touched(actor, loaded)); + return { action: 'clear_oauth_client', detail: { connection: connection.name } }; + } + const sealed = sealExtensionSecrets({ clientId: parsed.clientId, ...(parsed.clientSecret ? { clientSecret: parsed.clientSecret } : {}) }, pluginSecretIdentity(await this.instanceId(trx), pluginId, connection.name), this.environment); + await trx('plugin_secrets').insert({ plugin_id: pluginId, connection_name: connection.name, sealed, set_by: actor, set_at: this.now() }) + .onConflict(['plugin_id', 'connection_name']).merge(); + await trx('plugin_installations').where({ plugin_id: pluginId }).update(this.touched(actor, loaded)); + return { action: 'set_oauth_client', detail: { connection: connection.name, clientSecret: Boolean(parsed.clientSecret) } }; + }); + } + /** Enable (refused, with the reasons, while something required is missing) or disable. Disabling keeps settings and connections. */ async setEnabled(actor: string, id: unknown, enabled: unknown): Promise { if (typeof enabled !== 'boolean') throw new PluginError(400, 'enabled must be true or false'); @@ -194,7 +285,8 @@ export class PluginStore { if (blockers.length) throw new PluginError(409, `The plugin cannot be enabled: ${blockers.join('; ')}`); } await trx('plugin_installations').where({ plugin_id: pluginId }).update({ enabled, ...this.touched(actor, loaded) }); - return { action: enabled ? 'enable' : 'disable' }; + // A disable only deactivates the managed items as they are: it never depends on applying an upgraded manifest or its assets. + return { action: enabled ? 'enable' : 'disable', deactivateOnly: !enabled }; }); } @@ -226,11 +318,80 @@ export class PluginStore { } /** Core-internal: the stored secret of a connection, for applying contributions. Never exposed through the API. */ - async connectionSecret(pluginId: string, connection: string): Promise { - const row = await this.database('plugin_secrets').where({ plugin_id: pluginId, connection_name: connection }).first('sealed'); + async connectionSecret(pluginId: string, connection: string, database: Knex = this.database): Promise { + const row = await database('plugin_secrets').where({ plugin_id: pluginId, connection_name: connection }).first('sealed'); if (!row) return null; - return unsealExtensionSecrets(row.sealed, pluginSecretIdentity(await this.instanceId(this.database), pluginId, connection), this.environment).value ?? null; + return unsealExtensionSecrets(row.sealed, pluginSecretIdentity(await this.instanceId(database), pluginId, connection), this.environment).value ?? null; } + + /** Core-internal: the pre-registered client configured for an `oauth` connection, for applying contributions. Never exposed through the API. */ + private async oauthClient(pluginId: string, connection: string, database: Knex): Promise { + const row = await database('plugin_secrets').where({ plugin_id: pluginId, connection_name: connection }).first('sealed'); + if (!row) return null; + const { clientId, clientSecret } = unsealExtensionSecrets(row.sealed, pluginSecretIdentity(await this.instanceId(database), pluginId, connection), this.environment); + return clientId ? { clientId, ...(clientSecret ? { clientSecret } : {}) } : null; + } + + /** The installed plugin, its current manifest and the managed servers using one of its `oauth` connections. */ + private async oauthTarget(id: unknown, name: unknown) { + const pluginId = PluginStore.pluginId(id); + const loaded = await this.load(pluginId, this.database); + PluginStore.installed(loaded); const manifest = PluginStore.current(loaded); + const connection = manifest.connections.find(c => c.name === name && c.type === 'oauth'); + if (!connection) throw new PluginError(404, 'OAuth connection not found'); + const { snapshot } = await this.library.head(); + const servers = oauthServersOf(manifest, pluginId, snapshot, connection.name); + if (!servers.length) throw new PluginError(409, `No MCP server of this plugin uses connection ${connection.name}`); + return { pluginId, connection: connection.name, servers, instanceId: snapshot.instanceId }; + } + private async auditOnly(pluginId: string, action: PluginAuditAction, actor: string, detail: Record): Promise { + await this.database.transaction(async trx => { await this.authorize(actor, trx); await this.audit(trx, pluginId, action, actor, detail); }); + } + /** + * Start the extension OAuth sign-in of an `oauth` connection, for the first + * of its managed servers not yet connected. ProPR identifies itself as it + * does for library servers; the provider redirects to the extension callback. + */ + // eslint-disable-next-line max-params -- the actor, plugin, connection, browser session and instance URLs are explicit inputs + async startOAuth(actor: string, id: unknown, name: unknown, sessionId: string, urls: ExtensionOAuthUrls): Promise<{ authorizationUrl: string; server: string }> { + await this.authorize(actor); + const target = await this.oauthTarget(id, name); + const states = await oauthConnectionStates(this.database, target.instanceId, target.servers); + const server = target.servers.find(s => states[s.name] !== 'connected') ?? target.servers[0]; + const { authorizationUrl } = await viaLibrary(() => extensionOAuth(this.library).startSignIn(actor, sessionId, server.name, urls)); + await this.auditOnly(target.pluginId, 'start_oauth', actor, { connection: target.connection, server: server.name }); + return { authorizationUrl, server: server.name }; + } + /** Complete a sign-in this plugin connection started (the callback's `state` and `code`); the extension callback completes it too. */ + // eslint-disable-next-line max-params -- the actor, plugin, connection, browser session and callback query are explicit inputs + async completeOAuth(actor: string, id: unknown, name: unknown, sessionId: string, query: { state?: unknown; code?: unknown; error?: unknown; iss?: unknown }): Promise { + await this.authorize(actor); + const target = await this.oauthTarget(id, name); + // Only a sign-in pending for one of this connection's servers; checked before the single-use state is consumed. + const pending = typeof query.state === 'string' && await hasOAuthTables(this.database) ? await this.database(OAUTH_PENDING).where({ state_hash: createHash('sha256').update(query.state).digest('hex') }).first('server_name') : undefined; + if (!pending || !target.servers.some(s => s.name === pending.server_name)) throw new PluginError(400, 'Unknown, used or expired sign-in request'); + const { name: server } = await viaLibrary(() => extensionOAuth(this.library).completeSignIn(actor, sessionId, query)); + await this.auditOnly(target.pluginId, 'complete_oauth', actor, { connection: target.connection, server }); + return this.view(target.pluginId, await this.load(target.pluginId, this.database, true)); + } + /** Disconnect every managed server of an `oauth` connection: tokens are revoked where possible and deleted. */ + async disconnectOAuth(actor: string, id: unknown, name: unknown): Promise { + await this.authorize(actor); + const target = await this.oauthTarget(id, name); + for (const server of target.servers) await viaLibrary(() => extensionOAuth(this.library).disconnect(actor, server.name)); + await this.auditOnly(target.pluginId, 'disconnect_oauth', actor, { connection: target.connection, servers: target.servers.map(s => s.name) }); + return this.view(target.pluginId, await this.load(target.pluginId, this.database, true)); + } +} + +function parseOAuthClient(value: unknown): PluginOAuthClient | null { + if (value === null) return null; + const invalid = () => new PluginError(400, 'client must be null or { clientId, clientSecret? }: a client id of 1 to 512 printable characters without spaces and an optional secret of 1 to 4096'); + if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => key !== 'clientId' && key !== 'clientSecret')) throw invalid(); + const { clientId, clientSecret } = value as Record; + if (typeof clientId !== 'string' || !CLIENT_ID_PATTERN.test(clientId)) throw invalid(); + if (clientSecret !== undefined && clientSecret !== null && (typeof clientSecret !== 'string' || !PLUGIN_SECRET_PATTERN.test(clientSecret))) throw invalid(); + return { clientId, ...(typeof clientSecret === 'string' ? { clientSecret } : {}) }; } function parseRepositories(value: unknown): string[] | null { diff --git a/packages/core/src/plugins/pluginTemplates.ts b/packages/core/src/plugins/pluginTemplates.ts new file mode 100644 index 0000000000..cd32daa912 --- /dev/null +++ b/packages/core/src/plugins/pluginTemplates.ts @@ -0,0 +1,64 @@ +import type { Knex } from 'knex'; +import { AUTOMATION_TEMPLATE_CATALOG, type AutomationTemplate, type AutomationTemplateCatalog, type PluginManifest } from '@propr/shared'; +import logger from '../utils/logger.js'; +import { officialPluginAssets, type PluginAssetSource } from './pluginAssets.js'; +import { officialPluginCatalog, type PluginCatalogSource } from './pluginCatalog.js'; + +/** + * Catalog ids of plugin automation templates, by plugin and template id. A + * template keeps its own id unless a shipped template or a template of + * another plugin in the registry uses it; then it is prefixed with its plugin + * id, and a prefixed id that is itself a shipped or plugin template id, or + * an id already allocated, gets a `-2`, `-3`, … suffix. Allocated ids are + * unique across the shipped templates and every plugin template. The rule + * depends on the registry (and its order) only, so an id stays stable + * whichever plugins are enabled. + */ +export function pluginTemplateIds(manifests: readonly PluginManifest[]): Map> { + const counts = new Map(); + for (const template of AUTOMATION_TEMPLATE_CATALOG.templates) counts.set(template.id, 2); + for (const manifest of manifests) for (const template of manifest.contributes.automationTemplates) counts.set(template.id, (counts.get(template.id) ?? 0) + 1); + // Every original id is reserved: a generated id never equals a shipped or plugin template's own id. + const taken = new Set(counts.keys()); + const allocate = (base: string) => { + let candidate = base; + for (let n = 2; taken.has(candidate); n++) candidate = `${base}-${n}`; + taken.add(candidate); + return candidate; + }; + return new Map(manifests.map(manifest => [manifest.id, new Map(manifest.contributes.automationTemplates.map(t => [t.id, counts.get(t.id) === 1 ? t.id : allocate(`${manifest.id}-${t.id}`)]))])); +} + +/** Ids of the installed plugins that are enabled. */ +export async function enabledPluginIds(database: Knex): Promise> { + if (!await database.schema.hasTable('plugin_installations')) return new Set(); + const rows = await database('plugin_installations').where({ enabled: true }).select('plugin_id'); + return new Set(rows.map(row => row.plugin_id as string)); +} + +/** + * The automation template catalog: the shipped templates, then those of + * enabled plugins, marked with the plugin's id and name. A disabled or + * uninstalled plugin's templates are left out; automations created from them + * are ordinary automations and stay. A template that cannot be read is left + * out with a warning; the catalog itself never fails because of a plugin. + */ +export async function automationTemplateCatalog(database: Knex, catalog: PluginCatalogSource = officialPluginCatalog, assets: PluginAssetSource = officialPluginAssets): Promise { + const templates: AutomationTemplate[] = [...AUTOMATION_TEMPLATE_CATALOG.templates]; + let enabled: Set; + try { enabled = await enabledPluginIds(database); } catch { return { ...AUTOMATION_TEMPLATE_CATALOG, templates }; } + if (!enabled.size) return { ...AUTOMATION_TEMPLATE_CATALOG, templates }; + const manifests = await catalog(); + const ids = pluginTemplateIds(manifests); + for (const manifest of manifests.filter(m => enabled.has(m.id))) { + for (const entry of manifest.contributes.automationTemplates) { + try { + const template = await assets.automationTemplate(manifest, entry.path); + templates.push({ ...template, id: ids.get(manifest.id)?.get(entry.id) ?? entry.id, plugin: { id: manifest.id, name: manifest.name } }); + } catch { + logger.warn({ pluginId: manifest.id, template: entry.id }, '[plugins] Automation template unavailable; left out of the catalog'); + } + } + } + return { ...AUTOMATION_TEMPLATE_CATALOG, templates }; +} diff --git a/packages/core/test/extensionLibraryMigration.test.ts b/packages/core/test/extensionLibraryMigration.test.ts index 3c0719ded5..6a53a2e1cd 100644 --- a/packages/core/test/extensionLibraryMigration.test.ts +++ b/packages/core/test/extensionLibraryMigration.test.ts @@ -336,13 +336,13 @@ test('merged limits longer than an item holds are refused instead of truncated', const repositories = (from: number) => Array.from({ length: 40 }, (_, i) => `team/repo-${from + i}`); const bundleId = randomUUID(); await database('extension_skill_bundles').insert({ id: bundleId, instance_id: instanceId, descriptor: JSON.stringify({ id: bundleId, instanceId, digest: 'a'.repeat(64), name: 'writing', files: 1, bytes: 1, source: { kind: 'archive', filename: 'x.tar' }, requirements: [], createdAt: 1, auditActor: '1' }), files_json: '[]' }); - await profile(database, instanceId, { name: 'A', servers: [docs({ authentication: 'none' })], repositoryScope: repositories(0), skillBundleIds: [bundleId] }); - await profile(database, instanceId, { name: 'B', servers: [docs({ authentication: 'none' })], repositoryScope: repositories(40), skillBundleIds: [bundleId] }); + // Seven profiles of 40 repositories each merge to 280, more than the 256 an item holds. + for (let p = 0; p < 7; p++) await profile(database, instanceId, { name: `P${p}`, servers: [docs({ authentication: 'none' })], repositoryScope: repositories(p * 40), skillBundleIds: [bundleId] }); const report = await migrateExtensionProfilesToLibrary(database, environment, quiet); assert.equal(report.migrated, false); const diagnostic = report.warnings.join('\n'); - assert.match(diagnostic, /Extension server docs would need 80 repository limits \(an item holds at most 64\)/); - assert.match(diagnostic, new RegExp(`Skill bundle ${bundleId} would need 80 repository limits`)); + assert.match(diagnostic, /Extension server docs would need 280 repository limits \(an item holds at most 256\)/); + assert.match(diagnostic, new RegExp(`Skill bundle ${bundleId} would need 280 repository limits`)); assert.equal(await database('extension_library_migration').first(), undefined); assert.equal((await database('extension_library_revisions')).length, 0); }); diff --git a/packages/core/test/pluginContributions.test.ts b/packages/core/test/pluginContributions.test.ts new file mode 100644 index 0000000000..692d2875dc --- /dev/null +++ b/packages/core/test/pluginContributions.test.ts @@ -0,0 +1,558 @@ +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pluginManifestSchema, type ExtensionLibraryInput, type ExtensionLibraryServerInput, type PluginManifest } from '@propr/shared'; +import { getOfficialPlugin } from '../../plugins/src/index.js'; +import { extensionTestResources } from './helpers/extensionTestResources.js'; +import { npmRegistryFixture } from './helpers/npmRegistryFixture.js'; +import { up as identityMigration } from '../src/db/migrations/20261013210000_extension_profiles.js'; +import { up as grantMigration } from '../src/db/migrations/20261013220000_extension_grant_network.js'; +import { up as libraryMigration } from '../src/db/migrations/20261113000000_extension_library.js'; +import { up as oauthMigration } from '../src/db/migrations/20261113120000_extension_oauth.js'; +import { up as pluginMigration } from '../src/db/migrations/20261114000000_plugin_installations.js'; +import { PluginError, PluginStore } from '../src/plugins/pluginStore.js'; +import { PluginContributions } from '../src/plugins/pluginContributions.js'; +import { officialPluginAssets, type PluginAssetSource } from '../src/plugins/pluginAssets.js'; +import { ExtensionLibraryStore } from '../src/extensions/extensionProfileStore.js'; +import { extensionOAuth } from '../src/extensions/extensionOAuthStore.js'; +import { automationTemplateCatalog, pluginTemplateIds } from '../src/plugins/pluginTemplates.js'; +import { AUTOMATION_TEMPLATE_CATALOG } from '@propr/shared'; +import { ExtensionOAuthStore, setExtensionOAuth } from '../src/extensions/extensionOAuthStore.js'; +import { chooseRegistration, type AuthorizationServerMetadata } from '../src/extensions/extensionOAuth.js'; +import { executeWithExtensions, currentExtensionLaunch } from '../src/extensions/extensionLaunch.js'; +import { taskRunExtensionRequest } from '../src/extensions/extensionRunResolution.js'; +import { ExtensionPackageCache, type NpmRunner } from '../src/extensions/packageCache.js'; +import type { AgentConfig, AgentExtensionRequest, AgentTaskOptions } from '../src/agents/types.js'; +import logger from '../src/utils/logger.js'; +import { OAUTH_MCP_URL, OAUTH_URLS, PREREGISTERED_CLIENT, syntheticOAuthProvider, type SyntheticOAuthOptions } from '../../../test/fixtures/extensionOAuthFixture.js'; + +const ID = 'example-pack'; +const TOKEN_SENTINEL = 'PLUGIN_PACKAGE_TOKEN_SENTINEL_7c41e9_DO_NOT_DISCLOSE'; +const claude = { type: 'claude', alias: 'claude-main', id: randomUUID(), enabled: true, dockerImage: 'synthetic', configPath: '/unused', supportedModels: ['synthetic'] } as AgentConfig; +const result = { success: true, executionTimeMs: 0, modifiedFiles: [], commitMessage: null, logs: '', modelUsed: 'synthetic' }; +const issueTask = (repository = 'team/repo'): AgentExtensionRequest => taskRunExtensionRequest({ kind: 'issue', repository, labels: [] }); + +/** The example plugin, its remote server pointed at the synthetic OAuth MCP endpoint (an address literal: no DNS in tests). */ +function exampleManifest(change: (input: Record) => void = () => {}): PluginManifest { // eslint-disable-line @typescript-eslint/no-explicit-any + const input = structuredClone(getOfficialPlugin(ID, { includeDevelopment: true })!.manifest) as Record; // eslint-disable-line @typescript-eslint/no-explicit-any + input.contributes.mcpServers[0].url = OAUTH_MCP_URL; + change(input); + return pluginManifestSchema.parse(input); +} + +async function fixture(t: TestContext, options: { advertise?: SyntheticOAuthOptions['advertise'] } = {}) { + const resources = extensionTestResources(t); + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'plugin-contributions-')); + const registry = await npmRegistryFixture(); + resources.defer(() => registry.close()); + const previous = process.env.PROPR_EGRESS_SOCKET_DIR; process.env.PROPR_EGRESS_SOCKET_DIR = path.join(root, 'egress'); + resources.defer(async () => { + if (previous === undefined) delete process.env.PROPR_EGRESS_SOCKET_DIR; else process.env.PROPR_EGRESS_SOCKET_DIR = previous; + await fs.chmod(root, 0o755); await fs.rm(root, { recursive: true, force: true }).catch(async () => { await import('node:child_process').then(c => c.execFileSync('chmod', ['-R', 'u+w', root])); await fs.rm(root, { recursive: true, force: true }); }); + }); + resources.defer(async () => { await (await import('../src/db/connection.js')).closeConnection(); }); + const database = resources.database(); + await database.schema.createTable('config', c => { c.string('key').primary(); c.text('value'); }); + await database.schema.createTable('instance_members', c => { c.string('github_user_id').primary(); c.string('role'); }); + await database.schema.createTable('agent_definitions', c => { c.string('id').primary(); c.string('name'); c.text('extensions'); }); + await database.schema.createTable('tasks', c => c.string('task_id').primary()); + await database.schema.createTable('task_history', c => { c.string('task_id'); c.string('state'); c.string('timestamp'); c.text('reason'); c.text('metadata'); }); + await database('instance_members').insert([{ github_user_id: '1', role: 'admin' }, { github_user_id: '2', role: 'member' }]); + await identityMigration(database); await grantMigration(database); await libraryMigration(database); await oauthMigration(database); await pluginMigration(database); + const environment = { SYSTEM_TASK_SECRET: 'synthetic-plugin-custody', PROPR_EXTENSION_NPM_REGISTRY: registry.url, PROPR_EGRESS_SOCKET_DIR: path.join(root, 'egress'), PATH: process.env.PATH }; + let manifests = [exampleManifest()]; + const catalog = async () => manifests; + /** Skill assets as shipped, unless a test makes them unavailable or changes them. */ + let skillAssets: 'shipped' | 'unavailable' | 'changed' = 'shipped'; + const assets: PluginAssetSource = { + automationTemplate: (manifest, templatePath) => officialPluginAssets.automationTemplate(manifest, templatePath), + async skillFiles(manifest, skillPath) { + if (skillAssets === 'unavailable') throw new Error('asset folder missing'); + const files = await officialPluginAssets.skillFiles(manifest, skillPath); + if (skillAssets === 'shipped') return files; + return files.map(file => (file.path === 'SKILL.md' ? { ...file, content: Buffer.from(`${Buffer.from(file.content, 'base64').toString()}\nChanged by an upgrade.\n`).toString('base64') } : file)); + }, + }; + const plugins = new PluginStore(database, environment, catalog, Date.now, new PluginContributions(new ExtensionLibraryStore(database, environment), assets)); + const library = plugins.library; + const synthetic = syntheticOAuthProvider({ advertise: options.advertise ?? { dynamic: true } }); + setExtensionOAuth(library, new ExtensionOAuthStore(library, { transport: synthetic.transport, retryDelayMs: 1 })); + let installs = 0; + const npm: NpmRunner = (args, options) => new Promise((resolve, reject) => { + installs++; + const child = spawn('npm', args, { ...options, stdio: 'ignore' }); + child.on('error', reject); child.on('exit', code => (code === 0 ? resolve() : reject(new Error(`npm ${code}`)))); + }); + const cache = new ExtensionPackageCache(environment, npm); + const worktree = path.join(root, 'worktree'); await fs.mkdir(worktree); + /** Launch one run; what its container would receive and what its task event records. */ + const run = async (extensions: AgentExtensionRequest = issueTask()) => { + const taskId = randomUUID(); + await database('tasks').insert({ task_id: taskId }); + let launched: AgentTaskOptions | undefined; let mounts: string[] = []; + const options: AgentTaskOptions = { extensions, taskId, issueRef: { repoOwner: 'team', repoName: 'repo', number: 1 }, worktreePath: worktree, prompt: 'Run', githubToken: '' }; + await executeWithExtensions(claude, options, async prepared => { + const launch = currentExtensionLaunch(); + if (launch) { await launch.beforeLaunch(); mounts = launch.mounts; } + launched = prepared; + return result; + }, library, { packageCache: cache }); + const history = await database('task_history').where({ task_id: taskId }); + const event = history.map(h => JSON.parse(h.metadata)).find(m => m.event === 'extensions.resolved'); + const policy = launched!.toolPolicy; + return { + launched: launched!, mounts, event, history, + local: policy?.localMcpServers?.map(s => s.name) ?? [], remote: policy?.mcpServers?.map(s => s.name) ?? [], + skills: [...(launched!.prompt.matchAll(/for ([a-z-]+)\. Declared/g))].map(m => m[1]), + }; + }; + /** Sign in the plugin's OAuth connection through the plugin routes' Core methods. */ + const connect = async () => { + const { authorizationUrl } = await plugins.startOAuth('1', ID, 'example-account', 'session-a', OAUTH_URLS); + return plugins.completeOAuth('1', ID, 'example-account', 'session-a', synthetic.authorize(authorizationUrl)); + }; + /** Install with the acknowledgement, store the token and enable. */ + const enable = async () => { + await plugins.install('1', ID, ['example-local']); + await plugins.setConnection('1', ID, 'example-token', TOKEN_SENTINEL); + return plugins.setEnabled('1', ID, true); + }; + const head = async () => library.readLibrary('1'); + const save = async (mutate: (input: ExtensionLibraryInput) => ExtensionLibraryInput, secrets: Record = {}) => { + const current = await head(); + return library.saveLibrary('1', mutate({ version: 1, servers: current.servers, skills: current.skills }), secrets, current.revision); + }; + return { environment, assets, database, plugins, library, cache, registry, synthetic, run, connect, enable, head, save, catalog, installs: () => installs, setCatalog: (next: PluginManifest[]) => { manifests = next; }, setSkillAssets: (next: typeof skillAssets) => { skillAssets = next; } }; +} + +/** A refusal with this status: plugin refusals are PluginError, library refusals ExtensionError. */ +const refused = (status: number, pattern?: RegExp) => (error: unknown) => { + const refusal = error as PluginError; + assert.ok(refusal instanceof Error && typeof refusal.status === 'number', String(error)); + assert.equal(refusal.status, status, refusal.message); + if (pattern) assert.match(refusal.message, pattern); + return true; +}; + +test('enabling the example plugin adds managed library items and its template; runs get them; disable, re-enable and uninstall', async t => { + const f = await fixture(t); + await f.plugins.install('1', ID, ['example-local']); + // Installed but disabled: the managed items exist, inactive, so the OAuth connection can be signed in first. + let library = await f.head(); + assert.deepEqual(library.servers.map(s => [s.name, s.kind, s.managedBy]), [ + ['example-remote', 'https', { plugin: ID, item: 'example-remote', active: false, registrations: ['metadata_document', 'dynamic', 'preregistered'] }], + ['example-local', 'package', { plugin: ID, item: 'example-local', active: false }], + ]); + assert.equal(library.skills.length, 1); + assert.deepEqual(library.skillBundles.map(b => [b.name, b.source]), [['example-notes', { kind: 'plugin', plugin: ID, version: '0.1.0', path: 'skills/example-notes' }]]); + assert.equal((await automationTemplateCatalog(f.database, f.catalog)).templates.some(t => t.plugin), false, 'templates of a disabled plugin are hidden'); + + await f.plugins.setConnection('1', ID, 'example-token', TOKEN_SENTINEL); + await f.connect(); + const enabled = await f.plugins.setEnabled('1', ID, true); + assert.deepEqual(enabled.connections.map(c => [c.name, c.state]), [['example-account', 'connected'], ['example-token', 'connected']]); + assert.deepEqual(enabled.contributions?.mcpServers.map(s => [s.name, s.managed?.name, s.managed?.active]), [['example-remote', 'example-remote', true], ['example-local', 'example-local', true]]); + assert.equal(enabled.contributions?.skills[0].managed?.bundleId, library.skills[0].bundleId); + assert.deepEqual(enabled.contributions?.automationTemplates, [{ id: 'example-weekly-digest', catalogId: 'example-weekly-digest', available: true, href: '/api/automation-definitions/templates' }]); + library = await f.head(); + // Managed items default to automatic apply on, for every repository while the plugin has no repository limit. + for (const item of [...library.servers, ...library.skills]) assert.deepEqual([item.enabled, item.autoApply, item.repositories, item.managedBy?.active], [true, true, [], true]); + const local = library.servers[1]; + assert.equal(local.kind === 'package' && local.integrity, exampleManifest().contributes.mcpServers[1].kind === 'package' && (exampleManifest().contributes.mcpServers[1] as { integrity: string }).integrity); + assert.deepEqual(library.acknowledgements?.['example-local'], { actor: '1', at: (await f.plugins.get('1', ID)).installation!.acknowledgements['example-local'].at, package: 'propr-fixture-mcp', version: '1.0.0', secretEnv: ['FIXTURE_TOKEN'] }); + assert.deepEqual(library.secrets['example-local'], { count: 1, configured: true, configuredEnv: ['FIXTURE_TOKEN'] }); + + const catalog = await automationTemplateCatalog(f.database, f.catalog); + const template = catalog.templates.find(t => t.plugin); + assert.deepEqual([template?.id, template?.name, template?.plugin], ['example-weekly-digest', 'Example weekly digest', { id: ID, name: 'Example pack' }]); + assert.match(template!.prompt, /\S/); + + // A run in a matching repository gets the remote server, the package server and the skill through normal resolution. + const first = await f.run(); + assert.deepEqual([first.remote, first.local, first.skills], [['example-remote'], ['example-local'], ['example-notes']]); + assert.equal(f.installs(), 1, 'the package is installed once'); + assert.ok(first.mounts.some(m => /target=\/opt\/propr\/packages\/0,readonly/.test(m)), 'mounted read-only'); + const bridge = JSON.parse(Buffer.from(first.launched.toolPolicy!.localMcpServers![0].bridgeConfig, 'base64url').toString()); + assert.deepEqual(bridge.secretEnv, { FIXTURE_TOKEN: 'PROPR_EXTENSION_STDIO_0_0_SECRET' }); + assert.deepEqual(bridge.env, { FIXTURE_GREETING: 'hello' }); + assert.equal(first.launched.toolPolicy!.localMcpServers![0].env?.PROPR_EXTENSION_STDIO_0_0_SECRET, TOKEN_SENTINEL); + await f.cache.verify(library.servers[1] as Extract); + await f.run(); + assert.equal(f.installs(), 1, 'later runs reuse the verified install'); + + // The administrator may change enabled, limits and automatic apply. + await f.save(input => ({ ...input, servers: input.servers.map(s => (s.name === 'example-local' ? { ...s, repositories: ['team/repo'], agents: ['claude-main'] } : s)) })); + assert.deepEqual((await f.run(issueTask('team/other'))).local, []); + // A client that rebuilds an item from its editable fields (no managedBy) keeps the item managed. + const rebuilt = await f.save(input => ({ ...input, skills: input.skills.map(({ bundleId, enabled, repositories, autoApply }) => ({ bundleId, enabled, repositories, autoApply, agents: ['claude-main'] })) })); + assert.deepEqual([rebuilt.skills[0].agents, rebuilt.skills[0].managedBy?.plugin], [['claude-main'], ID]); + // Nothing else of a managed item can change through the library API. + const pkg = (change: Record) => (input: ExtensionLibraryInput) => ({ ...input, servers: input.servers.map(s => (s.name === 'example-local' ? { ...s, ...change } as ExtensionLibraryServerInput : s)) }); + for (const change of [{ package: 'propr-fixture-multi' }, { version: '2.0.0' }, { integrity: `sha512-${'A'.repeat(86)}==` }, { args: ['--evil'] }, { env: [{ name: 'FIXTURE_GREETING', value: 'changed' }] }, { secretEnv: [] }, { tools: ['echo'] }, { capability: 'write' }, { managedBy: { plugin: ID, item: 'example-local', active: false } }]) { + await assert.rejects(f.save(pkg(change)), refused(409, /managed by plugin example-pack/), JSON.stringify(change)); + } + await assert.rejects(f.save(input => ({ ...input, servers: input.servers.map(s => (s.name === 'example-remote' ? { ...s, url: 'https://8.8.4.4/mcp' } : s)) })), refused(409, /managed by plugin/)); + await assert.rejects(f.save(input => ({ ...input, servers: input.servers.filter(s => s.name !== 'example-remote') })), refused(409, /uninstall the plugin/)); + await assert.rejects(f.save(input => ({ ...input, skills: [] })), refused(409, /uninstall the plugin/)); + await assert.rejects(f.save(input => input, { 'example-local/FIXTURE_TOKEN': 'other-value' }), refused(409, /comes from its plugin/)); + await assert.rejects(f.library.replaceSecret('1', 'example-local/FIXTURE_TOKEN', null), refused(409, /plugin/)); + await assert.rejects(f.save(input => ({ ...input, servers: [...input.servers, { kind: 'https', name: 'forged', url: OAUTH_MCP_URL, enabled: false, tools: [], capability: 'read', authentication: 'none', repositories: [], agents: [], autoApply: true, managedBy: { plugin: ID, item: 'forged', active: true } }] })), refused(400, /Only ProPR/)); + + // Disabling deactivates the managed items and hides the template. + await f.plugins.setEnabled('1', ID, false); + const off = await f.run(); + assert.equal(off.launched.toolPolicy, undefined); + assert.ok(!off.launched.prompt.includes('example-notes')); + assert.equal((await automationTemplateCatalog(f.database, f.catalog)).templates.some(t => t.plugin), false); + // Re-enabling restores them with settings and connections intact. + const again = await f.plugins.setEnabled('1', ID, true); + assert.equal(again.connections[0].state, 'connected'); + const restored = await f.head(); + assert.deepEqual(restored.servers.find(s => s.name === 'example-local')?.repositories, ['team/repo']); + const back = await f.run(); + assert.deepEqual([back.remote, back.local, back.skills], [['example-remote'], ['example-local'], ['example-notes']]); + + // Uninstall is refused while an automation adds a managed item explicitly, naming the automation. + await f.database('agent_definitions').insert({ id: 'a1', name: 'Nightly notes', extensions: JSON.stringify({ servers: ['example-local'], skills: [] }) }); + await assert.rejects(f.plugins.uninstall('1', ID, ID), refused(409, /"Nightly notes" \(example-local\)/)); + assert.equal((await f.plugins.get('1', ID)).state, 'enabled', 'the refused uninstall changed nothing'); + await f.database('agent_definitions').where({ id: 'a1' }).delete(); + await f.plugins.uninstall('1', ID, ID); + const removed = await f.head(); + assert.deepEqual([removed.servers, removed.skills], [[], []]); + assert.equal((await f.database('extension_oauth_connections').select()).length, 0, 'the OAuth connection goes with its server'); + assert.equal((await f.run()).launched.toolPolicy, undefined); +}); + +test('a missing package secret skips the server for the run; without a current acknowledgement it is never launched', async t => { + const f = await fixture(t); + await f.enable(); + // As after a native export: the connection value is gone. The next plugin change syncs it. + await f.database('plugin_secrets').delete(); + await f.plugins.updateSettings('1', ID, { verbosity: 'quiet' }); + const missing = await f.run(); + assert.deepEqual(missing.local, []); + assert.deepEqual(missing.event.skipped.filter((s: { name: string }) => s.name === 'example-local'), [{ kind: 'server', name: 'example-local', reason: 'secret_not_configured', source: 'auto' }]); + assert.deepEqual(missing.skills, ['example-notes'], 'the run proceeds with the other items'); + assert.equal(f.installs(), 0); + + // An upgrade that changes the package needs the acknowledgement again: until approved the server stays inactive. + await f.plugins.setConnection('1', ID, 'example-token', TOKEN_SENTINEL); + f.setCatalog([exampleManifest(input => { input.version = '0.2.0'; input.contributes.mcpServers[1].version = '1.0.1'; })]); + const pending = await f.plugins.updateSettings('1', ID, { verbosity: 'normal' }); + assert.equal(pending.needsApproval, true); + const local = (await f.head()).servers.find(s => s.name === 'example-local')!; + assert.deepEqual([local.kind === 'package' && local.version, local.managedBy?.active], ['1.0.1', false]); + assert.equal((await f.head()).acknowledgements?.['example-local'], undefined, 'the library records no acknowledgement for it'); + const unacknowledged = await f.run(); + assert.deepEqual(unacknowledged.local, []); + assert.equal(f.installs(), 0, 'never installed or launched'); +}); + +test('a managed OAuth server is left out of runs until its plugin connection is connected; disconnecting removes it', async t => { + const f = await fixture(t); + const view = await f.enable(); + assert.equal(view.connections[0].state, 'not_connected'); + const before = await f.run(); + assert.deepEqual(before.remote, []); + assert.deepEqual(before.event.skipped.find((s: { name: string }) => s.name === 'example-remote'), { kind: 'server', name: 'example-remote', reason: 'oauth_not_connected', source: 'auto' }); + await assert.rejects(f.plugins.setConnection('1', ID, 'example-account', 'value'), refused(400, /sign-in/)); + await assert.rejects(f.plugins.startOAuth('2', ID, 'example-account', 'session-a', OAUTH_URLS), refused(403)); + await assert.rejects(f.plugins.startOAuth('1', ID, 'example-token', 'session-a', OAUTH_URLS), refused(404)); + // A sign-in of another plugin connection or library server cannot be completed here. + await assert.rejects(f.plugins.completeOAuth('1', ID, 'example-account', 'session-a', { state: 'x'.repeat(43), code: 'c' }), refused(400)); + const connected = await f.connect(); + assert.equal(connected.connections[0].state, 'connected'); + assert.deepEqual((await f.run()).remote, ['example-remote']); + const disconnected = await f.plugins.disconnectOAuth('1', ID, 'example-account'); + assert.equal(disconnected.connections[0].state, 'not_connected'); + assert.deepEqual((await f.run()).remote, []); + const audit = (await f.plugins.get('1', ID)).audit.map(a => a.action); + assert.deepEqual(audit.slice(0, 3), ['disconnect_oauth', 'complete_oauth', 'start_oauth']); +}); + +test('a name collision with an existing library item prefixes the managed item with the plugin id', async t => { + const f = await fixture(t); + await f.library.saveLibrary('1', { version: 1, servers: [{ kind: 'https', name: 'example-local', url: OAUTH_MCP_URL, enabled: true, tools: [], capability: 'read', authentication: 'none', repositories: [], agents: [], autoApply: false }], skills: [] }, {}, 0); + await f.enable(); + const library = await f.head(); + assert.deepEqual(library.servers.map(s => [s.name, s.managedBy?.item ?? null]), [['example-local', null], ['example-remote', 'example-remote'], ['example-pack-example-local', 'example-local']]); + assert.deepEqual(library.secrets['example-pack-example-local'], { count: 1, configured: true, configuredEnv: ['FIXTURE_TOKEN'] }); + assert.deepEqual((await f.run()).local, ['example-pack-example-local']); + // The administrator's own item stays editable and removable. + await f.save(input => ({ ...input, servers: input.servers.filter(s => s.name !== 'example-local') })); +}); + +test('plugin secrets never appear in responses, library revisions, task events or logs', async t => { + const f = await fixture(t); + const lines: string[] = []; + for (const level of ['info', 'warn', 'error', 'debug'] as const) t.mock.method(logger, level, (...args: unknown[]) => { lines.push(JSON.stringify(args)); }); + const views = [await f.enable(), await f.connect()]; + const runs = [await f.run(), await f.run()]; + const visible = JSON.stringify([ + views, await f.plugins.list('1'), await f.plugins.get('1', ID), await f.head(), await f.library.history('1'), + runs.map(r => [r.event, r.history, r.launched.toolPolicy?.localMcpServers?.map(s => s.bridgeConfig)]), + await f.database('extension_library_revisions').select('snapshot', 'secrets_encrypted'), await f.database('plugin_secrets').select('sealed'), await f.database('plugin_audit').select(), + await automationTemplateCatalog(f.database, f.catalog), lines, + ]); + assert.ok(!visible.includes(TOKEN_SENTINEL)); +}); + +test('disabling deactivates the existing managed items even when the upgraded manifest drops a referenced server and its skill assets are unavailable', async t => { + const f = await fixture(t); + await f.enable(); + const before = await f.head(); + await f.database('agent_definitions').insert({ id: 'a1', name: 'Nightly notes', extensions: JSON.stringify({ servers: ['example-local'], skills: [] }) }); + // A ProPR upgrade drops the package server from the manifest, and the skill folder cannot be read. + f.setCatalog([exampleManifest(input => { input.version = '0.2.0'; input.contributes.mcpServers = input.contributes.mcpServers.filter((s: { name: string }) => s.name !== 'example-local'); })]); + f.setSkillAssets('unavailable'); + const disabled = await f.plugins.setEnabled('1', ID, false); + assert.equal(disabled.state, 'installed'); + const after = await f.head(); + // The items stay as they were, only deactivated: the upgrade is not applied by the disable. + assert.deepEqual(after.servers.map(s => [s.name, s.managedBy?.active]), [['example-remote', false], ['example-local', false]]); + assert.deepEqual(after.skills.map(s => [s.bundleId, s.managedBy?.active]), [[before.skills[0].bundleId, false]]); + const off = await f.run({ ...issueTask(), servers: ['example-local'] }); + assert.equal(off.launched.toolPolicy, undefined, 'a deactivated item never applies, even where an automation adds it'); + assert.ok(!off.launched.prompt.includes('example-notes')); + // Operations that do apply the upgrade keep its validation: unavailable assets, then the referenced removal. + await assert.rejects(f.plugins.updateSettings('1', ID, { verbosity: 'quiet' }), refused(409, /Skill example-notes of the plugin is unavailable/)); + f.setSkillAssets('shipped'); + await assert.rejects(f.plugins.updateSettings('1', ID, { verbosity: 'quiet' }), refused(409, /"Nightly notes" \(example-local\).*before upgrading it/)); + await f.database('agent_definitions').where({ id: 'a1' }).delete(); + await f.plugins.updateSettings('1', ID, { verbosity: 'quiet' }); + assert.deepEqual((await f.head()).servers.map(s => s.name), ['example-remote'], 'once unreferenced, the upgrade removes the dropped server'); +}); + +test('a skill upgrade that replaces the bundle an automation adds explicitly is refused until the automation stops adding it', async t => { + const f = await fixture(t); + await f.enable(); + // The automation adds the plugin skill explicitly; automatic apply is off, so only that reference applies it. + await f.save(input => ({ ...input, skills: input.skills.map(s => ({ ...s, autoApply: false })) })); + const original = (await f.head()).skills[0].bundleId; + await f.database('agent_definitions').insert({ id: 'a1', name: 'Nightly notes', extensions: JSON.stringify({ servers: [], skills: [original] }) }); + assert.deepEqual((await f.run({ ...issueTask(), skills: [original] })).skills, ['example-notes']); + + // An upgrade changes the skill's files under the same manifest name: the skill would get a new bundle id. + f.setSkillAssets('changed'); + await assert.rejects(f.plugins.updateSettings('1', ID, { verbosity: 'quiet' }), refused(409, new RegExp(`"Nightly notes" \\(${original}\\).*before upgrading it`))); + const kept = await f.head(); + assert.deepEqual(kept.skills.map(s => s.bundleId), [original], 'the refused change left the library reference in place'); + assert.equal(kept.skillBundles.length, 1, 'no replacement bundle was stored'); + assert.deepEqual((await f.run({ ...issueTask(), skills: [original] })).skills, ['example-notes'], 'the automation keeps its skill'); + // Disabling still works without applying the upgrade. + await f.plugins.setEnabled('1', ID, false); + assert.deepEqual((await f.head()).skills.map(s => [s.bundleId, s.managedBy?.active]), [[original, false]]); + + // Once the automation no longer adds it, the upgrade applies and the skill gets its new bundle. + await f.database('agent_definitions').where({ id: 'a1' }).delete(); + await f.plugins.setEnabled('1', ID, true); + const upgraded = await f.head(); + assert.notEqual(upgraded.skills[0].bundleId, original); + assert.equal(upgraded.skills[0].managedBy?.active, true); +}); + +test('a plugin OAuth connection signs in only through the registration methods it permits, through plugin and extension routes alike', async t => { + const onlyMetadataDocument = () => exampleManifest(input => { input.connections.find((c: { name: string }) => c.name === 'example-account').registrations = ['metadata_document']; }); + // The provider offers dynamic registration but no client metadata documents. + const f = await fixture(t); + f.setCatalog([onlyMetadataDocument()]); + await f.enable(); + const remote = (await f.head()).servers.find(s => s.name === 'example-remote')!; + assert.deepEqual(remote.managedBy?.registrations, ['metadata_document']); + await assert.rejects(f.plugins.startOAuth('1', ID, 'example-account', 'session-a', OAUTH_URLS), refused(409, /none of the client registration methods the plugin permits \(metadata_document\)/)); + await assert.rejects(extensionOAuth(f.library).startSignIn('1', 'session-a', 'example-remote', OAUTH_URLS), refused(409, /registration methods the plugin permits/)); + assert.equal(f.synthetic.state.registrations, 0, 'no client was registered dynamically'); + assert.equal((await f.database('extension_oauth_pending').select()).length, 0, 'a refused start leaves no sign-in record'); + // The restriction is part of the read-only definition: a library save cannot drop it. + await assert.rejects(f.save(input => ({ ...input, servers: input.servers.map(s => (s.name === 'example-remote' ? { ...s, managedBy: { plugin: ID, item: 'example-remote', active: true } } : s)) })), refused(409, /managed by plugin/)); + + // A provider that supports client metadata documents signs in with one. + const g = await fixture(t, { advertise: { metadataDocument: true, dynamic: true } }); + g.setCatalog([onlyMetadataDocument()]); + await g.enable(); + const connected = await g.connect(); + assert.equal(connected.connections[0].state, 'connected'); + assert.equal(g.synthetic.state.registrations, 0); + assert.equal((await g.database('extension_oauth_connections').first('registration')).registration, 'metadata_document'); +}); + +test('disabling works when the stored credentials cannot be decrypted, and carries them over sealed', async t => { + const f = await fixture(t); + await f.enable(); + await f.connect(); + // As after a raw restore under another encryption root: neither plugin connections nor library credentials open. + const restored = new PluginStore(f.database, { ...f.environment, SYSTEM_TASK_SECRET: 'another-encryption-root' }, f.catalog, Date.now, new PluginContributions(new ExtensionLibraryStore(f.database, { ...f.environment, SYSTEM_TASK_SECRET: 'another-encryption-root' }), f.assets)); + await assert.rejects(restored.updateSettings('1', ID, { verbosity: 'quiet' }), 'applying the manifest needs the credentials'); + const before = await f.database('extension_library_revisions').orderBy('revision', 'desc').first('revision', 'secrets_encrypted'); + const disabled = await restored.setEnabled('1', ID, false); + assert.equal(disabled.state, 'installed'); + const after = await f.head(); + assert.deepEqual([...after.servers, ...after.skills].map(item => item.managedBy?.active), [false, false, false], 'every managed item is deactivated'); + assert.deepEqual(after.secrets['example-local'], { count: 1, configured: true, configuredEnv: ['FIXTURE_TOKEN'] }, 'credential statuses carry over'); + const latest = await f.database('extension_library_revisions').orderBy('revision', 'desc').first('revision', 'secrets_encrypted'); + assert.equal(latest.revision, before.revision + 1); + assert.equal(latest.secrets_encrypted, `${before.revision}:${before.secrets_encrypted}`, 'the sealed bytes carry over undecrypted'); + assert.equal((await f.run()).launched.toolPolicy, undefined); + // Under the original root, re-enabling restores the items with their credentials and OAuth connection. + const again = await f.plugins.setEnabled('1', ID, true); + assert.deepEqual(again.connections.map(c => c.state), ['connected', 'connected']); + const back = await f.run(); + assert.deepEqual([back.remote, back.local], [['example-remote'], ['example-local']]); + assert.equal(back.launched.toolPolicy!.localMcpServers![0].env?.PROPR_EXTENSION_STDIO_0_0_SECRET, TOKEN_SENTINEL); +}); + +test('a plugin OAuth connection that permits only pre-registration signs in with the client configured on the plugin', async t => { + // The provider offers neither dynamic registration nor client metadata documents. + const f = await fixture(t, { advertise: {} }); + f.setCatalog([exampleManifest(input => { input.connections.find((c: { name: string }) => c.name === 'example-account').registrations = ['preregistered']; })]); + await f.enable(); + await assert.rejects(f.plugins.startOAuth('1', ID, 'example-account', 'session-a', OAUTH_URLS), refused(409), 'no client configured yet'); + // Validation of the configuration route. + await assert.rejects(f.plugins.setOAuthClient('2', ID, 'example-account', { clientId: PREREGISTERED_CLIENT.id }), refused(403)); + await assert.rejects(f.plugins.setOAuthClient('1', ID, 'example-token', { clientId: PREREGISTERED_CLIENT.id }), refused(404)); + for (const invalid of [{}, { clientId: 'has space' }, { clientId: PREREGISTERED_CLIENT.id, extra: 1 }, { clientId: PREREGISTERED_CLIENT.id, clientSecret: '' }, 'client']) { + await assert.rejects(f.plugins.setOAuthClient('1', ID, 'example-account', invalid), refused(400), JSON.stringify(invalid)); + } + + const configured = await f.plugins.setOAuthClient('1', ID, 'example-account', { clientId: PREREGISTERED_CLIENT.id, clientSecret: PREREGISTERED_CLIENT.secret }); + assert.deepEqual([configured.connections[0].state, configured.connections[0].setBy], ['not_connected', '1']); + let library = await f.head(); + const remote = library.servers.find(s => s.name === 'example-remote')!; + assert.equal(remote.kind === 'https' && remote.clientId, PREREGISTERED_CLIENT.id, 'the client id is projected into the managed server'); + assert.deepEqual(library.secrets['example-remote'], { fields: ['clientSecret'], configured: true }); + // The client stays plugin-owned: the library cannot replace its secret. + await assert.rejects(f.library.replaceSecret('1', 'example-remote', 'other-secret'), refused(409, /plugin/)); + const connected = await f.connect(); + assert.equal(connected.connections[0].state, 'connected'); + assert.equal((await f.database('extension_oauth_connections').first('registration', 'client_id')).client_id, PREREGISTERED_CLIENT.id); + assert.deepEqual((await f.run()).remote, ['example-remote']); + const visible = JSON.stringify([connected, await f.plugins.get('1', ID), await f.head(), await f.database('extension_library_revisions').select('snapshot'), await f.database('plugin_audit').select()]); + assert.ok(!visible.includes(PREREGISTERED_CLIENT.secret), 'the client secret is never returned'); + + // Clearing the client removes it from the managed server and discards the connection made with it. + const cleared = await f.plugins.setOAuthClient('1', ID, 'example-account', null); + assert.equal(cleared.connections[0].state, 'not_connected'); + library = await f.head(); + const plain = library.servers.find(s => s.name === 'example-remote')!; + assert.equal(plain.kind === 'https' && plain.clientId, undefined); + assert.equal(library.secrets['example-remote'], undefined); + assert.equal((await f.database('extension_oauth_connections').select()).length, 0); + assert.deepEqual((await f.plugins.get('1', ID)).audit.slice(0, 1).map(a => a.action), ['clear_oauth_client']); + + // A connection that does not permit pre-registration refuses a client. + f.setCatalog([exampleManifest(input => { input.connections.find((c: { name: string }) => c.name === 'example-account').registrations = ['metadata_document']; })]); + await assert.rejects(f.plugins.setOAuthClient('1', ID, 'example-account', { clientId: PREREGISTERED_CLIENT.id }), refused(409, /does not permit a pre-registered client/)); +}); + +test('replacing only the secret of a configured client discards the connection and pending sign-ins made with the previous one', async t => { + const f = await fixture(t, { advertise: {} }); + f.setCatalog([exampleManifest(input => { input.connections.find((c: { name: string }) => c.name === 'example-account').registrations = ['preregistered']; })]); + await f.enable(); + const original = { clientId: PREREGISTERED_CLIENT.id, clientSecret: PREREGISTERED_CLIENT.secret }; + await f.plugins.setOAuthClient('1', ID, 'example-account', original); + assert.equal((await f.connect()).connections[0].state, 'connected'); + // A second sign-in, started from the extension library, is still pending. + const { authorizationUrl } = await extensionOAuth(f.library).startSignIn('1', 'session-b', 'example-remote', OAUTH_URLS); + const callback = f.synthetic.authorize(authorizationUrl); + assert.equal((await f.database('extension_oauth_pending').select()).length, 1); + + // Setting the same client again changes nothing. + assert.equal((await f.plugins.setOAuthClient('1', ID, 'example-account', { ...original })).connections[0].state, 'connected'); + assert.equal((await f.database('extension_oauth_pending').select()).length, 1); + + // The same client id with another secret: everything the connection binding compares is unchanged. + const rotated = await f.plugins.setOAuthClient('1', ID, 'example-account', { clientId: PREREGISTERED_CLIENT.id, clientSecret: 'ROTATED_CLIENT_SECRET_0002' }); + const remote = (await f.head()).servers.find(s => s.name === 'example-remote')!; + assert.equal(remote.kind === 'https' && remote.clientId, PREREGISTERED_CLIENT.id, 'the projected client id stays the same'); + assert.equal(rotated.connections[0].state, 'not_connected'); + assert.equal((await f.plugins.get('1', ID)).connections[0].state, 'not_connected', 'reads report it not connected'); + assert.equal((await f.database('extension_oauth_connections').select()).length, 0, 'the connection made with the previous secret is discarded'); + assert.equal((await f.database('extension_oauth_pending').select()).length, 0, 'the pending sign-in is discarded'); + assert.deepEqual((await f.database('extension_oauth_audit').where({ action: 'discarded' }).select('server_name', 'actor')).map(r => [r.server_name, r.actor]), [['example-remote', '1']]); + assert.deepEqual((await f.run()).remote, [], 'runs no longer include the server'); + // The sign-in started under the previous secret can no longer complete. + await assert.rejects(extensionOAuth(f.library).completeSignIn('1', 'session-b', callback), refused(400, /Unknown, used or expired/)); + assert.equal((await f.database('extension_oauth_connections').select()).length, 0); + + // Signing in again uses the configured client. + await f.plugins.setOAuthClient('1', ID, 'example-account', original); + assert.equal((await f.connect()).connections[0].state, 'connected'); +}); + +test('a configured client signs in with pre-registration even when the connection lists automatic methods first and the provider offers them', async t => { + // The example connection permits metadata_document, dynamic, preregistered in that order; the provider offers both automatic methods. + const f = await fixture(t, { advertise: { metadataDocument: true, dynamic: true } }); + await f.enable(); + assert.deepEqual((await f.head()).servers.find(s => s.name === 'example-remote')!.managedBy?.registrations, ['metadata_document', 'dynamic', 'preregistered']); + await f.plugins.setOAuthClient('1', ID, 'example-account', { clientId: PREREGISTERED_CLIENT.id, clientSecret: PREREGISTERED_CLIENT.secret }); + const connected = await f.connect(); + assert.equal(connected.connections[0].state, 'connected'); + const row = await f.database('extension_oauth_connections').first('registration', 'client_id'); + assert.deepEqual([row.registration, row.client_id], ['preregistered', PREREGISTERED_CLIENT.id]); + assert.equal(f.synthetic.state.registrations, 0, 'no client was registered dynamically'); + assert.equal((await f.plugins.get('1', ID)).connections[0].state, 'connected', 'reads keep reporting it connected'); + assert.deepEqual((await f.run()).remote, ['example-remote'], 'runs include the server'); + // The extension route chooses the same way. + const { authorizationUrl } = await extensionOAuth(f.library).startSignIn('1', 'session-b', 'example-remote', OAUTH_URLS); + assert.equal(new URL(authorizationUrl).searchParams.get('client_id'), PREREGISTERED_CLIENT.id); +}); + +test('a configured client id selects only pre-registration, and nothing when the allowed methods exclude it', () => { + const metadata = { issuer: 'https://issuer.example', authorization_endpoint: 'https://issuer.example/a', token_endpoint: 'https://issuer.example/t', registration_endpoint: 'https://issuer.example/r', client_id_metadata_document_supported: true } as AuthorizationServerMetadata; + const clientMetadataUrl = 'https://propr.example/client.json'; + assert.equal(chooseRegistration(metadata, { clientId: 'configured', clientMetadataUrl, allowed: ['metadata_document', 'dynamic', 'preregistered'] }), 'preregistered'); + assert.equal(chooseRegistration(metadata, { clientId: 'configured', clientMetadataUrl }), 'preregistered'); + assert.equal(chooseRegistration(metadata, { clientId: 'configured', clientMetadataUrl, allowed: ['metadata_document', 'dynamic'] }), null); + assert.equal(chooseRegistration(metadata, { clientMetadataUrl, allowed: ['dynamic', 'metadata_document', 'preregistered'] }), 'dynamic'); +}); + +test('a plugin repository limit up to the plugin maximum is copied onto its managed items; a larger one is refused as a plugin limit', async t => { + const f = await fixture(t); + await f.enable(); + const repositories = Array.from({ length: 65 }, (_, i) => `team/repo-${i}`); + const limited = await f.plugins.setRepositories('1', ID, repositories); + assert.deepEqual(limited.installation?.repositories, repositories); + let library = await f.head(); + assert.deepEqual([...library.servers, ...library.skills].filter(item => item.managedBy).map(item => item.repositories.length), [65, 65, 65]); + // The administrator can still save the library with those items, and keep editing their limits. + await f.save(input => ({ ...input, servers: input.servers.map(s => (s.name === 'example-local' ? { ...s, repositories: [...repositories.slice(1), 'team/repo'] } : s)) })); + const inside = await f.run(); const outside = await f.run(issueTask('team/repo-0')); + assert.deepEqual([inside.local, outside.local], [['example-local'], []], 'the edited limit applies to runs'); + + const maximum = Array.from({ length: 256 }, (_, i) => `team/repo-${i}`); + await f.plugins.setRepositories('1', ID, maximum); + library = await f.head(); + assert.deepEqual([...library.servers, ...library.skills].filter(item => item.managedBy).map(item => item.repositories.length), [256, 256, 256]); + const before = library.revision; + await assert.rejects(f.plugins.setRepositories('1', ID, [...maximum, 'team/one-more']), refused(400, /up to 256 owner\/repo names/)); + assert.equal((await f.head()).revision, before, 'a refused limit leaves the library unchanged'); + assert.equal((await f.plugins.get('1', ID)).installation?.repositories?.length, 256); +}); + +test('plugin template ids stay unique when a prefixed id equals another template id', () => { + const manifest = (id: string, templates: string[]) => exampleManifest(input => { + input.id = id; input.name = id; + input.contributes.automationTemplates = templates.map(t => ({ id: t, path: 'templates/example-weekly-digest' })); + }); + const shipped = AUTOMATION_TEMPLATE_CATALOG.templates[0].id; + const manifests = [ + manifest('alpha', ['digest', 'digest-x']), manifest('beta', ['digest', 'digest-x', 'x']), manifest('gamma', ['alpha-digest', shipped]), + manifest('delta', ['alpha-digest-2']), manifest('alpha-digest', ['x']), + ]; + const ids = pluginTemplateIds(manifests); + const all = [...AUTOMATION_TEMPLATE_CATALOG.templates.map(t => t.id), ...[...ids.values()].flatMap(m => [...m.values()])]; + assert.equal(new Set(all).size, all.length, `catalog ids are unique: ${all.join(', ')}`); + assert.equal(ids.get('gamma')!.get('alpha-digest'), 'alpha-digest', 'a template whose id nobody else uses keeps it'); + assert.equal(ids.get('delta')!.get('alpha-digest-2'), 'alpha-digest-2'); + assert.equal(ids.get('alpha')!.get('digest'), 'alpha-digest-3', 'a generated id never takes another template\'s own id'); + assert.equal(ids.get('beta')!.get('digest'), 'beta-digest'); + assert.equal(ids.get('gamma')!.get(shipped), `gamma-${shipped}`); + assert.equal(ids.get('alpha')!.get('digest-x'), 'alpha-digest-x'); + assert.equal(ids.get('alpha-digest')!.get('x'), 'alpha-digest-x-2', 'a generated id never takes another generated id'); + assert.deepEqual(pluginTemplateIds(manifests), ids, 'the allocation is deterministic'); +}); diff --git a/packages/core/test/pluginStore.test.ts b/packages/core/test/pluginStore.test.ts index 175f9b0882..def20bc3cd 100644 --- a/packages/core/test/pluginStore.test.ts +++ b/packages/core/test/pluginStore.test.ts @@ -5,6 +5,9 @@ import { getOfficialPlugin } from '../../plugins/src/index.js'; import { extensionTestResources } from './helpers/extensionTestResources.js'; import { up as identityMigration } from '../src/db/migrations/20261013210000_extension_profiles.js'; import { up as pluginMigration } from '../src/db/migrations/20261114000000_plugin_installations.js'; +import { up as grantMigration } from '../src/db/migrations/20261013220000_extension_grant_network.js'; +import { up as libraryMigration } from '../src/db/migrations/20261113000000_extension_library.js'; +import { up as oauthMigration } from '../src/db/migrations/20261113120000_extension_oauth.js'; import { PluginError, PluginStore } from '../src/plugins/pluginStore.js'; import { officialPluginCatalog } from '../src/plugins/pluginCatalog.js'; import logger from '../src/utils/logger.js'; @@ -33,7 +36,7 @@ async function fixture(t: TestContext, initial: PluginManifest = example) { await database.schema.createTable('instance_members', c => { c.string('github_user_id').primary(); c.string('role'); }); await database('instance_members').insert([{ github_user_id: '1', role: 'admin' }, { github_user_id: '2', role: 'member' }]); await database.schema.createTable('agent_definitions', c => { c.string('id').primary(); c.string('name'); }); - await identityMigration(database); await pluginMigration(database); + await identityMigration(database); await grantMigration(database); await libraryMigration(database); await oauthMigration(database); await pluginMigration(database); let manifests: PluginManifest[] = [initial]; let clock = 1_700_000_000_000; const store = new PluginStore(database, { SYSTEM_TASK_SECRET: 'plugin-store-test-root' }, async () => manifests, () => clock); diff --git a/packages/shared/src/automationTemplateContract.ts b/packages/shared/src/automationTemplateContract.ts index cfb07acc8d..d9deaa5a86 100644 --- a/packages/shared/src/automationTemplateContract.ts +++ b/packages/shared/src/automationTemplateContract.ts @@ -52,6 +52,8 @@ export interface AutomationTemplate { previousReportCount: number; /** What the user must review before saving. */ reviewBeforeSaving: AutomationTemplateReviewItem[]; + /** Set on templates an enabled plugin contributes: the plugin that ships it. Never part of the shipped catalog file. */ + plugin?: { id: string; name: string }; } export interface AutomationTemplateCatalog { diff --git a/packages/shared/src/extensionLibrary.ts b/packages/shared/src/extensionLibrary.ts index 37161679b5..ff78457265 100644 --- a/packages/shared/src/extensionLibrary.ts +++ b/packages/shared/src/extensionLibrary.ts @@ -1,10 +1,11 @@ import { z } from 'zod'; -import type { ExtensionOAuthConnectionState } from './extensionOAuth.js'; +import { EXTENSION_OAUTH_REGISTRATIONS, type ExtensionOAuthConnectionState } from './extensionOAuth.js'; import { EXTENSION_LIMITS, EXTENSION_PROFILE_VERSION, catalogMcpSchema, extensionAgentAliasSchema, extensionRepositorySchema, extensionServerNameSchema, remoteMcpSchema, type ExtensionSecretStatus, type ExtensionSkillBundle, } from './extensionProfiles.js'; import { EXTENSION_PACKAGE_ECOSYSTEMS, extensionPackageEnvConflict, packageServerFields, type ExtensionSecretAcknowledgement } from './extensionPackages.js'; +import { PLUGIN_REPOSITORY_LIMIT } from './pluginInstallations.js'; /** * The instance extension library: one list of MCP servers and skill bundle @@ -12,9 +13,31 @@ import { EXTENSION_PACKAGE_ECOSYSTEMS, extensionPackageEnvConflict, packageServe * `enabled` switch and `autoApply`. Automations add items explicitly on top. */ export const EXTENSION_LIBRARY_VERSION = EXTENSION_PROFILE_VERSION; -export const EXTENSION_LIBRARY_LIMITS = { servers: 32, bundles: 32, repositories: 64, agents: 64 } as const; +/** An item holds as many repositories as a plugin's repository limit, which its managed items copy. */ +export const EXTENSION_LIBRARY_LIMITS = { servers: 32, bundles: 32, repositories: PLUGIN_REPOSITORY_LIMIT, agents: 64 } as const; export const EXTENSION_RUNTIMES = ['claude', 'codex'] as const; +/** Plugin ids, as `pluginIdSchema` in plugins.ts (kept here to avoid an import cycle). */ +const managingPluginSchema = z.string().max(48).regex(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/); +/** + * Marks a library item contributed by an installed plugin. Its definition + * comes from the plugin manifest and is read-only; only `enabled`, the limits + * and `autoApply` can be changed. `item` is the name the manifest gives the + * server or skill (the library name may carry the plugin id as a prefix after + * a name collision). `active` is false while the plugin is disabled or a + * required acknowledgement is pending: the item then never applies. + * `registrations` (OAuth servers only) are the client registration methods + * the plugin's connection permits, in the order sign-in tries them; sign-in + * uses no other method. + */ +export const extensionManagedBySchema = z.strictObject({ + plugin: managingPluginSchema, item: z.string().regex(/^[a-z][a-z0-9_-]{0,47}$/), active: z.boolean(), + registrations: z.array(z.enum(EXTENSION_OAUTH_REGISTRATIONS)).min(1).max(EXTENSION_OAUTH_REGISTRATIONS.length).optional(), +}); +export type ExtensionManagedBy = z.infer; +/** Fields of a managed item an administrator may change through the library. */ +export const EXTENSION_MANAGED_EDITABLE_FIELDS = ['enabled', 'repositories', 'agents', 'autoApply'] as const; + const itemLimits = { /** Empty means every repository. */ repositories: z.array(extensionRepositorySchema).max(EXTENSION_LIBRARY_LIMITS.repositories).default([]), @@ -22,6 +45,8 @@ const itemLimits = { agents: z.array(extensionAgentAliasSchema).max(EXTENSION_LIBRARY_LIMITS.agents).default([]), /** When false the item applies only where it is added explicitly. */ autoApply: z.boolean().default(true), + /** Set only by ProPR for items a plugin contributes. */ + managedBy: extensionManagedBySchema.optional(), }; export const libraryRemoteServerSchema = remoteMcpSchema.extend(itemLimits); export const libraryCatalogServerSchema = catalogMcpSchema.extend(itemLimits); @@ -150,7 +175,8 @@ const compare = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); /** Why an item does not apply to a run, or null when it does (ignoring `autoApply` and per-run limits). */ export function extensionItemSkipReason(library: LibraryView, item: ExtensionLibraryServer | ExtensionLibrarySkill, context: Omit): ExtensionSkipReason | null { - if (!item.enabled) return 'disabled'; + // A managed item of a disabled plugin, or one awaiting an acknowledgement, never applies. + if (!item.enabled || item.managedBy?.active === false) return 'disabled'; if (!(EXTENSION_RUNTIMES as readonly string[]).includes(context.runtime)) return 'runtime'; if (item.repositories.length) { const repositories = runRepositories(context.repository); @@ -266,6 +292,7 @@ export const EXTENSION_LIBRARY_CONTRACT = { packageEcosystems: EXTENSION_PACKAGE_ECOSYSTEMS, skills: ['tar', 'tar.gz', 'github-exact-commit'], permission: 'instance.manage_settings', itemFields: ['enabled', 'repositories', 'agents', 'autoApply'], + managedItemFields: EXTENSION_MANAGED_EDITABLE_FIELDS, optOutLabels: EXTENSION_OPT_OUT_LABELS, } as const; diff --git a/packages/shared/src/extensionProfiles.ts b/packages/shared/src/extensionProfiles.ts index 809705d6a6..3f5a525f96 100644 --- a/packages/shared/src/extensionProfiles.ts +++ b/packages/shared/src/extensionProfiles.ts @@ -38,7 +38,9 @@ export interface ExtensionCatalogEntry { } export interface ExtensionSkillBundle { id: string; instanceId: string; digest: string; name: string; files: number; bytes: number; - source: { kind: 'archive'; filename: string } | { kind: 'github'; repository: string; commit: string; directory: string }; + source: { kind: 'archive'; filename: string } | { kind: 'github'; repository: string; commit: string; directory: string } + /** A skill folder shipped inside an official plugin, at that plugin version. */ + | { kind: 'plugin'; plugin: string; version: string; path: string }; requirements: string[]; createdAt: number; auditActor: string; } /** diff --git a/packages/shared/src/pluginInstallations.ts b/packages/shared/src/pluginInstallations.ts index 73dd26059e..f5a755fabb 100644 --- a/packages/shared/src/pluginInstallations.ts +++ b/packages/shared/src/pluginInstallations.ts @@ -15,16 +15,38 @@ export const PLUGIN_REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; export type PluginSettingValue = string | number | boolean | string[] | Record; export type PluginSettingValues = Record; -/** `connected` once a token or signing secret is stored. OAuth connections report `not_connected` until OAuth sign-in for plugins lands. */ -export type PluginConnectionState = 'connected' | 'not_connected'; +/** + * `connected` once a token or signing secret is stored. An `oauth` connection + * is `connected` once every managed server that uses it is signed in through + * the extension OAuth sign-in, and `needs_sign_in` when a refresh failed. + */ +export type PluginConnectionState = 'connected' | 'not_connected' | 'needs_sign_in'; export interface PluginConnectionStatus { name: string; type: PluginConnectionType; label: string; state: PluginConnectionState; /** A `token` connection a contributed MCP server uses; enabling waits for it. */ required: boolean; + /** Who set the value and when; for an `oauth` connection, who configured its pre-registered client. */ setBy?: string; setAt?: number; } +/** The extension library route that lists and edits managed items. */ +export const PLUGIN_LIBRARY_HREF = '/api/instance/extensions/v1' as const; +/** The automation template catalog route. */ +export const PLUGIN_TEMPLATE_CATALOG_HREF = '/api/automation-definitions/templates' as const; +/** + * What an installed plugin added: its managed library items (with their + * library reference) and its automation templates. `active` is true while + * the item can apply to runs as far as the plugin is concerned (the plugin is + * enabled and nothing awaits an acknowledgement); the item's own enabled + * switch and limits still apply. + */ +export interface PluginContributionViews { + mcpServers: { name: string; kind: 'https' | 'package'; managed: { kind: 'server'; name: string; enabled: boolean; active: boolean } | null; href: string }[]; + skills: { name: string; managed: { kind: 'skill'; bundleId: string; enabled: boolean; active: boolean } | null; href: string }[]; + automationTemplates: { id: string; catalogId: string; available: boolean; href: string }[]; +} + /** The administrator's acknowledgement that a package server's secret variables are readable inside the task container. */ export interface PluginSecretAcknowledgement { actor: string; at: number; package: string; version: string; secretEnv: string[] } /** A package server whose secret variables need (again) an acknowledgement. */ @@ -56,9 +78,11 @@ export interface PluginView { pendingAcknowledgements: PluginPendingAcknowledgement[]; /** Why enabling is refused right now; empty when the plugin can be enabled. */ enableBlockers: string[]; + /** Managed library items and templates; items are null until the plugin is installed. */ + contributions?: PluginContributionViews; } export interface PluginAuditEvent { action: PluginAuditAction; actor: string; at: number; detail: Record } -export const PLUGIN_AUDIT_ACTIONS = ['install', 'update_settings', 'set_repositories', 'set_connection', 'clear_connection', 'enable', 'disable', 'approve', 'uninstall'] as const; +export const PLUGIN_AUDIT_ACTIONS = ['install', 'update_settings', 'set_repositories', 'set_connection', 'clear_connection', 'enable', 'disable', 'approve', 'uninstall', 'start_oauth', 'complete_oauth', 'disconnect_oauth', 'set_oauth_client', 'clear_oauth_client'] as const; export type PluginAuditAction = typeof PLUGIN_AUDIT_ACTIONS[number]; export interface PluginDetail extends PluginView { audit: PluginAuditEvent[] } diff --git a/propr-ui/src/pages/SettingsPage/extensions/ExtensionItemRow.tsx b/propr-ui/src/pages/SettingsPage/extensions/ExtensionItemRow.tsx index 546b69766a..121af2f0e8 100644 --- a/propr-ui/src/pages/SettingsPage/extensions/ExtensionItemRow.tsx +++ b/propr-ui/src/pages/SettingsPage/extensions/ExtensionItemRow.tsx @@ -1,4 +1,5 @@ import { useState, type ReactNode } from 'react'; +import type { ExtensionManagedBy } from '@propr/shared'; import { appliesTo, buttonClass, dangerButtonClass, type ItemLimitFields } from './extensionUi'; export interface ExtensionItemNote { tone: 'info' | 'warning'; text: ReactNode } @@ -8,9 +9,11 @@ const badge = 'inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-s /** * One library item. The enabled checkbox saves at once through the * single-item endpoint; a switched-off row stays, dimmed, with its settings. - * Removing asks for confirmation first. + * Removing asks for confirmation first. An item a plugin contributes is + * tagged with the plugin id and cannot be removed here: uninstalling the + * plugin removes it. */ -export function ExtensionItemRow({ name, kind, capability, item, enabled, pending, busy, notes, onToggle, onEdit, onRemove, testId, children }: { +export function ExtensionItemRow({ name, kind, capability, item, enabled, pending, busy, notes, onToggle, onEdit, onRemove, testId, managedBy, children }: { name: string; kind: string; capability?: 'read' | 'write'; @@ -24,6 +27,8 @@ export function ExtensionItemRow({ name, kind, capability, item, enabled, pendin onEdit: () => void; onRemove: () => void; testId: string; + /** Set for an item an installed plugin contributes. */ + managedBy?: ExtensionManagedBy; /** Item-specific state and actions shown under the notes, such as an OAuth connection. */ children?: ReactNode; }) { @@ -38,6 +43,8 @@ export function ExtensionItemRow({ name, kind, capability, item, enabled, pendin

{name} {!enabled && Off} + {managedBy && Plugin: {managedBy.plugin}} + {managedBy && !managedBy.active && Inactive} {kind} {capability && {capability === 'write' ? 'Write' : 'Read'}} {pending && Saving…} @@ -66,7 +73,7 @@ export function ExtensionItemRow({ name, kind, capability, item, enabled, pendin ) : ( <> - + {!managedBy && } )} diff --git a/propr-ui/src/pages/SettingsPage/extensions/ExtensionServersList.tsx b/propr-ui/src/pages/SettingsPage/extensions/ExtensionServersList.tsx index 9c6597f9ff..fdc4ed6307 100644 --- a/propr-ui/src/pages/SettingsPage/extensions/ExtensionServersList.tsx +++ b/propr-ui/src/pages/SettingsPage/extensions/ExtensionServersList.tsx @@ -45,7 +45,7 @@ export function ExtensionServersList({ state }: { state: ExtensionLibraryState } setEditing(null)} /> ) : ( - diff --git a/propr-ui/src/pages/SettingsPage/extensions/ExtensionSkillsList.tsx b/propr-ui/src/pages/SettingsPage/extensions/ExtensionSkillsList.tsx index f017612f01..0dcd036454 100644 --- a/propr-ui/src/pages/SettingsPage/extensions/ExtensionSkillsList.tsx +++ b/propr-ui/src/pages/SettingsPage/extensions/ExtensionSkillsList.tsx @@ -46,8 +46,8 @@ export function ExtensionSkillsList({ state }: { state: ExtensionLibraryState }) {skills.length === 0 && !editing &&

No skills yet.

}
    {skills.map(skill => (editing?.opened?.bundleId === skill.bundleId ?
  • {form(editing.opened)}
  • : ( - void state.toggle(skillRef(skill), nameOf(skill.bundleId), enabled)} From 69abdd165708cfe3e3b5df2583b812178808aada Mon Sep 17 00:00:00 2001 From: Rinalds Uzkalns Date: Sat, 14 Nov 2026 10:13:46 +0000 Subject: [PATCH 05/26] ci: pin hosted Linux jobs to ubuntu-24.04 GitHub moved ubuntu-latest to Ubuntu 26.04. rpmbuild there fails the desktop RPM build (%install cannot find the staged usr/ directory), so rpm-maker.test.ts and rpm-signing.test.mjs fail in every full test suite run. Co-Authored-By: Claude Fable 5.2 --- .github/workflows/cli-node-compatibility.yml | 8 ++++---- .github/workflows/codeql.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- .../desktop-connect-discovery-guard.yml | 4 ++-- .github/workflows/desktop-release-guard.yml | 14 +++++++------- .github/workflows/docker-images.yml | 10 +++++----- .github/workflows/hosted-ui-deploy.yml | 2 +- .github/workflows/pr-build-check.yml | 14 +++++++------- .github/workflows/pr-preview.yml | 2 +- .github/workflows/pr-test-on-label.yml | 14 +++++++------- .github/workflows/test-nightly.yml | 6 +++--- CHANGELOG.md | 4 ++++ .../scripts/linux-preview-workflow.test.mjs | 2 +- docs/ci-runners.md | 9 +++++++++ test/ciFullSuiteSelection.test.mjs | 2 +- test/ciHostedCapacity.test.mjs | 4 ++-- test/ciRunnerRouting.test.mjs | 16 ++++++++-------- 17 files changed, 64 insertions(+), 51 deletions(-) diff --git a/.github/workflows/cli-node-compatibility.yml b/.github/workflows/cli-node-compatibility.yml index 55baf901aa..730bae728d 100644 --- a/.github/workflows/cli-node-compatibility.yml +++ b/.github/workflows/cli-node-compatibility.yml @@ -12,7 +12,7 @@ concurrency: jobs: classify: name: Classify changed surfaces - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read @@ -40,7 +40,7 @@ jobs: route: name: Select PR check runners if: ${{ vars.PROPR_ROOTLESS_PR_CHECKS == 'overflow' }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: contents: read @@ -71,7 +71,7 @@ jobs: # leaves the output empty, which runs the matrix. if: ${{ !cancelled() && needs.classify.outputs.cli != 'false' }} # Rootless opt-in (always, or overflow when hosted runners are saturated); approval-based trust, not strict admission control. See docs/ci-runners.md. - runs-on: ${{ fromJSON((vars.PROPR_ROOTLESS_PR_CHECKS == 'true' || (vars.PROPR_ROOTLESS_PR_CHECKS == 'overflow' && needs.route.outputs.overflow == 'true')) && github.actor != 'dependabot[bot]' && ((github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))) && '["self-hosted","Linux","X64","propr-rootless"]' || '["ubuntu-latest"]') }} + runs-on: ${{ fromJSON((vars.PROPR_ROOTLESS_PR_CHECKS == 'true' || (vars.PROPR_ROOTLESS_PR_CHECKS == 'overflow' && needs.route.outputs.overflow == 'true')) && github.actor != 'dependabot[bot]' && ((github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))) && '["self-hosted","Linux","X64","propr-rootless"]' || '["ubuntu-24.04"]') }} strategy: fail-fast: false matrix: @@ -168,7 +168,7 @@ jobs: name: CLI Node Compatibility Guard needs: [classify, project-options] if: ${{ !cancelled() }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: contents: read diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e0217807f5..7cffc1d3e0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -20,7 +20,7 @@ concurrency: jobs: analyze: name: Analyze JavaScript and TypeScript - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 30 steps: diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index a838d899f5..ea969715da 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -14,7 +14,7 @@ concurrency: jobs: review: name: Review dependency changes - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: diff --git a/.github/workflows/desktop-connect-discovery-guard.yml b/.github/workflows/desktop-connect-discovery-guard.yml index d38ca8c29d..cb968fcf17 100644 --- a/.github/workflows/desktop-connect-discovery-guard.yml +++ b/.github/workflows/desktop-connect-discovery-guard.yml @@ -27,7 +27,7 @@ concurrency: jobs: classify: name: Classify changed surfaces - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read @@ -191,7 +191,7 @@ jobs: name: Packaged Connect Discovery Guard needs: [classify, packaged-connect-discovery] if: ${{ !cancelled() }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: contents: read diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml index e82ad3db02..6f39f5839f 100644 --- a/.github/workflows/desktop-release-guard.yml +++ b/.github/workflows/desktop-release-guard.yml @@ -35,7 +35,7 @@ jobs: classify: name: Classify changed surfaces if: github.event_name == 'pull_request' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read @@ -108,7 +108,7 @@ jobs: if: >- ${{ !cancelled() && github.event_name != 'push' && needs.classify.outputs.desktop != 'false' }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: version: ${{ steps.version.outputs.version }} release_sha: ${{ github.sha }} @@ -639,7 +639,7 @@ jobs: name: Finalize unsigned validation checksums if: ${{ !cancelled() && github.event_name != 'push' }} needs: [classify, validation-version, package] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - name: Resolve desktop packaging applicability @@ -710,7 +710,7 @@ jobs: preflight: name: Protected read-only trusted release preflight if: github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref_name, 'desktop-v') - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 environment: name: desktop-release-preflight permissions: @@ -1099,7 +1099,7 @@ jobs: name: Revalidate production architectures and finalize checksums if: needs.preflight.result == 'success' needs: [preflight, release-package] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - name: Checkout exact immutable release SHA @@ -1142,7 +1142,7 @@ jobs: name: Sign trusted update metadata if: needs.preflight.result == 'success' needs: [preflight, release-finalize] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 environment: name: desktop-release @@ -1204,7 +1204,7 @@ jobs: name: Publish new immutable desktop release if: needs.preflight.result == 'success' needs: [preflight, sign] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: contents: write diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index aa7f43f412..acbe542c79 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -31,7 +31,7 @@ permissions: jobs: validate-release: name: Validate Release - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 # The unsharded full server and UI suites alone exceed an hour on a hosted # runner (945 units); 60 minutes cancelled the v0.9.0 release mid-suite. timeout-minutes: 150 @@ -118,7 +118,7 @@ jobs: prepare-cli: name: Build and Smoke Test CLI needs: validate-release - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: contents: read @@ -165,7 +165,7 @@ jobs: preflight-cli: name: Preflight npm Release Artifact needs: [validate-release, prepare-cli] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: contents: read @@ -340,7 +340,7 @@ jobs: name: Publish CLI with provenance if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' needs: [validate-release, prepare-cli, build-smoke-and-push] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: contents: read @@ -395,7 +395,7 @@ jobs: name: Publish GitHub release if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' needs: [validate-release, build-smoke-and-push, publish-cli] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: contents: write diff --git a/.github/workflows/hosted-ui-deploy.yml b/.github/workflows/hosted-ui-deploy.yml index 52038612f3..b884f136eb 100644 --- a/.github/workflows/hosted-ui-deploy.yml +++ b/.github/workflows/hosted-ui-deploy.yml @@ -25,7 +25,7 @@ permissions: jobs: deploy: name: Deploy ${{ inputs.release_tag }} to app.propr.dev - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 30 # The Cloudflare token lives only in this protected environment, so no # build, PR or other release job can read it. Its deployment policy allows diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index d8a47e57c7..2a6b3ba20e 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -14,7 +14,7 @@ jobs: # test/ciChangeClassification.test.mjs; nothing in this file restates them. classify: name: Classify changed surfaces - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read @@ -45,7 +45,7 @@ jobs: route: name: Select PR check runners if: ${{ vars.PROPR_ROOTLESS_PR_CHECKS == 'overflow' }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: contents: read @@ -77,7 +77,7 @@ jobs: # superseded-run cancellation intact. if: ${{ !cancelled() && needs.classify.outputs.cli != 'false' }} # Rootless opt-in (always, or overflow when hosted runners are saturated); approval-based trust, not strict admission control. See docs/ci-runners.md. - runs-on: ${{ fromJSON((vars.PROPR_ROOTLESS_PR_CHECKS == 'true' || (vars.PROPR_ROOTLESS_PR_CHECKS == 'overflow' && needs.route.outputs.overflow == 'true')) && github.actor != 'dependabot[bot]' && ((github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))) && '["self-hosted","Linux","X64","propr-rootless"]' || '["ubuntu-latest"]') }} + runs-on: ${{ fromJSON((vars.PROPR_ROOTLESS_PR_CHECKS == 'true' || (vars.PROPR_ROOTLESS_PR_CHECKS == 'overflow' && needs.route.outputs.overflow == 'true')) && github.actor != 'dependabot[bot]' && ((github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))) && '["self-hosted","Linux","X64","propr-rootless"]' || '["ubuntu-24.04"]') }} timeout-minutes: 30 permissions: contents: read @@ -204,7 +204,7 @@ jobs: name: CLI Agent Skill (Linux x64, glibc 2.31, Node 22) needs: classify if: ${{ !cancelled() && needs.classify.outputs.cli != 'false' }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 container: image: node:22.22.3-bullseye-slim@sha256:f81e523790f0a2c86827ff6356a28fad2edbe0fe36bc3c4275a4d1e16304bf43 permissions: @@ -446,7 +446,7 @@ jobs: needs: route if: ${{ !cancelled() }} # Rootless opt-in (always, or overflow when hosted runners are saturated); approval-based trust, not strict admission control. See docs/ci-runners.md. - runs-on: ${{ fromJSON((vars.PROPR_ROOTLESS_PR_CHECKS == 'true' || (vars.PROPR_ROOTLESS_PR_CHECKS == 'overflow' && needs.route.outputs.overflow == 'true')) && github.actor != 'dependabot[bot]' && ((github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))) && '["self-hosted","Linux","X64","propr-rootless"]' || '["ubuntu-latest"]') }} + runs-on: ${{ fromJSON((vars.PROPR_ROOTLESS_PR_CHECKS == 'true' || (vars.PROPR_ROOTLESS_PR_CHECKS == 'overflow' && needs.route.outputs.overflow == 'true')) && github.actor != 'dependabot[bot]' && ((github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))) && '["self-hosted","Linux","X64","propr-rootless"]' || '["ubuntu-24.04"]') }} env: ACTIONLINT_IMAGE: rhysd/actionlint@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 permissions: @@ -1169,7 +1169,7 @@ jobs: if: >- ${{ always() && needs.validate.result == 'failure' && github.event.pull_request.head.repo.full_name == github.repository }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read pull-requests: write @@ -1229,7 +1229,7 @@ jobs: - windows-connect-discovery - connect-authority-darwin if: ${{ !cancelled() }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: contents: read diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 1b37f1a4fa..e671d7e459 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -25,7 +25,7 @@ jobs: # repo before running PR code on the self-hosted runner. Fail-closed. authorize: name: Authorize label actor - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: >- github.event.action == 'labeled' && github.event.label.name == 'preview-env' && diff --git a/.github/workflows/pr-test-on-label.yml b/.github/workflows/pr-test-on-label.yml index 463a096525..487a6b145f 100644 --- a/.github/workflows/pr-test-on-label.yml +++ b/.github/workflows/pr-test-on-label.yml @@ -32,7 +32,7 @@ jobs: classify: name: Classify changed surfaces if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.draft }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read @@ -61,7 +61,7 @@ jobs: route: name: Select PR check runners if: ${{ vars.PROPR_ROOTLESS_PR_CHECKS == 'overflow' }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: contents: read @@ -96,7 +96,7 @@ jobs: needs: route if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || !github.event.pull_request.draft) }} # Rootless opt-in (always, or overflow when hosted runners are saturated); approval-based trust, not strict admission control. See docs/ci-runners.md. - runs-on: ${{ fromJSON((vars.PROPR_ROOTLESS_PR_CHECKS == 'true' || (vars.PROPR_ROOTLESS_PR_CHECKS == 'overflow' && needs.route.outputs.overflow == 'true')) && github.actor != 'dependabot[bot]' && ((github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))) && '["self-hosted","Linux","X64","propr-rootless"]' || '["ubuntu-latest"]') }} + runs-on: ${{ fromJSON((vars.PROPR_ROOTLESS_PR_CHECKS == 'true' || (vars.PROPR_ROOTLESS_PR_CHECKS == 'overflow' && needs.route.outputs.overflow == 'true')) && github.actor != 'dependabot[bot]' && ((github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))) && '["self-hosted","Linux","X64","propr-rootless"]' || '["ubuntu-24.04"]') }} timeout-minutes: 45 permissions: contents: read @@ -306,7 +306,7 @@ jobs: (github.event_name == 'workflow_dispatch' || !github.event.pull_request.draft) && needs.classify.outputs.docs != 'false' }} # Rootless opt-in (always, or overflow when hosted runners are saturated); approval-based trust, not strict admission control. See docs/ci-runners.md. - runs-on: ${{ fromJSON((vars.PROPR_ROOTLESS_PR_CHECKS == 'true' || (vars.PROPR_ROOTLESS_PR_CHECKS == 'overflow' && needs.route.outputs.overflow == 'true')) && github.actor != 'dependabot[bot]' && ((github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))) && '["self-hosted","Linux","X64","propr-rootless"]' || '["ubuntu-latest"]') }} + runs-on: ${{ fromJSON((vars.PROPR_ROOTLESS_PR_CHECKS == 'true' || (vars.PROPR_ROOTLESS_PR_CHECKS == 'overflow' && needs.route.outputs.overflow == 'true')) && github.actor != 'dependabot[bot]' && ((github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))) && '["self-hosted","Linux","X64","propr-rootless"]' || '["ubuntu-24.04"]') }} timeout-minutes: 30 permissions: contents: read @@ -412,7 +412,7 @@ jobs: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || !github.event.pull_request.draft) && needs.classify.outputs.desktop != 'false' }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: contents: read @@ -467,7 +467,7 @@ jobs: if: >- ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || !github.event.pull_request.draft) }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read @@ -577,7 +577,7 @@ jobs: ${{ always() && !cancelled() && github.event_name == 'pull_request' && needs.test.result == 'failure' && github.event.pull_request.head.repo.full_name == github.repository }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read pull-requests: write diff --git a/.github/workflows/test-nightly.yml b/.github/workflows/test-nightly.yml index 9bf0424f08..e1a588fc3f 100644 --- a/.github/workflows/test-nightly.yml +++ b/.github/workflows/test-nightly.yml @@ -253,7 +253,7 @@ jobs: native-electron: name: Nightly Native Electron (hosted) # The self-hosted runner does not provide the native sandbox/session contract. - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: contents: read @@ -320,7 +320,7 @@ jobs: name: Nightly desktop download needs: [full-tests, e2e-tests, native-electron, desktop-package, desktop-connect] if: ${{ !cancelled() }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: contents: read @@ -412,7 +412,7 @@ jobs: name: Nightly test health needs: [full-tests, e2e-tests, native-electron, desktop-package, desktop-connect, desktop-nightly-download] if: ${{ !cancelled() }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bac087166..7b7a543e1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -186,6 +186,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **CI runs on Ubuntu 24.04 again**: hosted Linux jobs name `ubuntu-24.04` + instead of `ubuntu-latest`, which GitHub moved to Ubuntu 26.04. The desktop + RPM build fails under that image's `rpmbuild`, so two desktop tests failed + in every full test suite run. - **MCP-created plans open in Studio at Review**: `create_plan` called with plan items stored the draft as `draft`, so Studio opened it at Define and showed no issues until it was published. A draft created or updated with diff --git a/apps/desktop/scripts/linux-preview-workflow.test.mjs b/apps/desktop/scripts/linux-preview-workflow.test.mjs index 2da6a2c720..d28aa830c2 100644 --- a/apps/desktop/scripts/linux-preview-workflow.test.mjs +++ b/apps/desktop/scripts/linux-preview-workflow.test.mjs @@ -151,7 +151,7 @@ test('regular desktop PR validation requires disposable-key and real RPM tests', const block = guard.slice(start, guard.indexOf('\n renderer-axe-boundary:', start)); assert.match(block, /github.event_name != 'push'/); assert.match(block, /needs.classify.outputs.desktop != 'false'/); - assert.match(block, /runs-on: ubuntu-latest/); + assert.match(block, /runs-on: ubuntu-24.04/); assert.match(block, /sudo apt-get install --yes rpm gnupg cpio/); assert.match(block, /PROPR_REQUIRE_RPM_TESTS: '1'/); assert.match(block, /node --test apps\/desktop\/scripts\/rpm-signing.test.mjs/); diff --git a/docs/ci-runners.md b/docs/ci-runners.md index b4f66b068d..6012f07d30 100644 --- a/docs/ci-runners.md +++ b/docs/ci-runners.md @@ -494,3 +494,12 @@ ProPR admits issues only by its configured trigger labels (`AI` by default) and ignores comments on issues that are not pull requests. The script refuses to apply `AI` or `llm-*` labels, so the health issue never enters ProPR's queue. Adding a trigger label by hand would still start an agent task. + +## Hosted runner image + +Hosted Linux jobs name `ubuntu-24.04` and not `ubuntu-latest`. When GitHub +moved `ubuntu-latest` to Ubuntu 26.04, `rpmbuild` there stopped building the +desktop RPM from the `electron-installer-redhat` spec (`%install` no longer +runs in the staged directory), so `apps/desktop/src/rpm-maker.test.ts` and +`apps/desktop/scripts/rpm-signing.test.mjs` failed on every run. Move to a +newer image in one change, together with the RPM packaging fix. diff --git a/test/ciFullSuiteSelection.test.mjs b/test/ciFullSuiteSelection.test.mjs index d019736e04..4597889b04 100644 --- a/test/ciFullSuiteSelection.test.mjs +++ b/test/ciFullSuiteSelection.test.mjs @@ -488,7 +488,7 @@ describe('Full Test Suite job selection', () => { assert.match(classify, /fetch-depth: 0\n/); assert.match(classify, /persist-credentials: false\n/); assert.match(classify, /permissions:\n\s+contents: read\n\s+outputs:/); - assert.match(classify, /\n {4}runs-on: ubuntu-latest\n/); + assert.match(classify, /\n {4}runs-on: ubuntu-24.04\n/); }); }); diff --git a/test/ciHostedCapacity.test.mjs b/test/ciHostedCapacity.test.mjs index 8c15443662..e17bdf56c9 100644 --- a/test/ciHostedCapacity.test.mjs +++ b/test/ciHostedCapacity.test.mjs @@ -10,7 +10,7 @@ import { decideOverflow } from '../scripts/ci-hosted-capacity.mjs'; const SCRIPT = fileURLToPath(new URL('../scripts/ci-hosted-capacity.mjs', import.meta.url)); const now = Date.parse('2026-09-28T12:00:00Z'); const ago = seconds => new Date(now - seconds * 1000).toISOString(); -const running = (count, labels = ['ubuntu-latest']) => Array.from({ length: count }, () => ({ status: 'in_progress', labels, created_at: ago(300) })); +const running = (count, labels = ['ubuntu-24.04']) => Array.from({ length: count }, () => ({ status: 'in_progress', labels, created_at: ago(300) })); describe('hosted capacity overflow decision', () => { test('stays hosted while running and planned jobs fit below the limit minus the reserve', () => { @@ -28,7 +28,7 @@ describe('hosted capacity overflow decision', () => { }); test('overflows as soon as a hosted Linux job has waited for a runner', () => { - const waiting = { status: 'queued', labels: ['ubuntu-latest'], created_at: ago(90) }; + const waiting = { status: 'queued', labels: ['ubuntu-24.04'], created_at: ago(90) }; assert.equal(decideOverflow({ jobs: [waiting], now, planned: 1 }).overflow, true); assert.equal(decideOverflow({ jobs: [{ ...waiting, created_at: ago(10) }], now, planned: 1 }).overflow, false, 'a job that was just queued is normal'); assert.equal(decideOverflow({ jobs: [{ ...waiting, labels: ['macos-15'] }], now, planned: 1 }).overflow, false, 'macOS has its own limit'); diff --git a/test/ciRunnerRouting.test.mjs b/test/ciRunnerRouting.test.mjs index 56d8e44bd0..d071aa6562 100644 --- a/test/ciRunnerRouting.test.mjs +++ b/test/ciRunnerRouting.test.mjs @@ -586,7 +586,7 @@ describe('PR check routing', () => { ]; for (const [vars, context, needs, selfHosted] of [...cases.map(([vars, context, selfHosted]) => [vars, context, {}, selfHosted]), ...overflowCases]) { assert.deepEqual(evaluate(vars, context, needs, JSON.parse, (pattern, value) => pattern.replace('{0}', value)), - selfHosted ? ['self-hosted', 'Linux', 'X64', 'propr-rootless'] : ['ubuntu-latest'], JSON.stringify({ vars, context, needs })); + selfHosted ? ['self-hosted', 'Linux', 'X64', 'propr-rootless'] : ['ubuntu-24.04'], JSON.stringify({ vars, context, needs })); } }); @@ -597,7 +597,7 @@ describe('PR check routing', () => { const workflow = readWorkflow(file); const route = jobBlock(workflow, 'route'); assert.match(route, /\n {4}if: \$\{\{ vars\.PROPR_ROOTLESS_PR_CHECKS == 'overflow' \}\}\n/, `${file} checks capacity only in overflow mode`); - assert.match(route, /\n {4}runs-on: ubuntu-latest\n/, file); + assert.match(route, /\n {4}runs-on: ubuntu-24.04\n/, file); assert.match(route, /permissions:\n\s+contents: read\n\s+actions: read\n\s+outputs:/, `${file} reads Actions data only`); assert.match(route, /persist-credentials: false/, file); assert.match(route, /run: node scripts\/ci-hosted-capacity\.mjs\n/, file); @@ -627,7 +627,7 @@ describe('PR check routing', () => { // gated on the shared classifier; test/ciFullSuiteSelection.test.mjs // evaluates the full condition. assert.match(jobBlock(fullSuite, 'docs'), /\n {10}\(github\.event_name == 'workflow_dispatch' \|\| !github\.event\.pull_request\.draft\) &&\n/); - for (const job of ['classify', 'route', 'native-electron', 'test', 'comment']) assert.match(jobBlock(fullSuite, job), /\n {4}runs-on: ubuntu-latest\n/); + for (const job of ['classify', 'route', 'native-electron', 'test', 'comment']) assert.match(jobBlock(fullSuite, job), /\n {4}runs-on: ubuntu-24.04\n/); assert.doesNotMatch(fullSuite, /run-local-shards|LOCAL_SHARD/, 'no nested local shard coordinator'); assert.ok(!existsSync(join(REPOSITORY, 'scripts', 'run-local-shards.mjs'))); assert.doesNotMatch(fullSuite, /pull_request_target/); @@ -847,15 +847,15 @@ describe('PR check routing', () => { assert.match(container, /--network none --memory 1g --memory-swap 1g --cpus 1 --pids-limit 256/, 'tool containers retain individual limits in addition to the per-user cap'); } const expected = { - 'cli-agent-skill-glibc-231': 'ubuntu-latest', + 'cli-agent-skill-glibc-231': 'ubuntu-24.04', 'cli-agent-skill-darwin': 'macos-15', 'windows-connect-discovery': 'windows-2025', 'connect-authority-darwin': 'macos-15', - comment: 'ubuntu-latest', + comment: 'ubuntu-24.04', // Selection and its fail-closed aggregate are cheap hosted jobs. - classify: 'ubuntu-latest', - route: 'ubuntu-latest', - 'compatibility-guard': 'ubuntu-latest', + classify: 'ubuntu-24.04', + route: 'ubuntu-24.04', + 'compatibility-guard': 'ubuntu-24.04', }; assert.deepEqual(jobNames(buildCheck).sort(), [...Object.keys(expected), 'validate', 'cli-node-matrix'].sort()); for (const [job, runner] of Object.entries(expected)) { From 19acf4cd170faccbaed8e72d291cf2cb0d192e2d Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <213159723+propr-dev[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 13:26:43 +0000 Subject: [PATCH 06/26] I've built the Plugins page for #3545. In a browser test, the example plugin goes through install, connect, limit to a repository, enable, disable and uninstall entirely from the page, and the state badge changes after each step. Tests: typecheck and zero-warning lint pass for `propr-ui`. The full UI suite passes (349 files, 3213 tests), and so does the new Playwright spec `e2e/plugins.pw.ts` (2 tests). The browser runs use a mocked plugin API that keeps state between steps, not a real server. No backend code changed. What's on the page (`/plugins` and `/plugins/`) - Access: only administrators with `instance.manage_settings` see it. It's in the sidebar, under More on mobile, and there's an "Open Plugins" link above Skills & MCP servers in Settings. Anyone else who opens the address gets "Administrator access required". - Catalog: one card per plugin with icon, name, description, category and state badge, a category filter and search by name. The badges are Not installed, Off, Needs connection, Enabled, Needs approval and Degraded. - Detail: four steps in fixed order, each marked Done or To do: 1. Install lists what the plugin adds in plain words from the manifest and each permission with its explanation. When a package server gets secret variables, it shows the package, exact version and variable names, and Install stays disabled until that acknowledgement is ticked. After an upgrade that needs approval, this step shows what's new and an Approve button. 2. Connect has one row per connection with the manifest's help text and link. OAuth reuses the existing Connect / Reconnect / Disconnect component from Skills & MCP servers. Tokens and signing secrets have a write-only field; once saved, the page only says who stored it and when. 3. Configure only offers Limit to repositories for now, as the issue asked. 4. Enable is one checkbox. While something is missing it can't be ticked, and the step lists the API's reasons, for example the missing required connection. - What it added lists the managed servers and skill, linking to Skills & MCP servers, and the template. - Uninstall is a separate action. Its confirmation lists what will be removed, and if the API refuses, the page shows the API's message word for word. - Safety: manifest text is shown as plain text, only `https` links are clickable, and nothing supplied by the plugin runs in the browser. Code: the API client is `propr-ui/src/api/pluginsApi.ts`, typed from the generated OpenAPI types. The page is in `propr-ui/src/pages/PluginsPage/`, split into small components that stay under the line limit. Component tests cover the catalog, each step and the error states. `docs/docs/features/plugins.md` now has a Plugins page section with a walkthrough, and `docs/docs/features/web-ui.md` has a Plugins section. Changes outside the brief: - I put the `/mcp-logs` route on one line in `App.tsx`, written the same way as the new Plugins route, so the file stays under the 400-line limit. - The Enabled checkbox shows the new state while the request is in progress. Without that, a click looked like it did nothing until the API answered. Decision for you: - Plugin icons: the API doesn't serve a plugin's icon file, so each card shows an icon chosen by category instead. Showing the real icon needs an icon route on the API. Previews of the catalog, the install acknowledgement, the blocked Enable step, uninstall with the API refusal, and two phone-width views are attached; they use mocked API data. --- docs/docs/features/plugins.md | 77 ++++- docs/docs/features/web-ui.md | 13 +- propr-ui/e2e/plugins.pw.ts | 196 +++++++++++++ propr-ui/src/App.tsx | 13 +- propr-ui/src/api/pluginsApi.ts | 63 ++++ propr-ui/src/components/Layout.tsx | 2 + .../MobileBottomNavigation.test.tsx | 13 + .../src/components/MobileBottomNavigation.tsx | 8 +- .../src/components/SidebarNavigation.test.tsx | 26 ++ propr-ui/src/components/SidebarNavigation.tsx | 8 +- propr-ui/src/pages/PluginsPage/PluginBits.tsx | 50 ++++ .../pages/PluginsPage/PluginCatalog.test.tsx | 62 ++++ .../src/pages/PluginsPage/PluginCatalog.tsx | 60 ++++ .../pages/PluginsPage/PluginConfigureStep.tsx | 44 +++ .../pages/PluginsPage/PluginConnections.tsx | 90 ++++++ .../pages/PluginsPage/PluginContributions.tsx | 41 +++ .../pages/PluginsPage/PluginDetail.test.tsx | 269 ++++++++++++++++++ .../src/pages/PluginsPage/PluginDetail.tsx | 65 +++++ .../pages/PluginsPage/PluginEnableStep.tsx | 41 +++ .../pages/PluginsPage/PluginInstallStep.tsx | 111 ++++++++ .../src/pages/PluginsPage/PluginUninstall.tsx | 43 +++ propr-ui/src/pages/PluginsPage/index.tsx | 54 ++++ .../PluginsPage/pluginFixtures.testUtils.tsx | 94 ++++++ .../src/pages/PluginsPage/pluginUi.test.ts | 37 +++ propr-ui/src/pages/PluginsPage/pluginUi.ts | 84 ++++++ .../src/pages/PluginsPage/usePluginDetail.ts | 124 ++++++++ .../SettingsPage/AgentExtensionsSection.tsx | 17 ++ .../pages/SettingsPage/settingsPermissions.ts | 4 + propr-ui/src/routeChunks.ts | 3 + 29 files changed, 1693 insertions(+), 19 deletions(-) create mode 100644 propr-ui/e2e/plugins.pw.ts create mode 100644 propr-ui/src/api/pluginsApi.ts create mode 100644 propr-ui/src/components/SidebarNavigation.test.tsx create mode 100644 propr-ui/src/pages/PluginsPage/PluginBits.tsx create mode 100644 propr-ui/src/pages/PluginsPage/PluginCatalog.test.tsx create mode 100644 propr-ui/src/pages/PluginsPage/PluginCatalog.tsx create mode 100644 propr-ui/src/pages/PluginsPage/PluginConfigureStep.tsx create mode 100644 propr-ui/src/pages/PluginsPage/PluginConnections.tsx create mode 100644 propr-ui/src/pages/PluginsPage/PluginContributions.tsx create mode 100644 propr-ui/src/pages/PluginsPage/PluginDetail.test.tsx create mode 100644 propr-ui/src/pages/PluginsPage/PluginDetail.tsx create mode 100644 propr-ui/src/pages/PluginsPage/PluginEnableStep.tsx create mode 100644 propr-ui/src/pages/PluginsPage/PluginInstallStep.tsx create mode 100644 propr-ui/src/pages/PluginsPage/PluginUninstall.tsx create mode 100644 propr-ui/src/pages/PluginsPage/index.tsx create mode 100644 propr-ui/src/pages/PluginsPage/pluginFixtures.testUtils.tsx create mode 100644 propr-ui/src/pages/PluginsPage/pluginUi.test.ts create mode 100644 propr-ui/src/pages/PluginsPage/pluginUi.ts create mode 100644 propr-ui/src/pages/PluginsPage/usePluginDetail.ts diff --git a/docs/docs/features/plugins.md b/docs/docs/features/plugins.md index dde1d20ced..aa4e2e0503 100644 --- a/docs/docs/features/plugins.md +++ b/docs/docs/features/plugins.md @@ -13,11 +13,10 @@ npm package of a local MCP server, which goes through the same package cache and integrity check as the [extension library's local package servers](./agent-extensions.md#local-package-servers). :::note Work in progress -This page describes the plugin **manifest**, the official plugin registry and -how an administrator **installs** a plugin through the API. The Plugins page, -applying contributions to runs, OAuth connections, handlers, webhooks and -commands are added by later releases; until then an installed plugin's state -is stored and served but has no effect on runs. +This page describes the plugin **manifest**, the official plugin registry, the +[Plugins page](#the-plugins-page) and the installation API. A plugin's +generated settings form, handlers, webhooks and commands are added by later +releases. ::: ## Where plugins live @@ -168,6 +167,74 @@ npm run plugins:check -- --online [--registry https://registry.npmjs.org] also asks the npm registry for each package server's version and compares the recorded `integrity` value. It needs network access and is not run in CI. +## The Plugins page + +**Plugins** (`/plugins`) is where an administrator installs a plugin and makes +it work without knowing what it contributes internally. It is in the sidebar +(on mobile, under **More**) and linked from **Settings → Agent Extensions & +Packages → Skills & MCP servers**, and only durable administrators with +`instance.manage_settings` see it. Everything on the page is drawn by ProPR +from the manifest: manifest text is shown as plain text, links appear only for +`https` URLs, and no code, image or markup supplied by the plugin runs in the +browser. + +### Catalog + +The catalog shows one card per plugin: an icon for its category, its name, +one-line description, category and a state badge. Filter by **Category** or +type in **Search by name**. + +| Badge | Meaning | +| --- | --- | +| Not installed | Available in this ProPR, not installed. | +| Off | Installed, every connection set, not enabled. | +| Needs connection | Installed and not enabled, with a connection still to set or sign in. | +| Enabled | Enabled, every connection set. | +| Needs approval | An upgrade asks for permissions or a secret acknowledgement not yet approved. | +| Degraded | Enabled while a connection is missing or needs a new sign-in, or no longer in this ProPR's registry. | + +### Walkthrough + +Open a card to see the plugin's steps, always in this order, each marked +**Done** or **To do**: + +1. **Install.** The step says what the plugin adds in plain words, for example + "Adds 1 MCP server the agent can read from", "Runs 1 local server inside + the task container" or "Adds the /deploy command", and lists the + permissions it asks for, each with a one-line explanation. When a local + package server receives secret variables, the step shows the package, its + exact version and the variable names, with a required checkbox confirming + that those secrets are readable inside the task container. **Install** + stays unavailable until it is ticked. After an upgrade that needs approval, + the step lists what is new and offers **Approve**. +2. **Connect.** One row per connection with its state, the manifest's help text + and a **How to get it** link. An OAuth connection has **Connect**, + **Reconnect** and **Disconnect**, the same controls as an OAuth server in + Skills & MCP servers; sign-in opens in a new tab and the page refreshes when + you come back. A `token` or `signing_secret` connection has a write-only + field: once saved, the page only says who stored it and when, and offers + **Replace** and **Clear**. A required connection cannot be cleared while + the plugin is enabled. +3. **Configure.** **Limit to repositories** picks the repositories the plugin + applies in; leave every box unchecked for all repositories. The form for + the plugin's own settings is added by a later release. +4. **Enable.** A single **Enabled** checkbox. While a required connection, + required setting or secret acknowledgement is missing it cannot be ticked, + and the step lists what is missing, for example "Connection Example API + token (example-token) is not set". Untick it to disable the plugin; its + settings and connections are kept. + +Below the steps, **What it added** lists the plugin's managed MCP servers and +skills, with a link to Skills & MCP servers where they are listed, and its +automation templates, each with whether it applies now. + +**Uninstall** is a separate action. Its confirmation lists what is removed: +the settings and repository limit, each connection and stored credential, the +managed library items and the templates. The audit trail is kept. When an +automation still adds one of the plugin's managed items, the API refuses the +uninstall and the page shows its message, which names the automation; remove +the item from that automation and try again. + ## Installing a plugin A plugin is **available** when it is in this ProPR's registry, **installed** diff --git a/docs/docs/features/web-ui.md b/docs/docs/features/web-ui.md index 816eebeb2b..3ff06510f8 100644 --- a/docs/docs/features/web-ui.md +++ b/docs/docs/features/web-ui.md @@ -9,7 +9,7 @@ The ProPR Web UI configures repositories and agents, launches work, and shows pr ## Navigation And Chrome -The sidebar's work area contains **Dashboard**, **Inbox**, **Tasks**, **Goals**, **Automations** and **Plans**. The system area contains **Repositories**, **Coding Agents** (administrators), **Analytics**, the collapsible **Logs** group, **Settings** and **Access** (administrators). Count badges show current work and unread Inbox items. Agent Tank usage appears below navigation when enabled. +The sidebar's work area contains **Dashboard**, **Inbox**, **Tasks**, **Goals**, **Automations** and **Plans**. The system area contains **Repositories**, **Coding Agents** (administrators), **Analytics**, the collapsible **Logs** group, **Settings**, **Plugins** and **Access** (administrators). Count badges show current work and unread Inbox items. Agent Tank usage appears below navigation when enabled. The header includes search (`Cmd/Ctrl+K`), activity/review controls, quick add to-do (`Alt+T`) and system health. Search opens a command palette that groups matching repositories, plans and tasks, with a preview of the highlighted item beside the list on wider screens. Tab and Shift+Tab switch between the **All**, **Repos**, **Plans** and **Tasks** tabs, Enter opens the highlighted item, Cmd/Ctrl+Enter opens it on GitHub, and **Search all tasks** runs the term as a full Tasks search. **New Task** is the default creation action. It becomes **New Plan** in Plans/Planner Studio, **New Goal** in Goals and **New Automation** in Automations; the adjacent menu offers the other actions. See [Launching work](./launching-work.md) to choose between them. After quick add confirms **To-Do added**, choose **Add another** to enter the next item without closing the popover. Desktop puts application information in **About ProPR** rather than the sidebar footer. @@ -122,6 +122,17 @@ for the operator walkthrough and the log's filters. ![Settings AI and Models tab showing implementation and planning model controls in a single column](/img/screenshots/0.9.0/settings.png) +## Plugins + +**Plugins** (`/plugins`) installs and manages [plugins](./plugins.md#the-plugins-page). It appears in the sidebar (on mobile, under **More**) and as an **Open Plugins** link above **Skills & MCP servers** in Settings, only for durable administrators with `instance.manage_settings`; anyone else who opens the address sees *Administrator access required*. + +1. The catalog shows a card per plugin with its name, description, category and a state badge (**Not installed**, **Enabled**, **Off**, **Needs connection**, **Needs approval** or **Degraded**). Filter by category or search by name, then open a card. +2. **Install** lists what the plugin adds and the permissions it asks for. If a local package server receives secret variables, tick the acknowledgement that names its package, version and variables, then choose **Install**. +3. **Connect** has one row per connection: **Connect** for an OAuth sign-in (it opens in a new tab), or a write-only field for an API token or signing secret. +4. **Configure** offers **Limit to repositories**; leave it empty to apply everywhere. +5. Tick **Enabled**. While something is missing, the checkbox is unavailable and the step lists what to do first. Untick it to disable the plugin and keep its settings. +6. **What it added** links the plugin's managed items in Skills & MCP servers. **Uninstall…** lists what will be removed before you confirm, and shows the API's message if an automation still uses one of the plugin's items. + ## Access **Access** (`/admin/members`) is available to administrators. It creates durable `admin` and `member` role assignments using stable GitHub user IDs, shows environment administrators configured through `PROPR_ADMIN_USERS`, surfaces recent role-audit events, and prevents removal or demotion of the last durable administrator. On a new installation, sign in as a configured environment administrator and use **Store my administrator role** before removing that username from `PROPR_ADMIN_USERS`. diff --git a/propr-ui/e2e/plugins.pw.ts b/propr-ui/e2e/plugins.pw.ts new file mode 100644 index 0000000000..ae627a5ee5 --- /dev/null +++ b/propr-ui/e2e/plugins.pw.ts @@ -0,0 +1,196 @@ +import { expect, test, type Page } from '@playwright/test'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; + +/** + * The Plugins page end to end against a stateful plugin API fixture: the + * example plugin is installed, connected, limited to a repository, enabled, + * disabled and uninstalled entirely from the page. With PROPR_CAPTURE_PREVIEWS + * set it also captures the catalog and the detail steps at desktop and phone widths. + */ + +const NOW = Date.parse('2026-11-14T09:00:00.000Z'); +const BASE = '/api/instance/plugins/v1'; +const REFUSAL = 'Automations add items of plugin example-pack explicitly; remove them from "Weekly digest" (example-remote) before uninstalling'; + +const MANIFEST = { + id: 'example-pack', name: 'Example pack', version: '0.1.0', category: 'other', + description: 'A small development plugin that exercises every manifest section: one remote MCP server, one pinned package server, one skill and one automation template.', + homepage: 'https://github.com/integry/propr/tree/main/packages/plugins/example-pack', + permissions: [ + { name: 'events.read', description: 'Receive ProPR task, pull request and automation events' }, + { name: 'tasks.read', description: 'Read tasks and their status' }, + ], + connections: [ + { type: 'oauth', name: 'example-account', label: 'Example account', help: 'Sign in with the example service account that the remote MCP server should act as.', helpUrl: 'https://example.com/docs/oauth' }, + { type: 'token', name: 'example-token', label: 'Example API token', help: 'Create a read-only API token in the example service settings and paste it here.', helpUrl: 'https://example.com/docs/tokens' }, + ], + settings: { type: 'object', properties: {}, required: [] }, + outboundHosts: ['api.example.com'], + contributes: { + mcpServers: [ + { kind: 'https', name: 'example-remote', url: 'https://mcp.example.com/mcp', tools: ['search', 'fetch'], capability: 'read', authentication: 'oauth', connection: 'example-account' }, + { kind: 'package', name: 'example-local', ecosystem: 'npm', package: 'propr-fixture-mcp', version: '1.0.0', tools: ['echo'], capability: 'read', secretEnv: [{ name: 'FIXTURE_TOKEN', connection: 'example-token' }] }, + ], + skills: ['example-notes'], automationTemplates: ['example-weekly-digest'], commands: [], subscriptions: [], webhooks: [], + }, +}; +const OTHERS = [ + { id: 'team-chat', name: 'Team chat', category: 'messaging', description: 'Posts task and pull request updates to a chat channel.' }, + { id: 'deploy-hooks', name: 'Deploy hooks', category: 'deployment', description: 'Adds the /deploy command and reports deployment status.' }, +]; + +interface State { installed: boolean; enabled: boolean; token: boolean; oauth: boolean; repositories: string[] | null; refuseUninstall: boolean } + +function view(state: State) { + const active = state.enabled; + const library = '/api/instance/extensions/v1'; + return { + id: 'example-pack', available: true, manifest: MANIFEST, + state: !state.installed ? 'available' : state.enabled ? 'enabled' : 'installed', + installation: state.installed ? { + installedVersion: '0.1.0', enabled: state.enabled, settings: {}, repositories: state.repositories, grantedPermissions: ['events.read', 'tasks.read'], + acknowledgements: { 'example-local': { actor: '1042', at: NOW, package: 'propr-fixture-mcp', version: '1.0.0', secretEnv: ['FIXTURE_TOKEN'] } }, + installedBy: '1042', installedAt: NOW, updatedBy: '1042', updatedAt: NOW, + } : null, + connections: [ + { name: 'example-account', type: 'oauth', label: 'Example account', state: state.oauth ? 'connected' : 'not_connected', required: false }, + { name: 'example-token', type: 'token', label: 'Example API token', state: state.token ? 'connected' : 'not_connected', required: true, ...(state.token ? { setBy: '1042', setAt: NOW } : {}) }, + ], + needsApproval: false, pendingPermissions: [], pendingAcknowledgements: [], + enableBlockers: state.installed && !state.token ? ['Connection Example API token (example-token) is not set'] : [], + contributions: { + mcpServers: [ + { name: 'example-remote', kind: 'https', managed: state.installed ? { kind: 'server', name: 'example-remote', enabled: true, active } : null, href: library }, + { name: 'example-local', kind: 'package', managed: state.installed ? { kind: 'server', name: 'example-local', enabled: true, active } : null, href: library }, + ], + skills: [{ name: 'example-notes', managed: state.installed ? { kind: 'skill', bundleId: 'bundle-1', enabled: true, active } : null, href: library }], + automationTemplates: [{ id: 'example-weekly-digest', catalogId: 'example-weekly-digest', available: state.enabled, href: '/api/automation-definitions/templates' }], + }, + }; +} +const otherView = (other: typeof OTHERS[number], enabled: boolean) => ({ + ...view({ installed: enabled, enabled, token: true, oauth: true, repositories: null, refuseUninstall: false }), + id: other.id, manifest: { ...MANIFEST, ...other, connections: [], contributes: { ...MANIFEST.contributes, mcpServers: [] } }, connections: [], +}); + +async function installFixture(page: Page, state: State): Promise { + await page.routeWebSocket('**/socket.io/**', socket => socket.close()); + await page.route('**/api/**', async route => { + const request = route.request(); + const { pathname } = new URL(request.url()); + const method = request.method(); + if (method !== 'GET' && request.headers()['x-propr-plugins'] !== '1') return route.fulfill({ status: 403, json: { error: 'Plugin changes require the preflight-protected JSON contract' } }); + const body = method === 'GET' ? {} : request.postDataJSON() as Record; + const reply = () => route.fulfill({ json: { plugin: view(state) } }); + if (pathname === BASE) return route.fulfill({ json: { plugins: [view(state), otherView(OTHERS[0], true), otherView(OTHERS[1], false)] } }); + if (pathname === `${BASE}/example-pack`) return route.fulfill({ json: { plugin: { ...view(state), audit: [] } } }); + if (pathname === `${BASE}/example-pack/install`) { + if (!(body.acknowledgements as string[] | undefined)?.includes('example-local')) return route.fulfill({ status: 400, json: { error: 'Confirm that the secret variables of example-local are readable' } }); + state.installed = true; return reply(); + } + if (pathname === `${BASE}/example-pack/connections/example-token`) { state.token = body.value !== null; return reply(); } + if (pathname === `${BASE}/example-pack/repositories`) { state.repositories = body.repositories as string[] | null; return reply(); } + if (pathname === `${BASE}/example-pack/enabled`) { state.enabled = body.enabled === true; return reply(); } + if (pathname === `${BASE}/example-pack/uninstall`) { + if (state.refuseUninstall) { state.refuseUninstall = false; return route.fulfill({ status: 409, json: { error: REFUSAL } }); } + Object.assign(state, { installed: false, enabled: false, token: false, oauth: false, repositories: null }); return reply(); + } + const responses: Record = { + '/api/auth/demo-mode': { demoMode: false }, + '/api/auth/user': { + id: '1042', login: 'preview', username: 'preview', displayName: 'Preview Operator', email: null, avatarUrl: null, role: 'admin', + permissions: ['instance.manage_settings', 'instance.manage_members', 'instance.manage_agents'], authorizationSource: 'local', + }, + '/api/instance/catalog': { agents: [], repositories: [{ name: 'integry/propr' }, { name: 'integry/propr-docs' }] }, + '/api/instance/extensions/v1': { oauth: { redirectUri: 'https://propr.example.com/api/instance/extensions/v1/oauth/callback', clientMetadataUrl: null, connections: {} } }, + '/api/notifications/unread-count': { unreadCount: 0 }, + '/api/notifications/config': { push: { configured: false, vapidPublicKey: null } }, + }; + if (pathname in responses) return route.fulfill({ json: responses[pathname] }); + return route.fulfill({ status: 503, json: { error: 'Unavailable in the plugins fixture' } }); + }); +} + +/** The app scrolls inside its own container, so a capture scrolls the part it shows into view first. */ +async function capture(page: Page, name: string, focus?: ReturnType): Promise { + if (!process.env.PROPR_CAPTURE_PREVIEWS) return; + if (focus) await focus.evaluate(element => element.scrollIntoView({ block: 'start' })); + const directory = path.resolve('../.propr/previews'); + await mkdir(directory, { recursive: true }); + await page.screenshot({ animations: 'disabled', path: path.join(directory, `${name}.png`) }); +} + +const step = (page: Page, title: string) => page.getByRole('region', { name: new RegExp(`^\\d\\. ${title}`) }); +const badge = (page: Page) => page.getByRole('heading', { level: 1 }).getByTestId('plugin-state'); + +test('installs, connects, limits, enables, disables and uninstalls the example plugin from the page', async ({ page }) => { + const state: State = { installed: false, enabled: false, token: false, oauth: false, repositories: null, refuseUninstall: true }; + await page.setViewportSize({ width: 1440, height: 900 }); + await installFixture(page, state); + await page.goto('/plugins'); + + await expect(page.getByRole('navigation').getByRole('link', { name: 'Plugins' })).toHaveAttribute('href', '/plugins'); + await expect(page.getByTestId('plugin-card')).toHaveCount(3); + await capture(page, 'plugins-catalog-desktop'); + await page.getByLabel('Category').selectOption('messaging'); + await expect(page.getByTestId('plugin-card')).toHaveCount(1); + await page.getByLabel('Category').selectOption(''); + await page.getByRole('link', { name: 'Example pack: open' }).click(); + + const install = step(page, 'Install'); + await expect(install.getByRole('button', { name: 'Install' })).toBeDisabled(); + await expect(install.getByTestId('plugin-secret-acknowledgement')).toContainText('FIXTURE_TOKEN'); + await capture(page, 'plugins-install-step-desktop', install.getByText('Permissions it asks for')); + await install.getByRole('checkbox', { name: /propr-fixture-mcp@1\.0\.0/ }).check(); + await install.getByRole('button', { name: 'Install' }).click(); + await expect(badge(page)).toHaveText('Needs connection'); + + const enable = step(page, 'Enable'); + await expect(enable.getByRole('checkbox', { name: /Enabled/ })).toBeDisabled(); + await expect(enable.getByRole('status')).toContainText('Connection Example API token (example-token) is not set'); + await capture(page, 'plugins-connect-and-enable-blocked-desktop', step(page, 'Connect')); + + const token = step(page, 'Connect').getByTestId('plugin-connection').nth(1); + await token.getByLabel('Example API token').fill('tok_preview_value'); + await token.getByRole('button', { name: 'Save' }).click(); + await expect(token).toContainText('Stored by administrator 1042'); + await expect(token.getByLabel('Replace Example API token')).toHaveValue(''); + + const configure = step(page, 'Configure'); + await configure.getByRole('checkbox', { name: 'integry/propr', exact: true }).check(); + await configure.getByRole('button', { name: 'Save repositories' }).click(); + await expect(page.getByTestId('plugin-repository-limit')).toHaveText('Limited to integry/propr.'); + expect(state.repositories).toEqual(['integry/propr']); + + await enable.getByRole('checkbox', { name: /Enabled/ }).check(); + await expect(badge(page)).toHaveText('Degraded'); // the OAuth account is still to sign in + await expect(page.getByTestId('plugin-contributions')).toContainText('MCP server example-remote · Active'); + state.oauth = true; + await page.reload(); + await expect(badge(page)).toHaveText('Enabled'); + await capture(page, 'plugins-enabled-desktop', step(page, 'Configure')); + + await step(page, 'Enable').getByRole('checkbox', { name: /Enabled/ }).uncheck(); + await expect(badge(page)).toHaveText('Off'); + + await page.getByRole('button', { name: 'Uninstall…' }).click(); + await expect(page.getByTestId('plugin-uninstall-confirm')).toContainText('Skill example-notes from Skills & MCP servers'); + await page.getByRole('button', { name: 'Uninstall Example pack' }).click(); + await expect(page.getByTestId('plugin-uninstall-error')).toHaveText(REFUSAL); + await capture(page, 'plugins-uninstall-refused-desktop', page.getByTestId('plugin-contributions')); + await page.getByRole('button', { name: 'Uninstall Example pack' }).click(); + await expect(badge(page)).toHaveText('Not installed'); +}); + +test('keeps the plugin steps usable on a phone', async ({ page }) => { + const state: State = { installed: true, enabled: false, token: false, oauth: false, repositories: null, refuseUninstall: false }; + await page.setViewportSize({ width: 390, height: 844 }); + await installFixture(page, state); + await page.goto('/plugins/example-pack'); + await expect(badge(page)).toHaveText('Needs connection'); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow).toBeLessThanOrEqual(0); + await capture(page, 'plugins-detail-phone'); + await capture(page, 'plugins-enable-blocked-phone', step(page, 'Enable')); +}); diff --git a/propr-ui/src/App.tsx b/propr-ui/src/App.tsx index 7b795925b0..c26ced19ca 100644 --- a/propr-ui/src/App.tsx +++ b/propr-ui/src/App.tsx @@ -45,6 +45,7 @@ import { LoginPage, McpLogsPage, PlansPage, + PluginsPage, PlanStudioPage, RepositoriesPage, RevertPage, @@ -280,6 +281,7 @@ const AppContent: React.FC = () => { } /> + } /> { } /> - - - - - - } - /> + } /> ; +export type PluginConnectionView = PluginView['connections'][number]; + +/** The refusal text the API returned, kept so the page can show it verbatim. */ +export class PluginApiError extends Error { + constructor(readonly status: number, message: string) { super(message); this.name = 'PluginApiError'; } +} + +async function request(path: string, init?: { method: 'POST' | 'PUT'; body: unknown }): Promise { + const response = await apiFetch(`${API_BASE_URL}${PLUGIN_API_BASE}${path}`, { + credentials: 'include', cache: 'no-store', + ...(init ? { method: init.method, headers: { 'Content-Type': 'application/json', 'X-ProPR-Plugins': '1' }, body: JSON.stringify(init.body) } : {}), + }); + if (response.ok) return response.json() as Promise; + const failure = await response.json().catch(() => null) as { code?: string; message?: string; error?: string } | null; + // The read-only demo explains why plugins are not shown. + if (response.status === 403 && failure?.code === 'DEMO_MODE_LIVE_DATA_UNAVAILABLE' && failure.message) throw new PluginApiError(403, failure.message); + if (typeof failure?.error === 'string' && failure.error) throw new PluginApiError(response.status, failure.error); + if (response.status === 403) throw new PluginApiError(403, 'A current durable instance administrator is required.'); + throw new PluginApiError(response.status, 'Plugin operation unavailable. Check the configuration and try again.'); +} + +const segment = (value: string) => `/${encodeURIComponent(value)}`; +const plugin = (id: string, rest = '') => `${segment(id)}${rest}`; +const connection = (id: string, name: string, rest = '') => plugin(id, `/connections${segment(name)}${rest}`); +type Plugin = Promise; + +export const listPlugins = () => request(''); +export const getPlugin = (id: string) => request(plugin(id)); +/** `acknowledgements` names the package servers whose secret variables the administrator confirmed are readable in the task container. */ +export const installPlugin = (id: string, acknowledgements: string[]): Plugin => + request(plugin(id, '/install'), { method: 'POST', body: acknowledgements.length ? { acknowledgements } : {} }); +/** Approve permissions and acknowledgements a newer manifest asks for. */ +export const approvePlugin = (id: string, acknowledgements: string[]): Plugin => + request(plugin(id, '/approve'), { method: 'POST', body: acknowledgements.length ? { acknowledgements } : {} }); +/** `null` (or an empty list) for all repositories. */ +export const setPluginRepositories = (id: string, repositories: string[] | null): Plugin => + request(plugin(id, '/repositories'), { method: 'PUT', body: { repositories: repositories?.length ? repositories : null } satisfies ProprApi.PluginRepositoriesRequest }); +export const setPluginEnabled = (id: string, enabled: boolean): Plugin => + request(plugin(id, '/enabled'), { method: 'PUT', body: { enabled } satisfies ProprApi.PluginEnabledRequest }); +/** Set (`value`) or clear (`null`) a `token` or `signing_secret` connection. */ +export const setPluginConnection = (id: string, name: string, value: string | null): Plugin => + request(connection(id, name), { method: 'PUT', body: { value } }); +/** The uninstall must repeat the plugin id. */ +export const uninstallPlugin = (id: string): Plugin => + request(plugin(id, '/uninstall'), { method: 'POST', body: { confirm: id } satisfies ProprApi.PluginUninstallRequest }); +export const startPluginOAuth = (id: string, name: string) => + request(connection(id, name, '/oauth/start'), { method: 'POST', body: {} }); +export const disconnectPluginOAuth = (id: string, name: string): Plugin => + request(connection(id, name, '/oauth/disconnect'), { method: 'POST', body: {} }); diff --git a/propr-ui/src/components/Layout.tsx b/propr-ui/src/components/Layout.tsx index ad7d2f787d..014bbbdb9e 100644 --- a/propr-ui/src/components/Layout.tsx +++ b/propr-ui/src/components/Layout.tsx @@ -23,6 +23,7 @@ import UserAvatar from './UserAvatar'; import VoiceBriefingControl from './VoiceBriefingControl'; import { HeaderScopeSlotContext } from './headerScopeSlot'; import { SidebarNavigation, type NavigationState } from './SidebarNavigation'; +import { canManagePlugins } from '../pages/SettingsPage/settingsPermissions'; interface LayoutProps { children: React.ReactNode; @@ -75,6 +76,7 @@ const Layout: React.FC = ({ children }) => { canManageMembers: userHasPermission(user, 'instance.manage_members'), // The MCP access log needs the permission its admin endpoints require. canReadMcpLog: userHasPermission(user, 'instance.manage_settings'), + canManagePlugins: canManagePlugins(user), }; const navigationState: NavigationState = { currentPath: location.pathname, diff --git a/propr-ui/src/components/MobileBottomNavigation.test.tsx b/propr-ui/src/components/MobileBottomNavigation.test.tsx index 5763035080..603b26323d 100644 --- a/propr-ui/src/components/MobileBottomNavigation.test.tsx +++ b/propr-ui/src/components/MobileBottomNavigation.test.tsx @@ -301,6 +301,19 @@ describe('MobileBottomNavigation', () => { expect(screen.queryByRole('link', { name: 'MCP Log' })).not.toBeInTheDocument(); }); + it('offers Plugins only to administrators who may change instance settings', () => { + renderNavigation('/plugins/example-pack', vi.fn(), { ...user, permissions: ['instance.manage_settings'] }); + expect(screen.getByRole('button', { name: 'More' })).toHaveAttribute('aria-current', 'page'); + fireEvent.click(screen.getByRole('button', { name: 'More' })); + expect(screen.getByRole('link', { name: 'Plugins' })).toHaveAttribute('href', '/plugins'); + }); + + it('hides Plugins from members and from administrators without the settings permission', () => { + renderNavigation('/tasks', vi.fn(), { ...user, role: 'member', permissions: ['instance.manage_settings'] }); + fireEvent.click(screen.getByRole('button', { name: 'More' })); + expect(screen.queryByRole('link', { name: 'Plugins' })).not.toBeInTheDocument(); + }); + it('hides Access without member management permission', () => { renderNavigation('/tasks', vi.fn(), { ...user, diff --git a/propr-ui/src/components/MobileBottomNavigation.tsx b/propr-ui/src/components/MobileBottomNavigation.tsx index 0942009222..f52d513514 100644 --- a/propr-ui/src/components/MobileBottomNavigation.tsx +++ b/propr-ui/src/components/MobileBottomNavigation.tsx @@ -13,6 +13,7 @@ import { ListTodo, MoreHorizontal, Plug, + Puzzle, Plus, ScrollText, Settings, @@ -24,6 +25,7 @@ import { import type { HeaderStats } from '../hooks/useHeaderStats'; import type { CurrentUser } from '../api/proprTypes'; import { userHasPermission } from '../contexts/AuthContext'; +import { canManagePlugins } from '../pages/SettingsPage/settingsPermissions'; import { publicAssetUrl } from '../config/runtimeMode'; import AgentTankSidebar from './AgentTankSidebar'; import QuickAddTodo from './QuickAddTodo'; @@ -67,7 +69,8 @@ const getNavigationState = (pathname: string, search: string) => { pathMatches(pathname, '/repositories') || pathMatches(pathname, '/summaries') || pathMatches(pathname, '/analytics') || pathMatches(pathname, '/llm-logs') || pathMatches(pathname, '/mcp-logs') || pathMatches(pathname, '/ai-agents') || - pathMatches(pathname, '/settings') || pathMatches(pathname, '/admin/members') + pathMatches(pathname, '/settings') || pathMatches(pathname, '/admin/members') || + pathMatches(pathname, '/plugins') ), }; }; @@ -99,6 +102,9 @@ const getMoreGroups = (user: CurrentUser | null) => ({ ? [{ label: 'Coding Agents', to: '/ai-agents', icon: Bot }] : []), { label: 'Settings', to: '/settings', icon: Settings }, + ...(canManagePlugins(user) + ? [{ label: 'Plugins', to: '/plugins', icon: Puzzle }] + : []), ...(userHasPermission(user, 'instance.manage_members') ? [{ label: 'Access', to: '/admin/members', icon: ShieldCheck }] : []), diff --git a/propr-ui/src/components/SidebarNavigation.test.tsx b/propr-ui/src/components/SidebarNavigation.test.tsx new file mode 100644 index 0000000000..bf35772802 --- /dev/null +++ b/propr-ui/src/components/SidebarNavigation.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it } from 'vitest'; +import { SidebarNavigation, type NavigationState } from './SidebarNavigation'; + +const state: NavigationState = { + currentPath: '/plugins', desktop: false, hasAgents: true, hasRepos: true, hasTasks: true, + taskCount: 0, goalCount: 0, generatingPlansCount: 0, unreadCount: null, +}; +const renderSidebar = (canManagePlugins?: boolean) => render( + + + , +); + +describe('SidebarNavigation', () => { + it('links Plugins for administrators who may manage plugins', () => { + renderSidebar(true); + expect(screen.getByRole('link', { name: /Plugins/ })).toHaveAttribute('href', '/plugins'); + }); + + it('hides Plugins from everyone else', () => { + renderSidebar(false); + expect(screen.queryByRole('link', { name: /Plugins/ })).not.toBeInTheDocument(); + }); +}); diff --git a/propr-ui/src/components/SidebarNavigation.tsx b/propr-ui/src/components/SidebarNavigation.tsx index 0166d03b61..336a2c23cd 100644 --- a/propr-ui/src/components/SidebarNavigation.tsx +++ b/propr-ui/src/components/SidebarNavigation.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { LayoutDashboard, ScrollText, ListTodo, BookMarked, Bot, ChartColumn, ChevronDown, ChevronRight, - Cpu, Logs, Plug, Settings, ShieldCheck, Inbox, Target, TriangleAlert, Workflow, + Cpu, Logs, Plug, Puzzle, Settings, ShieldCheck, Inbox, Target, TriangleAlert, Workflow, } from 'lucide-react'; import { SIDEBAR_ICON_STROKE_WIDTH, SIDEBAR_ICON_STROKE_CLASS } from './icons/sidebarIconStroke'; @@ -59,6 +59,7 @@ function getResourceNavigation( canManageAgents: boolean, canManageMembers: boolean, canReadMcpLog: boolean, + canManagePlugins = false, ): NavEntry[] { const navigation: NavEntry[] = [{ name: 'Repositories', href: '/repositories', icon: BookMarked }]; if (canManageAgents) navigation.push({ name: 'Coding Agents', href: '/ai-agents', icon: Bot }); @@ -71,6 +72,8 @@ function getResourceNavigation( { name: 'Logs', icon: Logs, items: logs }, { name: 'Settings', href: '/settings', icon: Settings }, ); + // Plugins need a durable administrator who may change instance settings, as their API does. + if (canManagePlugins) navigation.push({ name: 'Plugins', href: '/plugins', icon: Puzzle }); if (canManageMembers) navigation.push({ name: 'Access', href: '/admin/members', icon: ShieldCheck }); return navigation; } @@ -279,12 +282,13 @@ function NavigationEntry({ entry, state }: { entry: NavEntry; state: NavigationS */ export function SidebarNavigation({ state, permissions }: { state: NavigationState; - permissions: { canManageAgents: boolean; canManageMembers: boolean; canReadMcpLog: boolean }; + permissions: { canManageAgents: boolean; canManageMembers: boolean; canReadMcpLog: boolean; canManagePlugins?: boolean }; }) { const resources = getResourceNavigation( permissions.canManageAgents, permissions.canManageMembers, permissions.canReadMcpLog, + permissions.canManagePlugins, ); return (