diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index 65a592a29f..2f9e550523 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -1028,6 +1028,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/apps/desktop/scripts/packaged-acceptance-csp-reload.test.mjs b/apps/desktop/scripts/packaged-acceptance-csp-reload.test.mjs index 958386db04..e68c7f817f 100644 --- a/apps/desktop/scripts/packaged-acceptance-csp-reload.test.mjs +++ b/apps/desktop/scripts/packaged-acceptance-csp-reload.test.mjs @@ -562,15 +562,27 @@ test('exact observer preserves native denied evidence across real Chromium navig response.end(`CSP fixture`); }); const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); - const close = server => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + const close = server => new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + server.closeAllConnections(); + }); + // Teardown is bounded: an unbounded browser.close() in an after hook once + // held this CI job for ten minutes with only the main Chromium process left, + // which skipped the whole unsigned packaging matrix. The browser runs as an + // owned server process so a stuck close can be escalated to SIGKILL. + const within = (promise, ms) => Promise.race([promise.then(() => true, () => true), + new Promise(resolve => setTimeout(resolve, ms, false).unref())]); + let browserServer; + t.after(async () => { + if (browserServer && !await within(browserServer.close(), 10_000)) browserServer.process().kill('SIGKILL'); + await Promise.all([api, document].filter(server => server.listening).map(close)); + }); await listen(api); - t.after(() => close(api)); const fixtureOrigin = `http://127.0.0.1:${api.address().port}`; await listen(document); - t.after(() => close(document)); const documentOrigin = `http://127.0.0.1:${document.address().port}`; - const browser = await chromium.launch({ args: ['--no-sandbox'] }); - t.after(() => browser.close()); + browserServer = await chromium.launchServer({ args: ['--no-sandbox'] }); + const browser = await chromium.connect(browserServer.wsEndpoint()); t.diagnostic(`Chromium ${browser.version()}`); const page = await browser.newPage(); await page.goto(documentOrigin + '/denied'); diff --git a/docs/docs/architecture/plugin-runtime.md b/docs/docs/architecture/plugin-runtime.md new file mode 100644 index 0000000000..e501539e5e --- /dev/null +++ b/docs/docs/architecture/plugin-runtime.md @@ -0,0 +1,435 @@ +--- +sidebar_label: "Plugin Runtime" +title: "Plugin runtime: handlers and their context" +description: "How plugin handlers run: the handler SDK, runPluginHandler, the event dispatcher (cursors, ordering, retries, lease, health), the scoped ctx.propr client, outbound HTTP through the endpoint policy, installation state, logging and redaction, and the import rule." +--- + +# Plugin runtime + +A [plugin](../features/plugins.md) can ship code: a `handlers.ts` in its folder +that exports named async functions. This page describes how ProPR runs them and +what they can reach. The feature page covers [writing a handler](../features/plugins.md#handlers). + +Official plugins are trusted code and their handlers run **in-process**, but only +through a narrow interface: input goes in, and every effect goes out through +`ctx`. A handler imports nothing from ProPR except the SDK types in +`@propr/shared`, so the same plugin could later run in another process with `ctx` +proxied to ProPR. + +Handlers *observe and create new work*. A handler can read a pull request, open +an issue or start a task, but it never interacts with a task that is running. +Ordinary task runs stay one-shot. + +## Components + +| Part | Where | What it does | +| --- | --- | --- | +| SDK types | `packages/shared/src/pluginSdk.ts` | `PluginContext`, the input envelopes, `PluginSdkError`, the limits and the operation → permission table. | +| Handler loading | `packages/plugins/src/index.ts` (`loadPluginHandlers`) | Resolves a plugin's `handlers.ts` (compiled to `dist/plugins//handlers.js` in builds) and returns its exported functions. | +| Runner | `packages/core/src/plugins/pluginHandlerRunner.ts` | `runPluginHandler(installation, handlerName, input, options)`. | +| Context | `packages/core/src/plugins/pluginContext.ts` | Builds `ctx` for one invocation, captures and redacts its log. | +| `ctx.propr` | `pluginProprClient.ts` (checks) on `pluginProprBackend.ts` (GitHub, event journal, task queue) | The scoped ProPR client. | +| `ctx.http` | `pluginHttp.ts` on `extensions/endpointPolicy.ts` | Outbound HTTPS. | +| `ctx.state` | `pluginStateStore.ts`, table `plugin_state` | Installation-scoped key-value store. | +| Test kit | `packages/plugins/testing` | A fake `ctx` for plugin unit tests. | +| Event dispatcher | `packages/core/src/plugins/pluginDispatcher.ts`, started by the daemon (`src/daemon/pluginEventDispatch.ts`) | Reads the journal per installation and repository, runs subscribed handlers, retries, holds the lease. | +| Delivery records | `packages/core/src/plugins/pluginDeliveries.ts`, tables `plugin_dispatch`, `plugin_delivery_cursors`, `plugin_deliveries` | Activity records, health, Retry, Resume, retention, the Degraded notification. | +| Activity API | `packages/api/routes/pluginActivityRoutes.ts` | `GET …/:pluginId/activity`, `POST …/activity/:id/retry`, `POST …/:pluginId/resume`. | + +## Invocation + +```ts +runPluginHandler(installation, handlerName, input, options): Promise +``` + +`installation` is a plugin id (the installation is read from +`plugin_installations` and the manifest from the registry) or an installation +already loaded (`{ pluginId, manifest, record }`). The runner: + +1. Refuses a plugin that is not in the registry, not installed or not enabled, a + handler name that is not an identifier, and a name the module does not export + as a function. +2. Builds `ctx` from the current manifest and the installation record. +3. Calls `handler(input, ctx)` and waits at most `PLUGIN_HANDLER_TIMEOUT_MS` + (30 seconds). When the time runs out, `ctx.signal` is aborted. +4. Returns `{ outcome, timedOut, durationMs, error, logs, result? }`. + +The runner **never throws**. A thrown error, a timeout, a missing handler and a +result that is not JSON or is larger than 16 KiB are all `outcome: 'failed'` +with a message. The message, the log lines and the result are redacted. + +When the run ends, whether it succeeded, failed or timed out, `ctx` stops working +for anything the handler left running. A late call to `ctx.propr`, `ctx.http` or +`ctx.state` throws `unavailable`, and late log lines are dropped. A handler that +ignores `ctx.signal` can keep its own timers alive, but it can no longer act +through ProPR. + +The [event dispatcher](#event-dispatcher) calls the runner with +`PluginEventInput`. Later issues call it from the inbound webhook route +(`PluginWebhookInput`) and commands (`PluginCommandInput`). +`PluginInvokeInput` carries a JSON payload for direct calls and tests. + +## Event dispatcher + +`PluginDispatcher` delivers [journal events](./repository-event-journal.md) to +the handlers a plugin names in `contributes.subscriptions`. The daemon starts +one at boot and stops it on shutdown; the hourly maintenance sweep that prunes +the journal also prunes delivery records. The feature page lists the +[events, payloads and guarantees](../features/plugins.md#event-subscriptions). + +### Reading the journal + +The dispatcher only reads the journal. An append notifies it in the same +process (`subscribeRepositoryEventAppends`): a pass starts within 200 ms, and +a steady stream of appends never pushes that pass back. It also polls every 5 +seconds for appends from other processes. Nothing that appends an event waits for a +delivery, so a handler can never fail or delay the step that produced the +event. + +Each pass looks at every enabled installation whose current manifest has +subscriptions and that holds `events.read`. For each one: + +1. `plugin_dispatch` holds `start_seq`, the journal's highest `seq` when the + plugin was installed or last enabled. Only later events are delivered. An + installation without a row (or with a row of an earlier installation) + starts at the journal's end. That row is written only in a transaction + that confirms the installation is still the one the pass read, and + enabled: a pass that read it before an uninstall, reinstall or disable + skips it and never writes over the replacement's start. +2. The repositories to visit are those with events after `start_seq`, those + with unfinished deliveries, and those whose journal floor moved past + `start_seq` and past what their cursor last recorded (so a repository that + retention emptied is still visited), minus those outside the repository + limit. Repository names are case-insensitive: every spelling of a name is + one repository, keyed by its lower-case form. Each has a cursor in + `plugin_delivery_cursors`: the last `seq` whose deliveries are final. +3. For each repository, the events after the cursor, in any spelling, are + read in `seq` order (50 per pass). Each event goes to every subscription whose `on` and + `filter` match it, in manifest order. A delivery gets a record in + `plugin_deliveries` the first time it is seen, keyed by its delivery id + `evt--`. +4. When every delivery of an event is final (succeeded or failed), the cursor + moves past it. A delivery waiting for a retry stops that repository until the + retry is due; other repositories and other plugins go on. Installations are + delivered side by side. + +Before the events, failed deliveries an administrator retried get their one +extra attempt. A retry is marked `manual` until that attempt is final, so one +a crashed worker left `running` gets its attempt again from the next lease +holder, with the same delivery id. + +### Attempts and retries + +An attempt marks the record `running`, increments `attempt`, and calls +`runPluginHandler` with `{ kind: 'event', event, deliveryId, attempt }` and the +30 second limit. Before each attempt the dispatcher renews its lease and reads +the installation again: an installation that is paused, disabled, enabled +again since the pass began, or no longer granted `events.read` ends the pass, +and so does an event outside the current repository limit (a manual retry of +one is marked failed instead). The handler gets the installation as it is now. +An attempt that pauses the installation ends the pass too. The runner never throws. The outcome, duration, redacted error +and redacted log lines (at most 100 lines and 64 KiB) are written to the record. +A failed attempt becomes `retrying` with `next_attempt_at` 1 minute, 5 minutes, +30 minutes and 2 hours later (`PLUGIN_DELIVERY_RETRY_DELAYS_MS`); the fifth +failure is final (`failed`). A manual retry gets one attempt. + +### Lease + +Several daemons may run against one database. Only one delivers to a given +installation: the holder of its lease, `lease_owner` and `lease_expires_at` on +the `plugin_dispatch` row. ProPR runs on SQLite only, so the lease is this row +with an expiry, not a database lock. A dispatcher takes the lease with one +conditional `UPDATE` (free, expired or already its own) and renews it before +every attempt. The lease lasts 2 minutes, longer than an attempt can run. On +shutdown the dispatcher hands its leases back. + +The outcome of an attempt is written only while the dispatcher still holds the +lease. A dispatcher that stalled past its lease writes nothing; the new holder +finds the record still `running` and delivers it again with the same delivery +id. This is why delivery is at least once. + +### Gaps + +Retention deletes journal events after 30 days and records each repository's +floor (`getRepositoryEventFloor`). When a cursor is below the floor, events may +have gone undelivered: the dispatcher records one `gap` record (status +`skipped`, from the cursor to the floor) and goes on with the events that are +left. `floor_seq` on the cursor keeps it from recording the same gap twice. +Unfinished deliveries of events that are no longer in the journal become +`skipped`. Unexpired events below the floor are still delivered. + +### Health + +`plugin_dispatch` also counts consecutive failed attempts. A success resets the +count and clears Degraded. At 3 (`PLUGIN_DEGRADED_AFTER_FAILURES`) the +installation becomes Degraded: `degraded_at` is set, and every durable +administrator gets one `system_failure` Inbox notification (component +`plugins.dispatcher`, deduplicated by installation and the time it became +Degraded). The notification owed is queued in `degraded_notice` (a JSON list) +in the same write that sets `degraded_at`, behind any notification of an +earlier transition not sent yet, and each is removed only once it was created; +when sending fails, or the worker stops first, later passes send it again (at +most once a minute after a failure), paused installations included. At `PROPR_PLUGIN_PAUSE_AFTER_FAILURES` (default 10, at least 3) +`paused_at` is set and the dispatcher skips the installation. Resume clears +both, resets the count and makes waiting retries due. + +Installing a plugin, or enabling it again, restarts its dispatch state at the +journal's end; deliveries left unfinished are marked failed. A pass writes a +cursor or a delivery record only in a transaction that confirms it still holds +the lease of the installation and start it read, so a pass that started before +a restart writes nothing of the earlier start. Marking a delivery `running` +before its handler starts is one of those writes, and renews the lease in the +same transaction: a worker that lost the lease while suspended before an +attempt never starts the handler. Uninstalling +deletes the dispatch state, the cursors and the records. + +### What is stored + +A delivery record holds the plugin id, delivery id, kind, repository, event +`seq` and `.`, handler, status, attempt, times, duration, the +timeout flag, and the latest attempt's redacted error and log lines. It never +holds the event payload, the handler's result or a credential. Records are +kept for 14 days (`PLUGIN_DELIVERY_RETENTION_DAYS`); pending and retrying +records are kept until they finish. A final record is also kept while its +event is still in the journal and ahead of the repository's cursor (another +handler of the event is retrying, or deliveries are paused), so reading the +event again does not run a handler that already succeeded or failed for good. + +## `ctx.propr`: the scoped client + +Each operation needs one permission: + +| Operation | Permission | +| --- | --- | +| `getPullRequest(repository, number)` | `pull_requests.read` | +| `getIssue(repository, number)` | `issues.read` | +| `getTask(taskId)` | `tasks.read` | +| `listEvents({ repository, afterSeq?, domains?, limit? })` | `events.read` | +| `createIssue(repository, { title, body?, labels? })` | `issues.create` | +| `createTask(repository, issueNumber)` | `tasks.create` | +| `commentOnPullRequest(repository, number, body)` | `pull_requests.comment` | +| `commentOnIssue(repository, number, body)` | `issues.comment` | +| `addLabel(repository, number, label)` / `removeLabel(…)` | `labels.write` | + +Every call is checked before it reaches the backend: + +- **Grant.** The permission must be both granted to the installation and asked + for by the current manifest. A permission an upgrade added is unusable until + an administrator approves it, and one an upgrade dropped is unusable at once. +- **Repository limit.** The repository must be in the installation's + repository limit (compared case-insensitively), unless the plugin is enabled + for all repositories. A task's repository is known only after the task is + read, so `getTask` reads it and refuses to return a task outside the limit. +- **Input.** `owner/repo` names, positive numbers, and bounded titles (256), + bodies (64 KiB), labels (50 characters, at most 20) and event pages (at most + 100). +- **Target kind.** GitHub's issue routes serve pull requests too, so the method + name alone does not establish what a number refers to. `getIssue` and + `commentOnIssue` read the number first and refuse a pull request; + `commentOnPullRequest` refuses an issue (`not_found`, nothing is posted). An + `issues.comment` grant cannot comment on a pull request, nor the reverse. +- **Installation.** The permissions and the repository limit are first those + of the installation the run started with. Every call then requires that + installation to still be installed and enabled, and to still grant the + call's permission on the repository it names, as an administrator last set + them; the default backend checks the same again right before it sends each + GitHub request or adds a task job, after the App client or the queue is + ready. `getTask` names no repository until the task is read, so it checks + the task's repository against the current limit then, before returning it. A handler that resumes after its plugin was uninstalled, or + uninstalled and reinstalled, gets `unavailable` ("The plugin is no longer + installed"); after it was disabled, `unavailable` ("The plugin is + disabled"); after its repository limit was changed to exclude the + repository, `repository_not_allowed`; after a grant was revoked, + `permission_denied`. In each case it creates no issue, comment, label or + task. An administrative change does not wait for a GitHub request that + passed this check and is already being sent. + +A refused call throws `PluginSdkError` with code `permission_denied`, +`repository_not_allowed`, `invalid_input`, `not_found` or `unavailable`. It is +logged as a `warn` line in the run's log and in the server log. A backend failure reaches +the handler as `unavailable` without the backend's details, which could name +other repositories or carry tokens. + +### Attribution + +Every call, allowed or refused, is appended to `plugin_audit` with actor +`plugin:` and detail +`{ handler, operation, permission, callId, repository?, number?, taskId?, reason? }`. +The action is `handler_call` for a completed call and `handler_refused` for a +refused or failed one. A mutation (`createIssue`, `createTask`, the comment and +label operations) is first recorded as `handler_attempt`, after its checks and +before it is dispatched. If that record cannot be written, the call is refused +as `unavailable` and nothing reaches GitHub or the task queue. Once the attempt +is recorded, a failure to record the outcome is logged on the server and does +not fail the call, because the mutation may already have happened. The attempt +and its outcome share a `callId`. The calls appear in the plugin's audit trail +(`GET /api/instance/plugins/v1/{id}`) with the management actions. + +Text a plugin writes to GitHub (issue bodies and comments) ends with an +HTML comment, ``, so the writer is visible on the +issue itself. A task the plugin creates is queued with `requestedBy: +"plugin:"` in its job data. The task keeps that value in its stored job +data, and its first task event, the `pending` entry of `task_history` that +`get_task_events` returns, carries `{ "requestedBy": "plugin:" }` as +metadata. + +## `ctx.http`: outbound HTTP + +`ctx.http(connection?)` returns a `fetch`-like function: +`(url, { method?, headers?, body? | json? }) => Promise<{ status, ok, url, headers, text(), json() }>`. + +| Check | Refusal | +| --- | --- | +| `https:` only, default port, no credentials in the URL | `insecure_url`, `host_not_allowed`, `invalid_input` | +| The hostname must be exactly one of the manifest's `outboundHosts`; IP literals and local names (`localhost`, `*.local`, cloud metadata) never are | `host_not_allowed`, `private_address` | +| Every resolved address must be public: the extension endpoint policy's address check (private, loopback, link-local, CGNAT, documentation and fake-IP ranges are refused) | `private_address` | +| Methods `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`; headers ProPR sets (`Host`, `Content-Length`, hop-by-hop, `Proxy-*`) cannot be supplied | `invalid_input` | +| Request bodies up to 256 KiB, responses up to 1 MiB, 10 seconds per request | `invalid_input`, `response_too_large`, `http_failed` | + +As with extension MCP servers, ProPR dials the address it checked, keeping the +original hostname for TLS and the `Host` header. Redirects are not followed, and +no proxy environment, cookies or decompression apply. The handler gets the +redirect response itself. + +**Credentials.** With a connection name, the connection must be a `token` +connection of the plugin and must be set. Its sealed secret is opened only when +the request is sent and is injected as `Authorization: Bearer `. An +`oauth` connection authenticates the plugin's MCP servers, and a +`signing_secret` verifies inbound webhooks. Neither can be injected +(`connection_unavailable`). The handler never receives the secret: it is not +part of `ctx`, and the secret (plain, JSON-escaped, base64, base64url, hex, +URL-encoded and as the full header value) is replaced with `[REDACTED]` in the +response headers and body before the handler sees them. The hex spelling is +matched in any letter case (`Buffer.from(hex, 'hex')` decodes `AB`, `ab` and +`aB` alike); every other spelling is matched exactly, so a value that merely +differs from the secret in case is not touched. Each of these spellings +is also replaced where some of its characters are percent-escaped, with +lowercase or uppercase hex digits (`%2f` as well as `%2F`, or an escaped +letter), since `decodeURIComponent` recovers all of them. A JSON body is also +checked after decoding, so an escape such as `\u0041` or `\"` cannot spell the +secret back; when a decoded string held it, `text()` and `json()` return the +body re-serialized with the secret replaced. A string that holds the secret +only after it is decoded again, such as a JSON document serialized inside a +JSON string or a value URL-encoded twice, is replaced with `[REDACTED]` whole. +A response header or body whose text still holds the secret once its JSON and +percent escapes (`%2f`, `%u002f`) are decoded, once or repeatedly and in any +order, is replaced with `[REDACTED]` entirely. This covers a JSON-valued header +(also inside a longer or joined value), `\uXXXX` escapes in an HTML script, +nested serialized JSON and a percent escape inside a JSON escape. Text still +decoding after 32 rounds, or decoding to more than 8 different texts in one +round, counts as holding it. A response header whose name holds the secret in any of these +spellings is dropped. Header names arrive lowercased, so they are compared +case-insensitively. A +careless upstream that echoes the `Authorization` header cannot hand the secret +back. + +**Installation binding.** The secret is read from the installation the run +belongs to, in one transaction with it, and every authenticated request checks +again before it is sent that this installation is still installed and enabled. +A run that resumes after its plugin was uninstalled, or uninstalled and +reinstalled with a new connection of the same name, gets `unavailable` and +sends nothing; it never receives the replacement installation's secret. A run +that resumes after its plugin was disabled gets `unavailable` ("The plugin is +disabled") and sends nothing either. An unauthenticated `ctx.http()` request +carries nothing of the installation and is not bound to its enabled state. + +**Run lifetime.** The run's signal reaches the request: it is checked again +after the DNS lookup and after the credential is read, and no request starts +once the run has ended (`unavailable`). A request in flight when the run ends +is cancelled. The default `ctx.propr` backend applies the same rule to its +GitHub requests and queued tasks. + +Each request decision is a line in the run's log: `debug` when sent, `warn` when +refused. The line names only the hostname, never the path or query. + +## `ctx.state` + +A JSON key-value store scoped to the installation, for cursors and mappings +such as "this pull request maps to that chat thread": + +```ts +await ctx.state.set(`thread:${repository}#${number}`, { channel, ts }); +const thread = await ctx.state.get<{ channel: string; ts: string }>(`thread:${repository}#${number}`); +await ctx.state.delete(key); await ctx.state.keys('thread:'); +``` + +| Limit | Value | +| --- | --- | +| Keys | 256 per installation; 1 to 200 printable characters without spaces | +| Value | 16 KiB of serialized JSON; plain objects, arrays, text, finite numbers, booleans, `null` | +| Total | 256 KiB per installation | + +A write that would exceed a limit throws `state_limit` and changes nothing. +Rewriting a key replaces its size rather than adding to it, so an existing key +can be updated at the key limit. Values live in `plugin_state` +(`plugin_id`, `state_key`, `value`, `bytes`, `updated_at`; migration +`20261114180000_plugin_state.js`) and are deleted when the plugin is +uninstalled. A write runs in a transaction that first requires the +installation the run started with (the same `installed_at`) to still exist +and holds it until commit; uninstall deletes the installation row before the +state rows. A handler still running across an uninstall therefore cannot +recreate state, nor hand it to a later installation with the same id: its +write either commits first and is deleted with the rest, or throws +`unavailable`. Disabling keeps them. The uninstall's audit entry records how many +keys were deleted. Native exports keep the table, since it holds no +credentials. + +## `ctx.log` and redaction + +`ctx.log.debug|info|warn|error(message, fields?)` appends +`{ level, message, fields?, at }` to the run's log, which is returned to the +caller. Messages are cut at 2,000 characters, fields must be JSON of at most +4 KiB, and a run keeps at most 200 lines, followed by one "log limit reached" +line. Every message, field, error message and result is scrubbed of the +connection credentials used in the run as a response is (every spelling, also +percent-escaped in either case, and a string that still decodes to one is +replaced whole), then passes the extension output redaction. That redaction +removes those credentials again, plus the general secret patterns (GitHub, +cloud and API tokens, `Authorization` values and secret-like assignments) and +headers such as `authorization` and `cookie`. + +Messages (2,000 characters) and error messages (1,000) are cut only after the +final redaction when the run ends, which uses every credential the run +acquired. A credential that straddles the limit is replaced whole instead of +being left one character short. A credential too long to be matched whole before the cut has +its visible start withheld. + +## Import rule + +The root `eslint.config.js` has an override for `packages/plugins/**`, which +covers handlers, the registry and the test kit. Its `no-restricted-imports` and +`no-restricted-syntax` rules refuse: + +- `@propr/*` packages other than `@propr/shared`, and `propr-ui`; +- relative paths into another ProPR package (`../../core/…`, `../../api/…`, + `../../shared/…`, …) or the root sources (`../../../src/…`); +- dynamic `import()` of any of these. + +Node built-ins and files inside the plugins package are allowed. Run it with +`npm run lint -w @propr/plugins`. `packages/core/test/pluginHandlerLint.test.ts` +checks that a fixture plugin importing core fails. + +## Testing plugins + +`packages/plugins/testing` exports `createTestPluginContext(options)`. It +returns a fake `ctx` together with what the handler did: + +```ts +import { createTestPluginContext } from '../testing/index.js'; +import { recordEvent } from '../example-pack/handlers.js'; + +const kit = createTestPluginContext({ grantedPermissions: ['events.read', 'tasks.read'], outboundHosts: ['api.example.com'] }); +kit.respond('https://api.example.com/v1/', { json: { ok: true } }); +await recordEvent(input, kit.ctx); +kit.calls; // ctx.propr calls that passed the checks: [{ operation, args }] +kit.requests; // ctx.http requests: [{ url, init, connection }] +kit.state; // Map of the in-memory state +kit.logs; // captured log lines +``` + +The fake applies the same permission, repository-limit, `https`, outbound-host +and state-limit checks as the real context, so a handler test fails the way +production would. That includes `getTask`, which refuses a task from a +repository outside the limit once the answer is read. `ctx.propr` calls nobody +answered resolve with minimal placeholders. A placeholder task belongs to the +first repository in the limit. An HTTP request with no scripted response throws `http_failed`. diff --git a/docs/docs/architecture/repository-event-journal.md b/docs/docs/architecture/repository-event-journal.md index 70850c4649..6be7f06960 100644 --- a/docs/docs/architecture/repository-event-journal.md +++ b/docs/docs/architecture/repository-event-journal.md @@ -1,12 +1,12 @@ --- sidebar_label: "Repository Event Journal" title: "Repository change-event journal" -description: "The durable, append-only journal of pull request, epic queue and plan issue changes: table, event types, payloads, idempotency and retention." +description: "The durable, append-only journal of pull request, epic queue, plan issue and task changes: table, event types, payloads, idempotency and retention." --- # Repository change-event journal -ProPR records pull request, epic queue and plan issue changes in a durable, append-only journal: the `repository_events` table. Each event is appended where the change is already persisted, so a coordinator can read one ordered stream per repository. It does not have to poll several endpoints and diff the results. +ProPR records pull request, epic queue, plan issue and task changes in a durable, append-only journal: the `repository_events` table. Each event is appended where the change is already persisted, so a coordinator can read one ordered stream per repository. It does not have to poll several endpoints and diff the results. The journal is the source of truth. After each append ProPR also publishes a bare `activity:update` event over Socket.IO so the web UI can refresh. That event carries no payload, and a missed one loses nothing: the row is still in the journal. @@ -18,9 +18,9 @@ This page covers the storage, the writer and the event catalog. Clients read and | --- | --- | | `seq` | `INTEGER PRIMARY KEY AUTOINCREMENT`, used as the cursor. It only increases, and a position is never reused, even after retention deletes rows. | | `repository` | `owner/repo`. Every read is scoped to one repository. | -| `domain` | `pull_request`, `epic_queue` or `plan_issue`. | +| `domain` | `pull_request`, `epic_queue`, `plan_issue` or `task`. | | `type` | The event type within the domain (see below). | -| `entity_id` | PR number, epic execution id, or plan issue id, as text. | +| `entity_id` | PR number, epic execution id, plan issue id, or task id, as text. | | `pr_number`, `issue_number`, `plan_id`, `head_sha` | Optional identifiers to filter on. `plan_id` is the plan (draft) id. | | `payload` | Small JSON object (at most 4 KiB). | | `dedupe_key` | Optional key, unique per repository. | @@ -30,6 +30,8 @@ Indexes: `(repository, seq)`, `(repository, domain, entity_id, seq)`, and a uniq `repository_event_floors` stores, for each repository, one past the highest `seq` that retention has deleted (see [Retention](#retention)). +The `domain` column has a CHECK constraint listing the domains; the `task` domain was added by rebuilding the table, keeping every row, its `seq` and the AUTOINCREMENT high-water mark, so existing cursors stay valid. + Rows are written by explicit application appends, not SQLite triggers. A later migration that rebuilds a source table therefore has nothing to recreate. This is different from `goal_events`. ## Writer @@ -44,7 +46,7 @@ Rows are written by explicit application appends, not SQLite triggers. A later m ## Event types -Payloads carry identifiers and small scalars only. They never include PR bodies, comment text, diffs or log excerpts. The free-text reasons below are short, system-generated explanations, capped at 300 characters. +Payloads carry identifiers and small scalars only. They never include PR bodies, comment text, diffs, prompts, agent or tool output, file contents or log excerpts. The free-text reasons below are short, system-generated explanations, capped at 300 characters. ### `pull_request` @@ -86,6 +88,40 @@ A `blocked` event is appended only when the explanation changes, so rewriting th Each writer reads the rows it changes, with their prior status, in the same transaction as its guarded update and the append. One event is written per changed row, and nothing for rows its filters excluded or rows already at the new status (a redelivered webhook). There is no dedupe key, so a transition that repeats within one clock tick is still its own event. These writers already publish a `plan` activity event, so the journal does not publish a second one. +### `task` + +`entity_id` is the task id. `issue_number` holds the issue of an implementation task; `pr_number` holds the pull request of a follow-up, review or merge-conflict task. Events are appended by the task state manager (`WorkerStateManager`) right after it records the state change in `task_history`, so every job path journals the same way (`packages/core/src/services/taskJournalEvents.ts`). + +| Type | Written when | Dedupe key | +| --- | --- | --- | +| `queued` | The task state is created (`pending`), or the task returns to `pending` | `task:{taskId}:queued:v{version}` | +| `started` | The task leaves `pending` for a running state, or a failed task is retried | `task:{taskId}:started:v{version}` | +| `completed`, `failed`, `cancelled` | The task reaches that terminal state | `task:{taskId}:{type}:v{version}` | + +`version` is the task state's compare-and-set revision, so a republished transition appends nothing. A queue handoff (a `requeued` or `rescheduled` job recorded as `cancelled` without a terminal reason) is bookkeeping, not an end: it appends no `cancelled`, and resuming it appends no second `started`. A retry appends `started` and a new terminal event again. + +Every `task` payload has: + +| Field | Meaning | +| --- | --- | +| `taskId` | The task id. | +| `kind` | `implementation` (an issue task), `follow-up` (a pull request comment task), `review`, `merge-conflict` or `automation` (an automation run). Other task types, such as goals and task imports, are not journaled. | +| `issueNumber`, `prNumber` | The issue or pull request the task works on; the other is `null`. | +| `agent`, `model` | The agent alias and model the task was queued with, or `null`. | +| `attempts` | Retry attempts recorded so far. | + +Terminal events (`completed`, `failed`, `cancelled`) add: + +| Field | Meaning | +| --- | --- | +| `status` | The terminal state, the same as the event type. | +| `terminalReason` | The coded terminal reason, for example `timed_out`, `stalled`, `blocked`, `cancelled_by_user`, `cancelled_pr_closed`; `null` when none was recorded. | +| `durationMs` | Milliseconds from the run's `started` event to the terminal state, across any queue handoffs in between; `null` when the task never started. | +| `pullRequest` | `opened` (the task opened a pull request, including a continuation pull request), `updated` (it pushed a commit to an existing one) or `null`. | +| `failureCategory` | On `failed` only: the coded terminal reason, else the recorded error category (for example `claude_execution`, `git_operation`, `worker`), else `unknown`. Free-text categories are reported as `unknown`. `null` on other events. | + +Payloads are built from identifiers and coded fields only. They never include the task's reason text, error message, prompt, agent or tool output, comment bodies, logs, notification recaps or file contents. A task event has no activity mapping: the task state update already publishes its own realtime event. Like every append, a failed task event is logged and the task carries on with the same outcome. + ## Realtime The activity envelope (`packages/shared/src/activityEvents.ts`) has a `pull_request` domain. Each event mapping is shown below: @@ -102,7 +138,7 @@ The activity envelope (`packages/shared/src/activityEvents.ts`) has a `pull_requ ## Retention -The daemon's hourly maintenance sweep (the same one that reclaims stale draft context) deletes events older than 30 days, and only those: an unexpired event survives even if an older-stamped event (a delayed delivery, or an outcome stamped with its merge time) was appended after it. For each pruned repository it stores `floor_seq` in `repository_event_floors`. That value is one past the highest deleted `seq`, and it never moves backwards. +The daemon's hourly maintenance sweep (the same one that reclaims stale draft context) deletes events of every domain, `task` included, older than 30 days, and only those: an unexpired event survives even if an older-stamped event (a delayed delivery, or an outcome stamped with its merge time) was appended after it. For each pruned repository it stores `floor_seq` in `repository_event_floors`. That value is one past the highest deleted `seq`, and it never moves backwards. A reader holding cursor `c` for a repository may have missed pruned events when `c < floor_seq - 1`. Unexpired events below the floor stay readable. A `fromStart` read pages through them with cursors that also carry the repository's retention generation (a digest of its `repository_event_floors` rows); such a cursor stays valid below the floor until retention next prunes the repository, which rewrites `pruned_at` even when the floor does not move. This tells an expired cursor apart from an empty result. A repository that has never been pruned has floor `1`. diff --git a/docs/docs/features/agent-extensions.md b/docs/docs/features/agent-extensions.md index 2fa30416f7..c93960dff6 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 **From plugin:** *plugin name* badge linking to the +plugin in the settings screen, where only those fields are editable). 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 @@ -633,3 +651,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..c1b3618127 --- /dev/null +++ b/docs/docs/features/plugins.md @@ -0,0 +1,750 @@ +--- +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**, the official plugin registry, the +[Plugins page](#the-plugins-page), the installation API and the +[handler interface](#handlers) and [event subscriptions](#event-subscriptions). +Inbound webhooks and commands are added by later releases. +::: + +## 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`, +`labels.write`, `repositories.read` and `notifications.send`. A command can only +use permissions the plugin requests, and event subscriptions need `events.read`. +[Handlers](#what-ctxpropr-can-do) show which `ctx.propr` operation needs which +permission. + +### 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` | `{ "on", "handler", "filter"? }`: deliver a repository event (`.`, such as `pull_request.merged`) to an exported handler. See [Event subscriptions](#event-subscriptions). Needs the `events.read` permission. | +| `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. + +## 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 shows what changed (see [Needs approval](#needs-approval)) 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.** A settings form ProPR generates from the manifest's + [settings schema](#settings), then **Limit to repositories**, which picks + the repositories the plugin applies in (leave every box unchecked for all + repositories). The step is done once every required setting has a value. + See [Settings form](#settings-form). +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. **Activity** follows +once the plugin is installed: each event delivered to the plugin's handlers and +its result, with **Retry** on a failed delivery (see [Activity](#activity)). A +plugin whose deliveries keep failing shows **Degraded** next to its state, and +**Paused** with **Resume deliveries** once deliveries stopped (see +[Health, Degraded and Resume](#health-degraded-and-resume)). + +**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. + +### Settings form + +Each setting becomes one field, labelled with its `label` and described by its +`help`: + +| Setting | Field | +| --- | --- | +| `string` | A text box (limited to `maxLength`). | +| `string` with `enum` | A list to pick one value from, or **Not set**. | +| `number` | A number box, with the range and whether it must be a whole number shown under it. | +| `boolean` | A list with **Not set**, **Yes** and **No**, so an explicit **No** (`false`) is saved and kept apart from an unset setting. | +| `array` of `string` | One box per entry, with **Add entry** and **Remove**. Each entry is saved exactly as typed, including spaces, empty entries and line breaks. | +| `object` with `format: repository-map` | One row per repository: the repository (`owner/name`, with this instance's repositories suggested) and its value, with **Add repository** and **Remove**. | + +The form starts from the saved settings; a fresh installation starts from the +manifest's defaults. **Save settings** sends every setting at once +(`PUT /api/instance/plugins/v1//settings`). A field you did not change +sends its saved value unchanged. An emptied text or number field removes that +setting, and so does choosing **Not set** for a true/false setting. A list or map whose last entry is removed is saved as empty; +**Clear** removes the setting. The page checks the values with the same rules as the +API before sending them, and when the API refuses a value its message (for +example "maxItems must be at most 50") is shown under that field. A required +setting is marked **Required to enable**: it may stay empty while the plugin is +disabled, but **Enable** then lists it as missing (for example "Required setting +Verbosity (verbosity) is missing"), and while the plugin is enabled it cannot +be emptied. + +### Needs approval + +When a newer manifest asks for more than was approved, the plugin is badged +**Needs approval** and keeps running with what it was granted. The Install step +then shows: + +- **New permissions it asks for**, each with its explanation. +- **Changed package servers**: for each package server with secret variables + whose package, exact version or secret variables changed since they were + acknowledged, what changed, for example "Version changes from 1.0.0 to 1.1.0" + or "Secret variables added: FIXTURE_REGION", or that the server is new. +- The secret-variable acknowledgement for each of those servers, naming the + package, exact version and variables. + +**Approve** stays unavailable until every acknowledgement is ticked. Approving +records the new permissions and acknowledgements and clears the badge. + +### Managed items in Skills & MCP servers + +In **Settings → Agent Extensions & Packages → Skills & MCP servers**, each +server or skill a plugin contributes carries a **From plugin:** *plugin name* badge +that opens the plugin's page. It has no **Remove** (uninstall the plugin +instead), and **Edit limits** opens a form that shows its definition read-only: +for a remote server the endpoint, authentication and tools; for a package +server also the registry, package, exact version, command, arguments, variables +and secret variable names. Only the enabled checkbox, the repository and agent +limits and **Apply automatically** can be changed. + +## 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. 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, 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`. + +| 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": "" }` | +| 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 +`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. + +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 + +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. + +## 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. + +## Handlers + +A plugin folder may contain a `handlers.ts` that exports named async functions. +Each handler receives `(input, ctx)` and returns nothing or a small JSON result +(at most 16 KiB). `ctx` is the only way a handler touches anything. Handlers of +official plugins run in-process, but only through this interface, so the same +plugin could later run out of process. How ProPR runs them is described in +[Plugin runtime](../architecture/plugin-runtime.md). + +```ts +// packages/plugins/my-plugin/handlers.ts +import { definePluginHandler, type PluginEventInput } from '@propr/shared'; + +export const onTaskEvent = definePluginHandler(async (input, ctx) => { + const { event } = input; + const thread = await ctx.state.get(`thread:${event.entityId}`); + const response = await ctx.http('chat-token')('https://api.chat.example/messages', { + method: 'POST', json: { thread, text: `Task ${event.entityId} is ${event.type}` }, + }); + if (!response.ok) ctx.log.warn('chat refused the message', { status: response.status }); +}); +``` + +| `ctx` member | What it is | +| --- | --- | +| `ctx.settings` | The installation's settings, validated against the manifest, with its defaults. Read-only. | +| `ctx.repositories` | The repository limit (`owner/repo` list), or `null` for all repositories. | +| `ctx.propr` | ProPR, limited to the granted permissions and the repository limit (below). | +| `ctx.http(connection?)` | `fetch`-like outbound HTTPS to the manifest's `outboundHosts` only. With a `token` connection name, its credential is injected. | +| `ctx.state` | A small JSON key-value store for this installation: `get`, `set`, `delete`, `keys(prefix?)`. | +| `ctx.log` | `debug`, `info`, `warn`, `error(message, fields?)`: structured lines returned with the run, redacted. | +| `ctx.signal` | Aborted when the handler runs past its 30 second limit. | + +The input is one of the envelopes in `@propr/shared`: `{ kind: 'event', event }` +(a [repository journal event](../architecture/repository-event-journal.md)), +`{ kind: 'webhook', webhook, headers, body, receivedAt }`, +`{ kind: 'command', command, args, repository, actor }` or +`{ kind: 'invoke', payload }`. Events are delivered by +[event subscriptions](#event-subscriptions), with a stable `deliveryId` and the +`attempt` number. Delivering webhooks and commands to handlers is added by later +releases. + +A call `ctx` refuses throws a `PluginSdkError` with a `code` such as +`permission_denied`, `repository_not_allowed`, `host_not_allowed`, +`insecure_url`, `private_address`, `connection_unavailable` or `state_limit`. + +### What `ctx.propr` can do + +| Operation | Permission | +| --- | --- | +| `getPullRequest(repository, number)` | `pull_requests.read` | +| `getIssue(repository, number)` | `issues.read` | +| `getTask(taskId)` | `tasks.read` | +| `listEvents({ repository, afterSeq?, domains?, limit? })` | `events.read` | +| `createIssue(repository, { title, body?, labels? })` | `issues.create` | +| `createTask(repository, issueNumber)`: an ordinary one-shot task | `tasks.create` | +| `commentOnPullRequest(repository, number, body)` | `pull_requests.comment` | +| `commentOnIssue(repository, number, body)` | `issues.comment` | +| `addLabel(repository, number, label)`, `removeLabel(…)` | `labels.write` | + +A permission counts only when the administrator granted it and the current +manifest still asks for it. Each call must also stay inside the plugin's +repository limit. These are checked again, as an administrator last set them, +right before each call is dispatched: a handler that is still running when its +plugin is disabled, restricted to other repositories or uninstalled cannot act +under the authority it started with. + +Every call, allowed or refused, is recorded in the plugin's audit trail. The +actor is `plugin:` and the action is `handler_call` or `handler_refused`. +A call that changes something (an issue, comment, label or task) is also +recorded as `handler_attempt` before it is sent. If that record cannot be +written, the call is refused and nothing is sent. +Issues and comments a plugin writes end with ``. A +task it creates records `requestedBy: plugin:` in its first task event. + +Handlers observe and create new work: they never interact with a running task. + +### Outbound HTTP and secrets + +`ctx.http` reaches only the hostnames in `outboundHosts`, over HTTPS, on the +default port, and only when every address the name resolves to is public. +Private, loopback and link-local addresses and local names are refused, and +redirects are not followed. Requests are limited to 256 KiB and 10 seconds, and +responses to 1 MiB. + +Handlers never receive raw secrets. `ctx.http('my-token')` opens the stored +`token` connection only to send the request, as `Authorization: Bearer …`, and +removes the credential from the response before the handler reads it. Log lines, +error messages and results pass the secret redaction. + +### State + +`ctx.state` holds up to 256 keys and 256 KiB per installation, each value at +most 16 KiB of JSON. It is meant for cursors and mappings, for example "this +pull request maps to that chat thread". It is kept when the plugin is disabled +and deleted when it is uninstalled. + +### Imports + +Handler code may import `@propr/shared` (the SDK types live there), Node +built-ins and files of its own package, but nothing else from ProPR. A lint rule +for `packages/plugins/**` enforces this. Run it with +`npm run lint -w @propr/plugins`. + +### Testing handlers + +`packages/plugins/testing` provides `createTestPluginContext()`: a fake `ctx` +with recorded `ctx.propr` calls, scripted `ctx.http` responses, in-memory state +and captured logs. It applies the same permission, repository, host and state +checks as the real one. `packages/plugins/test/testing.test.ts` uses it on the +example plugin's `recordEvent` handler. + +## Event subscriptions + +A plugin with the `events.read` permission can subscribe its handlers to +[repository journal events](../architecture/repository-event-journal.md): + +```json +"contributes": { + "subscriptions": [ + { "on": "pull_request.merged", "handler": "onMerged" }, + { "on": "task.completed", "handler": "onTask", "filter": { "payload.kind": ["implementation", "review"] } } + ] +} +``` + +```ts +// handlers.ts +export const onMerged = definePluginHandler(async ({ event, deliveryId }, ctx) => { + if (await ctx.state.get(`posted:${deliveryId}`)) return; // delivered before: do nothing twice + await ctx.http('chat-token')('https://api.chat.example/messages', { method: 'POST', json: { text: `#${event.prNumber} merged` } }); + await ctx.state.set(`posted:${deliveryId}`, true); +}); +``` + +`on` is `.` of a journal event. `handler` is an exported function +of the plugin's `handlers.ts`. One event can go to several handlers, and one +handler can take several events. A pair of `on` and `handler` may appear only +once. + +`filter` is optional. Every key must match. A key is an event field +(`repository`, `entityId`, `prNumber`, `issueNumber`, `planId`, `headSha`) or a +payload field (`payload.`). A value is a string, number, boolean or `null`, +or a list of them, which matches any entry. `repository` is compared without +letter case. Filters only narrow what a subscription receives: the plugin's +repository limit always applies. + +### Events and payloads + +Each handler receives `{ kind: 'event', event, deliveryId, attempt }`. `event` +is the journal event: `seq`, `repository`, `domain`, `type`, `entityId`, +`prNumber`, `issueNumber`, `planId`, `headSha`, `payload` and `occurredAt`. +Payloads carry identifiers and short coded values only, never pull request +bodies, comments, prompts, logs or diffs. + +| `on` | When | `payload` | +| --- | --- | --- | +| `pull_request.opened` | A pull request was opened | `baseRef`, `draft` | +| `pull_request.head_changed` | New commits were pushed (`headSha` is the new head) | `previousHeadSha` | +| `pull_request.review_scored` | A review was scored | `score`, `goal`, `goalReached`, `source`, `cycle`, `reviewerModel` | +| `pull_request.ultrafix_started` | An Ultrafix loop started | `goal`, `maxCycles`, `workEpoch` | +| `pull_request.ultrafix_ended` | An Ultrafix loop ended | `outcome`, `finalScore`, `reason`, `workEpoch` | +| `pull_request.auto_merge_decision` | Auto-merge decided on a head | The auto-merge decision fields | +| `pull_request.merged` | The pull request was merged | none | +| `pull_request.closed` | The pull request was closed unmerged | none | +| `epic_queue.started` | An epic queue started | `issues`, `advanceOn`, `parallel` | +| `epic_queue.advanced` | The queue moved to the next issue | `fromIssue`, `toIssue`, `cursor` | +| `epic_queue.blocked` | The queue head is blocked | `headIssue`, `blockedReason` | +| `epic_queue.completed` | The queue finished | `fromIssue`, `cursor` | +| `plan_issue.status_changed` | A plan issue changed status | `from`, `to` | +| `task.queued`, `task.started` | A task was queued, or started running | `taskId`, `kind`, `issueNumber`, `prNumber`, `agent`, `model`, `attempts` | +| `task.completed`, `task.failed`, `task.cancelled` | A task finished | The `task.queued` fields and `status`, `terminalReason`, `durationMs`, `pullRequest`, `failureCategory` | + +The [journal page](../architecture/repository-event-journal.md#event-types) +describes each field. + +### Delivery guarantees and limits + +- **At least once.** A delivery can repeat, for example when a worker stops + while a handler runs. `deliveryId` (`evt--`) is the same on + every attempt, after a restart too, so a handler with side effects should + record the ids it has handled (`ctx.state` is meant for this). +- **In order per repository.** One plugin receives the events of one + repository one at a time, in journal order. The next event waits until the + current one has succeeded or finally failed. Other repositories go on + meanwhile. +- **30 seconds** per attempt. A handler still running then has failed. +- **Retries.** A failed attempt is retried after 1 minute, 5 minutes, + 30 minutes and 2 hours. After the fifth attempt the delivery is marked + failed and the next event follows. +- **No history.** Installing a plugin, or enabling it again, starts its + subscriptions at that moment. Earlier events, and events journaled while it + was disabled, are not delivered. +- **Repository limit.** Events of repositories outside the plugin's limit are + not delivered. A change to the limit, disabling the plugin or revoking + `events.read` applies from the next delivery, even within a batch. +- **Retention gaps.** The journal keeps 30 days of events. If events leave the + journal before they were delivered (for example while deliveries were + paused for a long time), they are skipped and the Activity list records the + gap. +- **Never in the way.** Delivery runs after the event is journaled, in the + daemon. A handler that fails, times out or crashes never fails or delays the + task, review, merge or queue step that produced the event. +- **One deliverer.** When several daemons run, only one delivers to a given + plugin at a time. + +### Health, Degraded and Resume + +After 3 failed attempts in a row the plugin is shown as **Degraded** on its +page, with the last error, and every administrator gets one Inbox +notification. A successful delivery clears it. + +After 10 failed attempts in a row (set `PROPR_PLUGIN_PAUSE_AFTER_FAILURES` to +change this; it is never below 3) deliveries pause: nothing is delivered to the +plugin, and new events wait in the journal. **Resume deliveries** on the plugin +page starts them again; the failure count starts over and waiting retries run +at once. + +### Activity + +Each delivery is recorded on the plugin page under **Activity**, newest +first: the time, the event type and `seq`, the handler, the outcome, the +duration, the attempt, the error message and the handler's log lines (all +redacted). Event payloads, handler results and credentials are never stored. +Records are kept for 14 days (longer while their event still waits on another +handler or on Resume) and are deleted when the plugin is uninstalled. + +**Retry** on a failed delivery gives it one more attempt, with the same +`deliveryId`, outside the repository's order. While deliveries are paused, the +retry waits for Resume. A retried delivery of a repository now outside the +plugin's limit is not delivered; it is marked failed again. + +The same records are available to administrators through the API: + +| Route | What it does | +| --- | --- | +| `GET /api/instance/plugins/v1/:pluginId/activity` | Delivery health and the newest 100 records. | +| `POST /api/instance/plugins/v1/:pluginId/activity/:id/retry` | Retry a failed delivery (409 otherwise). | +| `POST /api/instance/plugins/v1/:pluginId/resume` | Resume paused deliveries (409 when not paused). | + +Mutations need the same `X-ProPR-Plugins: 1` JSON contract as the other plugin +routes, and Retry and Resume are recorded in the plugin's audit trail. diff --git a/docs/docs/features/web-ui.md b/docs/docs/features/web-ui.md index 816eebeb2b..72872219a3 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,18 @@ 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** shows the plugin's settings as a form generated from its manifest (text, number, Not set / Yes / No, list of choices, one-per-line list and a value per repository) and **Limit to repositories**; leave the limit empty to apply everywhere. A value the API refuses shows its message under the field. Required settings are marked **Required to enable**. +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. After an upgrade that asks for more, **Install** shows the new permissions and what changed in each package server, with **Approve**. +7. **What it added** links the plugin's managed items in Skills & MCP servers, where they carry a **From plugin** badge linking back and only their enabled checkbox, limits and automatic apply can be changed. **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/docs/docs/operations/api-reference.md b/docs/docs/operations/api-reference.md index 1f2687a23a..a363675be2 100644 --- a/docs/docs/operations/api-reference.md +++ b/docs/docs/operations/api-reference.md @@ -121,7 +121,7 @@ An MCP receipt in state `unknown` with `OUTCOME_UNKNOWN` may have had its extern ## Repository change events -`GET /api/repository-events?repository=owner/repo` reads a repository's durable change-event journal: pull request changes (`opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`), epic queue changes (`started`, `advanced`, `blocked`, `completed`) and plan issue status changes (`status_changed`). `GET /api/repository-events/wait` takes the same parameters plus `timeoutSeconds` (default 15, max 30) and blocks until a matching event exists after the cursor. The MCP tools `list_repository_events` and `wait_repository_events` and `propr events list|wait` return the same results. +`GET /api/repository-events?repository=owner/repo` reads a repository's durable change-event journal: pull request changes (`opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`), epic queue changes (`started`, `advanced`, `blocked`, `completed`), plan issue status changes (`status_changed`) and task lifecycle changes (`queued`, `started`, `completed`, `failed`, `cancelled`; `entityId` is the task id). See the [journal reference](../architecture/repository-event-journal.md#task) for each domain's payloads. `GET /api/repository-events/wait` takes the same parameters plus `timeoutSeconds` (default 15, max 30) and blocks until a matching event exists after the cursor. The MCP tools `list_repository_events` and `wait_repository_events` and `propr events list|wait` return the same results. Parameters: `repository` (required), `afterCursor`, `fromStart=true`, `domains` and `types` (comma-separated), `pullRequest`, `planId` and `limit` (1–100, default 50). The list returns `{events, cursor, truncated}`; the wait returns `{outcome, events, cursor, truncated, waitedMs, timeoutSeconds}`, where `outcome` is `events` or `timeout`. A wait returns at once when matching events already exist; a timeout is an ordinary `200` with the cursor unchanged. Each event has `cursor`, `domain`, `type`, `repository`, `entityId`, `payload` (identifiers and small scalars only) and `occurredAt`, plus `pullRequest`, `issueNumber`, `planId` and `head` when they apply. diff --git a/docs/docs/operations/configuration-reference.md b/docs/docs/operations/configuration-reference.md index 016278ed1e..b20499e2e8 100644 --- a/docs/docs/operations/configuration-reference.md +++ b/docs/docs/operations/configuration-reference.md @@ -162,6 +162,7 @@ Queue and worker behavior; see [Worker Runtime](../architecture/worker-runtime.m | `PUSH_RESCUE_BUNDLE_DIR` | `/rescue` (next to the SQLite database) | Durable directory for git bundles written when a rejected push cannot reach a rescue ref. Bundles are stored as `///.bundle`. Must be visible to both the worker (which writes) and the daemon (which prunes). | Optional. | | `PUSH_RESCUE_WORKTREE_RECORD_DIR` | `/rescue-worktrees` (next to the SQLite database) | Worker-controlled records of worktrees kept by the last salvage rung. These records protect salvage state independently of `WORKTREE_RETENTION_STRATEGY`; such worktrees are protected while the record has no cleanup deadline or its `scheduledCleanup` is still in the future; a `.retention-info.json` inside the worktree is informational, since repository contents could forge it. Must be shared at the same configured location by workers and the daemon, whose idle sweep reads these records. | Optional. | | `PUSH_RESCUE_SWEEP_INTERVAL_MS` | `21600000` (6 hours) | How often the daemon deletes expired rescue refs and bundles. | Optional tuning. | +| `PROPR_PLUGIN_PAUSE_AFTER_FAILURES` | `10` | Consecutive failed event deliveries after which the daemon pauses deliveries to a plugin until an administrator presses Resume. Values below `3` (the Degraded threshold) are ignored. See [Plugins → Health, Degraded and Resume](../features/plugins.md#health-degraded-and-resume). | Optional tuning. | | `GIT_DEFAULT_BRANCH` | `main` | Default base branch for PRs. Per-repo overrides use `GIT_DEFAULT_BRANCH__` — see [Branch Configuration](../features/branch-config.md). | Optional. | | `GIT_SHALLOW_CLONE_DEPTH` | Empty (full clones) | Depth for shallow clones; leave empty to clone full history. | Optional. | diff --git a/docs/mcp-coverage.md b/docs/mcp-coverage.md index 82c9b41083..ec8a028745 100644 --- a/docs/mcp-coverage.md +++ b/docs/mcp-coverage.md @@ -74,6 +74,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 8619a43356..7ce28e5d5b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -119,8 +119,9 @@ schedule, next run, `eventTriggers`, capabilities and autonomy mode; `get_automation_definition_contract` returns the shared field contract, including `runTriggers` (with `event`) and `eventTriggers` (the events, here `plan.issue_blocked`, their reason kinds and `maxRunsPerIssue`, the loop bound of 3 runs per issue of a plan), 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 run started by an event has `trigger: "event"` and `triggerEvent`: the repository, plan, execution, issue number, @@ -361,6 +362,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/sidebars.ts b/docs/sidebars.ts index 80234fb602..b8bd039446 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -123,6 +123,7 @@ const sidebars: SidebarsConfig = { 'architecture/agent-runtime', 'architecture/git-management', 'architecture/repository-event-journal', + 'architecture/plugin-runtime', 'operations/web-ui-integration', 'features/cli-workflows', { diff --git a/docs/static/openapi/propr-api.yaml b/docs/static/openapi/propr-api.yaml index 9085cbcfe3..d927f1f568 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: 326 - documented: 88 + total: 342 + documented: 104 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,39 +4861,33 @@ paths: x-propr-auth: member x-propr-permission: instance.manage_settings x-legacy-error: true - /api/instance/storage/v1/cleanup/execute: - post: - operationId: executeInstanceStorageCleanup - summary: Consume a principal-bound confirmation and revalidate before deleting any candidate. + /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: |- - 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 - in: header + - name: pluginId + in: path required: true - description: Must equal 1; browser calls require an authorized CORS preflight. + description: Plugin id, for example `example-pack`. schema: type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/InstanceStorageExecuteRequest" responses: "200": - description: Versioned instance storage response. + description: The plugin. content: application/json: schema: - $ref: "#/components/schemas/InstanceStorageResult" + $ref: "#/components/schemas/PluginDetailResponse" "401": $ref: "#/components/responses/LegacyUnauthorized" "403": @@ -4900,62 +4897,69 @@ paths: x-propr-auth: member x-propr-permission: instance.manage_settings x-legacy-error: true - /api/instance/storage/v1/cleanup/operations/{operationId}: + /api/instance/plugins/v1/{pluginId}/activity: get: - operationId: getInstanceStorageCleanupResult - summary: Read the initiating principal’s durable cleanup result. + operationId: getPluginActivity + summary: "Read the plugin's event delivery health (healthy, degraded or paused, with the last error) and its newest delivery records: time, event id and type, handler, outcome, duration, attempt, redacted error and log lines. Records are kept for 14 days." 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 - schema: - type: string - - name: instanceId - in: query - required: true + description: Plugin id, for example `example-pack`. schema: type: string responses: "200": - description: Versioned instance storage response. + description: Health and delivery records, newest first. content: application/json: schema: - $ref: "#/components/schemas/InstanceStorageResult" + $ref: "#/components/schemas/PluginActivityResponse" "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: + /api/instance/plugins/v1/{pluginId}/activity/{deliveryId}/retry: post: - operationId: previewInstanceStorageCleanup - summary: Preview an explicit instance/category selection; worktrees includes only proven generated payloads; terminal task history requires explicit taskIds. + operationId: retryPluginDelivery + summary: "Retry a failed event delivery: it gets one more attempt, with the same delivery id, the next time the dispatcher runs (after Resume while deliveries are paused). Refused (409) for a delivery that has not failed." 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: deliveryId + in: path + required: true + description: The activity record id (`id` of a delivery). + schema: + type: string + - name: X-ProPR-Plugins in: header required: true description: Must equal 1; browser calls require an authorized CORS preflight. @@ -4966,14 +4970,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/InstanceStoragePreviewRequest" + $ref: "#/components/schemas/PluginEmptyRequest" responses: "200": - description: Versioned instance storage response. + description: The delivery, pending again. content: application/json: schema: - $ref: "#/components/schemas/InstanceStoragePreview" + $ref: "#/components/schemas/PluginDeliveryResponse" "401": $ref: "#/components/responses/LegacyUnauthorized" "403": @@ -4983,21 +4987,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}/approve: post: - operationId: startInstanceDatabaseExport - summary: Start a private asynchronous customer database snapshot. - description: Requires the `instance.manage_settings` instance permission. + operationId: approvePlugin + summary: Approve the permission set of the current manifest after an upgrade, with the secret acknowledgements it needs again. + 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 +5018,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 +5035,90 @@ 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}/connections/{connectionName}: + put: + operationId: setPluginConnection + summary: Set or clear a `token` or `signing_secret` connection. The secret is sealed and write-only. + 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 + - name: connectionName + in: path required: true + description: Connection name from the manifest. 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/PluginConnectionRequest" 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}/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: - - 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: 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. + description: Must equal 1; browser calls require an authorized CORS preflight. schema: type: string requestBody: @@ -5094,14 +5126,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/InstanceDatabaseExportRequest" + $ref: "#/components/schemas/PluginOAuthClientRequest" 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 +5143,36 @@ 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}/connections/{connectionName}/oauth/complete: post: - operationId: downloadInstanceDatabaseExport - summary: Redeem an export once using current instance-admin authority. - description: Requires the `instance.manage_settings` instance permission. + 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: - - 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: 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. + description: Must equal 1; browser calls require an authorized CORS preflight. schema: type: string requestBody: @@ -5138,14 +5180,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/InstanceDatabaseExportRequest" + $ref: "#/components/schemas/PluginOAuthCompleteRequest" 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,26 +5197,51 @@ paths: x-propr-auth: member x-propr-permission: instance.manage_settings x-legacy-error: true - /api/instance/storage/v1/policy: - get: - operationId: getInstanceStoragePolicy - summary: Get opt-in repository cleanup policy (disabled, 24 idle hours by default). + /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: |- - 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: 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: Versioned instance storage response. + description: The plugin's new state. content: application/json: schema: - $ref: "#/components/schemas/InstanceStoragePolicy" + $ref: "#/components/schemas/PluginResponse" "401": $ref: "#/components/responses/LegacyUnauthorized" "403": @@ -5184,20 +5251,33 @@ 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/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: |- - 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: 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. @@ -5208,14 +5288,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/InstanceStoragePolicyRequest" + $ref: "#/components/schemas/PluginEmptyRequest" responses: "200": - description: Versioned instance storage response. + description: The authorization URL to open. content: application/json: schema: - $ref: "#/components/schemas/InstanceStoragePolicy" + $ref: "#/components/schemas/PluginOAuthStartResponse" "401": $ref: "#/components/responses/LegacyUnauthorized" "403": @@ -5225,23 +5305,45 @@ paths: 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. + /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. 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. + + Requires the `instance.manage_settings` instance permission. tags: - - System + - Configuration security: - sessionCookie: [] - bearerAuth: [] + parameters: + - 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. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PluginEnabledRequest" responses: "200": - description: Safe identifiers and sizes; no host paths or contents. + description: The plugin's new state. content: application/json: schema: - $ref: "#/components/schemas/InstanceRepositoryInventory" + $ref: "#/components/schemas/PluginResponse" "401": $ref: "#/components/responses/LegacyUnauthorized" "403": @@ -5251,490 +5353,704 @@ paths: 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/plugins/v1/{pluginId}/install: + post: + 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: |- - 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: [] - 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 + - 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. schema: type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PluginAcknowledgementRequest" responses: "200": - description: Success. The response body is not documented yet. + 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: 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/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: - - Automations - security: [] + - Configuration + security: + - sessionCookie: [] + - bearerAuth: [] parameters: - - name: runId + - 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. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PluginRepositoriesRequest" 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. + 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: fleetSecret - x-undocumented: true + x-propr-auth: member + x-propr-permission: instance.manage_settings x-legacy-error: true - /api/llm-logs: - get: - operationId: getApiLlmLogs - summary: GET /api/llm-logs - description: This route is registered but not documented yet. + /api/instance/plugins/v1/{pluginId}/resume: + post: + operationId: resumePluginDeliveries + summary: "Resume event deliveries paused after repeated failures: the failure count starts over, Degraded clears and waiting retries are due at once. Refused (409) when deliveries are not paused." + 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: - - Observability + - Configuration security: - sessionCookie: [] - bearerAuth: [] + parameters: + - 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. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PluginEmptyRequest" responses: "200": - description: Success. The response body is not documented yet. + description: The new delivery health. + content: + application/json: + schema: + $ref: "#/components/schemas/PluginHealthResponse" "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: - get: - operationId: getApiLlmMetrics - summary: GET /api/llm-metrics - description: This route is registered but not documented yet. + /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: - - Observability + - Configuration security: - sessionCookie: [] - bearerAuth: [] + parameters: + - 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. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PluginSettingsRequest" responses: "200": - description: Success. The response body is not documented yet. + 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-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} + /api/instance/plugins/v1/{pluginId}/uninstall: + post: + operationId: uninstallPlugin + 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: |- - This route is registered but not documented yet. + 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. - 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 + - Configuration security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: correlationId + - 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. schema: type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PluginUninstallRequest" 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. + description: The plugin's new state. content: application/json: schema: - $ref: "#/components/schemas/LegacyError" + $ref: "#/components/schemas/PluginResponse" + "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/mcp: + /api/instance/storage/v1/capabilities: get: - operationId: getApiMcp - summary: GET /api/mcp - description: This route is registered but not documented yet. + operationId: getInstanceStorageCapabilities + summary: Negotiate native instance storage capabilities and process generation. + description: Requires the `instance.manage_settings` instance permission. tags: - - MCP + - System security: - - mcpOAuth: - - read + - sessionCookie: [] + - bearerAuth: [] responses: "200": - description: Success. The response body is not documented yet. + description: Instance administrator capabilities; no Connect subscription required. + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceStorageCapabilities" "401": $ref: "#/components/responses/LegacyUnauthorized" + "403": + $ref: "#/components/responses/LegacyForbidden" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: mcp - x-undocumented: true + x-propr-auth: member + x-propr-permission: instance.manage_settings x-legacy-error: true + /api/instance/storage/v1/cleanup/execute: post: - operationId: mcpRequest - summary: MCP Streamable HTTP endpoint - description: JSON-RPC requests of the Model Context Protocol. Each tool checks the token's scopes. Returns `404` while MCP is disabled. See the MCP guide (docs/features/mcp) for the tool catalogue. - tags: - - MCP - security: - - mcpOAuth: - - read + 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. + + Requires the `instance.manage_settings` instance permission. + tags: + - System + 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 - description: A JSON-RPC 2.0 request or notification. content: application/json: schema: - $ref: "#/components/schemas/JsonObject" + $ref: "#/components/schemas/InstanceStorageExecuteRequest" responses: "200": - description: JSON-RPC response. - content: - application/json: - schema: - $ref: "#/components/schemas/JsonObject" - "202": - description: Notification accepted. - "401": - $ref: "#/components/responses/LegacyUnauthorized" - "404": - description: MCP is disabled on this instance. + description: Versioned instance storage response. content: application/json: schema: - $ref: "#/components/schemas/LegacyError" - default: - $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: mcp - x-legacy-error: true - delete: - operationId: deleteApiMcp - summary: DELETE /api/mcp - description: This route is registered but not documented yet. - tags: - - MCP - security: - - mcpOAuth: - - read - responses: - "200": - description: Success. The response body is not documented yet. + $ref: "#/components/schemas/InstanceStorageResult" "401": $ref: "#/components/responses/LegacyUnauthorized" + "403": + $ref: "#/components/responses/LegacyForbidden" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: mcp - x-undocumented: true + x-propr-auth: member + x-propr-permission: instance.manage_settings x-legacy-error: true - /api/metrics: + /api/instance/storage/v1/cleanup/operations/{operationId}: get: - operationId: getApiMetrics - summary: GET /api/metrics - description: This route is registered but not documented yet. + 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: - - Observability + - 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: Success. The response body is not documented yet. + description: Versioned instance storage response. + content: + application/json: + schema: + $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-undocumented: true + x-propr-permission: instance.manage_settings x-legacy-error: true - /api/notifications: - get: - operationId: getApiNotifications - summary: GET /api/notifications - description: This route is registered but not documented yet. + /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. + + Requires the `instance.manage_settings` instance permission. tags: - - Notifications + - System 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: Success. The response body is not documented yet. + description: Versioned instance storage response. + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceStoragePreview" "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/notifications/capabilities: - get: - operationId: getApiNotificationsCapabilities - summary: GET /api/notifications/capabilities - description: This route is registered but not documented yet. + /api/instance/storage/v1/exports: + post: + operationId: startInstanceDatabaseExport + summary: Start a private asynchronous customer database snapshot. + description: Requires the `instance.manage_settings` instance permission. tags: - - Notifications + - System security: - sessionCookie: [] - bearerAuth: [] + parameters: + - 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: member - x-undocumented: true + x-propr-permission: instance.manage_settings x-legacy-error: true - /api/notifications/config: + /api/instance/storage/v1/exports/{operationId}: get: - operationId: getApiNotificationsConfig - summary: GET /api/notifications/config - description: This route is registered but not documented yet. + operationId: getInstanceDatabaseExport + summary: Read the initiating administrator’s export status. + description: Requires the `instance.manage_settings` instance permission. tags: - - Notifications + - System security: - sessionCookie: [] - bearerAuth: [] + parameters: + - 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. + 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-query-type: InstanceDatabaseExportQuery x-propr-auth: member - x-undocumented: true + x-propr-permission: instance.manage_settings x-legacy-error: true - /api/notifications/dismiss-all: + /api/instance/storage/v1/exports/{operationId}/cancel: post: - operationId: postApiNotificationsDismissAll - summary: POST /api/notifications/dismiss-all - description: This route is registered but not documented yet. + operationId: cancelInstanceDatabaseExport + summary: Abort and discard an administrator’s private export. + description: Requires the `instance.manage_settings` instance permission. tags: - - Notifications + - System 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: 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: member - x-undocumented: true + x-propr-permission: instance.manage_settings x-legacy-error: true - /api/notifications/preferences: - get: - operationId: getApiNotificationsPreferences - summary: GET /api/notifications/preferences - 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: - - Notifications + - System 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 - patch: - operationId: patchApiNotificationsPreferences - summary: PATCH /api/notifications/preferences - description: This route is registered but not documented yet. + /api/instance/storage/v1/policy: + 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. tags: - - Notifications + - 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/notifications/push-subscriptions: - get: - operationId: getApiNotificationsPushSubscriptions - summary: GET /api/notifications/push-subscriptions - description: This route is registered but not documented yet. + put: + operationId: updateInstanceStoragePolicy + summary: Persist the explicitly selected instance cleanup policy. + 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: - - Notifications + - System 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/InstanceStoragePolicyRequest" 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 - post: - operationId: postApiNotificationsPushSubscriptions - summary: POST /api/notifications/push-subscriptions - description: This route is registered but not documented yet. + /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: - - Notifications + - System security: - sessionCookie: [] - bearerAuth: [] responses: "200": - description: Success. The response body is not documented yet. + 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-undocumented: true + x-propr-permission: instance.manage_settings x-legacy-error: true - delete: - operationId: deleteApiNotificationsPushSubscriptions - summary: DELETE /api/notifications/push-subscriptions - description: This route is registered but not documented yet. + /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: - - Notifications + - 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/InstanceStorageUsage" "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/notifications/push-subscriptions/{subscriptionId}: - delete: - operationId: deleteApiNotificationsPushSubscriptionsSubscriptionId - summary: DELETE /api/notifications/push-subscriptions/{subscriptionId} + /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: - - Notifications - security: - - sessionCookie: [] - - bearerAuth: [] + - Automations + security: [] parameters: - - name: subscriptionId + - name: runId in: path required: true schema: @@ -5742,47 +6058,21 @@ paths: 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-propr-auth: public x-undocumented: true x-legacy-error: true - /api/notifications/unread-count: - get: - operationId: getNotificationUnreadCount - summary: Count unread inbox notifications - tags: - - Notifications - security: - - sessionCookie: [] - - bearerAuth: [] - responses: - "200": - description: Unread notifications of the caller. - content: - application/json: - schema: - $ref: "#/components/schemas/NotificationUnreadCount" - "401": - $ref: "#/components/responses/LegacyUnauthorized" - default: - $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: member - x-legacy-error: true - /api/notifications/{id}/dismiss: + /api/internal/agent-runs/{runId}/mcp-grants/revoke: post: - operationId: postApiNotificationsIdDismiss - summary: POST /api/notifications/{id}/dismiss + operationId: postApiInternalAgentRunsRunIdMcpGrantsRevoke + summary: POST /api/internal/agent-runs/{runId}/mcp-grants/revoke description: This route is registered but not documented yet. tags: - - Notifications - security: - - sessionCookie: [] - - bearerAuth: [] + - Automations + security: [] parameters: - - name: id + - name: runId in: path required: true schema: @@ -5790,86 +6080,69 @@ paths: 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-propr-auth: public x-undocumented: true x-legacy-error: true - /api/notifications/{id}/read: - post: - operationId: postApiNotificationsIdRead - summary: POST /api/notifications/{id}/read + /api/internal/hosted/bootstrap: + get: + operationId: getApiInternalHostedBootstrap + summary: GET /api/internal/hosted/bootstrap description: This route is registered but not documented yet. tags: - - Notifications + - System security: - - sessionCookie: [] - - bearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string + - fleetSecret: [] 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-propr-auth: fleetSecret x-undocumented: true x-legacy-error: true - /api/planner/abort: - post: - operationId: postApiPlannerAbort - summary: POST /api/planner/abort + /api/internal/hosted/queue: + get: + operationId: getApiInternalHostedQueue + summary: GET /api/internal/hosted/queue description: This route is registered but not documented yet. tags: - - Planner + - System security: - - sessionCookie: [] - - bearerAuth: [] + - fleetSecret: [] 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-propr-auth: fleetSecret x-undocumented: true x-legacy-error: true - /api/planner/abort-refinement: - post: - operationId: postApiPlannerAbortRefinement - summary: POST /api/planner/abort-refinement + /api/internal/hosted/status: + get: + operationId: getApiInternalHostedStatus + summary: GET /api/internal/hosted/status description: This route is registered but not documented yet. tags: - - Planner + - System security: - - sessionCookie: [] - - bearerAuth: [] + - fleetSecret: [] 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-propr-auth: fleetSecret x-undocumented: true x-legacy-error: true - /api/planner/context/stats: - post: - operationId: postApiPlannerContextStats - summary: POST /api/planner/context/stats + /api/llm-logs: + get: + operationId: getApiLlmLogs + summary: GET /api/llm-logs description: This route is registered but not documented yet. tags: - - Planner + - Observability security: - sessionCookie: [] - bearerAuth: [] @@ -5883,13 +6156,13 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/drafts: + /api/llm-metrics: get: - operationId: getApiPlannerDrafts - summary: GET /api/planner/drafts + operationId: getApiLlmMetrics + summary: GET /api/llm-metrics description: This route is registered but not documented yet. tags: - - Planner + - Observability security: - sessionCookie: [] - bearerAuth: [] @@ -5903,32 +6176,124 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - post: - operationId: postApiPlannerDrafts - summary: POST /api/planner/drafts - description: This route is registered but not documented yet. + /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: - - Planner + - 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: + schema: + $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/drafts/repositories: + /api/mcp: get: - operationId: getApiPlannerDraftsRepositories - summary: GET /api/planner/drafts/repositories + operationId: getApiMcp + summary: GET /api/mcp description: This route is registered but not documented yet. tags: - - Planner + - MCP + security: + - mcpOAuth: + - read + responses: + "200": + description: Success. The response body is not documented yet. + "401": + $ref: "#/components/responses/LegacyUnauthorized" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: mcp + x-undocumented: true + x-legacy-error: true + post: + operationId: mcpRequest + summary: MCP Streamable HTTP endpoint + description: JSON-RPC requests of the Model Context Protocol. Each tool checks the token's scopes. Returns `404` while MCP is disabled. See the MCP guide (docs/features/mcp) for the tool catalogue. + tags: + - MCP + security: + - mcpOAuth: + - read + requestBody: + required: true + description: A JSON-RPC 2.0 request or notification. + content: + application/json: + schema: + $ref: "#/components/schemas/JsonObject" + responses: + "200": + description: JSON-RPC response. + content: + application/json: + schema: + $ref: "#/components/schemas/JsonObject" + "202": + description: Notification accepted. + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "404": + description: MCP is disabled on this instance. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: mcp + x-legacy-error: true + delete: + operationId: deleteApiMcp + summary: DELETE /api/mcp + description: This route is registered but not documented yet. + tags: + - MCP + security: + - mcpOAuth: + - read + responses: + "200": + description: Success. The response body is not documented yet. + "401": + $ref: "#/components/responses/LegacyUnauthorized" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: mcp + x-undocumented: true + x-legacy-error: true + /api/metrics: + get: + operationId: getApiMetrics + summary: GET /api/metrics + description: This route is registered but not documented yet. + tags: + - Observability security: - sessionCookie: [] - bearerAuth: [] @@ -5942,22 +6307,16 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/drafts/{id}: + /api/notifications: get: - operationId: getApiPlannerDraftsId - summary: GET /api/planner/drafts/{id} + operationId: getApiNotifications + summary: GET /api/notifications description: This route is registered but not documented yet. tags: - - Planner + - Notifications security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -5968,21 +6327,16 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - put: - operationId: putApiPlannerDraftsId - summary: PUT /api/planner/drafts/{id} + /api/notifications/capabilities: + get: + operationId: getApiNotificationsCapabilities + summary: GET /api/notifications/capabilities description: This route is registered but not documented yet. tags: - - Planner + - Notifications security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -5993,21 +6347,16 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - delete: - operationId: deleteApiPlannerDraftsId - summary: DELETE /api/planner/drafts/{id} + /api/notifications/config: + get: + operationId: getApiNotificationsConfig + summary: GET /api/notifications/config description: This route is registered but not documented yet. tags: - - Planner + - Notifications security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -6018,22 +6367,16 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/drafts/{id}/attachments: + /api/notifications/dismiss-all: post: - operationId: postApiPlannerDraftsIdAttachments - summary: POST /api/planner/drafts/{id}/attachments + operationId: postApiNotificationsDismissAll + summary: POST /api/notifications/dismiss-all description: This route is registered but not documented yet. tags: - - Planner + - Notifications security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -6044,27 +6387,16 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/drafts/{id}/attachments/{attachmentId}: + /api/notifications/preferences: get: - operationId: getApiPlannerDraftsIdAttachmentsAttachmentId - summary: GET /api/planner/drafts/{id}/attachments/{attachmentId} + operationId: getApiNotificationsPreferences + summary: GET /api/notifications/preferences description: This route is registered but not documented yet. tags: - - Planner + - Notifications security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - - name: attachmentId - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -6075,26 +6407,15 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - delete: - operationId: deleteApiPlannerDraftsIdAttachmentsAttachmentId - summary: DELETE /api/planner/drafts/{id}/attachments/{attachmentId} + patch: + operationId: patchApiNotificationsPreferences + summary: PATCH /api/notifications/preferences description: This route is registered but not documented yet. tags: - - Planner + - Notifications security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - - name: attachmentId - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -6105,22 +6426,16 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/drafts/{id}/execution-queue: + /api/notifications/push-subscriptions: get: - operationId: getApiPlannerDraftsIdExecutionQueue - summary: GET /api/planner/drafts/{id}/execution-queue + operationId: getApiNotificationsPushSubscriptions + summary: GET /api/notifications/push-subscriptions description: This route is registered but not documented yet. tags: - - Planner + - Notifications security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -6132,20 +6447,14 @@ paths: x-undocumented: true x-legacy-error: true post: - operationId: postApiPlannerDraftsIdExecutionQueue - summary: POST /api/planner/drafts/{id}/execution-queue + operationId: postApiNotificationsPushSubscriptions + summary: POST /api/notifications/push-subscriptions description: This route is registered but not documented yet. tags: - - Planner + - Notifications security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -6156,190 +6465,80 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/drafts/{id}/execution-queue/blocked-events: - get: - operationId: listPlanExecutionBlockedEvents - summary: List the blocked events of a plan's epic execution - description: Every time an issue of the execution became blocked (with its reason kind and text), and every resolution of such a blocker, oldest first. A repeated observation of the same blocked state is recorded once. Page with `cursor`; poll with the last `nextCursor` to read only new events. + delete: + operationId: deleteApiNotificationsPushSubscriptions + summary: DELETE /api/notifications/push-subscriptions + description: This route is registered but not documented yet. tags: - - Planner + - Notifications security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: id - in: path - required: true - description: The plan (draft) id. - schema: - type: string - - name: executionId - in: query - required: false - description: Defaults to the plan's current execution. - schema: - type: string - - name: cursor - in: query - required: false - description: "`nextCursor` of an earlier page; omit to read from the first event." - schema: - type: string - - name: limit - in: query - required: false - description: Page size; defaults to 50. - schema: - type: integer - minimum: 1 - maximum: 200 responses: "200": - description: A page of events. - content: - application/json: - schema: - $ref: "#/components/schemas/EpicQueueBlockedEventPage" - "400": - description: The cursor or limit is invalid. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" + description: Success. The response body is not documented yet. "401": $ref: "#/components/responses/LegacyUnauthorized" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-query-type: EpicQueueBlockedEventsQuery x-propr-auth: member + x-undocumented: true x-legacy-error: true - /api/planner/drafts/{id}/execution-queue/issues/{issueNumber}/retry: - post: - operationId: retryPlanExecutionIssue - summary: Retry a blocked issue of an epic execution - description: "Clears the blocker and dispatches the issue again under the queue's normal guards (a paused plan holds it; a graph starts it once ready within its limit). An issue closed without merge is reopened first; one whose run reported a blocker is put back to pending. A wait for a human merge or a label handoff keeps its claim and is re-evaluated. Works for issues started through MCP `implement_plan`. Idempotent: repeating it answers `already_retried`." + /api/notifications/push-subscriptions/{subscriptionId}: + delete: + operationId: deleteApiNotificationsPushSubscriptionsSubscriptionId + summary: DELETE /api/notifications/push-subscriptions/{subscriptionId} + description: This route is registered but not documented yet. tags: - - Planner + - Notifications security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: id - in: path - required: true - description: The plan (draft) id. - schema: - type: string - - name: issueNumber + - name: subscriptionId in: path required: true - description: GitHub issue number of an issue in the execution. schema: type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/EpicQueueRetryRequest" responses: "200": - description: The blocker is cleared and the issue is dispatched under the normal guards. - content: - application/json: - schema: - $ref: "#/components/schemas/EpicQueueActionResult" - "400": - description: The body or issue number is invalid. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" + description: Success. The response body is not documented yet. "401": $ref: "#/components/responses/LegacyUnauthorized" - "404": - description: The issue is not part of the execution. - content: - application/json: - schema: - $ref: "#/components/schemas/EpicQueueActionError" - "409": - description: "Refused: a stale execution (`STALE_EXECUTION`), an issue in flight (`IN_FLIGHT`) or already done (`ALREADY_DONE`), a changed state (`STATE_CHANGED`), or, for retry, an issue that is not blocked (`NOT_BLOCKED`) or waits for setup (`NOT_RETRYABLE`)." - content: - application/json: - schema: - $ref: "#/components/schemas/EpicQueueActionError" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member + x-undocumented: true x-legacy-error: true - /api/planner/drafts/{id}/execution-queue/issues/{issueNumber}/skip: - post: - operationId: skipPlanExecutionIssue - summary: Skip a pending or blocked issue of an epic execution - description: "The queue treats the issue as finished for scheduling: a sequential execution moves on to the next issue; in a graph execution its dependents wait on the skipped prerequisite unless `satisfiesDependents` is true. The GitHub issue is never closed or edited. Idempotent: skipping a skipped issue answers `already_skipped`." + /api/notifications/unread-count: + get: + operationId: getNotificationUnreadCount + summary: Count unread inbox notifications tags: - - Planner + - Notifications security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: id - in: path - required: true - description: The plan (draft) id. - schema: - type: string - - name: issueNumber - in: path - required: true - description: GitHub issue number of an issue in the execution. - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/EpicQueueSkipRequest" responses: "200": - description: The issue is skipped. - content: - application/json: - schema: - $ref: "#/components/schemas/EpicQueueActionResult" - "400": - description: The body or issue number is invalid. + description: Unread notifications of the caller. content: application/json: schema: - $ref: "#/components/schemas/LegacyError" + $ref: "#/components/schemas/NotificationUnreadCount" "401": $ref: "#/components/responses/LegacyUnauthorized" - "404": - description: The issue is not part of the execution. - content: - application/json: - schema: - $ref: "#/components/schemas/EpicQueueActionError" - "409": - description: "Refused: a stale execution (`STALE_EXECUTION`), an issue in flight (`IN_FLIGHT`) or already done (`ALREADY_DONE`), a changed state (`STATE_CHANGED`), or, for retry, an issue that is not blocked (`NOT_BLOCKED`) or waits for setup (`NOT_RETRYABLE`)." - content: - application/json: - schema: - $ref: "#/components/schemas/EpicQueueActionError" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member x-legacy-error: true - /api/planner/drafts/{id}/execution-settings: - patch: - operationId: patchApiPlannerDraftsIdExecutionSettings - summary: PATCH /api/planner/drafts/{id}/execution-settings + /api/notifications/{id}/dismiss: + post: + operationId: postApiNotificationsIdDismiss + summary: POST /api/notifications/{id}/dismiss description: This route is registered but not documented yet. tags: - - Planner + - Notifications security: - sessionCookie: [] - bearerAuth: [] @@ -6359,13 +6558,13 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/drafts/{id}/issues: - get: - operationId: getApiPlannerDraftsIdIssues - summary: GET /api/planner/drafts/{id}/issues + /api/notifications/{id}/read: + post: + operationId: postApiNotificationsIdRead + summary: POST /api/notifications/{id}/read description: This route is registered but not documented yet. tags: - - Planner + - Notifications security: - sessionCookie: [] - bearerAuth: [] @@ -6385,27 +6584,16 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/drafts/{id}/issues/{issueNumber}: - patch: - operationId: patchApiPlannerDraftsIdIssuesIssueNumber - summary: PATCH /api/planner/drafts/{id}/issues/{issueNumber} + /api/planner/abort: + post: + operationId: postApiPlannerAbort + summary: POST /api/planner/abort description: This route is registered but not documented yet. tags: - Planner security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - - name: issueNumber - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -6416,27 +6604,16 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/drafts/{id}/issues/{issueNumber}/implement: + /api/planner/abort-refinement: post: - operationId: postApiPlannerDraftsIdIssuesIssueNumberImplement - summary: POST /api/planner/drafts/{id}/issues/{issueNumber}/implement + operationId: postApiPlannerAbortRefinement + summary: POST /api/planner/abort-refinement description: This route is registered but not documented yet. tags: - Planner security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - - name: issueNumber - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -6447,22 +6624,16 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/drafts/{id}/pause: + /api/planner/context/stats: post: - operationId: postApiPlannerDraftsIdPause - summary: POST /api/planner/drafts/{id}/pause + operationId: postApiPlannerContextStats + summary: POST /api/planner/context/stats description: This route is registered but not documented yet. tags: - Planner security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -6473,22 +6644,16 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/drafts/{id}/repository-info: + /api/planner/drafts: get: - operationId: getApiPlannerDraftsIdRepositoryInfo - summary: GET /api/planner/drafts/{id}/repository-info + operationId: getApiPlannerDrafts + summary: GET /api/planner/drafts description: This route is registered but not documented yet. tags: - Planner security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -6499,22 +6664,15 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/drafts/{id}/reset-to-setup: post: - operationId: postApiPlannerDraftsIdResetToSetup - summary: POST /api/planner/drafts/{id}/reset-to-setup + operationId: postApiPlannerDrafts + summary: POST /api/planner/drafts description: This route is registered but not documented yet. tags: - Planner security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -6525,22 +6683,16 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/drafts/{id}/resume: - post: - operationId: postApiPlannerDraftsIdResume - summary: POST /api/planner/drafts/{id}/resume + /api/planner/drafts/repositories: + get: + operationId: getApiPlannerDraftsRepositories + summary: GET /api/planner/drafts/repositories description: This route is registered but not documented yet. tags: - Planner security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -6551,10 +6703,10 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/drafts/{id}/revise: - post: - operationId: postApiPlannerDraftsIdRevise - summary: POST /api/planner/drafts/{id}/revise + /api/planner/drafts/{id}: + get: + operationId: getApiPlannerDraftsId + summary: GET /api/planner/drafts/{id} description: This route is registered but not documented yet. tags: - Planner @@ -6577,10 +6729,9 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/drafts/{id}/revisions: - get: - operationId: getApiPlannerDraftsIdRevisions - summary: GET /api/planner/drafts/{id}/revisions + put: + operationId: putApiPlannerDraftsId + summary: PUT /api/planner/drafts/{id} description: This route is registered but not documented yet. tags: - Planner @@ -6603,10 +6754,9 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/drafts/{id}/revisions/{revisionId}: - get: - operationId: getApiPlannerDraftsIdRevisionsRevisionId - summary: GET /api/planner/drafts/{id}/revisions/{revisionId} + delete: + operationId: deleteApiPlannerDraftsId + summary: DELETE /api/planner/drafts/{id} description: This route is registered but not documented yet. tags: - Planner @@ -6619,11 +6769,6 @@ paths: required: true schema: type: string - - name: revisionId - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -6634,10 +6779,10 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/drafts/{id}/revisions/{revisionId}/restore: + /api/planner/drafts/{id}/attachments: post: - operationId: postApiPlannerDraftsIdRevisionsRevisionIdRestore - summary: POST /api/planner/drafts/{id}/revisions/{revisionId}/restore + operationId: postApiPlannerDraftsIdAttachments + summary: POST /api/planner/drafts/{id}/attachments description: This route is registered but not documented yet. tags: - Planner @@ -6650,11 +6795,6 @@ paths: required: true schema: type: string - - name: revisionId - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -6665,16 +6805,27 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/finalize: - post: - operationId: postApiPlannerFinalize - summary: POST /api/planner/finalize + /api/planner/drafts/{id}/attachments/{attachmentId}: + get: + operationId: getApiPlannerDraftsIdAttachmentsAttachmentId + summary: GET /api/planner/drafts/{id}/attachments/{attachmentId} description: This route is registered but not documented yet. tags: - Planner security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: attachmentId + in: path + required: true + schema: + type: string responses: "200": description: Success. The response body is not documented yet. @@ -6685,16 +6836,26 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/generate: - post: - operationId: postApiPlannerGenerate - summary: POST /api/planner/generate + delete: + operationId: deleteApiPlannerDraftsIdAttachmentsAttachmentId + summary: DELETE /api/planner/drafts/{id}/attachments/{attachmentId} description: This route is registered but not documented yet. tags: - Planner security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: attachmentId + in: path + required: true + schema: + type: string responses: "200": description: Success. The response body is not documented yet. @@ -6705,16 +6866,22 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/preview: - post: - operationId: postApiPlannerPreview - summary: POST /api/planner/preview + /api/planner/drafts/{id}/execution-queue: + get: + operationId: getApiPlannerDraftsIdExecutionQueue + summary: GET /api/planner/drafts/{id}/execution-queue description: This route is registered but not documented yet. tags: - Planner security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string responses: "200": description: Success. The response body is not documented yet. @@ -6725,16 +6892,21 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/preview/context: post: - operationId: postApiPlannerPreviewContext - summary: POST /api/planner/preview/context + operationId: postApiPlannerDraftsIdExecutionQueue + summary: POST /api/planner/drafts/{id}/execution-queue description: This route is registered but not documented yet. tags: - Planner security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string responses: "200": description: Success. The response body is not documented yet. @@ -6745,134 +6917,195 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/planner/refine: - post: - operationId: postApiPlannerRefine - summary: POST /api/planner/refine - description: This route is registered but not documented yet. + /api/planner/drafts/{id}/execution-queue/blocked-events: + get: + operationId: listPlanExecutionBlockedEvents + summary: List the blocked events of a plan's epic execution + description: Every time an issue of the execution became blocked (with its reason kind and text), and every resolution of such a blocker, oldest first. A repeated observation of the same blocked state is recorded once. Page with `cursor`; poll with the last `nextCursor` to read only new events. tags: - Planner security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: id + in: path + required: true + description: The plan (draft) id. + schema: + type: string + - name: executionId + in: query + required: false + description: Defaults to the plan's current execution. + schema: + type: string + - name: cursor + in: query + required: false + description: "`nextCursor` of an earlier page; omit to read from the first event." + schema: + type: string + - name: limit + in: query + required: false + description: Page size; defaults to 50. + schema: + type: integer + minimum: 1 + maximum: 200 responses: "200": - description: Success. The response body is not documented yet. + description: A page of events. + content: + application/json: + schema: + $ref: "#/components/schemas/EpicQueueBlockedEventPage" + "400": + description: The cursor or limit is invalid. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" "401": $ref: "#/components/responses/LegacyUnauthorized" default: $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-query-type: EpicQueueBlockedEventsQuery x-propr-auth: member - x-undocumented: true x-legacy-error: true - /api/planner/relevance: + /api/planner/drafts/{id}/execution-queue/issues/{issueNumber}/retry: post: - operationId: postApiPlannerRelevance - summary: POST /api/planner/relevance - description: This route is registered but not documented yet. + operationId: retryPlanExecutionIssue + summary: Retry a blocked issue of an epic execution + description: "Clears the blocker and dispatches the issue again under the queue's normal guards (a paused plan holds it; a graph starts it once ready within its limit). An issue closed without merge is reopened first; one whose run reported a blocker is put back to pending. A wait for a human merge or a label handoff keeps its claim and is re-evaluated. Works for issues started through MCP `implement_plan`. Idempotent: repeating it answers `already_retried`." tags: - Planner security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: id + in: path + required: true + description: The plan (draft) id. + schema: + type: string + - name: issueNumber + in: path + required: true + description: GitHub issue number of an issue in the execution. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EpicQueueRetryRequest" responses: "200": - description: Success. The response body is not documented yet. + description: The blocker is cleared and the issue is dispatched under the normal guards. + content: + application/json: + schema: + $ref: "#/components/schemas/EpicQueueActionResult" + "400": + description: The body or issue number is invalid. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" "401": $ref: "#/components/responses/LegacyUnauthorized" + "404": + description: The issue is not part of the execution. + content: + application/json: + schema: + $ref: "#/components/schemas/EpicQueueActionError" + "409": + description: "Refused: a stale execution (`STALE_EXECUTION`), an issue in flight (`IN_FLIGHT`) or already done (`ALREADY_DONE`), a changed state (`STATE_CHANGED`), or, for retry, an issue that is not blocked (`NOT_BLOCKED`) or waits for setup (`NOT_RETRYABLE`)." + content: + application/json: + schema: + $ref: "#/components/schemas/EpicQueueActionError" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member - x-undocumented: true x-legacy-error: true - /api/planner/validate-context-repository: + /api/planner/drafts/{id}/execution-queue/issues/{issueNumber}/skip: post: - operationId: postApiPlannerValidateContextRepository - summary: POST /api/planner/validate-context-repository - description: This route is registered but not documented yet. + operationId: skipPlanExecutionIssue + summary: Skip a pending or blocked issue of an epic execution + description: "The queue treats the issue as finished for scheduling: a sequential execution moves on to the next issue; in a graph execution its dependents wait on the skipped prerequisite unless `satisfiesDependents` is true. The GitHub issue is never closed or edited. Idempotent: skipping a skipped issue answers `already_skipped`." tags: - Planner 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/preview-media/comments/{owner}/{repo}/{number}/{assetId}: - get: - operationId: getApiPreviewMediaCommentsOwnerRepoNumberAssetId - summary: GET /api/preview-media/comments/{owner}/{repo}/{number}/{assetId} - description: This route is registered but not documented yet. - tags: - - Repositories - security: - - sessionCookie: [] - - bearerAuth: [] parameters: - - name: owner - in: path - required: true - schema: - type: string - - name: repo - in: path - required: true - schema: - type: string - - name: number + - name: id in: path required: true + description: The plan (draft) id. schema: type: string - - name: assetId + - name: issueNumber in: path required: true + description: GitHub issue number of an issue in the execution. schema: type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EpicQueueSkipRequest" 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 + description: The issue is skipped. + content: + application/json: + schema: + $ref: "#/components/schemas/EpicQueueActionResult" + "400": + description: The body or issue number is invalid. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "404": + description: The issue is not part of the execution. + content: + application/json: + schema: + $ref: "#/components/schemas/EpicQueueActionError" + "409": + description: "Refused: a stale execution (`STALE_EXECUTION`), an issue in flight (`IN_FLIGHT`) or already done (`ALREADY_DONE`), a changed state (`STATE_CHANGED`), or, for retry, an issue that is not blocked (`NOT_BLOCKED`) or waits for setup (`NOT_RETRYABLE`)." + content: + application/json: + schema: + $ref: "#/components/schemas/EpicQueueActionError" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member x-legacy-error: true - /api/preview-media/pulls/{owner}/{repo}/{number}/{assetId}: - get: - operationId: getApiPreviewMediaPullsOwnerRepoNumberAssetId - summary: GET /api/preview-media/pulls/{owner}/{repo}/{number}/{assetId} + /api/planner/drafts/{id}/execution-settings: + patch: + operationId: patchApiPlannerDraftsIdExecutionSettings + summary: PATCH /api/planner/drafts/{id}/execution-settings description: This route is registered but not documented yet. tags: - - Repositories + - Planner security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: owner - in: path - required: true - schema: - type: string - - name: repo - in: path - required: true - schema: - type: string - - name: number - in: path - required: true - schema: - type: string - - name: assetId + - name: id in: path required: true schema: @@ -6887,72 +7120,49 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/pull-requests/{number}/readiness: + /api/planner/drafts/{id}/issues: get: - operationId: getPullRequestReadiness - summary: Get the readiness verdict of a pull request - description: "Read-only: one verdict (`ready`, `waiting`, `blocked`, `merged`, `closed`, `unknown`) with a coded reason per gate, evaluated with the ProPR GitHub App identity from the same signals auto-merge uses (check runs, legacy commit statuses, non-blocking checks, mergeability, reviews, the latest ProPR AI review, Ultrafix state, active ProPR work and, when auto-merge is requested, the auto-merge policy). It never writes to GitHub or Redis, never updates the branch, never merges and never arms or withdraws auto-merge. The caller needs read access to the repository on GitHub (the check MCP `get_pull_request_readiness` applies); the App identity is used only after it is proven. See the gate and reason-code tables in the API reference." + operationId: getApiPlannerDraftsIdIssues + summary: GET /api/planner/drafts/{id}/issues + description: This route is registered but not documented yet. tags: - - Tasks + - Planner security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: number + - name: id in: path required: true - description: Pull request number. - schema: - type: string - - name: repository - in: query - required: true - description: "`owner/repo` of the pull request." schema: type: string responses: "200": - description: The readiness verdict. - content: - application/json: - schema: - $ref: "#/components/schemas/PullRequestReadiness" - "400": - description: "`INVALID_INPUT`: the number or repository is malformed." - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + description: Success. The response body is not documented yet. "401": - $ref: "#/components/responses/EnvelopeUnauthorized" - "404": - description: "`REPOSITORY_NOT_ACCESSIBLE`: the caller cannot read the repository on GitHub, so nothing is evaluated; or `PULL_REQUEST_NOT_FOUND`: the pull request does not exist or the GitHub App cannot see it." - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "502": - description: "`GITHUB_UNAVAILABLE`: GitHub did not return the pull request, or could not be reached to check repository access; the read is safe to retry." - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + $ref: "#/components/responses/LegacyUnauthorized" default: - $ref: "#/components/responses/EnvelopeUnexpectedError" - x-propr-query-type: PullRequestReadinessQuery + $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member - /api/pull-requests/{number}/scores: - get: - operationId: getApiPullRequestsNumberScores - summary: GET /api/pull-requests/{number}/scores + x-undocumented: true + x-legacy-error: true + /api/planner/drafts/{id}/issues/{issueNumber}: + patch: + operationId: patchApiPlannerDraftsIdIssuesIssueNumber + summary: PATCH /api/planner/drafts/{id}/issues/{issueNumber} description: This route is registered but not documented yet. tags: - - Tasks + - Planner security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: number + - name: id + in: path + required: true + schema: + type: string + - name: issueNumber in: path required: true schema: @@ -6967,16 +7177,27 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/queue/stats: - get: - operationId: getApiQueueStats - summary: GET /api/queue/stats + /api/planner/drafts/{id}/issues/{issueNumber}/implement: + post: + operationId: postApiPlannerDraftsIdIssuesIssueNumberImplement + summary: POST /api/planner/drafts/{id}/issues/{issueNumber}/implement description: This route is registered but not documented yet. tags: - - Observability + - Planner security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: issueNumber + in: path + required: true + schema: + type: string responses: "200": description: Success. The response body is not documented yet. @@ -6987,16 +7208,22 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/repos/chat: + /api/planner/drafts/{id}/pause: post: - operationId: postApiReposChat - summary: POST /api/repos/chat + operationId: postApiPlannerDraftsIdPause + summary: POST /api/planner/drafts/{id}/pause description: This route is registered but not documented yet. tags: - - Repositories + - Planner security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string responses: "200": description: Success. The response body is not documented yet. @@ -7007,16 +7234,22 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/repos/chat/messages: + /api/planner/drafts/{id}/repository-info: get: - operationId: getApiReposChatMessages - summary: GET /api/repos/chat/messages + operationId: getApiPlannerDraftsIdRepositoryInfo + summary: GET /api/planner/drafts/{id}/repository-info description: This route is registered but not documented yet. tags: - - Repositories + - Planner security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string responses: "200": description: Success. The response body is not documented yet. @@ -7027,15 +7260,22 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true + /api/planner/drafts/{id}/reset-to-setup: post: - operationId: postApiReposChatMessages - summary: POST /api/repos/chat/messages + operationId: postApiPlannerDraftsIdResetToSetup + summary: POST /api/planner/drafts/{id}/reset-to-setup description: This route is registered but not documented yet. tags: - - Repositories + - Planner security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string responses: "200": description: Success. The response body is not documented yet. @@ -7046,15 +7286,22 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - delete: - operationId: deleteApiReposChatMessages - summary: DELETE /api/repos/chat/messages + /api/planner/drafts/{id}/resume: + post: + operationId: postApiPlannerDraftsIdResume + summary: POST /api/planner/drafts/{id}/resume description: This route is registered but not documented yet. tags: - - Repositories + - Planner security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string responses: "200": description: Success. The response body is not documented yet. @@ -7065,18 +7312,18 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/repos/chat/messages/{messageId}: - delete: - operationId: deleteApiReposChatMessagesMessageId - summary: DELETE /api/repos/chat/messages/{messageId} + /api/planner/drafts/{id}/revise: + post: + operationId: postApiPlannerDraftsIdRevise + summary: POST /api/planner/drafts/{id}/revise description: This route is registered but not documented yet. tags: - - Repositories + - Planner security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: messageId + - name: id in: path required: true schema: @@ -7091,16 +7338,22 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/repos/improvements: - post: - operationId: postApiReposImprovements - summary: POST /api/repos/improvements + /api/planner/drafts/{id}/revisions: + get: + operationId: getApiPlannerDraftsIdRevisions + summary: GET /api/planner/drafts/{id}/revisions description: This route is registered but not documented yet. tags: - - Repositories + - Planner security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string responses: "200": description: Success. The response body is not documented yet. @@ -7111,16 +7364,27 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/repos/media: + /api/planner/drafts/{id}/revisions/{revisionId}: get: - operationId: getApiReposMedia - summary: GET /api/repos/media + operationId: getApiPlannerDraftsIdRevisionsRevisionId + summary: GET /api/planner/drafts/{id}/revisions/{revisionId} description: This route is registered but not documented yet. tags: - - Repositories + - Planner security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: revisionId + in: path + required: true + schema: + type: string responses: "200": description: Success. The response body is not documented yet. @@ -7131,16 +7395,27 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/repos/todos: - get: - operationId: getApiReposTodos - summary: GET /api/repos/todos + /api/planner/drafts/{id}/revisions/{revisionId}/restore: + post: + operationId: postApiPlannerDraftsIdRevisionsRevisionIdRestore + summary: POST /api/planner/drafts/{id}/revisions/{revisionId}/restore description: This route is registered but not documented yet. tags: - - Repositories + - Planner security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: revisionId + in: path + required: true + schema: + type: string responses: "200": description: Success. The response body is not documented yet. @@ -7151,12 +7426,13 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true + /api/planner/finalize: post: - operationId: postApiReposTodos - summary: POST /api/repos/todos + operationId: postApiPlannerFinalize + summary: POST /api/planner/finalize description: This route is registered but not documented yet. tags: - - Repositories + - Planner security: - sessionCookie: [] - bearerAuth: [] @@ -7170,13 +7446,13 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/repos/todos/categories: - get: - operationId: getApiReposTodosCategories - summary: GET /api/repos/todos/categories + /api/planner/generate: + post: + operationId: postApiPlannerGenerate + summary: POST /api/planner/generate description: This route is registered but not documented yet. tags: - - Repositories + - Planner security: - sessionCookie: [] - bearerAuth: [] @@ -7190,12 +7466,13 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true + /api/planner/preview: post: - operationId: postApiReposTodosCategories - summary: POST /api/repos/todos/categories + operationId: postApiPlannerPreview + summary: POST /api/planner/preview description: This route is registered but not documented yet. tags: - - Repositories + - Planner security: - sessionCookie: [] - bearerAuth: [] @@ -7209,13 +7486,13 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/repos/todos/categories/reorder: + /api/planner/preview/context: post: - operationId: postApiReposTodosCategoriesReorder - summary: POST /api/repos/todos/categories/reorder + operationId: postApiPlannerPreviewContext + summary: POST /api/planner/preview/context description: This route is registered but not documented yet. tags: - - Repositories + - Planner security: - sessionCookie: [] - bearerAuth: [] @@ -7229,22 +7506,16 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/repos/todos/categories/{categoryId}: - put: - operationId: putApiReposTodosCategoriesCategoryId - summary: PUT /api/repos/todos/categories/{categoryId} + /api/planner/refine: + post: + operationId: postApiPlannerRefine + summary: POST /api/planner/refine description: This route is registered but not documented yet. tags: - - Repositories + - Planner security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: categoryId - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -7255,21 +7526,16 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - delete: - operationId: deleteApiReposTodosCategoriesCategoryId - summary: DELETE /api/repos/todos/categories/{categoryId} + /api/planner/relevance: + post: + operationId: postApiPlannerRelevance + summary: POST /api/planner/relevance description: This route is registered but not documented yet. tags: - - Repositories + - Planner security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: categoryId - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -7280,13 +7546,13 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/repos/todos/reorder: + /api/planner/validate-context-repository: post: - operationId: postApiReposTodosReorder - summary: POST /api/repos/todos/reorder + operationId: postApiPlannerValidateContextRepository + summary: POST /api/planner/validate-context-repository description: This route is registered but not documented yet. tags: - - Repositories + - Planner security: - sessionCookie: [] - bearerAuth: [] @@ -7300,10 +7566,10 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/repos/todos/{todoId}: + /api/preview-media/comments/{owner}/{repo}/{number}/{assetId}: get: - operationId: getApiReposTodosTodoId - summary: GET /api/repos/todos/{todoId} + operationId: getApiPreviewMediaCommentsOwnerRepoNumberAssetId + summary: GET /api/preview-media/comments/{owner}/{repo}/{number}/{assetId} description: This route is registered but not documented yet. tags: - Repositories @@ -7311,57 +7577,22 @@ paths: - sessionCookie: [] - bearerAuth: [] parameters: - - name: todoId + - name: owner in: path required: true schema: type: string - 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 - put: - operationId: putApiReposTodosTodoId - summary: PUT /api/repos/todos/{todoId} - description: This route is registered but not documented yet. - tags: - - Repositories - security: - - sessionCookie: [] - - bearerAuth: [] - parameters: - - name: todoId + - name: repo in: path required: true schema: type: string - 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 - delete: - operationId: deleteApiReposTodosTodoId - summary: DELETE /api/repos/todos/{todoId} - description: This route is registered but not documented yet. - tags: - - Repositories - security: - - sessionCookie: [] - - bearerAuth: [] - parameters: - - name: todoId + - name: number + in: path + required: true + schema: + type: string + - name: assetId in: path required: true schema: @@ -7376,16 +7607,37 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/repositories/indexing-status: + /api/preview-media/pulls/{owner}/{repo}/{number}/{assetId}: get: - operationId: getApiRepositoriesIndexingStatus - summary: GET /api/repositories/indexing-status + operationId: getApiPreviewMediaPullsOwnerRepoNumberAssetId + summary: GET /api/preview-media/pulls/{owner}/{repo}/{number}/{assetId} description: This route is registered but not documented yet. tags: - Repositories security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: owner + in: path + required: true + schema: + type: string + - name: repo + in: path + required: true + schema: + type: string + - name: number + in: path + required: true + schema: + type: string + - name: assetId + in: path + required: true + schema: + type: string responses: "200": description: Success. The response body is not documented yet. @@ -7396,82 +7648,38 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/repository-events: + /api/pull-requests/{number}/readiness: get: - operationId: listRepositoryEvents - summary: List repository change events after a cursor - description: "Reads the durable change-event journal of one repository (pull request, epic queue and plan issue changes) after `afterCursor`, in journal order, optionally filtered by domain, type, pull request and plan. Delivery is at-least-once: resume with the cursor of the last event you processed. A call without a cursor starts at the current end of the journal and returns the current cursor with no history, unless `fromStart=true`. The caller needs read access to the repository on GitHub (the check MCP `list_pull_requests` applies); events of other repositories are never returned." + operationId: getPullRequestReadiness + summary: Get the readiness verdict of a pull request + description: "Read-only: one verdict (`ready`, `waiting`, `blocked`, `merged`, `closed`, `unknown`) with a coded reason per gate, evaluated with the ProPR GitHub App identity from the same signals auto-merge uses (check runs, legacy commit statuses, non-blocking checks, mergeability, reviews, the latest ProPR AI review, Ultrafix state, active ProPR work and, when auto-merge is requested, the auto-merge policy). It never writes to GitHub or Redis, never updates the branch, never merges and never arms or withdraws auto-merge. The caller needs read access to the repository on GitHub (the check MCP `get_pull_request_readiness` applies); the App identity is used only after it is proven. See the gate and reason-code tables in the API reference." tags: - - Repositories + - Tasks security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: repository - in: query + - name: number + in: path required: true - description: "`owner/repo`. The caller must be able to read it on GitHub, and it must be configured and enabled on this instance." - schema: - type: string - - name: afterCursor - in: query - required: false - description: Opaque cursor from an earlier response for this repository. Only events after it are returned. - schema: - type: string - maxLength: 512 - - name: fromStart - in: query - required: false - description: "Without `afterCursor`: `true` starts at the oldest retained event. By default a call without a cursor starts at the current end of the journal and returns no history." - schema: - type: string - enum: - - "true" - - "false" - - name: domains - in: query - required: false - description: "Comma-separated domains: `pull_request`, `epic_queue`, `plan_issue`." - schema: - type: string - - name: types - in: query - required: false - description: "Comma-separated event types: `opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`, `started`, `advanced`, `blocked`, `completed`, `status_changed`." + description: Pull request number. schema: type: string - - name: pullRequest - in: query - required: false - description: Only events of this pull request number. - schema: - type: integer - minimum: 1 - - name: planId + - name: repository in: query - required: false - description: Only events of this plan. + required: true + description: "`owner/repo` of the pull request." schema: type: string - maxLength: 255 - - name: limit - in: query - required: false - description: Most events returned; defaults to 50. - schema: - type: integer - minimum: 1 - maximum: 100 responses: "200": - description: Matching events and the cursor to resume from. + description: The readiness verdict. content: application/json: schema: - $ref: "#/components/schemas/RepositoryEventList" + $ref: "#/components/schemas/PullRequestReadiness" "400": - description: "`INVALID_INPUT` (a malformed parameter), `CURSOR_INVALID` (the cursor is malformed or was not issued here) or `CURSOR_WRONG_REPOSITORY` (the cursor belongs to another repository). The `hint` says how to recover." + description: "`INVALID_INPUT`: the number or repository is malformed." content: application/json: schema: @@ -7479,252 +7687,94 @@ paths: "401": $ref: "#/components/responses/EnvelopeUnauthorized" "404": - description: "`REPOSITORY_NOT_ACCESSIBLE`: the repository is not configured and enabled here, or the caller cannot read it on GitHub." + description: "`REPOSITORY_NOT_ACCESSIBLE`: the caller cannot read the repository on GitHub, so nothing is evaluated; or `PULL_REQUEST_NOT_FOUND`: the pull request does not exist or the GitHub App cannot see it." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "410": - description: "`CURSOR_EXPIRED`: the cursor is older than the retained journal, so events after it may have been pruned. This is never reported as an empty result." + "502": + description: "`GITHUB_UNAVAILABLE`: GitHub did not return the pull request, or could not be reached to check repository access; the read is safe to retry." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" default: $ref: "#/components/responses/EnvelopeUnexpectedError" - x-propr-query-type: RepositoryEventsQuery + x-propr-query-type: PullRequestReadinessQuery x-propr-auth: member - /api/repository-events/wait: + /api/pull-requests/{number}/scores: get: - operationId: waitRepositoryEvents - summary: Wait for repository change events after a cursor - description: "Blocks for at most `timeoutSeconds` (default 15, max 30) until a matching event exists after the cursor, then returns it like the list read. Returns at once when matching events already exist. A timeout is an ordinary `200` with `outcome: timeout` and the starting cursor unchanged. Disconnecting only releases the waiter. Concurrent waits per owner are limited on each API process, counted together with MCP `wait_repository_events`." + operationId: getApiPullRequestsNumberScores + summary: GET /api/pull-requests/{number}/scores + description: This route is registered but not documented yet. tags: - - Repositories + - Tasks security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: repository - in: query - required: true - description: "`owner/repo`. The caller must be able to read it on GitHub, and it must be configured and enabled on this instance." - schema: - type: string - - name: afterCursor - in: query - required: false - description: Opaque cursor from an earlier response for this repository. Only events after it are returned. - schema: - type: string - maxLength: 512 - - name: fromStart - in: query - required: false - description: "Without `afterCursor`: `true` starts at the oldest retained event. By default a call without a cursor starts at the current end of the journal and returns no history." - schema: - type: string - enum: - - "true" - - "false" - - name: domains - in: query - required: false - description: "Comma-separated domains: `pull_request`, `epic_queue`, `plan_issue`." - schema: - type: string - - name: types - in: query - required: false - description: "Comma-separated event types: `opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`, `started`, `advanced`, `blocked`, `completed`, `status_changed`." - schema: - type: string - - name: pullRequest - in: query - required: false - description: Only events of this pull request number. - schema: - type: integer - minimum: 1 - - name: planId - in: query - required: false - description: Only events of this plan. + - name: number + in: path + required: true schema: type: string - maxLength: 255 - - name: limit - in: query - required: false - description: Most events returned; defaults to 50. - schema: - type: integer - minimum: 1 - maximum: 100 - - name: timeoutSeconds - in: query - required: false - description: Seconds to block when no matching event exists yet; defaults to 15. - schema: - type: number - minimum: 0 - maximum: 30 responses: "200": - description: Matching events, or a timeout with the unchanged cursor. - content: - application/json: - schema: - $ref: "#/components/schemas/RepositoryEventWait" - "400": - description: "`INVALID_INPUT`, `CURSOR_INVALID` or `CURSOR_WRONG_REPOSITORY`." - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + description: Success. The response body is not documented yet. "401": - $ref: "#/components/responses/EnvelopeUnauthorized" - "404": - description: "`REPOSITORY_NOT_ACCESSIBLE`: the repository is not configured and enabled here, or the caller cannot read it on GitHub." - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "410": - description: "`CURSOR_EXPIRED`: the cursor is older than the retained journal." - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "429": - description: "`WAIT_LIMIT`: too many repository event waits are open for this owner on this server." - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + $ref: "#/components/responses/LegacyUnauthorized" default: - $ref: "#/components/responses/EnvelopeUnexpectedError" - x-propr-query-type: RepositoryEventsWaitQuery + $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member - /api/stats/cost: + x-undocumented: true + x-legacy-error: true + /api/queue/stats: get: - operationId: getCostExport - summary: Export LLM cost grouped by day, repository, agent, model, effort, execution type or outcome - description: One row per group of agent executions (`llm_executions`) started in the period. Repository comes from the execution's task; agent and execution type from the matching LLM log call. Executions without a task have no repository and are left out when filtering by repository. + operationId: getApiQueueStats + summary: GET /api/queue/stats + description: This route is registered but not documented yet. tags: - Observability security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: period - in: query - required: false - description: Bounds executions by when they started; omitted means all time. - schema: - type: string - enum: - - 24h - - 7d - - 30d - - 90d - - 1y - - all - - name: repository - in: query - required: false - description: "`owner/repo`, or `all` (default)." - schema: - type: string - - name: group_by - in: query - required: false - description: Comma-separated list of `day`, `repository`, `agent`, `model`, `reasoning_level`, `execution_type`, `outcome`; defaults to `day`. - schema: - type: string responses: "200": - description: The grouped rows and their totals. - content: - application/json: - schema: - $ref: "#/components/schemas/CostExport" - "400": - description: "`period`, `repository` or `group_by` is invalid." - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" + description: Success. The response body is not documented yet. "401": $ref: "#/components/responses/LegacyUnauthorized" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-query-type: CostExportQuery x-propr-auth: member + x-undocumented: true x-legacy-error: true - /api/stats/cost.csv: - get: - operationId: getCostExportCsv - summary: Export LLM cost as CSV - description: "One row per group of agent executions (`llm_executions`) started in the period. Repository comes from the execution's task; agent and execution type from the matching LLM log call. Executions without a task have no repository and are left out when filtering by repository. A header row, then one row per group: the grouped dimensions in column order, then executions, tasks, input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, cost_usd. Unknown values are empty cells." + /api/repos/chat: + post: + operationId: postApiReposChat + summary: POST /api/repos/chat + description: This route is registered but not documented yet. tags: - - Observability + - Repositories security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: period - in: query - required: false - description: Bounds executions by when they started; omitted means all time. - schema: - type: string - enum: - - 24h - - 7d - - 30d - - 90d - - 1y - - all - - name: repository - in: query - required: false - description: "`owner/repo`, or `all` (default)." - schema: - type: string - - name: group_by - in: query - required: false - description: Comma-separated list of `day`, `repository`, `agent`, `model`, `reasoning_level`, `execution_type`, `outcome`; defaults to `day`. - schema: - type: string responses: "200": - description: RFC 4180 CSV attachment. - content: - text/csv: - schema: - type: string - "400": - description: "`period`, `repository` or `group_by` is invalid." - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" + description: Success. The response body is not documented yet. "401": $ref: "#/components/responses/LegacyUnauthorized" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-query-type: CostExportQuery x-propr-auth: member + x-undocumented: true x-legacy-error: true - /api/stats/dashboard: + /api/repos/chat/messages: get: - operationId: getApiStatsDashboard - summary: GET /api/stats/dashboard + operationId: getApiReposChatMessages + summary: GET /api/repos/chat/messages description: This route is registered but not documented yet. tags: - - Observability + - Repositories security: - sessionCookie: [] - bearerAuth: [] @@ -7738,13 +7788,12 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/stats/generating-plans: - get: - operationId: getApiStatsGeneratingPlans - summary: GET /api/stats/generating-plans + post: + operationId: postApiReposChatMessages + summary: POST /api/repos/chat/messages description: This route is registered but not documented yet. tags: - - Observability + - Repositories security: - sessionCookie: [] - bearerAuth: [] @@ -7758,13 +7807,12 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/stats/overview: - get: - operationId: getApiStatsOverview - summary: GET /api/stats/overview + delete: + operationId: deleteApiReposChatMessages + summary: DELETE /api/repos/chat/messages description: This route is registered but not documented yet. tags: - - Observability + - Repositories security: - sessionCookie: [] - bearerAuth: [] @@ -7778,16 +7826,22 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/stats/repositories: - get: - operationId: getApiStatsRepositories - summary: GET /api/stats/repositories + /api/repos/chat/messages/{messageId}: + delete: + operationId: deleteApiReposChatMessagesMessageId + summary: DELETE /api/repos/chat/messages/{messageId} description: This route is registered but not documented yet. tags: - - Observability + - Repositories security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: messageId + in: path + required: true + schema: + type: string responses: "200": description: Success. The response body is not documented yet. @@ -7798,13 +7852,13 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/stats/review-scores: - get: - operationId: getApiStatsReviewScores - summary: GET /api/stats/review-scores + /api/repos/improvements: + post: + operationId: postApiReposImprovements + summary: POST /api/repos/improvements description: This route is registered but not documented yet. tags: - - Observability + - Repositories security: - sessionCookie: [] - bearerAuth: [] @@ -7818,13 +7872,13 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/stats/review-scores.csv: + /api/repos/media: get: - operationId: getApiStatsReviewScoresCsv - summary: GET /api/stats/review-scores.csv + operationId: getApiReposMedia + summary: GET /api/repos/media description: This route is registered but not documented yet. tags: - - Observability + - Repositories security: - sessionCookie: [] - bearerAuth: [] @@ -7838,13 +7892,13 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/stats/tasks: + /api/repos/todos: get: - operationId: getApiStatsTasks - summary: GET /api/stats/tasks + operationId: getApiReposTodos + summary: GET /api/repos/todos description: This route is registered but not documented yet. tags: - - Observability + - Repositories security: - sessionCookie: [] - bearerAuth: [] @@ -7858,50 +7912,35 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/status: - get: - operationId: getSystemStatus - summary: Get the system health snapshot - description: Redis, queue, worker and agent health, the same snapshot the dashboard header shows. + post: + operationId: postApiReposTodos + summary: POST /api/repos/todos + description: This route is registered but not documented yet. tags: - - System + - Repositories security: - sessionCookie: [] - bearerAuth: [] responses: "200": - description: The health snapshot. - content: - application/json: - schema: - $ref: "#/components/schemas/JsonObject" + 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/summaries/{owner}/{repo}/status: + /api/repos/todos/categories: get: - operationId: getApiSummariesOwnerRepoStatus - summary: GET /api/summaries/{owner}/{repo}/status + operationId: getApiReposTodosCategories + summary: GET /api/repos/todos/categories description: This route is registered but not documented yet. tags: - Repositories security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: owner - in: path - required: true - schema: - type: string - - name: repo - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -7912,33 +7951,15 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/summaries/{owner}/{repo}/summary/{path}: - get: - operationId: getApiSummariesOwnerRepoSummaryPath - summary: GET /api/summaries/{owner}/{repo}/summary/{path} + post: + operationId: postApiReposTodosCategories + summary: POST /api/repos/todos/categories description: This route is registered but not documented yet. tags: - Repositories security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: owner - in: path - required: true - schema: - type: string - - name: repo - in: path - required: true - schema: - type: string - - name: path - in: path - required: true - description: Remaining path; may contain `/`. - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -7949,27 +7970,16 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/summaries/{owner}/{repo}/tree: - get: - operationId: getApiSummariesOwnerRepoTree - summary: GET /api/summaries/{owner}/{repo}/tree + /api/repos/todos/categories/reorder: + post: + operationId: postApiReposTodosCategoriesReorder + summary: POST /api/repos/todos/categories/reorder description: This route is registered but not documented yet. tags: - Repositories security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: owner - in: path - required: true - schema: - type: string - - name: repo - in: path - required: true - schema: - type: string responses: "200": description: Success. The response body is not documented yet. @@ -7980,10 +7990,10 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/summaries/{owner}/{repo}/tree/{path}: - get: - operationId: getApiSummariesOwnerRepoTreePath - summary: GET /api/summaries/{owner}/{repo}/tree/{path} + /api/repos/todos/categories/{categoryId}: + put: + operationId: putApiReposTodosCategoriesCategoryId + summary: PUT /api/repos/todos/categories/{categoryId} description: This route is registered but not documented yet. tags: - Repositories @@ -7991,20 +8001,9 @@ paths: - sessionCookie: [] - bearerAuth: [] parameters: - - name: owner - in: path - required: true - schema: - type: string - - name: repo - in: path - required: true - schema: - type: string - - name: path + - name: categoryId in: path required: true - description: Remaining path; may contain `/`. schema: type: string responses: @@ -8017,838 +8016,596 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/task-submission-extensions: - get: - operationId: getTaskSubmissionExtensions - summary: Preview the extensions a task would use - description: Names of the extension library MCP servers and skills that would apply automatically to a task in this repository for the chosen agent. Never includes URLs, credentials or item limits. Labels on the issue (`disable-mcps`, `disable-skills`, `disable-extensions`) can remove them at launch. + delete: + operationId: deleteApiReposTodosCategoriesCategoryId + summary: DELETE /api/repos/todos/categories/{categoryId} + description: This route is registered but not documented yet. tags: - - Task submissions + - Repositories security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: repository - in: query + - name: categoryId + in: path required: true - description: "`owner/name` of an enabled repository you can push to." - schema: - type: string - pattern: ^[\w.-]+\/[\w.-]+$ - - name: agentAlias - in: query - required: false - description: Agent the task would use. Omit for the instance default. - schema: - type: string - - name: model - in: query - required: false schema: type: string responses: "200": - description: The items that would apply. - content: - application/json: - schema: - $ref: "#/components/schemas/TaskSubmissionExtensions" - "400": - description: Missing repository, or the agent or model cannot be used. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" + description: Success. The response body is not documented yet. "401": $ref: "#/components/responses/LegacyUnauthorized" - "403": - description: You lack write access to the repository. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-query-type: TaskSubmissionExtensionsQuery x-propr-auth: member + x-undocumented: true x-legacy-error: true - /api/task-submissions: + /api/repos/todos/reorder: post: - operationId: createTaskSubmission - summary: Submit a task - description: |- - Opens a GitHub issue in the repository with the instruction, applies the routing labels and enqueues the implementation run. - - Submissions are idempotent per user and `Idempotency-Key`: repeating a request with the same key and content returns the existing submission and resumes it if it stopped part way. Reusing a key with different content is rejected with `409`. - - Send JSON, or `multipart/form-data` with the JSON in a `payload` field and up to 10 `files` attachments. + operationId: postApiReposTodosReorder + summary: POST /api/repos/todos/reorder + description: This route is registered but not documented yet. tags: - - Task submissions + - Repositories security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: Idempotency-Key - in: header - required: true - description: Client-chosen identity of this submission, for example a UUID. - schema: - type: string - maxLength: 255 - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/TaskSubmissionRequest" - multipart/form-data: - schema: - type: object - required: - - payload - properties: - payload: - type: string - description: The `TaskSubmissionRequest` as a JSON string. - contentMediaType: application/json - files: - type: array - maxItems: 10 - items: - type: string - contentMediaType: application/octet-stream responses: "200": - description: The issue was created and the run is queued. - content: - application/json: - schema: - $ref: "#/components/schemas/TaskSubmission" - "202": - description: The submission is in progress or stopped part way (`failed`); poll or retry it. - content: - application/json: - schema: - $ref: "#/components/schemas/TaskSubmission" - "400": - description: Missing key, invalid body, or the repository, agent or model cannot be used. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" + description: Success. The response body is not documented yet. "401": $ref: "#/components/responses/LegacyUnauthorized" - "403": - description: You lack write access to the repository, or the instance is in demo mode. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" - "409": - description: The key was already used with different content. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member + x-undocumented: true x-legacy-error: true - /api/task-submissions/{key}: + /api/repos/todos/{todoId}: get: - operationId: getTaskSubmission - summary: Get a task submission + operationId: getApiReposTodosTodoId + summary: GET /api/repos/todos/{todoId} + description: This route is registered but not documented yet. tags: - - Task submissions + - Repositories security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: key + - name: todoId in: path required: true - description: The `Idempotency-Key` the submission was created with. schema: type: string responses: "200": - description: The submission. - content: - application/json: - schema: - $ref: "#/components/schemas/TaskSubmission" + description: Success. The response body is not documented yet. "401": $ref: "#/components/responses/LegacyUnauthorized" - "404": - description: No submission of yours has this key. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member + x-undocumented: true x-legacy-error: true - /api/task-submissions/{key}/retry: - post: - operationId: retryTaskSubmission - summary: Resume a task submission that stopped part way + put: + operationId: putApiReposTodosTodoId + summary: PUT /api/repos/todos/{todoId} + description: This route is registered but not documented yet. tags: - - Task submissions + - Repositories security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: key + - name: todoId in: path required: true - description: The `Idempotency-Key` the submission was created with. schema: type: string responses: "200": - description: The resumed submission. - content: - application/json: - schema: - $ref: "#/components/schemas/TaskSubmission" + description: Success. The response body is not documented yet. "401": $ref: "#/components/responses/LegacyUnauthorized" - "404": - description: No submission of yours has this key. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member + x-undocumented: true x-legacy-error: true - /api/task/{taskId}: delete: - operationId: deleteTaskLegacyPath - summary: Delete a task run (legacy path) - description: |- - Same as `DELETE /api/tasks/{taskId}`. - - 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. + operationId: deleteApiReposTodosTodoId + summary: DELETE /api/repos/todos/{todoId} + description: This route is registered but not documented yet. tags: - - Tasks - deprecated: true + - Repositories security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: taskId + - name: todoId in: path required: true - description: Task (run) identifier, as returned in `TaskSummary.id`. - schema: - type: string - - name: force - in: query - required: false - description: Delete even when the task is still active. schema: type: string - enum: - - "true" - - "false" responses: - "204": - description: Deleted. + "200": + description: Success. The response body is not documented yet. "401": $ref: "#/components/responses/LegacyUnauthorized" - "404": - description: Task not found. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" - "409": - description: The task belongs to a goal; use the goal controls. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-query-type: DeleteTaskQuery x-propr-auth: member + x-undocumented: true x-legacy-error: true - /api/task/{taskId}/assignable-users: + /api/repositories/indexing-status: get: - operationId: listTaskAssignableUsers - summary: List users that can be assigned to a task - description: |- - The assignable users of the task's repository, at most 300, sorted by login. Cached for 60 seconds per repository. - - 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. + operationId: getApiRepositoriesIndexingStatus + summary: GET /api/repositories/indexing-status + description: This route is registered but not documented yet. tags: - - Tasks + - Repositories security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: taskId - in: path - required: true - description: Task (run) identifier, as returned in `TaskSummary.id`. - schema: - type: string responses: "200": - description: The assignable users. - content: - application/json: - schema: - $ref: "#/components/schemas/AssignableUsers" - "400": - description: The task id is invalid. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" + description: Success. The response body is not documented yet. "401": $ref: "#/components/responses/LegacyUnauthorized" - "404": - description: Task not found. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" - "409": - description: The task has no GitHub issue or pull request to assign. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" - "502": - description: GitHub could not be reached. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member + x-undocumented: true x-legacy-error: true - /api/task/{taskId}/assignees: + /api/repository-events: get: - operationId: getTaskAssignees - summary: Get the assignees of a task - description: |- - Reads the assignees of the task's GitHub issue or pull request from GitHub and stores them. With `refresh=false` the stored set is returned without calling GitHub. When GitHub cannot be read the stored set is returned with `synced: false`. - - 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. + operationId: listRepositoryEvents + summary: List repository change events after a cursor + description: "Reads the durable change-event journal of one repository (pull request, epic queue, plan issue and task changes) after `afterCursor`, in journal order, optionally filtered by domain, type, pull request and plan. Delivery is at-least-once: resume with the cursor of the last event you processed. A call without a cursor starts at the current end of the journal and returns the current cursor with no history, unless `fromStart=true`. The caller needs read access to the repository on GitHub (the check MCP `list_pull_requests` applies); events of other repositories are never returned." tags: - - Tasks + - Repositories security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: taskId - in: path + - name: repository + in: query required: true - description: Task (run) identifier, as returned in `TaskSummary.id`. + description: "`owner/repo`. The caller must be able to read it on GitHub, and it must be configured and enabled on this instance." schema: type: string - - name: refresh + - name: afterCursor in: query required: false - description: "`false` serves the stored set without calling GitHub." + description: Opaque cursor from an earlier response for this repository. Only events after it are returned. + schema: + type: string + maxLength: 512 + - name: fromStart + in: query + required: false + description: "Without `afterCursor`: `true` starts at the oldest retained event. By default a call without a cursor starts at the current end of the journal and returns no history." schema: type: string enum: - "true" - "false" + - name: domains + in: query + required: false + description: "Comma-separated domains: `pull_request`, `epic_queue`, `plan_issue`, `task`." + schema: + type: string + - name: types + in: query + required: false + description: "Comma-separated event types: `opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`, `started`, `advanced`, `blocked`, `completed`, `status_changed`, `queued`, `failed`, `cancelled`." + schema: + type: string + - name: pullRequest + in: query + required: false + description: Only events of this pull request number. + schema: + type: integer + minimum: 1 + - name: planId + in: query + required: false + description: Only events of this plan. + schema: + type: string + maxLength: 255 + - name: limit + in: query + required: false + description: Most events returned; defaults to 50. + schema: + type: integer + minimum: 1 + maximum: 100 responses: "200": - description: The task's assignees. + description: Matching events and the cursor to resume from. content: application/json: schema: - $ref: "#/components/schemas/TaskAssignees" + $ref: "#/components/schemas/RepositoryEventList" "400": - description: The task id is invalid. + description: "`INVALID_INPUT` (a malformed parameter), `CURSOR_INVALID` (the cursor is malformed or was not issued here) or `CURSOR_WRONG_REPOSITORY` (the cursor belongs to another repository). The `hint` says how to recover." content: application/json: schema: - $ref: "#/components/schemas/LegacyError" + $ref: "#/components/schemas/ErrorEnvelope" "401": - $ref: "#/components/responses/LegacyUnauthorized" + $ref: "#/components/responses/EnvelopeUnauthorized" "404": - description: Task not found. + description: "`REPOSITORY_NOT_ACCESSIBLE`: the repository is not configured and enabled here, or the caller cannot read it on GitHub." content: application/json: schema: - $ref: "#/components/schemas/LegacyError" - "409": - description: The task has no GitHub issue or pull request to assign. + $ref: "#/components/schemas/ErrorEnvelope" + "410": + description: "`CURSOR_EXPIRED`: the cursor is older than the retained journal, so events after it may have been pruned. This is never reported as an empty result." content: application/json: schema: - $ref: "#/components/schemas/LegacyError" + $ref: "#/components/schemas/ErrorEnvelope" default: - $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-query-type: TaskAssigneesQuery + $ref: "#/components/responses/EnvelopeUnexpectedError" + x-propr-query-type: RepositoryEventsQuery x-propr-auth: member - x-legacy-error: true - put: - operationId: setTaskAssignees - summary: Change the assignees of a task - description: |- - Assigns the task's GitHub issue or pull request on GitHub and returns the set GitHub confirmed. Requires write access to the repository on GitHub. - - 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. + /api/repository-events/wait: + get: + operationId: waitRepositoryEvents + summary: Wait for repository change events after a cursor + description: "Blocks for at most `timeoutSeconds` (default 15, max 30) until a matching event exists after the cursor, then returns it like the list read. Returns at once when matching events already exist. A timeout is an ordinary `200` with `outcome: timeout` and the starting cursor unchanged. Disconnecting only releases the waiter. Concurrent waits per owner are limited on each API process, counted together with MCP `wait_repository_events`." tags: - - Tasks + - Repositories security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: taskId - in: path + - name: repository + in: query required: true - description: Task (run) identifier, as returned in `TaskSummary.id`. + description: "`owner/repo`. The caller must be able to read it on GitHub, and it must be configured and enabled on this instance." schema: type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/TaskAssigneesUpdate" - responses: - "200": - description: The assignment GitHub confirmed. - content: - application/json: - schema: - $ref: "#/components/schemas/TaskAssigneesResult" - "400": - description: The body is malformed or names an unknown GitHub login. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" - "401": - $ref: "#/components/responses/LegacyUnauthorized" - "403": - description: You lack write access to the repository on GitHub. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" - "404": - description: Task not found, or the repository is not accessible. + - name: afterCursor + in: query + required: false + description: Opaque cursor from an earlier response for this repository. Only events after it are returned. + schema: + type: string + maxLength: 512 + - name: fromStart + in: query + required: false + description: "Without `afterCursor`: `true` starts at the oldest retained event. By default a call without a cursor starts at the current end of the journal and returns no history." + schema: + type: string + enum: + - "true" + - "false" + - name: domains + in: query + required: false + description: "Comma-separated domains: `pull_request`, `epic_queue`, `plan_issue`, `task`." + schema: + type: string + - name: types + in: query + required: false + description: "Comma-separated event types: `opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`, `started`, `advanced`, `blocked`, `completed`, `status_changed`, `queued`, `failed`, `cancelled`." + schema: + type: string + - name: pullRequest + in: query + required: false + description: Only events of this pull request number. + schema: + type: integer + minimum: 1 + - name: planId + in: query + required: false + description: Only events of this plan. + schema: + type: string + maxLength: 255 + - name: limit + in: query + required: false + description: Most events returned; defaults to 50. + schema: + type: integer + minimum: 1 + maximum: 100 + - name: timeoutSeconds + in: query + required: false + description: Seconds to block when no matching event exists yet; defaults to 15. + schema: + type: number + minimum: 0 + maximum: 30 + responses: + "200": + description: Matching events, or a timeout with the unchanged cursor. content: application/json: schema: - $ref: "#/components/schemas/LegacyError" - "409": - description: The task has no GitHub issue or pull request to assign. + $ref: "#/components/schemas/RepositoryEventWait" + "400": + description: "`INVALID_INPUT`, `CURSOR_INVALID` or `CURSOR_WRONG_REPOSITORY`." content: application/json: schema: - $ref: "#/components/schemas/LegacyError" - "422": - description: GitHub rejected the assignment, or did not assign some requested users (listed in `rejected`). + $ref: "#/components/schemas/ErrorEnvelope" + "401": + $ref: "#/components/responses/EnvelopeUnauthorized" + "404": + description: "`REPOSITORY_NOT_ACCESSIBLE`: the repository is not configured and enabled here, or the caller cannot read it on GitHub." content: application/json: schema: - $ref: "#/components/schemas/LegacyError" - "502": - description: GitHub could not be reached. + $ref: "#/components/schemas/ErrorEnvelope" + "410": + description: "`CURSOR_EXPIRED`: the cursor is older than the retained journal." content: application/json: schema: - $ref: "#/components/schemas/LegacyError" + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: "`WAIT_LIMIT`: too many repository event waits are open for this owner on this server." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" default: - $ref: "#/components/responses/LegacyUnexpectedError" + $ref: "#/components/responses/EnvelopeUnexpectedError" + x-propr-query-type: RepositoryEventsWaitQuery x-propr-auth: member - x-legacy-error: true - /api/task/{taskId}/cancel: - post: - operationId: cancelTask - summary: Cancel a task (alias of stop) - description: "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." + /api/stats/cost: + get: + operationId: getCostExport + summary: Export LLM cost grouped by day, repository, agent, model, effort, execution type or outcome + description: One row per group of agent executions (`llm_executions`) started in the period. Repository comes from the execution's task; agent and execution type from the matching LLM log call. Executions without a task have no repository and are left out when filtering by repository. tags: - - Tasks + - Observability security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: taskId - in: path - required: true - description: Task (run) identifier, as returned in `TaskSummary.id`. + - name: period + in: query + required: false + description: Bounds executions by when they started; omitted means all time. + schema: + type: string + enum: + - 24h + - 7d + - 30d + - 90d + - 1y + - all + - name: repository + in: query + required: false + description: "`owner/repo`, or `all` (default)." + schema: + type: string + - name: group_by + in: query + required: false + description: Comma-separated list of `day`, `repository`, `agent`, `model`, `reasoning_level`, `execution_type`, `outcome`; defaults to `day`. schema: type: string responses: "200": - description: The task was stopped or was already finished. - content: - application/json: - schema: - $ref: "#/components/schemas/JsonObject" - "401": - $ref: "#/components/responses/LegacyUnauthorized" - "404": - description: Not found, or the task belongs to another user's goal. + description: The grouped rows and their totals. content: application/json: schema: - $ref: "#/components/schemas/LegacyError" - "409": - description: The task belongs to a goal; use the goal controls. + $ref: "#/components/schemas/CostExport" + "400": + description: "`period`, `repository` or `group_by` is invalid." content: application/json: schema: $ref: "#/components/schemas/LegacyError" + "401": + $ref: "#/components/responses/LegacyUnauthorized" default: $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-query-type: CostExportQuery x-propr-auth: member x-legacy-error: true - /api/task/{taskId}/docker-info: + /api/stats/cost.csv: get: - operationId: getApiTaskTaskIdDockerInfo - summary: GET /api/task/{taskId}/docker-info - 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. + operationId: getCostExportCsv + summary: Export LLM cost as CSV + description: "One row per group of agent executions (`llm_executions`) started in the period. Repository comes from the execution's task; agent and execution type from the matching LLM log call. Executions without a task have no repository and are left out when filtering by repository. A header row, then one row per group: the grouped dimensions in column order, then executions, tasks, input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, cost_usd. Unknown values are empty cells." tags: - - Tasks + - Observability security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: taskId - in: path - required: true + - name: period + in: query + required: false + description: Bounds executions by when they started; omitted means all time. + schema: + type: string + enum: + - 24h + - 7d + - 30d + - 90d + - 1y + - all + - name: repository + in: query + required: false + description: "`owner/repo`, or `all` (default)." + schema: + type: string + - name: group_by + in: query + required: false + description: Comma-separated list of `day`, `repository`, `agent`, `model`, `reasoning_level`, `execution_type`, `outcome`; defaults to `day`. 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. + description: RFC 4180 CSV attachment. + content: + text/csv: + schema: + type: string + "400": + description: "`period`, `repository` or `group_by` is invalid." content: application/json: schema: $ref: "#/components/schemas/LegacyError" + "401": + $ref: "#/components/responses/LegacyUnauthorized" default: $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-query-type: CostExportQuery x-propr-auth: member - x-undocumented: true x-legacy-error: true - /api/task/{taskId}/docker-logs: + /api/stats/dashboard: get: - operationId: getApiTaskTaskIdDockerLogs - summary: GET /api/task/{taskId}/docker-logs - 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. + operationId: getApiStatsDashboard + summary: GET /api/stats/dashboard + description: This route is registered but not documented yet. tags: - - Tasks + - Observability security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: taskId - 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: - schema: - $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/task/{taskId}/file-changes: + /api/stats/generating-plans: get: - operationId: getTaskFileChanges - summary: List the files a task changed - description: "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." + operationId: getApiStatsGeneratingPlans + summary: GET /api/stats/generating-plans + description: This route is registered but not documented yet. tags: - - Tasks + - Observability security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: taskId - in: path - required: true - description: Task (run) identifier, as returned in `TaskSummary.id`. - schema: - type: string responses: "200": - description: Changed files with their diffs. - content: - application/json: - schema: - $ref: "#/components/schemas/JsonObject" + 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: - schema: - $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member + x-undocumented: true x-legacy-error: true - /api/task/{taskId}/history: + /api/stats/overview: get: - operationId: getTaskHistory - summary: Get a task with its lifecycle events - description: |- - Returns the task details and every recorded state change, oldest first, from the database, falling back to live worker state for runs that have not been persisted 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. + operationId: getApiStatsOverview + summary: GET /api/stats/overview + description: This route is registered but not documented yet. tags: - - Tasks + - Observability security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: taskId - in: path - required: true - description: Task (run) identifier, as returned in `TaskSummary.id`. - schema: - type: string responses: "200": - description: The task and its events. - content: - application/json: - schema: - $ref: "#/components/schemas/TaskHistory" + 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: - schema: - $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member + x-undocumented: true x-legacy-error: true - /api/task/{taskId}/live-details: + /api/stats/repositories: get: - operationId: getTaskLiveDetails - summary: Get live agent output of a running task - description: "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." + operationId: getApiStatsRepositories + summary: GET /api/stats/repositories + description: This route is registered but not documented yet. tags: - - Tasks + - Observability security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: taskId - in: path - required: true - description: Task (run) identifier, as returned in `TaskSummary.id`. - schema: - type: string responses: "200": - description: Parsed agent conversation and execution progress. - content: - application/json: - schema: - $ref: "#/components/schemas/JsonObject" + 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: - schema: - $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member + x-undocumented: true x-legacy-error: true - /api/task/{taskId}/stop: - post: - operationId: stopTask - summary: Stop a running task - description: "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." + /api/stats/review-scores: + get: + operationId: getApiStatsReviewScores + summary: GET /api/stats/review-scores + description: This route is registered but not documented yet. tags: - - Tasks + - Observability security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: taskId - in: path - required: true - description: Task (run) identifier, as returned in `TaskSummary.id`. - schema: - type: string responses: "200": - description: The task was stopped or was already finished. - content: - application/json: - schema: - $ref: "#/components/schemas/JsonObject" + 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: - schema: - $ref: "#/components/schemas/LegacyError" - "409": - description: The task belongs to a goal; use the goal controls. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member + x-undocumented: true x-legacy-error: true - /api/tasks: + /api/stats/review-scores.csv: get: - operationId: listTasks - summary: List task runs - description: Newest first. Filter by status, repository or search text, and page with `limit` and `offset`. With `groupBy=task`, paging counts tasks (the pull request or issue a run belongs to) and a page returns every matching run of its tasks. + operationId: getApiStatsReviewScoresCsv + summary: GET /api/stats/review-scores.csv + description: This route is registered but not documented yet. tags: - - Tasks + - Observability security: - sessionCookie: [] - bearerAuth: [] - parameters: - - name: status - in: query - required: false - description: "`all` (default), `active`, `waiting`, `attention`, or a worker state." - schema: - type: string - - name: repository - in: query - required: false - description: "`owner/name`, or `all` (default)." - schema: - type: string - - name: search - in: query - required: false - schema: - type: string - maxLength: 500 - - name: limit - in: query - required: false - description: Page size; defaults to 50. - schema: - type: integer - minimum: 1 - maximum: 1000 - - name: offset - in: query - required: false - schema: - type: integer - minimum: 0 - maximum: 1000000 - - name: forReview - in: query - required: false - description: Only runs whose pull request awaits review. - schema: - type: string - enum: - - "true" - - "false" - - name: excludeMerged - in: query - required: false - schema: - type: string - enum: - - "true" - - "false" - - name: groupBy - in: query - required: false - description: Page by task instead of by run. - schema: - type: string - const: task - - name: task - in: query - required: false - description: "With `groupBy=task`: only the task this run belongs to." - schema: - type: string - - name: assignee - in: query - required: false - description: "`all` (default), `me` (the signed-in user), `unassigned`, or comma-separated GitHub logins." - schema: - type: string - - name: syncAssignees - in: query - required: false - description: "With `task`: refresh that run's assignees from GitHub first." - schema: - type: string - enum: - - "true" - - "false" responses: "200": - description: A page of task runs. - content: - application/json: - schema: - $ref: "#/components/schemas/TaskPage" - "400": - description: A filter or paging parameter is invalid. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" + description: Success. The response body is not documented yet. "401": $ref: "#/components/responses/LegacyUnauthorized" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-query-type: ListTasksQuery x-propr-auth: member + x-undocumented: true x-legacy-error: true - /api/tasks/revert: - post: - operationId: postApiTasksRevert - summary: POST /api/tasks/revert + /api/stats/tasks: + get: + operationId: getApiStatsTasks + summary: GET /api/stats/tasks description: This route is registered but not documented yet. tags: - - Tasks + - Observability security: - sessionCookie: [] - bearerAuth: [] @@ -8862,841 +8619,1845 @@ paths: x-propr-auth: member x-undocumented: true x-legacy-error: true - /api/tasks/revert-preview: + /api/status: get: - operationId: getApiTasksRevertPreview - summary: GET /api/tasks/revert-preview - description: This route is registered but not documented yet. + operationId: getSystemStatus + summary: Get the system health snapshot + description: Redis, queue, worker and agent health, the same snapshot the dashboard header shows. tags: - - Tasks + - System security: - sessionCookie: [] - bearerAuth: [] responses: "200": - description: Success. The response body is not documented yet. + description: The health snapshot. + content: + application/json: + schema: + $ref: "#/components/schemas/JsonObject" "401": $ref: "#/components/responses/LegacyUnauthorized" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member - x-undocumented: true x-legacy-error: true - /api/tasks/{taskId}: - delete: - operationId: deleteTask - summary: Delete a task run and its history - description: "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." + /api/summaries/{owner}/{repo}/status: + get: + operationId: getApiSummariesOwnerRepoStatus + summary: GET /api/summaries/{owner}/{repo}/status + description: This route is registered but not documented yet. tags: - - Tasks + - Repositories security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: taskId + - name: owner in: path required: true - description: Task (run) identifier, as returned in `TaskSummary.id`. schema: type: string - - name: force - in: query - required: false - description: Delete even when the task is still active. + - name: repo + in: path + required: true schema: type: string - enum: - - "true" - - "false" responses: - "204": - description: Deleted. - "400": - description: The task is still active; retry with `force=true`. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" + "200": + description: Success. The response body is not documented yet. "401": $ref: "#/components/responses/LegacyUnauthorized" - "404": - description: Task not found. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" - "409": - description: The task belongs to a goal; use the goal controls. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-query-type: DeleteTaskQuery x-propr-auth: member + x-undocumented: true x-legacy-error: true - /api/tasks/{taskId}/followup: - post: - operationId: postTaskFollowup - summary: Post a follow-up instruction on a task - description: |- - Posts the comment on the task's pull request and queues a follow-up run for it. A failed implementation that has no pull request is retried on its issue instead: the comment is posted there and a new issue run is queued. - - 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. + /api/summaries/{owner}/{repo}/summary/{path}: + get: + operationId: getApiSummariesOwnerRepoSummaryPath + summary: GET /api/summaries/{owner}/{repo}/summary/{path} + description: This route is registered but not documented yet. tags: - - Tasks + - Repositories security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: taskId + - name: owner in: path required: true - description: Task (run) identifier, as returned in `TaskSummary.id`. schema: type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/TaskFollowupRequest" + - name: repo + in: path + required: true + schema: + type: string + - name: path + in: path + required: true + description: Remaining path; may contain `/`. + schema: + type: string responses: - "202": - description: The comment was posted; the outcome distinguishes queued, batched, handled, refused, failed and unknown submissions. An issue retry refuses the comment, without posting it, while it restarts an earlier retry whose start was never confirmed; `restartedRequest` reports that restart. - content: - application/json: - schema: - $ref: "#/components/schemas/TaskFollowupResult" - "400": - description: The comment body or target is invalid, or a pull request command was sent for a task without a pull request. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" + "200": + description: Success. The response body is not documented yet. "401": $ref: "#/components/responses/LegacyUnauthorized" - "404": - description: Task not found. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" - "409": - description: "The issue cannot be retried: the run has not failed, the issue is closed or done, a run is already queued, or an earlier retry may still start." - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" - "502": - description: The issue's label history could not be read from GitHub, so the retry could not be tied to its trigger and nothing was posted. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" - "503": - description: The issue retry could not be recorded, so nothing was posted. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member + x-undocumented: true x-legacy-error: true - /api/tasks/{taskId}/followup-runs: + /api/summaries/{owner}/{repo}/tree: get: - operationId: getTaskFollowupRuns - summary: Read the runs an issue follow-up started - description: |- - Read-only. Returns the other runs of the task's repository and issue whose job carried the follow-up's own comment for the signed-in user, so the page that sent it can open the new run. Empty while the run has not started; it never posts or queues anything. - - 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. + operationId: getApiSummariesOwnerRepoTree + summary: GET /api/summaries/{owner}/{repo}/tree + description: This route is registered but not documented yet. tags: - - Tasks + - Repositories security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: taskId + - name: owner in: path required: true - description: Task (run) identifier, as returned in `TaskSummary.id`. schema: type: string - - name: commentId - in: query + - name: repo + in: path required: true - description: The `commentId` the follow-up's receipt returned. schema: type: string - pattern: ^[1-9]\d{0,15}$ responses: "200": - description: The runs found, possibly none yet. - content: - application/json: - schema: - $ref: "#/components/schemas/TaskFollowupRuns" - "400": - description: The task ID or comment ID is invalid. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" + description: Success. The response body is not documented yet. "401": - description: Authentication required. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" - "404": - description: Task not found. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" + $ref: "#/components/responses/LegacyUnauthorized" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-query-type: TaskFollowupRunsQuery x-propr-auth: member + x-undocumented: true x-legacy-error: true - /api/tasks/{taskId}/review: - post: - operationId: postTaskReview - summary: Request an AI review for a task pull request - description: |- - Checks the signed-in user’s repository access and PR assignment, dispatches /review through command handling, and returns the same receipt on repeated requests for the source run. - - 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. + /api/summaries/{owner}/{repo}/tree/{path}: + get: + operationId: getApiSummariesOwnerRepoTreePath + summary: GET /api/summaries/{owner}/{repo}/tree/{path} + description: This route is registered but not documented yet. tags: - - Tasks + - Repositories security: - sessionCookie: [] - bearerAuth: [] parameters: - - name: taskId + - name: owner in: path required: true - description: Task (run) identifier, as returned in `TaskSummary.id`. + schema: + type: string + - name: repo + in: path + required: true + schema: + type: string + - name: path + in: path + required: true + description: Remaining path; may contain `/`. schema: type: string responses: - "202": - description: Review submission receipt. Only queued or batched is accepted; unknown requires reconciliation. + "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/task-submission-extensions: + get: + operationId: getTaskSubmissionExtensions + summary: Preview the extensions a task would use + description: Names of the extension library MCP servers and skills that would apply automatically to a task in this repository for the chosen agent. Never includes URLs, credentials or item limits. Labels on the issue (`disable-mcps`, `disable-skills`, `disable-extensions`) can remove them at launch. + tags: + - Task submissions + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: repository + in: query + required: true + description: "`owner/name` of an enabled repository you can push to." + schema: + type: string + pattern: ^[\w.-]+\/[\w.-]+$ + - name: agentAlias + in: query + required: false + description: Agent the task would use. Omit for the instance default. + schema: + type: string + - name: model + in: query + required: false + schema: + type: string + responses: + "200": + description: The items that would apply. content: application/json: schema: - $ref: "#/components/schemas/TaskReviewResult" + $ref: "#/components/schemas/TaskSubmissionExtensions" "400": - description: Invalid task or missing pull request. + description: Missing repository, or the agent or model cannot be used. content: application/json: schema: $ref: "#/components/schemas/LegacyError" "401": - description: Authentication required. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" + $ref: "#/components/responses/LegacyUnauthorized" "403": - description: GitHub authorization or PR assignment required. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" - "404": - description: Task or repository not accessible. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" - "409": - description: Pull request is closed. - content: - application/json: - schema: - $ref: "#/components/schemas/LegacyError" - "503": - description: Authorization could not be verified. + description: You lack write access to the repository. content: application/json: schema: $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-query-type: TaskSubmissionExtensionsQuery x-propr-auth: member x-legacy-error: true - /api/usage-tips: - get: - operationId: getApiUsageTips - summary: GET /api/usage-tips - 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/usage-tips/dismiss: + /api/task-submissions: post: - operationId: postApiUsageTipsDismiss - summary: POST /api/usage-tips/dismiss - description: This route is registered but not documented yet. + operationId: createTaskSubmission + summary: Submit a task + description: |- + Opens a GitHub issue in the repository with the instruction, applies the routing labels and enqueues the implementation run. + + Submissions are idempotent per user and `Idempotency-Key`: repeating a request with the same key and content returns the existing submission and resumes it if it stopped part way. Reusing a key with different content is rejected with `409`. + + Send JSON, or `multipart/form-data` with the JSON in a `payload` field and up to 10 `files` attachments. tags: - - Observability + - Task submissions security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: Idempotency-Key + in: header + required: true + description: Client-chosen identity of this submission, for example a UUID. + schema: + type: string + maxLength: 255 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TaskSubmissionRequest" + multipart/form-data: + schema: + type: object + required: + - payload + properties: + payload: + type: string + description: The `TaskSubmissionRequest` as a JSON string. + contentMediaType: application/json + files: + type: array + maxItems: 10 + items: + type: string + contentMediaType: application/octet-stream responses: "200": - description: Success. The response body is not documented yet. + description: The issue was created and the run is queued. + content: + application/json: + schema: + $ref: "#/components/schemas/TaskSubmission" + "202": + description: The submission is in progress or stopped part way (`failed`); poll or retry it. + content: + application/json: + schema: + $ref: "#/components/schemas/TaskSubmission" + "400": + description: Missing key, invalid body, or the repository, agent or model cannot be used. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" "401": $ref: "#/components/responses/LegacyUnauthorized" + "403": + description: You lack write access to the repository, or the instance is in demo mode. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "409": + description: The key was already used with different content. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member - x-undocumented: true x-legacy-error: true - /api/user/repo-preferences: + /api/task-submissions/{key}: get: - operationId: getApiUserRepoPreferences - summary: GET /api/user/repo-preferences - description: This route is registered but not documented yet. + operationId: getTaskSubmission + summary: Get a task submission tags: - - Repositories + - Task submissions security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: key + in: path + required: true + description: The `Idempotency-Key` the submission was created with. + schema: + type: string responses: "200": - description: Success. The response body is not documented yet. + description: The submission. + content: + application/json: + schema: + $ref: "#/components/schemas/TaskSubmission" "401": $ref: "#/components/responses/LegacyUnauthorized" + "404": + description: No submission of yours has this key. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member - x-undocumented: true x-legacy-error: true + /api/task-submissions/{key}/retry: post: - operationId: postApiUserRepoPreferences - summary: POST /api/user/repo-preferences - description: This route is registered but not documented yet. + operationId: retryTaskSubmission + summary: Resume a task submission that stopped part way tags: - - Repositories + - Task submissions security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: key + in: path + required: true + description: The `Idempotency-Key` the submission was created with. + schema: + type: string responses: "200": - description: Success. The response body is not documented yet. + description: The resumed submission. + content: + application/json: + schema: + $ref: "#/components/schemas/TaskSubmission" "401": $ref: "#/components/responses/LegacyUnauthorized" + "404": + description: No submission of yours has this key. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" x-propr-auth: member - x-undocumented: true x-legacy-error: true - /api/voice/briefing: - get: - operationId: getApiVoiceBriefing - summary: GET /api/voice/briefing - description: This route is registered but not documented yet. + /api/task/{taskId}: + delete: + operationId: deleteTaskLegacyPath + summary: Delete a task run (legacy path) + description: |- + Same as `DELETE /api/tasks/{taskId}`. + + 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: - - Voice + - Tasks + deprecated: true security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: taskId + in: path + required: true + description: Task (run) identifier, as returned in `TaskSummary.id`. + schema: + type: string + - name: force + in: query + required: false + description: Delete even when the task is still active. + schema: + type: string + enum: + - "true" + - "false" responses: - "200": - description: Success. The response body is not documented yet. + "204": + description: Deleted. "401": $ref: "#/components/responses/LegacyUnauthorized" + "404": + description: Task not found. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "409": + description: The task belongs to a goal; use the goal controls. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-query-type: DeleteTaskQuery x-propr-auth: member - x-undocumented: true x-legacy-error: true - /api/voice/capabilities: + /api/task/{taskId}/assignable-users: get: - operationId: getApiVoiceCapabilities - summary: GET /api/voice/capabilities - description: This route is registered but not documented yet. + operationId: listTaskAssignableUsers + summary: List users that can be assigned to a task + description: |- + The assignable users of the task's repository, at most 300, sorted by login. Cached for 60 seconds per repository. + + 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: - - Voice + - Tasks security: - sessionCookie: [] - bearerAuth: [] + parameters: + - name: taskId + in: path + required: true + description: Task (run) identifier, as returned in `TaskSummary.id`. + schema: + type: string 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 - /authorize: - get: - operationId: authorizeMcpClient - summary: Start an MCP OAuth authorization - description: OAuth 2.1 authorization endpoint with PKCE (`S256`). Takes `response_type=code`, `client_id`, `redirect_uri` (must match a registered URI exactly), `code_challenge`, `code_challenge_method`, and optionally `scope`, `state` and `resource` as query parameters, then redirects the browser to the consent page. Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp). - tags: - - MCP - security: [] - responses: - "302": - description: Redirect to the consent page, or to `redirect_uri` with an OAuth error. + description: The assignable users. + content: + application/json: + schema: + $ref: "#/components/schemas/AssignableUsers" "400": - description: Unknown client or a `redirect_uri` that is not registered. + description: The task id is invalid. content: application/json: schema: $ref: "#/components/schemas/LegacyError" + "401": + $ref: "#/components/responses/LegacyUnauthorized" "404": - description: MCP is disabled on this instance. + description: Task not found. content: application/json: schema: $ref: "#/components/schemas/LegacyError" - default: - $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: public - x-legacy-error: true - post: - operationId: authorizeMcpClientForm - summary: Start an MCP OAuth authorization (form post) - description: Same as `GET /authorize`, with the parameters in an `application/x-www-form-urlencoded` body. Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp). - tags: - - MCP - security: [] - responses: - "302": - description: Redirect to the consent page, or to `redirect_uri` with an OAuth error. - "400": - description: Unknown client or a `redirect_uri` that is not registered. + "409": + description: The task has no GitHub issue or pull request to assign. content: application/json: schema: $ref: "#/components/schemas/LegacyError" - "404": - description: MCP is disabled on this instance. + "502": + description: GitHub could not be reached. content: application/json: schema: $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: public + x-propr-auth: member x-legacy-error: true - /health: + /api/task/{taskId}/assignees: get: - operationId: getHealth - summary: Liveness probe + operationId: getTaskAssignees + summary: Get the assignees of a task + description: |- + Reads the assignees of the task's GitHub issue or pull request from GitHub and stores them. With `refresh=false` the stored set is returned without calling GitHub. When GitHub cannot be read the stored set is returned with `synced: false`. + + 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: - - System - security: [] + - Tasks + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: taskId + in: path + required: true + description: Task (run) identifier, as returned in `TaskSummary.id`. + schema: + type: string + - name: refresh + in: query + required: false + description: "`false` serves the stored set without calling GitHub." + schema: + type: string + enum: + - "true" + - "false" responses: "200": - description: The API process is serving requests. + description: The task's assignees. content: application/json: schema: - $ref: "#/components/schemas/Health" - default: - $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: public - x-legacy-error: true - /mcp/apps: - get: - operationId: getMcpApps - summary: GET /mcp/apps - description: This route is registered but not documented yet. - tags: - - MCP - security: - - sessionCookie: [] - responses: - "200": - description: Success. The response body is not documented yet. - default: - $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: browserSession - x-undocumented: true - x-legacy-error: true - /mcp/apps/revoke: - post: - operationId: postMcpAppsRevoke - summary: POST /mcp/apps/revoke - description: This route is registered but not documented yet. - tags: - - MCP - security: - - sessionCookie: [] - responses: - "200": - description: Success. The response body is not documented yet. + $ref: "#/components/schemas/TaskAssignees" + "400": + description: The task id is invalid. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "404": + description: Task not found. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "409": + description: The task has no GitHub issue or pull request to assign. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: browserSession - x-undocumented: true + x-propr-query-type: TaskAssigneesQuery + x-propr-auth: member x-legacy-error: true - /mcp/artifacts/{id}: - get: - operationId: getMcpArtifactsId - summary: GET /mcp/artifacts/{id} - description: This route is registered but not documented yet. + put: + operationId: setTaskAssignees + summary: Change the assignees of a task + description: |- + Assigns the task's GitHub issue or pull request on GitHub and returns the set GitHub confirmed. Requires write access to the repository on GitHub. + + 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: - - MCP + - Tasks security: - sessionCookie: [] + - bearerAuth: [] parameters: - - name: id + - name: taskId in: path required: true + description: Task (run) identifier, as returned in `TaskSummary.id`. schema: type: string - responses: - "200": - description: Success. The response body is not documented yet. - default: - $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: browserSession - x-undocumented: true - x-legacy-error: true - /mcp/consent: - get: - operationId: getMcpConsent - summary: GET /mcp/consent - description: This route is registered but not documented yet. - tags: - - MCP - security: - - sessionCookie: [] - responses: - "200": - description: Success. The response body is not documented yet. - default: - $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: browserSession - x-undocumented: true - x-legacy-error: true - post: - operationId: postMcpConsent - summary: POST /mcp/consent - description: This route is registered but not documented yet. - tags: - - MCP - security: - - sessionCookie: [] - responses: - "200": - description: Success. The response body is not documented yet. - default: - $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: browserSession - x-undocumented: true - x-legacy-error: true - /register: - post: - operationId: registerMcpClient - summary: Register an MCP OAuth client - description: "RFC 7591 dynamic client registration. Clients register as public clients (`token_endpoint_auth_method: none`). Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp)." - tags: - - MCP - security: [] requestBody: required: true - description: RFC 7591 client metadata, for example `client_name` and `redirect_uris`. content: application/json: schema: - $ref: "#/components/schemas/JsonObject" + $ref: "#/components/schemas/TaskAssigneesUpdate" responses: - "201": - description: The registered client, including its `client_id`. + "200": + description: The assignment GitHub confirmed. content: application/json: schema: - $ref: "#/components/schemas/JsonObject" + $ref: "#/components/schemas/TaskAssigneesResult" "400": - description: RFC 7591 error, for example `invalid_client_metadata`. + description: The body is malformed or names an unknown GitHub login. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "403": + description: You lack write access to the repository on GitHub. content: application/json: schema: $ref: "#/components/schemas/LegacyError" "404": - description: MCP is disabled on this instance. + description: Task not found, or the repository is not accessible. content: application/json: schema: $ref: "#/components/schemas/LegacyError" - default: - $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: public - x-legacy-error: true - /revoke: - post: - operationId: revokeMcpToken - summary: Revoke an MCP access or refresh token - description: RFC 7009 revocation. The `application/x-www-form-urlencoded` body carries `token`, optionally `token_type_hint`, and `client_id`. Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp). - tags: - - MCP - security: [] - responses: - "200": - description: The token is revoked, or was not valid. The body is `{}`. + "409": + description: The task has no GitHub issue or pull request to assign. content: application/json: schema: - $ref: "#/components/schemas/JsonObject" - "400": - description: RFC 7009 error. + $ref: "#/components/schemas/LegacyError" + "422": + description: GitHub rejected the assignment, or did not assign some requested users (listed in `rejected`). content: application/json: schema: $ref: "#/components/schemas/LegacyError" - "404": - description: MCP is disabled on this instance. + "502": + description: GitHub could not be reached. content: application/json: schema: $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: public + x-propr-auth: member x-legacy-error: true - /token: + /api/task/{taskId}/cancel: post: - operationId: issueMcpToken - summary: Exchange an authorization code or refresh token - description: "OAuth 2.1 token endpoint for public clients (`token_endpoint_auth_method: none`). The `application/x-www-form-urlencoded` body carries `grant_type` (`authorization_code` with `code`, `code_verifier` and `redirect_uri`, or `refresh_token` with `refresh_token`), `client_id` and optionally `resource`. Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp)." + operationId: cancelTask + summary: Cancel a task (alias of stop) + description: "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: - - MCP - security: [] + - Tasks + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: taskId + in: path + required: true + description: Task (run) identifier, as returned in `TaskSummary.id`. + schema: + type: string responses: "200": - description: "RFC 6749 token response: `access_token`, `token_type`, `expires_in`, `scope` and `refresh_token`." + description: The task was stopped or was already finished. content: application/json: schema: $ref: "#/components/schemas/JsonObject" - "400": - description: RFC 6749 error, for example `invalid_grant`. + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "404": + description: Not found, or the task belongs to another user's goal. content: application/json: schema: $ref: "#/components/schemas/LegacyError" + "409": + description: The task belongs to a goal; use the goal controls. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member + x-legacy-error: true + /api/task/{taskId}/docker-info: + get: + operationId: getApiTaskTaskIdDockerInfo + summary: GET /api/task/{taskId}/docker-info + 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: + - Tasks + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: taskId + 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: MCP is disabled on this instance. + description: Not found, or the task belongs to another user's goal. content: application/json: schema: $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: public + x-propr-auth: member + x-undocumented: true x-legacy-error: true - /webhook: - post: - operationId: receiveGitHubWebhook - summary: Receive a GitHub webhook delivery - description: Registered only when `GITHUB_EVENT_INTAKE_MODE=direct_webhook`. Deliveries must be signed with `GH_WEBHOOK_SECRET`. + /api/task/{taskId}/docker-logs: + get: + operationId: getApiTaskTaskIdDockerLogs + summary: GET /api/task/{taskId}/docker-logs + 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: - - Webhooks + - Tasks security: - - webhookSignature: [] + - sessionCookie: [] + - bearerAuth: [] parameters: - - name: X-GitHub-Event - in: header + - name: taskId + in: path required: true - description: GitHub event name. schema: type: string - - name: X-GitHub-Delivery - in: header + 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: + schema: + $ref: "#/components/schemas/LegacyError" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member + x-undocumented: true + x-legacy-error: true + /api/task/{taskId}/file-changes: + get: + operationId: getTaskFileChanges + summary: List the files a task changed + description: "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: + - Tasks + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: taskId + in: path required: true - description: GitHub delivery identifier. + description: Task (run) identifier, as returned in `TaskSummary.id`. schema: type: string - requestBody: - required: true - description: The GitHub event payload. - content: - application/json: - schema: - $ref: "#/components/schemas/JsonObject" responses: "200": - description: Accepted. + description: Changed files with their diffs. content: - text/plain: + application/json: schema: - type: string + $ref: "#/components/schemas/JsonObject" "401": - description: Missing or invalid signature. + $ref: "#/components/responses/LegacyUnauthorized" + "404": + description: Not found, or the task belongs to another user's goal. content: application/json: schema: $ref: "#/components/schemas/LegacyError" default: $ref: "#/components/responses/LegacyUnexpectedError" - x-propr-auth: webhook + x-propr-auth: member x-legacy-error: true -components: - schemas: - AgentRunAdmissionAgent: - type: object - properties: - alias: - type: string - description: Direct agent alias (never a synthetic alias). - ceiling: - anyOf: - - type: integer - exclusiveMinimum: 0 - - type: "null" - description: PROPR_AGENT_RUN_ALIAS_LIMITS entry; null = global limit only. - inUse: - type: integer - minimum: 0 - description: "What the ceiling is compared with: holders plus nested executions counted against it." - holders: - $ref: "#/components/schemas/AgentRunAdmissionStateCounts" - nested: - type: object - properties: - total: - type: integer - minimum: 0 - chargedUnderParent: - type: integer - minimum: 0 - countedAgainstCeiling: - type: integer - minimum: 0 - required: - - total - - chargedUnderParent - - countedAgainstCeiling - additionalProperties: false - waiting: - $ref: "#/components/schemas/AgentRunAdmissionWaitingCounts" - deferred: - type: integer - minimum: 0 - description: Queue jobs delayed until this agent has capacity. - tasks: - type: array - items: - $ref: "#/components/schemas/AgentRunAdmissionTask" - required: - - alias - - ceiling - - inUse - - holders - - nested - - waiting - - deferred - - tasks - additionalProperties: false - description: Admission state of one direct agent. - AgentRunAdmissionConfiguration: - type: object - properties: - status: - type: string - enum: - - current - - uninitialized - - pending_generation - - mismatch - - missing - description: 'current: compatible protocol; the stored limit and generation equal the configured ones. uninitialized: nothing stored yet; the first admission stores the configured ones. pending_generation: compatible protocol and the configured generation is higher; the next admission attempt fences and removes every older reservation, then switches. mismatch: incompatible protocol, same generation with another limit, a lower generation, or an unreadable stored record; every launch is refused. A higher generation resolves limit/generation mismatches only within a compatible protocol. For incompatible protocols, draining and raising the generation are insufficient: follow "Rolling restarts with mixed versions" in the agent-run capacity operations docs. Drain with the existing version, stop every execution process, confirm Redis holders and physical Docker work are empty, then reset only the admission configuration key; never clear holder records to force an upgrade. missing: nothing stored while holder records remain; every launch is refused.' - admitting: - type: boolean - description: "False when admission refuses every launch with the configured limit and generation: `limit` minus `inUse` is then not usable capacity." - stored: - anyOf: - - type: object - properties: - limit: - type: integer - exclusiveMinimum: 0 - generation: - type: integer - minimum: 0 - required: - - limit - - generation - additionalProperties: false - - type: "null" - description: Limit and generation of the stored admission configuration; null when none (or none readable) is stored. - required: - - status - - admitting - - stored - additionalProperties: false - description: The stored admission configuration compared with the configured global limit and generation. The admission protocol prefix is checked; the Docker daemon identity is neither shown nor compared. - AgentRunAdmissionReport: - type: object - properties: - asOf: - type: string - format: date-time - admission: - type: object - properties: - enabled: - type: boolean - agentCeilingsConfigured: - type: boolean - global: - anyOf: - - type: object - properties: - limit: - type: integer - exclusiveMinimum: 0 - description: Global limit configured on the answering process; enforced only while `configuration.admitting`. - limitSource: - type: string - enum: - - PROPR_AGENT_RUN_LIMIT - - hosted_entitlement - generation: - type: integer - minimum: 0 - description: Admission generation configured on the answering process (PROPR_AGENT_RUN_GENERATION, default 0). - configuration: - $ref: "#/components/schemas/AgentRunAdmissionConfiguration" - nestedLimit: - type: integer - exclusiveMinimum: 0 - nestedLimitSource: - type: string - enum: - - PROPR_AGENT_RUN_NESTED_LIMIT - - default - inUse: - type: integer - minimum: 0 - description: Top-level executions; each takes one global slot. - holders: - $ref: "#/components/schemas/AgentRunAdmissionStateCounts" - unattributedHolders: + /api/task/{taskId}/history: + get: + operationId: getTaskHistory + summary: Get a task with its lifecycle events + description: |- + Returns the task details and every recorded state change, oldest first, from the database, falling back to live worker state for runs that have not been persisted 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: + - Tasks + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: taskId + in: path + required: true + description: Task (run) identifier, as returned in `TaskSummary.id`. + schema: + type: string + responses: + "200": + description: The task and its events. + content: + application/json: + schema: + $ref: "#/components/schemas/TaskHistory" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "404": + description: Not found, or the task belongs to another user's goal. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member + x-legacy-error: true + /api/task/{taskId}/live-details: + get: + operationId: getTaskLiveDetails + summary: Get live agent output of a running task + description: "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: + - Tasks + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: taskId + in: path + required: true + description: Task (run) identifier, as returned in `TaskSummary.id`. + schema: + type: string + responses: + "200": + description: Parsed agent conversation and execution progress. + content: + application/json: + schema: + $ref: "#/components/schemas/JsonObject" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "404": + description: Not found, or the task belongs to another user's goal. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member + x-legacy-error: true + /api/task/{taskId}/stop: + post: + operationId: stopTask + summary: Stop a running task + description: "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: + - Tasks + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: taskId + in: path + required: true + description: Task (run) identifier, as returned in `TaskSummary.id`. + schema: + type: string + responses: + "200": + description: The task was stopped or was already finished. + content: + application/json: + schema: + $ref: "#/components/schemas/JsonObject" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "404": + description: Not found, or the task belongs to another user's goal. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "409": + description: The task belongs to a goal; use the goal controls. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member + x-legacy-error: true + /api/tasks: + get: + operationId: listTasks + summary: List task runs + description: Newest first. Filter by status, repository or search text, and page with `limit` and `offset`. With `groupBy=task`, paging counts tasks (the pull request or issue a run belongs to) and a page returns every matching run of its tasks. + tags: + - Tasks + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: status + in: query + required: false + description: "`all` (default), `active`, `waiting`, `attention`, or a worker state." + schema: + type: string + - name: repository + in: query + required: false + description: "`owner/name`, or `all` (default)." + schema: + type: string + - name: search + in: query + required: false + schema: + type: string + maxLength: 500 + - name: limit + in: query + required: false + description: Page size; defaults to 50. + schema: + type: integer + minimum: 1 + maximum: 1000 + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + maximum: 1000000 + - name: forReview + in: query + required: false + description: Only runs whose pull request awaits review. + schema: + type: string + enum: + - "true" + - "false" + - name: excludeMerged + in: query + required: false + schema: + type: string + enum: + - "true" + - "false" + - name: groupBy + in: query + required: false + description: Page by task instead of by run. + schema: + type: string + const: task + - name: task + in: query + required: false + description: "With `groupBy=task`: only the task this run belongs to." + schema: + type: string + - name: assignee + in: query + required: false + description: "`all` (default), `me` (the signed-in user), `unassigned`, or comma-separated GitHub logins." + schema: + type: string + - name: syncAssignees + in: query + required: false + description: "With `task`: refresh that run's assignees from GitHub first." + schema: + type: string + enum: + - "true" + - "false" + responses: + "200": + description: A page of task runs. + content: + application/json: + schema: + $ref: "#/components/schemas/TaskPage" + "400": + description: A filter or paging parameter is invalid. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-query-type: ListTasksQuery + x-propr-auth: member + x-legacy-error: true + /api/tasks/revert: + post: + operationId: postApiTasksRevert + summary: POST /api/tasks/revert + description: This route is registered but not documented yet. + tags: + - Tasks + 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/tasks/revert-preview: + get: + operationId: getApiTasksRevertPreview + summary: GET /api/tasks/revert-preview + description: This route is registered but not documented yet. + tags: + - Tasks + 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/tasks/{taskId}: + delete: + operationId: deleteTask + summary: Delete a task run and its history + description: "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: + - Tasks + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: taskId + in: path + required: true + description: Task (run) identifier, as returned in `TaskSummary.id`. + schema: + type: string + - name: force + in: query + required: false + description: Delete even when the task is still active. + schema: + type: string + enum: + - "true" + - "false" + responses: + "204": + description: Deleted. + "400": + description: The task is still active; retry with `force=true`. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "404": + description: Task not found. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "409": + description: The task belongs to a goal; use the goal controls. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-query-type: DeleteTaskQuery + x-propr-auth: member + x-legacy-error: true + /api/tasks/{taskId}/followup: + post: + operationId: postTaskFollowup + summary: Post a follow-up instruction on a task + description: |- + Posts the comment on the task's pull request and queues a follow-up run for it. A failed implementation that has no pull request is retried on its issue instead: the comment is posted there and a new issue run is queued. + + 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: + - Tasks + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: taskId + in: path + required: true + description: Task (run) identifier, as returned in `TaskSummary.id`. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TaskFollowupRequest" + responses: + "202": + description: The comment was posted; the outcome distinguishes queued, batched, handled, refused, failed and unknown submissions. An issue retry refuses the comment, without posting it, while it restarts an earlier retry whose start was never confirmed; `restartedRequest` reports that restart. + content: + application/json: + schema: + $ref: "#/components/schemas/TaskFollowupResult" + "400": + description: The comment body or target is invalid, or a pull request command was sent for a task without a pull request. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "401": + $ref: "#/components/responses/LegacyUnauthorized" + "404": + description: Task not found. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "409": + description: "The issue cannot be retried: the run has not failed, the issue is closed or done, a run is already queued, or an earlier retry may still start." + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "502": + description: The issue's label history could not be read from GitHub, so the retry could not be tied to its trigger and nothing was posted. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "503": + description: The issue retry could not be recorded, so nothing was posted. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member + x-legacy-error: true + /api/tasks/{taskId}/followup-runs: + get: + operationId: getTaskFollowupRuns + summary: Read the runs an issue follow-up started + description: |- + Read-only. Returns the other runs of the task's repository and issue whose job carried the follow-up's own comment for the signed-in user, so the page that sent it can open the new run. Empty while the run has not started; it never posts or queues anything. + + 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: + - Tasks + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: taskId + in: path + required: true + description: Task (run) identifier, as returned in `TaskSummary.id`. + schema: + type: string + - name: commentId + in: query + required: true + description: The `commentId` the follow-up's receipt returned. + schema: + type: string + pattern: ^[1-9]\d{0,15}$ + responses: + "200": + description: The runs found, possibly none yet. + content: + application/json: + schema: + $ref: "#/components/schemas/TaskFollowupRuns" + "400": + description: The task ID or comment ID is invalid. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "401": + description: Authentication required. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "404": + description: Task not found. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-query-type: TaskFollowupRunsQuery + x-propr-auth: member + x-legacy-error: true + /api/tasks/{taskId}/review: + post: + operationId: postTaskReview + summary: Request an AI review for a task pull request + description: |- + Checks the signed-in user’s repository access and PR assignment, dispatches /review through command handling, and returns the same receipt on repeated requests for the source run. + + 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: + - Tasks + security: + - sessionCookie: [] + - bearerAuth: [] + parameters: + - name: taskId + in: path + required: true + description: Task (run) identifier, as returned in `TaskSummary.id`. + schema: + type: string + responses: + "202": + description: Review submission receipt. Only queued or batched is accepted; unknown requires reconciliation. + content: + application/json: + schema: + $ref: "#/components/schemas/TaskReviewResult" + "400": + description: Invalid task or missing pull request. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "401": + description: Authentication required. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "403": + description: GitHub authorization or PR assignment required. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "404": + description: Task or repository not accessible. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "409": + description: Pull request is closed. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "503": + description: Authorization could not be verified. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: member + x-legacy-error: true + /api/usage-tips: + get: + operationId: getApiUsageTips + summary: GET /api/usage-tips + 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/usage-tips/dismiss: + post: + operationId: postApiUsageTipsDismiss + summary: POST /api/usage-tips/dismiss + 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/user/repo-preferences: + get: + operationId: getApiUserRepoPreferences + summary: GET /api/user/repo-preferences + description: This route is registered but not documented yet. + tags: + - Repositories + 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 + post: + operationId: postApiUserRepoPreferences + summary: POST /api/user/repo-preferences + description: This route is registered but not documented yet. + tags: + - Repositories + 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/voice/briefing: + get: + operationId: getApiVoiceBriefing + summary: GET /api/voice/briefing + description: This route is registered but not documented yet. + tags: + - Voice + 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/voice/capabilities: + get: + operationId: getApiVoiceCapabilities + summary: GET /api/voice/capabilities + description: This route is registered but not documented yet. + tags: + - Voice + 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 + /authorize: + get: + operationId: authorizeMcpClient + summary: Start an MCP OAuth authorization + description: OAuth 2.1 authorization endpoint with PKCE (`S256`). Takes `response_type=code`, `client_id`, `redirect_uri` (must match a registered URI exactly), `code_challenge`, `code_challenge_method`, and optionally `scope`, `state` and `resource` as query parameters, then redirects the browser to the consent page. Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp). + tags: + - MCP + security: [] + responses: + "302": + description: Redirect to the consent page, or to `redirect_uri` with an OAuth error. + "400": + description: Unknown client or a `redirect_uri` that is not registered. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "404": + description: MCP is disabled on this instance. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: public + x-legacy-error: true + post: + operationId: authorizeMcpClientForm + summary: Start an MCP OAuth authorization (form post) + description: Same as `GET /authorize`, with the parameters in an `application/x-www-form-urlencoded` body. Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp). + tags: + - MCP + security: [] + responses: + "302": + description: Redirect to the consent page, or to `redirect_uri` with an OAuth error. + "400": + description: Unknown client or a `redirect_uri` that is not registered. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "404": + description: MCP is disabled on this instance. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: public + x-legacy-error: true + /health: + get: + operationId: getHealth + summary: Liveness probe + tags: + - System + security: [] + responses: + "200": + description: The API process is serving requests. + content: + application/json: + schema: + $ref: "#/components/schemas/Health" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: public + x-legacy-error: true + /mcp/apps: + get: + operationId: getMcpApps + summary: GET /mcp/apps + description: This route is registered but not documented yet. + tags: + - MCP + security: + - sessionCookie: [] + responses: + "200": + description: Success. The response body is not documented yet. + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: browserSession + x-undocumented: true + x-legacy-error: true + /mcp/apps/revoke: + post: + operationId: postMcpAppsRevoke + summary: POST /mcp/apps/revoke + description: This route is registered but not documented yet. + tags: + - MCP + security: + - sessionCookie: [] + responses: + "200": + description: Success. The response body is not documented yet. + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: browserSession + x-undocumented: true + x-legacy-error: true + /mcp/artifacts/{id}: + get: + operationId: getMcpArtifactsId + summary: GET /mcp/artifacts/{id} + description: This route is registered but not documented yet. + tags: + - MCP + security: + - sessionCookie: [] + parameters: + - name: id + 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: browserSession + x-undocumented: true + x-legacy-error: true + /mcp/consent: + get: + operationId: getMcpConsent + summary: GET /mcp/consent + description: This route is registered but not documented yet. + tags: + - MCP + security: + - sessionCookie: [] + responses: + "200": + description: Success. The response body is not documented yet. + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: browserSession + x-undocumented: true + x-legacy-error: true + post: + operationId: postMcpConsent + summary: POST /mcp/consent + description: This route is registered but not documented yet. + tags: + - MCP + security: + - sessionCookie: [] + responses: + "200": + description: Success. The response body is not documented yet. + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: browserSession + x-undocumented: true + x-legacy-error: true + /register: + post: + operationId: registerMcpClient + summary: Register an MCP OAuth client + description: "RFC 7591 dynamic client registration. Clients register as public clients (`token_endpoint_auth_method: none`). Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp)." + tags: + - MCP + security: [] + requestBody: + required: true + description: RFC 7591 client metadata, for example `client_name` and `redirect_uris`. + content: + application/json: + schema: + $ref: "#/components/schemas/JsonObject" + responses: + "201": + description: The registered client, including its `client_id`. + content: + application/json: + schema: + $ref: "#/components/schemas/JsonObject" + "400": + description: RFC 7591 error, for example `invalid_client_metadata`. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "404": + description: MCP is disabled on this instance. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: public + x-legacy-error: true + /revoke: + post: + operationId: revokeMcpToken + summary: Revoke an MCP access or refresh token + description: RFC 7009 revocation. The `application/x-www-form-urlencoded` body carries `token`, optionally `token_type_hint`, and `client_id`. Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp). + tags: + - MCP + security: [] + responses: + "200": + description: The token is revoked, or was not valid. The body is `{}`. + content: + application/json: + schema: + $ref: "#/components/schemas/JsonObject" + "400": + description: RFC 7009 error. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "404": + description: MCP is disabled on this instance. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: public + x-legacy-error: true + /token: + post: + operationId: issueMcpToken + summary: Exchange an authorization code or refresh token + description: "OAuth 2.1 token endpoint for public clients (`token_endpoint_auth_method: none`). The `application/x-www-form-urlencoded` body carries `grant_type` (`authorization_code` with `code`, `code_verifier` and `redirect_uri`, or `refresh_token` with `refresh_token`), `client_id` and optionally `resource`. Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp)." + tags: + - MCP + security: [] + responses: + "200": + description: "RFC 6749 token response: `access_token`, `token_type`, `expires_in`, `scope` and `refresh_token`." + content: + application/json: + schema: + $ref: "#/components/schemas/JsonObject" + "400": + description: RFC 6749 error, for example `invalid_grant`. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + "404": + description: MCP is disabled on this instance. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: public + x-legacy-error: true + /webhook: + post: + operationId: receiveGitHubWebhook + summary: Receive a GitHub webhook delivery + description: Registered only when `GITHUB_EVENT_INTAKE_MODE=direct_webhook`. Deliveries must be signed with `GH_WEBHOOK_SECRET`. + tags: + - Webhooks + security: + - webhookSignature: [] + parameters: + - name: X-GitHub-Event + in: header + required: true + description: GitHub event name. + schema: + type: string + - name: X-GitHub-Delivery + in: header + required: true + description: GitHub delivery identifier. + schema: + type: string + requestBody: + required: true + description: The GitHub event payload. + content: + application/json: + schema: + $ref: "#/components/schemas/JsonObject" + responses: + "200": + description: Accepted. + content: + text/plain: + schema: + type: string + "401": + description: Missing or invalid signature. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyError" + default: + $ref: "#/components/responses/LegacyUnexpectedError" + x-propr-auth: webhook + x-legacy-error: true +components: + schemas: + AgentRunAdmissionAgent: + type: object + properties: + alias: + type: string + description: Direct agent alias (never a synthetic alias). + ceiling: + anyOf: + - type: integer + exclusiveMinimum: 0 + - type: "null" + description: PROPR_AGENT_RUN_ALIAS_LIMITS entry; null = global limit only. + inUse: + type: integer + minimum: 0 + description: "What the ceiling is compared with: holders plus nested executions counted against it." + holders: + $ref: "#/components/schemas/AgentRunAdmissionStateCounts" + nested: + type: object + properties: + total: + type: integer + minimum: 0 + chargedUnderParent: + type: integer + minimum: 0 + countedAgainstCeiling: + type: integer + minimum: 0 + required: + - total + - chargedUnderParent + - countedAgainstCeiling + additionalProperties: false + waiting: + $ref: "#/components/schemas/AgentRunAdmissionWaitingCounts" + deferred: + type: integer + minimum: 0 + description: Queue jobs delayed until this agent has capacity. + tasks: + type: array + items: + $ref: "#/components/schemas/AgentRunAdmissionTask" + required: + - alias + - ceiling + - inUse + - holders + - nested + - waiting + - deferred + - tasks + additionalProperties: false + description: Admission state of one direct agent. + AgentRunAdmissionConfiguration: + type: object + properties: + status: + type: string + enum: + - current + - uninitialized + - pending_generation + - mismatch + - missing + description: 'current: compatible protocol; the stored limit and generation equal the configured ones. uninitialized: nothing stored yet; the first admission stores the configured ones. pending_generation: compatible protocol and the configured generation is higher; the next admission attempt fences and removes every older reservation, then switches. mismatch: incompatible protocol, same generation with another limit, a lower generation, or an unreadable stored record; every launch is refused. A higher generation resolves limit/generation mismatches only within a compatible protocol. For incompatible protocols, draining and raising the generation are insufficient: follow "Rolling restarts with mixed versions" in the agent-run capacity operations docs. Drain with the existing version, stop every execution process, confirm Redis holders and physical Docker work are empty, then reset only the admission configuration key; never clear holder records to force an upgrade. missing: nothing stored while holder records remain; every launch is refused.' + admitting: + type: boolean + description: "False when admission refuses every launch with the configured limit and generation: `limit` minus `inUse` is then not usable capacity." + stored: + anyOf: + - type: object + properties: + limit: + type: integer + exclusiveMinimum: 0 + generation: + type: integer + minimum: 0 + required: + - limit + - generation + additionalProperties: false + - type: "null" + description: Limit and generation of the stored admission configuration; null when none (or none readable) is stored. + required: + - status + - admitting + - stored + additionalProperties: false + description: The stored admission configuration compared with the configured global limit and generation. The admission protocol prefix is checked; the Docker daemon identity is neither shown nor compared. + AgentRunAdmissionReport: + type: object + properties: + asOf: + type: string + format: date-time + admission: + type: object + properties: + enabled: + type: boolean + agentCeilingsConfigured: + type: boolean + global: + anyOf: + - type: object + properties: + limit: + type: integer + exclusiveMinimum: 0 + description: Global limit configured on the answering process; enforced only while `configuration.admitting`. + limitSource: + type: string + enum: + - PROPR_AGENT_RUN_LIMIT + - hosted_entitlement + generation: + type: integer + minimum: 0 + description: Admission generation configured on the answering process (PROPR_AGENT_RUN_GENERATION, default 0). + configuration: + $ref: "#/components/schemas/AgentRunAdmissionConfiguration" + nestedLimit: + type: integer + exclusiveMinimum: 0 + nestedLimitSource: + type: string + enum: + - PROPR_AGENT_RUN_NESTED_LIMIT + - default + inUse: + type: integer + minimum: 0 + description: Top-level executions; each takes one global slot. + holders: + $ref: "#/components/schemas/AgentRunAdmissionStateCounts" + unattributedHolders: type: integer minimum: 0 nested: @@ -10313,61 +11074,157 @@ components: cancelledAt: type: string format: date-time - required: - - status - - cancelledAt - additionalProperties: false - description: The provisional instance token was revoked. - DesktopPairingPending: - type: object - properties: - status: - type: string - const: pending + required: + - status + - cancelledAt + additionalProperties: false + description: The provisional instance token was revoked. + DesktopPairingPending: + type: object + properties: + status: + type: string + const: pending + interval: + type: integer + minimum: 1 + maximum: 60 + description: Seconds to wait before the next poll. + required: + - status + - interval + additionalProperties: false + description: The pairing is not approved yet (HTTP 202). + DesktopPairingPoll: + anyOf: + - $ref: "#/components/schemas/DesktopPairingPending" + - $ref: "#/components/schemas/DesktopPairingProvisional" + description: State of a pairing, by `status`. + DesktopPairingPollRequest: + type: object + properties: + deviceSecret: + type: string + pattern: ^[A-Za-z0-9_-]{43}$ + required: + - deviceSecret + additionalProperties: false + DesktopPairingProvisional: + type: object + properties: + status: + type: string + const: provisional + token: + type: string + pattern: ^propr_it_[A-Za-z0-9_-]{43}$ + description: Instance token. It works only after the pairing is activated. + tokenType: + type: string + const: Bearer + activationTicket: + type: string + pattern: ^[A-Za-z0-9_-]{43}$ + activationExpiresAt: + type: string + format: date-time + description: Activate or cancel before this time. + instanceId: + type: string + pattern: ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$ + description: "`publicInstanceIdentity.instanceId` from discovery." + origin: + type: string + description: Canonical API origin the client discovered, for example `https://propr.example.com`. + scope: + type: string + const: desktop-instance + credentialGeneration: + type: string + pattern: ^[A-Za-z0-9_-]{22}$ + description: Client-chosen generation that identifies this credential. + required: + - status + - token + - tokenType + - activationTicket + - activationExpiresAt + - instanceId + - origin + - scope + - credentialGeneration + additionalProperties: false + description: The pairing was approved and a provisional instance token was issued (HTTP 200). + DesktopPairingStart: + type: object + properties: + pairingId: + type: string + pattern: ^dpr_[A-Za-z0-9_-]{22}$ + deviceSecret: + type: string + pattern: ^[A-Za-z0-9_-]{43}$ + description: Secret the client presents on every later pairing request. Never shown to the browser. + approvalUrl: + type: string + description: Same-origin URL the user opens to approve the pairing. + expiresAt: + type: string + format: date-time + description: The pairing expires at this time, at most 30 minutes after it started. interval: type: integer minimum: 1 maximum: 60 - description: Seconds to wait before the next poll. + description: Seconds to wait between polls. required: - - status + - pairingId + - deviceSecret + - approvalUrl + - expiresAt - interval additionalProperties: false - description: The pairing is not approved yet (HTTP 202). - DesktopPairingPoll: - anyOf: - - $ref: "#/components/schemas/DesktopPairingPending" - - $ref: "#/components/schemas/DesktopPairingProvisional" - description: State of a pairing, by `status`. - DesktopPairingPollRequest: + description: A started pairing. + DesktopPairingStartRequest: type: object properties: - deviceSecret: + clientName: type: string - pattern: ^[A-Za-z0-9_-]{43}$ + minLength: 1 + maxLength: 80 + description: Shown on the approval page; 1 to 80 printable characters. + instanceId: + type: string + pattern: ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$ + description: "`publicInstanceIdentity.instanceId` from discovery." + origin: + type: string + description: Canonical API origin the client discovered, for example `https://propr.example.com`. + scope: + type: string + const: desktop-instance + credentialGeneration: + type: string + pattern: ^[A-Za-z0-9_-]{22}$ + description: Client-chosen generation that identifies this credential. required: - - deviceSecret + - clientName + - instanceId + - origin + - scope + - credentialGeneration additionalProperties: false - DesktopPairingProvisional: + description: Starts a pairing for the instance named by the binding fields. + DesktopPairingTicket: type: object properties: - status: - type: string - const: provisional - token: - type: string - pattern: ^propr_it_[A-Za-z0-9_-]{43}$ - description: Instance token. It works only after the pairing is activated. - tokenType: + deviceSecret: type: string - const: Bearer + pattern: ^[A-Za-z0-9_-]{43}$ activationTicket: type: string pattern: ^[A-Za-z0-9_-]{43}$ - activationExpiresAt: - type: string - format: date-time - description: Activate or cancel before this time. + description: "`activationTicket` from the provisional poll response." instanceId: type: string pattern: ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$ @@ -10383,1840 +11240,3219 @@ components: pattern: ^[A-Za-z0-9_-]{22}$ description: Client-chosen generation that identifies this credential. required: - - status - - token - - tokenType + - deviceSecret - activationTicket - - activationExpiresAt - instanceId - origin - scope - credentialGeneration additionalProperties: false - description: The pairing was approved and a provisional instance token was issued (HTTP 200). - DesktopPairingStart: + description: Proof of a provisional pairing, used to activate or cancel it. + EpicQueueActionError: + type: object + properties: + error: + type: string + code: + type: string + enum: + - NOT_FOUND + - STALE_EXECUTION + - IN_FLIGHT + - ALREADY_DONE + - STATE_CHANGED + - NOT_BLOCKED + - NOT_RETRYABLE + required: + - error + - code + additionalProperties: false + description: A refused queue action. + EpicQueueActionResult: + type: object + properties: + outcome: + type: string + enum: + - skipped + - already_skipped + - retried + - already_retried + executionId: + type: string + issueNumber: + type: integer + exclusiveMinimum: 0 + queue: + anyOf: + - type: object + properties: {} + additionalProperties: {} + - type: "null" + description: The execution queue after the action. + required: + - outcome + - executionId + - issueNumber + - queue + additionalProperties: false + EpicQueueBlockedEvent: + type: object + properties: + id: + type: integer + exclusiveMinimum: 0 + description: Monotonic event id; also the list cursor. + draftId: + type: string + description: The plan. + executionId: + type: string + issueNumber: + type: integer + exclusiveMinimum: 0 + position: + type: integer + minimum: 0 + description: "The occurrence: a requeued issue starts a new one at a new position." + type: + type: string + enum: + - blocked + - resolved + blockedKind: + type: string + enum: + - closed_unmerged + - missing_from_plan + - dispatch_failed + - agent_blocked + - awaiting_human_merge + - label_handoff + - setup_not_ready + reason: + type: string + blockedEventId: + anyOf: + - type: integer + exclusiveMinimum: 0 + - type: "null" + description: "For a resolved event: the blocked event it resolves." + resolution: + anyOf: + - type: string + enum: + - cleared + - replaced + - retried + - skipped + - settled + - type: "null" + description: "How the blocker ended: cleared by the queue, replaced by another blocker, retried or skipped by a person, or settled with the issue." + createdAt: + type: integer + description: Epoch milliseconds. + required: + - id + - draftId + - executionId + - issueNumber + - position + - type + - blockedKind + - reason + - blockedEventId + - resolution + - createdAt + additionalProperties: false + description: One blocked transition of an issue in an epic execution, or the resolution of one. + EpicQueueBlockedEventPage: + type: object + properties: + executionId: + description: The listed execution; null when the plan never queued work. + type: + - string + - "null" + events: + type: array + items: + $ref: "#/components/schemas/EpicQueueBlockedEvent" + nextCursor: + description: Pass as `cursor` to read the events after this page; unchanged when the page is empty. + type: + - string + - "null" + hasMore: + type: boolean + required: + - executionId + - events + - nextCursor + - hasMore + additionalProperties: false + EpicQueueRetryRequest: + type: object + properties: + executionId: + type: string + description: The execution read from the execution queue; a newer or finished one refuses the retry. + expectedState: + description: Refuse with `STATE_CHANGED` unless the issue is still in this state. + type: string + enum: + - pending + - dispatched + - done + - blocked + - skipped + required: + - executionId + additionalProperties: false + EpicQueueSkipRequest: + type: object + properties: + executionId: + type: string + description: The execution read from the execution queue; a newer or finished one refuses the skip. + expectedState: + description: Refuse with `STATE_CHANGED` unless the issue is still in this state. + type: string + enum: + - pending + - dispatched + - done + - blocked + - skipped + satisfiesDependents: + description: "Graph executions: treat the skipped issue as a met prerequisite, so its dependents can start. Skipping again with the other value changes only this option." + type: boolean + required: + - executionId + additionalProperties: false + ErrorEnvelope: type: object properties: - pairingId: - type: string - pattern: ^dpr_[A-Za-z0-9_-]{22}$ - deviceSecret: + code: type: string - pattern: ^[A-Za-z0-9_-]{43}$ - description: Secret the client presents on every later pairing request. Never shown to the browser. - approvalUrl: + description: Stable, machine-readable error code, for example `TASK_NOT_FOUND`. + message: type: string - description: Same-origin URL the user opens to approve the pairing. - expiresAt: + description: Human-readable explanation that is safe to show to an operator. + hint: + description: Optional next step that resolves the error. type: string - format: date-time - description: The pairing expires at this time, at most 30 minutes after it started. - interval: - type: integer - minimum: 1 - maximum: 60 - description: Seconds to wait between polls. required: - - pairingId - - deviceSecret - - approvalUrl - - expiresAt - - interval + - code + - message + additionalProperties: false + description: The common error envelope. New routes return it for every 4xx and 5xx response. + ExtensionItemEnabledRequest: + type: object + properties: + item: + anyOf: + - type: object + properties: + kind: + type: string + const: server + name: + type: string + required: + - kind + - name + additionalProperties: false + - type: object + properties: + kind: + type: string + const: skill + bundleId: + 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)$ + required: + - kind + - bundleId + additionalProperties: false + enabled: + type: boolean + required: + - item + - enabled + additionalProperties: false + ExtensionLibraryInput: + type: object + properties: + version: + type: number + const: 1 + servers: + maxItems: 32 + type: array + items: + oneOf: + - type: object + properties: + kind: + type: string + const: https + name: + type: string + pattern: ^[a-z][a-z0-9_-]{0,47}$ + url: + type: string + maxLength: 2048 + enabled: + type: boolean + tools: + maxItems: 64 + type: array + items: + type: string + pattern: ^[A-Za-z0-9_.-]{1,128}$ + capability: + type: string + enum: + - read + - write + authentication: + type: string + enum: + - none + - bearer + - oauth + scopes: + maxItems: 32 + type: array + items: + type: string + pattern: ^[\x21\x23-\x5b\x5d-\x7e]{1,256}$ + clientId: + type: string + pattern: ^[\x21-\x7e]{1,512}$ + repositories: + default: [] + maxItems: 256 + type: array + items: + type: string + pattern: ^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$ + agents: + default: [] + maxItems: 64 + type: array + items: + type: string + pattern: ^[A-Za-z0-9_-]{1,100}$ + 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 + - url + - enabled + - tools + - capability + - authentication + - repositories + - agents + - autoApply + additionalProperties: false + - type: object + properties: + kind: + type: string + const: catalog + name: + type: string + pattern: ^[a-z][a-z0-9_-]{0,47}$ + catalogId: + type: string + pattern: ^[a-z][a-z0-9_-]{0,63}$ + digest: + type: string + pattern: ^[0-9a-f]{64}$ + enabled: + type: boolean + tools: + maxItems: 64 + type: array + items: + type: string + pattern: ^[A-Za-z0-9_.-]{1,128}$ + capability: + type: string + enum: + - read + - write + repositories: + default: [] + maxItems: 256 + type: array + items: + type: string + pattern: ^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$ + agents: + default: [] + maxItems: 64 + type: array + items: + type: string + pattern: ^[A-Za-z0-9_-]{1,100}$ + 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 + - catalogId + - digest + - enabled + - tools + - capability + - repositories + - agents + - autoApply + additionalProperties: false + - type: object + properties: + name: + type: string + pattern: ^[a-z][a-z0-9_-]{0,47}$ + enabled: + type: boolean + tools: + maxItems: 64 + type: array + items: + type: string + pattern: ^[A-Za-z0-9_.-]{1,128}$ + capability: + type: string + enum: + - read + - write + kind: + type: string + const: package + ecosystem: + type: string + enum: + - npm + package: + type: string + maxLength: 214 + pattern: ^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$ + version: + type: string + maxLength: 256 + pattern: ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ + integrity: + type: string + pattern: ^sha512-[A-Za-z0-9+/]{86}==$ + bin: + type: string + pattern: ^[A-Za-z0-9@._-]{1,128}$ + args: + default: [] + maxItems: 64 + type: array + items: + type: string + maxLength: 2048 + pattern: ^[^\x00\r\n]*$ + env: + default: [] + maxItems: 32 + type: array + items: + type: object + properties: + name: + type: string + pattern: ^[A-Z_][A-Z0-9_]{0,99}$ + value: + type: string + maxLength: 4096 + pattern: ^[^\x00\r\n]*$ + required: + - name + - value + additionalProperties: false + secretEnv: + default: [] + maxItems: 16 + type: array + items: + type: string + pattern: ^[A-Z_][A-Z0-9_]{0,99}$ + repositories: + default: [] + maxItems: 256 + type: array + items: + type: string + pattern: ^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$ + agents: + default: [] + maxItems: 64 + type: array + items: + type: string + pattern: ^[A-Za-z0-9_-]{1,100}$ + 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 + - tools + - capability + - kind + - ecosystem + - package + - version + - args + - env + - secretEnv + - repositories + - agents + - autoApply + additionalProperties: false + skills: + maxItems: 32 + type: array + items: + type: object + properties: + bundleId: + 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)$ + enabled: + type: boolean + repositories: + default: [] + maxItems: 256 + type: array + items: + type: string + pattern: ^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$ + agents: + default: [] + maxItems: 64 + type: array + items: + type: string + pattern: ^[A-Za-z0-9_-]{1,100}$ + 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 + - repositories + - agents + - autoApply + additionalProperties: false + required: + - version + - servers + - skills additionalProperties: false - description: A started pairing. - DesktopPairingStartRequest: + 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: - clientName: - type: string - minLength: 1 - maxLength: 80 - description: Shown on the approval page; 1 to 80 printable characters. + contract: + type: object + properties: {} + additionalProperties: {} instanceId: type: string - pattern: ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$ - description: "`publicInstanceIdentity.instanceId` from discovery." - origin: - type: string - description: Canonical API origin the client discovered, for example `https://propr.example.com`. - scope: - type: string - const: desktop-instance - credentialGeneration: - type: string - pattern: ^[A-Za-z0-9_-]{22}$ - description: Client-chosen generation that identifies this credential. + library: + type: object + properties: + version: + type: number + const: 1 + instanceId: + type: string + revision: + type: integer + description: 0 before the first save. + createdAt: + type: number + updatedAt: + type: number + auditActor: + type: string + servers: + type: array + items: + type: object + properties: {} + additionalProperties: {} + skills: + type: array + items: + type: object + properties: {} + additionalProperties: {} + secrets: + type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + configured: + type: boolean + required: + - configured + additionalProperties: {} + description: Presence markers only; secret values are never returned. + skillBundles: + type: array + items: + type: object + properties: {} + additionalProperties: {} + required: + - version + - instanceId + - revision + - createdAt + - updatedAt + - auditActor + - servers + - skills + - secrets + - skillBundles + additionalProperties: {} + oauth: + $ref: "#/components/schemas/ExtensionOAuthInstanceInfo" + bundles: + type: array + items: + type: object + properties: {} + additionalProperties: {} + catalog: + type: array + items: + type: object + properties: {} + additionalProperties: {} + usage: + type: object + properties: + automations: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + servers: + type: array + items: + type: string + skills: + type: array + items: + type: string + required: + - id + - name + - servers + - skills + additionalProperties: false + importedFrom: + type: object + properties: + servers: + type: object + propertyNames: + type: string + additionalProperties: + type: array + items: + type: string + skills: + type: object + propertyNames: + type: string + additionalProperties: + type: array + items: + type: string + required: + - servers + - skills + additionalProperties: false + required: + - automations + - importedFrom + additionalProperties: false + description: Automations that add library items explicitly (server names and skill bundle ids), and the names of the extension profiles that migrated items came from. required: - - clientName + - contract - instanceId - - origin - - scope - - credentialGeneration + - library + - oauth + - bundles + - catalog + - usage additionalProperties: false - description: Starts a pairing for the instance named by the binding fields. - DesktopPairingTicket: + ExtensionLibraryRevisionResponse: type: object properties: - deviceSecret: - type: string - pattern: ^[A-Za-z0-9_-]{43}$ - activationTicket: - type: string - pattern: ^[A-Za-z0-9_-]{43}$ - description: "`activationTicket` from the provisional poll response." - instanceId: - type: string - pattern: ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$ - description: "`publicInstanceIdentity.instanceId` from discovery." - origin: - type: string - description: Canonical API origin the client discovered, for example `https://propr.example.com`. - scope: - type: string - const: desktop-instance - credentialGeneration: - type: string - pattern: ^[A-Za-z0-9_-]{22}$ - description: Client-chosen generation that identifies this credential. + library: + type: object + properties: + version: + type: number + const: 1 + instanceId: + type: string + revision: + type: integer + description: 0 before the first save. + createdAt: + type: number + updatedAt: + type: number + auditActor: + type: string + servers: + type: array + items: + type: object + properties: {} + additionalProperties: {} + skills: + type: array + items: + type: object + properties: {} + additionalProperties: {} + secrets: + type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + configured: + type: boolean + required: + - configured + additionalProperties: {} + description: Presence markers only; secret values are never returned. + skillBundles: + type: array + items: + type: object + properties: {} + additionalProperties: {} + required: + - version + - instanceId + - revision + - createdAt + - updatedAt + - auditActor + - servers + - skills + - secrets + - skillBundles + additionalProperties: {} required: - - deviceSecret - - activationTicket - - instanceId - - origin - - scope - - credentialGeneration + - library additionalProperties: false - description: Proof of a provisional pairing, used to activate or cancel it. - EpicQueueActionError: + ExtensionLibrarySave: type: object properties: - error: - type: string - code: - type: string - enum: - - NOT_FOUND - - STALE_EXECUTION - - IN_FLIGHT - - ALREADY_DONE - - STATE_CHANGED - - NOT_BLOCKED - - NOT_RETRYABLE + input: + $ref: "#/components/schemas/ExtensionLibraryInput" + expectedRevision: + type: integer + minimum: 0 + secrets: + type: object + propertyNames: + type: string + additionalProperties: + type: + - string + - "null" + acknowledgements: + description: 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. + type: array + items: + type: string required: - - error - - code + - input + - expectedRevision additionalProperties: false - description: A refused queue action. - EpicQueueActionResult: + ExtensionOAuthCheckRequest: type: object properties: - outcome: + url: type: string - enum: - - skipped - - already_skipped - - retried - - already_retried - executionId: + clientId: type: string - issueNumber: - type: integer - exclusiveMinimum: 0 - queue: - anyOf: - - type: object - properties: {} - additionalProperties: {} - - type: "null" - description: The execution queue after the action. + scopes: + maxItems: 32 + type: array + items: + type: string required: - - outcome - - executionId - - issueNumber - - queue + - url additionalProperties: false - EpicQueueBlockedEvent: + ExtensionOAuthCheckResponse: type: object properties: - id: - type: integer - exclusiveMinimum: 0 - description: Monotonic event id; also the list cursor. - draftId: - type: string - description: The plan. - executionId: - type: string - issueNumber: - type: integer - exclusiveMinimum: 0 - position: - type: integer - minimum: 0 - description: "The occurrence: a requeued issue starts a new one at a new position." - type: - type: string - enum: - - blocked - - resolved - blockedKind: - type: string - enum: - - closed_unmerged - - missing_from_plan - - dispatch_failed - - agent_blocked - - awaiting_human_merge - - label_handoff - - setup_not_ready - reason: - type: string - blockedEventId: - anyOf: - - type: integer - exclusiveMinimum: 0 - - type: "null" - description: "For a resolved event: the blocked event it resolves." - resolution: - anyOf: - - type: string + check: + type: object + properties: + authorizationRequired: + type: boolean + discoveredFrom: + type: string enum: - - cleared - - replaced - - retried - - skipped - - settled - - type: "null" - description: "How the blocker ended: cleared by the queue, replaced by another blocker, retried or skipped by a person, or settled with the issue." - createdAt: - type: integer - description: Epoch milliseconds. + - challenge + - well_known + resource: + type: string + issuer: + type: string + scopes: + type: array + items: + type: string + registration: + anyOf: + - type: string + enum: + - metadata_document + - dynamic + - preregistered + - type: "null" + message: + type: string + redirectUri: + type: + - string + - "null" + required: + - authorizationRequired + - registration + - message + - redirectUri + additionalProperties: false required: - - id - - draftId - - executionId - - issueNumber - - position - - type - - blockedKind - - reason - - blockedEventId - - resolution - - createdAt + - check additionalProperties: false - description: One blocked transition of an issue in an epic execution, or the resolution of one. - EpicQueueBlockedEventPage: + ExtensionOAuthInstanceInfo: type: object properties: - executionId: - description: The listed execution; null when the plan never queued work. + redirectUri: + description: Redirect URI to register with a provider; null without a public HTTPS base URL, which disables Connect. type: - string - "null" - events: - type: array - items: - $ref: "#/components/schemas/EpicQueueBlockedEvent" - nextCursor: - description: Pass as `cursor` to read the events after this page; unchanged when the page is empty. + clientMetadataUrl: + description: ProPR's Client ID Metadata Document URL, used as the client id where supported. type: - string - "null" - hasMore: - type: boolean + connections: + type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + state: + type: string + enum: + - not_connected + - connected + - needs_sign_in + connectedBy: + description: Actor id of the administrator who signed in. + type: string + connectedAt: + type: number + scopes: + description: Scopes the authorization server granted. + type: array + items: + type: string + registration: + type: string + enum: + - metadata_document + - dynamic + - preregistered + required: + - state + additionalProperties: false + description: Connection state by OAuth server name. Tokens are never returned. required: - - executionId - - events - - nextCursor - - hasMore + - redirectUri + - clientMetadataUrl + - connections additionalProperties: false - EpicQueueRetryRequest: + description: OAuth sign-in facts of this instance and the connection state of its OAuth library servers. + ExtensionOAuthItemRequest: type: object properties: - executionId: - type: string - description: The execution read from the execution queue; a newer or finished one refuses the retry. - expectedState: - description: Refuse with `STATE_CHANGED` unless the issue is still in this state. + name: type: string - enum: - - pending - - dispatched - - done - - blocked - - skipped required: - - executionId + - name additionalProperties: false - EpicQueueSkipRequest: + ExtensionOAuthStartResponse: type: object properties: - executionId: - type: string - description: The execution read from the execution queue; a newer or finished one refuses the skip. - expectedState: - description: Refuse with `STATE_CHANGED` unless the issue is still in this state. + authorizationUrl: type: string - enum: - - pending - - dispatched - - done - - blocked - - skipped - satisfiesDependents: - description: "Graph executions: treat the skipped issue as a met prerequisite, so its dependents can start. Skipping again with the other value changes only this option." - type: boolean + description: Provider authorization page to open in a new tab; carries a single-use state bound to this session, PKCE S256 and the resource indicator. required: - - executionId + - authorizationUrl additionalProperties: false - ErrorEnvelope: + ExtensionSecretReplaceRequest: type: object properties: - code: + name: type: string - description: Stable, machine-readable error code, for example `TASK_NOT_FOUND`. - message: + secret: + type: + - string + - "null" + expectedRevision: + type: integer + minimum: 0 + required: + - name + - secret + additionalProperties: false + ExtensionSelection: + type: object + properties: + instanceId: type: string - description: Human-readable explanation that is safe to show to an operator. - hint: - description: Optional next step that resolves the error. + 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)$ + profileId: 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)$ + revision: + type: integer + exclusiveMinimum: 0 required: - - code - - message + - instanceId + - profileId + - revision additionalProperties: false - description: The common error envelope. New routes return it for every 4xx and 5xx response. - ExtensionItemEnabledRequest: + description: Legacy profile selection; no longer accepted for new submissions. + FollowupHandoffDetail: type: object properties: - item: - anyOf: - - type: object - properties: - kind: - type: string - const: server - name: - type: string - required: - - kind - - name - additionalProperties: false - - type: object - properties: - kind: - type: string - const: skill - bundleId: - 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)$ - required: - - kind - - bundleId - additionalProperties: false - enabled: + status: + $ref: "#/components/schemas/FollowupHandoffStatus" + required: + - status + additionalProperties: false + FollowupHandoffList: + type: object + properties: + handoffs: + type: array + items: + $ref: "#/components/schemas/FollowupHandoffStatus" + required: + - handoffs + additionalProperties: false + FollowupHandoffRecoverySummary: + type: object + properties: + inspected: + type: integer + minimum: 0 + published: + type: integer + minimum: 0 + description: Interrupted work recovered and published exactly once. + noChanges: + type: integer + minimum: 0 + description: Interrupted runs that produced nothing; their requests were returned to the queue (settlement `returned_to_queue`). + settled: + type: integer + minimum: 0 + description: Runs whose no-change completion, failure or usage-limit retry the normal path had already delivered or scheduled; recorded without republishing or requeueing. + cleanupPending: + type: integer + minimum: 0 + description: Publication settled but branch release still owed; retried on the next pass. + skippedLiveAgent: + type: integer + minimum: 0 + failedClosed: + type: integer + minimum: 0 + description: Records whose container evidence was unavailable or ambiguous; left untouched. + awaitingOperator: + type: integer + minimum: 0 + lockedByOtherWriter: + type: integer + minimum: 0 + errors: + type: integer + minimum: 0 + description: Records whose recovery failed (for example unreachable remote evidence); retained for the next pass. + required: + - inspected + - published + - noChanges + - settled + - cleanupPending + - skippedLiveAgent + - failedClosed + - awaitingOperator + - lockedByOtherWriter + - errors + additionalProperties: false + description: Counts of one recovery pass. Keyed passes inspect at most the addressed pull request. + FollowupHandoffReleaseRequest: + type: object + properties: + force: + description: Also release a branch whose retained worktree still holds unpublished work. Never deletes worktree files, and never overrides a live or unverifiable agent. + type: boolean + additionalProperties: false + FollowupHandoffReleaseResult: + type: object + properties: + released: type: boolean + reason: + description: Refusal code when `released` is false, for example `agent_still_running`, `liveness_unavailable`, `lease_held_by_live_writer`, `worktree_holds_unpublished_work`, `branch_still_registered` (a Git worktree registration still holds the branch, even after a forced detach), `worktree_registrations_unavailable` or `cancelled_claims_withdrawal_failed` (the claimed comments of a cancelled request could not be withdrawn from the pending list, so releasing could let them run). + type: string required: - - item - - enabled + - released additionalProperties: false - ExtensionLibraryInput: + description: "Outcome of a release request; refusals answer `409` with `released: false`." + FollowupHandoffResumeRequest: type: object properties: - version: - type: number - const: 1 - servers: - maxItems: 32 - type: array - items: - oneOf: - - type: object - properties: - kind: - type: string - const: https - name: - type: string - pattern: ^[a-z][a-z0-9_-]{0,47}$ - url: - type: string - maxLength: 2048 - enabled: - type: boolean - tools: - maxItems: 64 - type: array - items: - type: string - pattern: ^[A-Za-z0-9_.-]{1,128}$ - capability: - type: string - enum: - - read - - write - authentication: - type: string - enum: - - none - - bearer - - oauth - scopes: - maxItems: 32 - type: array - items: - type: string - pattern: ^[\x21\x23-\x5b\x5d-\x7e]{1,256}$ - clientId: - type: string - pattern: ^[\x21-\x7e]{1,512}$ - repositories: - default: [] - maxItems: 64 - type: array - items: - type: string - pattern: ^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$ - agents: - default: [] - maxItems: 64 - type: array - items: - type: string - pattern: ^[A-Za-z0-9_-]{1,100}$ - autoApply: - default: true - type: boolean - required: - - kind - - name - - url - - enabled - - tools - - capability - - authentication - - repositories - - agents - - autoApply - additionalProperties: false - - type: object - properties: - kind: - type: string - const: catalog - name: - type: string - pattern: ^[a-z][a-z0-9_-]{0,47}$ - catalogId: - type: string - pattern: ^[a-z][a-z0-9_-]{0,63}$ - digest: - type: string - pattern: ^[0-9a-f]{64}$ - enabled: - type: boolean - tools: - maxItems: 64 - type: array - items: - type: string - pattern: ^[A-Za-z0-9_.-]{1,128}$ - capability: - type: string - enum: - - read - - write - repositories: - default: [] - maxItems: 64 - type: array - items: - type: string - pattern: ^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$ - agents: - default: [] - maxItems: 64 - type: array - items: - type: string - pattern: ^[A-Za-z0-9_-]{1,100}$ - autoApply: - default: true - type: boolean - required: - - kind - - name - - catalogId - - digest - - enabled - - tools - - capability - - repositories - - agents - - autoApply - additionalProperties: false - - type: object - properties: - name: - type: string - pattern: ^[a-z][a-z0-9_-]{0,47}$ - enabled: - type: boolean - tools: - maxItems: 64 - type: array - items: - type: string - pattern: ^[A-Za-z0-9_.-]{1,128}$ - capability: - type: string - enum: - - read - - write - kind: - type: string - const: package - ecosystem: - type: string - enum: - - npm - package: - type: string - maxLength: 214 - pattern: ^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$ - version: - type: string - maxLength: 256 - pattern: ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ - integrity: - type: string - pattern: ^sha512-[A-Za-z0-9+/]{86}==$ - bin: - type: string - pattern: ^[A-Za-z0-9@._-]{1,128}$ - args: - default: [] - maxItems: 64 - type: array - items: - type: string - maxLength: 2048 - pattern: ^[^\x00\r\n]*$ - env: - default: [] - maxItems: 32 - type: array - items: - type: object - properties: - name: - type: string - pattern: ^[A-Z_][A-Z0-9_]{0,99}$ - value: - type: string - maxLength: 4096 - pattern: ^[^\x00\r\n]*$ - required: - - name - - value - additionalProperties: false - secretEnv: - default: [] - maxItems: 16 - type: array - items: - type: string - pattern: ^[A-Z_][A-Z0-9_]{0,99}$ - repositories: - default: [] - maxItems: 64 - type: array - items: - type: string - pattern: ^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$ - agents: - default: [] - maxItems: 64 - type: array - items: - type: string - pattern: ^[A-Za-z0-9_-]{1,100}$ - autoApply: - default: true - type: boolean - required: - - name - - enabled - - tools - - capability - - kind - - ecosystem - - package - - version - - args - - env - - secretEnv - - repositories - - agents - - autoApply - additionalProperties: false - skills: - maxItems: 32 + authorizeCancelled: + description: Set `true` to explicitly authorize publishing work the user cancelled. Without it, resume re-arms the operator-held record and refuses cancelled work with code `requires_cancellation_authorization`. + type: boolean + additionalProperties: false + FollowupHandoffResumeResult: + type: object + properties: + summary: + $ref: "#/components/schemas/FollowupHandoffRecoverySummary" + status: + anyOf: + - $ref: "#/components/schemas/FollowupHandoffStatus" + - type: "null" + description: State after the keyed recovery pass; `null` once the handoff retired completely. + required: + - summary + - status + additionalProperties: false + FollowupHandoffStatus: + type: object + properties: + repository: + type: string + description: Lowercase `owner/repo` of the pull request the handoff belongs to. + pullRequest: + type: integer + exclusiveMinimum: 0 + taskId: + type: string + description: Task whose interrupted follow-up publication the handoff records. + predecessorTaskIds: + type: array + items: + type: string + description: Source task lineage recorded when the agent launched. + successorTaskId: + description: Task that adopted the handoff during recovery, when any. + type: + - string + - "null" + phase: + type: string + enum: + - agent_running + - agent_exited + - committed + - pushed + - comment_published + - cleanup_pending + description: Exact publication step recovery resumes from. `cleanup_pending` means everything is published and only branch release is owed. + status: + type: string + enum: + - active + - awaiting_operator + - released + description: "`active` records are recovered automatically; `awaiting_operator` records never publish without an explicit operator decision." + statusReason: + description: Why the handoff is held or failed closed, for example `cancelled_by_user`, `worktree_missing`, `head_advanced_externally`, `usage_limit_retry_unconfirmed`, `liveness_unavailable`. A mutable diagnostic; see `settlement` for the durable outcome. + type: + - string + - "null" + settlement: + anyOf: + - type: string + enum: + - returned_to_queue + - usage_limit_retry_scheduling + - usage_limit_retry_scheduled + - type: "null" + description: "Durable owner of the claimed comments once the attempt settles without publishing: `returned_to_queue` (recovery returned them to the pending list), `usage_limit_retry_scheduled` (a scheduled usage-limit retry job owns them; recovery never requeues them) or `usage_limit_retry_scheduling` (the worker stopped around the queue add; held for an operator as `usage_limit_retry_unconfirmed`). Unlike `statusReason`, later diagnostics never overwrite it, so the `noChanges`/`settled` counters stay correct." + operatorAuthorized: + type: boolean + description: Whether an operator explicitly authorized publishing user-cancelled work. + liveness: + type: string + description: Agent container liveness evidence, for example `running`, `stopped`, `not_found` or `unavailable` (recovery fails closed on the latter). + branchName: + type: string + description: PR head branch still attached to the retained worktree until cleanup releases it. + commitHash: + description: Commit recovery is bound to publish, once recorded. + type: + - string + - "null" + completionCommentUrl: + description: Delivered completion comment; recovery never overwrites it. + type: + - string + - "null" + nextAction: + type: string + description: Operator guidance for the current state. + retainedWorktreePath: + type: string + description: Host path of the retained worktree. Deliberately admin-only; never shown on member routes. + updatedAt: + type: number + description: Unix milliseconds of the last durable update. + required: + - repository + - pullRequest + - taskId + - predecessorTaskIds + - successorTaskId + - phase + - status + - statusReason + - settlement + - operatorAuthorized + - liveness + - branchName + - commitHash + - completionCommentUrl + - nextAction + - retainedWorktreePath + - updatedAt + additionalProperties: false + description: Operator-facing status of one durable follow-up publication handoff. + Health: + type: object + properties: + status: + type: string + const: ok + required: + - status + additionalProperties: false + description: Liveness probe result. + InstanceDatabaseExport: + type: object + properties: + version: + type: number + const: 1 + instanceId: + type: string + generation: + 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)$ + operationId: + 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)$ + state: + type: string + enum: + - running + - ready + - failed + - redeemed + expiresAt: + type: string + format: date-time + scope: + type: string + const: customer_database + sizeBytes: + type: number + minimum: 0 + sha256: + type: string + schemaVersion: + type: integer + migration: + type: + - string + - "null" + required: + - version + - instanceId + - generation + - operationId + - state + - expiresAt + - scope + additionalProperties: false + InstanceDatabaseExportRequest: + type: object + properties: + instanceId: + type: string + generation: + 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)$ + required: + - instanceId + - generation + additionalProperties: false + InstanceRepositoryInventory: + type: object + properties: + version: + type: number + const: 1 + instanceId: + type: string + state: + type: string + enum: + - complete + - partial + - unknown + - unsupported + observedAt: + type: string + format: date-time + lastUseAt: + anyOf: + - type: string + format: date-time + - type: "null" + repositories: type: array items: type: object properties: - bundleId: + id: 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)$ - enabled: - type: boolean - repositories: - default: [] - maxItems: 64 - type: array - items: - type: string - pattern: ^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$ - agents: - default: [] - maxItems: 64 - type: array - items: - type: string - pattern: ^[A-Za-z0-9_-]{1,100}$ - autoApply: - default: true - type: boolean + name: + type: string + category: + type: string + enum: + - database_config + - repository_caches + - worktrees + - salvage + - logs_artifacts + - provider_credentials + - task_history + estimatedBytes: + anyOf: + - type: number + minimum: 0 + - type: "null" + protectedReasons: + type: array + items: + type: string + enum: + - partial_generated_cleanup + - busy + - unknown_use + - recent_use + - unknown_ownership + - unsafe_path + - private_work + - worktrees + - local_history + - salvage + - remote_unavailable + - unknown_git_state + - changed + - unsupported required: - - bundleId - - enabled - - repositories - - agents - - autoApply + - id + - name + - category + - estimatedBytes + - protectedReasons additionalProperties: false required: - version - - servers - - skills + - instanceId + - state + - observedAt + - lastUseAt + - repositories 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." - ExtensionLibraryRead: + InstanceStorageCapabilities: type: object properties: - contract: - type: object - properties: {} - additionalProperties: {} + version: + type: number + const: 1 instanceId: type: string - library: - type: object - properties: - version: - type: number - const: 1 - instanceId: - type: string - revision: - type: integer - description: 0 before the first save. - createdAt: - type: number - updatedAt: - type: number - auditActor: - type: string - servers: - type: array - items: - type: object - properties: {} - additionalProperties: {} - skills: - type: array - items: - type: object - properties: {} - additionalProperties: {} - secrets: - type: object - propertyNames: - type: string - additionalProperties: - type: object - properties: - configured: - type: boolean - required: - - configured - additionalProperties: {} - description: Presence markers only; secret values are never returned. - skillBundles: - type: array - items: - type: object - properties: {} - additionalProperties: {} - required: - - version - - instanceId - - revision - - createdAt - - updatedAt - - auditActor - - servers - - skills - - secrets - - skillBundles - additionalProperties: {} - oauth: - $ref: "#/components/schemas/ExtensionOAuthInstanceInfo" - bundles: - type: array - items: - type: object - properties: {} - additionalProperties: {} - catalog: + generation: + 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)$ + features: type: array items: - type: object - properties: {} - additionalProperties: {} - usage: - type: object - properties: - automations: - type: array - items: - type: object - properties: - id: - type: string - name: - type: string - servers: - type: array - items: - type: string - skills: - type: array - items: - type: string - required: - - id - - name - - servers - - skills - additionalProperties: false - importedFrom: - type: object - properties: - servers: - type: object - propertyNames: - type: string - additionalProperties: - type: array - items: - type: string - skills: - type: object - propertyNames: - type: string - additionalProperties: - type: array - items: - type: string - required: - - servers - - skills - additionalProperties: false - required: - - automations - - importedFrom - additionalProperties: false - description: Automations that add library items explicitly (server names and skill bundle ids), and the names of the extension profiles that migrated items came from. + type: string + enum: + - repository_inventory + - repository_cleanup + - database_export required: - - contract + - version - instanceId - - library - - oauth - - bundles - - catalog - - usage + - generation + - features additionalProperties: false - ExtensionLibraryRevisionResponse: + InstanceStorageExecuteRequest: type: object properties: - library: + instanceId: + type: string + confirmation: + 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)$ + required: + - instanceId + - confirmation + additionalProperties: false + InstanceStoragePolicy: + type: object + properties: + version: + type: number + const: 1 + instanceId: + type: string + policy: type: object properties: - version: - type: number - const: 1 - instanceId: - type: string - revision: - type: integer - description: 0 before the first save. - createdAt: - type: number - updatedAt: - type: number - auditActor: - type: string - servers: - type: array - items: - type: object - properties: {} - additionalProperties: {} - skills: - type: array - items: - type: object - properties: {} - additionalProperties: {} - secrets: - type: object - propertyNames: - type: string - additionalProperties: - type: object - properties: - configured: - type: boolean - required: - - configured - additionalProperties: {} - description: Presence markers only; secret values are never returned. - skillBundles: - type: array - items: - type: object - properties: {} - additionalProperties: {} + enabled: + type: boolean + idleHours: + type: integer + minimum: 1 + maximum: 8760 required: - - version - - instanceId - - revision - - createdAt - - updatedAt - - auditActor - - servers - - skills - - secrets - - skillBundles - additionalProperties: {} + - enabled + - idleHours + additionalProperties: false required: - - library + - version + - instanceId + - policy additionalProperties: false - ExtensionLibrarySave: + InstanceStoragePolicyRequest: type: object properties: - input: - $ref: "#/components/schemas/ExtensionLibraryInput" - expectedRevision: + enabled: + type: boolean + idleHours: type: integer - minimum: 0 - secrets: - type: object - propertyNames: + minimum: 1 + maximum: 8760 + instanceId: + type: string + required: + - enabled + - idleHours + - instanceId + additionalProperties: false + InstanceStoragePreview: + type: object + properties: + version: + type: number + const: 1 + instanceId: + type: string + confirmation: + 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)$ + expiresAt: + type: string + format: date-time + categories: + type: array + items: type: string - additionalProperties: - type: - - string - - "null" - acknowledgements: - description: 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. + enum: + - database_config + - repository_caches + - worktrees + - salvage + - logs_artifacts + - provider_credentials + - task_history + impact: + type: string + candidates: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + category: + type: string + enum: + - database_config + - repository_caches + - worktrees + - salvage + - logs_artifacts + - provider_credentials + - task_history + estimatedBytes: + anyOf: + - type: number + minimum: 0 + - type: "null" + protectedReasons: + type: array + items: + type: string + enum: + - partial_generated_cleanup + - busy + - unknown_use + - recent_use + - unknown_ownership + - unsafe_path + - private_work + - worktrees + - local_history + - salvage + - remote_unavailable + - unknown_git_state + - changed + - unsupported + required: + - id + - category + - estimatedBytes + - protectedReasons + additionalProperties: false + required: + - version + - instanceId + - confirmation + - expiresAt + - categories + - impact + - candidates + additionalProperties: false + InstanceStoragePreviewRequest: + type: object + properties: + instanceId: + type: string + categories: + minItems: 1 + maxItems: 7 + type: array + items: + type: string + enum: + - database_config + - repository_caches + - worktrees + - salvage + - logs_artifacts + - provider_credentials + - task_history + taskIds: + maxItems: 10 type: array items: type: string + pattern: ^[A-Za-z0-9_-]{1,255}$ required: - - input - - expectedRevision + - instanceId + - categories additionalProperties: false - ExtensionOAuthCheckRequest: + InstanceStorageResult: type: object properties: - url: + version: + type: number + const: 1 + instanceId: type: string - clientId: + operationId: type: string - scopes: - maxItems: 32 + 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)$ + state: + type: string + enum: + - completed + - interrupted + deleted: type: array items: type: string + retained: + type: array + items: + type: object + properties: + id: + type: string + reasons: + type: array + items: + type: string + enum: + - partial_generated_cleanup + - busy + - unknown_use + - recent_use + - unknown_ownership + - unsafe_path + - private_work + - worktrees + - local_history + - salvage + - remote_unavailable + - unknown_git_state + - changed + - unsupported + required: + - id + - reasons + additionalProperties: false required: - - url + - version + - instanceId + - operationId + - state + - deleted + - retained additionalProperties: false - ExtensionOAuthCheckResponse: + InstanceStorageUsage: type: object properties: - check: - type: object - properties: - authorizationRequired: - type: boolean - discoveredFrom: - type: string - enum: - - challenge - - well_known - resource: - type: string - issuer: + version: + type: number + const: 1 + instanceId: + type: string + observedAt: + anyOf: + - type: string + format: date-time + - type: "null" + state: + type: string + enum: + - complete + - partial + - unknown + - unsupported + scanning: + type: boolean + ownedBytes: + anyOf: + - type: number + minimum: 0 + - type: "null" + generatedPayloads: + description: Last bounded generated-payload proof, protected reasons and durable result. Observations may be stale or partial; unregistered worktrees remain protected. + type: object + properties: + observedAt: + anyOf: + - type: string + format: date-time + - type: "null" + reclaimableBytes: + anyOf: + - type: number + minimum: 0 + - type: "null" + coverage: type: string - scopes: + candidates: type: array items: - type: string - registration: + type: object + properties: + id: + type: string + category: + type: string + enum: + - database_config + - repository_caches + - worktrees + - salvage + - logs_artifacts + - provider_credentials + - task_history + estimatedBytes: + anyOf: + - type: number + minimum: 0 + - type: "null" + protectedReasons: + type: array + items: + type: string + enum: + - partial_generated_cleanup + - busy + - unknown_use + - recent_use + - unknown_ownership + - unsafe_path + - private_work + - worktrees + - local_history + - salvage + - remote_unavailable + - unknown_git_state + - changed + - unsupported + required: + - id + - category + - estimatedBytes + - protectedReasons + additionalProperties: false + lastCleanup: anyOf: - - type: string - enum: - - metadata_document - - dynamic - - preregistered + - type: object + propertyNames: + type: string + additionalProperties: {} - type: "null" - message: - type: string - redirectUri: - type: - - string - - "null" required: - - authorizationRequired - - registration - - message - - redirectUri + - observedAt + - reclaimableBytes + - coverage + - lastCleanup additionalProperties: false - required: - - check - additionalProperties: false - ExtensionOAuthInstanceInfo: - type: object - properties: - redirectUri: - description: Redirect URI to register with a provider; null without a public HTTPS base URL, which disables Connect. - type: - - string - - "null" - clientMetadataUrl: - description: ProPR's Client ID Metadata Document URL, used as the client id where supported. - type: - - string - - "null" - connections: - type: object - propertyNames: - type: string - additionalProperties: + categories: + type: array + items: type: object properties: - state: + category: type: string enum: - - not_connected - - connected - - needs_sign_in - connectedBy: - description: Actor id of the administrator who signed in. - type: string - connectedAt: - type: number - scopes: - description: Scopes the authorization server granted. - type: array - items: - type: string - registration: + - database_config + - repository_caches + - worktrees + - salvage + - logs_artifacts + - provider_credentials + - task_history + bytes: + anyOf: + - type: number + minimum: 0 + - type: "null" + state: type: string enum: - - metadata_document - - dynamic - - preregistered + - complete + - partial + - unknown + - unsupported required: + - category + - bytes - state additionalProperties: false - description: Connection state by OAuth server name. Tokens are never returned. - required: - - redirectUri - - clientMetadataUrl - - connections - additionalProperties: false - description: OAuth sign-in facts of this instance and the connection state of its OAuth library servers. - ExtensionOAuthItemRequest: - type: object - properties: - name: - type: string - required: - - name - additionalProperties: false - ExtensionOAuthStartResponse: - type: object - properties: - authorizationUrl: - type: string - description: Provider authorization page to open in a new tab; carries a single-use state bound to this session, PKCE S256 and the resource indicator. + filesystems: + type: array + items: + type: object + properties: + id: + type: string + totalBytes: + type: number + minimum: 0 + availableBytes: + type: number + minimum: 0 + required: + - id + - totalBytes + - availableBytes + additionalProperties: false required: - - authorizationUrl + - version + - instanceId + - observedAt + - state + - scanning + - ownedBytes + - categories + - filesystems additionalProperties: false - ExtensionSecretReplaceRequest: + JsonObject: type: object - properties: - name: - type: string - secret: - type: - - string - - "null" - expectedRevision: - type: integer - minimum: 0 - required: - - name - - secret - additionalProperties: false - ExtensionSelection: + properties: {} + additionalProperties: {} + description: A JSON object whose fields are not documented yet. + LegacyError: type: object properties: - instanceId: + error: 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)$ - profileId: + description: Human-readable error message. + code: + description: Machine-readable code, present on authentication and some validation errors. + type: string + message: + description: Longer explanation, present on some authentication errors. 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)$ - revision: - type: integer - exclusiveMinimum: 0 - required: - - instanceId - - profileId - - revision - additionalProperties: false - description: Legacy profile selection; no longer accepted for new submissions. - FollowupHandoffDetail: - type: object - properties: - status: - $ref: "#/components/schemas/FollowupHandoffStatus" - required: - - status - additionalProperties: false - FollowupHandoffList: - type: object - properties: - handoffs: - type: array - items: - $ref: "#/components/schemas/FollowupHandoffStatus" required: - - handoffs - additionalProperties: false - FollowupHandoffRecoverySummary: + - error + additionalProperties: {} + description: "The ad-hoc error shape most existing routes return. Operations that use it carry `x-legacy-error: true`." + NotificationUnreadCount: type: object properties: - inspected: - type: integer - minimum: 0 - published: - type: integer - minimum: 0 - description: Interrupted work recovered and published exactly once. - noChanges: - type: integer - minimum: 0 - description: Interrupted runs that produced nothing; their requests were returned to the queue (settlement `returned_to_queue`). - settled: - type: integer - minimum: 0 - description: Runs whose no-change completion, failure or usage-limit retry the normal path had already delivered or scheduled; recorded without republishing or requeueing. - cleanupPending: - type: integer - minimum: 0 - description: Publication settled but branch release still owed; retried on the next pass. - skippedLiveAgent: - type: integer - minimum: 0 - failedClosed: - type: integer - minimum: 0 - description: Records whose container evidence was unavailable or ambiguous; left untouched. - awaitingOperator: - type: integer - minimum: 0 - lockedByOtherWriter: - type: integer - minimum: 0 - errors: + unreadCount: type: integer minimum: 0 - description: Records whose recovery failed (for example unreachable remote evidence); retained for the next pass. required: - - inspected - - published - - noChanges - - settled - - cleanupPending - - skippedLiveAgent - - failedClosed - - awaitingOperator - - lockedByOtherWriter - - errors + - unreadCount additionalProperties: false - description: Counts of one recovery pass. Keyed passes inspect at most the addressed pull request. - FollowupHandoffReleaseRequest: + PluginAcknowledgementRequest: type: object properties: - force: - description: Also release a branch whose retained worktree still holds unpublished work. Never deletes worktree files, and never overrides a live or unverifiable agent. - type: boolean + 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 - FollowupHandoffReleaseResult: + PluginActivityResponse: type: object properties: - released: - type: boolean - reason: - description: Refusal code when `released` is false, for example `agent_still_running`, `liveness_unavailable`, `lease_held_by_live_writer`, `worktree_holds_unpublished_work`, `branch_still_registered` (a Git worktree registration still holds the branch, even after a forced detach), `worktree_registrations_unavailable` or `cancelled_claims_withdrawal_failed` (the claimed comments of a cancelled request could not be withdrawn from the pending list, so releasing could let them run). - type: string + health: + $ref: "#/components/schemas/PluginHealth" + deliveries: + type: array + items: + $ref: "#/components/schemas/PluginDelivery" + description: The newest 100 delivery records, newest first. required: - - released - additionalProperties: false - description: "Outcome of a release request; refusals answer `409` with `released: false`." - FollowupHandoffResumeRequest: - type: object - properties: - authorizeCancelled: - description: Set `true` to explicitly authorize publishing work the user cancelled. Without it, resume re-arms the operator-held record and refuses cancelled work with code `requires_cancellation_authorization`. - type: boolean + - health + - deliveries additionalProperties: false - FollowupHandoffResumeResult: + PluginConnectionRequest: type: object properties: - summary: - $ref: "#/components/schemas/FollowupHandoffRecoverySummary" - status: + value: anyOf: - - $ref: "#/components/schemas/FollowupHandoffStatus" + - type: string + maxLength: 4096 - type: "null" - description: State after the keyed recovery pass; `null` once the handoff retired completely. + description: The secret to store (write-only), or null to clear it. required: - - summary - - status + - value additionalProperties: false - FollowupHandoffStatus: + PluginDelivery: type: object properties: + id: + type: integer + description: The activity record id, used by the retry route. + deliveryId: + type: string + description: The stable delivery id the handler receives (`evt--`); the same on every attempt. + kind: + type: string + enum: + - event + - gap + description: "`event`: one journal event to one handler. `gap`: journal retention removed events before they were delivered." repository: type: string - description: Lowercase `owner/repo` of the pull request the handoff belongs to. - pullRequest: + eventSeq: type: integer - exclusiveMinimum: 0 - taskId: - type: string - description: Task whose interrupted follow-up publication the handoff records. - predecessorTaskIds: - type: array - items: - type: string - description: Source task lineage recorded when the agent launched. - successorTaskId: - description: Task that adopted the handoff during recovery, when any. + description: The journal `seq` of the event; for a gap, the first `seq` that may be missing. + eventType: + description: "`.`; null for a gap." + type: + - string + - "null" + handler: type: - string - "null" - phase: - type: string - enum: - - agent_running - - agent_exited - - committed - - pushed - - comment_published - - cleanup_pending - description: Exact publication step recovery resumes from. `cleanup_pending` means everything is published and only branch release is owed. status: type: string enum: - - active - - awaiting_operator - - released - description: "`active` records are recovered automatically; `awaiting_operator` records never publish without an explicit operator decision." - statusReason: - description: Why the handoff is held or failed closed, for example `cancelled_by_user`, `worktree_missing`, `head_advanced_externally`, `usage_limit_retry_unconfirmed`, `liveness_unavailable`. A mutable diagnostic; see `settlement` for the durable outcome. + - pending + - running + - retrying + - succeeded + - failed + - skipped + attempt: + type: integer + description: Attempts made so far. + nextAttemptAt: + description: When the next attempt is due, while pending or retrying. type: - - string + - number - "null" - settlement: - anyOf: - - type: string - enum: - - returned_to_queue - - usage_limit_retry_scheduling - - usage_limit_retry_scheduled - - type: "null" - description: "Durable owner of the claimed comments once the attempt settles without publishing: `returned_to_queue` (recovery returned them to the pending list), `usage_limit_retry_scheduled` (a scheduled usage-limit retry job owns them; recovery never requeues them) or `usage_limit_retry_scheduling` (the worker stopped around the queue add; held for an operator as `usage_limit_retry_unconfirmed`). Unlike `statusReason`, later diagnostics never overwrite it, so the `noChanges`/`settled` counters stay correct." - operatorAuthorized: - type: boolean - description: Whether an operator explicitly authorized publishing user-cancelled work. - liveness: - type: string - description: Agent container liveness evidence, for example `running`, `stopped`, `not_found` or `unavailable` (recovery fails closed on the latter). - branchName: - type: string - description: PR head branch still attached to the retained worktree until cleanup releases it. - commitHash: - description: Commit recovery is bound to publish, once recorded. + createdAt: + type: number + startedAt: type: - - string + - number - "null" - completionCommentUrl: - description: Delivered completion comment; recovery never overwrites it. + finishedAt: + type: + - number + - "null" + durationMs: + type: + - number + - "null" + timedOut: + type: boolean + error: + description: The latest attempt's redacted error message. type: - string - "null" - nextAction: - type: string - description: Operator guidance for the current state. - retainedWorktreePath: - type: string - description: Host path of the retained worktree. Deliberately admin-only; never shown on member routes. - updatedAt: - type: number - description: Unix milliseconds of the last durable update. + logs: + type: array + items: + type: object + properties: + level: + type: string + enum: + - debug + - info + - warn + - error + message: + type: string + fields: + type: object + propertyNames: + type: string + additionalProperties: {} + at: + type: number + required: + - level + - message + - at + additionalProperties: false + description: The latest attempt's redacted log lines. + required: + - id + - deliveryId + - kind + - repository + - eventSeq + - eventType + - handler + - status + - attempt + - nextAttemptAt + - createdAt + - startedAt + - finishedAt + - durationMs + - timedOut + - error + - logs + additionalProperties: false + description: "One event delivery to a plugin handler, or a recorded gap. Holds the event id and type only: never the event payload or a credential." + PluginDeliveryResponse: + type: object + properties: + delivery: + $ref: "#/components/schemas/PluginDelivery" + required: + - delivery + 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 + - labels.write + - 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 + - labels.write + - 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 + - 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 + 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 + - labels.write + - 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. + 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: + type: object + properties: + action: + type: string + enum: + - 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 + - handler_attempt + - handler_call + - handler_refused + - retry_delivery + - resume_deliveries + 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: - - repository - - pullRequest - - taskId - - predecessorTaskIds - - successorTaskId - - phase - - status - - statusReason - - settlement - - operatorAuthorized - - liveness - - branchName - - commitHash - - completionCommentUrl - - nextAction - - retainedWorktreePath - - updatedAt + - plugin additionalProperties: false - description: Operator-facing status of one durable follow-up publication handoff. - Health: + PluginEmptyRequest: + type: object + properties: {} + additionalProperties: false + PluginEnabledRequest: type: object properties: - status: - type: string - const: ok + enabled: + type: boolean required: - - status + - enabled additionalProperties: false - description: Liveness probe result. - InstanceDatabaseExport: + PluginHealth: type: object properties: - version: - type: number - const: 1 - instanceId: - type: string - generation: - 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)$ - operationId: - 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)$ state: type: string enum: - - running - - ready - - failed - - redeemed - expiresAt: - type: string - format: date-time - scope: - type: string - const: customer_database - sizeBytes: - type: number - minimum: 0 - sha256: - type: string - schemaVersion: + - healthy + - degraded + - paused + description: "`degraded` after repeated consecutive failures; `paused` after the configured number of them, until an administrator resumes deliveries." + consecutiveFailures: type: integer - migration: + pauseAfterFailures: + type: integer + lastError: type: - string - "null" + lastErrorAt: + type: + - number + - "null" + degradedAt: + type: + - number + - "null" + pausedAt: + type: + - number + - "null" required: - - version - - instanceId - - generation - - operationId - state - - expiresAt - - scope + - consecutiveFailures + - pauseAfterFailures + - lastError + - lastErrorAt + - degradedAt + - pausedAt additionalProperties: false - InstanceDatabaseExportRequest: + description: Delivery health of a plugin installation. + PluginHealthResponse: type: object properties: - instanceId: - type: string - generation: - 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)$ + health: + $ref: "#/components/schemas/PluginHealth" required: - - instanceId - - generation + - health additionalProperties: false - InstanceRepositoryInventory: + PluginList: type: object properties: - version: - type: number - const: 1 - instanceId: - type: string - state: - type: string - enum: - - complete - - partial - - unknown - - unsupported - observedAt: - type: string - format: date-time - lastUseAt: - anyOf: - - type: string - format: date-time - - type: "null" - repositories: + plugins: type: array items: - type: object - properties: - id: - type: string - name: - type: string - category: - type: string - enum: - - database_config - - repository_caches - - worktrees - - salvage - - logs_artifacts - - provider_credentials - - task_history - estimatedBytes: - anyOf: - - type: number - minimum: 0 - - type: "null" - protectedReasons: - type: array - items: - type: string - enum: - - partial_generated_cleanup - - busy - - unknown_use - - recent_use - - unknown_ownership - - unsafe_path - - private_work - - worktrees - - local_history - - salvage - - remote_unavailable - - unknown_git_state - - changed - - unsupported - required: - - id - - name - - category - - estimatedBytes - - protectedReasons - additionalProperties: false + $ref: "#/components/schemas/PluginView" required: - - version - - instanceId - - state - - observedAt - - lastUseAt - - repositories + - plugins additionalProperties: false - InstanceStorageCapabilities: + PluginOAuthClientRequest: type: object properties: - version: - type: number - const: 1 - instanceId: - type: string - generation: - 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)$ - features: - type: array - items: - type: string - enum: - - repository_inventory - - repository_cleanup - - database_export + 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: - - version - - instanceId - - generation - - features + - client additionalProperties: false - InstanceStorageExecuteRequest: + PluginOAuthCompleteRequest: type: object properties: - instanceId: + state: + type: string + description: The `state` the provider returned to the redirect URI. + code: type: string - confirmation: + error: + type: string + iss: 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)$ required: - - instanceId - - confirmation + - state additionalProperties: false - InstanceStoragePolicy: + PluginOAuthStartResponse: type: object properties: - version: - type: number - const: 1 - instanceId: + authorizationUrl: type: string - policy: - type: object - properties: - enabled: - type: boolean - idleHours: - type: integer - minimum: 1 - maximum: 8760 - required: - - enabled - - idleHours - additionalProperties: false + 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: - - version - - instanceId - - policy + - authorizationUrl + - server additionalProperties: false - InstanceStoragePolicyRequest: + PluginRepositoriesRequest: type: object properties: - enabled: - type: boolean - idleHours: - type: integer - minimum: 1 - maximum: 8760 - instanceId: - type: string + repositories: + anyOf: + - maxItems: 256 + type: array + items: + type: string + - type: "null" + description: owner/repo names; null or an empty list for all repositories. required: - - enabled - - idleHours - - instanceId + - repositories additionalProperties: false - InstanceStoragePreview: + PluginResponse: type: object properties: - version: - type: number - const: 1 - instanceId: - type: string - confirmation: - 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)$ - expiresAt: - type: string - format: date-time - categories: - type: array - items: - type: string - enum: - - database_config - - repository_caches - - worktrees - - salvage - - logs_artifacts - - provider_credentials - - task_history - impact: - type: string - candidates: - type: array - items: - type: object - properties: - id: - type: string - name: - type: string - category: - type: string - enum: - - database_config - - repository_caches - - worktrees - - salvage - - logs_artifacts - - provider_credentials - - task_history - estimatedBytes: - anyOf: - - type: number - minimum: 0 - - type: "null" - protectedReasons: - type: array - items: - type: string - enum: - - partial_generated_cleanup - - busy - - unknown_use - - recent_use - - unknown_ownership - - unsafe_path - - private_work - - worktrees - - local_history - - salvage - - remote_unavailable - - unknown_git_state - - changed - - unsupported - required: - - id - - category - - estimatedBytes - - protectedReasons - additionalProperties: false + plugin: + $ref: "#/components/schemas/PluginView" required: - - version - - instanceId - - confirmation - - expiresAt - - categories - - impact - - candidates + - plugin additionalProperties: false - InstanceStoragePreviewRequest: + PluginSettingsRequest: type: object properties: - instanceId: - type: string - categories: - minItems: 1 - maxItems: 7 - type: array - items: - type: string - enum: - - database_config - - repository_caches - - worktrees - - salvage - - logs_artifacts - - provider_credentials - - task_history - taskIds: - maxItems: 10 - type: array - items: + settings: + type: object + propertyNames: type: string - pattern: ^[A-Za-z0-9_-]{1,255}$ + 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: - - instanceId - - categories + - settings additionalProperties: false - InstanceStorageResult: + PluginUninstallRequest: type: object properties: - version: - type: number - const: 1 - instanceId: - type: string - operationId: - 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)$ - state: + confirm: type: string - enum: - - completed - - interrupted - deleted: - type: array - items: - type: string - retained: - type: array - items: - type: object - properties: - id: - type: string - reasons: - type: array - items: - type: string - enum: - - partial_generated_cleanup - - busy - - unknown_use - - recent_use - - unknown_ownership - - unsafe_path - - private_work - - worktrees - - local_history - - salvage - - remote_unavailable - - unknown_git_state - - changed - - unsupported - required: - - id - - reasons - additionalProperties: false + description: Must repeat the plugin id. required: - - version - - instanceId - - operationId - - state - - deleted - - retained + - confirm additionalProperties: false - InstanceStorageUsage: + PluginView: type: object properties: - version: - type: number - const: 1 - instanceId: + id: type: string - observedAt: + available: + type: boolean + description: The plugin is in this ProPR version's registry. + manifest: anyOf: - - type: string - format: date-time + - 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 + - labels.write + - 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: - - complete - - partial - - unknown - - unsupported - scanning: - type: boolean - ownedBytes: + - available + - installed + - enabled + installation: anyOf: - - type: number - minimum: 0 - - type: "null" - generatedPayloads: - description: Last bounded generated-payload proof, protected reasons and durable result. Observations may be stale or partial; unregistered worktrees remain protected. - type: object - properties: - observedAt: - anyOf: - - type: string - format: date-time - - type: "null" - reclaimableBytes: - anyOf: - - type: number - minimum: 0 - - type: "null" - coverage: - type: string - candidates: - type: array - items: - type: object - properties: - id: - type: string - category: + - type: object + properties: + installedVersion: + type: string + description: The manifest version installed or last approved. + enabled: + type: boolean + settings: + type: object + propertyNames: type: string - enum: - - database_config - - repository_caches - - worktrees - - salvage - - logs_artifacts - - provider_credentials - - task_history - estimatedBytes: + additionalProperties: anyOf: + - type: string - type: number - minimum: 0 - - type: "null" - protectedReasons: - type: array - items: - type: string - enum: - - partial_generated_cleanup - - busy - - unknown_use - - recent_use - - unknown_ownership - - unsafe_path - - private_work - - worktrees - - local_history - - salvage - - remote_unavailable - - unknown_git_state - - changed - - unsupported - required: - - id - - category - - estimatedBytes - - protectedReasons - additionalProperties: false - lastCleanup: - anyOf: - - type: object + - 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 + - labels.write + - repositories.read + - notifications.send + acknowledgements: + type: object propertyNames: type: string - additionalProperties: {} - - type: "null" - required: - - observedAt - - reclaimableBytes - - coverage - - lastCleanup - additionalProperties: false - categories: + 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: - category: + name: + type: string + type: type: string enum: - - database_config - - repository_caches - - worktrees - - salvage - - logs_artifacts - - provider_credentials - - task_history - bytes: - anyOf: - - type: number - minimum: 0 - - type: "null" + - oauth + - token + - signing_secret + label: + type: string state: type: string enum: - - complete - - partial - - unknown - - unsupported + - connected + - not_connected + - 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 required: - - category - - bytes + - name + - type + - label - state + - required additionalProperties: false - filesystems: + 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 + - labels.write + - repositories.read + - notifications.send + pendingAcknowledgements: type: array items: type: object properties: - id: + server: type: string - totalBytes: - type: number - minimum: 0 - availableBytes: - type: number - minimum: 0 + package: + type: string + version: + type: string + secretEnv: + type: array + items: + type: string required: - - id - - totalBytes - - availableBytes + - 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. + 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: - - version - - instanceId - - observedAt + - id + - available + - manifest - state - - scanning - - ownedBytes - - categories - - filesystems - additionalProperties: false - JsonObject: - type: object - properties: {} - additionalProperties: {} - description: A JSON object whose fields are not documented yet. - LegacyError: - type: object - properties: - error: - type: string - description: Human-readable error message. - code: - description: Machine-readable code, present on authentication and some validation errors. - type: string - message: - description: Longer explanation, present on some authentication errors. - type: string - required: - - error - additionalProperties: {} - description: "The ad-hoc error shape most existing routes return. Operations that use it carry `x-legacy-error: true`." - NotificationUnreadCount: - type: object - properties: - unreadCount: - type: integer - minimum: 0 - required: - - unreadCount + - installation + - connections + - needsApproval + - pendingPermissions + - pendingAcknowledgements + - enableBlockers additionalProperties: false + description: A plugin with its installation state on this instance. PullRequestReadiness: type: object properties: @@ -12424,9 +14660,10 @@ components: - pull_request - epic_queue - plan_issue + - task type: type: string - description: Event type within the domain, for example `head_changed`, `review_scored`, `merged`, `advanced` or `status_changed`. + description: Event type within the domain, for example `head_changed`, `review_scored`, `merged`, `advanced`, `status_changed` or `queued`. repository: type: string pullRequest: @@ -12437,7 +14674,7 @@ components: type: string entityId: type: string - description: PR number, epic execution id or plan issue id, as text. + description: PR number, epic execution id, plan issue id or task id, as text. head: description: Head SHA the event was recorded for, when it concerns one. type: string @@ -12446,7 +14683,7 @@ components: propertyNames: type: string additionalProperties: {} - description: Identifiers and small scalars only; never PR bodies, comments, diffs or logs. + description: Identifiers and small scalars only; never PR bodies, comments, diffs, prompts, tool output or logs. occurredAt: type: string format: date-time diff --git a/eslint.config.js b/eslint.config.js index 4b19e1ec88..ab31c2501b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,6 +2,33 @@ import js from '@eslint/js' import globals from 'globals' import tseslint from 'typescript-eslint' +/** + * Plugin code (`packages/plugins/**`: handlers, the registry and the test kit) + * may import nothing from ProPR but `@propr/shared`, which holds the plugin SDK + * types. Handlers touch ProPR only through `ctx`, so the same plugin can later + * run out of process. + */ +const PLUGIN_IMPORT_MESSAGE = 'Plugin code may import only @propr/shared (including the plugin SDK types); use ctx to reach ProPR.' +// From packages/plugins//, sibling packages are two levels up and the repository root three. +const OTHER_PACKAGE = '^(?:\\.\\./){2,}(?:core|api|cli|client|local-setup|shared)(?:/|$)' +const ROOT_SOURCES = '^(?:\\.\\./){3,}(?:src|propr-ui|packages|scripts)(?:/|$)' +const pluginImportRules = { + 'no-restricted-imports': ['error', { + patterns: [ + { group: ['@propr/*', '!@propr/shared', '!@propr/shared/*'], message: PLUGIN_IMPORT_MESSAGE }, + { group: ['propr-ui', 'propr-ui/*'], message: PLUGIN_IMPORT_MESSAGE }, + // Relative paths out of the plugins package into another ProPR package or the root sources. + { regex: OTHER_PACKAGE, message: PLUGIN_IMPORT_MESSAGE }, + { regex: ROOT_SOURCES, message: PLUGIN_IMPORT_MESSAGE }, + ], + }], + 'no-restricted-syntax': ['error', ...[ + 'ImportExpression[source.value=/^@propr\\/(?!shared(?:$|\\/))/]', + `ImportExpression[source.value=/${OTHER_PACKAGE.replaceAll('/', '\\/')}/]`, + `ImportExpression[source.value=/${ROOT_SOURCES.replaceAll('/', '\\/')}/]`, + ].map(selector => ({ selector, message: PLUGIN_IMPORT_MESSAGE }))], +} + export default [ { ignores: ['node_modules', '**/dist', 'coverage'] }, { @@ -26,4 +53,14 @@ export default [ ...config, files: ['src/**/*.ts'], })), + { + files: ['packages/plugins/**/*.ts', 'packages/plugins/**/*.js', 'packages/plugins/**/*.mjs'], + languageOptions: { + parser: tseslint.parser, + ecmaVersion: 2022, + sourceType: 'module', + globals: { ...globals.node }, + }, + rules: pluginImportRules, + }, ] 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/api/mcp/tools.ts b/packages/api/mcp/tools.ts index bbb9ad5a90..8a4c51b994 100644 --- a/packages/api/mcp/tools.ts +++ b/packages/api/mcp/tools.ts @@ -36,6 +36,7 @@ import { planEpicDispatch, type EpicDispatch } from './epicDispatchPolicy.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'; @@ -194,6 +195,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/toolsAutomations.ts b/packages/api/mcp/toolsAutomations.ts index 9c7cf444ae..115b8b3b2c 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'; @@ -254,8 +255,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, event triggers (events, reason kinds, the per-issue run bound), 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 (with triggerEvent, the event that started an event run), 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/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/mcp/toolsRepositoryEvents.ts b/packages/api/mcp/toolsRepositoryEvents.ts index 36a73c2dd3..829bb760df 100644 --- a/packages/api/mcp/toolsRepositoryEvents.ts +++ b/packages/api/mcp/toolsRepositoryEvents.ts @@ -55,7 +55,7 @@ const CURSOR_CONTRACT = 'Delivery is at-least-once and ordered: pass the cursor export function addRepositoryEventTools(tools: McpTool[], deps: ToolDeps): void { tools.push({ name: 'list_repository_events', - description: `List durable change events of one repository after a cursor: pull_request (opened, head_changed, review_scored, ultrafix_started, ultrafix_ended, auto_merge_decision, merged, closed), epic_queue (started, advanced, blocked, completed) and plan_issue (status_changed). Filter by domains, types, pullRequest and planId. Returns {events, cursor, truncated}; truncated means more matching events follow the cursor. ${CURSOR_CONTRACT}`, + description: `List durable change events of one repository after a cursor: pull_request (opened, head_changed, review_scored, ultrafix_started, ultrafix_ended, auto_merge_decision, merged, closed), epic_queue (started, advanced, blocked, completed), plan_issue (status_changed) and task (queued, started, completed, failed, cancelled; entityId is the task id). Filter by domains, types, pullRequest and planId. Returns {events, cursor, truncated}; truncated means more matching events follow the cursor. ${CURSOR_CONTRACT}`, scope: 'read', readOnly: true, schema: z.object(filterShape()).strict(), diff --git a/packages/api/openapi/registeredRoutes.ts b/packages/api/openapi/registeredRoutes.ts index aa0a109425..3d753cccef 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(), placeholderDeps()), ...createMemberCatalogRouteEntries(placeholderDeps()), ...createManagementRouteEntries(placeholderDeps()), ]; diff --git a/packages/api/openapi/routeDocs.ts b/packages/api/openapi/routeDocs.ts index 02f72f5f11..baddbf3b6c 100644 --- a/packages/api/openapi/routeDocs.ts +++ b/packages/api/openapi/routeDocs.ts @@ -4,6 +4,7 @@ import { PLAN_QUEUE_ROUTE_DOCS } from './routeDocsPlanQueue.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'; @@ -18,6 +19,7 @@ export const ROUTE_DOCS: Readonly> = { ...STATS_ROUTE_DOCS, ...SYSTEM_ROUTE_DOCS, ...EXTENSION_OAUTH_ROUTE_DOCS, + ...PLUGIN_ROUTE_DOCS, ...TASK_ROUTE_DOCS, ...PLAN_QUEUE_ROUTE_DOCS, }; diff --git a/packages/api/openapi/routeDocsPlugins.ts b/packages/api/openapi/routeDocsPlugins.ts new file mode 100644 index 0000000000..37fdccdc38 --- /dev/null +++ b/packages/api/openapi/routeDocsPlugins.ts @@ -0,0 +1,54 @@ +import type { z } from 'zod'; +import { + PluginAcknowledgementRequest, PluginConnectionRequest, PluginDetailResponse, PluginEnabledRequest, PluginList, PluginRepositoriesRequest, PluginResponse, + PluginSettingsRequest, PluginUninstallRequest, PluginEmptyRequest, PluginOAuthClientRequest, PluginOAuthCompleteRequest, PluginOAuthStartResponse, + PluginActivityResponse, PluginDeliveryResponse, PluginHealthResponse, +} 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 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, +}); + +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. 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, 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 }), + [`GET ${BASE}/:pluginId/activity`]: { + operationId: 'getPluginActivity', summary: 'Read the plugin\'s event delivery health (healthy, degraded or paused, with the last error) and its newest delivery records: time, event id and type, handler, outcome, duration, attempt, redacted error and log lines. Records are kept for 14 days.', description, tags: ['Configuration'], + pathParams: pluginId, responses: { 200: { description: 'Health and delivery records, newest first.', schema: PluginActivityResponse } }, + }, + [`POST ${BASE}/:pluginId/activity/:deliveryId/retry`]: mutation('retryPluginDelivery', 'Retry a failed event delivery: it gets one more attempt, with the same delivery id, the next time the dispatcher runs (after Resume while deliveries are paused). Refused (409) for a delivery that has not failed.', PluginEmptyRequest, { + pathParams: { ...pluginId, deliveryId: 'The activity record id (`id` of a delivery).' }, responses: { 200: { description: 'The delivery, pending again.', schema: PluginDeliveryResponse } }, + }), + [`POST ${BASE}/:pluginId/resume`]: mutation('resumePluginDeliveries', 'Resume event deliveries paused after repeated failures: the failure count starts over, Degraded clears and waiting retries are due at once. Refused (409) when deliveries are not paused.', PluginEmptyRequest, { + responses: { 200: { description: 'The new delivery health.', schema: PluginHealthResponse } }, + }), + [`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/routeDocsTasks.ts b/packages/api/openapi/routeDocsTasks.ts index e7e1320976..552c427c4c 100644 --- a/packages/api/openapi/routeDocsTasks.ts +++ b/packages/api/openapi/routeDocsTasks.ts @@ -276,7 +276,7 @@ export const TASK_ROUTE_DOCS: Record = { 'GET /api/repository-events': { operationId: 'listRepositoryEvents', summary: 'List repository change events after a cursor', - description: 'Reads the durable change-event journal of one repository (pull request, epic queue and plan issue changes) after `afterCursor`, in journal order, optionally filtered by domain, type, pull request and plan. Delivery is at-least-once: resume with the cursor of the last event you processed. A call without a cursor starts at the current end of the journal and returns the current cursor with no history, unless `fromStart=true`. The caller needs read access to the repository on GitHub (the check MCP `list_pull_requests` applies); events of other repositories are never returned.', + description: 'Reads the durable change-event journal of one repository (pull request, epic queue, plan issue and task changes) after `afterCursor`, in journal order, optionally filtered by domain, type, pull request and plan. Delivery is at-least-once: resume with the cursor of the last event you processed. A call without a cursor starts at the current end of the journal and returns the current cursor with no history, unless `fromStart=true`. The caller needs read access to the repository on GitHub (the check MCP `list_pull_requests` applies); events of other repositories are never returned.', tags: ['Repositories'], query: RepositoryEventsQuery, errors: 'envelope', 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 new file mode 100644 index 0000000000..0418d67d3b --- /dev/null +++ b/packages/api/openapi/schemasPlugins.ts @@ -0,0 +1,113 @@ +import { z } from 'zod'; +import { + PLUGIN_AUDIT_ACTIONS, PLUGIN_CATEGORIES, PLUGIN_CONNECTION_TYPES, PLUGIN_DELIVERY_KINDS, PLUGIN_DELIVERY_STATUSES, PLUGIN_HEALTH_STATES, 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', '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().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.'), + 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.'), + 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) })); +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 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.') })); + +const PluginLogLine = z.object({ level: z.enum(['debug', 'info', 'warn', 'error']), message: z.string(), fields: z.record(z.string(), z.unknown()).optional(), at: z.number() }); +export const PluginDelivery = component('PluginDelivery', z.object({ + id: z.number().int().describe('The activity record id, used by the retry route.'), + deliveryId: z.string().describe('The stable delivery id the handler receives (`evt--`); the same on every attempt.'), + kind: z.enum(PLUGIN_DELIVERY_KINDS).describe('`event`: one journal event to one handler. `gap`: journal retention removed events before they were delivered.'), + repository: z.string(), + eventSeq: z.number().int().describe('The journal `seq` of the event; for a gap, the first `seq` that may be missing.'), + eventType: z.string().nullable().describe('`.`; null for a gap.'), + handler: z.string().nullable(), + status: z.enum(PLUGIN_DELIVERY_STATUSES), + attempt: z.number().int().describe('Attempts made so far.'), + nextAttemptAt: z.number().nullable().describe('When the next attempt is due, while pending or retrying.'), + createdAt: z.number(), startedAt: z.number().nullable(), finishedAt: z.number().nullable(), durationMs: z.number().nullable(), + timedOut: z.boolean(), + error: z.string().nullable().describe('The latest attempt\'s redacted error message.'), + logs: z.array(PluginLogLine).describe('The latest attempt\'s redacted log lines.'), +}), 'One event delivery to a plugin handler, or a recorded gap. Holds the event id and type only: never the event payload or a credential.'); +export const PluginHealth = component('PluginHealth', z.object({ + state: z.enum(PLUGIN_HEALTH_STATES).describe('`degraded` after repeated consecutive failures; `paused` after the configured number of them, until an administrator resumes deliveries.'), + consecutiveFailures: z.number().int(), pauseAfterFailures: z.number().int(), + lastError: z.string().nullable(), lastErrorAt: z.number().nullable(), degradedAt: z.number().nullable(), pausedAt: z.number().nullable(), +}), 'Delivery health of a plugin installation.'); +export const PluginActivityResponse = component('PluginActivityResponse', z.object({ health: PluginHealth, deliveries: z.array(PluginDelivery).describe('The newest 100 delivery records, newest first.') })); +export const PluginDeliveryResponse = component('PluginDeliveryResponse', z.object({ delivery: PluginDelivery })); +export const PluginHealthResponse = component('PluginHealthResponse', z.object({ health: PluginHealth })); diff --git a/packages/api/openapi/schemasRepositoryEvents.ts b/packages/api/openapi/schemasRepositoryEvents.ts index dadd0c58d4..529ea6ae7e 100644 --- a/packages/api/openapi/schemasRepositoryEvents.ts +++ b/packages/api/openapi/schemasRepositoryEvents.ts @@ -33,14 +33,14 @@ export const RepositoryEventsWaitQuery = querySet('RepositoryEventsWaitQuery', z export const RepositoryEvent = component('RepositoryEvent', z.object({ cursor: z.string().describe('Resumes immediately after this event.'), domain: z.enum(REPOSITORY_EVENT_DOMAINS), - type: z.string().describe('Event type within the domain, for example `head_changed`, `review_scored`, `merged`, `advanced` or `status_changed`.'), + type: z.string().describe('Event type within the domain, for example `head_changed`, `review_scored`, `merged`, `advanced`, `status_changed` or `queued`.'), repository: z.string(), pullRequest: z.number().int().optional(), issueNumber: z.number().int().optional(), planId: z.string().optional(), - entityId: z.string().describe('PR number, epic execution id or plan issue id, as text.'), + entityId: z.string().describe('PR number, epic execution id, plan issue id or task id, as text.'), head: z.string().optional().describe('Head SHA the event was recorded for, when it concerns one.'), - payload: z.record(z.string(), z.unknown()).describe('Identifiers and small scalars only; never PR bodies, comments, diffs or logs.'), + payload: z.record(z.string(), z.unknown()).describe('Identifiers and small scalars only; never PR bodies, comments, diffs, prompts, tool output or logs.'), occurredAt: z.string().meta({ format: 'date-time' }), }), 'One durable repository change event.'); diff --git a/packages/api/pluginRouteRegistry.ts b/packages/api/pluginRouteRegistry.ts new file mode 100644 index 0000000000..04d0cdbb35 --- /dev/null +++ b/packages/api/pluginRouteRegistry.ts @@ -0,0 +1,28 @@ +import type { createPluginRoutes } from './routes/pluginRoutes.js'; +import type { createPluginActivityRoutes } from './routes/pluginActivityRoutes.js'; +import { requireManageSettings } from './permissionGuards.js'; +import type { RouteEntry } from './routeRegistry.js'; +import { PLUGIN_API_BASE } from '@propr/shared'; + +/** Plugin installation and delivery activity routes; kept out of `routeRegistry.ts`, which is at its size limit. */ +export function createPluginRouteEntries(plugins: ReturnType, activity: 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], + ['put', `${base}/:pluginId/connections/:connectionName/oauth/client`, requireManageSettings, plugins.setOAuthClient], + ['post', `${base}/:pluginId/connections/:connectionName/oauth/start`, requireManageSettings, plugins.startOAuth], + ['post', `${base}/:pluginId/connections/:connectionName/oauth/complete`, requireManageSettings, plugins.completeOAuth], + ['post', `${base}/:pluginId/connections/:connectionName/oauth/disconnect`, requireManageSettings, plugins.disconnectOAuth], + ['get', `${base}/:pluginId/activity`, requireManageSettings, activity.list], + ['post', `${base}/:pluginId/activity/:deliveryId/retry`, requireManageSettings, activity.retry], + ['post', `${base}/:pluginId/resume`, requireManageSettings, activity.resume], + ]; +} diff --git a/packages/api/routes/automationDefinitionRoutes.ts b/packages/api/routes/automationDefinitionRoutes.ts index 868588ed6a..5fb9340874 100644 --- a/packages/api/routes/automationDefinitionRoutes.ts +++ b/packages/api/routes/automationDefinitionRoutes.ts @@ -1,3 +1,4 @@ +import { automationTemplateCatalog } from '../../core/src/plugins/pluginTemplates.js'; import { ExtensionLibraryStore } from '../../core/src/extensions/extensionProfileStore.js'; /* eslint-disable max-lines -- automation definitions, their input files and their runs share one owner-scoped HTTP boundary */ import path from 'node:path'; @@ -40,7 +41,6 @@ import { import { AUTOMATION_ACTION_OPERATOR_NOTE_MAX_CHARS, AUTOMATION_DEFINITION_CONTRACT, - AUTOMATION_TEMPLATE_CATALOG, automationRunNoChangeReason, hasAutomationExtensions, type AutomationExtensions, @@ -454,10 +454,10 @@ export function createAutomationDefinitionRoutes(deps: AutomationDefinitionRoute res.json(AUTOMATION_DEFINITION_CONTRACT); }); - /** The shipped template catalog; a template only prefills the new-automation form and creates nothing. */ + /** The template catalog: shipped templates and those of enabled plugins (marked with `plugin`). A template only prefills the new-automation form and creates nothing. */ const templates = handler('Failed to load the automation template catalog', async (req, res) => { 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/pluginActivityRoutes.ts b/packages/api/routes/pluginActivityRoutes.ts new file mode 100644 index 0000000000..96c3e437d8 --- /dev/null +++ b/packages/api/routes/pluginActivityRoutes.ts @@ -0,0 +1,31 @@ +import type { Request } from 'express'; +import { db } from '@propr/core'; +import { pluginIdSchema } from '@propr/shared'; +import { PluginError, PluginStore } from '../../core/src/plugins/pluginStore.js'; +import { listPluginActivity, resumePluginDeliveries, retryPluginDelivery } from '../../core/src/plugins/pluginDeliveries.js'; +import { pluginRouteHandler } from './pluginRoutes.js'; + +/** + * Plugin event delivery activity under `/api/instance/plugins/v1/:pluginId`: + * the installation's delivery health and newest delivery records, retrying a + * failed delivery and resuming paused deliveries. The same administrator and + * preflight requirements as the installation routes apply. Records carry the + * event id and type, never its payload or a credential. + */ +export function createPluginActivityRoutes(store = new PluginStore(db), database = db, environment: NodeJS.ProcessEnv = process.env) { + const handle = pluginRouteHandler(store); + const id = (req: Request) => { + const parsed = pluginIdSchema.safeParse(req.params.pluginId); + if (!parsed.success) throw new PluginError(404, 'Plugin not found'); + return parsed.data; + }; + const empty = (req: Request) => { + const value = req.body as unknown; + if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length) throw new PluginError(400, 'Invalid plugin request'); + }; + return { + list: handle(async req => listPluginActivity(database, id(req), { environment })), + retry: handle(async (req, actor) => { empty(req); return { delivery: await retryPluginDelivery(database, id(req), String(req.params.deliveryId), actor) }; }), + resume: handle(async (req, actor) => { empty(req); return { health: await resumePluginDeliveries(database, id(req), actor, { environment }) }; }), + }; +} diff --git a/packages/api/routes/pluginRoutes.ts b/packages/api/routes/pluginRoutes.ts new file mode 100644 index 0000000000..cc6a24495d --- /dev/null +++ b/packages/api/routes/pluginRoutes.ts @@ -0,0 +1,72 @@ +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'; +import { extensionOAuthUrls } from './extensionOAuthRoutes.js'; + +/** + * The plugin routes' request wrapper: refuses the demo viewer, a missing user, + * anyone but a durable administrator with `instance.manage_settings`, and a + * mutation without the preflight-protected JSON contract; answers `no-store` + * JSON, and a refusal as `{ error }` with the plugin error's status. + */ +export function pluginRouteHandler(store: PluginStore) { + return (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' }); + } + }; +} + +/** + * 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. `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), environment: NodeJS.ProcessEnv = process.env) { + const handle = pluginRouteHandler(store); + 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); + 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)) })), + /** `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) })), + /** 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/server.ts b/packages/api/server.ts index 6afa2887be..3bf4c422f6 100644 --- a/packages/api/server.ts +++ b/packages/api/server.ts @@ -1,6 +1,9 @@ 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 { createPluginActivityRoutes } from './routes/pluginActivityRoutes.js'; import { protectStorageQueue } from '../core/src/storage/lifecycle.js'; import { createDefaultStorageService, createInstanceStorageRoutes } from './routes/instanceStorageRoutes.js'; import { startDashboardReadService, type DashboardReadService } from './services/dashboardReadService.js'; @@ -513,6 +516,7 @@ export function setupRoutes(): void { const routes = [ ...operationalRoutes, ...createStorageRouteEntries(storageRoutes), + ...createPluginRouteEntries(createPluginRoutes(), createPluginActivityRoutes()), ...createMemberCatalogRouteEntries({ instanceCatalogRoutes }), ...createManagementRouteEntries({ adminRoutes, diff --git a/packages/api/test/mcpRepositoryEvents.test.ts b/packages/api/test/mcpRepositoryEvents.test.ts index 5f6552c051..8c806e97e3 100644 --- a/packages/api/test/mcpRepositoryEvents.test.ts +++ b/packages/api/test/mcpRepositoryEvents.test.ts @@ -200,6 +200,21 @@ test('list_repository_events, wait_repository_events and the REST routes share o assert.equal((await rest('waitEvents', { repository, timeoutSeconds: '0' })).body!.code, 'REPOSITORY_NOT_ACCESSIBLE'); denied = false; + assert.match(tool('list_repository_events').description, /task \(queued, started, completed, failed, cancelled/); + + // Task events read through the same tool and REST route, filtered by the task domain. + const beforeTasks = (await call('list_repository_events', { repository })).data.cursor; + await append({ domain: 'task', type: 'queued', entityId: 'pr-comments-7-1', prNumber: 7, payload: { taskId: 'pr-comments-7-1', kind: 'follow-up' } }); + await append({ domain: 'task', type: 'cancelled', entityId: 'pr-comments-7-1', prNumber: 7, payload: { taskId: 'pr-comments-7-1', kind: 'follow-up', status: 'cancelled' } }); + const tasks = await call('list_repository_events', { repository, afterCursor: beforeTasks, domains: ['task'] }); + assert.deepEqual(tasks.data.events.map((event: Json) => [event.domain, event.type, event.entityId, event.pullRequest]), + [['task', 'queued', 'pr-comments-7-1', 7], ['task', 'cancelled', 'pr-comments-7-1', 7]]); + assert.equal(tasks.data.events[1].payload.status, 'cancelled'); + const restTasks = await rest('listEvents', { repository, afterCursor: beforeTasks, domains: 'task' }); + assert.equal(restTasks.status, 200); + RepositoryEventList.parse(restTasks.body); + assert.deepEqual(restTasks.body!.events.map((event: Json) => event.type), ['queued', 'cancelled']); + // Expired cursors answer 410, distinct from an empty result. await db('repository_event_floors').insert({ repository, floor_seq: 3, pruned_at: new Date().toISOString() }); const expired = await rest('listEvents', { repository, afterCursor: cursorFor(repository, 0) }); diff --git a/packages/api/test/mcpTunnelMode.test.ts b/packages/api/test/mcpTunnelMode.test.ts index d30912e1b6..9249aeec7c 100644 --- a/packages/api/test/mcpTunnelMode.test.ts +++ b/packages/api/test/mcpTunnelMode.test.ts @@ -318,7 +318,10 @@ test('a 409 keeps forcing registration through transient failures until one succ assert.equal(manager.scheduledDelayMs(), 60_000, 'the success keeps the newer rejection\'s cooldown, not the daily refresh'); clock += 60_000; timers.pending().at(-1)!.callback(); - for (let tick = 0; tick < 100 && registrations.length < 5; tick += 1) await new Promise(resolve => setImmediate(resolve)); + // The timer-driven check awaits database and crypto work on the libuv threadpool, so a fixed + // number of event-loop turns is not enough under load; wait on wall-clock time instead. + const deadline = Date.now() + 10_000; + while (registrations.length < 5 && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 5)); assert.equal(registrations.length, 5, 'the newer rejection still forces a registration through the timer'); assert.equal(await manager.trigger(), 'registered'); assert.equal(manager.scheduledDelayMs(), CONNECT_REGISTRATION_REFRESH_MS); diff --git a/packages/api/test/pluginActivityRoutes.test.ts b/packages/api/test/pluginActivityRoutes.test.ts new file mode 100644 index 0000000000..12ec756d4c --- /dev/null +++ b/packages/api/test/pluginActivityRoutes.test.ts @@ -0,0 +1,138 @@ +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 { getOfficialPlugin, loadPluginHandlers } from '../../plugins/src/index.js'; +import { PluginStore } from '../../core/src/plugins/pluginStore.js'; +import { PluginDispatcher } from '../../core/src/plugins/pluginDispatcher.js'; +import { appendRepositoryEvent } from '../../core/src/services/repositoryEventJournal.js'; +import { up as identityMigration } from '../../core/src/db/migrations/20261013210000_extension_profiles.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 { up as pluginMigration } from '../../core/src/db/migrations/20261114000000_plugin_installations.js'; +import { up as pluginStateMigration } from '../../core/src/db/migrations/20261114180000_plugin_state.js'; +import { up as journalMigration } from '../../core/src/db/migrations/20261112160000_create_repository_events.js'; +import { up as taskDomainMigration } from '../../core/src/db/migrations/20261114120000_add_task_repository_event_domain.js'; +import { up as deliveriesMigration } from '../../core/src/db/migrations/20261115000000_plugin_event_deliveries.js'; + +/** The fixture plugin of the core dispatcher tests: its `flaky` handler fails while state key `fail` is above zero. */ +const REGISTRY = path.join(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'core', 'test', 'fixtures', 'plugins'); +const ID = 'fixture-events'; +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 activity API: deliveries and health are listed, a failed delivery is retried and paused deliveries resumed, administrators only', async t => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'plugin-activity-api-')); + process.env.DB_FILENAME = path.join(root, 'fixture.sqlite'); + const { createPluginRoutes } = await import('../routes/pluginRoutes.js'); + const { createPluginActivityRoutes } = await import('../routes/pluginActivityRoutes.js'); + const { createPluginRouteEntries } = await import('../pluginRouteRegistry.js'); + const { registerRouteEntries } = await import('../routeRegistry.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'); }); + for (const migrate of [identityMigration, grantMigration, libraryMigration, oauthMigration, pluginMigration, pluginStateMigration, journalMigration, taskDomainMigration, deliveriesMigration]) await migrate(database); + await database('instance_members').insert([{ github_user_id: '1', role: 'admin' }, { github_user_id: '2', role: 'member' }]); + const manifest = getOfficialPlugin(ID, { root: REGISTRY, includeDevelopment: true })!.manifest; + const catalog = async () => [manifest]; + const store = new PluginStore(database, { SYSTEM_TASK_SECRET: 'plugin-activity-custody' }, 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, {}), createPluginActivityRoutes(store, database, { PROPR_PLUGIN_PAUSE_AFTER_FAILURES: '3' })); + 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') }; + }; + for (const route of ['/:pluginId/activity', '/:pluginId/activity/:deliveryId/retry', '/:pluginId/resume']) { + const entry = entries.find(([, p]) => p === BASE + route)!; + assert.ok(ROUTE_DOCS[`${entry[0].toUpperCase()} ${entry[1]}`], `${route} has an OpenAPI entry`); + } + + assert.equal((await call('GET', `/${ID}/activity`)).status, 404, 'not installed'); + await store.install('1', ID); await store.setEnabled('1', ID, true); + const empty = await call('GET', `/${ID}/activity`); + assert.equal(empty.status, 200); assert.equal(empty.cache, 'no-store'); + assertConforms(schemas.PluginActivityResponse, empty.body, 'empty activity'); + assert.deepEqual(empty.body.deliveries, []); assert.equal(empty.body.health.state, 'healthy'); assert.equal(empty.body.health.pauseAfterFailures, 3); + + // Three failing deliveries: Degraded at the third failure, which also pauses (threshold 3 here). + await database('plugin_state').insert({ plugin_id: ID, state_key: 'fail', value: '3', bytes: 1, updated_at: 0 }); + for (const n of [1, 2, 3, 4]) await appendRepositoryEvent(database, { repository: 'owner/repo', domain: 'task', type: 'failed', entityId: `task-${n}`, payload: { body: 'payload-sentinel-77' } }, { publish: false }); + const dispatcher = new PluginDispatcher({ + database, catalog, retryDelaysMs: [], pauseAfterFailures: 3, timeoutMs: 1_000, notify: async () => {}, + handlers: id => loadPluginHandlers(id, { root: REGISTRY, includeDevelopment: true }), + }); + for (let i = 0; i < 5; i++) await dispatcher.pass(); + await dispatcher.stop(); + + const activity = await call('GET', `/${ID}/activity`); + assertConforms(schemas.PluginActivityResponse, activity.body, 'activity'); + assert.equal(activity.body.health.state, 'paused'); + assert.match(activity.body.health.lastError, /flaky failure/); + const deliveries = activity.body.deliveries as { id: number; status: string; handler: string; eventType: string; attempt: number; error: string; logs: { message: string }[] }[]; + assert.deepEqual(deliveries.map(d => [d.status, d.handler, d.eventType, d.attempt]), [['failed', 'flaky', 'task.failed', 1], ['failed', 'flaky', 'task.failed', 1], ['failed', 'flaky', 'task.failed', 1]], 'newest first; the fourth waits for Resume'); + assert.equal(deliveries[0].logs[0].message, 'failing on purpose'); + + // Members, a missing preflight header, a non-empty body, an unknown record and a delivery that has not failed are refused. + assert.equal((await call('GET', `/${ID}/activity`, undefined, { 'x-test-actor': '2', 'x-test-role': 'member' })).status, 403); + assert.equal((await call('POST', `/${ID}/activity/${deliveries[0].id}/retry`, {}, { 'x-test-actor': '1', 'content-type': 'application/json' })).status, 403); + assert.equal((await call('POST', `/${ID}/activity/${deliveries[0].id}/retry`, { now: 1 })).status, 400); + assert.equal((await call('POST', `/${ID}/activity/999/retry`, {})).status, 404); + assert.equal((await call('POST', `/${ID}/activity/abc/retry`, {})).status, 400); + assert.equal((await call('GET', '/Not_A_Plugin/activity')).status, 404); + + const retried = await call('POST', `/${ID}/activity/${deliveries[0].id}/retry`, {}); + assert.equal(retried.status, 200); + assertConforms(schemas.PluginDeliveryResponse, retried.body, 'retry'); + assert.equal(retried.body.delivery.status, 'pending'); + assert.equal((await call('POST', `/${ID}/activity/${deliveries[0].id}/retry`, {})).status, 409); + + const resumed = await call('POST', `/${ID}/resume`, {}); + assert.equal(resumed.status, 200); + assertConforms(schemas.PluginHealthResponse, resumed.body, 'resume'); + assert.equal(resumed.body.health.state, 'healthy'); + assert.equal((await call('POST', `/${ID}/resume`, {})).status, 409, 'not paused any more'); + + // After Resume the retried delivery and the waiting event are delivered (the fixture's failures are used up). + const after = new PluginDispatcher({ database, catalog, notify: async () => {}, handlers: id => loadPluginHandlers(id, { root: REGISTRY, includeDevelopment: true }) }); + for (let i = 0; i < 3; i++) await after.pass(); + await after.stop(); + const final = await call('GET', `/${ID}/activity`); + assert.deepEqual((final.body.deliveries as { status: string; attempt: number }[]).map(d => [d.status, d.attempt]), [['succeeded', 1], ['succeeded', 2], ['failed', 1], ['failed', 1]]); + + // Retry and resume are audited; no response or record carries the event payload. + const audit = (await store.get('1', ID)).audit.map(a => a.action); + assert.ok(audit.includes('retry_delivery') && audit.includes('resume_deliveries')); + assert.equal(bodies.some(text => text.includes('payload-sentinel-77')), false); + assert.equal(JSON.stringify(await database('plugin_deliveries').select()).includes('payload-sentinel-77'), false); +}); diff --git a/packages/api/test/pluginRoutes.test.ts b/packages/api/test/pluginRoutes.test.ts new file mode 100644 index 0000000000..9beea2a1ea --- /dev/null +++ b/packages/api/test/pluginRoutes.test.ts @@ -0,0 +1,222 @@ +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'; +import { up as pluginStateMigration } from '../../core/src/db/migrations/20261114180000_plugin_state.js'; +import { up as journalMigration } from '../../core/src/db/migrations/20261112160000_create_repository_events.js'; +import { up as deliveriesMigration } from '../../core/src/db/migrations/20261115000000_plugin_event_deliveries.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'; +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 grantMigration(database); await libraryMigration(database); await oauthMigration(database); await pluginMigration(database); await pluginStateMigration(database); await journalMigration(database); await deliveriesMigration(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' }]); + // 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 })); + 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' }; + (req as { sessionID?: string }).sessionID = req.get('x-test-session') ?? 'session-a'; next(); + }); + const { createPluginActivityRoutes } = await import('../routes/pluginActivityRoutes.js'); + const entries = createPluginRouteEntries(createPluginRoutes(store, { API_PUBLIC_URL: 'https://propr.example.com' }), createPluginActivityRoutes(store, database)); + 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); + 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'); + + // 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 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', '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/api/test/repositoryEventTaskDomain.test.ts b/packages/api/test/repositoryEventTaskDomain.test.ts new file mode 100644 index 0000000000..76a6a2fd09 --- /dev/null +++ b/packages/api/test/repositoryEventTaskDomain.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { after, afterEach, beforeEach, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import knex, { type Knex } from 'knex'; +import { appendRepositoryEvent, closeConnection, pruneRepositoryEvents } from '@propr/core'; +import { + activeRepositoryEventWaiterCount, + listRepositoryEventsAfterCursor, + waitForRepositoryEvents, +} from '../services/repositoryEventWait.js'; + +/** The `task` domain reads, waits and expires through the same journal read path as every other domain. */ +const migrations = fileURLToPath(new URL('../../core/src/db/migrations/', import.meta.url)); +const repository = 'acme/app'; +let db: Knex; + +after(closeConnection); +beforeEach(async () => { + db = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, pool: { min: 1, max: 1 } }); + await db.migrate.latest({ directory: migrations }); +}); +afterEach(async () => { + await db.destroy(); + assert.equal(activeRepositoryEventWaiterCount(), 0); +}); + +const DAY_MS = 24 * 60 * 60 * 1000; +async function appendTask(type: string, payload: Record = {}, occurredAt?: string): Promise { + const result = await appendRepositoryEvent(db, { + repository, domain: 'task', type, entityId: 'issue-42', issueNumber: 42, + payload: { taskId: 'issue-42', kind: 'implementation', ...payload }, dedupeKey: `task:issue-42:${type}`, occurredAt, + }, { publish: false }); + assert.equal(result.status, 'appended'); +} +const list = (options: Partial[0]> = {}) => + listRepositoryEventsAfterCursor({ db, repository, ...options }); + +test('task events are listed, filtered by domain and type, and waited on', async () => { + const start = (await list()).cursor; + await appendRepositoryEvent(db, { repository, domain: 'pull_request', type: 'opened', entityId: 7, prNumber: 7 }, { publish: false }); + await appendTask('queued'); + await appendTask('started'); + await appendTask('failed', { status: 'failed', failureCategory: 'git_operation' }); + + const page = await list({ afterCursor: start, domains: ['task'] }); + assert.deepEqual(page.events.map(event => [event.domain, event.type, event.entityId, event.issueNumber]), [ + ['task', 'queued', 'issue-42', 42], ['task', 'started', 'issue-42', 42], ['task', 'failed', 'issue-42', 42], + ]); + assert.equal(page.events[2].payload.failureCategory, 'git_operation'); + assert.deepEqual((await list({ afterCursor: start, types: ['failed'] })).events.map(event => event.type), ['failed']); + + const waited = await waitForRepositoryEvents({ + db, repository, ownerId: '1', afterCursor: start, domains: ['task'], types: ['started'], timeoutSeconds: 1, pollIntervalMs: 60_000, + }); + assert.equal(waited.outcome, 'events'); + assert.deepEqual(waited.events.map(event => event.type), ['started']); +}); + +test('retention prunes task events by age like any other event', async () => { + await appendTask('queued', {}, new Date(Date.now() - 40 * DAY_MS).toISOString()); + await appendTask('started'); + await appendTask('cancelled', { status: 'cancelled', terminalReason: 'cancelled_by_user' }); + const result = await pruneRepositoryEvents(db); + assert.equal(result.deleted, 1); + assert.deepEqual((await list({ fromStart: true, domains: ['task'] })).events.map(event => event.type), ['started', 'cancelled']); +}); diff --git a/packages/client/src/generated/apiTypes.ts b/packages/client/src/generated/apiTypes.ts index 72c2871769..87be9089a5 100644 --- a/packages/client/src/generated/apiTypes.ts +++ b/packages/client/src/generated/apiTypes.ts @@ -433,7 +433,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<{ @@ -449,6 +449,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; @@ -460,6 +466,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; @@ -480,14 +492,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 { @@ -889,6 +913,439 @@ 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 PluginActivityResponse { + health: PluginHealth; + /** The newest 100 delivery records, newest first. */ + deliveries: PluginDelivery[]; +} + +export interface PluginConnectionRequest { + /** The secret to store (write-only), or null to clear it. */ + value: string | null; +} + +/** One event delivery to a plugin handler, or a recorded gap. Holds the event id and type only: never the event payload or a credential. */ +export interface PluginDelivery { + /** The activity record id, used by the retry route. */ + id: number; + /** The stable delivery id the handler receives (`evt--`); the same on every attempt. */ + deliveryId: string; + /** `event`: one journal event to one handler. `gap`: journal retention removed events before they were delivered. */ + kind: "event" | "gap"; + repository: string; + /** The journal `seq` of the event; for a gap, the first `seq` that may be missing. */ + eventSeq: number; + /** `.`; null for a gap. */ + eventType: string | null; + handler: string | null; + status: "pending" | "running" | "retrying" | "succeeded" | "failed" | "skipped"; + /** Attempts made so far. */ + attempt: number; + /** When the next attempt is due, while pending or retrying. */ + nextAttemptAt: number | null; + createdAt: number; + startedAt: number | null; + finishedAt: number | null; + durationMs: number | null; + timedOut: boolean; + /** The latest attempt's redacted error message. */ + error: string | null; + /** The latest attempt's redacted log lines. */ + logs: Array<{ + level: "debug" | "info" | "warn" | "error"; + message: string; + fields?: { + [key: string]: unknown; + }; + at: number; + }>; +} + +export interface PluginDeliveryResponse { + delivery: PluginDelivery; +} + +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" | "labels.write" | "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" | "labels.write" | "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; + /** 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; + }>; + /** 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" | "labels.write" | "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 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" | "start_oauth" | "complete_oauth" | "disconnect_oauth" | "set_oauth_client" | "clear_oauth_client" | "handler_attempt" | "handler_call" | "handler_refused" | "retry_delivery" | "resume_deliveries"; + actor: string; + at: number; + detail: { + [key: string]: unknown; + }; + }>; + }; +} + +export type PluginEmptyRequest = Record; + +export interface PluginEnabledRequest { + enabled: boolean; +} + +/** Delivery health of a plugin installation. */ +export interface PluginHealth { + /** `degraded` after repeated consecutive failures; `paused` after the configured number of them, until an administrator resumes deliveries. */ + state: "healthy" | "degraded" | "paused"; + consecutiveFailures: number; + pauseAfterFailures: number; + lastError: string | null; + lastErrorAt: number | null; + degradedAt: number | null; + pausedAt: number | null; +} + +export interface PluginHealthResponse { + health: PluginHealth; +} + +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; +} + +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" | "labels.write" | "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" | "labels.write" | "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; + /** 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; + }>; + /** 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" | "labels.write" | "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 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. */ export interface PullRequestReadiness { repository: string; @@ -946,18 +1403,18 @@ export interface PullRequestReadiness { export interface RepositoryEvent { /** Resumes immediately after this event. */ cursor: string; - domain: "pull_request" | "epic_queue" | "plan_issue"; - /** Event type within the domain, for example `head_changed`, `review_scored`, `merged`, `advanced` or `status_changed`. */ + domain: "pull_request" | "epic_queue" | "plan_issue" | "task"; + /** Event type within the domain, for example `head_changed`, `review_scored`, `merged`, `advanced`, `status_changed` or `queued`. */ type: string; repository: string; pullRequest?: number; issueNumber?: number; planId?: string; - /** PR number, epic execution id or plan issue id, as text. */ + /** PR number, epic execution id, plan issue id or task id, as text. */ entityId: string; /** Head SHA the event was recorded for, when it concerns one. */ head?: string; - /** Identifiers and small scalars only; never PR bodies, comments, diffs or logs. */ + /** Identifiers and small scalars only; never PR bodies, comments, diffs, prompts, tool output or logs. */ payload: { [key: string]: unknown; }; @@ -1344,9 +1801,9 @@ export interface RepositoryEventsQuery { afterCursor?: string; /** Without `afterCursor`: `true` starts at the oldest retained event. By default a call without a cursor starts at the current end of the journal and returns no history. */ fromStart?: "true" | "false"; - /** Comma-separated domains: `pull_request`, `epic_queue`, `plan_issue`. */ + /** Comma-separated domains: `pull_request`, `epic_queue`, `plan_issue`, `task`. */ domains?: string; - /** Comma-separated event types: `opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`, `started`, `advanced`, `blocked`, `completed`, `status_changed`. */ + /** Comma-separated event types: `opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`, `started`, `advanced`, `blocked`, `completed`, `status_changed`, `queued`, `failed`, `cancelled`. */ types?: string; /** Only events of this pull request number. */ pullRequest?: number; @@ -1387,9 +1844,9 @@ export interface RepositoryEventsWaitQuery { afterCursor?: string; /** Without `afterCursor`: `true` starts at the oldest retained event. By default a call without a cursor starts at the current end of the journal and returns no history. */ fromStart?: "true" | "false"; - /** Comma-separated domains: `pull_request`, `epic_queue`, `plan_issue`. */ + /** Comma-separated domains: `pull_request`, `epic_queue`, `plan_issue`, `task`. */ domains?: string; - /** Comma-separated event types: `opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`, `started`, `advanced`, `blocked`, `completed`, `status_changed`. */ + /** Comma-separated event types: `opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`, `started`, `advanced`, `blocked`, `completed`, `status_changed`, `queued`, `failed`, `cancelled`. */ types?: string; /** Only events of this pull request number. */ pullRequest?: number; 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/db/migrations/20261114120000_add_task_repository_event_domain.js b/packages/core/src/db/migrations/20261114120000_add_task_repository_event_domain.js new file mode 100644 index 0000000000..673162a506 --- /dev/null +++ b/packages/core/src/db/migrations/20261114120000_add_task_repository_event_domain.js @@ -0,0 +1,59 @@ +/** + * Allow the `task` domain in the repository event journal (#3547). + * + * `repository_events.domain` carries a CHECK constraint, which SQLite cannot + * alter in place, so the table is rebuilt with the wider constraint: rows are + * copied with their `seq`, and the AUTOINCREMENT high-water mark is carried + * over so a position retention already deleted is never handed out again. + * The indexes of the original table and of the NOCASE index migration are + * recreated. `repository_event_floors` and the journal identity are untouched, + * so existing cursors stay valid. + */ +const DOMAINS_BEFORE = ['pull_request', 'epic_queue', 'plan_issue']; +const DOMAINS_AFTER = [...DOMAINS_BEFORE, 'task']; + +async function rebuild(knex, domains) { + const quoted = domains.map(domain => `'${domain}'`).join(', '); + const [sequence] = await knex.raw("SELECT seq FROM sqlite_sequence WHERE name = 'repository_events'"); + await knex.raw('DROP TABLE IF EXISTS repository_events_rebuild'); + await knex.raw(`CREATE TABLE repository_events_rebuild ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + repository TEXT NOT NULL, + domain TEXT NOT NULL CHECK (domain IN (${quoted})), + type TEXT NOT NULL, + entity_id TEXT NOT NULL, + pr_number INTEGER NULL, + issue_number INTEGER NULL, + plan_id TEXT NULL, + head_sha TEXT NULL, + payload TEXT NOT NULL DEFAULT '{}', + dedupe_key TEXT NULL, + occurred_at TEXT NOT NULL + )`); + const columns = 'seq, repository, domain, type, entity_id, pr_number, issue_number, plan_id, head_sha, payload, dedupe_key, occurred_at'; + await knex.raw(`INSERT INTO repository_events_rebuild (${columns}) SELECT ${columns} FROM repository_events WHERE domain IN (${quoted})`); + await knex.raw('DROP TABLE repository_events'); + await knex.raw('ALTER TABLE repository_events_rebuild RENAME TO repository_events'); + await knex.raw('CREATE INDEX repository_events_repository_seq ON repository_events (repository, seq)'); + await knex.raw('CREATE INDEX repository_events_entity_seq ON repository_events (repository, domain, entity_id, seq)'); + await knex.raw('CREATE UNIQUE INDEX repository_events_dedupe ON repository_events (repository, dedupe_key)'); + await knex.raw('CREATE INDEX repository_events_occurred_at ON repository_events (occurred_at)'); + await knex.raw('CREATE INDEX repository_events_repository_nocase_seq ON repository_events (repository COLLATE NOCASE, seq)'); + const highWater = Number(sequence?.seq ?? 0); + if (highWater > 0) { + const [row] = await knex.raw("SELECT seq FROM sqlite_sequence WHERE name = 'repository_events'"); + if (row) await knex.raw("UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = 'repository_events'", [highWater]); + else await knex.raw("INSERT INTO sqlite_sequence (name, seq) VALUES ('repository_events', ?)", [highWater]); + } +} + +export async function up(knex) { + if (!await knex.schema.hasTable('repository_events')) return; + await rebuild(knex, DOMAINS_AFTER); +} + +/** Rolling back drops the `task` events: the narrower constraint cannot hold them. */ +export async function down(knex) { + if (!await knex.schema.hasTable('repository_events')) return; + await rebuild(knex, DOMAINS_BEFORE); +} diff --git a/packages/core/src/db/migrations/20261114180000_plugin_state.js b/packages/core/src/db/migrations/20261114180000_plugin_state.js new file mode 100644 index 0000000000..c8b6d69757 --- /dev/null +++ b/packages/core/src/db/migrations/20261114180000_plugin_state.js @@ -0,0 +1,20 @@ +// Plugin handler state: a small key-value store per installed plugin +// (`ctx.state`), for cursors and mappings such as "this pull request maps to +// that chat thread". Values are JSON; `bytes` is the UTF-8 size of the stored +// value, so the per-installation size limit is one indexed sum. Rows are +// deleted when the plugin is uninstalled. + +export async function up(knex) { + if (await knex.schema.hasTable('plugin_state')) return; + await knex.schema.createTable('plugin_state', t => { + t.string('plugin_id').notNullable(); + t.string('state_key', 200).notNullable(); + t.text('value').notNullable(); + t.integer('bytes').notNullable(); + t.bigInteger('updated_at').notNullable(); + t.primary(['plugin_id', 'state_key']); + }); +} + +// Handler state belongs to installations, which stay on rollback; older versions ignore it. +export async function down() {} diff --git a/packages/core/src/db/migrations/20261115000000_plugin_event_deliveries.js b/packages/core/src/db/migrations/20261115000000_plugin_event_deliveries.js new file mode 100644 index 0000000000..beba7c7fdc --- /dev/null +++ b/packages/core/src/db/migrations/20261115000000_plugin_event_deliveries.js @@ -0,0 +1,85 @@ +// Plugin event subscriptions: the dispatcher's durable state. +// +// `plugin_dispatch` has one row per installation: the journal position its +// subscriptions start from (`start_seq`, "now" when it was installed or last +// enabled), the delivery lease (only one worker delivers per installation; +// the lease is a row with an expiry, not a database lock), and its delivery +// health (consecutive failures, last error, Degraded and paused times). +// `degraded_notice` is the queue (a JSON list) of administrator notifications +// owed for becoming Degraded: each is appended with its transition and removed +// once it was created. +// +// `plugin_delivery_cursors` holds, per installation and repository, the last +// journal `seq` whose deliveries are final, and the journal floor already +// recorded as a gap. +// +// `plugin_deliveries` records each delivery (one journal event to one handler) +// and each gap: status, attempts, the latest attempt's duration, redacted +// error and log lines. The event payload is never stored, only its `seq` and +// type. `manual` marks an administrator's Retry until its one attempt is +// final. Rows are pruned after a retention period. + +export async function up(knex) { + if (!await knex.schema.hasTable('plugin_dispatch')) { + await knex.schema.createTable('plugin_dispatch', t => { + t.string('plugin_id').primary(); + // The installation this state belongs to: a reinstall starts over. + t.bigInteger('installed_at').notNullable(); + t.bigInteger('start_seq').notNullable(); + t.string('lease_owner'); + t.bigInteger('lease_expires_at'); + t.integer('consecutive_failures').notNullable().defaultTo(0); + t.text('last_error'); + t.bigInteger('last_error_at'); + t.bigInteger('degraded_at'); + t.bigInteger('paused_at'); + t.text('degraded_notice'); + t.bigInteger('updated_at').notNullable(); + }); + } + // A database that ran an earlier copy of this migration lacks the column. + if (!await knex.schema.hasColumn('plugin_dispatch', 'degraded_notice')) { + await knex.schema.alterTable('plugin_dispatch', t => { t.text('degraded_notice'); }); + } + if (!await knex.schema.hasTable('plugin_delivery_cursors')) { + await knex.schema.createTable('plugin_delivery_cursors', t => { + t.string('plugin_id').notNullable(); + t.string('repository').notNullable(); + t.bigInteger('cursor_seq').notNullable(); + // The journal floor last recorded as a gap, so a gap is recorded once. + t.bigInteger('floor_seq').notNullable().defaultTo(1); + t.primary(['plugin_id', 'repository']); + }); + } + if (!await knex.schema.hasTable('plugin_deliveries')) { + await knex.schema.createTable('plugin_deliveries', t => { + t.increments('id').primary(); + t.string('plugin_id').notNullable(); + t.string('delivery_id').notNullable(); + t.string('kind').notNullable(); + t.string('repository').notNullable(); + t.bigInteger('event_seq').notNullable(); + t.string('event_type'); + t.string('handler'); + t.string('status').notNullable(); + t.integer('attempt').notNullable().defaultTo(0); + // An administrator's Retry: one attempt, out of the stream's order; recovered after a crash until it is final. + t.boolean('manual').notNullable().defaultTo(false); + t.bigInteger('next_attempt_at'); + t.bigInteger('created_at').notNullable(); + t.bigInteger('started_at'); + t.bigInteger('finished_at'); + t.integer('duration_ms'); + t.boolean('timed_out').notNullable().defaultTo(false); + t.text('error'); + t.text('logs').notNullable().defaultTo('[]'); + t.unique(['plugin_id', 'delivery_id']); + t.index(['plugin_id', 'id']); + t.index(['plugin_id', 'repository', 'status']); + t.index(['created_at']); + }); + } +} + +// Delivery state is operational history; older versions ignore it. +export async function down() {} diff --git a/packages/core/src/extensions/endpointPolicy.ts b/packages/core/src/extensions/endpointPolicy.ts index 2581953a5c..aa5359dff9 100644 --- a/packages/core/src/extensions/endpointPolicy.ts +++ b/packages/core/src/extensions/endpointPolicy.ts @@ -22,13 +22,17 @@ export class ExtensionUpstreamUnauthorizedError extends Error { constructor() { export function extensionPublicAddress(address: string): boolean { return !isNonPublicAddress(address) && !additional.check(address, isIP(address) === 6 ? 'ipv6' : 'ipv4'); } +/** Names that resolve to this host, the local network or cloud metadata, whatever DNS says. */ +export function extensionReservedHostname(host: string): boolean { + return /^(localhost|.*\.localhost|.*\.local|metadata\.google\.internal)$/i.test(host); +} export function extensionEndpoint(value: string): URL { let url: URL; try { url = new URL(value); } catch { throw new Error('Invalid MCP endpoint'); } // Query parameters can be credentials; v1 accepts path-only endpoints. if (!value.startsWith('https://') || /[?#]/.test(value) || url.protocol !== 'https:' || url.username || url.password || url.hash || url.search || (url.port && url.port !== '443') || value.length > 2048) throw new Error('MCP endpoints require HTTPS without credentials, query parameters or fragments'); const host = url.hostname.replace(/^\[|\]$/g, ''); if (isIP(host) && !extensionPublicAddress(host)) throw new Error('MCP endpoint must be public'); - if (/^(localhost|.*\.localhost|.*\.local|metadata\.google\.internal)$/i.test(host)) throw new Error('MCP endpoint must be public'); + if (extensionReservedHostname(host)) throw new Error('MCP endpoint must be public'); return url; } export type ExtensionLookup = (host: string) => Promise>; 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..a680d52465 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; @@ -198,15 +201,18 @@ export class ExtensionOAuthStore { * unused, unexpired and started by this administrator in this session for * the same item and endpoint; `iss` must name the issuer when supplied (and * when the server promises it). Nothing is stored unless the code exchange succeeds. + * With `servers`, only a sign-in pending for one of those items is claimed; any + * other state is refused without consuming it. */ - async completeSignIn(actor: string, sessionId: string, query: { state?: unknown; code?: unknown; error?: unknown; iss?: unknown }): Promise<{ name: string }> { + async completeSignIn(actor: string, sessionId: string, query: { state?: unknown; code?: unknown; error?: unknown; iss?: unknown }, servers?: readonly string[]): Promise<{ name: string }> { await this.tables(); if (typeof query.state !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(query.state)) throw new ExtensionError(400, 'Unknown, used or expired sign-in request'); const stateHash = sha256(query.state); // Single use: the first callback claims the state, and it is deleted whatever the outcome. The claimed // row is this attempt: Disconnect and binding changes delete it, which cancels the attempt. const claim = randomUUID(); - if (await this.database(OAUTH_PENDING).where({ state_hash: stateHash }).whereNull('claim').update({ claim }) !== 1) throw new ExtensionError(400, 'Unknown, used or expired sign-in request'); + const pending = this.database(OAUTH_PENDING).where({ state_hash: stateHash }).whereNull('claim'); + if (await (servers ? pending.whereIn('server_name', [...servers]) : pending).update({ claim }) !== 1) throw new ExtensionError(400, 'Unknown, used or expired sign-in request'); try { return await this.completeClaimed(actor, sessionId, query, { state_hash: stateHash, claim }); } finally { await this.database(OAUTH_PENDING).where({ state_hash: stateHash, claim }).delete(); } } 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/index.ts b/packages/core/src/index.ts index d52e729edd..f54518801e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -677,6 +677,9 @@ export * from './services/reviewScoreStore.js'; export * from './services/pullRequestStatus/index.js'; // Durable repository change-event journal (pull requests, epic queues, plan issues). export * from './services/repositoryEventJournal.js'; +// Plugin event subscriptions: the journal dispatcher and its delivery records. +export { PluginDispatcher, type PluginDispatcherOptions, type PluginDispatchPass } from './plugins/pluginDispatcher.js'; +export { prunePluginDeliveries } from './plugins/pluginDeliveries.js'; // GitHub user profile cache: maps stored GitHub numeric user ids to the login // and avatar an assignee is rendered with. 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/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/pluginContext.ts b/packages/core/src/plugins/pluginContext.ts new file mode 100644 index 0000000000..3d8f097241 --- /dev/null +++ b/packages/core/src/plugins/pluginContext.ts @@ -0,0 +1,244 @@ +import type { Knex } from 'knex'; +import { + PLUGIN_LOG_LIMITS, PluginSdkError, defaultPluginSettings, validatePluginSettings, + type PluginContext, type PluginHttpFetch, type PluginInstallationRecord, type PluginJsonValue, type PluginLogLevel, type PluginLogLine, + type PluginAuditAction, type PluginLogger, type PluginManifest, type PluginPermission, +} from '@propr/shared'; +import logger from '../utils/logger.js'; +import type { ExtensionLookup } from '../extensions/endpointPolicy.js'; +import { redactExtensionOutput } from '../extensions/extensionOutput.js'; +import { createPluginHttp, scrubCredentialValues, type PluginHttpTransport } from './pluginHttp.js'; +import { githubPluginProprBackend } from './pluginProprBackend.js'; +import { pluginActor, scopedProprClient, type PluginProprBackend, type PluginProprCallOutcome, type PluginProprCallRecord, type PluginProprCallScope } from './pluginProprClient.js'; +import { pluginStateStore, requireInstallation, type PluginInstallationAuthority, type PluginStateWriteGuard } from './pluginStateStore.js'; + +/** An installed plugin as a handler runs it: the current manifest and the installation record. */ +export interface PluginInstallationTarget { pluginId: string; manifest: PluginManifest; record: PluginInstallationRecord } + +export interface PluginContextDeps { + database: Knex; + environment?: NodeJS.ProcessEnv; + /** The ProPR services behind `ctx.propr`; defaults to GitHub, the event journal and the task queue. */ + backend?: PluginProprBackend; + /** The stored secret of a connection; defaults to the plugin store's sealed connection secrets. */ + connectionSecret?: (pluginId: string, connection: string) => Promise; + lookup?: ExtensionLookup; + transport?: PluginHttpTransport; + now?: () => number; +} + +/** What building a context returns besides `ctx`: the captured log and redaction of anything returned to the caller. */ +export interface BuiltPluginContext { + ctx: PluginContext; + /** The log lines for the caller: each redacted again with every credential of the run, then cut to `PLUGIN_LOG_LIMITS.messageChars`. */ + finalLogs(): PluginLogLine[]; + /** Redact a value for the caller: connection credentials used in this run (in every spelling, percent-escaped too) and the general secret patterns. */ + redact(value: T): T; + /** Redact `text` (kept with `retainForRedaction`), then cut it to `limit` characters, withholding a credential the cut would leave partly visible. */ + clip(text: string, limit: number): string; +} + +/** + * Characters of a message kept past its length limit until the run's final + * redaction. A credential that straddles the limit (and may only be acquired + * after the line was written) is then still matched whole and redacted before + * the message is cut, rather than cut first and left one character short. It + * covers every spelling of a storable connection secret (at most 4,096 + * characters, `PLUGIN_SECRET_PATTERN`): the hex of its header value, each + * character percent-escaped, is about 24,600 characters. + */ +const REDACTION_MARGIN_CHARS = 32 * 1024; +/** `text` as kept for a later redaction and cut to `limit`: `limit` characters and the margin. */ +export const retainForRedaction = (text: string, limit: number): string => text.slice(0, limit + REDACTION_MARGIN_CHARS); + +/** The raw spellings of a credential whose start a cut message could end with (`[spelling, any letter case]`). */ +function cutSpellings(secret: string): [string, boolean][] { + return [secret, `Bearer ${secret}`].flatMap(value => [ + [value, false], [Buffer.from(value).toString('base64'), false], [Buffer.from(value).toString('base64url'), false], + [encodeURIComponent(value), false], [Buffer.from(value).toString('hex'), true], + ] as [string, boolean][]); +} +/** How many trailing characters of `text` begin a credential spelling: a credential longer than the margin, cut at the limit. */ +function cutCredentialTail(text: string, credentials: readonly string[]): number { + let longest = 0; + const folded = text.toLowerCase(); + for (const [spelling, anyCase] of credentials.filter(Boolean).flatMap(cutSpellings)) { + const haystack = anyCase ? folded : text; + for (let length = Math.min(spelling.length - 1, haystack.length); length > longest; length--) { + if (haystack.endsWith(spelling.slice(0, length))) { longest = length; break; } + } + } + return longest; +} + +/** The audit action of each recorded `ctx.propr` call outcome. */ +const CALL_AUDIT_ACTIONS = { attempted: 'handler_attempt', completed: 'handler_call', refused: 'handler_refused' } as const satisfies Record; + +/** Permissions the handler has: granted by the administrator and still asked for by the current manifest. */ +export function effectivePluginPermissions(manifest: PluginManifest, record: Pick): PluginPermission[] { + return manifest.permissions.filter(permission => record.grantedPermissions.includes(permission)); +} + +/** The installation as it is now, required to be the one the run started with (not a reinstall) and enabled. */ +async function currentInstallation(database: Knex, pluginId: string, installation: PluginStateWriteGuard): Promise { + const authority = await database.transaction(trx => requireInstallation(trx, pluginId, installation)); + if (!authority.enabled) throw new PluginSdkError('unavailable', 'The plugin is disabled'); + return authority; +} + +/** + * Authorization of a run's dispatches against its installation as it is at + * that moment, not as the run started: an administrator may have disabled the + * plugin, uninstalled it (or reinstalled it), changed its repository limit or + * its grants while the handler was suspended. `request` is for an + * authenticated `ctx.http` request: the installation must be the same and + * enabled. `call` is for a `ctx.propr` dispatch: it must also still grant the + * permission (through the run's manifest) and, when the call names a + * repository, still allow it. + */ +export function installationAuthorizer(database: Knex, pluginId: string, manifest: PluginManifest, installation: PluginStateWriteGuard) { + return { + request: async (): Promise => { await currentInstallation(database, pluginId, installation); }, + call: async ({ permission, repository }: PluginProprCallScope): Promise => { + const authority = await currentInstallation(database, pluginId, installation); + if (!effectivePluginPermissions(manifest, authority).includes(permission)) throw new PluginSdkError('permission_denied', `The ${permission} permission is no longer granted to this plugin`); + if (repository && authority.repositories && !authority.repositories.some(allowed => allowed.toLowerCase() === repository.toLowerCase())) { + throw new PluginSdkError('repository_not_allowed', `This plugin is no longer enabled for ${repository}`); + } + }, + }; +} + +/** The installation's settings with the manifest's defaults, validated against the current manifest. */ +export function handlerSettings(manifest: PluginManifest, record: PluginInstallationRecord) { + const result = validatePluginSettings(manifest.settings, { ...defaultPluginSettings(manifest.settings), ...record.settings }); + if (!result.ok) throw new PluginSdkError('invalid_input', `The plugin settings do not match its manifest: ${result.errors.join('; ')}`); + return result.settings; +} + +/** A JSON copy of log fields, or a marker when they are not JSON or too large. */ +function logFields(fields: Record | undefined): Record | undefined { + if (fields === undefined) return undefined; + try { + const text = JSON.stringify(fields); + if (text === undefined || !fields || typeof fields !== 'object') return { invalidFields: true }; + if (Buffer.byteLength(text) > PLUGIN_LOG_LIMITS.fieldsBytes) return { truncatedFields: true }; + return JSON.parse(text) as Record; + } catch { return { invalidFields: true }; } +} + +/** The connection secret of the installation `guard` names, read in one transaction with that installation; refused once it was removed, replaced or disabled. */ +async function defaultConnectionSecret({ database, environment }: { database: Knex; environment: NodeJS.ProcessEnv }, pluginId: string, connection: string, guard: PluginStateWriteGuard): Promise { + const { PluginStore } = await import('./pluginStore.js'); + const store = new PluginStore(database, environment); + return database.transaction(async trx => { + const authority = await requireInstallation(trx, pluginId, guard); + if (!authority.enabled) throw new PluginSdkError('unavailable', 'The plugin is disabled'); + return store.connectionSecret(pluginId, connection, trx); + }); +} + +/** + * Build `ctx` for one handler invocation. Nothing in it holds a secret: a + * connection's credential is read only when `ctx.http(connection)` sends a + * request, injected there and scrubbed from the response. Log lines and + * anything returned to the caller pass the extension output redaction. + */ +export function buildPluginContext(target: PluginInstallationTarget, deps: PluginContextDeps, handler: string, signal: AbortSignal): BuiltPluginContext { + const { pluginId, manifest, record } = target; + const now = deps.now ?? Date.now; + const environment = deps.environment ?? process.env; + const credentials: string[] = []; + // Messages are kept past their limit (`retainForRedaction`) and cut only after the final redaction: see `clip`. + const logs: PluginLogLine[] = []; + // The extension output redaction matches one URL-encoded spelling only; the response scrubbing also catches `%2f` and other escapes first. + const redact = (value: T): T => redactExtensionOutput(scrubCredentialValues(value, credentials), credentials) as T; + // Redacted first, so a credential straddling the limit is replaced whole; a credential longer than the kept margin has its visible start withheld. + const clip = (text: string, limit: number): string => { + const redacted = redact(text); + if (redacted.length <= limit) return redacted; + const cut = redacted.slice(0, limit); + return cut.slice(0, cut.length - cutCredentialTail(cut, credentials)); + }; + let dropped = false; + + const write = (level: PluginLogLevel, message: unknown, fields?: Record) => { + if (signal.aborted) return; + if (logs.length >= PLUGIN_LOG_LIMITS.lines) { + if (!dropped) { dropped = true; logs.push({ level: 'warn', message: `Log limit of ${PLUGIN_LOG_LIMITS.lines} lines reached; later lines were dropped`, at: now() }); } + return; + } + const text = retainForRedaction(String(message), PLUGIN_LOG_LIMITS.messageChars); + const json = logFields(fields); + logs.push({ level, message: redact(text), ...(json ? { fields: redact(json) } : {}), at: now() }); + }; + const log: PluginLogger = { debug: (m, f) => write('debug', m, f), info: (m, f) => write('info', m, f), warn: (m, f) => write('warn', m, f), error: (m, f) => write('error', m, f) }; + + const recordCall = async (call: PluginProprCallRecord) => { + const { outcome, ...detail } = call; + await deps.database('plugin_audit').insert({ + plugin_id: pluginId, action: CALL_AUDIT_ACTIONS[outcome], actor: pluginActor(pluginId), at: now(), + detail: JSON.stringify({ handler, ...detail }), + }); + if (outcome === 'refused') { + write('warn', `ctx.propr.${call.operation} refused: ${call.reason}`, { operation: call.operation, permission: call.permission, reason: call.reason ?? null, ...(call.repository ? { repository: call.repository } : {}) }); + logger.warn({ pluginId, handler, operation: call.operation, reason: call.reason }, '[plugins] Handler call refused'); + } + }; + + // The installation this run belongs to: a credential is read from it only, and a request is sent only while it is still installed and enabled. + const installation: PluginStateWriteGuard = { installedAt: record.installedAt, signal }; + /** Refuses once this run's installation was uninstalled (or replaced by a reinstall), disabled or restricted, or the run ended. */ + const authorize = installationAuthorizer(deps.database, pluginId, manifest, installation); + const http = (connection?: string): PluginHttpFetch => { + let credential: (() => Promise) | undefined; + if (connection !== undefined) { + const declared = manifest.connections.find(c => c.name === connection); + if (!declared) throw new PluginSdkError('connection_unavailable', `The plugin declares no connection ${String(connection).slice(0, 48)}`); + if (declared.type !== 'token') throw new PluginSdkError('connection_unavailable', `Connection ${connection} is ${declared.type}; ctx.http injects token connections only`); + credential = async () => { + const secret = await (deps.connectionSecret ?? ((id, name) => defaultConnectionSecret({ database: deps.database, environment }, id, name, installation)))(pluginId, connection); + if (!secret) throw new PluginSdkError('connection_unavailable', `Connection ${connection} is not set`); + credentials.push(secret); + return secret; + }; + } + return createPluginHttp({ + outboundHosts: manifest.outboundHosts, credential, lookup: deps.lookup, transport: deps.transport, signal, + authorize: credential ? authorize.request : undefined, + onDecision: ({ host, allowed, reason }) => write(allowed ? 'debug' : 'warn', allowed ? `ctx.http request to ${host}` : `ctx.http request to ${host} refused: ${reason}`, { host, ...(connection ? { connection } : {}) }), + }); + }; + + // Once the run has ended (returned, failed or timed out) nothing the handler left running can act through ctx. + const live = (run: (...args: A) => Promise) => async (...args: A): Promise => { + if (signal.aborted) throw new PluginSdkError('unavailable', 'This handler run has ended'); + return run(...args); + }; + const liveMethods = (api: T): T => Object.freeze(Object.fromEntries(Object.entries(api).map(([name, run]) => [name, live(run as (...args: unknown[]) => Promise)]))) as T; + + const ctx: PluginContext = { + pluginId, + settings: Object.freeze(structuredClone(handlerSettings(manifest, record))), + repositories: record.repositories ? [...record.repositories] : null, + // Its permissions and repository limit are this installation's: every call (and, by default, every GitHub request and queued task) requires it to + // still be installed and enabled, and to still grant the call's permission on its repository. + propr: liveMethods(scopedProprClient({ + pluginId, grantedPermissions: effectivePluginPermissions(manifest, record), repositories: record.repositories, + backend: deps.backend ?? githubPluginProprBackend({ database: deps.database, signal, authorize: authorize.call }), record: recordCall, + onRecordFailure: (call, error) => logger.error({ pluginId, handler, operation: call.operation, callId: call.callId, outcome: call.outcome, error: error instanceof Error ? error.message : String(error) }, '[plugins] Could not record the outcome of an attempted handler call'), + authorize: authorize.call, + })), + http: connection => live(http(connection)), + // State is tied to this installation: once it is uninstalled (or replaced by a reinstall) every operation is refused. + state: liveMethods(pluginStateStore(deps.database, pluginId, now, { installedAt: record.installedAt, signal })), + log: Object.freeze(log), + signal, + }; + // Lines logged before a credential was acquired were redacted without it: redact every line again with all the credentials of this run, then cut it. + const finalLogs = (): PluginLogLine[] => logs.map(line => { + const redacted = redact(line); + return { ...redacted, message: clip(redacted.message, PLUGIN_LOG_LIMITS.messageChars) }; + }); + return { ctx: Object.freeze(ctx), finalLogs, redact, clip }; +} 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/pluginDeliveries.ts b/packages/core/src/plugins/pluginDeliveries.ts new file mode 100644 index 0000000000..0a1180b8dd --- /dev/null +++ b/packages/core/src/plugins/pluginDeliveries.ts @@ -0,0 +1,291 @@ +import type { Knex } from 'knex'; +import { + PLUGIN_ACTIVITY_LIMIT, PLUGIN_DEGRADED_AFTER_FAILURES, PLUGIN_DELIVERY_RETENTION_DAYS, PLUGIN_LOG_LIMITS, PLUGIN_PAUSE_AFTER_FAILURES, + type PluginActivity, type PluginAuditAction, type PluginDeliveryKind, type PluginDeliveryStatus, type PluginDeliveryView, type PluginHealthView, type PluginLogLine, +} from '@propr/shared'; +import logger from '../utils/logger.js'; +import { PluginError } from './pluginState.js'; + +/** + * Activity records of plugin event deliveries (`plugin_deliveries`) and the + * delivery health of each installation (`plugin_dispatch`). A record holds + * the event's `seq` and type, the handler, the outcome, timing, the attempt + * count and the latest attempt's redacted error and log lines: never the + * event payload, a handler result or a credential. + */ + +/** A `plugin_dispatch` row as stored. */ +export interface PluginDispatchRow { + plugin_id: string; installed_at: number | string; start_seq: number | string; + lease_owner: string | null; lease_expires_at: number | string | null; + consecutive_failures: number; last_error: string | null; last_error_at: number | string | null; + degraded_at: number | string | null; paused_at: number | string | null; updated_at: number | string; + /** The Degraded notifications still owed, oldest first (a JSON list of `PluginDegradedNotice`), or null. */ + degraded_notice?: string | null; +} +/** A `plugin_deliveries` row as stored. */ +export interface PluginDeliveryRow { + id: number; plugin_id: string; delivery_id: string; kind: PluginDeliveryKind; repository: string; event_seq: number | string; + event_type: string | null; handler: string | null; status: PluginDeliveryStatus; attempt: number; manual: number | boolean; next_attempt_at: number | string | null; + created_at: number | string; started_at: number | string | null; finished_at: number | string | null; duration_ms: number | null; + timed_out: number | boolean; error: string | null; logs: string; +} + +/** Log lines kept per delivery record, and their serialized size. */ +const STORED_LOG_LINES = 100; +const STORED_LOG_BYTES = 64 * 1024; +const ERROR_CHARS = 1_000; + +const num = (value: number | string | null | undefined): number | null => (value === null || value === undefined ? null : Number(value)); + +/** The stable id of one event's delivery to one handler; handlers de-duplicate on it. */ +export const pluginDeliveryId = (seq: number, handler: string) => `evt-${seq}-${handler}`; + +/** Consecutive failed attempts that pause deliveries: `PROPR_PLUGIN_PAUSE_AFTER_FAILURES`, at least the Degraded threshold. */ +export function pluginPauseAfterFailures(environment: NodeJS.ProcessEnv = process.env): number { + const value = Number.parseInt(environment.PROPR_PLUGIN_PAUSE_AFTER_FAILURES ?? '', 10); + return Number.isSafeInteger(value) && value >= PLUGIN_DEGRADED_AFTER_FAILURES ? value : PLUGIN_PAUSE_AFTER_FAILURES; +} + +/** Log lines as stored: at most `STORED_LOG_LINES` lines and `STORED_LOG_BYTES`, with a marker when lines were dropped. */ +export function storedLogs(logs: readonly PluginLogLine[]): string { + const kept: PluginLogLine[] = []; + let bytes = 2; + for (const line of logs.slice(0, STORED_LOG_LINES)) { + const size = Buffer.byteLength(JSON.stringify(line)) + 1; + if (bytes + size > STORED_LOG_BYTES) break; + kept.push(line); bytes += size; + } + if (kept.length < logs.length) kept.push({ level: 'warn', message: `${logs.length - kept.length} more log lines were not kept`, at: kept.at(-1)?.at ?? 0 }); + return JSON.stringify(kept); +} + +export const clipError = (error: string | null) => (error === null ? null : error.slice(0, ERROR_CHARS)); + +function parseLogs(text: string): PluginLogLine[] { + try { + const value: unknown = JSON.parse(text); + return Array.isArray(value) ? value.filter((line): line is PluginLogLine => Boolean(line) && typeof line === 'object' && typeof (line as PluginLogLine).message === 'string') + .map(line => ({ ...line, message: line.message.slice(0, PLUGIN_LOG_LIMITS.messageChars) })) : []; + } catch { return []; } +} + +export function deliveryView(row: PluginDeliveryRow): PluginDeliveryView { + return { + id: Number(row.id), deliveryId: row.delivery_id, kind: row.kind, repository: row.repository, eventSeq: Number(row.event_seq), + eventType: row.event_type, handler: row.handler, status: row.status, attempt: Number(row.attempt), + nextAttemptAt: row.status === 'pending' || row.status === 'retrying' ? num(row.next_attempt_at) : null, + createdAt: Number(row.created_at), startedAt: num(row.started_at), finishedAt: num(row.finished_at), + durationMs: row.duration_ms === null ? null : Number(row.duration_ms), timedOut: Boolean(row.timed_out), + error: row.error, logs: parseLogs(row.logs), + }; +} + +export function healthView(row: PluginDispatchRow | undefined, pauseAfterFailures: number): PluginHealthView { + const failures = Number(row?.consecutive_failures ?? 0); + const pausedAt = num(row?.paused_at); + const degradedAt = num(row?.degraded_at); + return { + state: pausedAt !== null ? 'paused' : degradedAt !== null ? 'degraded' : 'healthy', + consecutiveFailures: failures, pauseAfterFailures, + lastError: row?.last_error ?? null, lastErrorAt: num(row?.last_error_at), degradedAt, pausedAt, + }; +} + +// eslint-disable-next-line max-params -- the transaction, plugin, action, actor, time and detail are explicit audit inputs +const audit = (trx: Knex, pluginId: string, action: PluginAuditAction, actor: string, at: number, detail: Record) => + trx('plugin_audit').insert({ plugin_id: pluginId, action, actor, at, detail: JSON.stringify(detail) }); + +const installedAtOf = async (database: Knex, pluginId: string): Promise => { + const row = await database('plugin_installations').where({ plugin_id: pluginId }).first('installed_at') as { installed_at: number | string } | undefined; + if (!row) throw new PluginError(404, 'The plugin is not installed'); + return Number(row.installed_at); +}; +/** The dispatch row of the installation as it is now; a row left by an earlier installation does not count. */ +async function currentDispatch(database: Knex, pluginId: string): Promise { + const installedAt = await installedAtOf(database, pluginId); + const row = await database('plugin_dispatch').where({ plugin_id: pluginId }).first() as PluginDispatchRow | undefined; + return row && Number(row.installed_at) === installedAt ? row : undefined; +} + +/** The installation's delivery health and its newest delivery records, newest first. */ +export async function listPluginActivity(database: Knex, pluginId: string, options: { limit?: number; environment?: NodeJS.ProcessEnv } = {}): Promise { + const row = await currentDispatch(database, pluginId); + const limit = Math.min(Math.max(Math.trunc(options.limit ?? PLUGIN_ACTIVITY_LIMIT), 1), PLUGIN_ACTIVITY_LIMIT); + const rows = await database('plugin_deliveries').where({ plugin_id: pluginId }).orderBy('id', 'desc').limit(limit).select() as PluginDeliveryRow[]; + return { health: healthView(row, pluginPauseAfterFailures(options.environment)), deliveries: rows.map(deliveryView) }; +} + +/** + * Retry a failed delivery: it gets one more attempt as soon as the dispatcher + * runs (after a Resume while deliveries are paused). The event is read from + * the journal again; the delivery id stays the same. + */ +// eslint-disable-next-line max-params -- the database, plugin, record id, actor and time are explicit inputs +export async function retryPluginDelivery(database: Knex, pluginId: string, id: unknown, actor: string, now: number = Date.now()): Promise { + const recordId = typeof id === 'string' && /^[1-9][0-9]{0,15}$/.test(id) ? Number(id) : typeof id === 'number' && Number.isSafeInteger(id) && id > 0 ? id : null; + if (recordId === null) throw new PluginError(400, 'Invalid delivery id'); + return database.transaction(async trx => { + await installedAtOf(trx, pluginId); + const row = await trx('plugin_deliveries').where({ plugin_id: pluginId, id: recordId }).first() as PluginDeliveryRow | undefined; + if (!row) throw new PluginError(404, 'Delivery not found'); + if (row.kind !== 'event' || row.status !== 'failed') throw new PluginError(409, 'Only a failed event delivery can be retried'); + // A manual retry: one attempt, then final; `manual` lets a dispatcher recover it after a crash mid-attempt. + await trx('plugin_deliveries').where({ id: recordId, status: 'failed' }).update({ status: 'pending', manual: true, next_attempt_at: now }); + await audit(trx, pluginId, 'retry_delivery', actor, now, { deliveryId: row.delivery_id, eventSeq: Number(row.event_seq), handler: row.handler }); + return deliveryView({ ...row, status: 'pending', next_attempt_at: now }); + }); +} + +/** Resume paused deliveries: the failure count starts over, Degraded clears and waiting retries are due now. */ +export async function resumePluginDeliveries(database: Knex, pluginId: string, actor: string, options: { now?: number; environment?: NodeJS.ProcessEnv } = {}): Promise { + const now = options.now ?? Date.now(); + return database.transaction(async trx => { + const row = await currentDispatch(trx, pluginId); + if (!row || row.paused_at === null) throw new PluginError(409, 'Deliveries of this plugin are not paused'); + const update = { paused_at: null, degraded_at: null, consecutive_failures: 0, updated_at: now }; + await trx('plugin_dispatch').where({ plugin_id: pluginId }).update(update); + await trx('plugin_deliveries').where({ plugin_id: pluginId, status: 'retrying' }).update({ next_attempt_at: now }); + await audit(trx, pluginId, 'resume_deliveries', actor, now, { consecutiveFailures: Number(row.consecutive_failures), lastError: row.last_error }); + return healthView({ ...row, ...update }, pluginPauseAfterFailures(options.environment)); + }); +} + +/** The highest journal `seq` ever handed out: where a new subscription starts. */ +export async function journalHead(database: Knex): Promise { + const max = await database('repository_events').max({ seq: 'seq' }).first() as { seq: number | null } | undefined; + let sequence = 0; + try { + const rows = await database.raw("SELECT seq FROM sqlite_sequence WHERE name = 'repository_events'") as Array<{ seq: number }>; + sequence = Number(rows[0]?.seq ?? 0); + } catch { /* no AUTOINCREMENT bookkeeping */ } + return Math.max(Number(max?.seq ?? 0), sequence); +} + +/** + * Start the installation's subscriptions at the journal's current end: on + * install and when it is enabled again. Nothing earlier is delivered, so + * history is never replayed. Delivery records and health start over. + */ +export async function restartPluginDispatch(trx: Knex, pluginId: string, installedAt: number, now: number): Promise { + const start = await journalHead(trx); + await trx('plugin_delivery_cursors').where({ plugin_id: pluginId }).delete(); + await trx('plugin_dispatch').insert({ + plugin_id: pluginId, installed_at: installedAt, start_seq: start, lease_owner: null, lease_expires_at: null, + consecutive_failures: 0, last_error: null, last_error_at: null, degraded_at: null, paused_at: null, updated_at: now, + }).onConflict('plugin_id').merge(); + // Degraded notifications still owed (`degraded_notice`, not merged) are kept: they are sent once they can be. + // Deliveries that never finished would otherwise run after the restart: they belong to before it. + await trx('plugin_deliveries').where({ plugin_id: pluginId }).whereIn('status', ['pending', 'running', 'retrying']) + .update({ status: 'failed', manual: false, next_attempt_at: null, error: 'Not delivered: the plugin was disabled' }); +} + +/** Delete the dispatch state and delivery records of an installation (uninstall). */ +export async function deletePluginDispatch(trx: Knex, pluginId: string): Promise { + await trx('plugin_dispatch').where({ plugin_id: pluginId }).delete(); + await trx('plugin_delivery_cursors').where({ plugin_id: pluginId }).delete(); + return trx('plugin_deliveries').where({ plugin_id: pluginId }).delete(); +} + +/** + * Delete final delivery records older than the retention period. A record of + * an event still in the journal and ahead of its repository's cursor (the + * cursor of the lower-cased name, or the installation's start before one + * exists) is kept: the event is read again (a handler after it is retrying, + * or deliveries are paused), and the record is what makes a handler that + * already succeeded or failed for good not run again with its attempts reset. + */ +export async function prunePluginDeliveries(database: Knex, options: { olderThanDays?: number; now?: () => number } = {}): Promise { + const cutoff = (options.now ?? Date.now)() - (options.olderThanDays ?? PLUGIN_DELIVERY_RETENTION_DAYS) * 24 * 60 * 60 * 1000; + return database('plugin_deliveries').where('created_at', '<', cutoff).whereIn('status', ['succeeded', 'failed', 'skipped']) + .whereNot(needed => { + void needed.where('plugin_deliveries.kind', 'event') + .whereExists(q => { void q.select(1).from('repository_events as e').whereRaw('?? = ??', ['e.seq', 'plugin_deliveries.event_seq']); }) + .whereExists(q => { + void q.select(1).from('plugin_dispatch as d').whereRaw('?? = ??', ['d.plugin_id', 'plugin_deliveries.plugin_id']) + .whereRaw('?? > COALESCE((SELECT ?? FROM ?? AS c WHERE ?? = ?? AND ?? = lower(??)), ??)', [ + 'plugin_deliveries.event_seq', 'c.cursor_seq', 'plugin_delivery_cursors', 'c.plugin_id', 'plugin_deliveries.plugin_id', + 'c.repository', 'plugin_deliveries.repository', 'd.start_seq', + ]); + }); + }) + .delete(); +} + +export interface PluginDegradedNotice { pluginId: string; installedAt: number; degradedAt: number; consecutiveFailures: number; lastError: string | null } +export type PluginDegradedNotifier = (notice: PluginDegradedNotice) => Promise; + +/** One Inbox notification to every durable administrator when an installation becomes Degraded. */ +export function notifyAdministratorsOfDegradedPlugin(database: Knex): PluginDegradedNotifier { + return async ({ pluginId, installedAt, degradedAt, consecutiveFailures, lastError }) => { + const rows = await database('instance_members').distinct('github_user_id').where({ role: 'admin' }) as { github_user_id?: unknown }[]; + const recipients = rows.flatMap(row => (typeof row.github_user_id === 'string' ? [{ userId: row.github_user_id, pushEnabled: true }] : [])); + if (!recipients.length) return; + const { createNotificationEvent } = await import('../services/notificationService.js'); + await createNotificationEvent({ + deduplicationKey: `plugin-degraded:${pluginId}:${installedAt}:${degradedAt}`, + kind: 'system_failure', severity: 'warning', + target: { type: 'system_failure', component: 'plugins.dispatcher', correlationId: pluginId }, + title: `Plugin ${pluginId} is degraded`, + body: `${consecutiveFailures} event deliveries to plugin ${pluginId} failed in a row.${lastError ? ` Last error: ${lastError.slice(0, 300)}` : ''} See its Activity on the Plugins page.`, + actions: ['dismiss'], metadata: { plugin: pluginId }, + }, recipients); + }; +} + +/** Run a notifier without letting it fail the caller; whether it succeeded. */ +export async function notifySafely(notify: PluginDegradedNotifier, notice: PluginDegradedNotice): Promise { + try { await notify(notice); return true; } catch (error) { + logger.warn({ pluginId: notice.pluginId, error: error instanceof Error ? error.message : String(error) }, '[plugins] Could not notify administrators of a degraded plugin'); + return false; + } +} + +const isNotice = (value: unknown): value is PluginDegradedNotice => { + const notice = value as Partial | null; + return Boolean(notice) && typeof notice!.pluginId === 'string' && typeof notice!.installedAt === 'number' && typeof notice!.degradedAt === 'number'; +}; +/** The notices owed, oldest first: `degraded_notice` holds a JSON list (a single notice, as an earlier copy stored it, counts as one). */ +function owedNotices(text: string | null | undefined): PluginDegradedNotice[] { + if (!text) return []; + try { + const value: unknown = JSON.parse(text); + return (Array.isArray(value) ? value : [value]).filter(isNotice); + } catch { return []; } +} +const sameNotice = (a: PluginDegradedNotice, b: PluginDegradedNotice) => a.pluginId === b.pluginId && a.installedAt === b.installedAt && a.degradedAt === b.degradedAt; +const storedNotices = (notices: readonly PluginDegradedNotice[]) => (notices.length ? JSON.stringify(notices) : null); + +/** `degraded_notice` with `notice` queued after the notices still owed: an unsent notice is never written over. */ +export function owedNoticesWith(text: string | null | undefined, notice: PluginDegradedNotice): string { + return JSON.stringify([...owedNotices(text).filter(owed => !sameNotice(owed, notice)), notice]); +} + +/** + * Send the Degraded notifications still owed (`degraded_notice`, a queue + * appended to in the transaction that made the installation Degraded), oldest + * first. Each is removed from the queue only once its notifier succeeded, in + * a transaction that reads the queue as it is then, so a notice queued while + * the others were being sent stays owed, and a failed or interrupted send is + * tried again; the notification's deduplication key keeps a repeat, or two + * dispatchers sending it, to one Inbox entry. Returns whether every one went out. + */ +export async function sendPendingDegradedNotices(database: Knex, notify: PluginDegradedNotifier, pluginId?: string): Promise { + const rows = await database('plugin_dispatch').whereNotNull('degraded_notice').modify(q => { if (pluginId) void q.where({ plugin_id: pluginId }); }) + .select('plugin_id', 'degraded_notice') as { plugin_id: string; degraded_notice: string }[]; + let sent = true; + for (const row of rows) { + const delivered: PluginDegradedNotice[] = []; + for (const notice of owedNotices(row.degraded_notice)) { + if (await notifySafely(notify, notice)) delivered.push(notice); else sent = false; + } + await database.transaction(async trx => { + const current = await trx('plugin_dispatch').where({ plugin_id: row.plugin_id }).first('degraded_notice') as { degraded_notice: string | null } | undefined; + if (!current?.degraded_notice) return; + const remaining = owedNotices(current.degraded_notice).filter(owed => !delivered.some(done => sameNotice(done, owed))); + const stored = storedNotices(remaining); + if (stored !== current.degraded_notice) await trx('plugin_dispatch').where({ plugin_id: row.plugin_id }).update({ degraded_notice: stored }); + }); + } + return sent; +} diff --git a/packages/core/src/plugins/pluginDispatcher.ts b/packages/core/src/plugins/pluginDispatcher.ts new file mode 100644 index 0000000000..63f7ec5d02 --- /dev/null +++ b/packages/core/src/plugins/pluginDispatcher.ts @@ -0,0 +1,496 @@ +import { randomUUID } from 'node:crypto'; +import type { Knex } from 'knex'; +import { + PLUGIN_DEGRADED_AFTER_FAILURES, PLUGIN_DELIVERY_RETRY_DELAYS_MS, + type PluginHandlerRun, type PluginManifest, type PluginSubscription, type RepositoryEvent, +} from '@propr/shared'; +import logger from '../utils/logger.js'; +import { listRepositoryEvents, subscribeRepositoryEventAppends } from '../services/repositoryEventJournal.js'; +import { officialPluginCatalog } from './pluginCatalog.js'; +import { effectivePluginPermissions, type PluginInstallationTarget } from './pluginContext.js'; +import { runPluginHandler, type RunPluginHandlerOptions } from './pluginHandlerRunner.js'; +import { installationRecord, type PluginInstallationRow } from './pluginState.js'; +import { + clipError, journalHead, notifyAdministratorsOfDegradedPlugin, owedNoticesWith, pluginDeliveryId, pluginPauseAfterFailures, restartPluginDispatch, sendPendingDegradedNotices, storedLogs, + type PluginDegradedNotice, type PluginDegradedNotifier, type PluginDeliveryRow, type PluginDispatchRow, +} from './pluginDeliveries.js'; + +/** + * Delivers repository journal events to the handlers plugins subscribe to. + * + * Each installation reads the journal from its own durable cursor, kept per + * repository (case-insensitive: every spelling of a name is one stream, keyed + * by its lower-case form): events of one repository reach one plugin in `seq` order, one + * at a time, while another repository's events go on. Delivery is at least + * once, with a stable delivery id. A failed attempt is retried after 1 min, + * 5 min, 30 min and 2 h; then the delivery is marked failed and the next event + * follows. Only the worker holding an installation's lease delivers to it. + * + * The dispatcher only reads the journal. Nothing that appends an event waits + * for it, so a handler that fails, hangs or crashes never fails or delays the + * task, review, merge or queue step that produced the event. + */ +export interface PluginDispatcherOptions extends Omit { + database: Knex; + /** This dispatcher's lease owner name; unique per process by default. */ + owner?: string; + /** How long a lease lasts without renewal; longer than a handler's time limit. */ + leaseMs?: number; + /** Waits before each retry; the delivery fails after `retryDelaysMs.length + 1` attempts. */ + retryDelaysMs?: readonly number[]; + degradedAfterFailures?: number; + /** Defaults to `PROPR_PLUGIN_PAUSE_AFTER_FAILURES` or `PLUGIN_PAUSE_AFTER_FAILURES`. */ + pauseAfterFailures?: number; + /** Interval between journal reads when no in-process append wakes the dispatcher. */ + pollMs?: number; + /** Events read per repository and pass. */ + batchSize?: number; + /** Runs one handler attempt; defaults to `runPluginHandler`. */ + run?: typeof runPluginHandler; + /** Told once when an installation becomes Degraded; defaults to an Inbox notification to administrators. */ + notify?: PluginDegradedNotifier; +} + +export interface PluginDispatchPass { + /** Attempts made in this pass. */ + attempts: number; + /** Installations this dispatcher held the lease of. */ + leased: string[]; +} + +const DEFAULT_LEASE_MS = 2 * 60_000; +const DEFAULT_POLL_MS = 5_000; +const DEFAULT_BATCH = 50; +const WAKE_DELAY_MS = 200; +/** Wait before a pass tries again to send Degraded notifications that failed. */ +const NOTICE_RETRY_MS = 60_000; + +const lower = (value: string) => value.toLowerCase(); +/** The event value a filter key names: an event field or `payload.`; a missing value is null. */ +function filterValue(event: RepositoryEvent, key: string): unknown { + if (key.startsWith('payload.')) { + const name = key.slice('payload.'.length); + return Object.hasOwn(event.payload, name) ? event.payload[name] : null; + } + return (event as unknown as Record)[key] ?? null; +} +/** Whether a subscription takes this event: its `.`, and every filter key equal to one of its values. */ +export function subscriptionMatches(subscription: PluginSubscription, event: RepositoryEvent): boolean { + if (subscription.on !== `${event.domain}.${event.type}`) return false; + return Object.entries(subscription.filter ?? {}).every(([key, expected]) => { + const actual = filterValue(event, key); + return (Array.isArray(expected) ? expected : [expected]).some(option => (key === 'repository' && typeof actual === 'string' && typeof option === 'string' + ? lower(actual) === lower(option) : actual === option)); + }); +} + +/** One installation's delivery in a pass: the target is refreshed before each attempt; `attempts` counts the handlers started. */ +interface Installation { target: PluginInstallationTarget; state: PluginDispatchRow; attempts: number } +class LeaseLost extends Error {} +/** The repository waits (for a retry); the installation's other repositories go on. */ +class Blocked extends Error {} +/** The installation's pass ends: paused, disabled, out of grant, restarted or changed under it. The next pass starts from the current state. */ +class Stopped extends Error {} + +const UNFINISHED = ['pending', 'running', 'retrying'] as const; +const inScope = (repositories: readonly string[] | null | undefined, repository: string) => !repositories || repositories.map(lower).includes(lower(repository)); +const deliversEvents = (target: PluginInstallationTarget) => target.record.enabled && effectivePluginPermissions(target.manifest, target.record).includes('events.read'); + +export class PluginDispatcher { + readonly owner: string; + private readonly database: Knex; + private readonly now: () => number; + private readonly delays: readonly number[]; + private readonly pauseAfter: number; + private readonly degradedAfter: number; + private timer: NodeJS.Timeout | undefined; + /** When the scheduled pass is due (wall clock). */ + private timerDue = 0; + private unsubscribe: (() => void) | undefined; + private running: Promise | undefined; + private again = false; + /** Polling is on (between `start` and `stop`). */ + private polling = false; + /** `stop` was called: no further attempt starts. */ + private closed = false; + /** When a pass next sends owed Degraded notifications (after one failed). */ + private noticesDue = 0; + + constructor(private readonly options: PluginDispatcherOptions) { + this.database = options.database; + this.owner = options.owner ?? `dispatcher-${process.pid}-${randomUUID()}`; + this.now = options.now ?? Date.now; + this.delays = options.retryDelaysMs ?? PLUGIN_DELIVERY_RETRY_DELAYS_MS; + this.degradedAfter = Math.max(1, options.degradedAfterFailures ?? PLUGIN_DEGRADED_AFTER_FAILURES); + this.pauseAfter = Math.max(this.degradedAfter, options.pauseAfterFailures ?? pluginPauseAfterFailures(options.environment)); + } + + /** Poll the journal and react at once to appends in this process. */ + start(): void { + if (this.polling || this.closed) return; + this.polling = true; + this.unsubscribe = subscribeRepositoryEventAppends(() => this.wake(WAKE_DELAY_MS)); + this.wake(0); + } + + /** Stop polling, wait for the pass under way and hand the leases back. */ + async stop(): Promise { + this.polling = false; this.closed = true; + clearTimeout(this.timer); this.timer = undefined; + this.unsubscribe?.(); this.unsubscribe = undefined; + await this.running?.catch(() => undefined); + await this.database('plugin_dispatch').where({ lease_owner: this.owner }).update({ lease_owner: null, lease_expires_at: null }).catch(() => undefined); + } + + /** Schedule a pass in `delayMs`, unless one is due sooner: appends arriving without pause never push a pass back. */ + private wake(delayMs: number): void { + if (!this.polling) return; + if (this.running) { this.again = true; return; } + const due = Date.now() + delayMs; + if (this.timer && this.timerDue <= due) return; + clearTimeout(this.timer); + this.timerDue = due; + this.timer = setTimeout(() => { void this.loop(); }, delayMs); + this.timer.unref?.(); + } + + private async loop(): Promise { + this.timer = undefined; + await this.pass().catch(() => undefined); + const again = this.again; this.again = false; + this.wake(again ? 0 : this.options.pollMs ?? DEFAULT_POLL_MS); + } + + /** One pass over every enabled installation with subscriptions; passes never overlap. Never throws. */ + pass(): Promise { + if (this.running) return this.running.then(() => this.pass()); + const run = this.passOnce().finally(() => { this.running = undefined; }); + this.running = run; + return run; + } + + private async passOnce(): Promise { + const result: PluginDispatchPass = { attempts: 0, leased: [] }; + // Notifications owed by any installation, paused ones included. + if (this.now() >= this.noticesDue) await this.sendNotices(); + let installations: Installation[]; + try { installations = await this.installations(); } catch (error) { + logger.warn({ error: error instanceof Error ? error.message : String(error) }, '[plugins] Could not read plugin installations for event delivery'); + return result; + } + // Installations deliver side by side; each one's own events stay in order. + await Promise.all(installations.map(async installation => { + const { pluginId } = installation.target; + try { + if (!await this.acquire(pluginId)) return; + result.leased.push(pluginId); + await this.deliver(installation); + } catch (error) { + if (!(error instanceof LeaseLost) && !(error instanceof Stopped)) logger.warn({ pluginId, error: error instanceof Error ? error.message : String(error) }, '[plugins] Event delivery pass failed'); + } finally { + result.attempts += installation.attempts; + } + })); + return result; + } + + private async installations(): Promise { + const manifests = await (this.options.catalog ?? officialPluginCatalog)(); + const rows = await this.database('plugin_installations').where({ enabled: true }).select() as PluginInstallationRow[]; + const found: Installation[] = []; + for (const row of rows) { + const manifest: PluginManifest | undefined = manifests.find(m => m.id === row.plugin_id); + if (!manifest?.contributes.subscriptions.length) continue; + const target = { pluginId: row.plugin_id, manifest, record: installationRecord(row) }; + // Subscriptions need events.read, granted and still asked for. + if (!deliversEvents(target)) continue; + const state = await this.state(row.plugin_id, target.record.installedAt); + // Uninstalled, reinstalled or disabled since the read: the next pass starts from the installation as it is then. + if (!state || state.paused_at !== null) continue; + found.push({ target, state, attempts: 0 }); + } + return found; + } + + /** + * The dispatch state of the installation read as `installedAt`; an + * installation without one (or with an earlier installation's) starts at the + * journal's end. State is only created or replaced in one transaction with + * the check that the installation is still that one, enabled, and that the + * state it replaces is still not its own: an installation read before an + * uninstall, reinstall or disable never writes over its replacement's state + * (that would move the replacement's start past events it must receive). + * Undefined when the installation changed since it was read. + */ + private async state(pluginId: string, installedAt: number): Promise { + const read = (database: Knex) => database('plugin_dispatch').where({ plugin_id: pluginId }).first() as Promise; + const row = await read(this.database); + if (row && Number(row.installed_at) === installedAt) return row; + return this.database.transaction(async trx => { + const current = await trx('plugin_installations').where({ plugin_id: pluginId }).first('installed_at', 'enabled') as Pick | undefined; + if (!current || Number(current.installed_at) !== installedAt || !current.enabled) return undefined; + const existing = await read(trx); + if (!existing) { + await trx('plugin_dispatch').insert({ plugin_id: pluginId, installed_at: installedAt, start_seq: await journalHead(trx), consecutive_failures: 0, updated_at: this.now() }); + } else if (Number(existing.installed_at) !== installedAt) { + await restartPluginDispatch(trx, pluginId, installedAt, this.now()); + } + return read(trx); + }); + } + + /** Take or renew the installation's lease: free, expired or already ours. */ + private async acquire(pluginId: string): Promise { + const now = this.now(); + const updated = await this.database('plugin_dispatch').where({ plugin_id: pluginId }) + .where(q => { void q.whereNull('lease_owner').orWhere('lease_owner', this.owner).orWhereNull('lease_expires_at').orWhere('lease_expires_at', '<', now); }) + .update({ lease_owner: this.owner, lease_expires_at: now + (this.options.leaseMs ?? DEFAULT_LEASE_MS) }); + return updated === 1; + } + private async renew(pluginId: string): Promise { + if (this.closed || !await this.acquire(pluginId)) throw new LeaseLost(); + } + + /** Deliver what is due for one installation. Ends with `Stopped` once it may no longer deliver (paused, for one). */ + private async deliver(installation: Installation): Promise { + await this.manualRetries(installation); + for (const repository of await this.repositories(installation)) { // canonical (lower-case) names + if (!inScope(installation.target.record.repositories, repository)) continue; + try { await this.deliverRepository(installation, repository); } catch (error) { + if (!(error instanceof Blocked)) throw error; + } + } + } + + /** + * The repositories to visit: those with events after the installation's + * start, with unfinished deliveries, and those whose journal floor moved + * past the start and past what their cursor has recorded, so a repository + * that retention emptied still has its gap recorded. Each is named once, in + * lower case, whatever spellings its events use. + */ + private async repositories(installation: Installation): Promise { + const { pluginId } = installation.target; + const start = Number(installation.state.start_seq); + type Floor = { repository: string; floor_seq: number | string }; + const [events, unfinished, floors, cursors] = await Promise.all([ + this.database('repository_events').where('seq', '>', start).distinct('repository'), + this.database('plugin_deliveries').where({ plugin_id: pluginId, kind: 'event' }).whereIn('status', UNFINISHED).distinct('repository'), + this.database('repository_event_floors').where('floor_seq', '>', start + 1).select('repository', 'floor_seq'), + this.database('plugin_delivery_cursors').where({ plugin_id: pluginId }).select('repository', 'floor_seq'), + ]) as [{ repository: string }[], { repository: string }[], Floor[], Floor[]]; + const recorded = new Map(cursors.map(c => [c.repository, Number(c.floor_seq)])); + const moved = floors.filter(f => Number(f.floor_seq) > (recorded.get(lower(f.repository)) ?? 0)); + return [...new Set([...events, ...unfinished, ...moved].map(r => lower(r.repository)))].sort(); + } + + /** Administrator retries: one attempt each, out of the stream's order; one a crashed worker left `running` is attempted again. */ + private async manualRetries(installation: Installation): Promise { + const { pluginId } = installation.target; + const rows = await this.database('plugin_deliveries').where({ plugin_id: pluginId, kind: 'event', manual: true }) + .where(q => { void q.where(p => { void p.where('status', 'pending').where('next_attempt_at', '<=', this.now()); }).orWhere('status', 'running'); }) + .orderBy('id').select() as PluginDeliveryRow[]; + for (const row of rows) { + const [event] = await listRepositoryEvents(this.database, { repository: row.repository, afterSeq: Number(row.event_seq) - 1, limit: 1 }); + if (!event || event.seq !== Number(row.event_seq)) { + await this.fenced(installation, trx => trx('plugin_deliveries').where({ id: row.id, status: row.status, attempt: row.attempt }) + .update({ status: 'failed', manual: false, next_attempt_at: null, error: 'The event is no longer in the journal' })); + continue; + } + await this.attempt(installation, row, event); + } + } + + /** Deliver one repository's events (every spelling, `repository` is the lower-case name) in order, until one waits for a retry or the batch is done. */ + private async deliverRepository(installation: Installation, repository: string): Promise { + const { pluginId, manifest } = installation.target; + const cursor = await this.cursor(installation, repository); + const events = await listRepositoryEvents(this.database, { repository, anyCase: true, afterSeq: cursor.seq, limit: this.options.batchSize ?? DEFAULT_BATCH }); + for (const event of events) { + const subscriptions = manifest.contributes.subscriptions.filter(s => subscriptionMatches(s, event)); + for (const subscription of subscriptions) { + const row = await this.delivery(installation, event, subscription.handler); + if (row.status === 'succeeded' || row.status === 'failed' || row.status === 'skipped') continue; + if (row.status !== 'running' && Number(row.next_attempt_at ?? 0) > this.now()) throw new Blocked(); + const status = await this.attempt(installation, row, event); + if (status !== 'succeeded' && status !== 'failed') throw new Blocked(); + } + await this.fenced(installation, trx => trx('plugin_delivery_cursors').where({ plugin_id: pluginId, repository }).where('cursor_seq', '<', event.seq).update({ cursor_seq: event.seq })); + } + } + + /** + * Run a cursor or record write in one transaction with the check that this + * dispatcher still holds the lease of the installation it read: the same + * installation and journal start. A restart (enabled again) or a reinstall + * since the read ends the pass instead of writing state of the earlier one. + */ + private async fenced(installation: Installation, write: (trx: Knex.Transaction) => Promise): Promise { + const { state } = installation; + return this.database.transaction(async trx => { + const current = await trx('plugin_dispatch').where({ plugin_id: state.plugin_id, lease_owner: this.owner, installed_at: state.installed_at, start_seq: state.start_seq }).first(); + if (!current) throw new Stopped(); + return write(trx); + }); + } + + /** The repository's cursor, created at the installation's start; records a gap once when retention removed events not yet delivered. */ + private async cursor(installation: Installation, repository: string): Promise<{ seq: number }> { + const { pluginId } = installation.target; + const where = { plugin_id: pluginId, repository }; + let row = await this.database('plugin_delivery_cursors').where(where).first() as { cursor_seq: number | string; floor_seq: number | string } | undefined; + if (!row) { + const start = Number(installation.state.start_seq); + // Only while the start read is still the installation's: a restart since then (enabled again) deleted the cursors and moved the start. + row = await this.fenced(installation, async trx => { + await trx('plugin_delivery_cursors').insert({ ...where, cursor_seq: start, floor_seq: 1 }).onConflict(['plugin_id', 'repository']).ignore(); + return await trx('plugin_delivery_cursors').where(where).first() as { cursor_seq: number | string; floor_seq: number | string }; + }); + } + const seq = Number(row.cursor_seq); + // The highest floor of any spelling: completeness of the stream ends there. + const floors = await this.database('repository_event_floors').whereRaw('repository = ? COLLATE NOCASE', [repository]).max({ floor: 'floor_seq' }).first() as { floor: number | string | null } | undefined; + const floor = Number(floors?.floor ?? 1); + if (floor > Number(row.floor_seq)) await this.fenced(installation, async trx => { + if (seq < floor - 1) { + const now = this.now(); + await trx('plugin_deliveries').insert({ + plugin_id: pluginId, delivery_id: `gap-${seq + 1}-${floor - 1}-${repository}`.slice(0, 255), kind: 'gap', repository, event_seq: seq + 1, + event_type: null, handler: null, status: 'skipped', attempt: 0, next_attempt_at: null, created_at: now, finished_at: now, + error: `Events after ${seq} up to ${floor - 1} may have left the journal (retention) before they were delivered`, logs: '[]', + }).onConflict(['plugin_id', 'delivery_id']).ignore(); + } + // Unfinished deliveries of events retention removed are final now; retained events below the floor still go out. + await trx('plugin_deliveries').where({ plugin_id: pluginId, kind: 'event' }).whereRaw('repository = ? COLLATE NOCASE', [repository]) + .whereIn('status', UNFINISHED).where('event_seq', '<', floor) + .whereNotExists(q => { void q.select(1).from('repository_events').whereRaw('?? = ??', ['repository_events.seq', 'plugin_deliveries.event_seq']); }) + .update({ status: 'skipped', manual: false, next_attempt_at: null, finished_at: this.now(), error: 'The event left the journal (retention) before it was delivered' }); + await trx('plugin_delivery_cursors').where(where).update({ floor_seq: floor }); + }); + return { seq }; + } + + /** The delivery record of one event to one handler, created pending on first sight. */ + private async delivery(installation: Installation, event: RepositoryEvent, handler: string): Promise { + const { pluginId } = installation.target; + const deliveryId = pluginDeliveryId(event.seq, handler); + await this.fenced(installation, trx => trx('plugin_deliveries').insert({ + plugin_id: pluginId, delivery_id: deliveryId, kind: 'event', repository: event.repository, event_seq: event.seq, + event_type: `${event.domain}.${event.type}`, handler, status: 'pending', attempt: 0, next_attempt_at: this.now(), created_at: this.now(), logs: '[]', + }).onConflict(['plugin_id', 'delivery_id']).ignore()); + return await this.database('plugin_deliveries').where({ plugin_id: pluginId, delivery_id: deliveryId }).first() as PluginDeliveryRow; + } + + /** + * Before each attempt: the lease is still ours and the installation, as it + * is now, may receive this event. Paused, disabled, reinstalled, restarted + * (enabled again) or without events.read ends the pass. A repository outside + * the current limit ends an ordinary pass (the next one skips it) and fails + * a manual retry. The target used for the attempt is the current one. An + * administrator change landing between this check and the handler's start + * is a race no check can close; the next attempt sees it. + */ + private async admit(installation: Installation, row: PluginDeliveryRow, event: RepositoryEvent): Promise { + const { pluginId, manifest } = installation.target; + await this.renew(pluginId); + const [dispatch, current] = await Promise.all([ + this.database('plugin_dispatch').where({ plugin_id: pluginId }).first() as Promise, + this.database('plugin_installations').where({ plugin_id: pluginId }).first() as Promise, + ]); + if (!dispatch || !current || dispatch.paused_at !== null || dispatch.lease_owner !== this.owner) throw new Stopped(); + const { state } = installation; + if (Number(dispatch.installed_at) !== Number(state.installed_at) || Number(dispatch.start_seq) !== Number(state.start_seq)) throw new Stopped(); + const target = { pluginId, manifest, record: installationRecord(current) }; + if (target.record.installedAt !== installation.target.record.installedAt || !deliversEvents(target)) throw new Stopped(); + installation.target = target; + const manual = Boolean(row.manual); + if (!manual && event.seq <= Number(state.start_seq)) throw new Stopped(); + if (inScope(target.record.repositories, event.repository)) return true; + if (!manual) throw new Stopped(); + await this.fenced(installation, trx => trx('plugin_deliveries').where({ id: row.id, status: row.status, attempt: row.attempt }).update({ + status: 'failed', manual: false, next_attempt_at: null, finished_at: this.now(), error: "Not delivered: the repository is outside the plugin's repository limit", + })); + return false; + } + + /** One attempt: mark it running, run the handler, then record the outcome and the installation's health while still holding the lease. Ends the pass once it pauses the installation. */ + private async attempt(installation: Installation, row: PluginDeliveryRow, event: RepositoryEvent): Promise { + if (!await this.admit(installation, row, event)) return null; + const { pluginId } = installation.target; + const manual = Boolean(row.manual); + const attempt = Number(row.attempt) + 1; + // The claim is fenced: only while this dispatcher still holds the lease of the installation and start it read, + // the installation is still that one and enabled, and the record is unchanged. The lease is renewed in the same + // write, so a worker suspended since `admit` (its lease taken over meanwhile) never starts the handler. + const marked = await this.fenced(installation, async trx => { + const enabled = await trx('plugin_installations').where({ plugin_id: pluginId, installed_at: installation.target.record.installedAt, enabled: true }).first('plugin_id'); + if (!enabled) return 0; + const now = this.now(); + await trx('plugin_dispatch').where({ plugin_id: pluginId, lease_owner: this.owner }).update({ lease_expires_at: now + (this.options.leaseMs ?? DEFAULT_LEASE_MS) }); + return trx('plugin_deliveries').where({ id: row.id, status: row.status, attempt: row.attempt }).update({ status: 'running', attempt, started_at: now, finished_at: null }); + }); + if (marked !== 1) throw new Stopped(); + installation.attempts++; + let run: PluginHandlerRun; + try { + run = await (this.options.run ?? runPluginHandler)(installation.target, row.handler!, { kind: 'event', event, deliveryId: row.delivery_id, attempt }, { ...this.options, database: this.database }); + } catch (error) { + // The runner never throws; a replacement that does still only fails this attempt. + run = { outcome: 'failed', timedOut: false, durationMs: 0, error: error instanceof Error ? error.message : 'The handler failed', logs: [] }; + } + const now = this.now(); + const exhausted = manual || attempt > this.delays.length; + const status = run.outcome === 'succeeded' ? 'succeeded' : exhausted ? 'failed' : 'retrying'; + const health: { notice: boolean; paused: boolean } = { notice: false, paused: false }; + await this.database.transaction(async trx => { + // A dispatcher that lost the lease while the handler ran records nothing: the lease holder delivers it again, with the same id. + const state = await trx('plugin_dispatch').where({ plugin_id: pluginId, lease_owner: this.owner }).first() as PluginDispatchRow | undefined; + if (!state) throw new LeaseLost(); + await trx('plugin_deliveries').where({ id: row.id }).update({ + status, manual: false, finished_at: now, duration_ms: run.durationMs, timed_out: run.timedOut, error: clipError(run.error), logs: storedLogs(run.logs), + next_attempt_at: status === 'retrying' ? now + this.delays[attempt - 1] : null, + }); + Object.assign(health, await this.recordHealth(trx, state, run, now)); + }); + if (health.notice) await this.sendNotices(pluginId); + if (health.paused) throw new Stopped(); + return status; + } + + /** Send the Degraded notifications owed (of one installation); after a failure, passes try again after `NOTICE_RETRY_MS`. */ + private async sendNotices(pluginId?: string): Promise { + const sent = await sendPendingDegradedNotices(this.database, this.options.notify ?? notifyAdministratorsOfDegradedPlugin(this.database), pluginId) + .catch((error: unknown) => { + logger.warn({ pluginId, error: error instanceof Error ? error.message : String(error) }, '[plugins] Could not read the Degraded notifications owed'); + return false; + }); + if (!sent) this.noticesDue = this.now() + NOTICE_RETRY_MS; + } + + /** + * Count the attempt into the installation's health; returns whether it just + * became Degraded and whether it is paused now. Becoming Degraded queues the + * notification owed in the same write, behind any earlier one not sent yet, + * so a failed send or a crash before it leaves it to be sent again. + */ + private async recordHealth(trx: Knex.Transaction, state: PluginDispatchRow, run: PluginHandlerRun, now: number): Promise<{ notice: boolean; paused: boolean }> { + const where = { plugin_id: state.plugin_id }; + if (run.outcome === 'succeeded') { + await trx('plugin_dispatch').where(where).update({ consecutive_failures: 0, degraded_at: null, updated_at: now }); + return { notice: false, paused: state.paused_at !== null }; + } + const failures = Number(state.consecutive_failures) + 1; + const becomesDegraded = state.degraded_at === null && failures >= this.degradedAfter; + const error = clipError(run.error); + const notice: PluginDegradedNotice = { pluginId: state.plugin_id, installedAt: Number(state.installed_at), degradedAt: now, consecutiveFailures: failures, lastError: error }; + await trx('plugin_dispatch').where(where).update({ + consecutive_failures: failures, last_error: error, last_error_at: now, updated_at: now, + // Queued after any notice still owed from an earlier transition (`state` was read in this transaction). + ...(becomesDegraded ? { degraded_at: now, degraded_notice: owedNoticesWith(state.degraded_notice, notice) } : {}), + ...(state.paused_at === null && failures >= this.pauseAfter ? { paused_at: now } : {}), + }); + const paused = state.paused_at !== null || failures >= this.pauseAfter; + if (paused && state.paused_at === null) logger.warn({ pluginId: state.plugin_id, failures }, '[plugins] Event deliveries paused after repeated failures'); + return { notice: becomesDegraded, paused }; + } +} diff --git a/packages/core/src/plugins/pluginHandlerRunner.ts b/packages/core/src/plugins/pluginHandlerRunner.ts new file mode 100644 index 0000000000..c698f35806 --- /dev/null +++ b/packages/core/src/plugins/pluginHandlerRunner.ts @@ -0,0 +1,138 @@ +import type { Knex } from 'knex'; +import { + PLUGIN_HANDLER_TIMEOUT_MS, + type PluginHandler, type PluginHandlerInput, type PluginHandlerModule, type PluginHandlerRun, type PluginJsonValue, type PluginLogLine, type PluginManifest, +} from '@propr/shared'; +import logger from '../utils/logger.js'; +import { officialPluginCatalog, type PluginCatalogSource } from './pluginCatalog.js'; +import { buildPluginContext, retainForRedaction, type PluginContextDeps, type PluginInstallationTarget } from './pluginContext.js'; +import { installationRecord, type PluginInstallationRow } from './pluginState.js'; + +/** The handlers module of a plugin (its `handlers.ts`), or null when it has none. */ +export type PluginHandlerSource = (pluginId: string) => Promise; + +interface PluginRegistryHandlers { loadPluginHandlers(id: string): Promise } +// Not a literal import, like the catalog: Core runs where the registry package is absent. +const REGISTRY_MODULE = '@propr/plugins'; + +/** The handlers of the official plugin registry (`@propr/plugins`). */ +export const officialPluginHandlers: PluginHandlerSource = async pluginId => { + const registry = await import(REGISTRY_MODULE) as PluginRegistryHandlers; + return registry.loadPluginHandlers(pluginId); +}; + +export interface RunPluginHandlerOptions extends PluginContextDeps { + /** Where handler modules come from; defaults to the official registry. */ + handlers?: PluginHandlerSource; + /** The manifests, when the installation is given by plugin id; defaults to the official catalog. */ + catalog?: PluginCatalogSource; + /** Defaults to `PLUGIN_HANDLER_TIMEOUT_MS`; tests shorten it. */ + timeoutMs?: number; +} + +/** A plugin id (its installation is read from the database) or an installation already loaded. */ +export type PluginInstallationRef = string | PluginInstallationTarget; + +const HANDLER_NAME = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/; +const ERROR_CHARS = 1_000; +const RESULT_BYTES = 16 * 1024; + +class HandlerTimeout extends Error { constructor(ms: number) { super(`The handler did not finish within ${Math.round(ms / 1000)} seconds`); } } + +async function loadTarget(installation: PluginInstallationRef, database: Knex, catalog: PluginCatalogSource): Promise { + if (typeof installation !== 'string') return installation; + const manifest: PluginManifest | undefined = (await catalog()).find(m => m.id === installation); + if (!manifest) throw new Error(`Plugin ${installation.slice(0, 48)} is not in this ProPR's registry`); + const row = await database('plugin_installations').where({ plugin_id: installation }).first() as PluginInstallationRow | undefined; + if (!row) throw new Error(`Plugin ${installation} is not installed`); + return { pluginId: installation, manifest, record: installationRecord(row) }; +} + +async function loadHandler(target: PluginInstallationTarget, name: string, source: PluginHandlerSource): Promise { + if (!target.record.enabled) throw new Error(`Plugin ${target.pluginId} is not enabled`); + if (!HANDLER_NAME.test(name)) throw new Error('Invalid handler name'); + const module = await source(target.pluginId); + const handler = module && Object.hasOwn(module, name) ? module[name] : undefined; + if (typeof handler !== 'function') throw new Error(`Plugin ${target.pluginId} has no handler ${name}`); + return handler; +} + +/** The handler's return value as JSON, or an error when it is not JSON or too large. */ +function resultValue(value: unknown): PluginJsonValue | undefined { + if (value === undefined) return undefined; + let text: string | undefined; + try { text = JSON.stringify(value); } catch { /* not JSON */ } + if (text === undefined) throw new Error('The handler returned a value that is not JSON'); + if (Buffer.byteLength(text) > RESULT_BYTES) throw new Error(`The handler returned more than ${RESULT_BYTES} bytes`); + return JSON.parse(text) as PluginJsonValue; +} + +const HANDLER_FAILED = 'The handler failed'; + +/** + * The message of whatever the run failed with. A handler may throw any value: one + * that cannot be converted to a string (`Object.create(null)`, an own non-callable + * `toString`), an `Error` whose `message` is not a string or is a throwing getter, + * or a proxy whose traps throw. None of them may escape the runner's catch block, + * so a value that does not yield a string falls back to a constant message. + * Kept past ERROR_CHARS: `finish` cuts it only after redacting it, so a credential straddling the limit is redacted whole. + */ +function message(error: unknown): string { + let text: unknown; + try { text = error instanceof Error ? error.message : String(error); } catch { return HANDLER_FAILED; } + return typeof text === 'string' && text ? retainForRedaction(text, ERROR_CHARS) : HANDLER_FAILED; +} + +/** Whether the run failed on its time limit; never throws, whatever the handler threw. */ +function isTimeout(error: unknown): boolean { + try { return error instanceof HandlerTimeout; } catch { return false; } +} + +/** Run the handler, failing it once it runs past `timeoutMs`; the signal tells the handler to stop. */ +async function withTimeout(run: () => Promise, timeoutMs: number, controller: AbortController): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + Promise.resolve().then(run), + new Promise((_, reject) => { + timer = setTimeout(() => { const error = new HandlerTimeout(timeoutMs); controller.abort(error); reject(error); }, timeoutMs); + timer.unref(); + }), + ]); + } finally { clearTimeout(timer); } +} + +/** + * Run one handler of an installed, enabled plugin: load it from the registry, + * build its `ctx`, apply the time limit and return the outcome, duration, + * error message and log lines. Never throws; every failure, a timeout + * included, is a `failed` outcome. Messages, logs and the result are redacted. + */ +export async function runPluginHandler(installation: PluginInstallationRef, handlerName: string, input: PluginHandlerInput, options: RunPluginHandlerOptions): Promise { + const started = performance.now(); + const controller = new AbortController(); + let finalLogs = (): PluginLogLine[] => []; + let redact = (value: T): T => value; + let clip = (text: string, limit: number): string => text.slice(0, limit); + const pluginId = typeof installation === 'string' ? installation : installation?.pluginId; + const finish = (run: Omit): PluginHandlerRun => { + const durationMs = Math.round(performance.now() - started); + // Ends the run for anything the handler left running: ctx refuses further calls and log lines. + if (!controller.signal.aborted) controller.abort(new Error('The handler run has ended')); + // Lines logged (and errors raised) before a credential was acquired were redacted without it: redact them again with all the credentials of this run, then cut them. + const result: PluginHandlerRun = { ...run, error: run.error === null ? null : clip(run.error, ERROR_CHARS), durationMs, logs: finalLogs() }; + try { logger.info({ pluginId, handler: handlerName, outcome: result.outcome, timedOut: result.timedOut, durationMs }, '[plugins] Handler run'); } catch { /* logging never fails a run */ } + return result; + }; + try { + const target = await loadTarget(installation, options.database, options.catalog ?? officialPluginCatalog); + const handler = await loadHandler(target, handlerName, options.handlers ?? officialPluginHandlers); + const built = buildPluginContext(target, options, handlerName, controller.signal); + finalLogs = built.finalLogs; redact = built.redact; clip = built.clip; + const value = await withTimeout(() => handler(input, built.ctx), options.timeoutMs ?? PLUGIN_HANDLER_TIMEOUT_MS, controller); + const result = resultValue(value); + return finish({ outcome: 'succeeded', timedOut: false, error: null, ...(result === undefined ? {} : { result: redact(result) }) }); + } catch (error) { + return finish({ outcome: 'failed', timedOut: isTimeout(error), error: message(error) }); + } +} diff --git a/packages/core/src/plugins/pluginHttp.ts b/packages/core/src/plugins/pluginHttp.ts new file mode 100644 index 0000000000..fb5f692bf0 --- /dev/null +++ b/packages/core/src/plugins/pluginHttp.ts @@ -0,0 +1,429 @@ +import dns from 'node:dns/promises'; +import https from 'node:https'; +import { isIP } from 'node:net'; +import { + PLUGIN_HTTP_LIMITS, PluginSdkError, + type PluginHttpFetch, type PluginHttpRequestInit, type PluginHttpResponse, +} from '@propr/shared'; +import { extensionPublicAddress, extensionReservedHostname, type ExtensionLookup } from '../extensions/endpointPolicy.js'; + +/** One vetted request: the URL as the handler wrote it, the checked address to dial and the final headers. */ +export interface PluginHttpTransportRequest { + url: URL; address: string; family: number; + method: string; headers: Record; body?: Buffer; + timeoutMs: number; maxResponseBytes: number; + /** Aborted when the handler run ends; the transport stops sending and receiving then. */ + signal?: AbortSignal; +} +export interface PluginHttpTransportResponse { status: number; headers: Record; body: Buffer } +/** Sends one vetted request. Throws `PluginSdkError` (`response_too_large`, `http_failed`). */ +export type PluginHttpTransport = (request: PluginHttpTransportRequest) => Promise; + +export interface PluginHttpOptions { + /** The manifest's `outboundHosts`: exact hostnames, nothing else is reachable. */ + outboundHosts: readonly string[]; + /** The credential to inject as `Authorization: Bearer`, resolved on the first request; null for none. */ + credential?: () => Promise; + /** + * Called before every request is sent, after its lookups: throws to refuse + * it (the installation the credential belongs to was removed or replaced). + */ + authorize?: () => Promise; + lookup?: ExtensionLookup; + transport?: PluginHttpTransport; + /** Every request decision (`allowed` false when refused), for the handler log. */ + onDecision?: (decision: { host: string; allowed: boolean; reason?: string }) => void; + /** The handler run's signal: once aborted no request starts, and one in flight is cancelled. */ + signal?: AbortSignal; +} + +const METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']); +/** Headers ProPR sets itself or that would bypass the policy. */ +const RESERVED_HEADERS = /^(host|content-length|connection|keep-alive|transfer-encoding|te|trailer|upgrade|proxy-.*|expect)$/i; +const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/; +const HEADER_VALUE = /^[\t\x20-\x7e\x80-\xff]{0,8192}$/; + +const defaultLookup: ExtensionLookup = host => dns.lookup(host, { all: true, verbatim: true }); + +/** + * One spelling of a credential. `anyCase`: the spelling reads the same in any + * letter case (its hex encoding: `Buffer.from(hex, 'hex')` accepts `AB`, `ab` + * and `aB` alike), so it is matched with ASCII letters folded; every other + * spelling (the secret itself, its base64) is matched exactly. + */ +export interface CredentialSpelling { text: string; anyCase: boolean } + +/** Every spelling of a credential (alone and as the header value sent) a response could echo back. */ +function credentialSpellings(secret: string): CredentialSpelling[] { + const encodings = (value: string): CredentialSpelling[] => { + // Inside a JSON string: quotes, backslashes and control characters escaped, `/` optionally too. + const json = JSON.stringify(value).slice(1, -1); + const exact = [value, json, json.split('/').join('\\/'), Buffer.from(value).toString('base64'), Buffer.from(value).toString('base64url'), encodeURIComponent(value)]; + return [...exact.map(text => ({ text, anyCase: false })), { text: Buffer.from(value).toString('hex'), anyCase: true }]; + }; + return uniqueSpellings([...encodings(`Bearer ${secret}`), ...encodings(secret)]); +} +/** Distinct spellings, longest first: the header value's encodings are not made of the secret's. */ +function uniqueSpellings(spellings: readonly CredentialSpelling[]): CredentialSpelling[] { + const seen = new Map(); + for (const spelling of spellings) if (spelling.text) seen.set(`${spelling.anyCase ? 'i' : 'x'}:${spelling.text}`, spelling); + return [...seen.values()].sort((a, b) => b.text.length - a.text.length); +} + +/** `text` with its ASCII letters lowercased and nothing else changed: the same length, so an index in it is an index in `text`. */ +const foldAscii = (text: string) => text.replace(/[A-Z]+/g, run => run.toLowerCase()); +/** The haystacks a spelling is searched in: `text` itself, or its ASCII-folded copy for an any-case spelling. */ +function haystacks(text: string): (spelling: CredentialSpelling) => string { + let folded: string | undefined; + return spelling => (spelling.anyCase ? (folded ??= foldAscii(text)) : text); +} +/** Where `text` holds any spelling: `[start, end)` ranges, overlapping ones included. */ +function spellingRanges(text: string, spellings: readonly CredentialSpelling[]): [number, number][] { + const haystack = haystacks(text); + const ranges: [number, number][] = []; + for (const spelling of spellings) { + const source = haystack(spelling); + for (let at = source.indexOf(spelling.text); at >= 0; at = source.indexOf(spelling.text, at + 1)) ranges.push([at, at + spelling.text.length]); + } + return ranges; +} +/** Whether `text` holds any spelling (an any-case one in any letter case). */ +function holdsSpelling(text: string, spellings: readonly CredentialSpelling[]): boolean { + const haystack = haystacks(text); + return spellings.some(spelling => haystack(spelling).includes(spelling.text)); +} +/** `text` with each range replaced by `[REDACTED]`; overlapping ranges (the header value holds the secret) become one replacement. */ +function redactRanges(text: string, ranges: [number, number][]): string { + if (!ranges.length) return text; + let scrubbed = ''; let cursor = 0; + for (const [start, end] of ranges.sort((a, b) => a[0] - b[0])) { + if (start >= cursor) scrubbed += `${text.slice(cursor, start)}[REDACTED]`; + cursor = Math.max(cursor, end); + } + return scrubbed + text.slice(cursor); +} + +/** One percent-escaped ASCII character, its hex digits in either case: `%2f`, `%2F`, `%61`. */ +const ASCII_PERCENT_ESCAPE = /%[0-7][0-9a-fA-F]/g; + +/** + * `text` with every spelling replaced also where some of its characters are + * percent-escaped, whatever the case of the hex digits (`%2f` as well as the + * `%2F` of `encodeURIComponent`, or an escaped letter): `decodeURIComponent` + * recovers each of them. Connection secrets are printable ASCII, so only + * escapes of ASCII characters can spell one. The text is decoded once and each + * match is mapped back to the characters and escapes it was decoded from. + */ +function scrubPercentEscaped(text: string, spellings: readonly CredentialSpelling[]): string { + // The decoded position of each escaped character: a decoded index maps back past two more characters per escape before it. + const escapes: number[] = []; + const decoded = text.replace(ASCII_PERCENT_ESCAPE, (escape, offset: number) => { + escapes.push(offset - 2 * escapes.length); + return String.fromCharCode(parseInt(escape.slice(1), 16)); + }); + if (!escapes.length) return text; + const original = (index: number) => { + let low = 0; let high = escapes.length; + while (low < high) { const middle = (low + high) >> 1; if (escapes[middle] < index) low = middle + 1; else high = middle; } + return index + 2 * low; + }; + return redactRanges(text, spellingRanges(decoded, spellings).map(([start, end]) => [original(start), original(end)])); +} +/** `text` with every spelling replaced: as written (an any-case spelling in any letter case), then where percent-escaped. */ +const scrub = (text: string, spellings: readonly CredentialSpelling[]) => spellings.length + ? scrubPercentEscaped(redactRanges(text, spellingRanges(text, spellings)), spellings) + : text; + +const JSON_ESCAPE = /\\(u[0-9a-fA-F]{4}|["\\/bfnrt])/g; +const SHORT_ESCAPES: Record = { b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' }; +const jsonUnescapeOnce = (value: string) => value.replace(JSON_ESCAPE, (_, escape: string) => escape.length === 5 ? String.fromCharCode(parseInt(escape.slice(1), 16)) : SHORT_ESCAPES[escape] ?? escape); +/** + * Percent escapes as `decodeURIComponent` and `unescape` decode them, hex + * digits in either case (`%2f`, `%2F`, `%u002F`). Each byte becomes one + * character: both agree on the ASCII characters a secret is made of. + */ +const PERCENT_ESCAPE = /%(?:u[0-9a-fA-F]{4}|[0-9a-fA-F]{2})/g; +const percentDecodeOnce = (value: string) => value.replace(PERCENT_ESCAPE, escape => String.fromCharCode(parseInt(escape.slice(escape[1] === 'u' ? 2 : 1), 16))); +/** The decodings a handler can apply to text, in any order and any number of times. */ +const DECODINGS = [jsonUnescapeOnce, percentDecodeOnce]; +/** Rounds of decoding checked; each nesting level (a JSON document inside a JSON string, a value URL-encoded twice) takes one. */ +const UNESCAPE_ROUNDS = 32; +/** Distinct partly decoded texts followed per round (the decodings taken in different orders); more counts as holding a credential. */ +const DECODED_VARIANTS = 8; + +/** + * Whether `text` (already scrubbed of literal spellings) holds a credential + * once its JSON and percent escapes are decoded, once or repeatedly and in any + * order: `\uXXXX` escapes in HTML or a header, a serialized JSON document + * inside a JSON string, a value URL-encoded twice or a percent escape inside a + * JSON string. Text still decoding after the last round could hide one deeper, + * so counts too. `fold` is applied to each decoded text before comparing + * (header names compare case-insensitively). + */ +function revealsCredential(text: string, spellings: readonly CredentialSpelling[], fold: (value: string) => string = value => value): boolean { + let variants = [text]; + for (let round = 0; round < UNESCAPE_ROUNDS; round++) { + // Every decoding shortens the text, so no variant comes back. + const next = new Set(variants.flatMap(variant => DECODINGS.map(decode => decode(variant)).filter(decoded => decoded !== variant))); + if (!next.size) return false; + if (next.size > DECODED_VARIANTS) return true; + if ([...next].some(variant => holdsSpelling(fold(variant), spellings))) return true; + variants = [...next]; + } + return true; +} + +/** + * Scrub every decoded string (keys included) of a parsed JSON value; `changed` + * when one held a credential. A string that holds one only once decoded again + * (serialized JSON inside the string, a value URL-encoded twice) is withheld + * whole. + */ +function scrubJson(value: unknown, spellings: readonly CredentialSpelling[]): { value: unknown; changed: boolean } { + let changed = false; + const clean = (text: string) => { + const literal = scrub(text, spellings); + const scrubbed = revealsCredential(literal, spellings) ? '[REDACTED]' : literal; + if (scrubbed !== text) changed = true; + return scrubbed; + }; + const walk = (item: unknown): unknown => { + if (typeof item === 'string') return clean(item); + if (Array.isArray(item)) return item.map(walk); + if (item && typeof item === 'object') return Object.fromEntries(Object.entries(item).map(([key, entry]) => [clean(key), walk(entry)])); + return item; + }; + const scrubbed = walk(value); + return { value: scrubbed, changed }; +} + +/** + * The response text with every credential spelling replaced, also where it is + * percent-escaped in either case. A JSON body is also checked after decoding + * (an escaped credential such as `a\"b` decodes to the credential) and + * re-serialized scrubbed when a decoded string held one. Any body (HTML, text + * or JSON) that still holds the credential once its JSON and percent escapes + * are decoded is withheld entirely: a handler could recover it by decoding the + * escaped part itself. + */ +function scrubbedBody(raw: string, spellings: readonly CredentialSpelling[]): string { + const text = scrub(raw, spellings); + if (!spellings.length) return text; + let body = text; + let parsed: unknown; + let json = true; + try { parsed = JSON.parse(text); } catch { json = false; } + if (json) { + try { + const result = scrubJson(parsed, spellings); + if (result.changed) body = JSON.stringify(result.value); + } catch { + // Too deeply nested to check: refuse rather than return what could decode to the credential. + throw new PluginSdkError('http_failed', 'The response could not be checked for credentials'); + } + } + return revealsCredential(body, spellings) ? '[REDACTED]' : body; +} + +/** + * A response header with every credential spelling replaced, also where it is + * percent-escaped in either case. A value that still holds one once its JSON + * and percent escapes are decoded (a JSON-valued header escaping it as + * `\uXXXX`, also inside a longer, joined or re-encoded value, or a value + * URL-encoded twice) is withheld entirely: a handler could recover it with + * `JSON.parse` or `decodeURIComponent`. + */ +function scrubbedHeader(raw: string, spellings: readonly CredentialSpelling[]): string { + const text = scrub(raw, spellings); + if (!spellings.length) return text; + return revealsCredential(text, spellings) ? '[REDACTED]' : text; +} + +/** + * `value` (a log line, its JSON fields, an error message or a JSON result) with + * the credentials in `secrets` scrubbed from every string and key as a response + * is: every spelling replaced, also percent-escaped in either case, and a + * string that still decodes to one withheld whole. + */ +export function scrubCredentialValues(value: T, secrets: readonly string[]): T { + const spellings = uniqueSpellings(secrets.filter(Boolean).flatMap(credentialSpellings)); + return spellings.length ? scrubJson(value, spellings).value as T : value; +} + +const lowerCase = (value: string) => value.toLowerCase(); +/** + * Whether a response header name (lowercased, as the handler sees it) holds a + * credential. Names are case-insensitive and arrive folded to lowercase (Node + * folds them), so an echoed credential, its base64 or its `%2F`-style URL + * encoding, is compared case-insensitively, also once JSON and percent escapes + * are decoded. + * A name cannot be scrubbed in place without colliding with others, so a + * header whose name holds one is dropped. + */ +function nameRevealsCredential(name: string, spellings: readonly CredentialSpelling[]): boolean { + if (!spellings.length) return false; + const folded = spellings.map(spelling => ({ ...spelling, text: lowerCase(spelling.text) })); + return holdsSpelling(name, folded) || revealsCredential(name, folded, lowerCase); +} + +/** The error a request gets once the handler run has ended. */ +const ended = () => new PluginSdkError('unavailable', 'This handler run has ended'); +function checkLive(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw ended(); +} +/** Settle with `work`, or reject as soon as the handler run ends. */ +function untilEnded(work: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) return work; + checkLive(signal); + return new Promise((resolve, reject) => { + const abort = () => reject(ended()); + signal.addEventListener('abort', abort, { once: true }); + work.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); + }); +} + +/** Check scheme, credentials in the URL, port and host against the policy. */ +function vetUrl(value: unknown, outboundHosts: readonly string[]): URL { + if (typeof value !== 'string' || value.length > 8192) throw new PluginSdkError('invalid_input', 'The URL must be text of at most 8192 characters'); + let url: URL; + try { url = new URL(value); } catch { throw new PluginSdkError('invalid_input', 'Invalid URL'); } + if (url.protocol !== 'https:') throw new PluginSdkError('insecure_url', `Only https:// URLs are allowed, not ${url.protocol}`); + if (url.username || url.password) throw new PluginSdkError('invalid_input', 'URLs must not carry credentials'); + if (url.port && url.port !== '443') throw new PluginSdkError('host_not_allowed', 'Only the default HTTPS port is allowed'); + const host = url.hostname.toLowerCase(); + if (isIP(host.replace(/^\[|\]$/g, '')) || !outboundHosts.includes(host)) throw new PluginSdkError('host_not_allowed', `${host} is not one of the plugin's outbound hosts`); + if (extensionReservedHostname(host)) throw new PluginSdkError('private_address', `${host} is a local name`); + return url; +} + +/** Resolve and require every address to be public, like extension endpoints. */ +async function resolvePublic(host: string, lookup: ExtensionLookup): Promise<{ address: string; family: number }> { + let timer: NodeJS.Timeout | undefined; + let addresses: Awaited>; + try { + addresses = await Promise.race([ + lookup(host), + new Promise((_, reject) => { timer = setTimeout(() => reject(new PluginSdkError('http_failed', `DNS lookup of ${host} timed out`)), PLUGIN_HTTP_LIMITS.timeoutMs); timer.unref(); }), + ]); + } catch (error) { + throw error instanceof PluginSdkError ? error : new PluginSdkError('http_failed', `${host} could not be resolved`); + } finally { clearTimeout(timer); } + if (!addresses.length || !addresses.every(a => extensionPublicAddress(a.address))) throw new PluginSdkError('private_address', `${host} must resolve exclusively to public addresses`); + return addresses[0]; +} + +function requestHeaders(init: PluginHttpRequestInit): Record { + const supplied = init.headers ?? {}; + if (typeof supplied !== 'object' || Array.isArray(supplied) || Object.keys(supplied).length > PLUGIN_HTTP_LIMITS.headers) throw new PluginSdkError('invalid_input', `headers must be an object of at most ${PLUGIN_HTTP_LIMITS.headers} entries`); + const headers: Record = {}; + for (const [name, value] of Object.entries(supplied)) { + if (!HEADER_NAME.test(name) || typeof value !== 'string' || !HEADER_VALUE.test(value)) throw new PluginSdkError('invalid_input', `Invalid header ${name.slice(0, 64)}`); + if (RESERVED_HEADERS.test(name)) throw new PluginSdkError('invalid_input', `Header ${name} is set by ProPR`); + headers[name.toLowerCase()] = value; + } + return headers; +} + +function requestBody(init: PluginHttpRequestInit, headers: Record): Buffer | undefined { + if (init.body !== undefined && init.json !== undefined) throw new PluginSdkError('invalid_input', 'Pass either body or json, not both'); + let body: Buffer | undefined; + if (init.json !== undefined) { + body = Buffer.from(JSON.stringify(init.json)); + headers['content-type'] ??= 'application/json'; + } else if (init.body !== undefined) { + if (typeof init.body !== 'string') throw new PluginSdkError('invalid_input', 'body must be text'); + body = Buffer.from(init.body); + } + if (body && body.length > PLUGIN_HTTP_LIMITS.requestBytes) throw new PluginSdkError('invalid_input', `Request bodies are at most ${PLUGIN_HTTP_LIMITS.requestBytes} bytes`); + return body; +} + +/** The default transport: dial only the vetted address with the original TLS identity; no redirects, proxies, cookies or decompression. */ +export const httpsPluginTransport: PluginHttpTransport = request => new Promise((resolve, reject) => { + const host = request.url.hostname; + const failed = (message = `Request to ${host} failed`) => reject(new PluginSdkError('http_failed', message)); + if (request.signal?.aborted) { reject(ended()); return; } + const outgoing = https.request({ + host: request.address, family: request.family, port: 443, path: `${request.url.pathname}${request.url.search}`, + method: request.method, agent: false, servername: host, + lookup: (_host, _options, callback) => callback(null, request.address, request.family), + headers: { ...request.headers, host: request.url.host, ...(request.body ? { 'content-length': String(request.body.length) } : {}) }, + }, response => { + const chunks: Buffer[] = []; let bytes = 0; + response.on('data', (chunk: Buffer) => { + bytes += chunk.length; + if (bytes > request.maxResponseBytes) { response.destroy(); reject(new PluginSdkError('response_too_large', `Responses are at most ${request.maxResponseBytes} bytes`)); } else chunks.push(chunk); + }); + response.on('error', () => failed()); + response.on('end', () => { + const headers: Record = {}; + for (const [name, value] of Object.entries(response.headers)) if (value !== undefined) headers[name] = Array.isArray(value) ? value.join(', ') : value; + resolve({ status: response.statusCode ?? 0, headers, body: Buffer.concat(chunks) }); + }); + }); + const timer = setTimeout(() => { outgoing.destroy(); failed(`Request to ${host} timed out`); }, request.timeoutMs); + // The handler run ended: stop sending and receiving. + const abort = () => { outgoing.destroy(); reject(ended()); }; + request.signal?.addEventListener('abort', abort, { once: true }); + outgoing.on('close', () => { clearTimeout(timer); request.signal?.removeEventListener('abort', abort); }); + outgoing.on('error', () => failed()); + outgoing.end(request.body); +}); + +function bufferedResponse(url: URL, response: PluginHttpTransportResponse, spellings: readonly CredentialSpelling[]): PluginHttpResponse { + const text = scrubbedBody(response.body.toString('utf8'), spellings); + // A header whose name echoes the credential is dropped: the handler can read names as well as values. + const headers = Object.fromEntries(Object.entries(response.headers) + .map(([name, value]) => [name.toLowerCase(), value] as const) + .filter(([name]) => !nameRevealsCredential(name, spellings)) + .map(([name, value]) => [name, scrubbedHeader(value, spellings)])); + return { + status: response.status, ok: response.status >= 200 && response.status < 300, url: url.toString(), headers, + text: async () => text, + json: async () => { + try { return JSON.parse(text) as T; } catch { throw new PluginSdkError('http_failed', 'The response is not JSON'); } + }, + }; +} + +/** + * `ctx.http`: outbound HTTPS to the manifest's `outboundHosts` only, through + * the extension endpoint policy (public addresses only, the checked address + * is the one dialed), with bounded request and response sizes and time. A + * credential is injected as `Authorization: Bearer` and scrubbed from the + * response, so the handler never sees it. + */ +export function createPluginHttp(options: PluginHttpOptions): PluginHttpFetch { + const lookup = options.lookup ?? defaultLookup; + const transport = options.transport ?? httpsPluginTransport; + let credential: Promise | undefined; + return async (value, init = {}) => { + // Only the hostname is ever logged: a refused URL may carry credentials or a query. + let host = '(invalid URL)'; + try { host = new URL(String(value)).hostname || host; } catch { /* stays invalid */ } + try { + const url = vetUrl(value, options.outboundHosts); + const method = (init.method ?? 'GET').toUpperCase(); + if (!METHODS.has(method)) throw new PluginSdkError('invalid_input', `Method ${String(init.method).slice(0, 16)} is not allowed`); + const headers = requestHeaders(init); + const body = requestBody(init, headers); + const { signal } = options; + checkLive(signal); + const { address, family } = await untilEnded(resolvePublic(host, lookup), signal); + credential ??= options.credential ? options.credential() : Promise.resolve(null); + const secret = await untilEnded(credential, signal); + if (secret) headers.authorization = `Bearer ${secret}`; + if (options.authorize) await untilEnded(options.authorize(), signal); + // The run may have ended during the lookups: nothing is sent for an ended handler. + checkLive(signal); + options.onDecision?.({ host, allowed: true }); + const response = await untilEnded(transport({ url, address, family, method, headers, body, timeoutMs: PLUGIN_HTTP_LIMITS.timeoutMs, maxResponseBytes: PLUGIN_HTTP_LIMITS.responseBytes, signal }), signal); + checkLive(signal); + return bufferedResponse(url, response, secret ? credentialSpellings(secret) : []); + } catch (error) { + const refused = error instanceof PluginSdkError ? error : new PluginSdkError('http_failed', `Request to ${host} failed`); + options.onDecision?.({ host, allowed: false, reason: refused.code }); + throw refused; + } + }; +} diff --git a/packages/core/src/plugins/pluginProprBackend.ts b/packages/core/src/plugins/pluginProprBackend.ts new file mode 100644 index 0000000000..07ffd0845b --- /dev/null +++ b/packages/core/src/plugins/pluginProprBackend.ts @@ -0,0 +1,165 @@ +import type { Knex } from 'knex'; +import { PLUGIN_PROPR_OPERATIONS, PluginSdkError, type PluginIssueSummary, type PluginProprOperation, type PluginPullRequestSummary, type PluginTaskSummary, type RepositoryEventDomain } from '@propr/shared'; +import { listRepositoryEvents } from '../services/repositoryEventJournal.js'; +import { generateCorrelationId } from '../utils/logger.js'; +import type { IssueJobData } from '../queue/taskQueue.types.js'; +import type { PluginCommentTarget, PluginProprBackend, PluginProprCallScope } from './pluginProprClient.js'; + +/** The part of the issue task queue the default enqueue uses. */ +export interface PluginIssueQueue { + add(name: string, data: IssueJobData, options: Record): Promise; +} + +/** The part of an authenticated Octokit the backend uses. */ +export interface PluginGitHubClient { + request(route: string, parameters: Record): Promise<{ data: unknown }>; +} +export interface GitHubPluginBackendOptions { + database: Knex; + /** The ProPR GitHub App client; defaults to the configured one. */ + github?: () => Promise; + /** + * Queue an issue task job; defaults to adding it to `issueQueue`, as the task API does. + * Receives the handler run's signal: a job must not be added once it is aborted. + */ + enqueueIssueTask?: (data: IssueJobData, jobId: string, signal?: AbortSignal) => Promise; + /** The issue task queue the default enqueue adds to; defaults to the configured one. */ + issueQueue?: () => Promise; + /** The handler run's signal: once aborted no further GitHub request or queued task starts, and requests in flight are cancelled. */ + signal?: AbortSignal; + /** + * Called right before every GitHub request is sent and every task job is + * added, after the App client or the queue is initialized, with the + * permission and repository the operation acts under: throws to refuse it + * (the plugin's installation was removed, replaced, disabled or restricted + * meanwhile). + */ + authorize?: (call: PluginProprCallScope) => Promise; +} + +type GitHubIssue = { + number: number; title: string; state: string; html_url: string; labels?: Array; + user?: { login?: string } | null; created_at: string; updated_at: string; + /** Present when the number is a pull request: GitHub's issue routes serve pull requests too. */ + pull_request?: unknown; +}; +type GitHubPull = GitHubIssue & { draft?: boolean; merged?: boolean; merged_at?: string | null; head: { ref: string; sha: string }; base: { ref: string } }; + +const split = (repository: string) => { const [owner, repo] = repository.split('/'); return { owner, repo }; }; +const labelNames = (labels: GitHubIssue['labels']) => (labels ?? []).map(label => (typeof label === 'string' ? label : label.name ?? '')).filter(Boolean); +const issueSummary = (repository: string, issue: GitHubIssue): PluginIssueSummary => ({ + repository, number: issue.number, title: issue.title, state: issue.state === 'closed' ? 'closed' : 'open', url: issue.html_url, + labels: labelNames(issue.labels), author: issue.user?.login ?? null, createdAt: issue.created_at, updatedAt: issue.updated_at, +}); + +async function defaultGitHub(): Promise { + const { getAuthenticatedOctokit } = await import('../auth/githubAuth.js'); + return await getAuthenticatedOctokit() as unknown as PluginGitHubClient; +} +async function defaultIssueQueue(): Promise { + // Loaded on use: the queue module connects to Redis, which a handler that never creates a task does not need. + const { getIssueQueue } = await import('../queue/taskQueue.js'); + return await getIssueQueue() as unknown as PluginIssueQueue; +} +function queueEnqueue(issueQueue: () => Promise, authorize: (scope: PluginProprCallScope) => Promise) { + return async (data: IssueJobData, jobId: string, scope: PluginProprCallScope, signal?: AbortSignal): Promise => { + const queue = await issueQueue(); + // The run may have ended, or the plugin been uninstalled, disabled or restricted, while the queue was initialized: no job is added then. + if (signal?.aborted) throw new PluginSdkError('unavailable', 'This handler run has ended'); + await authorize(scope); + await queue.add('processGitHubIssue', data, { jobId, attempts: 3, backoff: { type: 'exponential', delay: 2000 }, removeOnComplete: true, removeOnFail: true }); + }; +} + +/** The latest recorded state of a task. */ +async function taskState(database: Knex, taskId: string): Promise { + const row = await database('task_history').where({ task_id: taskId }).orderBy('history_id', 'desc').first('state'); + return row ? String(row.state) : null; +} + +/** + * The ProPR services `ctx.propr` is built on: GitHub through the ProPR App, + * the repository event journal, the task tables and the issue task queue. + * Unscoped: only `scopedProprClient` calls it, after its checks. + */ +export function githubPluginProprBackend(options: GitHubPluginBackendOptions): PluginProprBackend { + const { database } = options; + const github = options.github ?? defaultGitHub; + const { signal } = options; + const live = () => { if (signal?.aborted) throw new PluginSdkError('unavailable', 'This handler run has ended'); }; + /** The run is live and its installation still installed and authorizing `scope`: checked last before anything is dispatched. */ + const authorized = async (scope: PluginProprCallScope) => { + live(); + if (options.authorize) await options.authorize(scope); + live(); + }; + const custom = options.enqueueIssueTask; + const enqueue = custom + ? async (data: IssueJobData, jobId: string, scope: PluginProprCallScope, runSignal?: AbortSignal) => { await authorized(scope); await custom(data, jobId, runSignal); } + : queueEnqueue(options.issueQueue ?? defaultIssueQueue, authorized); + const request = async (route: string, parameters: Record, scope: PluginProprCallScope) => { + live(); + const client = await github(); + // The run may have ended, or the plugin been uninstalled, disabled or restricted, while the App token was fetched: nothing is sent then. + await authorized(scope); + return (await client.request(route, { ...parameters, ...(signal ? { request: { signal } } : {}) })).data as T; + }; + /** The scope an operation on `repository` dispatches under. */ + const scope = (operation: PluginProprOperation, repository: string): PluginProprCallScope => ({ permission: PLUGIN_PROPR_OPERATIONS[operation], repository }); + /** + * The issue or pull request `number` names, required to be of the kind the + * operation's permission covers: the issue routes accept either, so the + * method alone does not establish the target. Read under the scope of the + * operation it is part of (`operation`). + */ + const issueOfKind = async (repository: string, number: number, kind: PluginCommentTarget, operation: PluginProprOperation): Promise => { + const issue = await request('GET /repos/{owner}/{repo}/issues/{issue_number}', { ...split(repository), issue_number: number }, scope(operation, repository)); + if (Boolean(issue.pull_request) !== (kind === 'pull_request')) { + throw new PluginSdkError('not_found', `${repository}#${number} is not ${kind === 'pull_request' ? 'a pull request' : 'an issue'}`); + } + return issue; + }; + return { + async getIssue(repository, number) { + return issueSummary(repository, await issueOfKind(repository, number, 'issue', 'getIssue')); + }, + async getPullRequest(repository, number): Promise { + const pull = await request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { ...split(repository), pull_number: number }, scope('getPullRequest', repository)); + return { ...issueSummary(repository, pull), draft: Boolean(pull.draft), merged: Boolean(pull.merged ?? pull.merged_at), headRef: pull.head.ref, baseRef: pull.base.ref, headSha: pull.head.sha }; + }, + async getTask(taskId): Promise { + const row = await database('tasks').where({ task_id: taskId }).first('task_id', 'repository', 'task_type', 'issue_number', 'model_name', 'created_at', ...(await database.schema.hasColumn('tasks', 'pr_number') ? ['pr_number'] : [])); + if (!row) return null; + return { + taskId: row.task_id, repository: row.repository, kind: row.task_type, issueNumber: row.issue_number ?? null, prNumber: row.pr_number ?? null, + state: await taskState(database, taskId), model: row.model_name ?? null, createdAt: new Date(row.created_at).toISOString(), + }; + }, + async listEvents(input) { + return listRepositoryEvents(database, { repository: input.repository, afterSeq: input.afterSeq, domains: input.domains as RepositoryEventDomain[] | undefined, limit: input.limit }); + }, + async createIssue(repository, issue) { + const created = await request('POST /repos/{owner}/{repo}/issues', { ...split(repository), title: issue.title, body: issue.body, labels: issue.labels }, scope('createIssue', repository)); + return { repository, number: created.number, url: created.html_url }; + }, + async createTask(repository, issueNumber, attribution) { + const { owner, repo } = split(repository); + const jobId = `issue-${owner}-${repo}-${issueNumber}`; + live(); + await enqueue({ repoOwner: owner, repoName: repo, number: issueNumber, requestedBy: attribution.requestedBy, correlationId: generateCorrelationId() }, jobId, scope('createTask', repository), signal); + return { repository, issueNumber, jobId }; + }, + async comment(repository, number, body, target) { + const operation = target === 'pull_request' ? 'commentOnPullRequest' : 'commentOnIssue'; + await issueOfKind(repository, number, target, operation); + const created = await request<{ id: number; html_url: string }>('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', { ...split(repository), issue_number: number, body }, scope(operation, repository)); + return { repository, number, id: created.id, url: created.html_url }; + }, + async addLabel(repository, number, label) { + await request('POST /repos/{owner}/{repo}/issues/{issue_number}/labels', { ...split(repository), issue_number: number, labels: [label] }, scope('addLabel', repository)); + }, + async removeLabel(repository, number, label) { + await request('DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}', { ...split(repository), issue_number: number, name: label }, scope('removeLabel', repository)); + }, + }; +} diff --git a/packages/core/src/plugins/pluginProprClient.ts b/packages/core/src/plugins/pluginProprClient.ts new file mode 100644 index 0000000000..7d7e38d37c --- /dev/null +++ b/packages/core/src/plugins/pluginProprClient.ts @@ -0,0 +1,208 @@ +import { randomUUID } from 'node:crypto'; +import { + PLUGIN_PROPR_LIMITS, PLUGIN_PROPR_OPERATIONS, PLUGIN_REPOSITORY_PATTERN, PluginSdkError, REPOSITORY_EVENT_DOMAINS, + type PluginCreatedComment, type PluginCreatedIssue, type PluginCreatedTask, type PluginIssueSummary, type PluginListEventsInput, + type PluginPermission, type PluginProprClient, type PluginProprOperation, type PluginPullRequestSummary, type PluginTaskSummary, + type RepositoryEvent, +} from '@propr/shared'; + +/** + * What `ctx.propr` is built on: the ProPR services, unscoped. The scoped + * client checks every call before it reaches the backend; the backend never + * sees a call the installation may not make. + */ +export interface PluginProprBackend { + getPullRequest(repository: string, number: number): Promise; + getIssue(repository: string, number: number): Promise; + /** Null when there is no such task. */ + getTask(taskId: string): Promise; + listEvents(input: Required> & PluginListEventsInput): Promise; + createIssue(repository: string, issue: { title: string; body: string; labels: string[] }): Promise; + /** Queue an ordinary task for an issue; `requestedBy` is recorded on the task. */ + createTask(repository: string, issueNumber: number, attribution: { requestedBy: string }): Promise; + /** Comment on `number`, which must be of the `target` kind; otherwise throws `not_found` and posts nothing. */ + comment(repository: string, number: number, body: string, target: PluginCommentTarget): Promise; + addLabel(repository: string, number: number, label: string): Promise; + removeLabel(repository: string, number: number, label: string): Promise; +} + +/** What a comment operation's permission covers: issues (`issues.comment`) or pull requests (`pull_requests.comment`). */ +export type PluginCommentTarget = 'issue' | 'pull_request'; + +/** + * How far a recorded call got. `attempted`: a mutation passed every check and + * is about to be dispatched; it is recorded before the backend is called. + * `completed`: the backend returned. `refused`: refused, or the backend failed. + */ +export type PluginProprCallOutcome = 'attempted' | 'completed' | 'refused'; + +/** One `ctx.propr` call as recorded in the plugin audit trail. */ +export interface PluginProprCallRecord { + operation: PluginProprOperation; permission: PluginPermission; outcome: PluginProprCallOutcome; + /** Shared by the records of one call: a mutation's attempt and its completion or refusal. */ + callId: string; + repository?: string; number?: number; taskId?: string; + /** Why the call was refused or failed: a `PluginSdkError` code. */ + reason?: string; +} + +/** Operations that change something outside ProPR's audit trail (GitHub, the task queue): recorded as attempted before they are dispatched. */ +export const PLUGIN_PROPR_MUTATIONS: ReadonlySet = new Set(['createIssue', 'createTask', 'commentOnPullRequest', 'commentOnIssue', 'addLabel', 'removeLabel']); + +export interface ScopedProprOptions { + pluginId: string; + grantedPermissions: readonly PluginPermission[]; + /** The installation's repository limit; null for all repositories. */ + repositories: readonly string[] | null; + backend: PluginProprBackend; + /** + * Records the call with the plugin as the actor. A mutation is dispatched + * only once its `attempted` record is written: a failure to write it + * refuses the call. After that, a failure to record the mutation's outcome + * is reported through `onRecordFailure` and does not change the call's + * result (the mutation may have happened; the attempt attributes it). For a + * read, a failure to record fails the call. + */ + record: (call: PluginProprCallRecord) => Promise; + /** Told when the outcome of an already attempted mutation could not be recorded. */ + onRecordFailure?: (call: PluginProprCallRecord, error: unknown) => void; + /** + * Called before every call reaches the backend, after its own checks, with + * the permission the call needs and the repository it names: throws to + * refuse it (the installation the permissions and repository limit were + * read from was removed, replaced, disabled, or no longer grants them). + * `getTask` names no repository until the task is read: it is called again + * then, with the task's repository, before the task is returned. + */ + authorize?: (call: PluginProprCallScope) => Promise; +} + +/** What a dispatch needs the installation to authorize right now: a permission, on a repository when the call names one. */ +export interface PluginProprCallScope { permission: PluginPermission; repository?: string } + +/** The actor ProPR records for anything a plugin's handler does. */ +export const pluginActor = (pluginId: string) => `plugin:${pluginId}`; +/** Appended to text a plugin writes to GitHub, so a comment or issue names the plugin that wrote it. */ +export const pluginAttributionMarker = (pluginId: string) => ``; + +type Target = { repository?: string; number?: number; taskId?: string }; + +function text(value: unknown, what: string, max: number, required = true): string { + if (value === undefined && !required) return ''; + if (typeof value !== 'string' || (required && !value.trim()) || value.length > max) throw new PluginSdkError('invalid_input', `${what} must be ${required ? '1' : '0'} to ${max} characters`); + return value; +} +function positive(value: unknown, what: string): number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) throw new PluginSdkError('invalid_input', `${what} must be a positive whole number`); + return value as number; +} +function labels(value: unknown): string[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > PLUGIN_PROPR_LIMITS.labels) throw new PluginSdkError('invalid_input', `labels must be a list of at most ${PLUGIN_PROPR_LIMITS.labels}`); + return value.map(label => text(label, 'A label', PLUGIN_PROPR_LIMITS.labelChars)); +} + +function eventQuery(input: PluginListEventsInput): Pick { + const { afterSeq, domains } = input; + if (afterSeq !== undefined && (!Number.isSafeInteger(afterSeq) || afterSeq < 0)) throw new PluginSdkError('invalid_input', 'afterSeq must be a whole number of at least 0'); + if (domains !== undefined && (!Array.isArray(domains) || domains.length > REPOSITORY_EVENT_DOMAINS.length || domains.some(d => !(REPOSITORY_EVENT_DOMAINS as readonly string[]).includes(d)))) { + throw new PluginSdkError('invalid_input', `domains must list journal domains: ${REPOSITORY_EVENT_DOMAINS.join(', ')}`); + } + return { ...(afterSeq !== undefined ? { afterSeq } : {}), ...(domains ? { domains: [...domains] } : {}) }; +} + +/** + * `ctx.propr`: the ProPR operations the installation's granted permissions + * allow, on the repositories its limit allows. Every call is recorded with + * the plugin as the actor; a refused call is recorded and throws. + */ +export function scopedProprClient(options: ScopedProprOptions): PluginProprClient { + const { pluginId, backend } = options; + const limit = options.repositories?.map(r => r.toLowerCase()) ?? null; + const withMarker = (body: string) => `${body}\n\n${pluginAttributionMarker(pluginId)}`; + + const repository = (value: unknown): string => { + if (typeof value !== 'string' || !PLUGIN_REPOSITORY_PATTERN.test(value)) throw new PluginSdkError('invalid_input', 'repository must be owner/repo'); + return value; + }; + const allowedRepository = (name: string) => !limit || limit.includes(name.toLowerCase()); + /** The current installation's authority over a task's repository; the refusal names no repository, as the handler did not. */ + const authorizeTaskRepository = async (name: string) => { + if (!options.authorize) return; + try { + await options.authorize({ permission: PLUGIN_PROPR_OPERATIONS.getTask, repository: name }); + } catch (error) { + if (error instanceof PluginSdkError && error.code === 'repository_not_allowed') throw new PluginSdkError('repository_not_allowed', 'This plugin is no longer enabled for the task\'s repository'); + throw error; + } + }; + + async function call(operation: PluginProprOperation, target: () => Target, run: (checked: Target) => Promise): Promise { + const permission = PLUGIN_PROPR_OPERATIONS[operation]; + const callId = randomUUID(); + let checked: Target = {}; + let attempted = false; + let result: T; + const entry = (outcome: PluginProprCallOutcome, reason?: string): PluginProprCallRecord => ({ operation, permission, outcome, callId, ...checked, ...(reason ? { reason } : {}) }); + /** Once a mutation's attempt is recorded, its outcome record is best effort: the attempt already attributes it. */ + const recordOutcome = async (call: PluginProprCallRecord) => { + if (!attempted) return options.record(call); + try { await options.record(call); } catch (error) { options.onRecordFailure?.(call, error); } + }; + try { + if (!options.grantedPermissions.includes(permission)) throw new PluginSdkError('permission_denied', `${operation} needs the ${permission} permission, which is not granted to this plugin`); + checked = target(); + if (checked.repository && !allowedRepository(checked.repository)) throw new PluginSdkError('repository_not_allowed', `This plugin is not enabled for ${checked.repository}`); + // The checks above use the installation as the run started: it must still be that installation, and still authorize this call. + if (options.authorize) await options.authorize({ permission, ...(checked.repository ? { repository: checked.repository } : {}) }); + if (PLUGIN_PROPR_MUTATIONS.has(operation)) { + // Attributed before it is dispatched: a mutation the audit trail cannot name is never sent. + try { await options.record(entry('attempted')); } catch { throw new PluginSdkError('unavailable', `ProPR could not record ${operation}, so it was not sent`); } + attempted = true; + } + result = await run(checked); + } catch (error) { + // A backend failure reaches the handler without its details (they can name other repositories or carry tokens). + const refused = error instanceof PluginSdkError ? error : new PluginSdkError('unavailable', `ProPR could not complete ${operation}`); + await recordOutcome(entry('refused', refused.code)); + throw refused; + } + await recordOutcome(entry('completed')); + return result; + } + + return { + getPullRequest: (repo, number) => call('getPullRequest', () => ({ repository: repository(repo), number: positive(number, 'number') }), + t => backend.getPullRequest(t.repository!, t.number!)), + getIssue: (repo, number) => call('getIssue', () => ({ repository: repository(repo), number: positive(number, 'number') }), + t => backend.getIssue(t.repository!, t.number!)), + getTask: taskId => call('getTask', () => ({ taskId: text(taskId, 'taskId', 255) }), async t => { + const task = await backend.getTask(t.taskId!); + if (!task) throw new PluginSdkError('not_found', 'Task not found'); + // The repository is known only once the task is read; a task outside the limit is not returned. + if (!allowedRepository(task.repository)) throw new PluginSdkError('repository_not_allowed', 'This plugin is not enabled for the task\'s repository'); + // The call's authorization named no repository (none was known): the installation must still allow this one now, not only as the run started. + await authorizeTaskRepository(task.repository); + return task; + }), + listEvents: input => call('listEvents', () => ({ repository: repository(input?.repository) }), t => backend.listEvents({ + repository: t.repository!, ...eventQuery(input), + limit: Math.min(Math.max(Math.trunc(Number(input.limit ?? PLUGIN_PROPR_LIMITS.events)) || 1, 1), PLUGIN_PROPR_LIMITS.events), + })), + createIssue: (repo, issue) => call('createIssue', () => ({ repository: repository(repo) }), t => backend.createIssue(t.repository!, { + title: text(issue?.title, 'title', PLUGIN_PROPR_LIMITS.titleChars), + body: withMarker(text(issue?.body, 'body', PLUGIN_PROPR_LIMITS.bodyChars, false)), + labels: labels(issue?.labels), + })), + createTask: (repo, issueNumber) => call('createTask', () => ({ repository: repository(repo), number: positive(issueNumber, 'issueNumber') }), + t => backend.createTask(t.repository!, t.number!, { requestedBy: pluginActor(pluginId) })), + commentOnPullRequest: (repo, number, body) => call('commentOnPullRequest', () => ({ repository: repository(repo), number: positive(number, 'number') }), + t => backend.comment(t.repository!, t.number!, withMarker(text(body, 'body', PLUGIN_PROPR_LIMITS.bodyChars)), 'pull_request')), + commentOnIssue: (repo, number, body) => call('commentOnIssue', () => ({ repository: repository(repo), number: positive(number, 'number') }), + t => backend.comment(t.repository!, t.number!, withMarker(text(body, 'body', PLUGIN_PROPR_LIMITS.bodyChars)), 'issue')), + addLabel: (repo, number, label) => call('addLabel', () => ({ repository: repository(repo), number: positive(number, 'number') }), + t => backend.addLabel(t.repository!, t.number!, text(label, 'label', PLUGIN_PROPR_LIMITS.labelChars))), + removeLabel: (repo, number, label) => call('removeLabel', () => ({ repository: repository(repo), number: positive(number, 'number') }), + t => backend.removeLabel(t.repository!, t.number!, text(label, 'label', PLUGIN_PROPR_LIMITS.labelChars))), + }; +} diff --git a/packages/core/src/plugins/pluginState.ts b/packages/core/src/plugins/pluginState.ts new file mode 100644 index 0000000000..a8b953b1b8 --- /dev/null +++ b/packages/core/src/plugins/pluginState.ts @@ -0,0 +1,112 @@ +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 => { + // 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 && connection.type !== 'oauth' ? '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/pluginStateStore.ts b/packages/core/src/plugins/pluginStateStore.ts new file mode 100644 index 0000000000..6f1ef8c390 --- /dev/null +++ b/packages/core/src/plugins/pluginStateStore.ts @@ -0,0 +1,132 @@ +import type { Knex } from 'knex'; +import { PLUGIN_STATE_LIMITS, PluginSdkError, type PluginJsonValue, type PluginPermission, type PluginStateStore } from '@propr/shared'; + +export const PLUGIN_STATE_TABLE = 'plugin_state'; +const KEY_PATTERN = /^[\x21-\x7e]+$/; + +function checkKey(key: unknown): string { + if (typeof key !== 'string' || !key.length || key.length > PLUGIN_STATE_LIMITS.keyLength || !KEY_PATTERN.test(key)) { + throw new PluginSdkError('invalid_input', `State keys are 1 to ${PLUGIN_STATE_LIMITS.keyLength} printable characters without spaces`); + } + return key; +} + +/** Serialize a value, refusing anything that is not plain JSON (a function, a cycle, NaN, a class instance). */ +function serialize(value: unknown): string { + const plain = (item: unknown, depth: number): boolean => { + if (depth > 32) return false; + if (item === null || typeof item === 'string' || typeof item === 'boolean') return true; + if (typeof item === 'number') return Number.isFinite(item); + if (Array.isArray(item)) return item.every(entry => plain(entry, depth + 1)); + if (typeof item === 'object' && Object.getPrototypeOf(item) === Object.prototype) return Object.values(item as object).every(entry => plain(entry, depth + 1)); + return false; + }; + if (!plain(value, 0)) throw new PluginSdkError('invalid_input', 'State values must be plain JSON: objects, arrays, text, finite numbers, booleans or null'); + return JSON.stringify(value); +} + +export interface PluginStateWriteGuard { + /** The installation the state belongs to (its `installed_at`); every operation is refused once it is uninstalled or replaced. */ + installedAt: number; + /** The handler run's signal: an operation that has not committed when the run ends is refused and rolled back. */ + signal?: AbortSignal; +} + +const isSqlite = (database: Knex) => ['better-sqlite3', 'sqlite3'].includes(String(database.client.config.client)); + +function requireLive(guard: PluginStateWriteGuard): void { + if (guard.signal?.aborted) throw new PluginSdkError('unavailable', 'This handler run has ended'); +} + +/** What an installation currently authorizes, as an administrator last set it. */ +export interface PluginInstallationAuthority { + enabled: boolean; + grantedPermissions: PluginPermission[]; + /** The repository limit (`owner/repo`); null for all repositories. */ + repositories: string[] | null; +} +type AuthorityRow = { installed_at: number | string; enabled: number | boolean; granted_permissions: string | null; repositories: string | null }; +const json = (value: string | null, fallback: T): T => { + if (value === null || value === undefined) return fallback; + try { return JSON.parse(value) as T; } catch { return fallback; } +}; + +/** + * Inside a state operation's transaction: require the installation to still + * exist (the same one, not a reinstall) and keep it until commit. Uninstall + * deletes the installation row before the state rows, so an operation either + * commits first (a write is then deleted with the rest), or finds the + * installation gone. SQLite runs write transactions one at a time. Returns + * what the installation authorizes right now (enabled, grants, repository + * limit), read in the same transaction, for a caller about to act on it. + */ +export async function requireInstallation(trx: Knex.Transaction, pluginId: string, guard: PluginStateWriteGuard): Promise { + const query = trx('plugin_installations').where({ plugin_id: pluginId }); + const row = await (isSqlite(trx) ? query : query.forShare()).first('installed_at', 'enabled', 'granted_permissions', 'repositories') as AuthorityRow | undefined; + if (!row || Number(row.installed_at) !== guard.installedAt) throw new PluginSdkError('unavailable', 'The plugin is no longer installed'); + requireLive(guard); + const permissions = json(row.granted_permissions, []); + const repositories = json(row.repositories, null); + return { + enabled: Boolean(row.enabled), + grantedPermissions: Array.isArray(permissions) ? permissions.filter((p): p is PluginPermission => typeof p === 'string') : [], + repositories: Array.isArray(repositories) ? repositories.filter((r): r is string => typeof r === 'string') : null, + }; +} + +/** + * `ctx.state`: JSON values per installation in `plugin_state`, with a key + * count, a per-value size and a total size limit. A write that would exceed + * a limit throws `state_limit` and changes nothing. With a `guard`, every + * operation (read, write, delete, list) runs in a transaction that requires + * its installation to still exist, and is refused (a change rolled back) + * when the handler run ends before it commits. + */ +export function pluginStateStore(database: Knex, pluginId: string, now: () => number = Date.now, guard?: PluginStateWriteGuard): PluginStateStore { + const fenced = (run: (trx: Knex.Transaction) => Promise): Promise => database.transaction(async trx => { + if (guard) await requireInstallation(trx, pluginId, guard); + const result = await run(trx); + // Cancellation observed during the operation: throwing rolls a change back. + if (guard) requireLive(guard); + return result; + }); + const rows = (trx: Knex.Transaction) => trx(PLUGIN_STATE_TABLE).where({ plugin_id: pluginId }); + return { + async get(key: string): Promise { + const stateKey = checkKey(key); + const row = await fenced(trx => rows(trx).where({ state_key: stateKey }).first('value')); + return row ? JSON.parse(row.value as string) as T : undefined; + }, + async set(key: string, value: PluginJsonValue): Promise { + const stateKey = checkKey(key); + const text = serialize(value); + const bytes = Buffer.byteLength(text); + if (bytes > PLUGIN_STATE_LIMITS.valueBytes) throw new PluginSdkError('state_limit', `A state value is at most ${PLUGIN_STATE_LIMITS.valueBytes} bytes; this one is ${bytes}`); + await fenced(async trx => { + // The other keys' count and size: a rewrite of a key replaces its own bytes. + const totals = await rows(trx).whereNot({ state_key: stateKey }) + .first(trx.raw('COUNT(*) AS count'), trx.raw('COALESCE(SUM(bytes), 0) AS total')) as { count: number | string; total: number | string } | undefined; + if (guard) requireLive(guard); + const count = Number(totals?.count ?? 0); const total = Number(totals?.total ?? 0); + if (count + 1 > PLUGIN_STATE_LIMITS.keys) throw new PluginSdkError('state_limit', `The plugin state holds at most ${PLUGIN_STATE_LIMITS.keys} keys`); + if (total + bytes > PLUGIN_STATE_LIMITS.totalBytes) throw new PluginSdkError('state_limit', `The plugin state holds at most ${PLUGIN_STATE_LIMITS.totalBytes} bytes`); + await trx(PLUGIN_STATE_TABLE).insert({ plugin_id: pluginId, state_key: stateKey, value: text, bytes, updated_at: now() }) + .onConflict(['plugin_id', 'state_key']).merge(); + }); + }, + async delete(key: string): Promise { + const stateKey = checkKey(key); + return (await fenced(trx => rows(trx).where({ state_key: stateKey }).delete())) > 0; + }, + async keys(prefix = ''): Promise { + if (typeof prefix !== 'string') throw new PluginSdkError('invalid_input', 'The key prefix must be text'); + const list = await fenced(trx => rows(trx).orderBy('state_key').pluck('state_key')) as string[]; + return list.filter(key => key.startsWith(prefix)); + }, + }; +} + +/** Delete every state value of a plugin: on uninstall. */ +export async function deletePluginState(database: Knex, pluginId: string): Promise { + return database(PLUGIN_STATE_TABLE).where({ plugin_id: pluginId }).delete(); +} diff --git a/packages/core/src/plugins/pluginStore.ts b/packages/core/src/plugins/pluginStore.ts new file mode 100644 index 0000000000..f9fc2db4d5 --- /dev/null +++ b/packages/core/src/plugins/pluginStore.ts @@ -0,0 +1,408 @@ +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 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 { discardOAuthConnectionsOf, oauthConnectionStates } from '../extensions/extensionOAuthState.js'; +import { PluginContributions, oauthServersOf, pluginContributionViews, type PluginOAuthClient } from './pluginContributions.js'; +import { pluginTemplateIds } from './pluginTemplates.js'; +import { deletePluginState } from './pluginStateStore.js'; +import { deletePluginDispatch, restartPluginDispatch } from './pluginDeliveries.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; +/** 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`. 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. + 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; + } + 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): Promise { + const pluginId = PluginStore.pluginId(id); + await this.database.transaction(async trx => { + await this.authorize(actor, trx); + 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); + }); + 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 { + 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 { 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; + } + /** 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 view = await this.view(pluginId, await this.load(pluginId, this.database)); + const audit = await this.auditEvents(pluginId); + await this.authorize(actor); + 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'); + 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, + }); + // Event subscriptions start at the journal's current end: history is not replayed. + await restartPluginDispatch(trx, pluginId, now, 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 }, resetRepositories: true }; + }); + } + + /** 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, '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(); + 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 } }; + }); + } + + /** + * 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'); + 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) }); + // Events journaled while the plugin was disabled are not delivered. + if (enabled && !row.enabled) await restartPluginDispatch(trx, pluginId, Number(row.installed_at), this.now()); + // 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 }; + }); + } + + /** 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, its connections and its handler state (`ctx.state`). `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(); + // The installation row first: a concurrent ctx.state write holds it until it commits (so its row is deleted below) or finds it gone. + await trx('plugin_installations').where({ plugin_id: pluginId }).delete(); + const state = await deletePluginState(trx, pluginId); + await deletePluginDispatch(trx, pluginId); + return { action: 'uninstall', detail: { version: row.manifest_version, stateKeys: state } }; + }); + } + + /** Core-internal: the stored secret of a connection, for applying contributions. Never exposed through the API. */ + 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(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 is claimed; any other single-use state is left unconsumed. + const { name: server } = await viaLibrary(() => extensionOAuth(this.library).completeSignIn(actor, sessionId, query, target.servers.map(s => s.name))); + 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 { + 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/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/src/queue/taskQueue.types.ts b/packages/core/src/queue/taskQueue.types.ts index 07513319a2..54b5711c82 100644 --- a/packages/core/src/queue/taskQueue.types.ts +++ b/packages/core/src/queue/taskQueue.types.ts @@ -41,6 +41,8 @@ export interface IssueJobData extends RepositoryWorkflowDeferralData, AgentCapac repositoryWorkflowDeferred?: boolean; /** Stable GitHub user ID when a verified triggering recipient is known. */ userId?: string; + /** Who asked for the task when it was not a GitHub user: `plugin:` for a task a plugin handler created. */ + requestedBy?: string; /** Authenticated user retrying failed submission-backed work; owns that retry instead of the submitter. */ retryRequesterId?: string; /** Issue comment carrying a requested retry's instructions; posted by the app, so exempt from author filtering. */ diff --git a/packages/core/src/services/repositoryEventJournal.ts b/packages/core/src/services/repositoryEventJournal.ts index d71fe6f233..6a23dc185e 100644 --- a/packages/core/src/services/repositoryEventJournal.ts +++ b/packages/core/src/services/repositoryEventJournal.ts @@ -245,6 +245,8 @@ function fromRow(row: RepositoryEventRow): RepositoryEvent { export interface ListRepositoryEventsInput { repository: string; + /** Match every spelling of the repository (`COLLATE NOCASE`) as one stream; exact by default. */ + anyCase?: boolean; /** Exclusive cursor; 0 reads from the start of the retained journal. */ afterSeq?: number; domains?: readonly RepositoryEventDomain[]; @@ -257,7 +259,8 @@ const MAX_LIST_LIMIT = 500; /** Events of one repository after a cursor, in `seq` order. */ export async function listRepositoryEvents(database: Knex, input: ListRepositoryEventsInput): Promise { const limit = Math.min(Math.max(Math.trunc(input.limit ?? 100), 1), MAX_LIST_LIMIT); - const query = database('repository_events').where('repository', input.repository) + const query = (input.anyCase ? database('repository_events').whereRaw('repository = ? COLLATE NOCASE', [input.repository]) + : database('repository_events').where('repository', input.repository)) .where('seq', '>', Math.max(0, Math.trunc(input.afterSeq ?? 0))); const domains = (input.domains ?? []).filter(domain => REPOSITORY_EVENT_DOMAINS.includes(domain)); if (domains.length) query.whereIn('domain', domains); diff --git a/packages/core/src/services/taskIntent.ts b/packages/core/src/services/taskIntent.ts index 3eb98c86ec..ba34014a96 100644 --- a/packages/core/src/services/taskIntent.ts +++ b/packages/core/src/services/taskIntent.ts @@ -4,7 +4,7 @@ import { getAuthenticatedOctokit } from '../auth/githubAuth.js'; import { loadPrimaryProcessingLabels } from '../config/configManager.js'; import { getIssueQueue } from '../queue/taskQueue.js'; import { getStateManager } from '../utils/workerStateManager.js'; -import { isBookkeepingCancellation, type IssueRef, type TaskStateData, type TaskTerminalReason } from '../utils/workerStateManager.types.js'; +import { isBookkeepingCancellation, type TaskStateData, type TaskTerminalReason } from '../utils/workerStateManager.types.js'; import { db } from '../db/connection.js'; import logger from '../utils/logger.js'; import { withRetry, retryConfigs } from '../utils/retryHandler.js'; @@ -13,14 +13,10 @@ import { WITHDRAWAL_CLEANUP_KEY, openIssueCleanupStep, releaseWithdrawalCleanup, export { releaseWithdrawalCleanup } from './withdrawalCleanup.js'; import { stopTaskExecution, type StopTaskRedisClient } from './taskCancellation.js'; +import { taskIntentIssueRef, type IntentTarget } from './taskIntentIssueRef.js'; +export { taskIntentIssueRef, type IntentTarget } from './taskIntentIssueRef.js'; + export type IntentCancellationReason = 'cancelled_issue_closed' | 'cancelled_label_removed' | 'cancelled_pr_closed'; -export interface IntentTarget { - repoOwner: string; - repoName: string; - number: number; - kind: 'issue' | 'pr'; - triggeringLabel?: string; -} export function taskIntentTarget(data: Record, type = typeof data.type === 'string' ? data.type : undefined): IntentTarget | null { // Goals, imports and system maintenance are not issue implementations. @@ -35,18 +31,6 @@ export function taskIntentTarget(data: Record, type = typeof da return { repoOwner, repoName, number, kind, ...(typeof data.triggeringLabel === 'string' ? { triggeringLabel: data.triggeringLabel } : {}) }; } -/** Persist only the small reference needed for intent, display and task identity. */ -export function taskIntentIssueRef(data: object, target: IntentTarget): IssueRef { - const fields = data as Record; - return { - number: target.number, repoOwner: target.repoOwner, repoName: target.repoName, - type: target.kind === 'pr' ? 'pr-comment' : 'issue', - ...(target.kind === 'pr' ? { pullRequestNumber: target.number } : {}), - ...Object.fromEntries(['triggeringLabel', 'modelName', 'agentAlias', 'correlationId'].flatMap(key => - typeof fields[key] === 'string' ? [[key, fields[key]]] : [])), - }; -} - async function readCurrentTaskIntent(target: IntentTarget): Promise { const octokit = await getAuthenticatedOctokit(); const response = await withRetry<{ data: CurrentTaskIntent }>(() => target.kind === 'pr' diff --git a/packages/core/src/services/taskIntentIssueRef.ts b/packages/core/src/services/taskIntentIssueRef.ts new file mode 100644 index 0000000000..78a2bb8153 --- /dev/null +++ b/packages/core/src/services/taskIntentIssueRef.ts @@ -0,0 +1,25 @@ +import type { IssueRef } from '../utils/workerStateManager.types.js'; + +/** The issue or pull request a task acts on. */ +export interface IntentTarget { + repoOwner: string; + repoName: string; + number: number; + kind: 'issue' | 'pr'; + triggeringLabel?: string; +} + +/** The job data fields a task keeps in its state; `requestedBy` names a plugin that created the task. */ +export const TASK_INTENT_CARRIED_FIELDS = ['triggeringLabel', 'modelName', 'agentAlias', 'correlationId', 'requestedBy'] as const; + +/** Persist only the small reference needed for intent, display and task identity. */ +export function taskIntentIssueRef(data: object, target: IntentTarget): IssueRef { + const fields = data as Record; + return { + number: target.number, repoOwner: target.repoOwner, repoName: target.repoName, + type: target.kind === 'pr' ? 'pr-comment' : 'issue', + ...(target.kind === 'pr' ? { pullRequestNumber: target.number } : {}), + ...Object.fromEntries(TASK_INTENT_CARRIED_FIELDS.flatMap(key => + typeof fields[key] === 'string' ? [[key, fields[key]]] : [])), + }; +} diff --git a/packages/core/src/services/taskJournalEvents.ts b/packages/core/src/services/taskJournalEvents.ts new file mode 100644 index 0000000000..171c319c5b --- /dev/null +++ b/packages/core/src/services/taskJournalEvents.ts @@ -0,0 +1,192 @@ +/** + * The `task` domain of the repository event journal. + * + * A task appends `queued` when its state is created, `started` when it starts + * running (again, after a retry or a reopened goal), and one terminal event, + * `completed`, `failed` or `cancelled`, when it ends. Events are appended by + * the task state manager right where it records the state change in + * `task_history`, so every job path journals the same way. + * + * Payloads are built from identifiers and coded fields of the task state only: + * never the reason text, error message, agent result, prompt, tool output, + * comment bodies or notification recaps that the state also carries. + * + * Appending never fails or delays the task beyond one local insert: the + * journal logs its own failures, and anything thrown while building the event + * is caught here. + */ + +import type { Knex } from 'knex'; +import { + taskEventKind, + type TaskEventPayload, + type TaskEventPullRequestOutcome, + type TaskEventType, + type TaskTerminalEventPayload, +} from '@propr/shared'; +import logger from '../utils/logger.js'; +import { db as defaultDb } from '../db/connection.js'; +import { appendRepositoryEvent, type RepositoryEventInput } from './repositoryEventJournal.js'; +import { isBookkeepingCancellation, type HistoryEntry, type TaskState, type TaskStateData, type UpdateMetadata } from '../utils/workerStateManager.types.js'; + +const RUNNING_STATES = new Set(['processing', 'claude_execution', 'post_processing']); +const TERMINAL_STATES = new Set(['completed', 'failed', 'cancelled']); +const PULL_REQUEST_KINDS = new Set(['follow-up', 'review', 'merge-conflict']); +const IDENTIFIER_MAX_CHARS = 100; +/** A coded category: lower-case words joined by `_`, `-` or `.`. Anything else is free text and is dropped. */ +const CODE_PATTERN = /^[a-z][a-z0-9_.-]{0,63}$/; + +/** The parts of a state transition the journal needs. */ +export interface TaskJournalTransition { + state: TaskStateData; + /** Null when the transition creates the task state. */ + previousState: TaskState | null; + /** The previous state was a queue handoff (`requeued` / `rescheduled`), not a real cancellation. */ + previousWasBookkeeping?: boolean; +} + +const positiveInteger = (value: unknown): number | null => + typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : null; + +const identifier = (value: unknown): string | null => + typeof value === 'string' && value.trim() ? value.trim().slice(0, IDENTIFIER_MAX_CHARS) : null; + +const code = (value: unknown): string | null => + typeof value === 'string' && CODE_PATTERN.test(value) ? value : null; + +function basePayload(state: TaskStateData): { repository: string; payload: TaskEventPayload } | null { + const { issueRef } = state; + const kind = taskEventKind(issueRef?.type); + if (!kind) return null; + const owner = identifier(issueRef.repoOwner); + const repo = identifier(issueRef.repoName); + if (!owner || !repo || owner === 'unknown' || repo === 'unknown') return null; + const number = positiveInteger(issueRef.pullRequestNumber) ?? positiveInteger(issueRef.number); + const isPullRequest = PULL_REQUEST_KINDS.has(kind); + return { + repository: `${owner}/${repo}`, + payload: { + taskId: state.taskId, + kind, + issueNumber: isPullRequest ? null : number, + prNumber: isPullRequest ? number : null, + agent: identifier(issueRef.agentAlias) ?? identifier(issueRef.agent), + model: identifier(issueRef.modelName), + attempts: Number.isSafeInteger(state.attempts) ? state.attempts : 0, + }, + }; +} + +/** The journal event a transition appends, or null when it appends none. */ +export function taskEventType(transition: TaskJournalTransition): TaskEventType | null { + const { state, previousState } = transition; + if (state.state === previousState) return null; + if (state.state === 'pending') return 'queued'; + if (TERMINAL_STATES.has(state.state)) { + // A queue handoff records `cancelled` without a reason and is resumed + // later: the task did not end, so it is not journaled. + if (isBookkeepingCancellation(state)) return null; + return state.state as TaskEventType; + } + if (!RUNNING_STATES.has(state.state)) return null; + const startsRun = previousState === 'pending' + || (previousState !== null && TERMINAL_STATES.has(previousState) && !transition.previousWasBookkeeping); + return startsRun ? 'started' : null; +} + +/** A history entry recording a queue handoff (`requeued` / `rescheduled`), judged like the live state is. */ +function isBookkeepingEntry(entry: HistoryEntry): boolean { + const terminalReason = entry.metadata?.terminalReason; + return isBookkeepingCancellation({ + state: entry.state, + terminalReason: typeof terminalReason === 'string' ? terminalReason as TaskStateData['terminalReason'] : undefined, + history: [entry], + }); +} + +/** + * Milliseconds from the latest start of a run to the state's last update. A + * run continues through running states and through queue handoffs, matching + * `taskEventType`, which appends no `started` when a handoff resumes; a + * resumption after a real terminal state (a retry) starts a new run. + */ +function runDurationMs(state: TaskStateData): number | null { + const history = state.history ?? []; + for (let index = history.length - 1; index >= 0; index--) { + const entry = history[index]; + if (!RUNNING_STATES.has(entry.state)) continue; + const before = history[index - 1]; + if (before && (RUNNING_STATES.has(before.state) || isBookkeepingEntry(before))) continue; + const elapsed = Date.parse(state.updatedAt) - Date.parse(entry.timestamp); + return Number.isFinite(elapsed) && elapsed >= 0 ? elapsed : null; + } + return null; +} + +function pullRequestOutcome(state: TaskStateData, kind: string, metadata: UpdateMetadata): TaskEventPullRequestOutcome { + if (state.state !== 'completed' && state.state !== 'failed') return null; + const prResult = state.prResult ?? {}; + const continuation = metadata.historyMetadata?.continuation; + if (prResult.prCreated === true || (kind === 'implementation' && positiveInteger(prResult.prNumber)) + || (continuation && typeof continuation === 'object')) return 'opened'; + if (typeof metadata.commitHash === 'string' && metadata.commitHash) return 'updated'; + return null; +} + +/** The failure category of a failed task: its coded terminal reason, else its recorded error category. */ +function failureCategory(state: TaskStateData, metadata: UpdateMetadata): string | null { + if (state.state !== 'failed') return null; + return code(state.terminalReason) ?? code(metadata.error?.category) ?? code(metadata.errorCategory) + ?? code(state.lastError?.category) ?? 'unknown'; +} + +/** Build the journal event of a task state transition (creation included), or null when none is due. */ +export function buildTaskJournalEvent(transition: TaskJournalTransition, metadata: UpdateMetadata = {}): RepositoryEventInput | null { + const type = taskEventType(transition); + if (!type) return null; + const { state } = transition; + const base = basePayload(state); + if (!base) return null; + let payload: TaskEventPayload | TaskTerminalEventPayload = base.payload; + if (type === 'completed' || type === 'failed' || type === 'cancelled') { + payload = { + ...base.payload, + status: type, + terminalReason: code(state.terminalReason), + durationMs: runDurationMs(state), + pullRequest: pullRequestOutcome(state, base.payload.kind, metadata), + failureCategory: failureCategory(state, metadata), + } satisfies TaskTerminalEventPayload; + } + return { + repository: base.repository, + domain: 'task', + type, + entityId: state.taskId, + issueNumber: base.payload.issueNumber, + prNumber: base.payload.prNumber, + payload: { ...payload }, + // Each transition writes a new version, so a republished transition appends nothing. + dedupeKey: `task:${state.taskId}:${type}:v${state.version ?? 0}`, + occurredAt: state.updatedAt, + }; +} + +/** + * Append the journal event of a task state transition. Never throws: a failed + * append is logged (by the journal, or here) and the task carries on. + */ +export async function appendTaskJournalEvent( + transition: TaskJournalTransition, metadata: UpdateMetadata = {}, database: Knex = defaultDb, +): Promise { + try { + const event = buildTaskJournalEvent(transition, metadata); + if (!event) return; + await appendRepositoryEvent(database, event); + } catch (error) { + try { + logger.warn({ taskId: transition?.state?.taskId, error: (error as Error)?.message ?? String(error) }, + 'Failed to journal task state change; continuing without it'); + } catch { /* logging never affects the task either */ } + } +} 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/src/utils/workerStateManager.ts b/packages/core/src/utils/workerStateManager.ts index 9cf5d5aa74..bab1ce92b3 100644 --- a/packages/core/src/utils/workerStateManager.ts +++ b/packages/core/src/utils/workerStateManager.ts @@ -4,14 +4,14 @@ import { db } from '../db/connection.js'; import { pullRequestNumberOfTask, requestPullRequestStatusSync } from '../services/pullRequestStatus/pullRequestStatusSignal.js'; import type { Logger } from 'pino'; import { - TaskStates, isBookkeepingCancellation, type TaskState, type IssueRef, type TaskStateData, type UpdateMetadata, + TaskStates, isBookkeepingCancellation, taskCreationMetadata, type TaskState, type IssueRef, type TaskStateData, type UpdateMetadata, type TaskResult, type TaskTerminalReason, type ResumableTaskInfo, type TaskStateExpectation, type NonTerminalTaskScanResult, type TaskStateUpdateResult, type WorkerStateManagerOptions } from './workerStateManager.types.js'; import { getEventPublisher } from './eventPublisher.js'; import { buildTaskStateTransition, buildTaskStateMutation, compareAndSetTaskStateData, - compareAndSetTaskState, publishTaskStateTransition, + compareAndSetTaskState, publishTaskStateTransition, journalTaskStateCreation, } from './workerStateTransition.js'; import { scanNonTerminalTaskStates } from './workerStateScan.js'; @@ -108,7 +108,7 @@ export class WorkerStateManager { taskId, issueRef, correlationId: correlationId ?? generateCorrelationId(), state: TaskStates.PENDING, createdAt: timestamp, updatedAt: timestamp, version: 1, attempts: 0, - history: [{ state: TaskStates.PENDING, timestamp, reason: 'Task created' }] + history: [{ state: TaskStates.PENDING, timestamp, reason: 'Task created', ...taskCreationMetadata(issueRef) }] }; } @@ -141,15 +141,15 @@ export class WorkerStateManager { await db('tasks').insert(taskData).onConflict('task_id').ignore(); const historyData = { task_id: taskId, state: TaskStates.PENDING, - timestamp: state.createdAt, reason: 'Task created', metadata: JSON.stringify({}) + timestamp: state.createdAt, reason: 'Task created', metadata: JSON.stringify(taskCreationMetadata(issueRef).metadata ?? {}) }; await db('task_history').insert(historyData); correlatedLogger.debug({ taskId }, 'Task state persisted to database'); + await journalTaskStateCreation(state); requestPullRequestStatusSync(repository, pullRequestNumberOfTask(issueRef)); // Publish real-time event for task creation - const eventPublisher = getEventPublisher(); - await eventPublisher.publishTaskUpdate({ + await getEventPublisher().publishTaskUpdate({ taskId, state: TaskStates.PENDING, repository, diff --git a/packages/core/src/utils/workerStateManager.types.ts b/packages/core/src/utils/workerStateManager.types.ts index 120df90b9f..125a083079 100644 --- a/packages/core/src/utils/workerStateManager.types.ts +++ b/packages/core/src/utils/workerStateManager.types.ts @@ -155,3 +155,8 @@ export function isBookkeepingCancellation(task: Pick { + await appendTaskJournalEvent({ state, previousState: null }); +} + const TERMINAL_STATES = new Set(['completed', 'failed', 'cancelled']); const PULL_REQUEST_TASK_TYPES = new Set(['pr-comment', 'review', 'merge_conflict']); @@ -225,6 +234,9 @@ export async function publishTaskStateTransition( }, 'Failed to persist task state update to database'); } + // Never throws; a failed append leaves the task's own outcome unchanged. + await appendTaskJournalEvent(transition, metadata); + try { publication.eventPublished = await getEventPublisher().publishTaskUpdate({ taskId, 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/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/extensionOAuth.test.ts b/packages/core/test/extensionOAuth.test.ts index bfab319aa2..4cd6254196 100644 --- a/packages/core/test/extensionOAuth.test.ts +++ b/packages/core/test/extensionOAuth.test.ts @@ -155,6 +155,12 @@ test('PKCE mismatch, unknown, reused or expired state, another session or admini await assert.rejects(f.oauth.startSignIn('2', 'session-a', 'docs', OAUTH_URLS), /administrator/); await assert.rejects(f.oauth.startSignIn('1', 'session-a', 'docs', { redirectUri: null, clientMetadataUrl: null }), /public HTTPS base URL/); + // Restricted to other items (a plugin connection's servers): refused without consuming the state. + const restricted = await f.consent(); + await assert.rejects(f.oauth.completeSignIn('1', 'session-a', restricted.params, ['other']), /Unknown, used or expired/); + await empty(); + assert.deepEqual(await f.oauth.completeSignIn('1', 'session-a', restricted.params, ['docs']), { name: 'docs' }); + const reused = await f.consent(); await f.oauth.completeSignIn('1', 'session-a', reused.params); await assert.rejects(f.oauth.completeSignIn('1', 'session-a', reused.params), /Unknown, used or expired/); diff --git a/packages/core/test/fixtures/plugins/fixture-events/handlers.ts b/packages/core/test/fixtures/plugins/fixture-events/handlers.ts new file mode 100644 index 0000000000..70a7692f6e --- /dev/null +++ b/packages/core/test/fixtures/plugins/fixture-events/handlers.ts @@ -0,0 +1,39 @@ +import type { PluginContext, PluginEventInput } from '@propr/shared'; + +/** + * Test fixture handlers for packages/core/test/pluginDispatcher.test.ts. Each + * delivery is appended to `ctx.state` key `seen` (delivery id, seq, attempt), + * so a test sees what was delivered, in which order and how often. Imports + * only @propr/shared types. + */ +type Seen = { id: string; seq: number; repository: string; attempt: number }[]; + +async function remember(input: PluginEventInput, ctx: PluginContext) { + const seen = (await ctx.state.get('seen')) ?? []; + seen.push({ id: input.deliveryId, seq: input.event.seq, repository: input.event.repository, attempt: input.attempt }); + await ctx.state.set('seen', seen); +} + +export async function record(input: PluginEventInput, ctx: PluginContext) { + await remember(input, ctx); + ctx.log.info(`received ${input.event.domain}.${input.event.type}`, { seq: input.event.seq }); +} + +/** Behaves as the `mode` state key says: `ok`, `throw` or `hang` (past the time limit). */ +export async function onMerged(input: PluginEventInput, ctx: PluginContext) { + const mode = (await ctx.state.get('mode')) ?? 'ok'; + await remember(input, ctx); + if (mode === 'throw') throw new Error(`merge handler failure for ${input.deliveryId}`); + if (mode === 'hang') return new Promise(() => undefined); +} + +/** Fails while `fail` (a count of attempts still to fail) is above zero. */ +export async function flaky(input: PluginEventInput, ctx: PluginContext) { + await remember(input, ctx); + const remaining = (await ctx.state.get('fail')) ?? 0; + if (remaining > 0) { + await ctx.state.set('fail', remaining - 1); + ctx.log.warn('failing on purpose', { remaining }); + throw new Error(`flaky failure, attempt ${input.attempt}`); + } +} diff --git a/packages/core/test/fixtures/plugins/fixture-events/manifest.json b/packages/core/test/fixtures/plugins/fixture-events/manifest.json new file mode 100644 index 0000000000..f43584228f --- /dev/null +++ b/packages/core/test/fixtures/plugins/fixture-events/manifest.json @@ -0,0 +1,21 @@ +{ + "manifestVersion": 1, + "id": "fixture-events", + "name": "Fixture events", + "description": "Test fixture for plugin event subscriptions: handlers that record deliveries, fail on demand and hang, subscribed to journal events with and without filters.", + "version": "1.0.0", + "category": "other", + "homepage": "https://example.com/fixture-events", + "icon": "icon.svg", + "permissions": ["events.read"], + "contributes": { + "subscriptions": [ + { "on": "pull_request.opened", "handler": "record" }, + { "on": "pull_request.merged", "handler": "onMerged" }, + { "on": "task.completed", "handler": "record", "filter": { "payload.kind": ["implementation", "review"] } }, + { "on": "task.failed", "handler": "flaky" }, + { "on": "pull_request.closed", "handler": "record" }, + { "on": "pull_request.closed", "handler": "flaky" } + ] + } +} diff --git a/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts b/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts new file mode 100644 index 0000000000..b18cb95bca --- /dev/null +++ b/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts @@ -0,0 +1,160 @@ +import type { PluginContext, PluginHandlerInput, PluginHttpRequestInit, PluginJsonValue, PluginProprClient } from '@propr/shared'; + +/** Test fixture handlers for packages/core/test/pluginHandlerRunner.test.ts. Imports only @propr/shared types. */ +type Payload = Record; +const payload = (input: PluginHandlerInput): Payload => (input.kind === 'invoke' ? input.payload as Payload : {}); + +export async function succeed(input: PluginHandlerInput, ctx: PluginContext) { + ctx.log.info('fixture handler ran', { kind: input.kind, greeting: ctx.settings.greeting as string }); + ctx.log.debug('a GitHub token in a log line is redacted: ghp_0123456789abcdefghijABCDEFGHIJ012345'); + return { greeting: ctx.settings.greeting as string, repositories: ctx.repositories }; +} + +export async function throws(_input: PluginHandlerInput, ctx: PluginContext) { + ctx.log.warn('about to fail'); + throw new Error('fixture handler failure'); +} + +/** Never settles; once aborted it tries to keep working through ctx. */ +export async function hangs(_input: PluginHandlerInput, ctx: PluginContext) { + ctx.log.info('hanging'); + ctx.signal.addEventListener('abort', () => { + ctx.log.info('after the time limit'); + ctx.state.set('late', true).catch(() => undefined); + }); + return new Promise(() => undefined); +} + +export async function callPropr(input: PluginHandlerInput, ctx: PluginContext) { + const { operation, args } = payload(input) as { operation: keyof PluginProprClient; args: PluginJsonValue[] }; + const method = ctx.propr[operation] as (...values: PluginJsonValue[]) => Promise; + try { + return { ok: true, value: (await method(...args)) as PluginJsonValue ?? null }; + } catch (error) { + return { ok: false, code: (error as { code?: string }).code ?? null, message: (error as Error).message }; + } +} + +/** Fetch through ctx.http and return everything the handler can see: ctx, the response status, headers and body. */ +export async function fetchUrl(input: PluginHandlerInput, ctx: PluginContext) { + const { url, connection, init } = payload(input) as { url: string; connection?: string; init?: PluginHttpRequestInit }; + try { + const response = await ctx.http(connection)(url, init); + const body = await response.text(); + ctx.log.info('fetched', { status: response.status, headers: response.headers, body }); + return { ok: true, status: response.status, headers: response.headers, body, ctx: JSON.parse(JSON.stringify(ctx)) as PluginJsonValue }; + } catch (error) { + return { ok: false, code: (error as { code?: string }).code ?? null, message: (error as Error).message }; + } +} + +/** + * Fetch and report whether the handler could see `probe` (the test's credential) in any + * handler-visible form: the text, the text parsed by the handler, or `json()`. + */ +export async function fetchProbe(input: PluginHandlerInput, ctx: PluginContext) { + const { url, connection, probe } = payload(input) as { url: string; connection?: string; probe: string }; + try { + const response = await ctx.http(connection)(url); + const text = await response.text(); + const parsed = (() => { try { return JSON.parse(text) as PluginJsonValue; } catch { return null; } })(); + // A form or HTML body is not JSON: json() refuses it. + const json = await response.json().catch(() => null); + const decoded = (value: unknown): boolean => typeof value === 'string' ? value.includes(probe) : value !== null && typeof value === 'object' && Object.entries(value).some(([k, v]) => k.includes(probe) || decoded(v)); + // A header value (or one part of a joined value) parsed as JSON by the handler. + const parsedHeader = (value: string) => [value, ...value.split(', ')].some(part => { try { return decoded(JSON.parse(part)); } catch { return false; } }); + // A header name (lowercased, as names arrive) as is, URL-decoded or JSON-decoded by the handler, compared case-insensitively. + const attempt = (decode: () => unknown) => { try { return decode(); } catch { return null; } }; + const nameHasProbe = (name: string) => [name, attempt(() => decodeURIComponent(name)), attempt(() => JSON.parse(`"${name}"`))] + .some(value => typeof value === 'string' && value.toLowerCase().includes(probe.toLowerCase())); + // Every string the handler can see (the text, each parsed or json() string and key, each header value, also parsed as JSON), URL-decoded again and again. + const strings = (value: unknown): string[] => typeof value === 'string' ? [value] : value !== null && typeof value === 'object' ? Object.entries(value).flatMap(([k, v]) => [k, ...strings(v)]) : []; + const headerValues = Object.values(response.headers); + const visible = [text, ...strings(parsed), ...strings(json), ...headerValues, ...headerValues.flatMap(v => [v, ...v.split(', ')].flatMap(part => strings(attempt(() => JSON.parse(part)))))]; + // decodeURIComponent, or each escape alone as unescape() decodes it (`%2f`, `%u002f`) where it refuses the text. + const urlDecode = (value: string) => (attempt(() => decodeURIComponent(value)) as string | null) + ?? value.replace(/%(?:u[0-9a-f]{4}|[0-9a-f]{2})/gi, escape => String.fromCharCode(parseInt(escape.replace(/^%u?/i, ''), 16))); + const urlDecodes = (value: string) => { + for (let current = value, round = 0; round < 8; round++) { + const next = urlDecode(current); + if (next.includes(probe)) return true; + if (next === current) return false; + current = next; + } + return false; + }; + return { + ok: true, textHasProbe: text.includes(probe), parsedHasProbe: decoded(parsed), jsonHasProbe: decoded(json), + headersHaveProbe: headerValues.some(v => v.includes(probe)), parsedHeadersHaveProbe: headerValues.some(parsedHeader), + headerNamesHaveProbe: Object.keys(response.headers).some(nameHasProbe), urlDecodedHasProbe: visible.some(urlDecodes), + }; + } catch (error) { + return { ok: false, code: (error as { code?: string }).code ?? null, message: (error as Error).message }; + } +} + +/** Make an authenticated request (the run then holds its credential), then log `note`, a value from the input, and return it. */ +export async function fetchThenLog(input: PluginHandlerInput, ctx: PluginContext) { + const { url, connection, note } = payload(input) as { url: string; connection: string; note: string }; + await ctx.http(connection)(url); + ctx.log.info(note, { note }); + return { note }; +} + +/** Log `note` (a value from the input) before the run holds any credential, then make an authenticated request, and return the note. */ +export async function logThenFetch(input: PluginHandlerInput, ctx: PluginContext) { + const { url, connection, note } = payload(input) as { url: string; connection: string; note: string }; + ctx.log.info(note, { note }); + await ctx.http(connection)(url); + return { note }; +} + +/** + * Wait on a ctx.propr read (the test holds it), then use state: a handler still + * running across an uninstall. `op` defaults to writing `key`. + */ +export async function suspendThenSet(input: PluginHandlerInput, ctx: PluginContext) { + const { key, op = 'set' } = payload(input) as { key: string; op?: 'set' | 'get' | 'delete' | 'keys' }; + await ctx.propr.getIssue('owner/allowed', 3).catch(() => undefined); + try { + if (op === 'get') return { ok: true, value: (await ctx.state.get(key)) ?? null }; + if (op === 'delete') return { ok: true, value: await ctx.state.delete(key) }; + if (op === 'keys') return { ok: true, value: await ctx.state.keys() }; + await ctx.state.set(key, 'written after resuming'); + return { ok: true }; + } catch (error) { + return { ok: false, code: (error as { code?: string }).code ?? null, message: (error as Error).message }; + } +} + +/** Make an authenticated request (the run then holds its credential), then fail with `note`, a value from the input, as the error message. */ +export async function fetchThenThrow(input: PluginHandlerInput, ctx: PluginContext) { + const { url, connection, note } = payload(input) as { url: string; connection: string; note: string }; + await ctx.http(connection)(url); + throw new Error(note); +} + +/** Wait on a ctx.propr read (the test holds it), then make one ctx.propr call: a mutation resuming across an uninstall. */ +export async function suspendThenPropr(input: PluginHandlerInput, ctx: PluginContext) { + await ctx.propr.getIssue('owner/allowed', 3).catch(() => undefined); + return callPropr(input, ctx); +} + +export async function useState(input: PluginHandlerInput, ctx: PluginContext) { + const { op, key, value, prefix } = payload(input) as { op: 'get' | 'set' | 'delete' | 'keys'; key: string; value?: PluginJsonValue; prefix?: string }; + try { + if (op === 'set') { await ctx.state.set(key, value ?? null); return { ok: true }; } + if (op === 'get') return { ok: true, value: (await ctx.state.get(key)) ?? null }; + if (op === 'delete') return { ok: true, value: await ctx.state.delete(key) }; + return { ok: true, value: await ctx.state.keys(prefix) }; + } catch (error) { + return { ok: false, code: (error as { code?: string }).code ?? null, message: (error as Error).message }; + } +} + +export async function returnsTooMuch() { + return { data: 'x'.repeat(20_000) }; +} + +/** Not a handler: only exported functions are. */ +export const notAFunction = 42; diff --git a/packages/core/test/fixtures/plugins/fixture-handlers/manifest.json b/packages/core/test/fixtures/plugins/fixture-handlers/manifest.json new file mode 100644 index 0000000000..51ad2214b7 --- /dev/null +++ b/packages/core/test/fixtures/plugins/fixture-handlers/manifest.json @@ -0,0 +1,24 @@ +{ + "manifestVersion": 1, + "id": "fixture-handlers", + "name": "Fixture handlers", + "description": "Test fixture for the plugin handler runtime: handlers that succeed, throw, hang, call ctx.propr, ctx.http and ctx.state.", + "version": "1.0.0", + "category": "other", + "homepage": "https://example.com/fixture-handlers", + "icon": "icon.svg", + "permissions": ["events.read", "tasks.read", "tasks.create", "issues.read", "issues.create", "issues.comment", "labels.write"], + "connections": [ + { "type": "token", "name": "api-token", "label": "API token", "help": "A token for the fixture API.", "helpUrl": "https://example.com/docs/tokens" }, + { "type": "token", "name": "unset-token", "label": "Unset token", "help": "Never set by the tests.", "helpUrl": "https://example.com/docs/tokens" }, + { "type": "signing_secret", "name": "hook-secret", "label": "Webhook secret", "help": "Verifies inbound webhooks.", "helpUrl": "https://example.com/docs/webhooks" } + ], + "settings": { + "type": "object", + "properties": { + "greeting": { "type": "string", "label": "Greeting", "default": "hi" } + }, + "required": [] + }, + "outboundHosts": ["api.fixture.example", "private.fixture.example"] +} diff --git a/packages/core/test/fixtures/plugins/fixture-imports-core/handlers.ts b/packages/core/test/fixtures/plugins/fixture-imports-core/handlers.ts new file mode 100644 index 0000000000..0b3f509919 --- /dev/null +++ b/packages/core/test/fixtures/plugins/fixture-imports-core/handlers.ts @@ -0,0 +1,12 @@ +// Lint fixture: a plugin handler that imports ProPR internals. The +// restricted-imports rule for packages/plugins/** must reject every import +// below; packages/core/test/pluginHandlerLint.test.ts lints this file as if it +// were packages/plugins/fixture-imports-core/handlers.ts. Never executed. +import { PluginStore } from '@propr/core'; +import { listRepositoryEvents } from '../../core/src/services/repositoryEventJournal.js'; +import type { PluginContext } from '@propr/shared'; + +export async function leak(_input: unknown, ctx: PluginContext) { + const api = await import('@propr/api'); + return { store: typeof PluginStore, events: typeof listRepositoryEvents, api: typeof api, plugin: ctx.pluginId }; +} diff --git a/packages/core/test/fixtures/plugins/registry.json b/packages/core/test/fixtures/plugins/registry.json new file mode 100644 index 0000000000..1c546a5fa3 --- /dev/null +++ b/packages/core/test/fixtures/plugins/registry.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "plugins": [ + { "id": "fixture-handlers", "development": true }, + { "id": "fixture-events", "development": true } + ] +} diff --git a/packages/core/test/helpers/pluginDispatcherFixture.ts b/packages/core/test/helpers/pluginDispatcherFixture.ts new file mode 100644 index 0000000000..39e009bc17 --- /dev/null +++ b/packages/core/test/helpers/pluginDispatcherFixture.ts @@ -0,0 +1,59 @@ +import type { TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { pluginHandlerFixture, EVENTS_FIXTURE_ID as ID } from './pluginHandlerFixture.js'; +import { PluginDispatcher } from '../../src/plugins/pluginDispatcher.js'; +import { listPluginActivity } from '../../src/plugins/pluginDeliveries.js'; +import { appendRepositoryEvent } from '../../src/services/repositoryEventJournal.js'; + +/** + * The `fixture-events` plugin installed through the real plugin store, limited + * to two repositories, with a dispatcher factory on a test clock. Import it + * after mocking `eventPublisher.js` (no Redis in tests). + */ +type Seen = { id: string; seq: number; repository: string; attempt: number }[]; +export const ALLOWED = 'owner/allowed'; +export const SECOND = 'owner/second'; + +export async function setup(t: TestContext) { + const f = await pluginHandlerFixture(t, { pluginId: ID, repositories: [ALLOWED, SECOND] }); + const clock = { now: 1_700_000_000_000 }; + const notices: unknown[] = []; + const dispatcher = (extra: Partial[0]> = {}) => new PluginDispatcher({ + ...f.options, now: () => clock.now, timeoutMs: 200, pauseAfterFailures: 100, notify: async notice => { notices.push(notice); }, ...extra, + }); + const append = async (repository: string, domain: 'pull_request' | 'task', type: string, entityId: number | string, payload: Record = {}, occurredAt?: string) => { + const result = await appendRepositoryEvent(f.database, { + repository, domain, type, entityId, ...(domain === 'pull_request' ? { prNumber: Number(entityId) } : {}), payload, ...(occurredAt ? { occurredAt } : {}), + }, { publish: false }); + assert.equal(result.status, 'appended'); + return (result as { seq: number }).seq; + }; + const state = async (key: string) => { + const row = await f.database('plugin_state').where({ plugin_id: ID, state_key: key }).first() as { value: string } | undefined; + return row ? JSON.parse(row.value) as T : undefined; + }; + const setState = (key: string, value: unknown) => f.database('plugin_state').insert({ plugin_id: ID, state_key: key, value: JSON.stringify(value), bytes: 8, updated_at: 0 }).onConflict(['plugin_id', 'state_key']).merge(); + const seen = async () => (await state('seen')) ?? []; + const deliveries = async () => (await listPluginActivity(f.database, ID)).deliveries; + return { f, clock, notices, dispatcher, append, seen, setState, deliveries }; +} + +/** Passes until one makes no attempt. */ +export async function drain(dispatcher: InstanceType) { + let total = 0; + for (let i = 0; i < 20; i++) { + const { attempts } = await dispatcher.pass(); + if (!attempts) return total; + total += attempts; + } + throw new Error('the dispatcher did not settle'); +} + +/** Waits until the predicate holds (a background pass reached a state). */ +export async function until(predicate: () => Promise, what: string) { + for (let i = 0; i < 100; i++) { + if (await predicate()) return; + await new Promise(resolve => setTimeout(resolve, 10)); + } + throw new Error(`timed out waiting for ${what}`); +} diff --git a/packages/core/test/helpers/pluginHandlerFixture.ts b/packages/core/test/helpers/pluginHandlerFixture.ts new file mode 100644 index 0000000000..8ff6ae6316 --- /dev/null +++ b/packages/core/test/helpers/pluginHandlerFixture.ts @@ -0,0 +1,101 @@ +import type { TestContext } from 'node:test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { PluginManifest } from '@propr/shared'; +import { getOfficialPlugin, loadPluginHandlers } from '../../../plugins/src/index.js'; +import { extensionTestResources } from './extensionTestResources.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 { up as stateMigration } from '../../src/db/migrations/20261114180000_plugin_state.js'; +import { up as journalMigration } from '../../src/db/migrations/20261112160000_create_repository_events.js'; +import { up as taskDomainMigration } from '../../src/db/migrations/20261114120000_add_task_repository_event_domain.js'; +import { up as deliveriesMigration } from '../../src/db/migrations/20261115000000_plugin_event_deliveries.js'; +import { PluginStore } from '../../src/plugins/pluginStore.js'; +import type { PluginProprBackend } from '../../src/plugins/pluginProprClient.js'; +import type { PluginHttpTransport, PluginHttpTransportRequest } from '../../src/plugins/pluginHttp.js'; +import type { RunPluginHandlerOptions } from '../../src/plugins/pluginHandlerRunner.js'; + +/** A registry root holding the `fixture-handlers` plugin (and the lint-only `fixture-imports-core` folder). */ +export const FIXTURE_REGISTRY = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'fixtures', 'plugins'); +export const FIXTURE_ID = 'fixture-handlers'; +/** The event subscription fixture plugin. */ +export const EVENTS_FIXTURE_ID = 'fixture-events'; +export const fixtureManifest = (id = FIXTURE_ID): PluginManifest => getOfficialPlugin(id, { root: FIXTURE_REGISTRY, includeDevelopment: true })!.manifest; +export const ENVIRONMENT = { SYSTEM_TASK_SECRET: 'plugin-handler-test-root' }; + +/** Hosts the fake resolver knows: one public, one private. */ +export const lookup = async (host: string) => { + if (host === 'api.fixture.example') return [{ address: '93.184.216.34', family: 4 }]; + if (host === 'private.fixture.example') return [{ address: '10.0.0.5', family: 4 }]; + throw new Error('ENOTFOUND'); +}; + +/** + * A transport that echoes the request's headers (the injected Authorization included) back, as a careless upstream would: + * in a header value, and the credential itself in a header name. + */ +export function echoTransport() { + const requests: PluginHttpTransportRequest[] = []; + const transport: PluginHttpTransport = async request => { + requests.push(request); + const authorization = request.headers.authorization ?? ''; + return { + status: 200, + headers: { 'content-type': 'application/json', 'x-echo-authorization': authorization, ...(authorization ? { [`x-echo-${authorization.slice('Bearer '.length)}`]: 'echoed' } : {}) }, + body: Buffer.from(JSON.stringify({ path: request.url.pathname, authorization, base64: Buffer.from(authorization).toString('base64') })), + }; + }; + return { transport, requests }; +} + +/** A backend that records what reached it. */ +export function recordingBackend() { + const calls: { operation: string; args: unknown[] }[] = []; + const record = (operation: string, result: T) => async (...args: unknown[]) => { calls.push({ operation, args }); return result; }; + const backend: PluginProprBackend = { + getPullRequest: record('getPullRequest', { repository: 'owner/allowed', number: 7, title: 'PR', state: 'open', url: 'https://github.com/owner/allowed/pull/7', labels: [], author: 'octo', createdAt: '', updatedAt: '', draft: false, merged: false, headRef: 'h', baseRef: 'main', headSha: 'abc' }), + getIssue: record('getIssue', { repository: 'owner/allowed', number: 3, title: 'Issue', state: 'open', url: 'https://github.com/owner/allowed/issues/3', labels: ['bug'], author: 'octo', createdAt: '', updatedAt: '' }), + getTask: async (taskId: string) => { + calls.push({ operation: 'getTask', args: [taskId] }); + if (taskId === 'missing') return null; + return { taskId, repository: taskId === 'elsewhere' ? 'other/repo' : 'owner/allowed', kind: 'implementation', issueNumber: 3, prNumber: null, state: 'completed', model: null, createdAt: '' }; + }, + listEvents: record('listEvents', []), + createIssue: record('createIssue', { repository: 'owner/allowed', number: 11, url: 'https://github.com/owner/allowed/issues/11' }), + createTask: async (repository, issueNumber, attribution) => { calls.push({ operation: 'createTask', args: [repository, issueNumber, attribution] }); return { repository, issueNumber, jobId: `issue-${repository.replace('/', '-')}-${issueNumber}` }; }, + comment: record('comment', { repository: 'owner/allowed', number: 3, id: 99, url: 'https://github.com/owner/allowed/issues/3#issuecomment-99' }), + addLabel: record('addLabel', undefined), + removeLabel: record('removeLabel', undefined), + }; + return { backend, calls }; +} + +/** + * An in-memory instance with the fixture plugin installed through the real + * plugin store: `api-token` set (sealed), limited to `owner/allowed`, enabled. + */ +export async function pluginHandlerFixture(t: TestContext, options: { secret?: string; repositories?: string[] | null; pluginId?: string } = {}) { + 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' }); + await database.schema.createTable('agent_definitions', c => { c.string('id').primary(); c.string('name'); }); + for (const migrate of [identityMigration, grantMigration, libraryMigration, oauthMigration, pluginMigration, stateMigration, journalMigration, taskDomainMigration, deliveriesMigration]) await migrate(database); + const pluginId = options.pluginId ?? FIXTURE_ID; + const manifest = fixtureManifest(pluginId); + const catalog = async () => [manifest]; + const store = new PluginStore(database, ENVIRONMENT, catalog); + await store.install('1', pluginId); + if (options.secret) await store.setConnection('1', pluginId, 'api-token', options.secret); + await store.setRepositories('1', pluginId, options.repositories === undefined ? ['owner/allowed'] : options.repositories); + await store.setEnabled('1', pluginId, true); + const { backend, calls } = recordingBackend(); + const http = echoTransport(); + const runOptions: RunPluginHandlerOptions = { + database, environment: ENVIRONMENT, catalog, backend, lookup, transport: http.transport, + handlers: id => loadPluginHandlers(id, { root: FIXTURE_REGISTRY, includeDevelopment: true }), + }; + return { database, store, manifest, backend, calls, http, options: runOptions }; +} diff --git a/packages/core/test/pluginContributions.test.ts b/packages/core/test/pluginContributions.test.ts new file mode 100644 index 0000000000..1cd457e70b --- /dev/null +++ b/packages/core/test/pluginContributions.test.ts @@ -0,0 +1,561 @@ +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 { up as pluginStateMigration } from '../src/db/migrations/20261114180000_plugin_state.js'; +import { up as journalMigration } from '../src/db/migrations/20261112160000_create_repository_events.js'; +import { up as deliveriesMigration } from '../src/db/migrations/20261115000000_plugin_event_deliveries.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); await pluginStateMigration(database); await journalMigration(database); await deliveriesMigration(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/pluginDispatcher.test.ts b/packages/core/test/pluginDispatcher.test.ts new file mode 100644 index 0000000000..b3d5af184d --- /dev/null +++ b/packages/core/test/pluginDispatcher.test.ts @@ -0,0 +1,279 @@ +import test, { mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { PLUGIN_DELIVERY_RETRY_DELAYS_MS, type PluginHandlerRun } from '@propr/shared'; + +/** + * Plugin event subscriptions, end to end against SQLite: the fixture plugin + * `fixture-events` is installed through the real plugin store and its + * handlers run through the real runner. Journal appends publish no activity + * event (no Redis in tests). + */ +await mock.module('../src/utils/eventPublisher.js', { namedExports: { getEventPublisher: () => ({ publishActivity: async () => true }) } }); +const { EVENTS_FIXTURE_ID: ID } = await import('./helpers/pluginHandlerFixture.js'); +const { ALLOWED, SECOND, drain, setup } = await import('./helpers/pluginDispatcherFixture.js'); +const { listPluginActivity, prunePluginDeliveries, resumePluginDeliveries, retryPluginDelivery } = await import('../src/plugins/pluginDeliveries.js'); +const { appendRepositoryEvent, pruneRepositoryEvents } = await import('../src/services/repositoryEventJournal.js'); +const { recordPullRequestOutcome } = await import('../src/services/reviewScoreStore.js'); +const { up: pullRequestStateMigration } = await import('../src/db/migrations/20260829010000_add_notification_pull_request_state.js'); +const { up: reviewScoresMigration } = await import('../src/db/migrations/20261006000000_create_review_scores.js'); + +test('a subscribed handler runs once per matching event, in order per repository, with filters and the repository limit honoured', async t => { + const s = await setup(t); + const expected: { seq: number; repository: string }[] = []; + const a1 = await s.append(ALLOWED, 'pull_request', 'opened', 1, { draft: false }); expected.push({ seq: a1, repository: ALLOWED }); + const b1 = await s.append(SECOND, 'pull_request', 'opened', 2); expected.push({ seq: b1, repository: SECOND }); + const a2 = await s.append(ALLOWED, 'task', 'completed', 'task-1', { kind: 'implementation', secretish: 'payload-body-sentinel' }); expected.push({ seq: a2, repository: ALLOWED }); + await s.append(ALLOWED, 'task', 'completed', 'task-2', { kind: 'automation' }); // filtered out by payload.kind + await s.append('owner/other', 'pull_request', 'opened', 3); // outside the repository limit + await s.append(ALLOWED, 'pull_request', 'head_changed', 1); // not subscribed + const a3 = await s.append('Owner/Allowed', 'pull_request', 'opened', 4); expected.push({ seq: a3, repository: 'Owner/Allowed' }); + + const dispatcher = s.dispatcher(); + assert.equal(await drain(dispatcher), 4); + const seen = await s.seen(); + assert.deepEqual(seen.map(e => e.seq).sort((x, y) => x - y), expected.map(e => e.seq).sort((x, y) => x - y), 'each matching event once'); + for (const repository of [ALLOWED, SECOND]) { + // Every spelling of a repository name is one stream. + const order = seen.filter(e => e.repository.toLowerCase() === repository).map(e => e.seq); + assert.deepEqual(order, [...order].sort((x, y) => x - y), `events of ${repository} in order`); + } + assert.deepEqual(seen.map(e => e.id).sort(), expected.map(e => `evt-${e.seq}-record`).sort(), 'stable delivery ids'); + assert.ok(seen.every(e => e.attempt === 1)); + + // A second pass and a fresh dispatcher deliver nothing again. + assert.equal(await drain(dispatcher), 0); + assert.equal(await drain(s.dispatcher()), 0); + + const records = await s.deliveries(); + assert.equal(records.length, 4); + for (const record of records) { + assert.equal(record.status, 'succeeded'); assert.equal(record.kind, 'event'); assert.equal(record.handler, 'record'); assert.equal(record.attempt, 1); + assert.equal(typeof record.durationMs, 'number'); assert.match(record.logs[0].message, /^received /); + } + // Only the event id and type are stored: no payload. + const raw = JSON.stringify(await s.f.database('plugin_deliveries').select()); + assert.equal(raw.includes('payload-body-sentinel'), false); + assert.equal(raw.includes('implementation'), false); +}); + +test('installing or enabling starts at the end of the journal: history is not replayed', async t => { + const s = await setup(t); + await s.append(ALLOWED, 'pull_request', 'opened', 1); + await drain(s.dispatcher()); + assert.equal((await s.seen()).length, 1); + await s.f.store.setEnabled('1', ID, false); + await s.append(ALLOWED, 'pull_request', 'opened', 2); // while disabled + assert.equal(await drain(s.dispatcher()), 0); + await s.f.store.setEnabled('1', ID, true); + const after = await s.append(ALLOWED, 'pull_request', 'opened', 3); + await drain(s.dispatcher()); + assert.deepEqual((await s.seen()).map(e => e.seq).slice(1), [after]); + + await s.f.store.uninstall('1', ID, ID); + assert.equal(await s.f.database('plugin_deliveries').where({ plugin_id: ID }).first(), undefined, 'uninstall deletes the records'); + await s.f.store.install('1', ID); await s.f.store.setRepositories('1', ID, [ALLOWED]); await s.f.store.setEnabled('1', ID, true); + const fresh = await s.append(ALLOWED, 'pull_request', 'opened', 5); + await drain(s.dispatcher()); + assert.deepEqual((await s.seen()).map(e => e.seq), [fresh], 'a reinstall sees only new events'); +}); + +test('a failing handler is retried on the schedule, holds back its repository only, and is then marked failed', async t => { + const s = await setup(t); + await s.setState('fail', 1000); + const failing = await s.append(ALLOWED, 'task', 'failed', 'task-1'); + const behind = await s.append(ALLOWED, 'pull_request', 'opened', 1); + const elsewhere = await s.append(SECOND, 'pull_request', 'opened', 2); + const dispatcher = s.dispatcher(); + const start = s.clock.now; + + await drain(dispatcher); + let seen = await s.seen(); + assert.deepEqual(seen.map(e => e.seq), [failing, elsewhere], 'the other repository is not held back; the later event of the same one is'); + const attemptTimes: number[] = [start]; + for (const [index, delay] of PLUGIN_DELIVERY_RETRY_DELAYS_MS.entries()) { + const [record] = (await s.deliveries()).filter(d => d.handler === 'flaky'); + assert.equal(record.status, 'retrying'); assert.equal(record.attempt, index + 1); + assert.equal(record.nextAttemptAt, attemptTimes.at(-1)! + delay, `retry ${index + 1} after ${delay / 60_000} min`); + assert.match(record.error!, /flaky failure/); + s.clock.now = record.nextAttemptAt! - 1; + assert.equal(await drain(dispatcher), 0, 'nothing runs before the retry is due'); + s.clock.now = record.nextAttemptAt!; + attemptTimes.push(s.clock.now); + await drain(dispatcher); + } + const [failed] = (await s.deliveries()).filter(d => d.handler === 'flaky'); + assert.equal(failed.status, 'failed'); assert.equal(failed.attempt, PLUGIN_DELIVERY_RETRY_DELAYS_MS.length + 1); assert.equal(failed.nextAttemptAt, null); + assert.equal(failed.logs[0].message, 'failing on purpose'); + seen = await s.seen(); + assert.deepEqual(seen.filter(e => e.seq === failing).map(e => e.attempt), [1, 2, 3, 4, 5]); + assert.ok(seen.filter(e => e.seq === failing).every(e => e.id === `evt-${failing}-flaky`), 'the delivery id is the same on every attempt'); + assert.deepEqual(seen.at(-1), { id: `evt-${behind}-record`, seq: behind, repository: ALLOWED, attempt: 1 }, 'then the next event follows'); + + // An administrator retries the failed delivery: one more attempt, same id. + await s.setState('fail', 0); + const retried = await retryPluginDelivery(s.f.database, ID, String(failed.id), '1', s.clock.now); + assert.equal(retried.status, 'pending'); + await assert.rejects(retryPluginDelivery(s.f.database, ID, String(failed.id), '1'), /Only a failed event delivery/); + await drain(dispatcher); + const [done] = (await s.deliveries()).filter(d => d.handler === 'flaky'); + assert.equal(done.status, 'succeeded'); assert.equal(done.attempt, 6); + assert.deepEqual((await s.seen()).at(-1), { id: `evt-${failing}-flaky`, seq: failing, repository: ALLOWED, attempt: 6 }); +}); + +test('the cursor survives a worker restart; a crashed attempt is delivered again with the same id', async t => { + const s = await setup(t); + const first = await s.append(ALLOWED, 'pull_request', 'opened', 1); + const a = s.dispatcher({ owner: 'worker-a' }); + await drain(a); + await a.stop(); + const second = await s.append(ALLOWED, 'pull_request', 'opened', 2); + const third = await s.append(SECOND, 'pull_request', 'opened', 3); + const b = s.dispatcher({ owner: 'worker-b' }); + await drain(b); + await b.stop(); + assert.deepEqual((await s.seen()).map(e => e.seq), [first, second, third], 'nothing lost, nothing repeated'); + + // Worker C dies while its handler runs: the record stays `running` and C keeps the lease until it expires. + const crashed = await s.append(ALLOWED, 'pull_request', 'opened', 4); + let release!: (run: PluginHandlerRun) => void; + const hung = new Promise(resolve => { release = resolve; }); + const c = s.dispatcher({ owner: 'worker-c', leaseMs: 60_000, run: () => hung }); + const cPass = c.pass(); + for (let i = 0; i < 50 && (await s.deliveries())[0]?.status !== 'running'; i++) await new Promise(r => setTimeout(r, 10)); + assert.equal((await s.deliveries())[0].status, 'running'); + const d = s.dispatcher({ owner: 'worker-d' }); + assert.equal(await drain(d), 0, 'the lease holder is not displaced while its lease lasts'); + s.clock.now += 60_001; + assert.equal(await drain(d), 1); + assert.deepEqual((await s.seen()).at(-1), { id: `evt-${crashed}-record`, seq: crashed, repository: ALLOWED, attempt: 2 }); + // The stale worker finishes late: its outcome is not recorded over the lease holder's. + release({ outcome: 'failed', timedOut: false, durationMs: 1, error: 'late stale outcome', logs: [] }); + await cPass; + const [record] = await s.deliveries(); + assert.equal(record.status, 'succeeded'); assert.equal(record.error, null); +}); + +test('two dispatchers running at once deliver each event once (lease)', async t => { + const s = await setup(t); + const a = s.dispatcher({ owner: 'worker-a' }); + const b = s.dispatcher({ owner: 'worker-b' }); + const appended: number[] = []; + const holders = new Set(); + for (let round = 0; round < 5; round++) { + for (let i = 0; i < 3; i++) appended.push(await s.append(i % 2 ? SECOND : ALLOWED, 'pull_request', 'opened', round * 10 + i)); + const [ra, rb] = await Promise.all([a.pass(), b.pass()]); + assert.ok(!(ra.leased.includes(ID) && rb.leased.includes(ID)), 'only one holds the lease'); + if (ra.leased.includes(ID)) holders.add('a'); if (rb.leased.includes(ID)) holders.add('b'); + } + await Promise.all([drain(a), drain(b)]); + const seen = (await s.seen()).map(e => e.seq); + assert.deepEqual([...seen].sort((x, y) => x - y), appended); + assert.equal(new Set(seen).size, seen.length, 'no event delivered twice'); + assert.equal(holders.size, 1, 'the lease stays with one dispatcher while it lasts'); + // Once the holder stops, the other takes over. + const [holder, other] = holders.has('a') ? [a, b] : [b, a]; + await holder.stop(); + const later = await s.append(ALLOWED, 'pull_request', 'opened', 99); + assert.equal(await drain(other), 1); + assert.equal((await s.seen()).at(-1)!.seq, later); + await other.stop(); +}); + +test('repeated failures make the plugin Degraded with one notification, then pause it until Resume', async t => { + const s = await setup(t); + await s.setState('fail', 1000); + const dispatcher = s.dispatcher({ pauseAfterFailures: 5, retryDelaysMs: [] }); + for (let i = 0; i < 3; i++) await s.append(ALLOWED, 'task', 'failed', `task-${i}`); + await drain(dispatcher); + let activity = await listPluginActivity(s.f.database, ID); + assert.equal(activity.health.state, 'degraded'); assert.equal(activity.health.consecutiveFailures, 3); + assert.match(activity.health.lastError!, /flaky failure/); + assert.equal(s.notices.length, 1); + for (let i = 3; i < 8; i++) await s.append(ALLOWED, 'task', 'failed', `task-${i}`); + await drain(dispatcher); + activity = await listPluginActivity(s.f.database, ID); + assert.equal(activity.health.state, 'paused'); assert.equal(activity.health.consecutiveFailures, 5); + assert.equal(activity.deliveries.filter(d => d.status === 'failed').length, 5, 'deliveries stop at the pause'); + assert.equal(s.notices.length, 1, 'one notification while it stays Degraded'); + assert.deepEqual(s.notices[0], { pluginId: ID, installedAt: (await s.f.store.get('1', ID)).installation!.installedAt, degradedAt: s.clock.now, consecutiveFailures: 3, lastError: activity.deliveries.at(-1)!.error }); + assert.equal(await drain(dispatcher), 0, 'paused: nothing is delivered'); + + await s.setState('fail', 0); + const health = await resumePluginDeliveries(s.f.database, ID, '1', { now: s.clock.now }); + assert.equal(health.state, 'healthy'); assert.equal(health.consecutiveFailures, 0); assert.match(health.lastError!, /flaky failure/); + await assert.rejects(resumePluginDeliveries(s.f.database, ID, '1'), /not paused/); + assert.equal(await drain(dispatcher), 3, 'the rest is delivered after Resume'); + activity = await listPluginActivity(s.f.database, ID); + assert.equal(activity.health.state, 'healthy'); + assert.equal(activity.deliveries.filter(d => d.status === 'succeeded').length, 3); +}); + +test('events that left the journal before delivery are skipped with a recorded gap; records are pruned after retention', async t => { + const s = await setup(t); + await s.f.store.setEnabled('1', ID, false); + await s.f.store.setEnabled('1', ID, true); + const dispatcher = s.dispatcher(); + await drain(dispatcher); // creates the cursors once events exist + const old = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString(); + const pruned = await appendRepositoryEvent(s.f.database, { repository: ALLOWED, domain: 'pull_request', type: 'opened', entityId: 1, prNumber: 1, occurredAt: old }, { publish: false }); + const kept = await s.append(ALLOWED, 'pull_request', 'opened', 2); + await pruneRepositoryEvents(s.f.database); + await drain(dispatcher); + assert.deepEqual((await s.seen()).map(e => e.seq), [kept]); + const gap = (await s.deliveries()).find(d => d.kind === 'gap')!; + assert.equal(gap.status, 'skipped'); assert.equal(gap.eventSeq, (pruned as { seq: number }).seq); + assert.match(gap.error!, /left the journal/); + await drain(dispatcher); + assert.equal((await s.deliveries()).filter(d => d.kind === 'gap').length, 1, 'a gap is recorded once'); + + assert.equal(await prunePluginDeliveries(s.f.database, { now: () => s.clock.now + 13 * 24 * 60 * 60 * 1000 }), 0, 'kept within the retention period'); + assert.equal(await prunePluginDeliveries(s.f.database, { now: () => s.clock.now + 15 * 24 * 60 * 60 * 1000 }), 2); + assert.deepEqual(await s.deliveries(), []); +}); + +test('a handler on pull_request.merged that throws, hangs or exhausts its retries never affects the merge step', async t => { + const s = await setup(t); + await pullRequestStateMigration(s.f.database); await reviewScoresMigration(s.f.database); + // The handler's time limit is far longer than a merge step may take, so a merge that waited for it would show. + const dispatcher = s.dispatcher({ timeoutMs: 1_500, retryDelaysMs: [1, 1], pollMs: 20 }); + dispatcher.start(); + t.after(() => dispatcher.stop()); + let pr = 100; + /** The real merge step: records the outcome and journals `pull_request.merged`. */ + const merge = async () => { + const started = performance.now(); + pr += 1; + await recordPullRequestOutcome(s.f.database, { repository: ALLOWED, prNumber: pr, action: 'closed', merged: true, mergedAt: new Date().toISOString() } as Parameters[1]); + const row = await s.f.database('notification_pull_request_state').where({ repository: ALLOWED, pr_number: pr }).first(); + return { ms: performance.now() - started, outcome: row?.outcome }; + }; + const settle = async (seq: number) => { + for (let i = 0; i < 1000; i++) { + const record = (await s.deliveries()).find(d => d.eventSeq === seq && d.handler === 'onMerged'); + if (record && (record.status === 'failed' || record.status === 'succeeded')) return record; + s.clock.now += 10; + await new Promise(r => setTimeout(r, 20)); + } + throw new Error(`delivery of ${seq} did not settle`); + }; + const lastSeq = async () => Number((await s.f.database('repository_events').max({ seq: 'seq' }).first())!.seq); + + for (const mode of ['throw', 'hang'] as const) { + await s.setState('mode', mode); + const result = await merge(); + assert.equal(result.outcome, 'merged', `${mode}: the merge is recorded`); + assert.ok(result.ms < 750, `${mode}: the merge step does not wait for the handler (${Math.round(result.ms)} ms)`); + const record = await settle(await lastSeq()); + assert.equal(record.status, 'failed', `${mode}: retries exhausted`); + assert.equal(record.attempt, 3); + assert.equal(record.timedOut, mode === 'hang'); + assert.match(record.error!, mode === 'hang' ? /did not finish within/ : /merge handler failure/); + } + // A merge while the hanging handler runs is not delayed either. + await s.setState('mode', 'hang'); + const pending = merge(); + const concurrent = await merge(); + assert.equal(concurrent.outcome, 'merged'); + assert.equal((await pending).outcome, 'merged'); + assert.ok(concurrent.ms < 750); +}); diff --git a/packages/core/test/pluginDispatcherGuards.test.ts b/packages/core/test/pluginDispatcherGuards.test.ts new file mode 100644 index 0000000000..092195787b --- /dev/null +++ b/packages/core/test/pluginDispatcherGuards.test.ts @@ -0,0 +1,299 @@ +import test, { mock } from 'node:test'; +import assert from 'node:assert/strict'; +import type { PluginHandlerRun } from '@propr/shared'; + +/** + * What stops a delivery once it has started: a pause reached mid-pass, an + * administrator's change to the installation while a handler runs, a manual + * retry interrupted by a crash, and journal retention emptying a repository. + */ +await mock.module('../src/utils/eventPublisher.js', { namedExports: { getEventPublisher: () => ({ publishActivity: async () => true }) } }); +const { EVENTS_FIXTURE_ID: ID } = await import('./helpers/pluginHandlerFixture.js'); +const { ALLOWED, SECOND, drain, setup, until } = await import('./helpers/pluginDispatcherFixture.js'); +const { listPluginActivity, prunePluginDeliveries, resumePluginDeliveries, retryPluginDelivery } = await import('../src/plugins/pluginDeliveries.js'); +const { runPluginHandler } = await import('../src/plugins/pluginHandlerRunner.js'); +const { pruneRepositoryEvents } = await import('../src/services/repositoryEventJournal.js'); + +const OLD = () => new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString(); +type Setup = Awaited>; + +/** A delivery to `flaky` of a `task.failed` event in the repository, failed for good. */ +async function failedDelivery(s: Setup, repository: string) { + await s.setState('fail', 1000); + const seq = await s.append(repository, 'task', 'failed', `task-${repository}`); + const dispatcher = s.dispatcher({ retryDelaysMs: [] }); + await drain(dispatcher); + await dispatcher.stop(); // hands the lease back + const record = (await s.deliveries()).find(d => d.eventSeq === seq)!; + assert.equal(record.status, 'failed'); + return { seq, record }; +} + +test('the pause reached in one repository stops the other repositories in the same pass', async t => { + const s = await setup(t); + await s.setState('fail', 1000); + const first = await s.append(ALLOWED, 'task', 'failed', 'task-a'); + await s.append(SECOND, 'task', 'failed', 'task-b'); + await s.append(SECOND, 'pull_request', 'opened', 1); + const dispatcher = s.dispatcher({ degradedAfterFailures: 1, pauseAfterFailures: 1 }); + assert.equal(await drain(dispatcher), 1, 'the attempt that paused it is the last one'); + assert.deepEqual((await s.seen()).map(e => e.seq), [first]); + assert.equal((await listPluginActivity(s.f.database, ID)).health.state, 'paused'); +}); + +test('a manual retry that reaches the pause stops ordinary delivery in the same pass', async t => { + const s = await setup(t); + // One failure so far; the ordinary delivery is in the repository visited first. + const { seq, record } = await failedDelivery(s, SECOND); + const later = await s.append(ALLOWED, 'pull_request', 'opened', 1); + await retryPluginDelivery(s.f.database, ID, String(record.id), '1', s.clock.now); + const dispatcher = s.dispatcher({ degradedAfterFailures: 1, pauseAfterFailures: 2 }); + assert.equal(await drain(dispatcher), 1); + assert.deepEqual((await s.seen()).map(e => e.seq), [seq, seq], 'only the manual attempt ran'); + assert.equal((await listPluginActivity(s.f.database, ID)).health.state, 'paused'); + + await resumePluginDeliveries(s.f.database, ID, '1', { now: s.clock.now }); + assert.equal(await drain(dispatcher), 1); + assert.equal((await s.seen()).at(-1)!.seq, later, 'delivered after Resume'); +}); + +test('a manual retry interrupted by a crash is attempted again by the next lease holder, with the same id, once', async t => { + const s = await setup(t); + const { seq, record } = await failedDelivery(s, ALLOWED); + await retryPluginDelivery(s.f.database, ID, String(record.id), '1', s.clock.now); + let release!: (run: PluginHandlerRun) => void; + const hung = new Promise(resolve => { release = resolve; }); + const crashed = s.dispatcher({ owner: 'worker-c', leaseMs: 60_000, run: () => hung }); + const crashedPass = crashed.pass(); + await until(async () => (await s.deliveries())[0]?.status === 'running', 'the manual attempt to start'); + const next = s.dispatcher({ owner: 'worker-d' }); + assert.equal(await drain(next), 0, 'not while the lease lasts'); + s.clock.now += 60_001; + // Still failing: the recovered attempt is the retry's one attempt, so the delivery fails for good. + assert.equal(await drain(next), 1); + const [after] = await s.deliveries(); + assert.equal(after.status, 'failed'); assert.equal(after.attempt, 3); assert.equal(after.nextAttemptAt, null); + assert.deepEqual((await s.seen()).at(-1), { id: `evt-${seq}-flaky`, seq, repository: ALLOWED, attempt: 3 }); + assert.equal(await drain(next), 0, 'no automatic retries after it'); + release({ outcome: 'succeeded', timedOut: false, durationMs: 1, error: null, logs: [] }); + await crashedPass; + assert.equal((await s.deliveries())[0].status, 'failed', 'the stale worker records nothing'); + // It can be retried again. + await s.setState('fail', 0); + await retryPluginDelivery(s.f.database, ID, String(after.id), '1', s.clock.now); + assert.equal(await drain(next), 1); + assert.equal((await s.deliveries())[0].status, 'succeeded'); +}); + +test('a manual retry of an event outside the current repository limit is not handed to the handler', async t => { + const s = await setup(t); + const { seq, record } = await failedDelivery(s, SECOND); + await s.f.store.setRepositories('1', ID, [ALLOWED]); + await s.setState('fail', 0); + await retryPluginDelivery(s.f.database, ID, String(record.id), '1', s.clock.now); + assert.equal(await drain(s.dispatcher()), 0); + assert.deepEqual((await s.seen()).map(e => e.seq), [seq], 'the handler did not see it again'); + const [after] = await s.deliveries(); + assert.equal(after.status, 'failed'); assert.match(after.error!, /outside the plugin's repository limit/); +}); + +const changes = { + 'narrowing the repository limit': (s: Setup) => s.f.store.setRepositories('1', ID, [ALLOWED]), + 'disabling the plugin': (s: Setup) => s.f.store.setEnabled('1', ID, false), + 'disabling and enabling it again': async (s: Setup) => { await s.f.store.setEnabled('1', ID, false); await s.f.store.setEnabled('1', ID, true); }, + 'revoking events.read': (s: Setup) => s.f.database('plugin_installations').where({ plugin_id: ID }).update({ granted_permissions: '[]' }), +}; +for (const [change, apply] of Object.entries(changes)) { + test(`${change} while a handler runs stops the deliveries after it`, async t => { + const s = await setup(t); + const first = await s.append(ALLOWED, 'pull_request', 'opened', 1); + await s.append(SECOND, 'pull_request', 'opened', 2); + await s.append(SECOND, 'task', 'failed', 'task-b'); + let changed = false; + const run: typeof runPluginHandler = async (...args) => { + if (!changed) { changed = true; await apply(s); } + return runPluginHandler(...args); + }; + await drain(s.dispatcher({ run })); + assert.deepEqual((await s.seen()).map(e => e.seq), [first], 'nothing after the change reached the handler'); + assert.deepEqual((await s.deliveries()).filter(d => d.attempt > 0).map(d => d.eventSeq), [first], 'and no attempt was recorded'); + }); +} + +test('a repository that retention emptied still has its gap recorded and its unfinished deliveries finished', async t => { + const s = await setup(t); + await s.setState('fail', 1000); + const dispatcher = s.dispatcher(); + const prunedFailing = await s.append(ALLOWED, 'task', 'failed', 'task-a', {}, OLD()); + await s.append(ALLOWED, 'pull_request', 'opened', 1, {}, OLD()); + const retainedFailing = await s.append(SECOND, 'task', 'failed', 'task-b'); + await drain(dispatcher); + assert.equal((await s.deliveries()).filter(d => d.status === 'retrying').length, 2); + const prunedSecond = await s.append(SECOND, 'pull_request', 'opened', 2, {}, OLD()); + await pruneRepositoryEvents(s.f.database); + assert.equal(await s.f.database('repository_events').where({ repository: ALLOWED }).first(), undefined, 'nothing of the repository is left'); + + await s.setState('fail', 0); + s.clock.now += 24 * 60 * 60 * 1000; + await drain(dispatcher); + const records = await s.deliveries(); + const gaps = records.filter(d => d.kind === 'gap'); + assert.deepEqual(gaps.map(g => g.repository).sort(), [ALLOWED, SECOND]); + assert.equal(gaps.find(g => g.repository === ALLOWED)!.eventSeq, prunedFailing); + const removed = records.find(d => d.eventSeq === prunedFailing && d.kind === 'event')!; + assert.equal(removed.status, 'skipped'); assert.match(removed.error!, /left the journal/); + const retained = records.find(d => d.eventSeq === retainedFailing && d.kind === 'event')!; + assert.equal(retained.status, 'succeeded', 'an event still in the journal below the floor is delivered'); + assert.ok(!(await s.seen()).some(e => e.seq === prunedSecond)); + await drain(dispatcher); + assert.equal((await s.deliveries()).filter(d => d.kind === 'gap').length, 2, 'a gap is recorded once'); + assert.equal(await prunePluginDeliveries(s.f.database, { now: () => s.clock.now + 15 * 24 * 60 * 60 * 1000 }), 4, 'nothing is left unfinished'); +}); + +test('a repository emptied before the plugin ever delivered to it has its gap recorded', async t => { + const s = await setup(t); + const pruned = await s.append(SECOND, 'pull_request', 'opened', 1, {}, OLD()); + await pruneRepositoryEvents(s.f.database); + await drain(s.dispatcher()); + const [gap] = await s.deliveries(); + assert.equal(gap.kind, 'gap'); assert.equal(gap.repository, SECOND); assert.equal(gap.eventSeq, pruned); +}); + +/** Runs `change` once, the first time the dispatcher's private `method` is called (before or after the original). */ +function intercept(dispatcher: object, method: 'acquire' | 'cursor' | 'state', change: () => Promise, when: 'before' | 'after') { + const internals = dispatcher as Record Promise>; + const original = internals[method].bind(dispatcher); + let done = false; + internals[method] = async (...args) => { + const first = !done; done = true; + if (first && when === 'before') await change(); + const value = await original(...args); + if (first && when === 'after') await change(); + return value; + }; +} +const enableAgain = async (s: Setup) => { await s.f.store.setEnabled('1', ID, false); await s.f.store.setEnabled('1', ID, true); }; + +for (const [moment, method, when] of [ + ['between reading the installation and taking its lease', 'acquire', 'before'], + ['between creating the cursor and the delivery record', 'cursor', 'after'], +] as const) { + test(`enabling again ${moment} leaves no state of the earlier start behind: later events are delivered`, async t => { + const s = await setup(t); + const first = s.dispatcher(); + assert.equal(await drain(first), 0, 'the dispatch state starts at the journal end'); + await first.stop(); // hands the lease back + const earlier = await s.append(ALLOWED, 'pull_request', 'opened', 1); + const dispatcher = s.dispatcher(); + intercept(dispatcher, method, () => enableAgain(s), when); + assert.equal(await drain(dispatcher), 0, 'the event before enabling again is not delivered'); + assert.deepEqual(await s.f.database('plugin_delivery_cursors').where({ plugin_id: ID }).select(), [], 'no cursor at the earlier start'); + assert.deepEqual(await s.f.database('plugin_deliveries').where({ plugin_id: ID, event_seq: earlier }).select(), [], 'no record of the earlier event'); + + const later = await s.append(ALLOWED, 'pull_request', 'opened', 2); + assert.equal(await drain(dispatcher), 1); + assert.deepEqual((await s.seen()).map(e => e.seq), [later]); + const [cursor] = await s.f.database('plugin_delivery_cursors').where({ plugin_id: ID }).select() as { cursor_seq: number }[]; + assert.equal(Number(cursor.cursor_seq), later); + }); +} + +/** Install again after an uninstall (a later `installed_at`), limited and enabled as before. */ +const installAgain = async (s: Setup) => { + await new Promise(resolve => setTimeout(resolve, 5)); + await s.f.store.install('1', ID); + await s.f.store.setRepositories('1', ID, [ALLOWED, SECOND]); + await s.f.store.setEnabled('1', ID, true); +}; +const reinstall = async (s: Setup) => { await s.f.store.uninstall('1', ID, ID); await installAgain(s); }; +const dispatchRow = (s: Setup) => s.f.database('plugin_dispatch').where({ plugin_id: ID }).first() as Promise<{ installed_at: number; start_seq: number } | undefined>; + +test('a reinstall between reading the installation and its dispatch state keeps the replacement\'s start: its events are delivered', async t => { + const s = await setup(t); + const dispatcher = s.dispatcher(); + let event = 0; + let replacement: { installed_at: number; start_seq: number } | undefined; + intercept(dispatcher, 'state', async () => { + await reinstall(s); + replacement = await dispatchRow(s); + event = await s.append(ALLOWED, 'pull_request', 'opened', 1); + }, 'before'); + assert.equal((await dispatcher.pass()).attempts, 0, 'the stale installation stops'); + assert.deepEqual(await dispatchRow(s), replacement, 'the replacement\'s state is not written over'); + assert.equal(await drain(dispatcher), 1); + assert.deepEqual((await s.seen()).map(e => e.seq), [event], 'the event after the replacement was enabled is delivered'); +}); + +test('an uninstall between reading the installation and its missing dispatch state creates none', async t => { + const s = await setup(t); + await s.f.database('plugin_dispatch').where({ plugin_id: ID }).delete(); + const dispatcher = s.dispatcher(); + intercept(dispatcher, 'state', () => s.f.store.uninstall('1', ID, ID), 'before'); + assert.equal((await dispatcher.pass()).attempts, 0); + assert.equal(await dispatchRow(s), undefined, 'no state for a plugin that is not installed'); + await installAgain(s); + const event = await s.append(ALLOWED, 'pull_request', 'opened', 1); + assert.equal(await drain(dispatcher), 1); + assert.deepEqual((await s.seen()).map(e => e.seq), [event]); +}); + +test('an installation whose dispatch state is missing gets one at the journal end', async t => { + const s = await setup(t); + const earlier = await s.append(ALLOWED, 'pull_request', 'opened', 1); + await s.f.database('plugin_dispatch').where({ plugin_id: ID }).delete(); + const dispatcher = s.dispatcher(); + assert.equal(await drain(dispatcher), 0, 'history is not replayed'); + const row = await dispatchRow(s); + assert.equal(Number(row?.start_seq), earlier); + assert.equal(Number(row?.installed_at), (await s.f.store.get('1', ID)).installation!.installedAt); + const later = await s.append(ALLOWED, 'pull_request', 'opened', 2); + assert.equal(await drain(dispatcher), 1); + assert.deepEqual((await s.seen()).map(e => e.seq), [later]); +}); + +test('a Degraded notification whose send failed is sent again until it succeeds, once, even after the pause', async t => { + const s = await setup(t); + await s.setState('fail', 1000); + const sent: unknown[] = []; + let inboxDown = true; + const notify = async (notice: unknown) => { if (inboxDown) throw new Error('inbox down'); sent.push(notice); }; + const dispatcher = s.dispatcher({ degradedAfterFailures: 1, pauseAfterFailures: 2, retryDelaysMs: [], notify }); + await s.append(ALLOWED, 'task', 'failed', 'task-a'); + await s.append(ALLOWED, 'task', 'failed', 'task-b'); + const degradedAt = s.clock.now; + assert.equal(await drain(dispatcher), 2); + assert.equal((await listPluginActivity(s.f.database, ID)).health.state, 'paused'); + const owed = async () => (await s.f.database('plugin_dispatch').where({ plugin_id: ID }).first() as { degraded_notice: string | null }).degraded_notice; + assert.notEqual(await owed(), null, 'the notification is still owed'); + + // Failing again: a pass tries once a minute, not on every pass. + await dispatcher.pass(); + s.clock.now += 60_000; + await dispatcher.pass(); + assert.deepEqual(sent, []); + inboxDown = false; + await dispatcher.pass(); + assert.deepEqual(sent, [], 'not before the retry wait'); + s.clock.now += 60_000; + await dispatcher.pass(); + const installedAt = (await s.f.store.get('1', ID)).installation!.installedAt; + assert.deepEqual(sent, [{ pluginId: ID, installedAt, degradedAt, consecutiveFailures: 1, lastError: (await s.deliveries()).at(-1)!.error }]); + assert.equal(await owed(), null, 'cleared once sent'); + s.clock.now += 60_000; + await dispatcher.pass(); + await s.dispatcher({ notify }).pass(); + assert.equal(sent.length, 1, 'sent once'); +}); + +test('a Degraded notification owed when the worker stopped is sent by the next dispatcher', async t => { + const s = await setup(t); + await s.setState('fail', 1000); + // The worker exits after recording the transition, before its notifier finishes. + const crashed = s.dispatcher({ degradedAfterFailures: 1, retryDelaysMs: [], notify: () => new Promise(() => undefined) }); + await s.append(ALLOWED, 'task', 'failed', 'task-a'); + void crashed.pass(); + await until(async () => (await s.f.database('plugin_dispatch').where({ plugin_id: ID }).first() as { degraded_notice: string | null } | undefined)?.degraded_notice != null, 'the Degraded transition'); + await s.dispatcher({ owner: 'worker-e' }).pass(); + assert.equal(s.notices.length, 1); + assert.equal((s.notices[0] as { pluginId: string }).pluginId, ID); +}); diff --git a/packages/core/test/pluginDispatcherOwnership.test.ts b/packages/core/test/pluginDispatcherOwnership.test.ts new file mode 100644 index 0000000000..6bc6a4ba26 --- /dev/null +++ b/packages/core/test/pluginDispatcherOwnership.test.ts @@ -0,0 +1,130 @@ +import test, { mock } from 'node:test'; +import assert from 'node:assert/strict'; + +/** + * Obligations a worker must not act on once it lost them: the lease (a worker + * suspended after admitting an attempt must not start it, nor finish a manual + * retry, once another worker took the lease) and Degraded notifications still + * owed (a later transition never replaces one not sent yet). + */ +await mock.module('../src/utils/eventPublisher.js', { namedExports: { getEventPublisher: () => ({ publishActivity: async () => true }) } }); +const { EVENTS_FIXTURE_ID: ID } = await import('./helpers/pluginHandlerFixture.js'); +const { ALLOWED, drain, setup } = await import('./helpers/pluginDispatcherFixture.js'); +const { listPluginActivity, retryPluginDelivery } = await import('../src/plugins/pluginDeliveries.js'); + +type Internals = Record Promise>; +/** Runs `change` once, after the first call of the dispatcher's private `method` returned, before the caller goes on. */ +function after(dispatcher: object, method: 'admit' | 'acquire', change: () => Promise) { + const internals = dispatcher as Internals; + const original = internals[method].bind(dispatcher); + let done = false; + internals[method] = async (...args) => { + const value = await original(...args); + if (!done) { done = true; await change(); } + return value; + }; +} +/** Another worker takes the installation's lease, as `acquire` does once it expired. */ +const takeLease = (other: object) => (other as Internals).acquire(ID) as Promise; + +test('a worker suspended between admitting an attempt and claiming it past its lease does not start the handler', async t => { + const s = await setup(t); + const seq = await s.append(ALLOWED, 'pull_request', 'opened', 1); + const stale = s.dispatcher({ owner: 'worker-a' }); + const holder = s.dispatcher({ owner: 'worker-b' }); + after(stale, 'admit', async () => { + s.clock.now += 2 * 60_000 + 1; // past the default lease + assert.equal(await takeLease(holder), true, 'the expired lease is taken over'); + }); + assert.equal((await stale.pass()).attempts, 0, 'the worker that lost the lease starts nothing'); + assert.deepEqual(await s.seen(), [], 'the handler did not run'); + const [record] = await s.deliveries(); + assert.equal(record.status, 'pending'); assert.equal(record.attempt, 0); + + assert.equal(await drain(holder), 1); + assert.deepEqual(await s.seen(), [{ id: `evt-${seq}-record`, seq, repository: ALLOWED, attempt: 1 }], 'delivered once, by the lease holder'); +}); + +test('a claim renews the lease: no other worker takes over between the claim and the handler', async t => { + const s = await setup(t); + await s.append(ALLOWED, 'pull_request', 'opened', 1); + const leaseMs = 60_000; + const other = s.dispatcher({ owner: 'worker-b', leaseMs }); + let tookOver: boolean | undefined; + const worker = s.dispatcher({ + owner: 'worker-a', leaseMs, + run: async () => { + // Within one lease of the claim, but more than one lease after admission renewed it. + s.clock.now += leaseMs - 1; + tookOver = await takeLease(other); + return { outcome: 'succeeded', timedOut: false, durationMs: 1, error: null, logs: [] }; + }, + }); + after(worker, 'admit', async () => { s.clock.now += leaseMs - 1; }); + assert.equal((await worker.pass()).attempts, 1); + assert.equal(tookOver, false); + assert.equal((await s.deliveries())[0].status, 'succeeded'); +}); + +test('a worker that lost the lease before finishing a manual retry of an event gone from the journal writes nothing', async t => { + const s = await setup(t); + await s.setState('fail', 1000); + const seq = await s.append(ALLOWED, 'task', 'failed', 'task-a'); + const first = s.dispatcher({ retryDelaysMs: [] }); + await drain(first); + await first.stop(); + const [failed] = await s.deliveries(); + await retryPluginDelivery(s.f.database, ID, String(failed.id), '1', s.clock.now); + await s.f.database('repository_events').where({ seq }).delete(); + + const stale = s.dispatcher({ owner: 'worker-a' }); + const holder = s.dispatcher({ owner: 'worker-b' }); + after(stale, 'acquire', async () => { + s.clock.now += 2 * 60_000 + 1; + assert.equal(await takeLease(holder), true); + }); + await stale.pass(); + const [untouched] = await s.deliveries(); + assert.equal(untouched.status, 'pending', 'the stale worker did not make the retry final'); + + await holder.pass(); + const [final] = await s.deliveries(); + assert.equal(final.status, 'failed'); assert.match(final.error!, /no longer in the journal/); +}); + +test('a Degraded notification owed from an earlier transition is still sent after recovery and a second degradation', async t => { + const s = await setup(t); + const sent: { degradedAt: number }[] = []; + let inboxDown = true; + const notify = async (notice: { degradedAt: number }) => { if (inboxDown) throw new Error('inbox down'); sent.push(notice); }; + const dispatcher = s.dispatcher({ degradedAfterFailures: 1, retryDelaysMs: [], notify }); + + await s.setState('fail', 1); + await s.append(ALLOWED, 'task', 'failed', 'task-a'); + const firstAt = s.clock.now; + assert.equal(await drain(dispatcher), 1); + assert.equal((await listPluginActivity(s.f.database, ID)).health.state, 'degraded'); + + s.clock.now += 1_000; + await s.append(ALLOWED, 'task', 'failed', 'task-b'); // succeeds: Degraded clears + assert.equal(await drain(dispatcher), 1); + assert.equal((await listPluginActivity(s.f.database, ID)).health.state, 'healthy'); + + s.clock.now += 1_000; + await s.setState('fail', 1); + await s.append(ALLOWED, 'task', 'failed', 'task-c'); + const secondAt = s.clock.now; + assert.equal(await drain(dispatcher), 1); + assert.equal((await listPluginActivity(s.f.database, ID)).health.state, 'degraded'); + assert.deepEqual(sent, []); + + inboxDown = false; + s.clock.now += 60_000; + await dispatcher.pass(); + assert.deepEqual(sent.map(n => n.degradedAt), [firstAt, secondAt], 'both transitions are notified, oldest first'); + const owed = await s.f.database('plugin_dispatch').where({ plugin_id: ID }).first() as { degraded_notice: string | null }; + assert.equal(owed.degraded_notice, null, 'nothing is owed once both were sent'); + s.clock.now += 60_000; + await dispatcher.pass(); + assert.equal(sent.length, 2, 'each sent once'); +}); diff --git a/packages/core/test/pluginDispatcherStreams.test.ts b/packages/core/test/pluginDispatcherStreams.test.ts new file mode 100644 index 0000000000..c0ccd8da25 --- /dev/null +++ b/packages/core/test/pluginDispatcherStreams.test.ts @@ -0,0 +1,87 @@ +import test, { mock } from 'node:test'; +import assert from 'node:assert/strict'; + +/** + * What a delivery stream relies on: every spelling of a repository name is one + * ordered stream, appends arriving without pause never hold delivery back, and + * pruning keeps the records an event that will be read again still needs. + */ +await mock.module('../src/utils/eventPublisher.js', { namedExports: { getEventPublisher: () => ({ publishActivity: async () => true }) } }); +const { EVENTS_FIXTURE_ID: ID } = await import('./helpers/pluginHandlerFixture.js'); +const { ALLOWED, drain, setup, until } = await import('./helpers/pluginDispatcherFixture.js'); +const { listPluginActivity, prunePluginDeliveries, resumePluginDeliveries } = await import('../src/plugins/pluginDeliveries.js'); + +const DAY = 24 * 60 * 60 * 1000; + +test('events of one repository under different letter case are one ordered stream, held back by one retry', async t => { + const s = await setup(t); + const a1 = await s.append('owner/allowed', 'pull_request', 'opened', 1); + const a2 = await s.append('Owner/Allowed', 'pull_request', 'opened', 2); + const a3 = await s.append('OWNER/ALLOWED', 'pull_request', 'opened', 3); + const dispatcher = s.dispatcher(); + assert.equal(await drain(dispatcher), 3); + assert.deepEqual((await s.seen()).map(e => e.seq), [a1, a2, a3], 'journal order across spellings'); + assert.deepEqual(await s.f.database('plugin_delivery_cursors').where({ plugin_id: ID }).pluck('repository'), [ALLOWED], 'one cursor'); + + // A retry waiting on one spelling holds back the later event under another. + await s.setState('fail', 1); + const failing = await s.append('owner/allowed', 'task', 'failed', 'task-1'); + const behind = await s.append('Owner/Allowed', 'pull_request', 'opened', 4); + await drain(dispatcher); + assert.deepEqual((await s.seen()).slice(3).map(e => e.seq), [failing]); + s.clock.now += 60_000; + await drain(dispatcher); + assert.deepEqual((await s.seen()).slice(3).map(e => [e.seq, e.attempt]), [[failing, 1], [failing, 2], [behind, 1]]); +}); + +test('appends arriving without pause do not hold the next pass back', async t => { + const s = await setup(t); + const dispatcher = s.dispatcher({ pollMs: 60_000 }); + dispatcher.start(); + t.after(() => dispatcher.stop()); + await new Promise(resolve => setTimeout(resolve, 100)); // the first pass ran; the next poll is a minute away + const first = await s.append(ALLOWED, 'pull_request', 'opened', 1); + // Each append comes well within the 200 ms wake delay of the previous one. + for (let i = 2; i < 60 && !(await s.seen()).some(e => e.seq === first); i++) { + await new Promise(resolve => setTimeout(resolve, 40)); + await s.append(ALLOWED, 'pull_request', 'opened', i); + } + assert.ok((await s.seen()).some(e => e.seq === first), 'delivered while appends kept arriving'); + await until(async () => (await s.seen()).length > 1, 'later appends delivered'); +}); + +test('pruning keeps the records of an event that a pause left ahead of the cursor: a handler that succeeded is not run again', async t => { + const s = await setup(t); + await s.setState('fail', 1); + const dispatcher = s.dispatcher({ degradedAfterFailures: 1, pauseAfterFailures: 1 }); + const closed = await s.append(ALLOWED, 'pull_request', 'closed', 1); + await drain(dispatcher); + assert.equal((await listPluginActivity(s.f.database, ID)).health.state, 'paused'); + assert.deepEqual((await s.seen()).map(e => [e.id, e.attempt]), [[`evt-${closed}-record`, 1], [`evt-${closed}-flaky`, 1]]); + + // Paused past the retention period: the succeeded record is still needed. + assert.equal(await prunePluginDeliveries(s.f.database, { now: () => s.clock.now + 15 * DAY }), 0); + await resumePluginDeliveries(s.f.database, ID, '1', { now: s.clock.now }); + await drain(dispatcher); + assert.deepEqual((await s.seen()).map(e => [e.id, e.attempt]).slice(2), [[`evt-${closed}-flaky`, 2]], 'only the retrying handler runs'); + // Once the cursor is past the event, its records go. + assert.equal(await prunePluginDeliveries(s.f.database, { now: () => s.clock.now + 15 * DAY }), 2); +}); + +test('pruning keeps a final failure that paused deliveries before the cursor moved: Resume does not restart its retries', async t => { + const s = await setup(t); + await s.setState('fail', 1000); + const dispatcher = s.dispatcher({ degradedAfterFailures: 1, pauseAfterFailures: 1, retryDelaysMs: [] }); + const failing = await s.append(ALLOWED, 'task', 'failed', 'task-1'); + const later = await s.append(ALLOWED, 'pull_request', 'opened', 1); + await drain(dispatcher); + assert.equal((await listPluginActivity(s.f.database, ID)).health.state, 'paused'); + assert.equal(await prunePluginDeliveries(s.f.database, { now: () => s.clock.now + 15 * DAY }), 0); + + await resumePluginDeliveries(s.f.database, ID, '1', { now: s.clock.now }); + await drain(dispatcher); + assert.deepEqual((await s.seen()).map(e => [e.seq, e.attempt]), [[failing, 1], [later, 1]], 'the failed delivery is not attempted again'); + const record = (await s.deliveries()).find(d => d.eventSeq === failing)!; + assert.equal(record.status, 'failed'); assert.equal(record.attempt, 1); + assert.equal(await prunePluginDeliveries(s.f.database, { now: () => s.clock.now + 15 * DAY }), 2); +}); diff --git a/packages/core/test/pluginHandlerLifecycle.test.ts b/packages/core/test/pluginHandlerLifecycle.test.ts new file mode 100644 index 0000000000..9d39b65bff --- /dev/null +++ b/packages/core/test/pluginHandlerLifecycle.test.ts @@ -0,0 +1,632 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { PluginHandlerInput, PluginJsonValue } from '@propr/shared'; +import { runPluginHandler } from '../src/plugins/pluginHandlerRunner.js'; +import { createPluginHttp, scrubCredentialValues, type PluginHttpTransport, type PluginHttpTransportRequest } from '../src/plugins/pluginHttp.js'; +import { PluginStore } from '../src/plugins/pluginStore.js'; +import { githubPluginProprBackend } from '../src/plugins/pluginProprBackend.js'; +import { pluginStateStore, requireInstallation } from '../src/plugins/pluginStateStore.js'; +import { installationAuthorizer } from '../src/plugins/pluginContext.js'; +import { ENVIRONMENT, FIXTURE_ID, lookup, pluginHandlerFixture } from './helpers/pluginHandlerFixture.js'; + +const invoke = (payload: PluginJsonValue = {}): PluginHandlerInput => ({ kind: 'invoke', payload }); +/** Header-valid, but a JSON string must escape it: a quotation mark and a backslash. */ +const ESCAPED_SECRET = 'fixture"quoted\\back/slash-5d0e9a'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} +const tick = () => new Promise(resolve => setTimeout(resolve, 20)); + +/** JSON with every character of every string escaped as \uXXXX, as some serializers write it. */ +const unicodeEscapedJson = (value: Record) => `{${Object.entries(value).map(([k, v]) => `"${k}":"${[...v].map(c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`).join('')}"`).join(',')}}`; + +test('a JSON-escaped echo of the credential is scrubbed before the handler can decode it', async t => { + const f = await pluginHandlerFixture(t, { secret: ESCAPED_SECRET }); + const probe = async (transport: PluginHttpTransport) => { + const run = await runPluginHandler(FIXTURE_ID, 'fetchProbe', invoke({ url: 'https://api.fixture.example/v1', connection: 'api-token', probe: ESCAPED_SECRET }), { ...f.options, transport }); + assert.equal(run.outcome, 'succeeded', run.error ?? ''); + return run.result as Record; + }; + const clean = { ok: true, textHasProbe: false, parsedHasProbe: false, jsonHasProbe: false, headersHaveProbe: false, parsedHeadersHaveProbe: false, headerNamesHaveProbe: false, urlDecodedHasProbe: false }; + + // JSON.stringify escaping (the fixture echo transport): `\"` and `\\`. + assert.deepEqual(await probe(f.http.transport), clean); + assert.equal(f.http.requests[0].headers.authorization, `Bearer ${ESCAPED_SECRET}`, 'the upstream did receive the credential'); + // Every character \u-escaped, `/` escaped, and the credential as an object key. + const echo = (body: (authorization: string) => string): PluginHttpTransport => async request => ({ status: 200, headers: {}, body: Buffer.from(body(request.headers.authorization ?? '')) }); + assert.deepEqual(await probe(echo(authorization => unicodeEscapedJson({ authorization }))), clean); + assert.deepEqual(await probe(echo(authorization => JSON.stringify({ token: authorization.slice(7) }).split('/').join('\\/'))), clean); + assert.deepEqual(await probe(echo(authorization => JSON.stringify({ [authorization.slice(7)]: 1 }))), clean); + + // The same escapes in JSON-valued response headers: alone, an object, joined with another value, and escaped twice. + const unicodeEscaped = (value: string) => [...value].map(c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`).join(''); + const echoHeader = (header: (authorization: string) => string): PluginHttpTransport => async request => ({ status: 200, headers: { 'x-echo': header(request.headers.authorization ?? '') }, body: Buffer.from('{}') }); + for (const header of [ + (authorization: string) => `"${unicodeEscaped(authorization)}"`, + (authorization: string) => unicodeEscapedJson({ token: authorization.slice(7) }), + (authorization: string) => `"${unicodeEscaped(authorization.slice(7))}", other`, + (authorization: string) => JSON.stringify(`"${unicodeEscaped(authorization.slice(7))}"`), + ]) { + const result = await probe(echoHeader(header)); + assert.deepEqual(result, clean, header.toString()); + } +}); + +test('a JSON-valued response header without the credential reaches the handler unchanged', async () => { + const value = '{"note":"\\u0041 \\"quoted\\""}'; + const response = await createPluginHttp({ outboundHosts: ['api.fixture.example'], credential: async () => ESCAPED_SECRET, lookup, transport: async () => ({ status: 200, headers: { 'x-meta': value }, body: Buffer.from('') }) })('https://api.fixture.example/v1'); + assert.equal(response.headers['x-meta'], value); +}); + +test('a response header whose name echoes the credential, in any spelling or case, never reaches the handler', async t => { + // Mixed case and a `/`: names arrive lowercased, so the base64 and `%2F` spellings arrive folded too. + const secret = 'Mixed/Case-Sentinel-5d0e9a'; + const f = await pluginHandlerFixture(t, { secret }); + const unicodeEscaped = (value: string) => [...value].map(c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`).join(''); + const names = (token: string) => [ + `X-${token}`, `x-${token.toLowerCase()}`, `x-b64-${Buffer.from(token).toString('base64url').toLowerCase()}`, + `x-hex-${Buffer.from(token).toString('hex')}`, `x-url-${encodeURIComponent(token).toLowerCase()}`, `x-json-${unicodeEscaped(token)}`, + `x-full-${encodeURIComponent(`Bearer ${token}`).toLowerCase()}`, + ]; + const transport: PluginHttpTransport = async request => ({ + status: 200, body: Buffer.from('{}'), + headers: { 'Content-Type': 'application/json', 'X-Request-Id': 'req-1', ...Object.fromEntries(names(request.headers.authorization!.slice(7)).map(name => [name, 'innocuous'])) }, + }); + const payload = { url: 'https://api.fixture.example/v1', connection: 'api-token' }; + + const probed = await runPluginHandler(FIXTURE_ID, 'fetchProbe', invoke({ ...payload, probe: secret }), { ...f.options, transport }); + assert.equal(probed.outcome, 'succeeded', probed.error ?? ''); + assert.equal((probed.result as { headerNamesHaveProbe: boolean }).headerNamesHaveProbe, false); + + // The handler sees the other headers, by their lowercased names, unchanged. + const fetched = await runPluginHandler(FIXTURE_ID, 'fetchUrl', invoke(payload), { ...f.options, transport }); + const { headers } = fetched.result as { headers: Record }; + assert.deepEqual(headers, { 'content-type': 'application/json', 'x-request-id': 'req-1' }); + assert.equal(JSON.stringify(fetched).toLowerCase().includes(secret.toLowerCase()), false); +}); + +test('text() of a non-JSON body holds no JSON-escaped credential, and a clean JSON body is returned unchanged', async () => { + const transportFor = (body: (authorization: string) => string): PluginHttpTransport => async request => ({ status: 200, headers: { 'x-echo': JSON.stringify(request.headers.authorization) }, body: Buffer.from(body(request.headers.authorization ?? '')) }); + const fetchWith = (transport: PluginHttpTransport) => createPluginHttp({ outboundHosts: ['api.fixture.example'], credential: async () => ESCAPED_SECRET, lookup, transport })('https://api.fixture.example/v1'); + const escaped = JSON.stringify(ESCAPED_SECRET).slice(1, -1); + + const html = await fetchWith(transportFor(authorization => ``)); + const text = await html.text(); + assert.equal(text.includes(escaped), false, text); + assert.equal(text.includes(ESCAPED_SECRET), false, text); + assert.equal(html.headers['x-echo'].includes(escaped), false, html.headers['x-echo']); + + const untouched = '{ "a" : [1, 2],\n "b": "\\u0041" }'; + assert.equal(await (await fetchWith(transportFor(() => untouched))).text(), untouched, 'formatting is kept when no credential was found'); +}); + +/** A credential with a character `encodeURIComponent` escapes (`/`, as `%2F`). */ +const PERCENT_SECRET = 'plugin/credential-sentinel-5d0e9a'; +/** `encodeURIComponent` with lowercase hex digits (`%2f`), as some encoders write it. */ +const lowerHexEncoded = (value: string) => encodeURIComponent(value).replace(/%[0-9A-F]{2}/g, escape => escape.toLowerCase()); +/** Every character percent-escaped, letters included. */ +const everyCharEscaped = (value: string) => [...value].map(c => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`).join(''); +/** Whether URL-decoding `text` again and again (each escape alone, as `unescape()` does: `%2f`, `%u002f`) spells `secret`. */ +function urlRecovers(text: string, secret: string): boolean { + for (let current = text, round = 0; round < 8; round++) { + if (current.includes(secret)) return true; + const next = current.replace(/%(?:u[0-9a-f]{4}|[0-9a-f]{2})/gi, escape => String.fromCharCode(parseInt(escape.replace(/^%u?/i, ''), 16))); + if (next === current) return false; + current = next; + } + return false; +} + +test('a percent-encoded echo of the credential, in either case of hex digits, is scrubbed before the handler can decode it', async t => { + const f = await pluginHandlerFixture(t, { secret: PERCENT_SECRET }); + const clean = { ok: true, textHasProbe: false, parsedHasProbe: false, jsonHasProbe: false, headersHaveProbe: false, parsedHeadersHaveProbe: false, headerNamesHaveProbe: false, urlDecodedHasProbe: false }; + // The upstream echoes the credential encoded: in a header value, a form-style header and a JSON or form body. + const echo = (encode: (secret: string) => string, body: 'json' | 'form' = 'json'): PluginHttpTransport => async request => { + const encoded = encode((request.headers.authorization ?? '').slice('Bearer '.length)); + return { status: 200, headers: { 'x-echo': encoded, 'x-form': `token=${encoded}&page=2` }, body: Buffer.from(body === 'json' ? JSON.stringify({ token: encoded, keep: 'visible' }) : `token=${encoded}&page=2`) }; + }; + const probe = async (transport: PluginHttpTransport) => { + const run = await runPluginHandler(FIXTURE_ID, 'fetchProbe', invoke({ url: 'https://api.fixture.example/v1', connection: 'api-token', probe: PERCENT_SECRET }), { ...f.options, transport }); + assert.equal(run.outcome, 'succeeded', run.error ?? ''); + return run.result; + }; + const encodings: [string, (secret: string) => string][] = [ + ['lowercase hex', lowerHexEncoded], + ['the header value, lowercase hex', secret => lowerHexEncoded(`Bearer ${secret}`)], + ['every character escaped', everyCharEscaped], + ['every character escaped, uppercase hex', secret => everyCharEscaped(secret).toUpperCase()], + ['URL-encoded twice', secret => encodeURIComponent(lowerHexEncoded(secret))], + ['%u escapes', secret => secret.replace('/', '%u002f')], + ]; + for (const [name, encode] of encodings) { + for (const body of ['json', 'form'] as const) assert.deepEqual(await probe(echo(encode, body)), clean, `${name}, ${body} body`); + } + // A percent escape inside a JSON escape: JSON.parse turns `\u0025` into the `%` of `%2f`. + const viaJson = (secret: string) => secret.replace('/', '\\u00252f'); + assert.deepEqual(await probe(async request => { + const secret = (request.headers.authorization ?? '').slice('Bearer '.length); + return { status: 200, headers: { 'x-echo': `"${viaJson(secret)}"` }, body: Buffer.from(`{"token":"${viaJson(secret)}"}`) }; + }), clean, 'percent escape inside a JSON escape'); + + // The returned run (result and logs) holds no spelling that URL-decodes to the credential. + const fetched = await runPluginHandler(FIXTURE_ID, 'fetchUrl', invoke({ url: 'https://api.fixture.example/v1', connection: 'api-token' }), { ...f.options, transport: echo(lowerHexEncoded) }); + assert.equal(fetched.outcome, 'succeeded', fetched.error ?? ''); + assert.equal(urlRecovers(JSON.stringify(fetched), PERCENT_SECRET), false, JSON.stringify(fetched)); + + // A single-level escape is replaced in place, so the rest of the value is kept; escapes that spell nothing secret are untouched. + const fetchWith = (transport: PluginHttpTransport) => createPluginHttp({ outboundHosts: ['api.fixture.example'], credential: async () => PERCENT_SECRET, lookup, transport })('https://api.fixture.example/v1'); + const inPlace = await fetchWith(echo(lowerHexEncoded, 'form')); + assert.equal(await inPlace.text(), 'token=[REDACTED]&page=2'); + assert.equal(inPlace.headers['x-form'], 'token=[REDACTED]&page=2'); + assert.deepEqual(await (await fetchWith(echo(lowerHexEncoded))).json(), { token: '[REDACTED]', keep: 'visible' }); + const untouched = 'next=%2fitems%3fpage%3d2 %41%u0042 100% sure'; + const kept = await fetchWith(async () => ({ status: 200, headers: { 'x-next': untouched }, body: Buffer.from(untouched) })); + assert.equal(await kept.text(), untouched); + assert.equal(kept.headers['x-next'], untouched); +}); + +test('a credential percent-encoded in a handler input never reaches its log lines or result', async t => { + const f = await pluginHandlerFixture(t, { secret: PERCENT_SECRET }); + const logged = async (note: string) => { + const run = await runPluginHandler(FIXTURE_ID, 'fetchThenLog', invoke({ url: 'https://api.fixture.example/v1', connection: 'api-token', note }), f.options); + assert.equal(run.outcome, 'succeeded', run.error ?? ''); + return run; + }; + // Lowercase hex is replaced in place, in the message, its fields and the result. + const inPlace = await logged(`callback for ${lowerHexEncoded(PERCENT_SECRET)} on page 2`); + const expected = 'callback for [REDACTED] on page 2'; + assert.deepEqual([inPlace.logs.at(-1)?.message, inPlace.logs.at(-1)?.fields, inPlace.result], [expected, { note: expected }, { note: expected }]); + for (const note of [everyCharEscaped(PERCENT_SECRET), encodeURIComponent(lowerHexEncoded(PERCENT_SECRET)), `see ${PERCENT_SECRET.replace('/', '%u002f')}`]) { + const run = await logged(note); + assert.equal(urlRecovers(JSON.stringify(run), PERCENT_SECRET), false, JSON.stringify(run)); + } +}); + +test('a request whose DNS lookup or credential resolves after the handler timed out is never sent', async t => { + const f = await pluginHandlerFixture(t, { secret: 'plugin-http-credential-sentinel-5d0e9a' }); + const fetchPayload = invoke({ url: 'https://api.fixture.example/v1/mutate', connection: 'api-token', init: { method: 'POST', json: { change: true } } }); + + // DNS held until after the time limit. + const dns = deferred(); + const lookups: string[] = []; + const slowLookup = async (host: string) => { lookups.push(host); await dns.promise; return lookup(host); }; + const timedOut = await runPluginHandler(FIXTURE_ID, 'fetchUrl', fetchPayload, { ...f.options, lookup: slowLookup, timeoutMs: 100 }); + assert.equal(timedOut.timedOut, true); + assert.deepEqual(lookups, ['api.fixture.example'], 'the lookup had started before the timeout'); + dns.resolve(); + await tick(); + assert.equal(f.http.requests.length, 0, 'nothing reached the transport after the run ended'); + + // The connection secret held until after the time limit. + const secret = deferred(); + const slowSecret = await runPluginHandler(FIXTURE_ID, 'fetchUrl', fetchPayload, { ...f.options, connectionSecret: () => secret.promise, timeoutMs: 100 }); + assert.equal(slowSecret.timedOut, true); + secret.resolve('late-secret'); + await tick(); + assert.equal(f.http.requests.length, 0); + + // A request in flight when the run ends gets the aborted signal, which the default transport uses to cancel. + const inFlight: PluginHttpTransportRequest[] = []; + const hanging: PluginHttpTransport = request => { inFlight.push(request); return new Promise(() => undefined); }; + const cancelled = await runPluginHandler(FIXTURE_ID, 'fetchUrl', fetchPayload, { ...f.options, transport: hanging, timeoutMs: 100 }); + assert.equal(cancelled.timedOut, true); + assert.equal(inFlight.length, 1); + assert.equal(inFlight[0].signal?.aborted, true); +}); + +test('a handler resuming after its plugin was uninstalled cannot recreate state, nor hand it to a reinstall', async t => { + for (const reinstall of [false, true]) { + const f = await pluginHandlerFixture(t); + const held = deferred(); + const reached = deferred(); + const backend = { ...f.backend, getIssue: async () => { reached.resolve(); await held.promise; throw new Error('released'); } }; + const running = runPluginHandler(FIXTURE_ID, 'suspendThenSet', invoke({ key: 'resumed' }), { ...f.options, backend }); + await reached.promise; + + await f.store.setEnabled('1', FIXTURE_ID, false); + await f.store.uninstall('1', FIXTURE_ID, FIXTURE_ID); + if (reinstall) { + // A later installation with the same id: the old run must not write into it. + const later = new PluginStore(f.database, ENVIRONMENT, async () => [f.manifest], () => Date.now() + 60_000); + await later.install('1', FIXTURE_ID); + } + held.resolve(); + const run = await running; + assert.equal(run.outcome, 'succeeded', run.error ?? ''); + assert.deepEqual(run.result, { ok: false, code: 'unavailable', message: 'The plugin is no longer installed' }, `reinstall: ${reinstall}`); + assert.equal(await f.database('plugin_state').where({ plugin_id: FIXTURE_ID }).first(), undefined, `reinstall: ${reinstall}`); + } +}); + +test('a handler resuming after an uninstall cannot read, list or delete a reinstall\'s state', async t => { + for (const op of ['get', 'keys', 'delete'] as const) { + const f = await pluginHandlerFixture(t); + const held = deferred(); + const reached = deferred(); + const backend = { ...f.backend, getIssue: async () => { reached.resolve(); await held.promise; throw new Error('released'); } }; + const running = runPluginHandler(FIXTURE_ID, 'suspendThenSet', invoke({ key: 'mapping', op }), { ...f.options, backend }); + await reached.promise; + + await f.store.setEnabled('1', FIXTURE_ID, false); + await f.store.uninstall('1', FIXTURE_ID, FIXTURE_ID); + const later = new PluginStore(f.database, ENVIRONMENT, async () => [f.manifest], () => Date.now() + 60_000); + await later.install('1', FIXTURE_ID); + // The new installation's own state, written through its own (unguarded) store. + await pluginStateStore(f.database, FIXTURE_ID).set('mapping', 'new installation'); + + held.resolve(); + const run = await running; + assert.equal(run.outcome, 'succeeded', run.error ?? ''); + assert.deepEqual(run.result, { ok: false, code: 'unavailable', message: 'The plugin is no longer installed' }, op); + assert.equal(await pluginStateStore(f.database, FIXTURE_ID).get('mapping'), 'new installation', op); + } +}); + +test('a state write or delete is refused and rolled back when the run ends during its queries', async t => { + const f = await pluginHandlerFixture(t); + const installedAt = Number((await f.database('plugin_installations').where({ plugin_id: FIXTURE_ID }).first('installed_at')).installed_at); + await pluginStateStore(f.database, FIXTURE_ID).set('kept', 'before'); + // Abort the run when a given statement is sent: the cancellation lands while that query is outstanding. + const abortOn = (match: (sql: string) => boolean) => { + const controller = new AbortController(); + const listener = (query: { sql: string }) => { if (match(query.sql.toLowerCase())) controller.abort(); }; + f.database.on('query', listener); + t.after(() => f.database.removeListener('query', listener)); + return pluginStateStore(f.database, FIXTURE_ID, Date.now, { installedAt, signal: controller.signal }); + }; + const ended = { code: 'unavailable', message: 'This handler run has ended' }; + + // While the aggregate count/size query is held: nothing is written. + await assert.rejects(abortOn(sql => sql.includes('coalesce(sum')).set('fresh', 'late'), ended); + // During the insert itself: the write is rolled back. + await assert.rejects(abortOn(sql => sql.startsWith('insert into')).set('kept', 'late'), ended); + // During the delete: the row stays. + await assert.rejects(abortOn(sql => sql.startsWith('delete from')).delete('kept'), ended); + + const unguarded = pluginStateStore(f.database, FIXTURE_ID); + assert.deepEqual(await unguarded.keys(), ['kept']); + assert.equal(await unguarded.get('kept'), 'before'); + // A live guard with the current installation still works. + const live = pluginStateStore(f.database, FIXTURE_ID, Date.now, { installedAt, signal: new AbortController().signal }); + await live.set('kept', 'after'); + assert.equal(await live.get('kept'), 'after'); + assert.equal(await live.delete('kept'), true); +}); + +test('a request resuming after an uninstall never acquires or sends a reinstall\'s credential', async t => { + const REPLACEMENT = 'replacement-installation-token-7c41f2'; + for (const variant of ['uninstalled', 'reinstalled', 'reinstalled with injected secret'] as const) { + const f = await pluginHandlerFixture(t, { secret: 'plugin-http-credential-sentinel-5d0e9a' }); + const dns = deferred(); + const reached = deferred(); + const heldLookup = async (host: string) => { reached.resolve(); await dns.promise; return lookup(host); }; + // The injected-secret variant reads only by plugin id and name, as a custom source could: dispatch authorization alone must refuse. + const connectionSecret = variant === 'reinstalled with injected secret' + ? (id: string, name: string) => new PluginStore(f.database, ENVIRONMENT, async () => [f.manifest]).connectionSecret(id, name) + : undefined; + const running = runPluginHandler(FIXTURE_ID, 'fetchUrl', invoke({ url: 'https://api.fixture.example/v1/mutate', connection: 'api-token', init: { method: 'POST', json: { change: true } } }), { ...f.options, lookup: heldLookup, ...(connectionSecret ? { connectionSecret } : {}) }); + await reached.promise; + + await f.store.setEnabled('1', FIXTURE_ID, false); + await f.store.uninstall('1', FIXTURE_ID, FIXTURE_ID); + if (variant !== 'uninstalled') { + const later = new PluginStore(f.database, ENVIRONMENT, async () => [f.manifest], () => Date.now() + 60_000); + await later.install('1', FIXTURE_ID); + await later.setConnection('1', FIXTURE_ID, 'api-token', REPLACEMENT); + await later.setEnabled('1', FIXTURE_ID, true); + } + dns.resolve(); + const run = await running; + assert.equal(run.outcome, 'succeeded', run.error ?? ''); + assert.deepEqual(run.result, { ok: false, code: 'unavailable', message: 'The plugin is no longer installed' }, variant); + assert.equal(f.http.requests.length, 0, `${variant}: nothing reached the transport`); + assert.equal(JSON.stringify(run).includes(REPLACEMENT), false, variant); + } +}); + +const unicodeEscaped = (value: string) => [...value].map(c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`).join(''); +/** Each round of JSON decoding turns `\u005c` into the backslash of the next escape: `rounds` decodes before the text appears. */ +const deeplyEscaped = (value: string, rounds: number) => [...value].map(c => `\\${'u005c'.repeat(rounds - 1)}u${c.charCodeAt(0).toString(16).padStart(4, '0')}`).join(''); +/** What a handler can recover from text: every JSON string literal in it, parsed again and again. */ +function recoverable(text: string): string[] { + const found: string[] = []; + const visit = (value: string, depth: number) => { + found.push(value); + if (depth > 64) return; + for (const literal of value.match(/"(?:[^"\\]|\\.)*"/g) ?? []) { + try { visit(JSON.parse(literal) as string, depth + 1); } catch { /* not a JSON string */ } + } + // A bare escaped run (no quotes left after decoding) parsed as a string of its own. + if (value.includes('\\')) { try { const next = JSON.parse(`"${value}"`) as string; if (next !== value) visit(next, depth + 1); } catch { /* not decodable */ } } + }; + visit(text, 0); + return found; +} + +test('a response body that spells the credential only after decoding its JSON escapes is withheld', async () => { + const fetchWith = (body: (secret: string) => string, headers: (secret: string) => Record = () => ({})) => createPluginHttp({ + outboundHosts: ['api.fixture.example'], credential: async () => ESCAPED_SECRET, lookup, + transport: async request => { const secret = (request.headers.authorization ?? '').slice(7); return { status: 200, headers: headers(secret), body: Buffer.from(body(secret)) }; }, + })('https://api.fixture.example/v1'); + const leaks = (text: string) => recoverable(text).some(value => value.includes(ESCAPED_SECRET)); + + // HTML: the credential \u-escaped inside a script, which the handler extracts and parses. + const html = await fetchWith(secret => ``); + const htmlText = await html.text(); + const script = htmlText.match(/var auth = ("(?:[^"\\]|\\.)*");/); + assert.equal(script ? (JSON.parse(script[1]) as string).includes(ESCAPED_SECRET) : false, false, htmlText); + assert.equal(leaks(htmlText), false, htmlText); + + // JSON holding another JSON document as a string, the credential \u-escaped inside it: that string is withheld, the rest kept. + const nested = await fetchWith(secret => JSON.stringify({ keep: 'visible', doc: unicodeEscapedJson({ token: secret }) })); + const nestedJson = await nested.json<{ keep: string; doc: string }>(); + assert.equal(nestedJson.keep, 'visible'); + assert.equal((() => { try { return JSON.stringify(JSON.parse(nestedJson.doc)).includes(ESCAPED_SECRET); } catch { return false; } })(), false, nestedJson.doc); + assert.equal(leaks(await nested.text()), false, await nested.text()); + + // Deeper than any fixed round count: in an HTML body and in a header, ten decodes deep, and beyond what is checked. + for (const rounds of [10, 40]) { + const deep = await fetchWith(secret => `

${deeplyEscaped(secret, rounds)}

`, secret => ({ 'x-echo': `"${deeplyEscaped(secret, rounds)}"` })); + assert.equal(leaks(await deep.text()), false, `body, ${rounds} rounds`); + assert.equal(leaks(deep.headers['x-echo']), false, `header, ${rounds} rounds`); + } + assert.ok(recoverable(`

${deeplyEscaped(ESCAPED_SECRET, 10)}

`).some(value => value.includes(ESCAPED_SECRET)), 'the probe does recover an unscrubbed deep escape'); + + // Escapes that spell nothing secret reach the handler unchanged. + const clean = ''; + assert.equal(await (await fetchWith(() => clean)).text(), clean); + const cleanNested = JSON.stringify({ doc: unicodeEscapedJson({ token: 'not the credential' }) }); + assert.equal(await (await fetchWith(() => cleanNested)).text(), cleanNested); +}); + +test('a handler resuming after its plugin was uninstalled cannot create an issue or a task, nor act for a reinstall', async t => { + for (const [operation, args] of [['createIssue', ['owner/allowed', { title: 'late', body: 'after uninstall' }]], ['createTask', ['owner/allowed', 3]], ['commentOnIssue', ['owner/allowed', 3, 'late']]] as const) { + for (const reinstall of [false, true]) { + const f = await pluginHandlerFixture(t); + const held = deferred(); + const reached = deferred(); + const backend = { ...f.backend, getIssue: async () => { reached.resolve(); await held.promise; throw new Error('released'); } }; + const running = runPluginHandler(FIXTURE_ID, 'suspendThenPropr', invoke({ operation, args: args as unknown as PluginJsonValue[] }), { ...f.options, backend }); + await reached.promise; + + await f.store.setEnabled('1', FIXTURE_ID, false); + await f.store.uninstall('1', FIXTURE_ID, FIXTURE_ID); + if (reinstall) { + const later = new PluginStore(f.database, ENVIRONMENT, async () => [f.manifest], () => Date.now() + 60_000); + await later.install('1', FIXTURE_ID); + await later.setRepositories('1', FIXTURE_ID, ['owner/allowed']); + await later.setEnabled('1', FIXTURE_ID, true); + } + held.resolve(); + const run = await running; + const label = `${operation}, reinstall: ${reinstall}`; + assert.equal(run.outcome, 'succeeded', run.error ?? ''); + assert.deepEqual(run.result, { ok: false, code: 'unavailable', message: 'The plugin is no longer installed' }, label); + assert.deepEqual(f.calls, [], `${label}: nothing reached the backend`); + // The held read is recorded too (it failed); the mutation after the uninstall is recorded as refused. + const refused = (await f.database('plugin_audit').where({ plugin_id: FIXTURE_ID, action: 'handler_refused' }).orderBy('id').select('detail')).map(row => JSON.parse(row.detail) as { operation: string; reason: string }); + assert.deepEqual(refused.map(r => [r.operation, r.reason]), [['getIssue', 'unavailable'], [operation, 'unavailable']], label); + } + } +}); + +test('the GitHub backend rechecks the installation after the App client or the queue is ready, before dispatching', async t => { + const f = await pluginHandlerFixture(t); + const installedAt = Number((await f.database('plugin_installations').where({ plugin_id: FIXTURE_ID }).first('installed_at')).installed_at); + const signal = new AbortController().signal; + const authorize = () => f.database.transaction(trx => requireInstallation(trx, FIXTURE_ID, { installedAt, signal })); + const sent: string[] = []; + const backend = githubPluginProprBackend({ + database: f.database, signal, authorize, + github: async () => ({ request: async (route: string) => { sent.push(route); return { data: { number: 1, html_url: 'u' } }; } }), + issueQueue: async () => ({ add: async (_name: string, _data: unknown, options: Record) => { sent.push(String(options.jobId)); } }), + }); + // A live installation is served. + assert.equal((await backend.createIssue('owner/allowed', { title: 't', body: 'b', labels: [] })).number, 1); + assert.equal((await backend.createTask('owner/allowed', 3, { requestedBy: 'plugin:x' })).jobId, 'issue-owner-allowed-3'); + assert.deepEqual(sent, ['POST /repos/{owner}/{repo}/issues', 'issue-owner-allowed-3']); + sent.length = 0; + + // Uninstalled while the App token is fetched and while the queue is initialized. + const held = { token: deferred(), queue: deferred() }; + const heldBackend = githubPluginProprBackend({ + database: f.database, signal, authorize, + github: async () => { await held.token.promise; return { request: async (route: string) => { sent.push(route); return { data: {} }; } }; }, + issueQueue: async () => { await held.queue.promise; return { add: async (_n: string, _d: unknown, options: Record) => { sent.push(String(options.jobId)); } }; }, + }); + const issue = heldBackend.createIssue('owner/allowed', { title: 't', body: 'b', labels: [] }); + const task = heldBackend.createTask('owner/allowed', 3, { requestedBy: 'plugin:x' }); + await tick(); + await f.store.setEnabled('1', FIXTURE_ID, false); + await f.store.uninstall('1', FIXTURE_ID, FIXTURE_ID); + held.token.resolve(); held.queue.resolve(); + await assert.rejects(issue, { code: 'unavailable', message: 'The plugin is no longer installed' }); + await assert.rejects(task, { code: 'unavailable', message: 'The plugin is no longer installed' }); + // A custom enqueue is refused the same way. + const custom = githubPluginProprBackend({ database: f.database, signal, authorize, enqueueIssueTask: async (_data, jobId) => { sent.push(jobId); } }); + await assert.rejects(custom.createTask('owner/allowed', 3, { requestedBy: 'plugin:x' }), { code: 'unavailable' }); + assert.deepEqual(sent, [], 'nothing was sent or queued'); +}); + +/** A credential with letters in both cases: its hex encoding differs from itself in any case, and its own case matters. */ +const HEX_SECRET = 'Plugin-Credential-Sentinel-5d0e9a'; +const upperHex = (value: string) => Buffer.from(value).toString('hex').toUpperCase(); +/** Hex digits alternating in case, as a handler could not care about: `Buffer.from(mixed, 'hex')` decodes it all the same. */ +const mixedHex = (value: string) => [...Buffer.from(value).toString('hex')].map((c, i) => (i % 2 ? c.toUpperCase() : c)).join(''); +/** Whether `text` holds a run of hex digits, in any case, that decodes to `secret` (what a handler recovers with `Buffer.from(run, 'hex')`). */ +const hexRecovers = (text: string, secret: string) => (text.match(/[0-9a-fA-F]{2,}/g) ?? []).some(run => Buffer.from(run, 'hex').toString('utf8').includes(secret)); + +test('an uppercase or mixed-case hex echo of the credential is scrubbed from the body, the headers, the logs and the result', async t => { + const f = await pluginHandlerFixture(t, { secret: HEX_SECRET }); + const echo = (encode: (value: string) => string, body: 'json' | 'text'): PluginHttpTransport => async request => { + const authorization = request.headers.authorization ?? ''; + const secret = encode(authorization.slice('Bearer '.length)); + const header = encode(authorization); + return { + status: 200, headers: { 'x-echo-token': secret, 'x-echo-header': `v=${header}; path=/` }, + body: Buffer.from(body === 'json' ? JSON.stringify({ token: secret, header, keep: 'visible' }) : `token=${secret}&header=${header}&keep=visible`), + }; + }; + for (const [name, encode] of [['uppercase hex', upperHex], ['mixed-case hex', mixedHex]] as const) { + for (const body of ['json', 'text'] as const) { + const run = await runPluginHandler(FIXTURE_ID, 'fetchUrl', invoke({ url: 'https://api.fixture.example/v1', connection: 'api-token' }), { ...f.options, transport: echo(encode, body) }); + const label = `${name}, ${body} body`; + assert.equal(run.outcome, 'succeeded', run.error ?? ''); + const result = run.result as { body: string; headers: Record }; + // Replaced in place: the rest of the value is kept. + assert.equal(result.body, body === 'json' ? JSON.stringify({ token: '[REDACTED]', header: '[REDACTED]', keep: 'visible' }) : 'token=[REDACTED]&header=[REDACTED]&keep=visible', label); + assert.deepEqual(result.headers, { 'x-echo-token': '[REDACTED]', 'x-echo-header': 'v=[REDACTED]; path=/' }, label); + // Nothing handler-visible (the result, its logged copy) decodes to the credential. + assert.equal(hexRecovers(JSON.stringify(run), HEX_SECRET), false, label); + } + // json() of the parsed body, through the HTTP client directly. + const response = await createPluginHttp({ outboundHosts: ['api.fixture.example'], credential: async () => HEX_SECRET, lookup, transport: echo(encode, 'json') })('https://api.fixture.example/v1'); + assert.deepEqual(await response.json(), { token: '[REDACTED]', header: '[REDACTED]', keep: 'visible' }, name); + } + // The same spellings in a log line and a result, through the run's redaction. + assert.deepEqual(scrubCredentialValues({ a: `id ${upperHex(HEX_SECRET)} b`, [mixedHex(`Bearer ${HEX_SECRET}`)]: 1 }, [HEX_SECRET]), { a: 'id [REDACTED] b', '[REDACTED]': 1 }); + // Ordinary spellings stay case-sensitive: another case of the secret is a different value, and so is its base64. + const other = { a: HEX_SECRET.toUpperCase(), b: Buffer.from(HEX_SECRET).toString('base64').toLowerCase(), c: 'ABCDEF0123' }; + assert.deepEqual(scrubCredentialValues(other, [HEX_SECRET]), other); + const kept = await createPluginHttp({ outboundHosts: ['api.fixture.example'], credential: async () => HEX_SECRET, lookup, transport: async () => ({ status: 200, headers: { 'x-id': 'DEADBEEF' }, body: Buffer.from(HEX_SECRET.toUpperCase()) }) })('https://api.fixture.example/v1'); + assert.equal(await kept.text(), HEX_SECRET.toUpperCase()); + assert.equal(kept.headers['x-id'], 'DEADBEEF'); +}); + +/** Administrative changes to the fixture installation while a handler is suspended, and the refusal each must cause. */ +const RESTRICTIONS = [ + ['disabled', async (f: Awaited>) => { await f.store.setEnabled('1', FIXTURE_ID, false); }, { code: 'unavailable', message: 'The plugin is disabled' }], + ['limited to another repository', async (f: Awaited>) => { await f.store.setRepositories('1', FIXTURE_ID, ['owner/other']); }, { code: 'repository_not_allowed', message: 'This plugin is no longer enabled for owner/allowed' }], + ['permission revoked', async (f: Awaited>) => { + const row = await f.database('plugin_installations').where({ plugin_id: FIXTURE_ID }).first('granted_permissions'); + const granted = (JSON.parse(row.granted_permissions) as string[]).filter(permission => permission !== 'issues.create' && permission !== 'tasks.create'); + await f.database('plugin_installations').where({ plugin_id: FIXTURE_ID }).update({ granted_permissions: JSON.stringify(granted) }); + }, { code: 'permission_denied', message: /is no longer granted to this plugin$/ }], +] as const; + +test('a handler resuming after its installation was disabled or restricted cannot create an issue or a task', async t => { + for (const [operation, args] of [['createIssue', ['owner/allowed', { title: 'late', body: 'after restriction' }]], ['createTask', ['owner/allowed', 3]]] as const) { + for (const [change, restrict, expected] of RESTRICTIONS) { + const f = await pluginHandlerFixture(t); + const held = deferred(); + const reached = deferred(); + const backend = { ...f.backend, getIssue: async () => { reached.resolve(); await held.promise; throw new Error('released'); } }; + const running = runPluginHandler(FIXTURE_ID, 'suspendThenPropr', invoke({ operation, args: args as unknown as PluginJsonValue[] }), { ...f.options, backend }); + await reached.promise; + await restrict(f); + held.resolve(); + const run = await running; + const label = `${operation}, ${change}`; + assert.equal(run.outcome, 'succeeded', run.error ?? ''); + const result = run.result as { ok: boolean; code: string; message: string }; + assert.equal(result.ok, false, label); + assert.equal(result.code, expected.code, label); + assert.match(result.message, expected.message instanceof RegExp ? expected.message : new RegExp(`^${expected.message}$`), label); + assert.deepEqual(f.calls, [], `${label}: nothing reached the backend`); + // The mutation is recorded as refused with the refusal's code; no attempt was recorded for it. + const audit = (await f.database('plugin_audit').where({ plugin_id: FIXTURE_ID }).whereIn('action', ['handler_attempt', 'handler_refused']).orderBy('id').select('action', 'detail')) + .map(row => [row.action, (JSON.parse(row.detail) as { operation: string; reason?: string }).operation, (JSON.parse(row.detail) as { reason?: string }).reason]); + assert.deepEqual(audit, [['handler_refused', 'getIssue', 'unavailable'], ['handler_refused', operation, expected.code]], label); + } + } +}); + +test('an authenticated request resuming after its installation was disabled is never sent', async t => { + const f = await pluginHandlerFixture(t, { secret: HEX_SECRET }); + for (const stage of ['lookup', 'credential'] as const) { + const held = deferred(); + const reached = deferred(); + const heldLookup = async (host: string) => { if (stage === 'lookup') { reached.resolve(); await held.promise; } return lookup(host); }; + const connectionSecret = stage === 'credential' ? async () => { reached.resolve(); await held.promise; return HEX_SECRET; } : undefined; + const running = runPluginHandler(FIXTURE_ID, 'fetchUrl', invoke({ url: 'https://api.fixture.example/v1/mutate', connection: 'api-token', init: { method: 'POST', json: { change: true } } }), { ...f.options, lookup: heldLookup, ...(connectionSecret ? { connectionSecret } : {}) }); + await reached.promise; + await f.store.setEnabled('1', FIXTURE_ID, false); + held.resolve(); + const run = await running; + assert.equal(run.outcome, 'succeeded', run.error ?? ''); + assert.deepEqual(run.result, { ok: false, code: 'unavailable', message: 'The plugin is disabled' }, stage); + assert.equal(f.http.requests.length, 0, `${stage}: nothing reached the transport`); + assert.equal(run.logs.some(line => line.message === 'ctx.http request to api.fixture.example refused: unavailable'), true, stage); + await f.store.setEnabled('1', FIXTURE_ID, true); + } + // An unauthenticated request carries no credential of the installation and is not bound to its enabled state. + await f.store.setEnabled('1', FIXTURE_ID, false); + const plain = await createPluginHttp({ outboundHosts: ['api.fixture.example'], lookup, transport: f.http.transport })('https://api.fixture.example/v1'); + assert.equal(plain.status, 200); +}); + +test('the GitHub backend rechecks the installation\'s enabled state, grants and repository limit right before dispatching', async t => { + const f = await pluginHandlerFixture(t, { repositories: ['owner/allowed', 'owner/second'] }); + const installedAt = Number((await f.database('plugin_installations').where({ plugin_id: FIXTURE_ID }).first('installed_at')).installed_at); + const authorize = installationAuthorizer(f.database, FIXTURE_ID, f.manifest, { installedAt, signal: new AbortController().signal }).call; + const sent: string[] = []; + const held = { token: deferred(), queue: deferred() }; + const backend = githubPluginProprBackend({ + database: f.database, signal: new AbortController().signal, authorize, + github: async () => { await held.token.promise; return { request: async (route: string) => { sent.push(route); return { data: { number: 1, html_url: 'u', id: 5 } }; } }; }, + issueQueue: async () => { await held.queue.promise; return { add: async (_n: string, _d: unknown, options: Record) => { sent.push(String(options.jobId)); } }; }, + }); + // The limit changes to exclude owner/allowed while the App token is fetched and the queue is initialized: nothing for it is sent, a call on owner/second is. + const refused = [backend.createIssue('owner/allowed', { title: 't', body: 'b', labels: [] }), backend.createTask('owner/allowed', 3, { requestedBy: 'plugin:x' }), backend.addLabel('owner/allowed', 3, 'late')]; + const allowed = backend.createIssue('owner/second', { title: 't', body: 'b', labels: [] }); + await tick(); + await f.store.setRepositories('1', FIXTURE_ID, ['owner/second']); + held.token.resolve(); held.queue.resolve(); + for (const call of refused) await assert.rejects(call, { code: 'repository_not_allowed', message: 'This plugin is no longer enabled for owner/allowed' }); + assert.equal((await allowed).number, 1); + assert.deepEqual(sent, ['POST /repos/{owner}/{repo}/issues'], 'only the still-allowed repository was reached'); + sent.length = 0; + // Disabled: every dispatch is refused, the custom enqueue included; reads too. + await f.store.setEnabled('1', FIXTURE_ID, false); + await assert.rejects(backend.createIssue('owner/second', { title: 't', body: 'b', labels: [] }), { code: 'unavailable', message: 'The plugin is disabled' }); + await assert.rejects(backend.getIssue('owner/second', 3), { code: 'unavailable', message: 'The plugin is disabled' }); + const custom = githubPluginProprBackend({ database: f.database, authorize, enqueueIssueTask: async (_data, jobId) => { sent.push(jobId); } }); + await assert.rejects(custom.createTask('owner/second', 3, { requestedBy: 'plugin:x' }), { code: 'unavailable', message: 'The plugin is disabled' }); + await f.store.setEnabled('1', FIXTURE_ID, true); + // A grant revoked: the commenting read and the comment carry the comment permission, the label the label permission. + const row = await f.database('plugin_installations').where({ plugin_id: FIXTURE_ID }).first('granted_permissions'); + await f.database('plugin_installations').where({ plugin_id: FIXTURE_ID }).update({ granted_permissions: JSON.stringify((JSON.parse(row.granted_permissions) as string[]).filter(p => p !== 'issues.comment')) }); + await assert.rejects(backend.comment('owner/second', 3, 'late', 'issue'), { code: 'permission_denied' }); + await backend.addLabel('owner/second', 3, 'ok'); + assert.deepEqual(sent, ['POST /repos/{owner}/{repo}/issues/{issue_number}/labels'], 'the revoked permission\'s read was not sent either'); +}); + +test('a handler resuming after its repository limit changed cannot read a task of the repository it started with', async t => { + // getTask names no repository, so the authorization before the read passes on tasks.read alone; the task's repository is checked once it is read. + for (const [change, restrict, expected] of RESTRICTIONS) { + const f = await pluginHandlerFixture(t); + const held = deferred(); + const reached = deferred(); + const backend = { ...f.backend, getIssue: async () => { reached.resolve(); await held.promise; throw new Error('released'); } }; + const running = runPluginHandler(FIXTURE_ID, 'suspendThenPropr', invoke({ operation: 'getTask', args: ['task-1'] }), { ...f.options, backend }); + await reached.promise; + if (change === 'permission revoked') { + await f.database('plugin_installations').where({ plugin_id: FIXTURE_ID }).update({ granted_permissions: JSON.stringify(['issues.read']) }); + } else { + await restrict(f); + } + held.resolve(); + const run = await running; + assert.equal(run.outcome, 'succeeded', run.error ?? ''); + const result = run.result as { ok: boolean; code: string; message: string; value?: unknown }; + assert.equal(result.ok, false, change); + assert.equal(result.code, expected.code, change); + assert.equal(result.value, undefined, `${change}: the task was not returned`); + // The limit changed: the refusal names the task's repository no more than the handler did. + if (change === 'limited to another repository') assert.equal(result.message, 'This plugin is no longer enabled for the task\'s repository'); + else assert.match(result.message, expected.message instanceof RegExp ? expected.message : new RegExp(`^${expected.message}$`), change); + // Only the repository restriction is discovered after the read: the task itself is read, then withheld. The other changes refuse before the backend. + assert.deepEqual(f.calls.map(c => c.operation), change === 'limited to another repository' ? ['getTask'] : [], change); + const refused = (await f.database('plugin_audit').where({ plugin_id: FIXTURE_ID, action: 'handler_refused' }).orderBy('id').select('detail')).map(row => JSON.parse(row.detail) as { operation: string; reason: string }); + assert.deepEqual(refused.map(r => [r.operation, r.reason]), [['getIssue', 'unavailable'], ['getTask', expected.code]], change); + } + // Unchanged, the same suspended read returns the task: the recheck refuses only what the administrator took away. + const f = await pluginHandlerFixture(t); + const backend = { ...f.backend, getIssue: async () => { throw new Error('released'); } }; + const run = await runPluginHandler(FIXTURE_ID, 'suspendThenPropr', invoke({ operation: 'getTask', args: ['task-1'] }), { ...f.options, backend }); + assert.equal((run.result as { ok: boolean; value: { repository: string } }).ok, true); + assert.equal((run.result as { ok: boolean; value: { repository: string } }).value.repository, 'owner/allowed'); +}); diff --git a/packages/core/test/pluginHandlerLint.test.ts b/packages/core/test/pluginHandlerLint.test.ts new file mode 100644 index 0000000000..22a12bb790 --- /dev/null +++ b/packages/core/test/pluginHandlerLint.test.ts @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ESLint } from 'eslint'; +import { FIXTURE_REGISTRY } from './helpers/pluginHandlerFixture.js'; + +/** + * The restricted-imports rule for `packages/plugins/**` (root eslint.config.js): + * plugin code imports nothing from ProPR but `@propr/shared`. The fixture + * handlers are linted as if they were plugin folders of the registry package. + */ +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const eslint = new ESLint({ cwd: ROOT, overrideConfigFile: path.join(ROOT, 'eslint.config.js') }); +const lintAsPlugin = async (fixture: string, code = readFileSync(path.join(FIXTURE_REGISTRY, fixture, 'handlers.ts'), 'utf8')) => { + const [result] = await eslint.lintText(code, { filePath: path.join(ROOT, 'packages', 'plugins', fixture, 'handlers.ts') }); + return result.messages; +}; + +test('the restricted-imports rule fails a fixture plugin that imports core modules', async () => { + const messages = await lintAsPlugin('fixture-imports-core'); + assert.deepEqual(messages.map(m => [m.ruleId, m.line, m.severity]), [ + ['no-restricted-imports', 5, 2], + ['no-restricted-imports', 6, 2], + ['no-restricted-syntax', 10, 2], + ], JSON.stringify(messages, null, 2)); + assert.ok(messages.every(m => /only @propr\/shared/.test(m.message))); +}); + +test('a plugin importing only @propr/shared passes, and every way into ProPR internals is refused', async () => { + assert.deepEqual(await lintAsPlugin('fixture-handlers'), []); + const refused = [ + "import { db } from '@propr/core';", + "import type { PluginStore } from '@propr/core';", + "import { x } from '@propr/api/routes/pluginRoutes.js';", + "import { x } from '../../core/src/db/connection.js';", + "import { x } from '../../api/server.js';", + "import { x } from '../../shared/src/plugins.js';", + "import { x } from '../../../src/worker.js';", + "import { x } from '../../../packages/core/src/index.js';", + "export { x } from '@propr/core';", + "const m = await import('@propr/core'); export default m;", + "const m = await import('../../core/src/index.js'); export default m;", + ]; + for (const code of refused) { + const messages = await lintAsPlugin('some-plugin', `${code}\n`); + assert.ok(messages.some(m => m.ruleId === 'no-restricted-imports' || m.ruleId === 'no-restricted-syntax'), `${code} was not refused`); + } + const allowed = [ + "import { PluginSdkError, type PluginContext } from '@propr/shared';", + "import { createHash } from 'node:crypto';", + "import { helper } from './helper.js';", + "import { officialPlugins } from '../src/index.js';", + ]; + for (const code of allowed) assert.deepEqual(await lintAsPlugin('some-plugin', `${code}\nexport { };\n`), [], code); +}); diff --git a/packages/core/test/pluginHandlerRunner.test.ts b/packages/core/test/pluginHandlerRunner.test.ts new file mode 100644 index 0000000000..cd42b0e7da --- /dev/null +++ b/packages/core/test/pluginHandlerRunner.test.ts @@ -0,0 +1,251 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PLUGIN_LOG_LIMITS, PLUGIN_STATE_LIMITS, type PluginHandlerInput, type PluginJsonValue } from '@propr/shared'; +import { runPluginHandler } from '../src/plugins/pluginHandlerRunner.js'; +import { FIXTURE_ID, pluginHandlerFixture } from './helpers/pluginHandlerFixture.js'; + +const invoke = (payload: PluginJsonValue = {}): PluginHandlerInput => ({ kind: 'invoke', payload }); +const SENTINEL = 'plugin-http-credential-sentinel-5d0e9a'; + +test('runPluginHandler runs a fixture handler and returns its outcome, result, duration and redacted logs', async t => { + const f = await pluginHandlerFixture(t); + const run = await runPluginHandler(FIXTURE_ID, 'succeed', invoke(), f.options); + assert.equal(run.outcome, 'succeeded', run.error ?? ''); + assert.equal(run.timedOut, false); + assert.equal(run.error, null); + assert.ok(run.durationMs >= 0); + assert.deepEqual(run.result, { greeting: 'hi', repositories: ['owner/allowed'] }); + assert.deepEqual(run.logs.map(l => [l.level, l.message]).slice(0, 1), [['info', 'fixture handler ran']]); + assert.deepEqual(run.logs[0].fields, { kind: 'invoke', greeting: 'hi' }); + assert.doesNotMatch(JSON.stringify(run.logs), /ghp_0123456789/, 'log lines pass the secret redaction'); + assert.match(run.logs[1].message, /REDACTED/); +}); + +test('a throwing handler, a hanging handler and other failures return a failed outcome without throwing', async t => { + const f = await pluginHandlerFixture(t); + const thrown = await runPluginHandler(FIXTURE_ID, 'throws', invoke(), f.options); + assert.equal(thrown.outcome, 'failed'); + assert.equal(thrown.timedOut, false); + assert.equal(thrown.error, 'fixture handler failure'); + assert.deepEqual(thrown.logs.map(l => l.message), ['about to fail']); + + const started = Date.now(); + const hung = await runPluginHandler(FIXTURE_ID, 'hangs', invoke(), { ...f.options, timeoutMs: 150 }); + assert.equal(hung.outcome, 'failed'); + assert.equal(hung.timedOut, true); + assert.match(hung.error ?? '', /did not finish within/); + assert.ok(Date.now() - started < 5_000); + assert.deepEqual(hung.logs.map(l => l.message), ['hanging'], 'nothing the handler does after the time limit is logged'); + await new Promise(resolve => setTimeout(resolve, 20)); + assert.equal(await f.database('plugin_state').where({ plugin_id: FIXTURE_ID, state_key: 'late' }).first(), undefined, 'ctx refuses calls once the run has ended'); + + for (const [handler, pattern] of [['missing', /has no handler missing/], ['notAFunction', /has no handler/], ['returnsTooMuch', /more than 16384 bytes/], ['constructor', /has no handler/], ['../x', /Invalid handler name/]] as const) { + const run = await runPluginHandler(FIXTURE_ID, handler, invoke(), f.options); + assert.equal(run.outcome, 'failed', handler); + assert.match(run.error ?? '', pattern, handler); + } + // A broken handler source, an unknown plugin and a disabled plugin fail the same way. + assert.equal((await runPluginHandler(FIXTURE_ID, 'succeed', invoke(), { ...f.options, handlers: async () => { throw new Error('registry unavailable'); } })).error, 'registry unavailable'); + assert.match((await runPluginHandler('not-installed', 'succeed', invoke(), f.options)).error ?? '', /not in this ProPR's registry/); + await f.store.setEnabled('1', FIXTURE_ID, false); + assert.match((await runPluginHandler(FIXTURE_ID, 'succeed', invoke(), f.options)).error ?? '', /is not enabled/); +}); + +test('a handler throwing a value that cannot be converted to a string returns a failed outcome and ends the run', async t => { + const f = await pluginHandlerFixture(t); + const signals: AbortSignal[] = []; + const throwing = (value: () => unknown) => ({ ...f.options, handlers: async () => ({ + throwsValue: async (_input: PluginHandlerInput, ctx: { signal: AbortSignal }) => { signals.push(ctx.signal); throw value(); }, + }) }); + const nonStringMessage = () => Object.assign(new Error('x'), { message: { toString: 'upstream field' } }); + const throwingMessage = () => Object.defineProperty(new Error('x'), 'message', { get() { throw new Error('getter'); } }); + const trappedProxy = () => new Proxy({}, { getPrototypeOf() { throw new Error('trap'); } }); + const cases: [string, () => unknown][] = [ + // JSON-compatible: an invocation payload can carry it. + ['own non-callable toString', () => ({ message: 'Upstream failed', toString: 'upstream field' })], + ['null prototype', () => Object.create(null)], + ['Error with a non-string message', nonStringMessage], + ['Error with a throwing message getter', throwingMessage], + ['proxy whose getPrototypeOf trap throws', trappedProxy], + ]; + for (const [name, value] of cases) { + const run = await runPluginHandler(FIXTURE_ID, 'throwsValue', invoke(), throwing(value)); + assert.equal(run.outcome, 'failed', name); + assert.equal(run.timedOut, false, name); + assert.equal(run.error, 'The handler failed', name); + assert.equal(signals.at(-1)?.aborted, true, `${name}: the run's context is aborted`); + } + assert.equal(signals.length, cases.length); + // A primitive still gives its own message. + assert.equal((await runPluginHandler(FIXTURE_ID, 'throwsValue', invoke(), throwing(() => 'plain text'))).error, 'plain text'); +}); + +test('the official registry loads example-pack handlers through the plugin registry', async t => { + const f = await pluginHandlerFixture(t); + const { getOfficialPlugin } = await import('../../plugins/src/index.js'); + const example = getOfficialPlugin('example-pack', { includeDevelopment: true })!; + assert.ok(example.assets.handlers?.endsWith('example-pack/handlers.ts')); + const { loadPluginHandlers } = await import('../../plugins/src/index.js'); + const handlers = await loadPluginHandlers('example-pack', { includeDevelopment: true }); + assert.equal(typeof handlers?.recordEvent, 'function'); + assert.equal(await loadPluginHandlers('example-pack', { includeDevelopment: false }), null, 'a development plugin is hidden without the flag'); + const record = { installedVersion: '0.1.0', enabled: true, settings: {}, repositories: null, grantedPermissions: example.manifest.permissions, acknowledgements: {}, installedBy: '1', installedAt: 0, updatedBy: '1', updatedAt: 0 }; + // ctx.state writes need the installation to exist: the row the record describes. + await f.database('plugin_installations').insert({ plugin_id: 'example-pack', manifest_version: '0.1.0', enabled: true, granted_permissions: JSON.stringify(example.manifest.permissions), installed_by: '1', installed_at: 0, updated_by: '1', updated_at: 0 }); + const run = await runPluginHandler({ pluginId: 'example-pack', manifest: example.manifest, record }, 'recordEvent', { + kind: 'event', event: { seq: 12, repository: 'owner/allowed', domain: 'task', type: 'completed', entityId: 'task-1', prNumber: null, issueNumber: 3, planId: null, headSha: null, payload: {}, occurredAt: '' }, deliveryId: 'evt-12-recordEvent', attempt: 1, + }, { ...f.options, handlers: async id => loadPluginHandlers(id, { includeDevelopment: true }) }); + assert.equal(run.outcome, 'succeeded', run.error ?? ''); + assert.deepEqual(run.result, { repository: 'owner/allowed', cursor: 12 }); + assert.match(run.logs[0].message, /task task-1 is completed/); +}); + +test('ctx.state enforces its key, size and key-count limits and is deleted on uninstall', async t => { + const f = await pluginHandlerFixture(t); + const state = async (payload: PluginJsonValue) => (await runPluginHandler(FIXTURE_ID, 'useState', invoke(payload), f.options)).result as { ok: boolean; value?: PluginJsonValue; code?: string }; + + assert.deepEqual(await state({ op: 'set', key: 'pr:owner/allowed#7', value: { thread: 'C123/1700000000.1' } }), { ok: true }); + assert.deepEqual(await state({ op: 'get', key: 'pr:owner/allowed#7' }), { ok: true, value: { thread: 'C123/1700000000.1' } }); + assert.deepEqual(await state({ op: 'keys', prefix: 'pr:' }), { ok: true, value: ['pr:owner/allowed#7'] }); + + // Too large a value, a bad key and an over-long key are refused. + assert.equal((await state({ op: 'set', key: 'big', value: 'x'.repeat(PLUGIN_STATE_LIMITS.valueBytes) })).code, 'state_limit'); + assert.equal((await state({ op: 'set', key: 'has space', value: 1 })).code, 'invalid_input'); + assert.equal((await state({ op: 'set', key: 'k'.repeat(PLUGIN_STATE_LIMITS.keyLength + 1), value: 1 })).code, 'invalid_input'); + + // The total size limit: fill with values just under the per-value limit. + const chunk = 'y'.repeat(PLUGIN_STATE_LIMITS.valueBytes - 100); + const fits = Math.floor(PLUGIN_STATE_LIMITS.totalBytes / (chunk.length + 2)); + for (let i = 0; i < fits; i++) assert.deepEqual(await state({ op: 'set', key: `chunk:${i}`, value: chunk }), { ok: true }, `chunk ${i}`); + assert.equal((await state({ op: 'set', key: 'chunk:over', value: chunk })).code, 'state_limit'); + // Rewriting an existing key replaces its bytes instead of adding to them. + assert.deepEqual(await state({ op: 'set', key: 'chunk:0', value: chunk }), { ok: true }); + for (let i = 0; i < fits; i++) await state({ op: 'delete', key: `chunk:${i}` }); + + // The key-count limit. + const rows = Array.from({ length: PLUGIN_STATE_LIMITS.keys - 1 }, (_, i) => ({ plugin_id: FIXTURE_ID, state_key: `n:${i}`, value: '1', bytes: 1, updated_at: 0 })); + for (let i = 0; i < rows.length; i += 100) await f.database('plugin_state').insert(rows.slice(i, i + 100)); + assert.equal(await f.database('plugin_state').where({ plugin_id: FIXTURE_ID }).count({ n: '*' }).first().then(r => Number(r?.n)), PLUGIN_STATE_LIMITS.keys); + assert.equal((await state({ op: 'set', key: 'one-too-many', value: 1 })).code, 'state_limit'); + assert.deepEqual(await state({ op: 'set', key: 'n:0', value: 2 }), { ok: true }, 'an existing key can still be rewritten at the limit'); + + // Another plugin's state is separate and survives this plugin's uninstall. + await f.database('plugin_state').insert({ plugin_id: 'other-plugin', state_key: 'kept', value: '1', bytes: 1, updated_at: 0 }); + await f.store.setEnabled('1', FIXTURE_ID, false); + await f.store.uninstall('1', FIXTURE_ID, FIXTURE_ID); + assert.equal(await f.database('plugin_state').where({ plugin_id: FIXTURE_ID }).first(), undefined); + assert.ok(await f.database('plugin_state').where({ plugin_id: 'other-plugin' }).first()); + const uninstall = await f.database('plugin_audit').where({ plugin_id: FIXTURE_ID, action: 'uninstall' }).first(); + assert.equal(JSON.parse(uninstall.detail).stateKeys, PLUGIN_STATE_LIMITS.keys); +}); + +test('ctx.http injects the connection credential, which the handler and the logs never see', async t => { + const f = await pluginHandlerFixture(t, { secret: SENTINEL }); + const run = await runPluginHandler(FIXTURE_ID, 'fetchUrl', invoke({ url: 'https://api.fixture.example/v1/items?page=2', connection: 'api-token', init: { method: 'POST', json: { a: 1 } } }), f.options); + assert.equal(run.outcome, 'succeeded', run.error ?? ''); + const result = run.result as { ok: boolean; status: number; headers: Record; body: string }; + assert.equal(result.ok, true, JSON.stringify(result)); + assert.equal(result.status, 200); + + // The upstream received the credential on the checked address, with the original host as TLS identity. + const [sent] = f.http.requests; + assert.equal(sent.headers.authorization, `Bearer ${SENTINEL}`); + assert.equal(sent.address, '93.184.216.34'); + assert.equal(sent.url.hostname, 'api.fixture.example'); + assert.equal(sent.url.search, '?page=2'); + assert.equal(sent.method, 'POST'); + assert.equal(sent.body?.toString(), '{"a":1}'); + assert.equal(sent.headers['content-type'], 'application/json'); + + // Sentinel: in no spelling anywhere the handler or its caller can see (ctx, response headers and body, logs, result). + const visible = JSON.stringify(run); + for (const spelling of [SENTINEL, Buffer.from(SENTINEL).toString('base64'), Buffer.from(`Bearer ${SENTINEL}`).toString('base64')]) { + assert.equal(visible.includes(spelling), false, `${spelling} leaked`); + } + assert.match(result.headers['x-echo-authorization'], /REDACTED/); + // The upstream also echoed the credential as a header name: that header is dropped, the others are kept. + assert.deepEqual(Object.keys(result.headers).sort(), ['content-type', 'x-echo-authorization']); + assert.equal(JSON.stringify(await f.database('plugin_audit').select()).includes(SENTINEL), false); +}); + +test('a credential logged before the handler acquires it is redacted from the returned logs', async t => { + const f = await pluginHandlerFixture(t, { secret: SENTINEL }); + // The input carries the opaque token (an upstream callback payload, say); the handler logs it before its first authenticated request. + const note = `callback for ${SENTINEL} with ${Buffer.from(SENTINEL).toString('base64')} and ${encodeURIComponent(`/${SENTINEL}`)}`; + const run = await runPluginHandler(FIXTURE_ID, 'logThenFetch', invoke({ url: 'https://api.fixture.example/v1', connection: 'api-token', note }), f.options); + assert.equal(run.outcome, 'succeeded', run.error ?? ''); + assert.equal(f.http.requests[0]?.headers.authorization, `Bearer ${SENTINEL}`, 'the credential was acquired after the line was logged'); + assert.deepEqual(run.logs.map(l => [l.level, l.message]).slice(0, 1), [['info', 'callback for [REDACTED] with [REDACTED] and %2F[REDACTED]']]); + assert.deepEqual(run.logs[0].fields, { note: 'callback for [REDACTED] with [REDACTED] and %2F[REDACTED]' }); + const visible = JSON.stringify(run); + for (const spelling of [SENTINEL, Buffer.from(SENTINEL).toString('base64'), encodeURIComponent(SENTINEL)]) { + assert.equal(visible.includes(spelling), false, `${spelling} leaked`); + } + assert.equal(JSON.stringify(await f.database('plugin_audit').select()).includes(SENTINEL), false); +}); + +/** Whether `text` shows the first `length` characters of `secret` or more. */ +const showsPrefix = (text: string, secret: string, length = 8) => text.includes(secret.slice(0, length)); + +test('a credential straddling the log or error length limit is redacted before the message is cut', async t => { + const f = await pluginHandlerFixture(t, { secret: SENTINEL }); + const request = { url: 'https://api.fixture.example/v1', connection: 'api-token' }; + // All of the credential but its last character falls inside the limit. + const straddling = (limit: number) => `${'x'.repeat(limit - SENTINEL.length + 1)}${SENTINEL}`; + const note = straddling(PLUGIN_LOG_LIMITS.messageChars); + for (const handler of ['logThenFetch', 'fetchThenLog']) { + const run = await runPluginHandler(FIXTURE_ID, handler, invoke({ ...request, note }), f.options); + assert.equal(run.outcome, 'succeeded', run.error ?? ''); + const line = run.logs.find(l => l.level === 'info')!; + assert.ok(line.message.length <= PLUGIN_LOG_LIMITS.messageChars, handler); + assert.ok(line.message.startsWith('x'.repeat(100)), handler); + assert.equal(showsPrefix(JSON.stringify(run), SENTINEL), false, `${handler}: a prefix of the credential leaked`); + } + const failed = await runPluginHandler(FIXTURE_ID, 'fetchThenThrow', invoke({ ...request, note: straddling(1_000) }), f.options); + assert.equal(failed.outcome, 'failed'); + assert.ok((failed.error ?? '').length <= 1_000); + assert.match(failed.error ?? '', /^x{100}/); + assert.equal(showsPrefix(JSON.stringify(failed), SENTINEL), false, 'a prefix of the credential leaked from the error message'); +}); + +test('a credential longer than the kept margin and cut at the limit has its visible start withheld', async t => { + // Longer than a stored connection secret can be: a custom `connectionSecret` source. + const secret = `long-credential-${'k'.repeat(40 * 1024)}-end`; + const f = await pluginHandlerFixture(t); + const prefix = 'x'.repeat(PLUGIN_LOG_LIMITS.messageChars - 100); + const note = `${prefix}${secret}`; + const run = await runPluginHandler(FIXTURE_ID, 'logThenFetch', invoke({ url: 'https://api.fixture.example/v1', connection: 'api-token', note }), { ...f.options, connectionSecret: async () => secret }); + assert.equal(f.http.requests[0]?.headers.authorization, `Bearer ${secret}`, 'the credential was acquired after the line was logged'); + const line = run.logs.find(l => l.level === 'info')!; + assert.equal(line.message, prefix); + assert.equal(showsPrefix(JSON.stringify(run), secret, 4), false); +}); + +test('ctx.http refuses hosts not in outboundHosts, private addresses, plain HTTP and unusable connections', async t => { + const f = await pluginHandlerFixture(t, { secret: SENTINEL }); + const fetch = async (payload: PluginJsonValue) => { + const run = await runPluginHandler(FIXTURE_ID, 'fetchUrl', invoke(payload), f.options); + return { ...(run.result as { ok: boolean; code: string; message: string }), logs: run.logs }; + }; + const cases: [PluginJsonValue, string][] = [ + [{ url: 'https://evil.example/steal', connection: 'api-token' }, 'host_not_allowed'], + [{ url: 'http://api.fixture.example/v1' }, 'insecure_url'], + [{ url: 'https://private.fixture.example/v1', connection: 'api-token' }, 'private_address'], + [{ url: 'https://93.184.216.34/v1' }, 'host_not_allowed'], + [{ url: 'https://api.fixture.example:8443/v1' }, 'host_not_allowed'], + [{ url: 'https://user:pass@api.fixture.example/v1' }, 'invalid_input'], + [{ url: 'https://api.fixture.example/v1', init: { headers: { Host: 'evil.example' } } }, 'invalid_input'], + [{ url: 'https://api.fixture.example/v1', connection: 'unset-token' }, 'connection_unavailable'], + [{ url: 'https://api.fixture.example/v1', connection: 'hook-secret' }, 'connection_unavailable'], + [{ url: 'https://api.fixture.example/v1', connection: 'undeclared' }, 'connection_unavailable'], + ]; + for (const [payload, code] of cases) { + const result = await fetch(payload); + assert.equal(result.ok, false, JSON.stringify(payload)); + assert.equal(result.code, code, `${JSON.stringify(payload)}: ${result.message}`); + } + assert.equal(f.http.requests.length, 0, 'no refused request reached the network'); + const refused = await fetch({ url: 'https://evil.example/steal?token=abc', connection: 'api-token' }); + assert.deepEqual(refused.logs.map(l => [l.level, l.message]), [['warn', 'ctx.http request to evil.example refused: host_not_allowed']]); + assert.equal(JSON.stringify(refused.logs).includes('token=abc'), false, 'only the hostname of a refused URL is logged'); +}); diff --git a/packages/core/test/pluginProprClient.test.ts b/packages/core/test/pluginProprClient.test.ts new file mode 100644 index 0000000000..20e0de04ef --- /dev/null +++ b/packages/core/test/pluginProprClient.test.ts @@ -0,0 +1,339 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PLUGIN_PROPR_OPERATIONS, PLUGIN_PERMISSIONS, type PluginJsonValue } from '@propr/shared'; +import { runPluginHandler } from '../src/plugins/pluginHandlerRunner.js'; +import { githubPluginProprBackend } from '../src/plugins/pluginProprBackend.js'; +import { pluginAttributionMarker, scopedProprClient, type PluginProprCallRecord } from '../src/plugins/pluginProprClient.js'; +import type { IssueJobData } from '../src/queue/taskQueue.types.js'; +import { taskIntentIssueRef } from '../src/services/taskIntentIssueRef.js'; +import { FIXTURE_ID, pluginHandlerFixture, recordingBackend } from './helpers/pluginHandlerFixture.js'; + +type CallResult = { ok: boolean; value?: PluginJsonValue; code?: string; message?: string }; + +async function proprFixture(t: Parameters[0]) { + const f = await pluginHandlerFixture(t); + const call = async (operation: string, ...args: PluginJsonValue[]) => { + const run = await runPluginHandler(FIXTURE_ID, 'callPropr', { kind: 'invoke', payload: { operation, args } }, f.options); + assert.equal(run.outcome, 'succeeded', run.error ?? ''); + return { ...(run.result as CallResult), logs: run.logs }; + }; + const audit = async () => (await f.database('plugin_audit').whereIn('action', ['handler_attempt', 'handler_call', 'handler_refused']).orderBy('id').select('action', 'actor', 'detail')) + .map(row => ({ action: row.action as string, actor: row.actor as string, ...JSON.parse(row.detail) as Record })); + return { ...f, call, audit }; +} + +test('every ctx.propr operation names a permission of the closed set', () => { + for (const permission of Object.values(PLUGIN_PROPR_OPERATIONS)) assert.ok((PLUGIN_PERMISSIONS as readonly string[]).includes(permission), permission); +}); + +test('ctx.propr refuses operations outside the granted permissions, and logs and records the refusal', async t => { + const f = await proprFixture(t); + // The fixture does not ask for pull_requests.read or pull_requests.comment. + for (const [operation, args, permission] of [['getPullRequest', ['owner/allowed', 7], 'pull_requests.read'], ['commentOnPullRequest', ['owner/allowed', 7, 'hello'], 'pull_requests.comment']] as const) { + const result = await f.call(operation, ...args); + assert.equal(result.ok, false); + assert.equal(result.code, 'permission_denied'); + assert.match(result.message ?? '', new RegExp(permission.replace('.', '\\.'))); + assert.deepEqual(result.logs.map(l => [l.level, l.message]), [['warn', `ctx.propr.${operation} refused: permission_denied`]]); + } + assert.deepEqual(f.calls, [], 'a refused call never reaches the backend'); + + // A permission granted at install that the current manifest dropped is not usable either. + await f.database('plugin_installations').where({ plugin_id: FIXTURE_ID }).update({ granted_permissions: JSON.stringify(['tasks.read', 'pull_requests.read']) }); + assert.equal((await f.call('getPullRequest', 'owner/allowed', 7)).code, 'permission_denied'); + assert.equal((await f.call('listEvents', { repository: 'owner/allowed' })).code, 'permission_denied', 'a requested but ungranted permission is refused'); + assert.equal((await f.call('getTask', 'task-1')).ok, true); + + const audit = await f.audit(); + assert.deepEqual(audit.map(a => [a.action, a.actor, a.operation, a.reason ?? null]), [ + ['handler_refused', `plugin:${FIXTURE_ID}`, 'getPullRequest', 'permission_denied'], + ['handler_refused', `plugin:${FIXTURE_ID}`, 'commentOnPullRequest', 'permission_denied'], + ['handler_refused', `plugin:${FIXTURE_ID}`, 'getPullRequest', 'permission_denied'], + ['handler_refused', `plugin:${FIXTURE_ID}`, 'listEvents', 'permission_denied'], + ['handler_call', `plugin:${FIXTURE_ID}`, 'getTask', null], + ]); + assert.equal(audit[0].handler, 'callPropr'); +}); + +test('ctx.propr refuses repositories outside the installation limit, tasks of other repositories included', async t => { + const f = await proprFixture(t); + for (const [operation, args] of [ + ['getIssue', ['other/repo', 3]], ['createIssue', ['other/repo', { title: 'x' }]], ['createTask', ['Other/Repo', 3]], + ['commentOnIssue', ['other/repo', 3, 'hi']], ['addLabel', ['other/repo', 3, 'bug']], ['removeLabel', ['other/repo', 3, 'bug']], + ['listEvents', [{ repository: 'other/repo' }]], + ] as const) { + const result = await f.call(operation, ...(args as unknown as PluginJsonValue[])); + assert.equal(result.code, 'repository_not_allowed', `${operation}: ${result.message}`); + } + assert.deepEqual(f.calls, [], 'no call outside the limit reaches the backend'); + // A task is read before its repository is known: one outside the limit is not returned. + const elsewhere = await f.call('getTask', 'elsewhere'); + assert.equal(elsewhere.code, 'repository_not_allowed'); + assert.equal(elsewhere.value, undefined); + assert.equal((await f.call('getTask', 'missing')).code, 'not_found'); + // The limit is case-insensitive, like GitHub names. + assert.equal((await f.call('getIssue', 'Owner/Allowed', 3)).ok, true); + // Invalid input is refused before the backend too. + assert.equal((await f.call('getIssue', 'not-a-repo', 3)).code, 'invalid_input'); + assert.equal((await f.call('getIssue', 'owner/allowed', -1)).code, 'invalid_input'); + assert.equal((await f.call('createIssue', 'owner/allowed', { title: '' })).code, 'invalid_input'); + assert.equal((await f.call('listEvents', { repository: 'owner/allowed', afterSeq: -1 })).code, 'invalid_input'); + assert.equal((await f.call('listEvents', { repository: 'owner/allowed', domains: ['secrets'] })).code, 'invalid_input'); +}); + +test('allowed ctx.propr operations reach the backend attributed to the plugin', async t => { + const f = await proprFixture(t); + assert.equal((await f.call('getIssue', 'owner/allowed', 3)).ok, true); + assert.equal((await f.call('listEvents', { repository: 'owner/allowed', limit: 5000, afterSeq: 4, domains: ['task'] })).ok, true); + const created = await f.call('createIssue', 'owner/allowed', { title: 'Follow up', body: 'From the chat', labels: ['triage'] }); + assert.deepEqual(created.value, { repository: 'owner/allowed', number: 11, url: 'https://github.com/owner/allowed/issues/11' }); + assert.deepEqual((await f.call('createTask', 'owner/allowed', 11)).value, { repository: 'owner/allowed', issueNumber: 11, jobId: 'issue-owner-allowed-11' }); + assert.equal((await f.call('commentOnIssue', 'owner/allowed', 3, 'Linked thread')).ok, true); + assert.equal((await f.call('addLabel', 'owner/allowed', 3, 'synced')).ok, true); + assert.equal((await f.call('removeLabel', 'owner/allowed', 3, 'synced')).ok, true); + + const marker = pluginAttributionMarker(FIXTURE_ID); + assert.deepEqual(f.calls.map(c => c.operation), ['getIssue', 'listEvents', 'createIssue', 'createTask', 'comment', 'addLabel', 'removeLabel']); + assert.deepEqual(f.calls[1].args[0], { repository: 'owner/allowed', afterSeq: 4, domains: ['task'], limit: 100 }, 'event lists are bounded'); + assert.deepEqual(f.calls[2].args, ['owner/allowed', { title: 'Follow up', body: `From the chat\n\n${marker}`, labels: ['triage'] }]); + assert.deepEqual(f.calls[3].args, ['owner/allowed', 11, { requestedBy: `plugin:${FIXTURE_ID}` }]); + assert.equal(f.calls[4].args[2], `Linked thread\n\n${marker}`); + + const audit = await f.audit(); + assert.ok(audit.every(a => a.actor === `plugin:${FIXTURE_ID}` && a.handler === 'callPropr')); + // Reads are recorded once they complete; a mutation is recorded as attempted before it is dispatched, then as completed. + assert.deepEqual(audit.map(a => [a.action, a.operation, a.permission, a.repository, a.number ?? null]), [ + ['handler_call', 'getIssue', 'issues.read', 'owner/allowed', 3], + ['handler_call', 'listEvents', 'events.read', 'owner/allowed', null], + ...([['createIssue', 'issues.create', 'owner/allowed', null], ['createTask', 'tasks.create', 'owner/allowed', 11], ['commentOnIssue', 'issues.comment', 'owner/allowed', 3], ['addLabel', 'labels.write', 'owner/allowed', 3], ['removeLabel', 'labels.write', 'owner/allowed', 3]] as const) + .flatMap(call => [['handler_attempt', ...call], ['handler_call', ...call]]), + ]); + // A mutation's attempt and completion share one call id; each call has its own. + const ids = audit.map(a => a.callId as string); + assert.ok(ids.every(id => typeof id === 'string' && id.length > 0)); + assert.equal(new Set(ids).size, 7); + for (let i = 2; i < audit.length; i += 2) assert.equal(ids[i], ids[i + 1], `${audit[i].operation}: attempt and completion`); + // The audit trail an administrator reads shows them with the plugin as the actor. + const detail = await f.store.get('1', FIXTURE_ID); + assert.equal(detail.audit[0].action, 'handler_call'); + assert.equal(detail.audit[0].actor, `plugin:${FIXTURE_ID}`); + assert.equal(detail.audit[0].detail.operation, 'removeLabel'); +}); + +test('the GitHub backend queues the task with the plugin as requester, which the task keeps in its first event', async t => { + const f = await pluginHandlerFixture(t); + const jobs: { data: IssueJobData; jobId: string }[] = []; + const requests: { route: string; parameters: Record }[] = []; + const backend = githubPluginProprBackend({ + database: f.database, + enqueueIssueTask: async (data, jobId) => { jobs.push({ data, jobId }); }, + github: async () => ({ request: async (route, parameters) => { + requests.push({ route, parameters }); + return { data: { number: 5, id: 77, html_url: 'https://github.com/owner/allowed/issues/5', title: 't', state: 'open', labels: [{ name: 'bug' }], user: { login: 'octo' }, created_at: 'c', updated_at: 'u' } }; + } }), + }); + const run = await runPluginHandler(FIXTURE_ID, 'callPropr', { kind: 'invoke', payload: { operation: 'createTask', args: ['owner/allowed', 5] } }, { ...f.options, backend }); + assert.equal((run.result as CallResult).ok, true, JSON.stringify(run)); + assert.equal(jobs.length, 1); + assert.equal(jobs[0].jobId, 'issue-owner-allowed-5'); + assert.equal(jobs[0].data.requestedBy, `plugin:${FIXTURE_ID}`); + assert.deepEqual([jobs[0].data.repoOwner, jobs[0].data.repoName, jobs[0].data.number], ['owner', 'allowed', 5]); + // The issue job builds the task's state from its data: `requestedBy` survives into the task record. + assert.equal(taskIntentIssueRef(jobs[0].data, { kind: 'issue', number: 5, repoOwner: 'owner', repoName: 'allowed' }).requestedBy, `plugin:${FIXTURE_ID}`); + + const issue = await runPluginHandler(FIXTURE_ID, 'callPropr', { kind: 'invoke', payload: { operation: 'getIssue', args: ['owner/allowed', 5] } }, { ...f.options, backend }); + assert.deepEqual((issue.result as CallResult).value, { repository: 'owner/allowed', number: 5, title: 't', state: 'open', url: 'https://github.com/owner/allowed/issues/5', labels: ['bug'], author: 'octo', createdAt: 'c', updatedAt: 'u' }); + await runPluginHandler(FIXTURE_ID, 'callPropr', { kind: 'invoke', payload: { operation: 'commentOnIssue', args: ['owner/allowed', 5, 'hi'] } }, { ...f.options, backend }); + // getIssue reads the number; commentOnIssue reads it again to confirm it is an issue before posting. + assert.deepEqual(requests.map(r => r.route), ['GET /repos/{owner}/{repo}/issues/{issue_number}', 'GET /repos/{owner}/{repo}/issues/{issue_number}', 'POST /repos/{owner}/{repo}/issues/{issue_number}/comments']); + assert.equal(requests[2].parameters.body, `hi\n\n${pluginAttributionMarker(FIXTURE_ID)}`); + + // A backend failure reaches the handler without its details. + const failing = githubPluginProprBackend({ database: f.database, github: async () => ({ request: async () => { throw new Error('HttpError: Bad credentials ghs_secretvalue'); } }) }); + const failed = await runPluginHandler(FIXTURE_ID, 'callPropr', { kind: 'invoke', payload: { operation: 'getIssue', args: ['owner/allowed', 5] } }, { ...f.options, backend: failing }); + assert.deepEqual((failed.result as CallResult), { ok: false, code: 'unavailable', message: 'ProPR could not complete getIssue' }); +}); + +/** A GitHub where #5 is an issue and #7 is a pull request (the issue routes serve both), recording every request. */ +function issueOrPullGitHub() { + const requests: { route: string; parameters: Record }[] = []; + const item = (number: number) => ({ + number, id: 100 + number, html_url: `https://github.com/owner/allowed/${number === 7 ? 'pull' : 'issues'}/${number}`, title: 't', state: 'open', labels: [], user: { login: 'octo' }, created_at: 'c', updated_at: 'u', + ...(number === 7 ? { pull_request: { url: 'https://api.github.com/repos/owner/allowed/pulls/7' } } : {}), + }); + const github = async () => ({ request: async (route: string, parameters: Record) => { + requests.push({ route, parameters }); + return { data: item(Number(parameters.issue_number)) }; + } }); + return { requests, github }; +} + +test('a comment permission covers only its own target kind: issues.comment cannot reach a pull request, nor the reverse', async t => { + const f = await pluginHandlerFixture(t); + const posts = (requests: { route: string; parameters: Record }[]) => requests.filter(r => r.route.startsWith('POST')); + const client = (permissions: PluginProprCallRecord['permission'][]) => { + const gh = issueOrPullGitHub(); + const records: PluginProprCallRecord[] = []; + const propr = scopedProprClient({ pluginId: FIXTURE_ID, grantedPermissions: permissions, repositories: ['owner/allowed'], backend: githubPluginProprBackend({ database: f.database, github: gh.github }), record: async call => { records.push(call); } }); + return { propr, records, requests: gh.requests }; + }; + + // issues.comment only: commentOnIssue with a pull request's number is refused and posts nothing. + const issueOnly = client(['issues.comment', 'issues.read']); + await assert.rejects(issueOnly.propr.commentOnIssue('owner/allowed', 7, 'sneaky'), { code: 'not_found' }); + assert.deepEqual(posts(issueOnly.requests), []); + assert.deepEqual(issueOnly.records.map(r => [r.operation, r.outcome, r.reason]), [['commentOnIssue', 'attempted', undefined], ['commentOnIssue', 'refused', 'not_found']]); + // getIssue (issues.read) cannot read a pull request through the issue route either. + await assert.rejects(issueOnly.propr.getIssue('owner/allowed', 7), { code: 'not_found' }); + // The issue itself is still allowed. + assert.equal((await issueOnly.propr.commentOnIssue('owner/allowed', 5, 'ok')).id, 105); + assert.deepEqual(posts(issueOnly.requests).map(r => r.parameters.issue_number), [5]); + + // pull_requests.comment only: commentOnPullRequest with an issue's number is refused and posts nothing. + const pullOnly = client(['pull_requests.comment']); + await assert.rejects(pullOnly.propr.commentOnPullRequest('owner/allowed', 5, 'sneaky'), { code: 'not_found' }); + assert.deepEqual(posts(pullOnly.requests), []); + assert.equal((await pullOnly.propr.commentOnPullRequest('owner/allowed', 7, 'ok')).id, 107); + // Without the permission the method is refused before GitHub is asked anything. + await assert.rejects(pullOnly.propr.commentOnIssue('owner/allowed', 5, 'x'), { code: 'permission_denied' }); + + // Through a handler: the fixture has issues.comment but not pull_requests.comment. + const gh = issueOrPullGitHub(); + const run = await runPluginHandler(FIXTURE_ID, 'callPropr', { kind: 'invoke', payload: { operation: 'commentOnIssue', args: ['owner/allowed', 7, 'sneaky'] } }, { ...f.options, backend: githubPluginProprBackend({ database: f.database, github: gh.github }) }); + assert.equal((run.result as CallResult).code, 'not_found'); + assert.deepEqual(posts(gh.requests), []); +}); + +test('the GitHub backend sends nothing once the handler run has ended, even when the App token arrives later', async () => { + const controller = new AbortController(); + const requests: string[] = []; + let release!: () => void; + const token = new Promise(resolve => { release = resolve; }); + const backend = githubPluginProprBackend({ + database: {} as never, signal: controller.signal, + enqueueIssueTask: async () => { requests.push('enqueue'); }, + github: async () => { await token; return { request: async (route: string) => { requests.push(route); return { data: {} }; } }; }, + }); + const pending = backend.comment('owner/allowed', 5, 'late', 'issue'); + controller.abort(); + release(); + await assert.rejects(pending, { code: 'unavailable' }); + await assert.rejects(backend.createTask('owner/allowed', 5, { requestedBy: 'plugin:x' }), { code: 'unavailable' }); + assert.deepEqual(requests, []); +}); + +test('the default enqueue adds no job when the run ends while the issue queue is initialized', async () => { + const controller = new AbortController(); + const added: string[] = []; + let release!: () => void; + const initialized = new Promise(resolve => { release = resolve; }); + let initializing!: () => void; + const started = new Promise(resolve => { initializing = resolve; }); + const backend = githubPluginProprBackend({ + database: {} as never, signal: controller.signal, + issueQueue: async () => { initializing(); await initialized; return { add: async (_name: string, _data: IssueJobData, options: Record) => { added.push(String(options.jobId)); } }; }, + }); + const pending = backend.createTask('owner/allowed', 5, { requestedBy: 'plugin:x' }); + await started; + controller.abort(); + release(); + await assert.rejects(pending, { code: 'unavailable' }); + assert.deepEqual(added, []); + + // A live run's task is added to the same queue. + const live = githubPluginProprBackend({ database: {} as never, signal: new AbortController().signal, issueQueue: async () => ({ add: async (_n: string, _d: IssueJobData, options: Record) => { added.push(String(options.jobId)); } }) }); + assert.equal((await live.createTask('owner/allowed', 5, { requestedBy: 'plugin:x' })).jobId, 'issue-owner-allowed-5'); + assert.deepEqual(added, ['issue-owner-allowed-5']); +}); + +/** Every ctx.propr mutation, with arguments inside the fixture's limit. */ +const MUTATION_CALLS = [ + ['createIssue', ['owner/allowed', { title: 'Follow up' }]], ['createTask', ['owner/allowed', 11]], ['commentOnIssue', ['owner/allowed', 3, 'hi']], + ['commentOnPullRequest', ['owner/allowed', 7, 'hi']], ['addLabel', ['owner/allowed', 3, 'synced']], ['removeLabel', ['owner/allowed', 3, 'synced']], +] as const; + +test('a ctx.propr mutation is attributed in the audit trail before it is dispatched', async t => { + const f = await proprFixture(t); + // What the audit trail held at the moment each mutation reached the backend. + const seen: { operation: string; actions: string[] }[] = []; + const snapshot = (operation: string) => async () => { + const rows = await f.database('plugin_audit').where({ plugin_id: FIXTURE_ID, actor: `plugin:${FIXTURE_ID}` }).orderBy('id').select('action', 'detail'); + seen.push({ operation, actions: rows.filter(r => JSON.parse(r.detail).operation === operation).map(r => r.action as string) }); + }; + const backend = { ...f.backend }; + for (const name of ['createIssue', 'createTask', 'comment', 'addLabel', 'removeLabel'] as const) { + const original = backend[name] as (...args: unknown[]) => Promise; + (backend as Record)[name] = async (...args: unknown[]) => { + await snapshot(name === 'comment' ? (args[3] === 'issue' ? 'commentOnIssue' : 'commentOnPullRequest') : name)(); + return original(...args); + }; + } + for (const operation of ['createIssue', 'createTask', 'commentOnIssue', 'addLabel', 'removeLabel'] as const) { + const [, args] = MUTATION_CALLS.find(([name]) => name === operation)!; + const run = await runPluginHandler(FIXTURE_ID, 'callPropr', { kind: 'invoke', payload: { operation, args: args as unknown as PluginJsonValue[] } }, { ...f.options, backend }); + assert.equal((run.result as CallResult).ok, true, JSON.stringify(run.result)); + } + assert.deepEqual(seen.map(s => [s.operation, s.actions]), ['createIssue', 'createTask', 'commentOnIssue', 'addLabel', 'removeLabel'].map(op => [op, ['handler_attempt']])); +}); + +test('a ctx.propr mutation whose attempt cannot be recorded is refused and never dispatched', async t => { + const f = await proprFixture(t); + // The audit trail accepts everything but attempts, as a failing insert would. + await f.database.raw(`CREATE TRIGGER no_attempts BEFORE INSERT ON plugin_audit WHEN NEW.action = 'handler_attempt' BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END`); + for (const operation of ['createIssue', 'createTask', 'commentOnIssue', 'addLabel', 'removeLabel'] as const) { + const [, args] = MUTATION_CALLS.find(([name]) => name === operation)!; + const result = await f.call(operation, ...(args as unknown as PluginJsonValue[])); + assert.deepEqual([result.ok, result.code, result.message], [false, 'unavailable', `ProPR could not record ${operation}, so it was not sent`], operation); + } + assert.deepEqual(f.calls, [], 'no mutation reached the backend'); + assert.deepEqual((await f.audit()).map(a => [a.action, a.operation, a.reason]), ['createIssue', 'createTask', 'commentOnIssue', 'addLabel', 'removeLabel'].map(op => ['handler_refused', op, 'unavailable'])); + // Reads are not mutations: they need no attempt record. + assert.equal((await f.call('getIssue', 'owner/allowed', 3)).ok, true); + + // Directly: a failing attempt write stops every mutation, before the backend. + const { backend, calls } = recordingBackend(); + const records: PluginProprCallRecord[] = []; + const propr = scopedProprClient({ + pluginId: FIXTURE_ID, grantedPermissions: Object.values(PLUGIN_PROPR_OPERATIONS), repositories: null, backend, + record: async call => { if (call.outcome === 'attempted') throw new Error('insert failed'); records.push(call); }, + }); + for (const [operation, args] of MUTATION_CALLS) { + await assert.rejects((propr[operation] as (...values: unknown[]) => Promise)(...args), { code: 'unavailable' }, operation); + } + assert.deepEqual(calls, []); + assert.deepEqual(records.map(r => [r.operation, r.outcome, r.reason]), MUTATION_CALLS.map(([op]) => [op, 'refused', 'unavailable'])); +}); + +test('once a ctx.propr mutation is attempted, its outcome record cannot fail the call or erase the attempt', async t => { + const f = await proprFixture(t); + // Completions cannot be written: the mutation still returns its result, and the attempt attributes it. + await f.database.raw(`CREATE TRIGGER no_completions BEFORE INSERT ON plugin_audit WHEN NEW.action = 'handler_call' BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END`); + const created = await f.call('createIssue', 'owner/allowed', { title: 'Follow up' }); + assert.deepEqual(created.value, { repository: 'owner/allowed', number: 11, url: 'https://github.com/owner/allowed/issues/11' }, 'a retry would create a second issue'); + assert.deepEqual(f.calls.map(c => c.operation), ['createIssue']); + assert.deepEqual((await f.audit()).map(a => [a.action, a.operation]), [['handler_attempt', 'createIssue']]); + // A read has no attempt to fall back on: failing to record it still fails it. + assert.equal((await f.call('getIssue', 'owner/allowed', 3)).ok, false); + + // Directly: the outcome failure is reported, the attempt and the backend failure share a call id. + const { backend, calls } = recordingBackend(); + const records: PluginProprCallRecord[] = []; + const failures: [string, string][] = []; + const failing = { ...backend, comment: async () => { calls.push({ operation: 'comment', args: [] }); throw new Error('HttpError: timeout after the comment was posted'); } }; + const propr = scopedProprClient({ + pluginId: FIXTURE_ID, grantedPermissions: Object.values(PLUGIN_PROPR_OPERATIONS), repositories: null, backend: failing, + record: async call => { records.push(call); if (call.outcome !== 'attempted') throw new Error('insert failed'); }, + onRecordFailure: (call, error) => { failures.push([call.outcome, (error as Error).message]); }, + }); + assert.equal((await propr.addLabel('owner/allowed', 3, 'synced')), undefined); + await assert.rejects(propr.commentOnIssue('owner/allowed', 3, 'hi'), { code: 'unavailable', message: 'ProPR could not complete commentOnIssue' }); + assert.deepEqual(calls.map(c => c.operation), ['addLabel', 'comment']); + assert.deepEqual(records.map(r => [r.operation, r.outcome, r.reason]), [ + ['addLabel', 'attempted', undefined], ['addLabel', 'completed', undefined], + ['commentOnIssue', 'attempted', undefined], ['commentOnIssue', 'refused', 'unavailable'], + ]); + assert.equal(records[0].callId, records[1].callId); + assert.equal(records[2].callId, records[3].callId); + assert.notEqual(records[0].callId, records[2].callId); + assert.deepEqual(failures, [['completed', 'insert failed'], ['refused', 'insert failed']]); +}); diff --git a/packages/core/test/pluginStore.test.ts b/packages/core/test/pluginStore.test.ts new file mode 100644 index 0000000000..4d1b575df9 --- /dev/null +++ b/packages/core/test/pluginStore.test.ts @@ -0,0 +1,271 @@ +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 { up as pluginStateMigration } from '../src/db/migrations/20261114180000_plugin_state.js'; +import { up as journalMigration } from '../src/db/migrations/20261112160000_create_repository_events.js'; +import { up as deliveriesMigration } from '../src/db/migrations/20261115000000_plugin_event_deliveries.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'; + +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 grantMigration(database); await libraryMigration(database); await oauthMigration(database); await pluginMigration(database); await pluginStateMigration(database); await journalMigration(database); await deliveriesMigration(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/core/test/taskJournalEvents.test.ts b/packages/core/test/taskJournalEvents.test.ts new file mode 100644 index 0000000000..3e357d41a0 --- /dev/null +++ b/packages/core/test/taskJournalEvents.test.ts @@ -0,0 +1,300 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, mock, test } from 'node:test'; +import knex from 'knex'; +import { up as createJournal } from '../src/db/migrations/20261112160000_create_repository_events.js'; +import { up as addTaskDomain } from '../src/db/migrations/20261114120000_add_task_repository_event_domain.js'; + +/** + * The task state manager journals `task` events where it records task state + * changes: `queued` on creation, `started` when a run starts, and one terminal + * event. Driven through the real manager against SQLite and an in-memory Redis. + */ +const database = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); +const warnings: Array> = []; +const log = { + info() {}, debug() {}, error() {}, warn(entry: Record) { warnings.push(entry); }, + withCorrelation: () => log, +}; +const redisValues = new Map(); +await mock.module('ioredis', { namedExports: { Redis: function Redis() { + return { + setex: async (key: string, _ttl: number, value: string) => { redisValues.set(key, value); return 'OK'; }, + set: async (key: string, value: string, ..._args: unknown[]) => { + if (redisValues.has(key)) return null; + redisValues.set(key, value); return 'OK'; + }, + get: async (key: string) => redisValues.get(key) ?? null, + // The compare-and-set script: replace the value only while it still equals the one read. + eval: async (_script: string, _keys: number, key: string, current: string, _ttl: number, next: string) => { + if (redisValues.get(key) !== current) return 0; + redisValues.set(key, next); return 1; + }, + on: () => {}, quit: async () => {}, disconnect: () => {}, + }; +} } }); +await mock.module('../src/db/connection.js', { namedExports: { db: database, closeConnection: async () => {} } }); +await mock.module('../src/utils/logger.js', { defaultExport: log, namedExports: { generateCorrelationId: () => 'corr-1' } }); +const activity: unknown[] = []; +await mock.module('../src/utils/eventPublisher.js', { namedExports: { getEventPublisher: () => ({ + publishTaskUpdate: async () => true, + publishActivity: async (event: unknown) => { activity.push(event); return true; }, +}) } }); + +const { WorkerStateManager, TaskStates } = await import('../src/utils/workerStateManager.js'); +const { listRepositoryEvents } = await import('../src/services/repositoryEventJournal.js'); +const { buildTaskJournalEvent } = await import('../src/services/taskJournalEvents.js'); + +await database.schema.createTable('tasks', table => { + table.string('task_id').primary(); table.string('job_id').unique(); table.string('correlation_id'); table.string('repository'); + table.integer('issue_number'); table.string('task_type'); table.string('model_name'); table.timestamp('created_at'); table.text('initial_job_data'); +}); +await database.schema.createTable('task_history', table => { + table.increments('history_id'); table.string('task_id'); table.string('state'); table.timestamp('timestamp'); + table.text('reason'); table.json('metadata'); +}); +await createJournal(database); +await addTaskDomain(database); + +const manager = new WorkerStateManager({ keyPrefix: 'test:task-journal:', stateExpiry: 3600 }); +after(async () => { await manager.close(); await database.destroy(); }); +beforeEach(async () => { + for (const table of ['tasks', 'task_history', 'repository_events']) await database(table).delete(); + redisValues.clear(); warnings.length = 0; activity.length = 0; +}); + +const repository = 'acme/app'; +const issueRef = (overrides: Record = {}) => ({ + number: 42, repoOwner: 'acme', repoName: 'app', type: 'issue', agentAlias: 'claude', modelName: 'claude-opus-5-5', ...overrides, +}); +const taskEvents = async () => (await listRepositoryEvents(database, { repository, domains: ['task'] })) + .map(event => ({ type: event.type, entityId: event.entityId, issueNumber: event.issueNumber, prNumber: event.prNumber, payload: event.payload })); +const types = async () => (await taskEvents()).map(event => event.type); + +async function runToProcessing(taskId: string, ref: Record): Promise { + await manager.createTaskStateIfAbsent(taskId, ref as never, null, `job-${taskId}`); + await manager.updateTaskState(taskId, TaskStates.PROCESSING, { reason: 'Starting' }); + await manager.updateTaskState(taskId, TaskStates.CLAUDE_EXECUTION, { reason: 'Agent running' }); + await manager.updateTaskState(taskId, TaskStates.POST_PROCESSING, { reason: 'Publishing' }); +} + +test('an implementation task journals queued, started and completed in order with the documented payload', async () => { + await runToProcessing('issue-42', issueRef()); + await manager.markTaskCompleted('issue-42', { prCreated: true, prNumber: 77, prUrl: 'https://github.com/acme/app/pull/77' }); + + const events = await taskEvents(); + assert.deepEqual(events.map(event => event.type), ['queued', 'started', 'completed']); + const base = { taskId: 'issue-42', kind: 'implementation', issueNumber: 42, prNumber: null, agent: 'claude', model: 'claude-opus-5-5', attempts: 0 }; + assert.deepEqual(events[0], { type: 'queued', entityId: 'issue-42', issueNumber: 42, prNumber: null, payload: base }); + assert.deepEqual(events[1].payload, base); + const { durationMs, ...terminal } = events[2].payload; + assert.deepEqual(terminal, { ...base, status: 'completed', terminalReason: null, pullRequest: 'opened', failureCategory: null }); + assert.equal(typeof durationMs, 'number'); + assert.ok((durationMs as number) >= 0); + // Task state already has its own realtime event; the journal publishes no second one. + assert.equal(activity.length, 0); +}); + +test('a pull request follow-up journals queued, started and completed, and reports the pull request as updated', async () => { + const ref = issueRef({ number: 9, pullRequestNumber: 9, type: 'pr-comment', agentAlias: 'codex', modelName: 'gpt-5' }); + await runToProcessing('pr-comment-9-1', ref); + await manager.updateTaskState('pr-comment-9-1', TaskStates.COMPLETED, { reason: 'PR comment processing completed successfully', commitHash: 'abc123' }); + + const events = await taskEvents(); + assert.deepEqual(events.map(event => event.type), ['queued', 'started', 'completed']); + for (const event of events) { + assert.equal(event.prNumber, 9); + assert.equal(event.issueNumber, null); + assert.equal(event.payload.kind, 'follow-up'); + assert.equal(event.payload.agent, 'codex'); + assert.equal(event.payload.model, 'gpt-5'); + } + assert.equal(events[2].payload.pullRequest, 'updated'); +}); + +test('a cancelled task appends cancelled with its coded reason', async () => { + await manager.createTaskStateIfAbsent('issue-5', issueRef({ number: 5 }) as never); + await manager.markTaskCancelled('issue-5', 'user'); + const events = await taskEvents(); + assert.deepEqual(events.map(event => event.type), ['queued', 'cancelled']); + assert.equal(events[1].payload.status, 'cancelled'); + assert.equal(events[1].payload.terminalReason, 'cancelled_by_user'); + assert.equal(events[1].payload.durationMs, null, 'a task cancelled before it started has no run duration'); + assert.equal(events[1].payload.failureCategory, null); +}); + +test('a failed task appends failed with a failure category; a retry starts again and ends once more', async () => { + await runToProcessing('issue-6', issueRef({ number: 6 })); + await manager.markTaskFailed('issue-6', new Error('git push rejected'), { errorCategory: 'git_operation' }); + let events = await taskEvents(); + assert.deepEqual(events.map(event => event.type), ['queued', 'started', 'failed']); + assert.equal(events[2].payload.failureCategory, 'git_operation'); + assert.equal(events[2].payload.status, 'failed'); + + await manager.updateTaskState('issue-6', TaskStates.PROCESSING, { isRetry: true, reason: 'Retrying' }); + await manager.updateTaskState('issue-6', TaskStates.FAILED, { terminalReason: 'timed_out', reason: 'Timed out' }); + events = await taskEvents(); + assert.deepEqual(events.map(event => event.type), ['queued', 'started', 'failed', 'started', 'failed']); + assert.equal(events[3].payload.attempts, 1); + assert.equal(events[4].payload.failureCategory, 'timed_out'); +}); + +test('a free-text error category is not journaled as a failure category', async () => { + await runToProcessing('issue-8', issueRef({ number: 8 })); + await manager.markTaskFailed('issue-8', new Error('boom'), { errorCategory: 'Error: the agent said something long' }); + assert.equal((await taskEvents())[2].payload.failureCategory, 'unknown'); +}); + +/** Run `steps` with `Date` frozen at each given minute past midnight in turn. */ +async function atMinutes(steps: Array<[number, () => Promise]>): Promise { + const midnight = Date.parse('2026-11-14T00:00:00.000Z'); + mock.timers.enable({ apis: ['Date'], now: midnight }); + try { + for (const [minute, step] of steps) { mock.timers.setTime(midnight + minute * 60_000); await step(); } + } finally { mock.timers.reset(); } +} + +test('a queue handoff is not journaled as a cancellation, resuming it is not a new start, and its duration spans the handoff', async () => { + const taskId = 'pr-comment-3-1'; + await atMinutes([ + [0, () => runToProcessing(taskId, issueRef({ number: 3, type: 'pr-comment' }))], + [5, () => manager.updateTaskState(taskId, TaskStates.CANCELLED, { + reason: 'PR comment job requeued', historyMetadata: { jobResultStatus: 'requeued' }, + })], + [10, () => manager.updateTaskState(taskId, TaskStates.PROCESSING, { reason: 'Resumed' })], + [11, () => manager.updateTaskState(taskId, TaskStates.COMPLETED, { reason: 'done' })], + ]); + assert.deepEqual(await types(), ['queued', 'started', 'completed']); + assert.equal((await taskEvents())[2].payload.durationMs, 11 * 60_000, 'the run is measured from its journaled start'); +}); + +test('repeated queue handoffs keep the original start; a retry after a real failure measures the new run', async () => { + const taskId = 'pr-comment-4-1'; + await atMinutes([ + [0, () => runToProcessing(taskId, issueRef({ number: 4, type: 'pr-comment' }))], + [2, () => manager.updateTaskState(taskId, TaskStates.CANCELLED, { reason: 'Task job rescheduled: rate limit' })], + [3, () => manager.updateTaskState(taskId, TaskStates.PROCESSING, { reason: 'Resumed' })], + [4, () => manager.updateTaskState(taskId, TaskStates.CANCELLED, { + reason: 'PR comment job requeued', historyMetadata: { jobResultStatus: 'requeued' }, + })], + [6, () => manager.updateTaskState(taskId, TaskStates.CLAUDE_EXECUTION, { reason: 'Resumed' })], + [7, () => manager.markTaskFailed(taskId, new Error('boom'), { errorCategory: 'worker' })], + [20, () => manager.updateTaskState(taskId, TaskStates.PROCESSING, { isRetry: true, reason: 'Retrying' })], + [23, () => manager.updateTaskState(taskId, TaskStates.COMPLETED, { reason: 'done' })], + ]); + const events = await taskEvents(); + assert.deepEqual(events.map(event => event.type), ['queued', 'started', 'failed', 'started', 'completed']); + assert.equal(events[2].payload.durationMs, 7 * 60_000, 'both handoffs belong to the first run'); + assert.equal(events[4].payload.durationMs, 3 * 60_000, 'the retry is a new run'); +}); + +test('task types outside the documented kinds, and tasks without a repository, append nothing', async () => { + await manager.createTaskStateIfAbsent('goal-1', issueRef({ number: 0, type: 'goal' }) as never); + await manager.markTaskCompleted('goal-1', {}); + await manager.createTaskStateIfAbsent('agent-run-x', issueRef({ number: 0, type: 'agent-run', repoOwner: '', repoName: '' }) as never); + await manager.markTaskCompleted('agent-run-x', {}); + assert.deepEqual(await types(), []); + assert.equal(warnings.length, 0); + + await manager.createTaskStateIfAbsent('agent-run-y', issueRef({ number: 0, type: 'agent-run' }) as never); + const [queued] = await taskEvents(); + assert.equal(queued.payload.kind, 'automation'); + assert.equal(queued.issueNumber, null); +}); + +test('sentinel: prompt text and tool output never appear in a task event', async () => { + const PROMPT = 'SENTINEL_PROMPT_TEXT_93f1'; + const TOOL = 'SENTINEL_TOOL_OUTPUT_5ab2'; + const ref = issueRef({ + number: 11, title: PROMPT, body: PROMPT, taskDescription: PROMPT, prompt: PROMPT, commentBody: PROMPT, + triggeringLabel: PROMPT, + }); + await manager.createTaskStateIfAbsent('issue-11', ref as never); + await manager.updateTaskState('issue-11', TaskStates.PROCESSING, { reason: `Starting: ${PROMPT}`, historyMetadata: { prompt: PROMPT } }); + await manager.updateTaskState('issue-11', TaskStates.CLAUDE_EXECUTION, { + reason: TOOL, claudeResult: { success: true, sessionId: TOOL }, historyMetadata: { toolOutput: TOOL, log: TOOL }, + }); + await manager.markTaskCompleted('issue-11', { + prCreated: true, prNumber: 12, prUrl: `https://example.test/${TOOL}`, notificationRecap: TOOL, notificationSummary: PROMPT, + commitResult: { message: TOOL, files: [PROMPT] }, + }); + await runToProcessing('issue-13', issueRef({ number: 13, title: PROMPT })); + await manager.markTaskFailed('issue-13', new Error(`${TOOL} ${PROMPT}`), { errorCategory: TOOL, historyMetadata: { stderr: TOOL } }); + + const rows = await database('repository_events').where({ domain: 'task' }).select('*'); + assert.deepEqual(rows.map(row => row.type), ['queued', 'started', 'completed', 'queued', 'started', 'failed']); + const serialized = JSON.stringify(rows); + assert.ok(!serialized.includes(PROMPT), 'prompt text leaked into a task event'); + assert.ok(!serialized.includes(TOOL), 'tool output leaked into a task event'); + // Every payload is limited to the documented keys. + const allowed = new Set(['taskId', 'kind', 'issueNumber', 'prNumber', 'agent', 'model', 'attempts', + 'status', 'terminalReason', 'durationMs', 'pullRequest', 'failureCategory']); + for (const row of rows) for (const key of Object.keys(JSON.parse(row.payload))) assert.ok(allowed.has(key), `unexpected payload key ${key}`); +}); + +test('a journal append failure does not change the task outcome', async () => { + await database.schema.renameTable('repository_events', 'repository_events_away'); + try { + await runToProcessing('issue-21', issueRef({ number: 21 })); + const completed = await manager.markTaskCompleted('issue-21', { prCreated: true, prNumber: 3 }); + assert.equal(completed.state, TaskStates.COMPLETED); + assert.equal((await manager.getTaskState('issue-21'))?.state, TaskStates.COMPLETED); + const history = await database('task_history').where({ task_id: 'issue-21' }).orderBy('history_id').pluck('state'); + assert.deepEqual(history, ['pending', 'processing', 'claude_execution', 'post_processing', 'completed']); + assert.ok(warnings.some(entry => entry.domain === 'task'), 'the failed append is logged'); + } finally { + await database.schema.renameTable('repository_events_away', 'repository_events'); + } +}); + +test('building an event from a malformed state never throws into the task', async () => { + const { appendTaskJournalEvent } = await import('../src/services/taskJournalEvents.js'); + await assert.doesNotReject(appendTaskJournalEvent({ state: null as never, previousState: 'pending' })); + assert.equal(buildTaskJournalEvent({ state: { taskId: 't', issueRef: issueRef(), state: 'processing', history: [] } as never, previousState: 'processing' }), null); +}); + +test('the domain migration keeps rows, indexes and the seq high-water mark, and rolls back without task rows', async () => { + const { up: nocaseIndex } = await import('../src/db/migrations/20261113000000_add_repository_events_nocase_index.js'); + const { down: removeTaskDomain } = await import('../src/db/migrations/20261114120000_add_task_repository_event_domain.js'); + const scratch = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + const row = (seq: number | undefined, domain: string, type: string) => ({ + ...(seq === undefined ? {} : { seq }), repository, domain, type, entity_id: '1', payload: '{}', occurred_at: '2026-11-14T00:00:00.000Z', + }); + try { + await createJournal(scratch); + await nocaseIndex(scratch); + await scratch('repository_events').insert([row(undefined, 'pull_request', 'opened'), row(undefined, 'plan_issue', 'status_changed'), row(undefined, 'epic_queue', 'advanced')]); + // Retention deleted the newest row: its seq must never be reused. + await scratch('repository_events').where({ seq: 3 }).delete(); + await assert.rejects(scratch('repository_events').insert(row(undefined, 'task', 'queued')), /CHECK constraint/); + + await addTaskDomain(scratch); + assert.deepEqual(await scratch('repository_events').orderBy('seq').pluck('seq'), [1, 2]); + const indexes = (await scratch.raw("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'repository_events'")) + .map((entry: { name: string }) => entry.name).sort(); + assert.deepEqual(indexes, ['repository_events_dedupe', 'repository_events_entity_seq', 'repository_events_occurred_at', + 'repository_events_repository_nocase_seq', 'repository_events_repository_seq']); + const [seq] = await scratch('repository_events').insert(row(undefined, 'task', 'queued')).returning('seq'); + assert.equal(typeof seq === 'object' ? seq.seq : seq, 4); + await assert.rejects(scratch('repository_events').insert(row(undefined, 'unknown', 'x')), /CHECK constraint/); + + await removeTaskDomain(scratch); + assert.deepEqual(await scratch('repository_events').orderBy('seq').pluck('domain'), ['pull_request', 'plan_issue']); + const [next] = await scratch('repository_events').insert(row(undefined, 'pull_request', 'merged')).returning('seq'); + assert.equal(typeof next === 'object' ? next.seq : next, 5); + } finally { + await scratch.destroy(); + } +}); + +test('a task a plugin handler created names the plugin in its first task event and its job data', async () => { + await manager.createTaskStateIfAbsent('issue-61', issueRef({ number: 61, requestedBy: 'plugin:fixture-handlers' }) as never, null, 'job-issue-61'); + const [first] = await database('task_history').where({ task_id: 'issue-61' }).orderBy('history_id'); + assert.equal(first.state, 'pending'); + assert.deepEqual(JSON.parse(first.metadata), { requestedBy: 'plugin:fixture-handlers' }); + const task = await database('tasks').where({ task_id: 'issue-61' }).first(); + assert.equal(JSON.parse(task.initial_job_data).requestedBy, 'plugin:fixture-handlers'); + assert.deepEqual((await manager.getTaskState('issue-61'))?.history[0].metadata, { requestedBy: 'plugin:fixture-handlers' }); + // A task with no plugin behind it keeps an empty first event. + await manager.createTaskStateIfAbsent('issue-62', issueRef({ number: 62 }) as never, null, 'job-issue-62'); + assert.equal((await database('task_history').where({ task_id: 'issue-62' }).first()).metadata, '{}'); +}); diff --git a/packages/plugins/example-pack/README.md b/packages/plugins/example-pack/README.md new file mode 100644 index 0000000000..ce63d5de09 --- /dev/null +++ b/packages/plugins/example-pack/README.md @@ -0,0 +1,24 @@ +# 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. +- **recordEvent** (`handlers.ts`): a handler that keeps the newest journal + event per repository as a cursor in `ctx.state` and logs a task event's task + state. `../test/testing.test.ts` tests it with the plugin test kit. + +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/handlers.ts b/packages/plugins/example-pack/handlers.ts new file mode 100644 index 0000000000..494fed3038 --- /dev/null +++ b/packages/plugins/example-pack/handlers.ts @@ -0,0 +1,22 @@ +import { definePluginHandler, type PluginEventInput } from '@propr/shared'; + +/** + * Example handler: remembers the newest journal event seen per repository (a + * cursor in `ctx.state`) and, for a task event, logs the task's current state. + * It only reads, so the plugin's `events.read` and `tasks.read` permissions + * are enough. + */ +export const recordEvent = definePluginHandler(async (input, ctx) => { + const { event } = input; + const key = `cursor:${event.repository}`; + const previous = await ctx.state.get(key); + const cursor = Math.max(previous ?? 0, event.seq); + await ctx.state.set(key, cursor); + if (event.domain === 'task' && event.entityId) { + const task = await ctx.propr.getTask(event.entityId); + ctx.log.info(`${ctx.settings.greeting ?? 'hello'}: task ${task.taskId} is ${task.state ?? 'unknown'}`, { repository: event.repository, type: event.type }); + } else { + ctx.log.info(`${event.domain} ${event.type}`, { repository: event.repository, seq: event.seq }); + } + return { repository: event.repository, cursor }; +}); 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..dde3bfd8a7 --- /dev/null +++ b/packages/plugins/package.json @@ -0,0 +1,25 @@ +{ + "name": "@propr/plugins", + "version": "0.9.1", + "private": true, + "description": "Registry of the official ProPR plugins: manifests, assets, handlers and the plugin test kit", + "license": "Apache-2.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "tsc && tsc -p tsconfig.handlers.json", + "typecheck": "tsc --noEmit && tsc -p tsconfig.handlers.json --noEmit", + "lint": "eslint ." + }, + "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..762c59cd72 --- /dev/null +++ b/packages/plugins/src/index.ts @@ -0,0 +1,152 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { formatPluginManifestIssues, pluginIdSchema, pluginManifestSchema, type PluginHandlerModule, 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)), '..'); +/** This module runs compiled (from `dist/`), where handlers are the compiled `dist/plugins//handlers.js`. */ +const COMPILED = import.meta.url.endsWith('.js'); + +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 }[]; + /** The plugin's handlers module, when its folder has a `handlers.ts`: the compiled file when running compiled and it exists. */ + handlers: string | null; +} + +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) })), + handlers: resolvePluginHandlersModule(directory), + }; +} + +/** + * The handlers module of a plugin folder: `handlers.ts` exporting named async + * functions. Compiled, the registry loads `dist/plugins//handlers.js` of + * this package, or a `handlers.js` next to the source in another root. + */ +export function resolvePluginHandlersModule(directory: string): string | null { + const source = path.join(directory, 'handlers.ts'); + if (!existsSync(source)) return null; + if (!COMPILED) return source; + const root = path.dirname(directory); + const compiled = root === PLUGINS_ROOT ? path.join(PLUGINS_ROOT, 'dist', 'plugins', path.basename(directory), 'handlers.js') : path.join(directory, 'handlers.js'); + return existsSync(compiled) ? compiled : source; +} + +/** 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); +} + +const handlerModules = new Map>(); + +/** + * The handlers of one official plugin (named async functions of its + * `handlers.ts`), or null when it has none or is not in the registry. Only + * exported functions are handlers. Loaded once per module path. + */ +export async function loadPluginHandlers(id: string, options: OfficialPluginOptions = {}): Promise { + const file = getOfficialPlugin(id, options)?.assets.handlers; + if (!file) return null; + let module = handlerModules.get(file); + if (!module) { + module = import(pathToFileURL(file).href).then((loaded: Record) => Object.freeze(Object.fromEntries( + Object.entries(loaded).filter(([, value]) => typeof value === 'function'), + )) as PluginHandlerModule); + // A failed import is retried on the next call rather than cached. + module.catch(() => handlerModules.delete(file)); + handlerModules.set(file, module); + } + return module; +} 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/test/testing.test.ts b/packages/plugins/test/testing.test.ts new file mode 100644 index 0000000000..8bb87f1dfb --- /dev/null +++ b/packages/plugins/test/testing.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { PLUGIN_STATE_LIMITS, PluginSdkError, type PluginEventInput, type RepositoryEvent } from '@propr/shared'; +import { createTestPluginContext } from '../testing/index.js'; +import { recordEvent } from '../example-pack/handlers.js'; + +const event = (overrides: Partial = {}): PluginEventInput => ({ + kind: 'event', + event: { seq: 4, repository: 'owner/repo', domain: 'task', type: 'completed', entityId: 'task-9', prNumber: null, issueNumber: 2, planId: null, headSha: null, payload: {}, occurredAt: '', ...overrides }, + deliveryId: `evt-${overrides.seq ?? 4}-recordEvent`, attempt: 1, +}); + +test('the test kit runs a handler with recorded propr calls, in-memory state and captured logs', async () => { + const kit = createTestPluginContext({ + pluginId: 'example-pack', settings: { greeting: 'hey' }, grantedPermissions: ['events.read', 'tasks.read'], + propr: { getTask: taskId => ({ taskId, repository: 'owner/repo', kind: 'implementation', issueNumber: 2, prNumber: null, state: 'completed', model: null, createdAt: '' }) }, + }); + assert.deepEqual(await recordEvent(event(), kit.ctx), { repository: 'owner/repo', cursor: 4 }); + assert.deepEqual(kit.calls, [{ operation: 'getTask', args: ['task-9'] }]); + assert.equal(kit.state.get('cursor:owner/repo'), 4); + assert.deepEqual(kit.logs.map(l => [l.level, l.message]), [['info', 'hey: task task-9 is completed']]); + + // The cursor only moves forward. + assert.deepEqual(await recordEvent(event({ seq: 2, domain: 'pull_request', type: 'opened', entityId: '5' }), kit.ctx), { repository: 'owner/repo', cursor: 4 }); + assert.equal(kit.logs[1].message, 'pull_request opened'); +}); + +test('the test kit refuses ungranted permissions, repositories outside the limit, unlisted hosts and plain HTTP', async () => { + const kit = createTestPluginContext({ grantedPermissions: ['issues.read'], repositories: ['owner/repo'], outboundHosts: ['api.example.com'], connections: ['token'] }); + await assert.rejects(kit.ctx.propr.commentOnIssue('owner/repo', 1, 'hi'), (error: unknown) => error instanceof PluginSdkError && error.code === 'permission_denied'); + await assert.rejects(kit.ctx.propr.getIssue('other/repo', 1), (error: unknown) => error instanceof PluginSdkError && error.code === 'repository_not_allowed'); + assert.equal((await kit.ctx.propr.getIssue('owner/repo', 1)).number, 1); + assert.deepEqual(kit.calls.map(c => c.operation), ['getIssue']); + + kit.respond('https://api.example.com/v1/', { json: { items: [1, 2] } }); + kit.respond(/\/v1\/missing$/, { status: 404, body: 'nope' }); + const fetch = kit.ctx.http('token'); + const ok = await fetch('https://api.example.com/v1/items', { method: 'POST', json: { q: 1 } }); + assert.equal(ok.ok, true); + assert.deepEqual(await ok.json(), { items: [1, 2] }); + assert.equal(ok.headers['content-type'], 'application/json'); + const missing = await fetch('https://api.example.com/v1/missing'); + assert.equal(missing.status, 404); + assert.equal(await missing.text(), 'nope'); + assert.deepEqual(kit.requests.map(r => [r.url, r.init.method ?? 'GET', r.connection]), [['https://api.example.com/v1/items', 'POST', 'token'], ['https://api.example.com/v1/missing', 'GET', 'token']]); + await assert.rejects(fetch('http://api.example.com/v1/items'), (error: unknown) => error instanceof PluginSdkError && error.code === 'insecure_url'); + await assert.rejects(fetch('https://evil.example/v1/items'), (error: unknown) => error instanceof PluginSdkError && error.code === 'host_not_allowed'); + await assert.rejects(kit.ctx.http()('https://api.example.com/other'), (error: unknown) => error instanceof PluginSdkError && error.code === 'http_failed'); + assert.throws(() => kit.ctx.http('unknown'), (error: unknown) => error instanceof PluginSdkError && error.code === 'connection_unavailable'); +}); + +test('the test kit refuses a task from a repository outside the limit, as the real getTask does', async () => { + const task = (taskId: string, repository: string) => ({ taskId, repository, kind: 'implementation' as const, issueNumber: 2, prNumber: null, state: 'completed', model: null, createdAt: '' }); + const kit = createTestPluginContext({ + grantedPermissions: ['tasks.read'], repositories: ['owner/allowed'], + // Read asynchronously, as a test answering from a fake store would. + propr: { getTask: async taskId => task(taskId, taskId === 'elsewhere' ? 'other/repo' : 'Owner/Allowed') }, + }); + await assert.rejects(kit.ctx.propr.getTask('elsewhere'), (error: unknown) => error instanceof PluginSdkError && error.code === 'repository_not_allowed'); + assert.equal((await kit.ctx.propr.getTask('mine')).repository, 'Owner/Allowed', 'compared case-insensitively'); + assert.deepEqual(kit.calls, [{ operation: 'getTask', args: ['mine'] }], 'the refused call is not recorded'); + + // An unanswered getTask resolves with a placeholder task inside the limit; a missing task is not found. + const placeholder = createTestPluginContext({ grantedPermissions: ['tasks.read'], repositories: ['owner/allowed'] }); + assert.equal((await placeholder.ctx.propr.getTask('t')).repository, 'owner/allowed'); + const missing = createTestPluginContext({ grantedPermissions: ['tasks.read'], repositories: ['owner/allowed'], propr: { getTask: () => null as never } }); + await assert.rejects(missing.ctx.propr.getTask('gone'), (error: unknown) => error instanceof PluginSdkError && error.code === 'not_found'); +}); + +test('the test kit state applies the real limits', async () => { + const kit = createTestPluginContext({ state: { a: 1 } }); + assert.equal(await kit.ctx.state.get('a'), 1); + await assert.rejects(kit.ctx.state.set('big', 'x'.repeat(PLUGIN_STATE_LIMITS.valueBytes)), (error: unknown) => error instanceof PluginSdkError && error.code === 'state_limit'); + await assert.rejects(kit.ctx.state.set('bad key', 1), (error: unknown) => error instanceof PluginSdkError && error.code === 'invalid_input'); + for (let i = 1; i < PLUGIN_STATE_LIMITS.keys; i++) await kit.ctx.state.set(`k${i}`, i); + await assert.rejects(kit.ctx.state.set('one-more', 1), (error: unknown) => error instanceof PluginSdkError && error.code === 'state_limit'); + assert.equal(await kit.ctx.state.delete('a'), true); + const expected = Array.from({ length: PLUGIN_STATE_LIMITS.keys - 1 }, (_, i) => `k${i + 1}`).filter(key => key.startsWith('k1')).sort(); + assert.deepEqual(await kit.ctx.state.keys('k1'), expected); +}); diff --git a/packages/plugins/testing/index.ts b/packages/plugins/testing/index.ts new file mode 100644 index 0000000000..ff5df5fe11 --- /dev/null +++ b/packages/plugins/testing/index.ts @@ -0,0 +1,159 @@ +import { + PLUGIN_PERMISSIONS, PLUGIN_PROPR_OPERATIONS, PLUGIN_STATE_LIMITS, PluginSdkError, + type PluginContext, type PluginHttpRequestInit, type PluginHttpResponse, type PluginJsonValue, type PluginLogLevel, type PluginLogLine, + type PluginPermission, type PluginProprClient, type PluginProprOperation, type PluginSettingValues, +} from '@propr/shared'; + +/** + * A fake `ctx` for plugin unit tests: `ctx.propr` calls are recorded (and + * answered by the test), `ctx.http` answers from scripted responses, state is + * in memory with the real limits and log lines are captured. Like the real + * context, a call outside the granted permissions or the repository limit + * throws a `PluginSdkError`, and so does a request to a host the plugin does + * not list or over plain HTTP. + */ + +export interface RecordedProprCall { operation: PluginProprOperation; args: unknown[] } +export interface RecordedHttpRequest { url: string; init: PluginHttpRequestInit; connection: string | null } +export interface ScriptedHttpResponse { status?: number; headers?: Record; body?: string; json?: PluginJsonValue } +type Matcher = string | RegExp | ((url: string, init: PluginHttpRequestInit) => boolean); +type Answers = { [K in PluginProprOperation]?: (...args: Parameters) => Awaited> | Promise>> }; + +export interface TestPluginContextOptions { + pluginId?: string; + settings?: PluginSettingValues; + /** Repositories the installation is limited to; null (the default) for all. */ + repositories?: string[] | null; + /** Granted permissions; every permission by default. */ + grantedPermissions?: readonly PluginPermission[]; + /** The manifest's `outboundHosts`; any host when omitted. */ + outboundHosts?: readonly string[]; + /** Connection names `ctx.http(connection)` accepts; any when omitted. */ + connections?: readonly string[]; + /** Answers to `ctx.propr` calls; an unanswered call resolves with a minimal placeholder. */ + propr?: Answers; + /** Initial state. */ + state?: Record; +} + +export interface TestPluginContext { + ctx: PluginContext; + /** Every `ctx.propr` call that passed the permission and repository checks, in order. */ + calls: RecordedProprCall[]; + /** Every `ctx.http` request, in order. */ + requests: RecordedHttpRequest[]; + logs: PluginLogLine[]; + state: Map; + /** Answer requests matching `matcher` (a URL prefix, a pattern or a predicate) with `response`; the newest script wins. */ + respond(matcher: Matcher, response: ScriptedHttpResponse | ((url: string, init: PluginHttpRequestInit) => ScriptedHttpResponse)): void; +} + +const placeholders: { [K in PluginProprOperation]: (...args: never[]) => unknown } = { + getPullRequest: (repository: string, number: number) => ({ repository, number, title: '', state: 'open', url: '', labels: [], author: null, createdAt: '', updatedAt: '', draft: false, merged: false, headRef: '', baseRef: '', headSha: '' }), + getIssue: (repository: string, number: number) => ({ repository, number, title: '', state: 'open', url: '', labels: [], author: null, createdAt: '', updatedAt: '' }), + getTask: (taskId: string) => ({ taskId, repository: 'owner/repo', kind: 'implementation', issueNumber: null, prNumber: null, state: null, model: null, createdAt: '' }), + listEvents: () => [], + createIssue: (repository: string) => ({ repository, number: 1, url: '' }), + createTask: (repository: string, issueNumber: number) => ({ repository, issueNumber, jobId: `issue-${repository.replace('/', '-')}-${issueNumber}` }), + commentOnPullRequest: (repository: string, number: number) => ({ repository, number, id: 1, url: '' }), + commentOnIssue: (repository: string, number: number) => ({ repository, number, id: 1, url: '' }), + addLabel: () => undefined, + removeLabel: () => undefined, +}; + +function matches(matcher: Matcher, url: string, init: PluginHttpRequestInit): boolean { + if (typeof matcher === 'string') return url.startsWith(matcher); + if (matcher instanceof RegExp) return matcher.test(url); + return matcher(url, init); +} + +function response(url: string, scripted: ScriptedHttpResponse): PluginHttpResponse { + const status = scripted.status ?? 200; + const text = scripted.json !== undefined ? JSON.stringify(scripted.json) : scripted.body ?? ''; + const headers = Object.fromEntries(Object.entries({ ...(scripted.json !== undefined ? { 'content-type': 'application/json' } : {}), ...scripted.headers }).map(([k, v]) => [k.toLowerCase(), v])); + return { status, ok: status >= 200 && status < 300, url, headers, text: async () => text, json: async () => JSON.parse(text) as T }; +} + +function stateStore(state: Map): PluginContext['state'] { + const size = (skip?: string) => [...state].reduce((sum, [key, value]) => (key === skip ? sum : sum + Buffer.byteLength(JSON.stringify(value))), 0); + const check = (key: unknown) => { + if (typeof key !== 'string' || !key.length || key.length > PLUGIN_STATE_LIMITS.keyLength || !/^[\x21-\x7e]+$/.test(key)) throw new PluginSdkError('invalid_input', 'Invalid state key'); + return key; + }; + return { + get: async (key: string) => structuredClone(state.get(check(key))) as T | undefined, + async set(key, value) { + check(key); + const bytes = Buffer.byteLength(JSON.stringify(value) ?? ''); + if (bytes > PLUGIN_STATE_LIMITS.valueBytes) throw new PluginSdkError('state_limit', 'State value too large'); + if (!state.has(key) && state.size + 1 > PLUGIN_STATE_LIMITS.keys) throw new PluginSdkError('state_limit', 'Too many state keys'); + if (size(key) + bytes > PLUGIN_STATE_LIMITS.totalBytes) throw new PluginSdkError('state_limit', 'State too large'); + state.set(key, structuredClone(value)); + }, + delete: async key => state.delete(check(key)), + keys: async (prefix = '') => [...state.keys()].filter(key => key.startsWith(prefix)).sort(), + }; +} + +export function createTestPluginContext(options: TestPluginContextOptions = {}): TestPluginContext { + const granted = options.grantedPermissions ?? PLUGIN_PERMISSIONS; + const limit = options.repositories?.map(r => r.toLowerCase()) ?? null; + const allowed = (repository: unknown) => !limit || (typeof repository === 'string' && limit.includes(repository.toLowerCase())); + /** An unanswered call's placeholder; a placeholder task belongs to a repository inside the limit. */ + const placeholder = (operation: PluginProprOperation, args: unknown[]) => { + const value = (placeholders[operation] as (...a: unknown[]) => unknown)(...args); + return operation === 'getTask' && options.repositories?.length ? { ...(value as object), repository: options.repositories[0] } : value; + }; + const calls: RecordedProprCall[] = []; const requests: RecordedHttpRequest[] = []; const logs: PluginLogLine[] = []; + const state = new Map(Object.entries(structuredClone(options.state ?? {}))); + const scripts: { matcher: Matcher; answer: ScriptedHttpResponse | ((url: string, init: PluginHttpRequestInit) => ScriptedHttpResponse) }[] = []; + + const propr = Object.fromEntries((Object.keys(PLUGIN_PROPR_OPERATIONS) as PluginProprOperation[]).map(operation => [operation, async (...args: unknown[]) => { + const permission = PLUGIN_PROPR_OPERATIONS[operation]; + if (!granted.includes(permission)) throw new PluginSdkError('permission_denied', `${operation} needs the ${permission} permission`); + const repository = operation === 'getTask' ? null : operation === 'listEvents' ? (args[0] as { repository?: string })?.repository : args[0]; + if (typeof repository === 'string' && !allowed(repository)) throw new PluginSdkError('repository_not_allowed', `This plugin is not enabled for ${repository}`); + const call: RecordedProprCall = { operation, args: structuredClone(args) }; + calls.push(call); + const answer = options.propr?.[operation] as ((...a: unknown[]) => unknown) | undefined; + const result = await (answer ? answer(...args) : placeholder(operation, args)); + if (operation === 'getTask') { + // Like the real getTask: the task's repository is known only once it is read, and a task outside the limit is not returned. + if (!result) throw new PluginSdkError('not_found', 'Task not found'); + if (!allowed((result as { repository?: unknown }).repository)) { + // Refused by the repository check, so not one of `calls`. + calls.splice(calls.indexOf(call), 1); + throw new PluginSdkError('repository_not_allowed', 'This plugin is not enabled for the task\'s repository'); + } + } + return result; + }])) as unknown as PluginProprClient; + + const http = (connection?: string) => { + if (connection !== undefined && options.connections && !options.connections.includes(connection)) throw new PluginSdkError('connection_unavailable', `No connection ${connection}`); + return async (url: string, init: PluginHttpRequestInit = {}) => { + let parsed: URL; + try { parsed = new URL(url); } catch { throw new PluginSdkError('invalid_input', 'Invalid URL'); } + if (parsed.protocol !== 'https:') throw new PluginSdkError('insecure_url', 'Only https:// URLs are allowed'); + if (options.outboundHosts && !options.outboundHosts.includes(parsed.hostname)) throw new PluginSdkError('host_not_allowed', `${parsed.hostname} is not an outbound host`); + requests.push({ url, init: structuredClone(init), connection: connection ?? null }); + const script = [...scripts].reverse().find(s => matches(s.matcher, url, init)); + if (!script) throw new PluginSdkError('http_failed', `No scripted response for ${init.method ?? 'GET'} ${url}`); + return response(url, typeof script.answer === 'function' ? script.answer(url, init) : script.answer); + }; + }; + + const write = (level: PluginLogLevel) => (message: string, fields?: Record) => { + logs.push({ level, message: String(message), ...(fields ? { fields: JSON.parse(JSON.stringify(fields)) as Record } : {}), at: Date.now() }); + }; + + const ctx: PluginContext = { + pluginId: options.pluginId ?? 'test-plugin', + settings: structuredClone(options.settings ?? {}), + repositories: options.repositories ? [...options.repositories] : null, + propr, http, state: stateStore(state), + log: { debug: write('debug'), info: write('info'), warn: write('warn'), error: write('error') }, + signal: new AbortController().signal, + }; + return { ctx, calls, requests, logs, state, respond: (matcher, answer) => { scripts.push({ matcher, answer }); } }; +} diff --git a/packages/plugins/tsconfig.handlers.json b/packages/plugins/tsconfig.handlers.json new file mode 100644 index 0000000000..f710c1bda7 --- /dev/null +++ b/packages/plugins/tsconfig.handlers.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist/plugins", + "declaration": false, + "declarationMap": false + }, + "include": ["*/handlers.ts", "testing/**/*.ts"], + "exclude": ["node_modules", "dist", "src", "test"] +} 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/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/index.ts b/packages/shared/src/index.ts index 5a75148c0e..21e1102bca 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -591,6 +591,10 @@ export * from './extensionProfiles.js'; export * from './extensionLibrary.js'; export * from './extensionPackages.js'; export * from './extensionOAuth.js'; +export * from './plugins.js'; +export * from './pluginInstallations.js'; +export * from './pluginSdk.js'; +export * from './pluginDeliveries.js'; export { sqliteTimestampMs } from './sqliteTimestamp.js'; export { AGENT_FAILURE_LINE_MAX_CHARS, agentFailureLine } from './agentFailureLine.js'; diff --git a/packages/shared/src/pluginDeliveries.ts b/packages/shared/src/pluginDeliveries.ts new file mode 100644 index 0000000000..90d6c87dd8 --- /dev/null +++ b/packages/shared/src/pluginDeliveries.ts @@ -0,0 +1,77 @@ +import type { PluginLogLine } from './pluginSdk.js'; + +/** + * Event deliveries to plugin handlers and an installation's delivery health, + * as the Plugins page and API show them. A delivery record holds the event id + * (`seq`) and type only: never the event payload, and never a credential. + */ + +/** Waits before the 2nd, 3rd, 4th and 5th attempt of a failing delivery; after the last attempt it is marked failed. */ +export const PLUGIN_DELIVERY_RETRY_DELAYS_MS = [60_000, 5 * 60_000, 30 * 60_000, 2 * 60 * 60_000] as const; +/** Delivery records are kept this long. */ +export const PLUGIN_DELIVERY_RETENTION_DAYS = 14; +/** Consecutive failed attempts after which an installation is shown as Degraded (and administrators are notified once). */ +export const PLUGIN_DEGRADED_AFTER_FAILURES = 3; +/** Default number of consecutive failed attempts after which deliveries pause until an administrator resumes them. */ +export const PLUGIN_PAUSE_AFTER_FAILURES = 10; +/** Delivery records one activity read returns at most. */ +export const PLUGIN_ACTIVITY_LIMIT = 100; + +/** + * `pending`: waiting for its first attempt. `running`: an attempt is under way. + * `retrying`: an attempt failed and the next one is scheduled. `succeeded`, + * `failed` (retries exhausted, or a manual retry failed) and `skipped` (a gap: + * events that left the journal before they could be delivered) are final. + */ +export const PLUGIN_DELIVERY_STATUSES = ['pending', 'running', 'retrying', 'succeeded', 'failed', 'skipped'] as const; +export type PluginDeliveryStatus = typeof PLUGIN_DELIVERY_STATUSES[number]; +/** `event`: one journal event to one handler. `gap`: journal retention removed events before they were delivered. */ +export const PLUGIN_DELIVERY_KINDS = ['event', 'gap'] as const; +export type PluginDeliveryKind = typeof PLUGIN_DELIVERY_KINDS[number]; + +export interface PluginDeliveryView { + /** The activity record id, used by the retry route. */ + id: number; + /** The stable delivery id handlers receive (`evt--`); the same on every attempt. */ + deliveryId: string; + kind: PluginDeliveryKind; + repository: string; + /** The journal `seq` of the event; for a gap, the first `seq` that may be missing. */ + eventSeq: number; + /** `.`; null for a gap. */ + eventType: string | null; + handler: string | null; + status: PluginDeliveryStatus; + /** Attempts made so far. */ + attempt: number; + /** When the next attempt is due, while `pending` or `retrying`. */ + nextAttemptAt: number | null; + /** When the delivery was recorded. */ + createdAt: number; + /** When the latest attempt started and finished. */ + startedAt: number | null; + finishedAt: number | null; + durationMs: number | null; + timedOut: boolean; + /** The latest attempt's redacted error message. */ + error: string | null; + /** The latest attempt's redacted log lines. */ + logs: PluginLogLine[]; +} + +/** `healthy`; `degraded`: repeated failures; `paused`: deliveries stopped until an administrator resumes them. */ +export const PLUGIN_HEALTH_STATES = ['healthy', 'degraded', 'paused'] as const; +export type PluginHealthState = typeof PLUGIN_HEALTH_STATES[number]; + +export interface PluginHealthView { + state: PluginHealthState; + consecutiveFailures: number; + /** Consecutive failed attempts that pause deliveries. */ + pauseAfterFailures: number; + lastError: string | null; + lastErrorAt: number | null; + degradedAt: number | null; + pausedAt: number | null; +} + +export interface PluginActivity { health: PluginHealthView; deliveries: PluginDeliveryView[] } diff --git a/packages/shared/src/pluginInstallations.ts b/packages/shared/src/pluginInstallations.ts new file mode 100644 index 0000000000..71f6917252 --- /dev/null +++ b/packages/shared/src/pluginInstallations.ts @@ -0,0 +1,177 @@ +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. 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. */ +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[]; + /** Managed library items and templates; items are null until the plugin is installed. */ + contributions?: PluginContributionViews; +} +/** + * One audit trail entry. Management actions name the administrator; `handler_attempt` + * (a mutation about to be dispatched), `handler_call` (completed) and `handler_refused` + * (refused or failed) record a handler's `ctx.propr` calls with `plugin:` as the actor. + */ +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', 'start_oauth', 'complete_oauth', 'disconnect_oauth', 'set_oauth_client', 'clear_oauth_client', 'handler_attempt', 'handler_call', 'handler_refused', 'retry_delivery', 'resume_deliveries'] 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] === ''); +} diff --git a/packages/shared/src/pluginSdk.ts b/packages/shared/src/pluginSdk.ts new file mode 100644 index 0000000000..19c9bf7090 --- /dev/null +++ b/packages/shared/src/pluginSdk.ts @@ -0,0 +1,171 @@ +import type { PluginPermission } from './plugins.js'; +import type { PluginSettingValues } from './pluginInstallations.js'; +import type { RepositoryEvent, RepositoryEventDomain } from './repositoryEvents.js'; + +/** + * The plugin handler SDK. A plugin folder may contain `handlers.ts` exporting + * named async functions `(input, ctx) => result`. `ctx` is the only way a + * handler touches anything: its settings, a ProPR client limited to the + * installation's grant and repository limit, outbound HTTP limited to the + * manifest's `outboundHosts`, a small key-value state store and a structured + * log. Official plugins run in-process, but only through this interface, so + * the same plugin can later run out of process. + * + * Handlers import nothing from ProPR but `@propr/shared` (these types among + * them); a lint rule enforces this for `packages/plugins/**`. + */ + +/** A handler runs at most this long; a handler still running is reported as a failure. */ +export const PLUGIN_HANDLER_TIMEOUT_MS = 30_000; + +/** Limits of `ctx.state`, per installation. Values are JSON; sizes are UTF-8 bytes of the serialized value. */ +export const PLUGIN_STATE_LIMITS = { keys: 256, keyLength: 200, valueBytes: 16 * 1024, totalBytes: 256 * 1024 } as const; +/** Limits of `ctx.http`. */ +export const PLUGIN_HTTP_LIMITS = { requestBytes: 256 * 1024, responseBytes: 1024 * 1024, timeoutMs: 10_000, headers: 64 } as const; +/** Limits of `ctx.log`: lines kept per invocation and characters per message. */ +export const PLUGIN_LOG_LIMITS = { lines: 200, messageChars: 2_000, fieldsBytes: 4 * 1024 } as const; +/** Limits of text a handler writes through `ctx.propr`. */ +export const PLUGIN_PROPR_LIMITS = { titleChars: 256, bodyChars: 65_536, labelChars: 50, labels: 20, events: 100 } as const; + +export type PluginJsonValue = string | number | boolean | null | PluginJsonValue[] | { [key: string]: PluginJsonValue }; + +/** Why a `ctx` call was refused. Thrown to the handler as a `PluginSdkError`. */ +export const PLUGIN_SDK_ERROR_CODES = [ + 'permission_denied', 'repository_not_allowed', 'not_found', 'invalid_input', + 'host_not_allowed', 'insecure_url', 'private_address', 'connection_unavailable', 'response_too_large', 'http_failed', + 'state_limit', 'unavailable', +] as const; +export type PluginSdkErrorCode = typeof PLUGIN_SDK_ERROR_CODES[number]; +export class PluginSdkError extends Error { + readonly name = 'PluginSdkError'; + constructor(readonly code: PluginSdkErrorCode, message: string) { super(message); } +} + +/** The ProPR operations `ctx.propr` offers and the permission each needs. */ +export const PLUGIN_PROPR_OPERATIONS = { + getPullRequest: 'pull_requests.read', + getIssue: 'issues.read', + getTask: 'tasks.read', + listEvents: 'events.read', + createIssue: 'issues.create', + createTask: 'tasks.create', + commentOnPullRequest: 'pull_requests.comment', + commentOnIssue: 'issues.comment', + addLabel: 'labels.write', + removeLabel: 'labels.write', +} as const satisfies Record; +export type PluginProprOperation = keyof typeof PLUGIN_PROPR_OPERATIONS; + +export interface PluginIssueSummary { + repository: string; number: number; title: string; state: 'open' | 'closed'; url: string; + labels: string[]; author: string | null; createdAt: string; updatedAt: string; +} +export interface PluginPullRequestSummary extends PluginIssueSummary { + draft: boolean; merged: boolean; headRef: string; baseRef: string; headSha: string; +} +export interface PluginTaskSummary { + taskId: string; repository: string; kind: string; issueNumber: number | null; prNumber: number | null; + state: string | null; model: string | null; createdAt: string; +} +export interface PluginCreatedIssue { repository: string; number: number; url: string } +export interface PluginCreatedTask { repository: string; issueNumber: number; jobId: string } +export interface PluginCreatedComment { repository: string; number: number; id: number; url: string } +export interface PluginListEventsInput { repository: string; afterSeq?: number; domains?: RepositoryEventDomain[]; limit?: number } + +/** + * ProPR, as far as this installation may see and change it. Every call is + * checked against the granted permissions and the repository limit and is + * recorded with the plugin as the actor; a refused call throws a + * `PluginSdkError` and is logged. + */ +export interface PluginProprClient { + getPullRequest(repository: string, number: number): Promise; + getIssue(repository: string, number: number): Promise; + getTask(taskId: string): Promise; + listEvents(input: PluginListEventsInput): Promise; + createIssue(repository: string, issue: { title: string; body?: string; labels?: string[] }): Promise; + /** Start an ordinary one-shot task for an issue. Handlers never interact with a running task. */ + createTask(repository: string, issueNumber: number): Promise; + commentOnPullRequest(repository: string, number: number, body: string): Promise; + commentOnIssue(repository: string, number: number, body: string): Promise; + addLabel(repository: string, number: number, label: string): Promise; + removeLabel(repository: string, number: number, label: string): Promise; +} + +export interface PluginHttpRequestInit { + method?: 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + headers?: Record; + /** A text body; use `json` for a JSON body. */ + body?: string; + json?: PluginJsonValue; +} +/** A buffered response. Redirects are not followed; the connection's credential never appears in it. */ +export interface PluginHttpResponse { + status: number; ok: boolean; url: string; + headers: Record; + text(): Promise; + json(): Promise; +} +/** `fetch`-like outbound HTTPS, limited to the manifest's `outboundHosts`. */ +export type PluginHttpFetch = (url: string, init?: PluginHttpRequestInit) => Promise; + +/** A small key-value store scoped to the installation; deleted on uninstall. */ +export interface PluginStateStore { + get(key: string): Promise; + set(key: string, value: PluginJsonValue): Promise; + delete(key: string): Promise; + /** Keys in order, optionally only those starting with `prefix`. */ + keys(prefix?: string): Promise; +} + +export type PluginLogLevel = 'debug' | 'info' | 'warn' | 'error'; +export interface PluginLogLine { level: PluginLogLevel; message: string; fields?: Record; at: number } +export type PluginLogger = Record) => void>; + +export interface PluginContext { + pluginId: string; + /** The validated settings of the installation. */ + settings: PluginSettingValues; + /** Repositories (`owner/repo`) the installation is limited to; `null` for all. */ + repositories: string[] | null; + propr: PluginProprClient; + /** Outbound HTTP; with a connection name, that connection's credential is injected. */ + http(connection?: string): PluginHttpFetch; + state: PluginStateStore; + log: PluginLogger; + /** Aborted when the handler runs past its time limit. */ + signal: AbortSignal; +} + +/** Handler input envelopes. The dispatcher, webhook route and commands each build one. */ +/** + * A journal event delivered to a subscribed handler. Delivery is at least + * once: `deliveryId` is the same on every attempt (and after a worker + * restart), so a handler with side effects can de-duplicate on it. `attempt` + * counts from 1. + */ +export interface PluginEventInput { kind: 'event'; event: RepositoryEvent; deliveryId: string; attempt: number } +export interface PluginWebhookInput { kind: 'webhook'; webhook: string; headers: Record; body: string; receivedAt: number } +export interface PluginCommandInput { kind: 'command'; command: string; args: string; repository: string | null; actor: string } +export interface PluginInvokeInput { kind: 'invoke'; payload: PluginJsonValue } +export type PluginHandlerInput = PluginEventInput | PluginWebhookInput | PluginCommandInput | PluginInvokeInput; + +/** A handler returns nothing or a small JSON result. */ +export type PluginHandlerResultValue = PluginJsonValue | void; +export type PluginHandler = (input: I, ctx: PluginContext) => Promise; +export type PluginHandlerModule = Record; +/** Declare a handler with typed input; identity at runtime. */ +export const definePluginHandler = (handler: PluginHandler): PluginHandler => handler; + +/** What `runPluginHandler` returns; it never throws. */ +export interface PluginHandlerRun { + outcome: 'succeeded' | 'failed'; + /** The handler ran past `PLUGIN_HANDLER_TIMEOUT_MS`. */ + timedOut: boolean; + durationMs: number; + /** Redacted; null when the handler succeeded. */ + error: string | null; + logs: PluginLogLine[]; + /** The handler's result, redacted, when it succeeded and returned one. */ + result?: PluginJsonValue; +} diff --git a/packages/shared/src/plugins.ts b/packages/shared/src/plugins.ts new file mode 100644 index 0000000000..48e9003077 --- /dev/null +++ b/packages/shared/src/plugins.ts @@ -0,0 +1,362 @@ +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'; +import { isRepositoryEventType } from './repositoryEvents.js'; + +/** Whether `name` is `.` of a repository journal event. */ +export function isPluginSubscriptionEvent(name: unknown): boolean { + if (typeof name !== 'string') return false; + const dot = name.indexOf('.'); + return dot > 0 && isRepositoryEventType(name.slice(0, dot), name.slice(dot + 1)); +} + +/** + * 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', + 'labels.write', + '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', + 'labels.write': 'Add and remove labels on issues and 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', 'testing', '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([]) }); +/** A handler name: an exported function of the plugin's `handlers.ts`. */ +export const pluginHandlerNameSchema = z.string().regex(/^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/, 'Use the name of an exported handler function'); +/** Event fields a subscription filter can match, besides `payload.`. */ +export const PLUGIN_SUBSCRIPTION_FILTER_FIELDS = ['repository', 'entityId', 'prNumber', 'issueNumber', 'planId', 'headSha'] as const; +const isFilterKey = (key: string) => (PLUGIN_SUBSCRIPTION_FILTER_FIELDS as readonly string[]).includes(key) || /^payload\.[A-Za-z][A-Za-z0-9_]{0,47}$/.test(key); +const filterScalarSchema = z.union([z.string().max(PLUGIN_LIMITS.text), z.number().finite(), z.boolean(), z.null()]); +/** + * Equality filter of a subscription: every key must match. A key is an event + * field (`repository` compares without letter case) or `payload.`; a list + * value matches any of its entries. + */ +export const pluginSubscriptionFilterSchema = z.record(z.string(), z.union([filterScalarSchema, z.array(filterScalarSchema).min(1).max(32)])) + .superRefine((filter, ctx) => { + const keys = Object.keys(filter); + if (keys.length > 16) ctx.addIssue({ code: 'custom', message: 'A filter has at most 16 keys' }); + for (const key of keys.filter(k => !isFilterKey(k))) ctx.addIssue({ code: 'custom', path: [key], message: `Filter keys are ${PLUGIN_SUBSCRIPTION_FILTER_FIELDS.join(', ')} or payload.` }); + }); +export type PluginSubscriptionFilter = z.infer; +/** + * An event subscription: `on` is `.` of a repository journal + * event (for example `pull_request.merged`), `handler` the exported function + * that receives it and `filter` narrows which events of that type it gets. + */ +export const pluginSubscriptionSchema = z.strictObject({ + on: z.string().refine(isPluginSubscriptionEvent, 'Use a repository event name such as pull_request.merged or task.completed'), + handler: pluginHandlerNameSchema, + filter: pluginSubscriptionFilterSchema.optional(), +}); +export type PluginSubscription = z.infer; +/** 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.on} → ${s.handler}`), '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.on), + webhooks: contributes.webhooks.map(w => ({ name: w.name, description: w.description })), + }, + }; +} diff --git a/packages/shared/src/repositoryEvents.ts b/packages/shared/src/repositoryEvents.ts index 009b594317..b0b70336ed 100644 --- a/packages/shared/src/repositoryEvents.ts +++ b/packages/shared/src/repositoryEvents.ts @@ -1,14 +1,15 @@ /** * The durable repository change-event journal (`repository_events`). * - * Each row records one persisted change to a pull request, an epic queue or a - * plan issue. `seq` is the cursor: it only grows, and a reader that remembers + * Each row records one persisted change to a pull request, an epic queue, a + * plan issue or a task. `seq` is the cursor: it only grows, and a reader that remembers * the last `seq` it handled resumes from there. Payloads carry identifiers and - * small scalars only, never PR bodies, comment text, diffs or log excerpts. + * small scalars only, never PR bodies, comment text, diffs, prompts, tool + * output or log excerpts. */ /** Record kinds the journal covers. Additive: append, never reorder. */ -export const REPOSITORY_EVENT_DOMAINS = ['pull_request', 'epic_queue', 'plan_issue'] as const; +export const REPOSITORY_EVENT_DOMAINS = ['pull_request', 'epic_queue', 'plan_issue', 'task'] as const; export type RepositoryEventDomain = (typeof REPOSITORY_EVENT_DOMAINS)[number]; export const PULL_REQUEST_EVENT_TYPES = [ @@ -29,11 +30,15 @@ export type EpicQueueEventType = (typeof EPIC_QUEUE_EVENT_TYPES)[number]; export const PLAN_ISSUE_EVENT_TYPES = ['status_changed'] as const; export type PlanIssueEventType = (typeof PLAN_ISSUE_EVENT_TYPES)[number]; +export const TASK_EVENT_TYPES = ['queued', 'started', 'completed', 'failed', 'cancelled'] as const; +export type TaskEventType = (typeof TASK_EVENT_TYPES)[number]; + /** The event types each domain accepts. */ export const REPOSITORY_EVENT_TYPES: { readonly [D in RepositoryEventDomain]: readonly string[] } = { pull_request: PULL_REQUEST_EVENT_TYPES, epic_queue: EPIC_QUEUE_EVENT_TYPES, plan_issue: PLAN_ISSUE_EVENT_TYPES, + task: TASK_EVENT_TYPES, }; export function isRepositoryEventType(domain: unknown, type: unknown): boolean { @@ -98,13 +103,63 @@ export interface PlanIssueStatusChangedEventPayload { to: string; } +/** What a journaled task does. Task types outside these kinds (goals, imports) are not journaled. */ +export const TASK_EVENT_KINDS = ['implementation', 'follow-up', 'review', 'merge-conflict', 'automation'] as const; +export type TaskEventKind = (typeof TASK_EVENT_KINDS)[number]; + +/** The journal kind of a stored task type (`tasks.task_type`, `issueRef.type`), or null when it is not journaled. */ +export function taskEventKind(taskType: unknown): TaskEventKind | null { + switch (taskType ?? 'issue') { + case 'issue': return 'implementation'; + case 'pr-comment': return 'follow-up'; + case 'review': return 'review'; + case 'merge_conflict': return 'merge-conflict'; + case 'agent-run': return 'automation'; + default: return null; + } +} + +/** Terminal `task` event types. */ +export const TASK_TERMINAL_EVENT_TYPES = ['completed', 'failed', 'cancelled'] as const satisfies readonly TaskEventType[]; + +/** How a terminal task touched a pull request: `opened` a new one, `updated` an existing one, or neither (null). */ +export type TaskEventPullRequestOutcome = 'opened' | 'updated' | null; + +/** + * Payload of every `task` event. Identifiers and coded scalars only: never + * prompt text, agent or tool output, logs, comments or file contents. + */ +export interface TaskEventPayload { + taskId: string; + kind: TaskEventKind; + issueNumber: number | null; + prNumber: number | null; + agent: string | null; + model: string | null; + /** Retry attempts recorded on the task so far. */ + attempts: number; +} + +/** Payload of `task` / `completed`, `failed` and `cancelled`. */ +export interface TaskTerminalEventPayload extends TaskEventPayload { + /** The terminal state: `completed`, `failed` or `cancelled`. */ + status: (typeof TASK_TERMINAL_EVENT_TYPES)[number]; + /** The coded terminal reason (for example `timed_out`, `cancelled_by_user`), when one was recorded. */ + terminalReason: string | null; + /** Milliseconds from the latest start to the terminal state; null when the task never started. */ + durationMs: number | null; + pullRequest: TaskEventPullRequestOutcome; + /** Short coded failure category on `failed` (for example `claude_execution`, `timed_out`); null otherwise. */ + failureCategory: string | null; +} + /** One journal row as a reader sees it. */ export interface RepositoryEvent { seq: number; repository: string; domain: RepositoryEventDomain; type: string; - /** PR number, epic execution id, or plan issue id, as text. */ + /** PR number, epic execution id, plan issue id, or task id, as text. */ entityId: string; prNumber: number | null; issueNumber: number | null; diff --git a/packages/shared/test/plugins.test.ts b/packages/shared/test/plugins.test.ts new file mode 100644 index 0000000000..470005a40d --- /dev/null +++ b/packages/shared/test/plugins.test.ts @@ -0,0 +1,174 @@ +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: [{ on: 'task.completed', handler: 'onTask' }], + 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({ on: 'task.completed', handler: 'onTask' }); }), /Duplicate subscription task\.completed → onTask/); + 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' }]); +}); + +test('subscriptions name a journal event, a handler and an optional equality filter', () => { + const ok = variant(m => { m.contributes.subscriptions = [{ on: 'pull_request.merged', handler: 'onMerged', filter: { repository: 'Owner/Repo', 'payload.kind': ['implementation', 'review'] } }]; }); + const parsed = pluginManifestSchema.safeParse(ok); + assert.ok(parsed.success, parsed.success ? '' : parsed.error.message); + rejects(variant(m => { m.contributes.subscriptions = [{ on: 'pull_request.exploded', handler: 'onMerged' }]; }), /repository event name/); + rejects(variant(m => { m.contributes.subscriptions = [{ on: 'task', handler: 'onTask' }]; }), /repository event name/); + rejects(variant(m => { m.contributes.subscriptions = [{ on: 'task.completed', handler: 'not-an-identifier' }]; }), /exported handler/); + rejects(variant(m => { m.contributes.subscriptions = [{ on: 'task.completed', handler: 'onTask', filter: { body: 'x' } }]; }), /Filter keys/); + rejects(variant(m => { m.contributes.subscriptions = [{ on: 'task.completed', handler: 'onTask', filter: { 'payload.kind': { nested: true } } }]; }), /filter/); + rejects(variant(m => { m.contributes.subscriptions = [{ event: 'task.completed' }]; }), /subscriptions/); +}); diff --git a/packages/shared/test/repositoryEvents.test.ts b/packages/shared/test/repositoryEvents.test.ts new file mode 100644 index 0000000000..64bc635f5e --- /dev/null +++ b/packages/shared/test/repositoryEvents.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + REPOSITORY_EVENT_DOMAINS, REPOSITORY_EVENT_TYPES, TASK_EVENT_KINDS, TASK_EVENT_TYPES, TASK_TERMINAL_EVENT_TYPES, + isRepositoryEventType, taskEventKind, +} from '../src/repositoryEvents.js'; +import { REPOSITORY_EVENT_ALL_TYPES, isRepositoryEventDomain } from '../src/repositoryEventWait.js'; + +test('the task domain is appended after the existing domains with its five event types', () => { + assert.deepEqual([...REPOSITORY_EVENT_DOMAINS], ['pull_request', 'epic_queue', 'plan_issue', 'task']); + assert.deepEqual([...TASK_EVENT_TYPES], ['queued', 'started', 'completed', 'failed', 'cancelled']); + assert.equal(REPOSITORY_EVENT_TYPES.task, TASK_EVENT_TYPES); + assert.deepEqual([...TASK_TERMINAL_EVENT_TYPES], ['completed', 'failed', 'cancelled']); + for (const type of TASK_EVENT_TYPES) assert.equal(isRepositoryEventType('task', type), true, type); + assert.equal(isRepositoryEventType('task', 'status_changed'), false); + assert.equal(isRepositoryEventType('plan_issue', 'queued'), false); + assert.equal(isRepositoryEventDomain('task'), true); + // Readers that accept a type filter derive it from the domain list. + for (const type of ['queued', 'started', 'failed', 'cancelled']) assert.ok(REPOSITORY_EVENT_ALL_TYPES.includes(type), type); +}); + +test('stored task types map to the documented task kinds; other task types are not journaled', () => { + assert.deepEqual([...TASK_EVENT_KINDS], ['implementation', 'follow-up', 'review', 'merge-conflict', 'automation']); + assert.equal(taskEventKind('issue'), 'implementation'); + assert.equal(taskEventKind(undefined), 'implementation'); + assert.equal(taskEventKind('pr-comment'), 'follow-up'); + assert.equal(taskEventKind('review'), 'review'); + assert.equal(taskEventKind('merge_conflict'), 'merge-conflict'); + assert.equal(taskEventKind('agent-run'), 'automation'); + for (const type of ['goal', 'goal-step', 'task-import', 'something-else']) assert.equal(taskEventKind(type), null, type); +}); diff --git a/propr-ui/e2e/plugin-activity.pw.ts b/propr-ui/e2e/plugin-activity.pw.ts new file mode 100644 index 0000000000..485b3abfe0 --- /dev/null +++ b/propr-ui/e2e/plugin-activity.pw.ts @@ -0,0 +1,69 @@ +import { expect, test } from '@playwright/test'; +import { NOW, DEFAULTS, installFixture, capture, type Delivery, type State } from './plugins.fixture'; + +/** + * The plugin detail's Activity list against the plugin API fixture: delivery + * records with their outcome, attempt, duration, error and log lines, Retry on + * a failed delivery, Degraded and Paused health with Resume, at desktop and + * phone widths. With PROPR_CAPTURE_PREVIEWS set it captures the Activity list. + */ + +/** Deliveries of the enabled example plugin: one gap, failures exhausting their retries, a timed-out attempt waiting to retry and successes. */ +function deliveries(): Delivery[] { + const at = (minutes: number) => NOW - minutes * 60_000; + const base = { kind: 'event' as const, repository: 'integry/propr', timedOut: false, logs: [] as Delivery['logs'], nextAttemptAt: null, error: null }; + return [ + { ...base, id: 6, deliveryId: 'evt-4127-onMerged', eventSeq: 4127, eventType: 'pull_request.merged', handler: 'onMerged', status: 'failed', attempt: 5, createdAt: at(212), startedAt: at(2), finishedAt: at(2), durationMs: 412, + error: 'Chat API answered 503 Service Unavailable', logs: [{ level: 'warn', message: 'chat.example refused the message (503)', at: at(2) }] }, + { ...base, id: 5, deliveryId: 'evt-4126-onTask', eventSeq: 4126, eventType: 'task.completed', handler: 'onTask', status: 'retrying', attempt: 2, nextAttemptAt: NOW + 4 * 60_000, createdAt: at(8), startedAt: at(1), finishedAt: at(0.5), durationMs: 30_000, timedOut: true, + error: 'The handler did not finish within 30 seconds' }, + { ...base, id: 4, deliveryId: 'evt-4119-onMerged', eventSeq: 4119, eventType: 'pull_request.merged', handler: 'onMerged', status: 'failed', attempt: 5, createdAt: at(260), startedAt: at(20), finishedAt: at(20), durationMs: 388, + error: 'Chat API answered 503 Service Unavailable' }, + { ...base, id: 3, deliveryId: 'evt-4102-onTask', eventSeq: 4102, eventType: 'task.completed', handler: 'onTask', status: 'succeeded', attempt: 1, createdAt: at(300), startedAt: at(300), finishedAt: at(300), durationMs: 184, + logs: [{ level: 'info', message: 'posted task 9f3c to #propr-updates', at: at(300) }, { level: 'debug', message: 'thread 1731 reused', at: at(300) }] }, + { ...base, id: 2, deliveryId: 'evt-4098-onMerged', eventSeq: 4098, eventType: 'pull_request.merged', handler: 'onMerged', status: 'succeeded', attempt: 1, createdAt: at(340), startedAt: at(340), finishedAt: at(340), durationMs: 236 }, + { ...base, id: 1, deliveryId: 'gap-3901-3950-integry/propr', kind: 'gap', eventSeq: 3901, eventType: null, handler: null, status: 'skipped', attempt: 0, createdAt: at(400), startedAt: null, finishedAt: at(400), durationMs: null, + error: 'Events after 3900 up to 3950 may have left the journal (retention) before they were delivered' }, + ]; +} + +test('shows delivery activity with Retry, Degraded and Paused with Resume', async ({ page }) => { + const lastError = 'Chat API answered 503 Service Unavailable'; + const state: State = { + installed: true, enabled: true, token: true, oauth: true, repositories: ['integry/propr'], refuseUninstall: false, settings: { ...DEFAULTS }, + activity: { health: { state: 'degraded', consecutiveFailures: 3, pauseAfterFailures: 10, lastError, lastErrorAt: NOW - 2 * 60_000, degradedAt: NOW - 20 * 60_000, pausedAt: null }, deliveries: deliveries() }, + }; + await page.setViewportSize({ width: 1440, height: 900 }); + await installFixture(page, state); + await page.goto('/plugins/example-pack'); + const activity = page.getByRole('region', { name: 'Activity' }); + await expect(page.getByRole('heading', { level: 1 }).getByTestId('plugin-health')).toHaveText('Degraded'); + await expect(activity.getByTestId('plugin-health-notice')).toContainText('Degraded: 3 deliveries failed in a row'); + await expect(activity.getByTestId('plugin-delivery')).toHaveCount(6); + await activity.getByTestId('plugin-delivery').nth(3).getByText('Log (2 lines)').click(); + await capture(page, 'plugins-activity-degraded-desktop', activity); + + await activity.getByRole('button', { name: 'Retry pull_request.merged to onMerged' }).first().click(); + await expect(activity.getByRole('status')).toContainText('Retry scheduled'); + await expect(activity.getByTestId('plugin-delivery').first()).toHaveAttribute('data-status', 'pending'); + + // Later the failures go on and deliveries pause. + state.activity!.health = { ...state.activity!.health, state: 'paused', consecutiveFailures: 10, pausedAt: NOW }; + await activity.getByRole('button', { name: 'Refresh' }).click(); + await expect(page.getByRole('heading', { level: 1 }).getByTestId('plugin-health')).toHaveText('Paused'); + const resume = activity.getByRole('button', { name: 'Resume deliveries' }); + await expect(resume).toBeVisible(); + await capture(page, 'plugins-activity-paused-desktop', activity); + await resume.click(); + await expect(activity.getByRole('status')).toHaveText('Deliveries resumed.'); + await expect(page.getByTestId('plugin-health')).toHaveCount(0); + + await page.setViewportSize({ width: 390, height: 844 }); + state.activity!.health = { ...state.activity!.health, state: 'paused', consecutiveFailures: 10, pausedAt: NOW }; + await activity.getByRole('button', { name: 'Refresh' }).click(); + await expect(resume).toBeVisible(); + await expect(activity.getByRole('status')).toHaveCount(0); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow).toBeLessThanOrEqual(0); + await capture(page, 'plugins-activity-paused-phone', activity); +}); diff --git a/propr-ui/e2e/plugins.fixture.ts b/propr-ui/e2e/plugins.fixture.ts new file mode 100644 index 0000000000..83da903732 --- /dev/null +++ b/propr-ui/e2e/plugins.fixture.ts @@ -0,0 +1,235 @@ +import type { Page } from '@playwright/test'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; + +/** + * The plugin API fixture of the Plugins page specs: a stateful example plugin + * (install, connections, settings, repository limit, enable, approval, + * uninstall and delivery activity) behind `page.route`, and the preview + * capture used with PROPR_CAPTURE_PREVIEWS. + */ + +export const NOW = Date.parse('2026-11-14T09:00:00.000Z'); +export const BASE = '/api/instance/plugins/v1'; +export const REFUSAL = 'Automations add items of plugin example-pack explicitly; remove them from "Weekly digest" (example-remote) before uninstalling'; + +export 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: { + 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', tools: ['echo'], capability: 'read', secretEnv: [{ name: 'FIXTURE_TOKEN', connection: 'example-token' }] }, + ], + skills: ['example-notes'], automationTemplates: ['example-weekly-digest'], commands: [], subscriptions: [], webhooks: [], + }, +}; +export 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.' }, +]; + +export const DEFAULTS = { greeting: 'hello', verbosity: 'normal', maxItems: 10, includeDrafts: false, labels: [] }; +/** The upgrade awaiting approval: version 0.2.0 asks for one more permission and moves the package server to a new version with one more secret variable. */ +export const UPGRADE = { version: '0.2.0', permission: { name: 'pull_requests.read', description: 'Read pull requests and their status' }, package: 'propr-fixture-mcp', packageVersion: '1.1.0', secretEnv: ['FIXTURE_TOKEN', 'FIXTURE_REGION'] }; + +export interface Delivery { + id: number; deliveryId: string; kind: 'event' | 'gap'; repository: string; eventSeq: number; eventType: string | null; handler: string | null; + status: 'pending' | 'running' | 'retrying' | 'succeeded' | 'failed' | 'skipped'; attempt: number; nextAttemptAt: number | null; + createdAt: number; startedAt: number | null; finishedAt: number | null; durationMs: number | null; timedOut: boolean; error: string | null; + logs: { level: 'debug' | 'info' | 'warn' | 'error'; message: string; at: number }[]; +} +export interface Health { + state: 'healthy' | 'degraded' | 'paused'; consecutiveFailures: number; pauseAfterFailures: number; + lastError: string | null; lastErrorAt: number | null; degradedAt: number | null; pausedAt: number | null; +} + +export interface State { + installed: boolean; enabled: boolean; token: boolean; oauth: boolean; repositories: string[] | null; refuseUninstall: boolean; + settings?: Record; upgrade?: boolean; + activity?: { health: Health; deliveries: Delivery[] }; +} + +export const HEALTHY: Health = { state: 'healthy', consecutiveFailures: 0, pauseAfterFailures: 10, lastError: null, lastErrorAt: null, degradedAt: null, pausedAt: null }; +/** The plugin's delivery activity routes: null when the path is not one. */ +export function activityRoute(state: State, route: string, method: string): { status: number; json: unknown } | null { + const activity = state.activity ?? { health: HEALTHY, deliveries: [] }; + if (route === '/activity' && method === 'GET') return { status: 200, json: activity }; + const retry = /^\/activity\/(\d+)\/retry$/.exec(route); + if (retry && method === 'POST') { + const delivery = activity.deliveries.find(d => d.id === Number(retry[1])); + if (!delivery || delivery.status !== 'failed') return { status: 409, json: { error: 'Only a failed event delivery can be retried' } }; + Object.assign(delivery, { status: 'pending', nextAttemptAt: NOW }); + return { status: 200, json: { delivery } }; + } + if (route === '/resume' && method === 'POST') { + if (activity.health.state !== 'paused') return { status: 409, json: { error: 'Deliveries of this plugin are not paused' } }; + activity.health = { ...HEALTHY, lastError: activity.health.lastError, lastErrorAt: activity.health.lastErrorAt }; + return { status: 200, json: { health: activity.health } }; + } + return null; +} + +export const blockers = (state: State) => [ + ...(state.settings?.verbosity === undefined ? ['Required setting Verbosity (verbosity) is missing'] : []), + ...(state.token ? [] : ['Connection Example API token (example-token) is not set']), +]; + +export function view(state: State) { + const active = state.enabled; + const library = '/api/instance/extensions/v1'; + return { + id: 'example-pack', available: true, manifest: state.upgrade ? upgradedManifest() : MANIFEST, + state: !state.installed ? 'available' : state.enabled ? 'enabled' : 'installed', + installation: state.installed ? { + installedVersion: '0.1.0', enabled: state.enabled, settings: state.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: Boolean(state.upgrade), pendingPermissions: state.upgrade ? [UPGRADE.permission.name] : [], + pendingAcknowledgements: state.upgrade ? [{ server: 'example-local', package: UPGRADE.package, version: UPGRADE.packageVersion, secretEnv: UPGRADE.secretEnv }] : [], + enableBlockers: state.installed ? blockers(state) : [], + 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' }], + }, + }; +} +export function upgradedManifest() { + const servers = MANIFEST.contributes.mcpServers.map(server => (server.kind === 'package' + ? { ...server, version: UPGRADE.packageVersion, secretEnv: UPGRADE.secretEnv.map(name => ({ name, connection: 'example-token' })) } : server)); + return { ...MANIFEST, version: UPGRADE.version, permissions: [...MANIFEST.permissions, UPGRADE.permission], contributes: { ...MANIFEST.contributes, mcpServers: servers } }; +} + +/** The extension library as Skills & MCP servers reads it: the plugin's managed items once it is installed, next to one own server. */ +export function extensionLibrary(state: State) { + const managedBy = (item: string) => ({ plugin: 'example-pack', item, active: state.enabled }); + const limits = { repositories: state.repositories ?? [], agents: [], autoApply: true }; + const bundleId = '075bb20e-4156-4f6c-a7e2-20fd6c8ba84e'; + const own = { kind: 'https', name: 'docs', url: 'https://tools.example.com/mcp', enabled: true, tools: ['search'], capability: 'read', authentication: 'none', repositories: [], agents: [], autoApply: true }; + const servers = state.installed ? [ + { kind: 'https', name: 'example-remote', url: 'https://mcp.example.com/mcp', enabled: true, tools: ['search', 'fetch'], capability: 'read', authentication: 'oauth', ...limits, managedBy: { ...managedBy('example-remote'), registrations: ['dynamic'] } }, + { + kind: 'package', name: 'example-local', ecosystem: 'npm', package: 'propr-fixture-mcp', version: '1.0.0', integrity: `sha512-${'Q'.repeat(86)}==`, bin: 'propr-fixture-mcp', + args: [], env: [{ name: 'FIXTURE_GREETING', value: 'hello' }], secretEnv: ['FIXTURE_TOKEN'], enabled: true, tools: ['echo', 'secret_length'], capability: 'read', ...limits, managedBy: managedBy('example-local'), + }, + own, + ] : [own]; + const bundle = { id: bundleId, instanceId: 'instance', name: 'example-notes', digest: 'd'.repeat(64), files: 1, bytes: 512, source: { kind: 'plugin', plugin: 'example-pack', version: '0.1.0', path: 'skills/example-notes' }, requirements: [], createdAt: NOW, auditActor: '1042' }; + return { + instanceId: 'instance', bundles: [], catalog: [], + library: { + instanceId: 'instance', revision: 7, version: 1, createdAt: NOW, updatedAt: NOW, auditActor: '1042', servers, + skills: state.installed ? [{ bundleId, enabled: true, ...limits, managedBy: managedBy('example-notes') }] : [], skillBundles: state.installed ? [bundle] : [], secrets: {}, + }, + oauth: { redirectUri: 'https://propr.example.com/api/instance/extensions/v1/oauth/callback', clientMetadataUrl: null, connections: {} }, + }; +} + +/** The settings route of the fixture: the same checks as the API for the one rule the page is driven into, and null removes a setting. */ +export function saveSettings(state: State, settings: Record) { + if (typeof settings.maxItems === 'number' && (settings.maxItems < 1 || settings.maxItems > 50)) return { status: 400, error: `maxItems must be ${settings.maxItems < 1 ? 'at least 1' : 'at most 50'}` }; + if (state.enabled && settings.verbosity === null) return { status: 409, error: 'Required settings cannot be removed while the plugin is enabled: verbosity' }; + state.settings = Object.fromEntries(Object.entries(settings).filter(([, value]) => value !== null)); + return null; +} + +export const ACKNOWLEDGE = 'Confirm that the secret variables of example-local are readable'; +export const acknowledges = (body: Record) => (body.acknowledgements as string[] | undefined)?.includes('example-local'); +/** The example plugin's mutation routes: null when the path is not one, a refusal, or `{}` once the state changed. */ +export function pluginMutation(state: State, route: string, body: Record): { status?: number; error?: string } | null { + switch (route) { + case '/install': + if (!acknowledges(body)) return { status: 400, error: ACKNOWLEDGE }; + Object.assign(state, { installed: true, settings: { ...DEFAULTS } }); return {}; + case '/settings': return saveSettings(state, body.settings as Record) ?? {}; + case '/approve': + if (!acknowledges(body)) return { status: 400, error: ACKNOWLEDGE }; + state.upgrade = false; return {}; + case '/connections/example-token': state.token = body.value !== null; return {}; + case '/repositories': state.repositories = body.repositories as string[] | null; return {}; + case '/enabled': state.enabled = body.enabled === true; return {}; + case '/uninstall': + if (state.refuseUninstall) { state.refuseUninstall = false; return { status: 409, error: REFUSAL }; } + Object.assign(state, { installed: false, enabled: false, token: false, oauth: false, repositories: null, settings: undefined }); return {}; + default: return null; + } +} + +export 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: [], +}); + +export 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: [] } } }); + const activity = pathname.startsWith(`${BASE}/example-pack/`) ? activityRoute(state, pathname.slice(`${BASE}/example-pack`.length), method) : null; + if (activity) return route.fulfill(activity); + const mutated = pluginMutation(state, pathname.slice(`${BASE}/example-pack`.length), body); + if (mutated) return mutated.error ? route.fulfill({ status: mutated.status, json: { error: mutated.error } }) : reply(); + if (pathname === '/api/instance/extensions/v1') return route.fulfill({ json: extensionLibrary(state) }); + 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/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. */ +export 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`) }); +} + +export const step = (page: Page, title: string) => page.getByRole('region', { name: new RegExp(`^\\d\\. ${title}`) }); +export const badge = (page: Page) => page.getByRole('heading', { level: 1 }).getByTestId('plugin-state'); diff --git a/propr-ui/e2e/plugins.pw.ts b/propr-ui/e2e/plugins.pw.ts new file mode 100644 index 0000000000..8cd48b3045 --- /dev/null +++ b/propr-ui/e2e/plugins.pw.ts @@ -0,0 +1,190 @@ +import { expect, test } from '@playwright/test'; +import { REFUSAL, DEFAULTS, installFixture, capture, step, badge, type State } from './plugins.fixture'; + +/** + * The Plugins page end to end against a stateful plugin API fixture: the + * example plugin is installed, connected, configured through the generated + * settings form, limited to a repository and enabled; its managed items are + * then shown in Skills & MCP servers; it is disabled and uninstalled entirely + * from the page. An upgrade asking for more is approved from the detail page. + * With PROPR_CAPTURE_PREVIEWS set it also captures the catalog and the detail + * steps at desktop and phone widths. + */ + +test('installs, connects, configures, enables, shows the managed items, disables and uninstalls the example plugin', 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(''); + + // Configure: the form is generated from the manifest's settings schema and starts from the installed defaults. + const configure = step(page, 'Configure'); + const settings = configure.getByRole('form', { name: 'Plugin settings' }); + await expect(settings.getByLabel('Greeting')).toHaveValue('hello'); + await expect(settings.getByLabel('Verbosity')).toHaveValue('normal'); + await expect(settings.getByLabel('Maximum items')).toHaveValue('10'); + await expect(settings.getByLabel('Include draft pull requests')).toHaveValue('false'); + + // A required setting left empty blocks Enable, with the reason. + await settings.getByLabel('Verbosity').selectOption(''); + await settings.getByRole('button', { name: 'Save settings' }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Settings saved.' })).toBeVisible(); + // Untouched settings keep their saved values, including the empty default list. + expect(state.settings).toEqual({ greeting: 'hello', maxItems: 10, includeDrafts: false, labels: [] }); + await expect(enable.getByRole('checkbox', { name: /Enabled/ })).toBeDisabled(); + await expect(enable.getByRole('status')).toContainText('Required setting Verbosity (verbosity) is missing'); + await expect(configure.getByRole('status').first()).toHaveText('Set Verbosity to enable the plugin.'); + + // Invalid input shows the error next to its field. + const settingsForm = () => step(page, 'Configure').getByRole('form', { name: 'Plugin settings' }); + // Text the browser cannot read as a number shows an error instead of removing the saved value. The browser reports + // it as an empty value, so it is typed key by key (fill refuses it), both over the saved number and into an emptied field. + let settingsSaves = 0; + page.on('request', request => { if (request.method() === 'PUT' && request.url().endsWith('/settings')) settingsSaves += 1; }); + const maxItemsError = () => settingsForm().locator('[data-setting="maxItems"]').getByTestId('plugin-setting-error'); + for (const [fills, typed] of [[['10'], '1e'], [['7', ''], '-']] as const) { + for (const value of fills) await settingsForm().getByLabel('Maximum items').fill(value); + await expect(maxItemsError()).toHaveCount(0); + await settingsForm().getByLabel('Maximum items').press('ControlOrMeta+a'); + await settingsForm().getByLabel('Maximum items').pressSequentially(typed); + expect(await settingsForm().getByLabel('Maximum items').evaluate(input => (input as HTMLInputElement).validity.badInput)).toBe(true); + await settingsForm().getByRole('button', { name: 'Save settings' }).click(); + await expect(maxItemsError()).toHaveText('maxItems must be a number'); + } + expect(settingsSaves).toBe(0); + expect(state.settings).toEqual({ greeting: 'hello', maxItems: 10, includeDrafts: false, labels: [] }); + + await settingsForm().getByLabel('Maximum items').fill('99'); + await settingsForm().getByRole('button', { name: 'Save settings' }).click(); + await expect(settingsForm().locator('[data-setting="maxItems"]').getByTestId('plugin-setting-error')).toHaveText('maxItems must be at most 50'); + await capture(page, 'plugins-settings-field-error-desktop', settingsForm()); + + await settingsForm().getByLabel('Maximum items').fill('20'); + await settingsForm().getByLabel('Verbosity').selectOption('detailed'); + await settingsForm().getByLabel('Include draft pull requests').selectOption('true'); + await settingsForm().getByRole('button', { name: 'Add entry to Labels to watch' }).click(); + await settingsForm().getByLabel('Labels to watch: entry 1').fill(' bug '); + await settingsForm().getByRole('button', { name: 'Add entry to Labels to watch' }).click(); + await settingsForm().getByLabel('Labels to watch: entry 2').fill('first\nsecond'); + await settingsForm().getByRole('button', { name: 'Add repository to Channel per repository' }).click(); + await settingsForm().getByLabel('Channel per repository: repository 1').fill('integry/propr'); + await settingsForm().getByLabel('Channel per repository: value for repository 1').fill('#propr'); + await settingsForm().getByRole('button', { name: 'Save settings' }).click(); + await expect(enable.getByRole('checkbox', { name: /Enabled/ })).toBeEnabled(); + expect(state.settings).toEqual({ greeting: 'hello', verbosity: 'detailed', maxItems: 20, includeDrafts: true, labels: [' bug ', 'first\nsecond'], channels: { 'integry/propr': '#propr' } }); + + // The saved values round-trip through a fresh read. + await page.reload(); + await expect(settingsForm().getByLabel('Verbosity')).toHaveValue('detailed'); + await expect(settingsForm().getByLabel('Maximum items')).toHaveValue('20'); + await expect(settingsForm().getByLabel('Include draft pull requests')).toHaveValue('true'); + await expect(settingsForm().getByLabel('Labels to watch: entry 1')).toHaveValue(' bug '); + await expect(settingsForm().getByLabel('Labels to watch: entry 2')).toHaveValue('first\nsecond'); + await expect(settingsForm().getByLabel('Channel per repository: value for repository 1')).toHaveValue('#propr'); + await expect(step(page, 'Configure')).toHaveAttribute('data-done', 'true'); + + await step(page, 'Configure').getByRole('checkbox', { name: 'integry/propr', exact: true }).check(); + await step(page, '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 step(page, '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')); + + // The managed items in Skills & MCP servers: badged, linked to the plugin, definition read-only. + await page.getByTestId('plugin-contributions').getByRole('link', { name: 'Skills & MCP servers' }).first().click(); + const library = page.getByRole('region', { name: 'Skills & MCP servers' }); + const local = library.getByTestId('extension-server').filter({ hasText: 'example-local' }); + await expect(local.getByRole('link', { name: 'From plugin: Example pack' })).toHaveAttribute('href', '/plugins/example-pack'); + await expect(library.getByTestId('extension-skill').getByRole('link', { name: 'From plugin: Example pack' })).toBeVisible(); + await expect(local.getByRole('button', { name: 'Remove' })).toHaveCount(0); + const own = library.getByTestId('extension-server').filter({ has: page.getByText('docs', { exact: true }) }); + await expect(own.getByRole('button', { name: 'Remove' })).toHaveCount(1); + await expect(own.getByTestId('extension-managed-badge')).toHaveCount(0); + await local.getByRole('button', { name: 'Edit limits' }).click(); + const managedForm = library.getByTestId('extension-server-form'); + await expect(managedForm.getByTestId('extension-managed-definition')).toContainText('propr-fixture-mcp'); + await expect(managedForm.getByTestId('extension-managed-definition')).toContainText('FIXTURE_TOKEN'); + await expect(managedForm.getByLabel('Package')).toHaveCount(0); + await expect(managedForm.getByRole('checkbox', { name: /Apply automatically/ })).toBeEnabled(); + await capture(page, 'plugins-managed-item-library-desktop', managedForm); + await managedForm.getByRole('button', { name: 'Cancel' }).click(); + await local.getByRole('link', { name: 'From plugin: Example pack' }).click(); + await expect(badge(page)).toHaveText('Enabled'); + + 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('shows what an upgrade asks for and approving it clears Needs approval', async ({ page }) => { + const state: State = { installed: true, enabled: true, token: true, oauth: true, repositories: null, refuseUninstall: false, settings: { ...DEFAULTS }, upgrade: true }; + await page.setViewportSize({ width: 1440, height: 900 }); + await installFixture(page, state); + await page.goto('/plugins/example-pack'); + await expect(badge(page)).toHaveText('Needs approval'); + const approval = page.getByTestId('plugin-approval'); + await expect(approval).toContainText('Version 0.2.0 needs your approval (approved: 0.1.0)'); + await expect(approval.getByTestId('plugin-approval-permissions')).toContainText('pull_requests.read: Read pull requests and their status'); + await expect(approval.getByTestId('plugin-approval-package')).toContainText('Version changes from 1.0.0 to 1.1.0'); + await expect(approval.getByTestId('plugin-approval-package')).toContainText('Secret variables added: FIXTURE_REGION'); + const approve = approval.getByRole('button', { name: 'Approve' }); + await expect(approve).toBeDisabled(); + await capture(page, 'plugins-needs-approval-desktop', approval); + await approval.getByRole('checkbox', { name: /propr-fixture-mcp@1\.1\.0 receives the secret variables FIXTURE_TOKEN, FIXTURE_REGION/ }).check(); + await approve.click(); + await expect(badge(page)).toHaveText('Enabled'); + await expect(page.getByTestId('plugin-approval')).toHaveCount(0); + expect(state.upgrade).toBe(false); +}); + +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 } : {} }); +/** Replace the settings; a `null` value removes that setting. The API validates them against the manifest's settings schema. */ +export const setPluginSettings = (id: string, settings: ProprApi.PluginSettingsRequest['settings']): Plugin => + request(plugin(id, '/settings'), { method: 'PUT', body: { settings } satisfies ProprApi.PluginSettingsRequest }); +/** `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: {} }); + +export type PluginActivityView = ProprApi.PluginActivityResponse; +export type PluginDeliveryView = ProprApi.PluginDelivery; +export type PluginHealthView = ProprApi.PluginHealth; +/** Event delivery health (healthy, degraded or paused) and the newest delivery records, newest first. */ +export const getPluginActivity = (id: string) => request(plugin(id, '/activity')); +/** Give a failed delivery one more attempt, with the same delivery id. */ +export const retryPluginDelivery = (id: string, recordId: number) => + request(plugin(id, `/activity${segment(String(recordId))}/retry`), { method: 'POST', body: {} }); +/** Resume deliveries paused after repeated failures. */ +export const resumePluginDeliveries = (id: string) => request(plugin(id, '/resume'), { 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 (