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..807c333ffd --- /dev/null +++ b/docs/docs/architecture/plugin-runtime.md @@ -0,0 +1,306 @@ +--- +sidebar_label: "Plugin Runtime" +title: "Plugin runtime: handlers and their context" +description: "How plugin handlers run: the handler SDK, runPluginHandler, 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. | + +## 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 next issues call the runner from the event dispatcher (`PluginEventInput`), +the inbound webhook route (`PluginWebhookInput`) and commands +(`PluginCommandInput`). `PluginInvokeInput` carries a JSON payload for direct +calls and tests. + +## `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/features/plugins.md b/docs/docs/features/plugins.md index 2a289b726d..70f8351eec 100644 --- a/docs/docs/features/plugins.md +++ b/docs/docs/features/plugins.md @@ -14,9 +14,9 @@ integrity check as the [extension library's local package servers](./agent-exten :::note Work in progress This page describes the plugin **manifest**, the official plugin registry, the -[Plugins page](#the-plugins-page) and the installation API. A plugin's -generated settings form, handlers, webhooks and commands are added by later -releases. +[Plugins page](#the-plugins-page), the installation API and the +[handler interface](#handlers). Delivering events to handlers, inbound webhooks +and commands are added by later releases. ::: ## Where plugins live @@ -68,8 +68,10 @@ than silently ignored. `events.read`, `tasks.read`, `tasks.create`, `issues.read`, `issues.create`, `issues.comment`, `pull_requests.read`, `pull_requests.comment`, -`repositories.read` and `notifications.send`. A command can only use -permissions the plugin requests, and event subscriptions need `events.read`. +`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 @@ -501,3 +503,114 @@ 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 }`. Delivering events, 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. + diff --git a/docs/sidebars.ts b/docs/sidebars.ts index ba3861c509..4e5287cec0 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -122,6 +122,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 36901f2d31..88ae7fcd31 100644 --- a/docs/static/openapi/propr-api.yaml +++ b/docs/static/openapi/propr-api.yaml @@ -12660,6 +12660,7 @@ components: - issues.comment - pull_requests.read - pull_requests.comment + - labels.write - repositories.read - notifications.send description: @@ -12846,6 +12847,7 @@ components: - issues.comment - pull_requests.read - pull_requests.comment + - labels.write - repositories.read - notifications.send acknowledgements: @@ -12949,6 +12951,7 @@ components: - issues.comment - pull_requests.read - pull_requests.comment + - labels.write - repositories.read - notifications.send pendingAcknowledgements: @@ -13110,6 +13113,9 @@ components: - disconnect_oauth - set_oauth_client - clear_oauth_client + - handler_attempt + - handler_call + - handler_refused actor: type: string at: @@ -13320,6 +13326,7 @@ components: - issues.comment - pull_requests.read - pull_requests.comment + - labels.write - repositories.read - notifications.send description: @@ -13506,6 +13513,7 @@ components: - issues.comment - pull_requests.read - pull_requests.comment + - labels.write - repositories.read - notifications.send acknowledgements: @@ -13609,6 +13617,7 @@ components: - issues.comment - pull_requests.read - pull_requests.comment + - labels.write - repositories.read - notifications.send pendingAcknowledgements: 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/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/pluginRoutes.test.ts b/packages/api/test/pluginRoutes.test.ts index 5dacd149a1..d6bbd4f113 100644 --- a/packages/api/test/pluginRoutes.test.ts +++ b/packages/api/test/pluginRoutes.test.ts @@ -13,6 +13,7 @@ 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 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'; @@ -42,7 +43,7 @@ test('plugin REST API and MCP read tools: administrators only, audited, secret-f const database = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); await database.schema.createTable('instance_members', c => { c.string('github_user_id').primary(); c.string('role'); }); await database.schema.createTable('agent_definitions', c => { c.string('id').primary(); c.string('name'); }); - await identityMigration(database); await grantMigration(database); await libraryMigration(database); await oauthMigration(database); await pluginMigration(database); + await identityMigration(database); await grantMigration(database); await libraryMigration(database); await oauthMigration(database); await pluginMigration(database); await pluginStateMigration(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. diff --git a/packages/client/src/generated/apiTypes.ts b/packages/client/src/generated/apiTypes.ts index ba11879492..09056c5938 100644 --- a/packages/client/src/generated/apiTypes.ts +++ b/packages/client/src/generated/apiTypes.ts @@ -874,7 +874,7 @@ export interface PluginDetailResponse { category: "messaging" | "issue-tracking" | "observability" | "deployment" | "context" | "other"; homepage: string; permissions: Array<{ - name: "events.read" | "tasks.read" | "tasks.create" | "issues.read" | "issues.create" | "issues.comment" | "pull_requests.read" | "pull_requests.comment" | "repositories.read" | "notifications.send"; + 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<{ @@ -927,7 +927,7 @@ export interface PluginDetailResponse { }; /** Repositories (`owner/repo`) the plugin is limited to; null for all repositories. */ repositories: string[] | null; - grantedPermissions: Array<"events.read" | "tasks.read" | "tasks.create" | "issues.read" | "issues.create" | "issues.comment" | "pull_requests.read" | "pull_requests.comment" | "repositories.read" | "notifications.send">; + 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]: { @@ -957,7 +957,7 @@ export interface PluginDetailResponse { }>; /** The current manifest asks for permissions or secret acknowledgements not yet approved; the plugin keeps its old grant. */ needsApproval: boolean; - pendingPermissions: Array<"events.read" | "tasks.read" | "tasks.create" | "issues.read" | "issues.create" | "issues.comment" | "pull_requests.read" | "pull_requests.comment" | "repositories.read" | "notifications.send">; + 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; @@ -1007,7 +1007,7 @@ export interface PluginDetailResponse { }; /** 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"; + 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"; actor: string; at: number; detail: { @@ -1088,7 +1088,7 @@ export interface PluginView { category: "messaging" | "issue-tracking" | "observability" | "deployment" | "context" | "other"; homepage: string; permissions: Array<{ - name: "events.read" | "tasks.read" | "tasks.create" | "issues.read" | "issues.create" | "issues.comment" | "pull_requests.read" | "pull_requests.comment" | "repositories.read" | "notifications.send"; + 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<{ @@ -1141,7 +1141,7 @@ export interface PluginView { }; /** Repositories (`owner/repo`) the plugin is limited to; null for all repositories. */ repositories: string[] | null; - grantedPermissions: Array<"events.read" | "tasks.read" | "tasks.create" | "issues.read" | "issues.create" | "issues.comment" | "pull_requests.read" | "pull_requests.comment" | "repositories.read" | "notifications.send">; + 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]: { @@ -1171,7 +1171,7 @@ export interface PluginView { }>; /** The current manifest asks for permissions or secret acknowledgements not yet approved; the plugin keeps its old grant. */ needsApproval: boolean; - pendingPermissions: Array<"events.read" | "tasks.read" | "tasks.create" | "issues.read" | "issues.create" | "issues.comment" | "pull_requests.read" | "pull_requests.comment" | "repositories.read" | "notifications.send">; + 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; 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/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/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/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/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 index 16134a669b..8abcd9f5dc 100644 --- a/packages/core/src/plugins/pluginStore.ts +++ b/packages/core/src/plugins/pluginStore.ts @@ -13,6 +13,7 @@ import { extensionOAuth, type ExtensionOAuthUrls } from '../extensions/extension import { OAUTH_PENDING, discardOAuthConnectionsOf, hasOAuthTables, oauthConnectionStates } from '../extensions/extensionOAuthState.js'; import { PluginContributions, oauthServersOf, pluginContributionViews, type PluginOAuthClient } from './pluginContributions.js'; import { pluginTemplateIds } from './pluginTemplates.js'; +import { deletePluginState } from './pluginStateStore.js'; import { PluginError, enableBlockers, installationRecord, nextAcknowledgements, parseAcknowledgements, pendingAcknowledgements, pendingPermissions, pluginView, type PluginInstallationRow, type PluginSecretRow, @@ -306,14 +307,16 @@ export class PluginStore { }); } - /** Delete the installation, its settings and its connections. `confirm` must repeat the plugin id. The audit trail stays. */ + /** 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(); - return { action: 'uninstall', detail: { version: row.manifest_version } }; + const state = await deletePluginState(trx, pluginId); + return { action: 'uninstall', detail: { version: row.manifest_version, stateKeys: state } }; }); } 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/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/utils/workerStateManager.ts b/packages/core/src/utils/workerStateManager.ts index 32dbc6455e..609bc21c32 100644 --- a/packages/core/src/utils/workerStateManager.ts +++ b/packages/core/src/utils/workerStateManager.ts @@ -3,7 +3,7 @@ import logger, { generateCorrelationId } from './logger.js'; import { db } from '../db/connection.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'; @@ -107,7 +107,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) }] }; } @@ -142,7 +142,7 @@ 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'); 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; +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..66c2901ee0 --- /dev/null +++ b/packages/core/test/fixtures/plugins/registry.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "plugins": [ + { "id": "fixture-handlers", "development": true } + ] +} diff --git a/packages/core/test/helpers/pluginHandlerFixture.ts b/packages/core/test/helpers/pluginHandlerFixture.ts new file mode 100644 index 0000000000..8848b49d63 --- /dev/null +++ b/packages/core/test/helpers/pluginHandlerFixture.ts @@ -0,0 +1,95 @@ +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 { 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'; +export const fixtureManifest = (): PluginManifest => getOfficialPlugin(FIXTURE_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 } = {}) { + 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]) await migrate(database); + const manifest = fixtureManifest(); + const catalog = async () => [manifest]; + const store = new PluginStore(database, ENVIRONMENT, catalog); + await store.install('1', FIXTURE_ID); + if (options.secret) await store.setConnection('1', FIXTURE_ID, 'api-token', options.secret); + await store.setRepositories('1', FIXTURE_ID, options.repositories === undefined ? ['owner/allowed'] : options.repositories); + await store.setEnabled('1', FIXTURE_ID, 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 index 692d2875dc..0243b22f1a 100644 --- a/packages/core/test/pluginContributions.test.ts +++ b/packages/core/test/pluginContributions.test.ts @@ -14,6 +14,7 @@ import { up as grantMigration } from '../src/db/migrations/20261013220000_extens 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 { PluginError, PluginStore } from '../src/plugins/pluginStore.js'; import { PluginContributions } from '../src/plugins/pluginContributions.js'; import { officialPluginAssets, type PluginAssetSource } from '../src/plugins/pluginAssets.js'; @@ -62,7 +63,7 @@ async function fixture(t: TestContext, options: { advertise?: SyntheticOAuthOpti 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 identityMigration(database); await grantMigration(database); await libraryMigration(database); await oauthMigration(database); await pluginMigration(database); await pluginStateMigration(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; 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..faff69b4f9 --- /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: '' }, + }, { ...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 index def20bc3cd..895a7077d1 100644 --- a/packages/core/test/pluginStore.test.ts +++ b/packages/core/test/pluginStore.test.ts @@ -5,6 +5,7 @@ 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 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'; @@ -36,7 +37,7 @@ async function fixture(t: TestContext, initial: PluginManifest = example) { await database.schema.createTable('instance_members', c => { c.string('github_user_id').primary(); c.string('role'); }); await database('instance_members').insert([{ github_user_id: '1', role: 'admin' }, { github_user_id: '2', role: 'member' }]); await database.schema.createTable('agent_definitions', c => { c.string('id').primary(); c.string('name'); }); - await identityMigration(database); await grantMigration(database); await libraryMigration(database); await oauthMigration(database); await pluginMigration(database); + await identityMigration(database); await grantMigration(database); await libraryMigration(database); await oauthMigration(database); await pluginMigration(database); await pluginStateMigration(database); let manifests: PluginManifest[] = [initial]; let clock = 1_700_000_000_000; const store = new PluginStore(database, { SYSTEM_TASK_SECRET: 'plugin-store-test-root' }, async () => manifests, () => clock); diff --git a/packages/core/test/taskJournalEvents.test.ts b/packages/core/test/taskJournalEvents.test.ts index de84bee46a..3e357d41a0 100644 --- a/packages/core/test/taskJournalEvents.test.ts +++ b/packages/core/test/taskJournalEvents.test.ts @@ -285,3 +285,16 @@ test('the domain migration keeps rows, indexes and the seq high-water mark, and 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 index 48c0a8e741..ce63d5de09 100644 --- a/packages/plugins/example-pack/README.md +++ b/packages/plugins/example-pack/README.md @@ -17,5 +17,8 @@ It contributes: `--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/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/package.json b/packages/plugins/package.json index 6656db4827..dde3bfd8a7 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -2,15 +2,18 @@ "name": "@propr/plugins", "version": "0.9.1", "private": true, - "description": "Registry of the official ProPR plugins: manifests and their assets", + "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" }, + "engines": { + "node": ">=22" + }, "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit" + "build": "tsc && tsc -p tsconfig.handlers.json", + "typecheck": "tsc --noEmit && tsc -p tsconfig.handlers.json --noEmit", + "lint": "eslint ." }, "dependencies": { "@propr/shared": "^0.9.1" diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index c1c096e0f6..762c59cd72 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { formatPluginManifestIssues, pluginIdSchema, pluginManifestSchema, type PluginManifest } from '@propr/shared'; +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 @@ -15,6 +15,8 @@ export const PLUGIN_REGISTRY_VERSION = 1; 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; @@ -29,6 +31,8 @@ export interface PluginAssets { 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 { @@ -65,9 +69,24 @@ export function resolvePluginAssets(directory: string, manifest: PluginManifest) 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); @@ -109,3 +128,25 @@ export function officialPlugins(options: OfficialPluginOptions = {}): OfficialPl 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/testing.test.ts b/packages/plugins/test/testing.test.ts new file mode 100644 index 0000000000..1f847f1aa7 --- /dev/null +++ b/packages/plugins/test/testing.test.ts @@ -0,0 +1,79 @@ +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 }, +}); + +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/shared/src/index.ts b/packages/shared/src/index.ts index 450466563c..6581d77180 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -593,6 +593,7 @@ export * from './extensionPackages.js'; export * from './extensionOAuth.js'; export * from './plugins.js'; export * from './pluginInstallations.js'; +export * from './pluginSdk.js'; export { sqliteTimestampMs } from './sqliteTimestamp.js'; export { AGENT_FAILURE_LINE_MAX_CHARS, agentFailureLine } from './agentFailureLine.js'; diff --git a/packages/shared/src/pluginInstallations.ts b/packages/shared/src/pluginInstallations.ts index f5a755fabb..5965f8f1a8 100644 --- a/packages/shared/src/pluginInstallations.ts +++ b/packages/shared/src/pluginInstallations.ts @@ -81,8 +81,13 @@ export interface PluginView { /** 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'] as const; +export const PLUGIN_AUDIT_ACTIONS = ['install', 'update_settings', 'set_repositories', 'set_connection', 'clear_connection', 'enable', 'disable', 'approve', 'uninstall', 'start_oauth', 'complete_oauth', 'disconnect_oauth', 'set_oauth_client', 'clear_oauth_client', 'handler_attempt', 'handler_call', 'handler_refused'] as const; export type PluginAuditAction = typeof PLUGIN_AUDIT_ACTIONS[number]; export interface PluginDetail extends PluginView { audit: PluginAuditEvent[] } diff --git a/packages/shared/src/pluginSdk.ts b/packages/shared/src/pluginSdk.ts new file mode 100644 index 0000000000..c99ecb34a1 --- /dev/null +++ b/packages/shared/src/pluginSdk.ts @@ -0,0 +1,165 @@ +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. */ +export interface PluginEventInput { kind: 'event'; event: RepositoryEvent } +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 index 51edc0c739..7381efc58f 100644 --- a/packages/shared/src/plugins.ts +++ b/packages/shared/src/plugins.ts @@ -23,6 +23,7 @@ export const PLUGIN_PERMISSIONS = [ 'tasks.read', 'tasks.create', 'issues.read', 'issues.create', 'issues.comment', 'pull_requests.read', 'pull_requests.comment', + 'labels.write', 'repositories.read', 'notifications.send', ] as const; @@ -36,6 +37,7 @@ export const PLUGIN_PERMISSION_DESCRIPTIONS: Record = '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', }; @@ -51,7 +53,7 @@ export const PLUGIN_LIMITS = { } as const; /** Folders of the registry package that can never be plugin ids. */ -export const RESERVED_PLUGIN_IDS = ['src', 'dist', 'test', 'node_modules', 'propr'] as const; +export const 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. */