From c7aad411fddad84bea55b086a5bd48e712f90086 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 19:58:28 +0000 Subject: [PATCH 01/15] Nothing changes here either: the second copy of that wait loop also hit its time limit and was stopped, for the same reason. I won't restart it. The test results were already read straight from the suite log, so the implementation summary and acceptance criteria above still stand. --- docs/docs/architecture/plugin-runtime.md | 226 ++++++++++++++++++ docs/docs/features/plugins.md | 117 ++++++++- docs/sidebars.ts | 1 + docs/static/openapi/propr-api.yaml | 8 + eslint.config.js | 37 +++ packages/api/test/pluginRoutes.test.ts | 3 +- packages/client/src/generated/apiTypes.ts | 14 +- .../migrations/20261114180000_plugin_state.js | 20 ++ .../core/src/extensions/endpointPolicy.ts | 6 +- packages/core/src/plugins/pluginContext.ts | 145 +++++++++++ .../core/src/plugins/pluginHandlerRunner.ts | 117 +++++++++ packages/core/src/plugins/pluginHttp.ts | 177 ++++++++++++++ .../core/src/plugins/pluginProprBackend.ts | 100 ++++++++ .../core/src/plugins/pluginProprClient.ts | 143 +++++++++++ packages/core/src/plugins/pluginStateStore.ts | 70 ++++++ packages/core/src/plugins/pluginStore.ts | 6 +- packages/core/src/queue/taskQueue.types.ts | 2 + packages/core/src/services/taskIntent.ts | 24 +- .../core/src/services/taskIntentIssueRef.ts | 25 ++ packages/core/src/utils/workerStateManager.ts | 6 +- .../src/utils/workerStateManager.types.ts | 5 + .../plugins/fixture-handlers/handlers.ts | 68 ++++++ .../plugins/fixture-handlers/manifest.json | 24 ++ .../plugins/fixture-imports-core/handlers.ts | 12 + .../core/test/fixtures/plugins/registry.json | 6 + .../core/test/helpers/pluginHandlerFixture.ts | 92 +++++++ .../core/test/pluginContributions.test.ts | 3 +- packages/core/test/pluginHandlerLint.test.ts | 57 +++++ .../core/test/pluginHandlerRunner.test.ts | 165 +++++++++++++ packages/core/test/pluginProprClient.test.ts | 151 ++++++++++++ packages/core/test/pluginStore.test.ts | 3 +- packages/core/test/taskJournalEvents.test.ts | 13 + packages/plugins/example-pack/README.md | 3 + packages/plugins/example-pack/handlers.ts | 22 ++ packages/plugins/package.json | 11 +- packages/plugins/src/index.ts | 45 +++- packages/plugins/test/testing.test.ts | 61 +++++ packages/plugins/testing/index.ts | 142 +++++++++++ packages/plugins/tsconfig.handlers.json | 11 + packages/shared/src/index.ts | 1 + packages/shared/src/pluginInstallations.ts | 6 +- packages/shared/src/pluginSdk.ts | 165 +++++++++++++ packages/shared/src/plugins.ts | 4 +- 43 files changed, 2268 insertions(+), 49 deletions(-) create mode 100644 docs/docs/architecture/plugin-runtime.md create mode 100644 packages/core/src/db/migrations/20261114180000_plugin_state.js create mode 100644 packages/core/src/plugins/pluginContext.ts create mode 100644 packages/core/src/plugins/pluginHandlerRunner.ts create mode 100644 packages/core/src/plugins/pluginHttp.ts create mode 100644 packages/core/src/plugins/pluginProprBackend.ts create mode 100644 packages/core/src/plugins/pluginProprClient.ts create mode 100644 packages/core/src/plugins/pluginStateStore.ts create mode 100644 packages/core/src/services/taskIntentIssueRef.ts create mode 100644 packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts create mode 100644 packages/core/test/fixtures/plugins/fixture-handlers/manifest.json create mode 100644 packages/core/test/fixtures/plugins/fixture-imports-core/handlers.ts create mode 100644 packages/core/test/fixtures/plugins/registry.json create mode 100644 packages/core/test/helpers/pluginHandlerFixture.ts create mode 100644 packages/core/test/pluginHandlerLint.test.ts create mode 100644 packages/core/test/pluginHandlerRunner.test.ts create mode 100644 packages/core/test/pluginProprClient.test.ts create mode 100644 packages/plugins/example-pack/handlers.ts create mode 100644 packages/plugins/test/testing.test.ts create mode 100644 packages/plugins/testing/index.ts create mode 100644 packages/plugins/tsconfig.handlers.json create mode 100644 packages/shared/src/pluginSdk.ts diff --git a/docs/docs/architecture/plugin-runtime.md b/docs/docs/architecture/plugin-runtime.md new file mode 100644 index 0000000000..775c8b9950 --- /dev/null +++ b/docs/docs/architecture/plugin-runtime.md @@ -0,0 +1,226 @@ +--- +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). + +A refused call throws `PluginSdkError` with code `permission_denied`, +`repository_not_allowed`, `invalid_input` or `not_found`. 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`: action +`handler_call` or `handler_refused`, actor `plugin:`, and detail +`{ handler, operation, permission, repository?, number?, taskId?, reason? }`. 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, 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. A careless upstream that echoes the +`Authorization` header cannot hand the secret back. + +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. 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 passes the extension output +redaction. That redaction removes the connection credentials used in the run in +every spelling, plus the general secret patterns (GitHub, cloud and API tokens, +`Authorization` values and secret-like assignments) and headers such as +`authorization` and `cookie`. + +## 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. `ctx.propr` calls nobody answered resolve with minimal +placeholders. 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..3f402464aa 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,108 @@ 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. + +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`. +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..e1840a44bd 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,8 @@ components: - disconnect_oauth - set_oauth_client - clear_oauth_client + - handler_call + - handler_refused actor: type: string at: @@ -13320,6 +13325,7 @@ components: - issues.comment - pull_requests.read - pull_requests.comment + - labels.write - repositories.read - notifications.send description: @@ -13506,6 +13512,7 @@ components: - issues.comment - pull_requests.read - pull_requests.comment + - labels.write - repositories.read - notifications.send acknowledgements: @@ -13609,6 +13616,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/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..38f2e6018b 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_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..42d349f848 --- /dev/null +++ b/packages/core/src/plugins/pluginContext.ts @@ -0,0 +1,145 @@ +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 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, type PluginHttpTransport } from './pluginHttp.js'; +import { githubPluginProprBackend } from './pluginProprBackend.js'; +import { pluginActor, scopedProprClient, type PluginProprBackend, type PluginProprCallRecord } from './pluginProprClient.js'; +import { pluginStateStore } 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; + logs: PluginLogLine[]; + /** Redact a value for the caller: connection credentials used in this run and the general secret patterns. */ + redact(value: T): T; +} + +/** Permissions the handler has: granted by the administrator and still asked for by the current manifest. */ +export function effectivePluginPermissions(manifest: PluginManifest, record: PluginInstallationRecord): PluginPermission[] { + return manifest.permissions.filter(permission => record.grantedPermissions.includes(permission)); +} + +/** 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 }; } +} + +async function defaultConnectionSecret(database: Knex, environment: NodeJS.ProcessEnv, pluginId: string, connection: string): Promise { + const { PluginStore } = await import('./pluginStore.js'); + return new PluginStore(database, environment).connectionSecret(pluginId, connection); +} + +/** + * 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[] = []; + const logs: PluginLogLine[] = []; + const redact = (value: T): T => redactExtensionOutput(value, credentials) as T; + 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 = String(message).slice(0, 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 { allowed, ...detail } = call; + await deps.database('plugin_audit').insert({ + plugin_id: pluginId, action: allowed ? 'handler_call' : 'handler_refused', actor: pluginActor(pluginId), at: now(), + detail: JSON.stringify({ handler, ...detail }), + }); + if (!allowed) { + 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'); + } + }; + + 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(deps.database, environment, id, name)))(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, + 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, + propr: liveMethods(scopedProprClient({ + pluginId, grantedPermissions: effectivePluginPermissions(manifest, record), repositories: record.repositories, + backend: deps.backend ?? githubPluginProprBackend({ database: deps.database }), record: recordCall, + })), + http: connection => live(http(connection)), + state: liveMethods(pluginStateStore(deps.database, pluginId, now)), + log: Object.freeze(log), + signal, + }; + return { ctx: Object.freeze(ctx), logs, redact }; +} diff --git a/packages/core/src/plugins/pluginHandlerRunner.ts b/packages/core/src/plugins/pluginHandlerRunner.ts new file mode 100644 index 0000000000..1217d24f86 --- /dev/null +++ b/packages/core/src/plugins/pluginHandlerRunner.ts @@ -0,0 +1,117 @@ +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, 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 message = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(0, ERROR_CHARS) || 'The handler failed'; + +/** 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 logs: PluginLogLine[] = []; + let redact = (value: T): T => value; + 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')); + const result: PluginHandlerRun = { ...run, error: run.error === null ? null : redact(run.error), durationMs, logs: [...logs] }; + 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); + logs = built.logs; redact = built.redact; + 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: error instanceof HandlerTimeout, 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..76dc384830 --- /dev/null +++ b/packages/core/src/plugins/pluginHttp.ts @@ -0,0 +1,177 @@ +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; +} +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; + lookup?: ExtensionLookup; + transport?: PluginHttpTransport; + /** Every request decision (`allowed` false when refused), for the handler log. */ + onDecision?: (decision: { host: string; allowed: boolean; reason?: string }) => void; +} + +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 }); + +/** Every spelling of a credential (alone and as the header value sent) a response could echo back. */ +function credentialSpellings(secret: string): string[] { + const encodings = (value: string) => [value, Buffer.from(value).toString('base64'), Buffer.from(value).toString('base64url'), Buffer.from(value).toString('hex'), encodeURIComponent(value)]; + // Longest first: the header value's encodings are not made of the secret's. + return [...new Set([...encodings(`Bearer ${secret}`), ...encodings(secret)])].filter(Boolean).sort((a, b) => b.length - a.length); +} +const scrub = (text: string, spellings: readonly string[]) => spellings.reduce((value, spelling) => value.split(spelling).join('[REDACTED]'), text); + +/** 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)); + 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); + outgoing.on('close', () => clearTimeout(timer)); + outgoing.on('error', () => failed()); + outgoing.end(request.body); +}); + +function bufferedResponse(url: URL, response: PluginHttpTransportResponse, spellings: readonly string[]): PluginHttpResponse { + const text = scrub(response.body.toString('utf8'), spellings); + const headers = Object.fromEntries(Object.entries(response.headers).map(([name, value]) => [name.toLowerCase(), scrub(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 { address, family } = await resolvePublic(host, lookup); + credential ??= options.credential ? options.credential() : Promise.resolve(null); + const secret = await credential; + if (secret) headers.authorization = `Bearer ${secret}`; + options.onDecision?.({ host, allowed: true }); + const response = await transport({ url, address, family, method, headers, body, timeoutMs: PLUGIN_HTTP_LIMITS.timeoutMs, maxResponseBytes: PLUGIN_HTTP_LIMITS.responseBytes }); + 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..4d3cc8005d --- /dev/null +++ b/packages/core/src/plugins/pluginProprBackend.ts @@ -0,0 +1,100 @@ +import type { Knex } from 'knex'; +import type { PluginIssueSummary, PluginPullRequestSummary, PluginTaskSummary, 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 { PluginProprBackend } from './pluginProprClient.js'; + +/** 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 the issue queue, as the task API does. */ + enqueueIssueTask?: (data: IssueJobData, jobId: string) => Promise; +} + +type GitHubIssue = { + number: number; title: string; state: string; html_url: string; labels?: Array; + user?: { login?: string } | null; created_at: string; updated_at: string; +}; +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 defaultEnqueue(data: IssueJobData, jobId: string): 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'); + const queue = await getIssueQueue(); + 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 enqueue = options.enqueueIssueTask ?? defaultEnqueue; + const request = async (route: string, parameters: Record) => (await (await github()).request(route, parameters)).data as T; + return { + async getIssue(repository, number) { + return issueSummary(repository, await request('GET /repos/{owner}/{repo}/issues/{issue_number}', { ...split(repository), issue_number: number })); + }, + async getPullRequest(repository, number): Promise { + const pull = await request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { ...split(repository), pull_number: number }); + 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 }); + 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}`; + await enqueue({ repoOwner: owner, repoName: repo, number: issueNumber, requestedBy: attribution.requestedBy, correlationId: generateCorrelationId() }, jobId); + return { repository, issueNumber, jobId }; + }, + async comment(repository, number, body) { + const created = await request<{ id: number; html_url: string }>('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', { ...split(repository), issue_number: number, body }); + 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] }); + }, + async removeLabel(repository, number, label) { + await request('DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}', { ...split(repository), issue_number: number, name: label }); + }, + }; +} diff --git a/packages/core/src/plugins/pluginProprClient.ts b/packages/core/src/plugins/pluginProprClient.ts new file mode 100644 index 0000000000..6173d79869 --- /dev/null +++ b/packages/core/src/plugins/pluginProprClient.ts @@ -0,0 +1,143 @@ +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(repository: string, number: number, body: string): Promise; + addLabel(repository: string, number: number, label: string): Promise; + removeLabel(repository: string, number: number, label: string): Promise; +} + +/** One `ctx.propr` call as recorded in the plugin audit trail. */ +export interface PluginProprCallRecord { + operation: PluginProprOperation; permission: PluginPermission; allowed: boolean; + repository?: string; number?: number; taskId?: string; + /** Why the call was refused or failed: a `PluginSdkError` code. */ + reason?: string; +} + +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 failure to record fails the call. */ + record: (call: PluginProprCallRecord) => Promise; +} + +/** 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()); + + async function call(operation: PluginProprOperation, target: () => Target, run: (checked: Target) => Promise): Promise { + const permission = PLUGIN_PROPR_OPERATIONS[operation]; + let checked: Target = {}; + let result: T; + 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}`); + 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 options.record({ operation, permission, allowed: false, ...checked, reason: refused.code }); + throw refused; + } + await options.record({ operation, permission, allowed: true, ...checked }); + 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'); + 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)))), + 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)))), + 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..3097bfc741 --- /dev/null +++ b/packages/core/src/plugins/pluginStateStore.ts @@ -0,0 +1,70 @@ +import type { Knex } from 'knex'; +import { PLUGIN_STATE_LIMITS, PluginSdkError, type PluginJsonValue, 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); +} + +/** + * `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. + */ +export function pluginStateStore(database: Knex, pluginId: string, now: () => number = Date.now): PluginStateStore { + const rows = () => database(PLUGIN_STATE_TABLE).where({ plugin_id: pluginId }); + return { + async get(key: string): Promise { + const row = await rows().where({ state_key: checkKey(key) }).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 database.transaction(async trx => { + // The other keys' count and size: a rewrite of a key replaces its own bytes. + const totals = await trx(PLUGIN_STATE_TABLE).where({ plugin_id: pluginId }).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; + 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 { + return (await rows().where({ state_key: checkKey(key) }).delete()) > 0; + }, + async keys(prefix = ''): Promise { + if (typeof prefix !== 'string') throw new PluginSdkError('invalid_input', 'The key prefix must be text'); + const list = await rows().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..e9a1e7f510 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,15 @@ 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(); + const state = await deletePluginState(trx, pluginId); await trx('plugin_installations').where({ plugin_id: pluginId }).delete(); - return { action: 'uninstall', detail: { version: row.manifest_version } }; + 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 }; + } +} + +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..ecbb8bc2d5 --- /dev/null +++ b/packages/core/test/helpers/pluginHandlerFixture.ts @@ -0,0 +1,92 @@ +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. */ +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 }, + 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/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..436675eaa7 --- /dev/null +++ b/packages/core/test/pluginHandlerRunner.test.ts @@ -0,0 +1,165 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { 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('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 }; + 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/); + assert.equal(JSON.stringify(await f.database('plugin_audit').select()).includes(SENTINEL), 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..2b2bcf357e --- /dev/null +++ b/packages/core/test/pluginProprClient.test.ts @@ -0,0 +1,151 @@ +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 } 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 } 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_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.action === 'handler_call' && a.actor === `plugin:${FIXTURE_ID}` && a.handler === 'callPropr')); + assert.deepEqual(audit.map(a => [a.operation, a.permission, a.repository, a.number ?? null]), [ + ['getIssue', 'issues.read', 'owner/allowed', 3], + ['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], + ]); + // 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 }); + assert.deepEqual(requests.map(r => r.route), ['GET /repos/{owner}/{repo}/issues/{issue_number}', 'POST /repos/{owner}/{repo}/issues/{issue_number}/comments']); + assert.equal(requests[1].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' }); +}); 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..c9c9095f12 --- /dev/null +++ b/packages/plugins/test/testing.test.ts @@ -0,0 +1,61 @@ +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 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..a3ecb39298 --- /dev/null +++ b/packages/plugins/testing/index.ts @@ -0,0 +1,142 @@ +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 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 (limit && typeof repository === 'string' && !limit.includes(repository.toLowerCase())) throw new PluginSdkError('repository_not_allowed', `This plugin is not enabled for ${repository}`); + calls.push({ operation, args: structuredClone(args) }); + const answer = options.propr?.[operation] as ((...a: unknown[]) => unknown) | undefined; + return answer ? answer(...args) : (placeholders[operation] as (...a: unknown[]) => unknown)(...args); + }])) 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..01081758db 100644 --- a/packages/shared/src/pluginInstallations.ts +++ b/packages/shared/src/pluginInstallations.ts @@ -81,8 +81,12 @@ 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_call` + * and `handler_refused` 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_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. */ From 85d63e852429a6a63d7e2cd5f0c88666e1b225ef Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 20:27:51 +0000 Subject: [PATCH 02/15] feat(ai): I've fixed all four findings and added regression tests for each. To check the tests, I ran the new ones against the original source files: all 7 failed there, and all 48 plugin tests pass with the fix. Core typecheck and zero-warning lint on `src/plugins` pass too. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I've fixed all four findings and added regression tests for each. To check the tests, I ran the new ones against the original source files: all 7 failed there, and all 48 plugin tests pass with the fix. Core typecheck and zero-warning lint on `src/plugins` pass too. **F1 – comment permission vs. target type.** GitHub's issue endpoints accept pull-request numbers too, so the method name alone didn't prove what a number was. The backend's `comment` now takes the target kind (`'issue'` or `'pull_request'`). It reads the number first and throws `not_found` without posting if the kind doesn't match. `commentOnIssue` and `commentOnPullRequest` each pass their own kind. - **Same defect elsewhere:** `getIssue` had the same gap, letting `issues.read` read a pull request, and now refuses one too. - **Tests:** both substitutions are covered, directly and through a handler, and nothing is posted in either case. One existing test changed: it now expects the extra lookup request before the comment is posted. **F2 – JSON-escaped credentials.** The list of credential spellings to scrub now includes the JSON-escaped form, plus a variant with `/` escaped as `\/`. A JSON body is also checked after decoding, so an upstream can't spell the credential back with `\uXXXX` or `\"`. If a decoded string held it, `text()` and `json()` return the body re-serialised with it replaced; a clean body is returned unchanged. A body nested too deeply to check is refused rather than returned. - **Tests:** a credential containing a quote, a backslash and a `/`, echoed four ways: `JSON.stringify`, all-`\u` escapes, `\/` escapes, and as an object key. A non-JSON page with the escaped credential embedded is covered too. **F3 – HTTP requests after timeout.** The handler's abort signal now reaches `ctx.http`. The DNS lookup, credential read and transport call all stop as soon as the run ends, and the signal is checked again before sending. The default transport cancels an in-flight request on abort. - **Same defect elsewhere:** the default `ctx.propr` backend had the same gap between fetching the GitHub App token and sending. It now gets the signal too and checks it after the token arrives and before queueing a task. - **Tests:** DNS held until after the timeout, the credential held until after the timeout, and an in-flight request getting the abort. In each case nothing is sent. **F4 – state recreated after uninstall.** Inside its transaction, a `ctx.state.set` now requires the installation to still exist with the same `installed_at` the run started with, so a reinstall under the same id doesn't count. Uninstall now deletes the installation row before the state rows. Either the write commits first and uninstall deletes its row, or the write finds the installation gone and throws `unavailable`. - On Postgres the write holds a `FOR SHARE` lock on that row until it commits. Our tests use SQLite, which runs one write transaction at a time, so the Postgres locking path itself hasn't been run. - **Tests:** a handler suspended mid-run resumes after uninstall, and after uninstall plus reinstall. Its write fails and no state row exists. - One existing test ran example-pack from a record that was never installed. It now inserts a matching installation row first. I updated `docs/docs/architecture/plugin-runtime.md` for each change. Nothing here is visible in the UI, so there are no previews. The new tests are in `packages/core/test/pluginHandlerLifecycle.test.ts`, plus additions to `pluginProprClient.test.ts`. Two things I noticed but didn't change, since they're outside these findings: - `createTask(repository, issueNumber)` doesn't check that the number is an issue rather than a pull request. It only needs one permission, so it can't be used to get around one. - A handler that is still running when its plugin is disabled, but not uninstalled, can still write state until it finishes or times out. PR: #3573 Comment by: @propr-ultrafix (ID: 0) Model: Claude Opus 5.5 --- docs/docs/architecture/plugin-runtime.md | 28 ++++- packages/core/src/plugins/pluginContext.ts | 7 +- packages/core/src/plugins/pluginHttp.ts | 78 +++++++++++- .../core/src/plugins/pluginProprBackend.ts | 36 +++++- .../core/src/plugins/pluginProprClient.ts | 10 +- packages/core/src/plugins/pluginStateStore.ts | 29 ++++- packages/core/src/plugins/pluginStore.ts | 3 +- .../plugins/fixture-handlers/handlers.ts | 27 +++++ .../core/test/pluginHandlerLifecycle.test.ts | 111 ++++++++++++++++++ .../core/test/pluginHandlerRunner.test.ts | 2 + packages/core/test/pluginProprClient.test.ts | 75 +++++++++++- 11 files changed, 379 insertions(+), 27 deletions(-) create mode 100644 packages/core/test/pluginHandlerLifecycle.test.ts diff --git a/docs/docs/architecture/plugin-runtime.md b/docs/docs/architecture/plugin-runtime.md index 775c8b9950..39c2deda3d 100644 --- a/docs/docs/architecture/plugin-runtime.md +++ b/docs/docs/architecture/plugin-runtime.md @@ -94,6 +94,11 @@ Every call is checked before it reaches the backend: - **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. A refused call throws `PluginSdkError` with code `permission_denied`, `repository_not_allowed`, `invalid_input` or `not_found`. It is logged as a @@ -141,11 +146,20 @@ 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, 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. A careless upstream that echoes the +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. 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 careless upstream that echoes the `Authorization` header cannot hand the secret back. +**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. @@ -171,7 +185,13 @@ 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. Disabling keeps them. The uninstall's audit entry records how many +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. diff --git a/packages/core/src/plugins/pluginContext.ts b/packages/core/src/plugins/pluginContext.ts index 42d349f848..2eef5d751d 100644 --- a/packages/core/src/plugins/pluginContext.ts +++ b/packages/core/src/plugins/pluginContext.ts @@ -116,7 +116,7 @@ export function buildPluginContext(target: PluginInstallationTarget, deps: Plugi }; } return createPluginHttp({ - outboundHosts: manifest.outboundHosts, credential, lookup: deps.lookup, transport: deps.transport, + outboundHosts: manifest.outboundHosts, credential, lookup: deps.lookup, transport: deps.transport, signal, onDecision: ({ host, allowed, reason }) => write(allowed ? 'debug' : 'warn', allowed ? `ctx.http request to ${host}` : `ctx.http request to ${host} refused: ${reason}`, { host, ...(connection ? { connection } : {}) }), }); }; @@ -134,10 +134,11 @@ export function buildPluginContext(target: PluginInstallationTarget, deps: Plugi repositories: record.repositories ? [...record.repositories] : null, propr: liveMethods(scopedProprClient({ pluginId, grantedPermissions: effectivePluginPermissions(manifest, record), repositories: record.repositories, - backend: deps.backend ?? githubPluginProprBackend({ database: deps.database }), record: recordCall, + backend: deps.backend ?? githubPluginProprBackend({ database: deps.database, signal }), record: recordCall, })), http: connection => live(http(connection)), - state: liveMethods(pluginStateStore(deps.database, pluginId, now)), + // Writes are tied to this installation: once it is uninstalled (or replaced by a reinstall) they are refused. + state: liveMethods(pluginStateStore(deps.database, pluginId, now, { installedAt: record.installedAt, signal })), log: Object.freeze(log), signal, }; diff --git a/packages/core/src/plugins/pluginHttp.ts b/packages/core/src/plugins/pluginHttp.ts index 76dc384830..be24753b02 100644 --- a/packages/core/src/plugins/pluginHttp.ts +++ b/packages/core/src/plugins/pluginHttp.ts @@ -12,6 +12,8 @@ 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`). */ @@ -26,6 +28,8 @@ export interface PluginHttpOptions { 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']); @@ -38,12 +42,65 @@ const defaultLookup: ExtensionLookup = host => dns.lookup(host, { all: true, ver /** Every spelling of a credential (alone and as the header value sent) a response could echo back. */ function credentialSpellings(secret: string): string[] { - const encodings = (value: string) => [value, Buffer.from(value).toString('base64'), Buffer.from(value).toString('base64url'), Buffer.from(value).toString('hex'), encodeURIComponent(value)]; + const encodings = (value: string) => { + // Inside a JSON string: quotes, backslashes and control characters escaped, `/` optionally too. + const json = JSON.stringify(value).slice(1, -1); + return [value, json, json.split('/').join('\\/'), Buffer.from(value).toString('base64'), Buffer.from(value).toString('base64url'), Buffer.from(value).toString('hex'), encodeURIComponent(value)]; + }; // Longest first: the header value's encodings are not made of the secret's. return [...new Set([...encodings(`Bearer ${secret}`), ...encodings(secret)])].filter(Boolean).sort((a, b) => b.length - a.length); } const scrub = (text: string, spellings: readonly string[]) => spellings.reduce((value, spelling) => value.split(spelling).join('[REDACTED]'), text); +/** Scrub every decoded string (keys included) of a parsed JSON value; `changed` when one held a credential. */ +function scrubJson(value: unknown, spellings: readonly string[]): { value: unknown; changed: boolean } { + let changed = false; + const clean = (text: string) => { const scrubbed = scrub(text, spellings); 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. 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. + */ +function scrubbedBody(raw: string, spellings: readonly string[]): string { + const text = scrub(raw, spellings); + if (!spellings.length) return text; + let parsed: unknown; + try { parsed = JSON.parse(text); } catch { return text; } + try { + const result = scrubJson(parsed, spellings); + return result.changed ? JSON.stringify(result.value) : text; + } 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'); + } +} + +/** 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'); @@ -104,6 +161,7 @@ function requestBody(init: PluginHttpRequestInit, headers: Record 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, @@ -123,13 +181,16 @@ export const httpsPluginTransport: PluginHttpTransport = request => new Promise( }); }); const timer = setTimeout(() => { outgoing.destroy(); failed(`Request to ${host} timed out`); }, request.timeoutMs); - outgoing.on('close', () => clearTimeout(timer)); + // 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 string[]): PluginHttpResponse { - const text = scrub(response.body.toString('utf8'), spellings); + const text = scrubbedBody(response.body.toString('utf8'), spellings); const headers = Object.fromEntries(Object.entries(response.headers).map(([name, value]) => [name.toLowerCase(), scrub(value, spellings)])); return { status: response.status, ok: response.status >= 200 && response.status < 300, url: url.toString(), headers, @@ -161,12 +222,17 @@ export function createPluginHttp(options: PluginHttpOptions): PluginHttpFetch { 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 { address, family } = await resolvePublic(host, lookup); + 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 credential; + const secret = await untilEnded(credential, signal); if (secret) headers.authorization = `Bearer ${secret}`; + // 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 transport({ url, address, family, method, headers, body, timeoutMs: PLUGIN_HTTP_LIMITS.timeoutMs, maxResponseBytes: PLUGIN_HTTP_LIMITS.responseBytes }); + 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`); diff --git a/packages/core/src/plugins/pluginProprBackend.ts b/packages/core/src/plugins/pluginProprBackend.ts index 4d3cc8005d..f6f766dfa7 100644 --- a/packages/core/src/plugins/pluginProprBackend.ts +++ b/packages/core/src/plugins/pluginProprBackend.ts @@ -1,9 +1,9 @@ import type { Knex } from 'knex'; -import type { PluginIssueSummary, PluginPullRequestSummary, PluginTaskSummary, RepositoryEventDomain } from '@propr/shared'; +import { PluginSdkError, type PluginIssueSummary, 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 { PluginProprBackend } from './pluginProprClient.js'; +import type { PluginCommentTarget, PluginProprBackend } from './pluginProprClient.js'; /** The part of an authenticated Octokit the backend uses. */ export interface PluginGitHubClient { @@ -15,11 +15,15 @@ export interface GitHubPluginBackendOptions { github?: () => Promise; /** Queue an issue task job; defaults to the issue queue, as the task API does. */ enqueueIssueTask?: (data: IssueJobData, jobId: string) => Promise; + /** The handler run's signal: once aborted no further GitHub request or queued task starts, and requests in flight are cancelled. */ + signal?: AbortSignal; } 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 } }; @@ -56,10 +60,30 @@ export function githubPluginProprBackend(options: GitHubPluginBackendOptions): P const { database } = options; const github = options.github ?? defaultGitHub; const enqueue = options.enqueueIssueTask ?? defaultEnqueue; - const request = async (route: string, parameters: Record) => (await (await github()).request(route, parameters)).data as T; + const { signal } = options; + const live = () => { if (signal?.aborted) throw new PluginSdkError('unavailable', 'This handler run has ended'); }; + const request = async (route: string, parameters: Record) => { + live(); + const client = await github(); + // The run may have ended while the App token was fetched: nothing is sent for an ended handler. + live(); + return (await client.request(route, { ...parameters, ...(signal ? { request: { signal } } : {}) })).data as T; + }; + /** + * 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. + */ + const issueOfKind = async (repository: string, number: number, kind: PluginCommentTarget): Promise => { + const issue = await request('GET /repos/{owner}/{repo}/issues/{issue_number}', { ...split(repository), issue_number: number }); + 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 request('GET /repos/{owner}/{repo}/issues/{issue_number}', { ...split(repository), issue_number: number })); + return issueSummary(repository, await issueOfKind(repository, number, 'issue')); }, async getPullRequest(repository, number): Promise { const pull = await request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { ...split(repository), pull_number: number }); @@ -83,10 +107,12 @@ export function githubPluginProprBackend(options: GitHubPluginBackendOptions): P 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); return { repository, issueNumber, jobId }; }, - async comment(repository, number, body) { + async comment(repository, number, body, target) { + await issueOfKind(repository, number, target); const created = await request<{ id: number; html_url: string }>('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', { ...split(repository), issue_number: number, body }); return { repository, number, id: created.id, url: created.html_url }; }, diff --git a/packages/core/src/plugins/pluginProprClient.ts b/packages/core/src/plugins/pluginProprClient.ts index 6173d79869..3838aa2577 100644 --- a/packages/core/src/plugins/pluginProprClient.ts +++ b/packages/core/src/plugins/pluginProprClient.ts @@ -19,11 +19,15 @@ export interface PluginProprBackend { 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(repository: string, number: number, body: 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'; + /** One `ctx.propr` call as recorded in the plugin audit trail. */ export interface PluginProprCallRecord { operation: PluginProprOperation; permission: PluginPermission; allowed: boolean; @@ -132,9 +136,9 @@ export function scopedProprClient(options: ScopedProprOptions): PluginProprClien 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)))), + 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)))), + 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') }), diff --git a/packages/core/src/plugins/pluginStateStore.ts b/packages/core/src/plugins/pluginStateStore.ts index 3097bfc741..932819fe25 100644 --- a/packages/core/src/plugins/pluginStateStore.ts +++ b/packages/core/src/plugins/pluginStateStore.ts @@ -25,12 +25,36 @@ function serialize(value: unknown): string { return JSON.stringify(value); } +export interface PluginStateWriteGuard { + /** The installation the writes belong to (its `installed_at`); a write is refused once it is uninstalled or replaced. */ + installedAt: number; + /** The handler run's signal: a write that has not committed when the run ends is refused. */ + signal?: AbortSignal; +} + +const isSqlite = (database: Knex) => ['better-sqlite3', 'sqlite3'].includes(String(database.client.config.client)); + +/** + * Inside a state write'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 a write either commits + * first and is deleted with the rest, or finds the installation gone. + * SQLite runs write transactions one at a time. + */ +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') as { installed_at: number | string } | undefined; + if (!row || Number(row.installed_at) !== guard.installedAt) throw new PluginSdkError('unavailable', 'The plugin is no longer installed'); + if (guard.signal?.aborted) throw new PluginSdkError('unavailable', 'This handler run has ended'); +} + /** * `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. + * a limit throws `state_limit` and changes nothing. With a `guard`, a write + * also requires its installation to still exist. */ -export function pluginStateStore(database: Knex, pluginId: string, now: () => number = Date.now): PluginStateStore { +export function pluginStateStore(database: Knex, pluginId: string, now: () => number = Date.now, guard?: PluginStateWriteGuard): PluginStateStore { const rows = () => database(PLUGIN_STATE_TABLE).where({ plugin_id: pluginId }); return { async get(key: string): Promise { @@ -43,6 +67,7 @@ export function pluginStateStore(database: Knex, pluginId: string, now: () => nu 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 database.transaction(async trx => { + if (guard) await requireInstallation(trx, pluginId, guard); // The other keys' count and size: a rewrite of a key replaces its own bytes. const totals = await trx(PLUGIN_STATE_TABLE).where({ plugin_id: pluginId }).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; diff --git a/packages/core/src/plugins/pluginStore.ts b/packages/core/src/plugins/pluginStore.ts index e9a1e7f510..8abcd9f5dc 100644 --- a/packages/core/src/plugins/pluginStore.ts +++ b/packages/core/src/plugins/pluginStore.ts @@ -313,8 +313,9 @@ export class PluginStore { 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(); - const state = await deletePluginState(trx, pluginId); + // The installation row first: a concurrent ctx.state write holds it until it commits (so its row is deleted below) or finds it gone. await trx('plugin_installations').where({ plugin_id: pluginId }).delete(); + const state = await deletePluginState(trx, pluginId); return { action: 'uninstall', detail: { version: row.manifest_version, stateKeys: state } }; }); } diff --git a/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts b/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts index 83590f8c6b..7e41b436d4 100644 --- a/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts +++ b/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts @@ -48,6 +48,33 @@ export async function fetchUrl(input: PluginHandlerInput, ctx: PluginContext) { } } +/** + * 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; } })(); + const json = await response.json(); + 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)); + return { ok: true, textHasProbe: text.includes(probe), parsedHasProbe: decoded(parsed), jsonHasProbe: decoded(json), headersHaveProbe: Object.values(response.headers).some(v => v.includes(probe)) }; + } catch (error) { + return { ok: false, code: (error as { code?: string }).code ?? null, message: (error as Error).message }; + } +} + +/** Wait on a ctx.propr read (the test holds it), then write state: a handler still running across an uninstall. */ +export async function suspendThenSet(input: PluginHandlerInput, ctx: PluginContext) { + const { key } = payload(input) as { key: string }; + await ctx.propr.getIssue('owner/allowed', 3).catch(() => undefined); + try { 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 }; + } +} + 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 { diff --git a/packages/core/test/pluginHandlerLifecycle.test.ts b/packages/core/test/pluginHandlerLifecycle.test.ts new file mode 100644 index 0000000000..751fff986e --- /dev/null +++ b/packages/core/test/pluginHandlerLifecycle.test.ts @@ -0,0 +1,111 @@ +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, type PluginHttpTransport, type PluginHttpTransportRequest } from '../src/plugins/pluginHttp.js'; +import { PluginStore } from '../src/plugins/pluginStore.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 }; + + // 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); +}); + +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'); +}); + +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}`); + } +}); diff --git a/packages/core/test/pluginHandlerRunner.test.ts b/packages/core/test/pluginHandlerRunner.test.ts index 436675eaa7..25a8d58271 100644 --- a/packages/core/test/pluginHandlerRunner.test.ts +++ b/packages/core/test/pluginHandlerRunner.test.ts @@ -61,6 +61,8 @@ test('the official registry loads example-pack handlers through the plugin regis 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 }) }); diff --git a/packages/core/test/pluginProprClient.test.ts b/packages/core/test/pluginProprClient.test.ts index 2b2bcf357e..900f31c134 100644 --- a/packages/core/test/pluginProprClient.test.ts +++ b/packages/core/test/pluginProprClient.test.ts @@ -3,7 +3,7 @@ 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 } from '../src/plugins/pluginProprClient.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 } from './helpers/pluginHandlerFixture.js'; @@ -141,11 +141,80 @@ test('the GitHub backend queues the task with the plugin as requester, which the 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 }); - assert.deepEqual(requests.map(r => r.route), ['GET /repos/{owner}/{repo}/issues/{issue_number}', 'POST /repos/{owner}/{repo}/issues/{issue_number}/comments']); - assert.equal(requests[1].parameters.body, `hi\n\n${pluginAttributionMarker(FIXTURE_ID)}`); + // 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.allowed, r.reason]), [['commentOnIssue', false, '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, []); +}); From d30665f221e2b994c2032c85da611a7f7639fd22 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 21:06:41 +0000 Subject: [PATCH 03/15] feat(ai): I fixed all three findings (F5, F6, F7), each with a regression test. When I ran the new tests against the original source files, all three failed; with the fix, the plugin test files pass. Core typecheck passes, and zero-warning lint passes on `src/plugins`. Lint only reported "file ignored" for the test files, because core lint covers `src/` only. I fixed all three findings (F5, F6, F7), each with a regression test. When I ran the new tests against the original source files, all three failed; with the fix, the plugin test files pass. Core typecheck passes, and zero-warning lint passes on `src/plugins`. Lint only reported "file ignored" for the test files, because core lint covers `src/` only. **F5: every state operation is now tied to the installation** (`pluginStateStore.ts`) - `get`, `set`, `delete` and `keys` now all run inside one transaction that first checks the installation is still the same one, by comparing `installed_at`. On Postgres it also holds a shared lock on the installation row until commit, so an uninstall can't overtake an operation in progress. - An old handler that resumes after an uninstall and reinstall now gets `unavailable` from all four methods. It can no longer read, list or delete the new installation's state. - Test: for each of `get`, `keys` and `delete`, a held handler resumes after uninstall plus reinstall, gets `unavailable`, and the new installation's value is still there. The `suspendThenSet` fixture handler gained an optional `op` input so this test could reuse it. **F7: state cancellation is checked again before and after each change** - `set` checks the signal again after the count/size query and before the insert. - Every operation also checks the signal just before its transaction commits. If the run ended during the insert or delete, the change is rolled back. - Test: I abort the signal at the moment the count/size query is sent, then do the same during the insert and during the delete. Each call is refused, and the stored value and key list stay as they were. **F6: no task is queued after cancellation** (`pluginProprBackend.ts`) - `createTask` now passes the run's signal to the enqueue function. - The default enqueue loads the queue, checks the signal, and only then calls `queue.add`. - To make the test possible, there is a new optional `issueQueue` setting so a test can supply the queue. A custom `enqueueIssueTask` now also receives the signal as a third argument. - Test: queue initialization is held until after the abort; no job is added and the call is refused with `unavailable`. A live run still queues its job. I checked the backend's other GitHub write calls for the same gap and found none: every request already rechecks the signal after the App token is fetched. I found nothing separate to report outside these three findings, and I made no previews because nothing here is visible to a user. PR: #3573 Comment by: @propr-ultrafix (ID: 0) Model: Claude Opus 5.5 --- packages/core/src/plugins/pluginContext.ts | 2 +- .../core/src/plugins/pluginProprBackend.ts | 31 +++++++--- packages/core/src/plugins/pluginStateStore.ts | 49 ++++++++++------ .../plugins/fixture-handlers/handlers.ts | 15 ++++- .../core/test/pluginHandlerLifecycle.test.ts | 56 +++++++++++++++++++ packages/core/test/pluginProprClient.test.ts | 24 ++++++++ 6 files changed, 149 insertions(+), 28 deletions(-) diff --git a/packages/core/src/plugins/pluginContext.ts b/packages/core/src/plugins/pluginContext.ts index 2eef5d751d..690ae4cfc6 100644 --- a/packages/core/src/plugins/pluginContext.ts +++ b/packages/core/src/plugins/pluginContext.ts @@ -137,7 +137,7 @@ export function buildPluginContext(target: PluginInstallationTarget, deps: Plugi backend: deps.backend ?? githubPluginProprBackend({ database: deps.database, signal }), record: recordCall, })), http: connection => live(http(connection)), - // Writes are tied to this installation: once it is uninstalled (or replaced by a reinstall) they are refused. + // 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, diff --git a/packages/core/src/plugins/pluginProprBackend.ts b/packages/core/src/plugins/pluginProprBackend.ts index f6f766dfa7..362f88a193 100644 --- a/packages/core/src/plugins/pluginProprBackend.ts +++ b/packages/core/src/plugins/pluginProprBackend.ts @@ -5,6 +5,11 @@ import { generateCorrelationId } from '../utils/logger.js'; import type { IssueJobData } from '../queue/taskQueue.types.js'; import type { PluginCommentTarget, PluginProprBackend } 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 }>; @@ -13,8 +18,13 @@ export interface GitHubPluginBackendOptions { database: Knex; /** The ProPR GitHub App client; defaults to the configured one. */ github?: () => Promise; - /** Queue an issue task job; defaults to the issue queue, as the task API does. */ - enqueueIssueTask?: (data: IssueJobData, jobId: string) => 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; } @@ -38,11 +48,18 @@ async function defaultGitHub(): Promise { const { getAuthenticatedOctokit } = await import('../auth/githubAuth.js'); return await getAuthenticatedOctokit() as unknown as PluginGitHubClient; } -async function defaultEnqueue(data: IssueJobData, jobId: string): Promise { +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'); - const queue = await getIssueQueue(); - await queue.add('processGitHubIssue', data, { jobId, attempts: 3, backoff: { type: 'exponential', delay: 2000 }, removeOnComplete: true, removeOnFail: true }); + return await getIssueQueue() as unknown as PluginIssueQueue; +} +function queueEnqueue(issueQueue: () => Promise) { + return async (data: IssueJobData, jobId: string, signal?: AbortSignal): Promise => { + const queue = await issueQueue(); + // The run may have ended while the queue was initialized: no job is added for an ended handler. + if (signal?.aborted) throw new PluginSdkError('unavailable', 'This handler run has ended'); + await queue.add('processGitHubIssue', data, { jobId, attempts: 3, backoff: { type: 'exponential', delay: 2000 }, removeOnComplete: true, removeOnFail: true }); + }; } /** The latest recorded state of a task. */ @@ -59,7 +76,7 @@ async function taskState(database: Knex, taskId: string): Promise export function githubPluginProprBackend(options: GitHubPluginBackendOptions): PluginProprBackend { const { database } = options; const github = options.github ?? defaultGitHub; - const enqueue = options.enqueueIssueTask ?? defaultEnqueue; + const enqueue = options.enqueueIssueTask ?? queueEnqueue(options.issueQueue ?? defaultIssueQueue); const { signal } = options; const live = () => { if (signal?.aborted) throw new PluginSdkError('unavailable', 'This handler run has ended'); }; const request = async (route: string, parameters: Record) => { @@ -108,7 +125,7 @@ export function githubPluginProprBackend(options: GitHubPluginBackendOptions): P 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); + await enqueue({ repoOwner: owner, repoName: repo, number: issueNumber, requestedBy: attribution.requestedBy, correlationId: generateCorrelationId() }, jobId, signal); return { repository, issueNumber, jobId }; }, async comment(repository, number, body, target) { diff --git a/packages/core/src/plugins/pluginStateStore.ts b/packages/core/src/plugins/pluginStateStore.ts index 932819fe25..d97a4d3ebf 100644 --- a/packages/core/src/plugins/pluginStateStore.ts +++ b/packages/core/src/plugins/pluginStateStore.ts @@ -26,39 +26,53 @@ function serialize(value: unknown): string { } export interface PluginStateWriteGuard { - /** The installation the writes belong to (its `installed_at`); a write is refused once it is uninstalled or replaced. */ + /** 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: a write that has not committed when the run ends is refused. */ + /** 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'); +} + /** - * Inside a state write'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 a write either commits - * first and is deleted with the rest, or finds the installation gone. - * SQLite runs write transactions one at a time. + * 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. */ 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') as { installed_at: number | string } | undefined; if (!row || Number(row.installed_at) !== guard.installedAt) throw new PluginSdkError('unavailable', 'The plugin is no longer installed'); - if (guard.signal?.aborted) throw new PluginSdkError('unavailable', 'This handler run has ended'); + requireLive(guard); } /** * `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`, a write - * also requires its installation to still exist. + * 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 rows = () => database(PLUGIN_STATE_TABLE).where({ plugin_id: pluginId }); + 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 row = await rows().where({ state_key: checkKey(key) }).first('value'); + 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 { @@ -66,11 +80,11 @@ export function pluginStateStore(database: Knex, pluginId: string, now: () => nu 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 database.transaction(async trx => { - if (guard) await requireInstallation(trx, pluginId, guard); + await fenced(async trx => { // The other keys' count and size: a rewrite of a key replaces its own bytes. - const totals = await trx(PLUGIN_STATE_TABLE).where({ plugin_id: pluginId }).whereNot({ state_key: stateKey }) + 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`); @@ -79,11 +93,12 @@ export function pluginStateStore(database: Knex, pluginId: string, now: () => nu }); }, async delete(key: string): Promise { - return (await rows().where({ state_key: checkKey(key) }).delete()) > 0; + 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 rows().orderBy('state_key').pluck('state_key') as string[]; + const list = await fenced(trx => rows(trx).orderBy('state_key').pluck('state_key')) as string[]; return list.filter(key => key.startsWith(prefix)); }, }; diff --git a/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts b/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts index 7e41b436d4..3e43d00b84 100644 --- a/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts +++ b/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts @@ -66,11 +66,20 @@ export async function fetchProbe(input: PluginHandlerInput, ctx: PluginContext) } } -/** Wait on a ctx.propr read (the test holds it), then write state: a handler still running across an uninstall. */ +/** + * 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 } = payload(input) as { key: string }; + const { key, op = 'set' } = payload(input) as { key: string; op?: 'set' | 'get' | 'delete' | 'keys' }; await ctx.propr.getIssue('owner/allowed', 3).catch(() => undefined); - try { await ctx.state.set(key, 'written after resuming'); return { ok: true }; } catch (error) { + 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 }; } } diff --git a/packages/core/test/pluginHandlerLifecycle.test.ts b/packages/core/test/pluginHandlerLifecycle.test.ts index 751fff986e..6777f495b2 100644 --- a/packages/core/test/pluginHandlerLifecycle.test.ts +++ b/packages/core/test/pluginHandlerLifecycle.test.ts @@ -4,6 +4,7 @@ import type { PluginHandlerInput, PluginJsonValue } from '@propr/shared'; import { runPluginHandler } from '../src/plugins/pluginHandlerRunner.js'; import { createPluginHttp, type PluginHttpTransport, type PluginHttpTransportRequest } from '../src/plugins/pluginHttp.js'; import { PluginStore } from '../src/plugins/pluginStore.js'; +import { pluginStateStore } from '../src/plugins/pluginStateStore.js'; import { ENVIRONMENT, FIXTURE_ID, lookup, pluginHandlerFixture } from './helpers/pluginHandlerFixture.js'; const invoke = (payload: PluginJsonValue = {}): PluginHandlerInput => ({ kind: 'invoke', payload }); @@ -109,3 +110,58 @@ test('a handler resuming after its plugin was uninstalled cannot recreate state, 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); +}); diff --git a/packages/core/test/pluginProprClient.test.ts b/packages/core/test/pluginProprClient.test.ts index 900f31c134..67e3ecd835 100644 --- a/packages/core/test/pluginProprClient.test.ts +++ b/packages/core/test/pluginProprClient.test.ts @@ -218,3 +218,27 @@ test('the GitHub backend sends nothing once the handler run has ended, even when 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']); +}); From 3c22120299ef3d60380249e34d30068c84a49bb1 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 21:28:17 +0000 Subject: [PATCH 04/15] feat(ai): I fixed F8 and F9 and added regression tests for both. With the fixes, the touched core plugin test files pass (pluginHandlerLifecycle 8/8, pluginHandlerRunner 6/6), and so does `packages/plugins/test/testing.test.ts`. When I swapped the original source files back in, both new tests failed, so they catch the defects. Core typecheck, the plugins package typecheck and zero-warning lint on `src/plugins` pass. I made one last small refactor, to clear a lint warning about too many parameters; lint, typecheck and those two suites were re-run after it. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I fixed F8 and F9 and added regression tests for both. With the fixes, the touched core plugin test files pass (pluginHandlerLifecycle 8/8, pluginHandlerRunner 6/6), and so does `packages/plugins/test/testing.test.ts`. When I swapped the original source files back in, both new tests failed, so they catch the defects. Core typecheck, the plugins package typecheck and zero-warning lint on `src/plugins` pass. I made one last small refactor, to clear a lint warning about too many parameters; lint, typecheck and those two suites were re-run after it. **F8: credentials hidden in escaped response headers** (`pluginHttp.ts`) - Headers now go through a new `scrubbedHeader` step instead of the literal `scrub` alone. After literal scrubbing, it decodes JSON escapes (`\uXXXX`, `\"`, `\\`, `\/`, `\n` and so on) anywhere in the value, up to 8 times so double-escaped values are caught too. If the decoded text contains any spelling of the credential, the whole header value is replaced with `[REDACTED]`. - This goes further than only handling headers that are entirely JSON. A value like `"\u0042…", other`, where several headers were joined with `, `, is not valid JSON as a whole, but a handler could split it and parse one part. - Headers that don't contain the credential are passed through byte-for-byte; a new test checks this. - `fetchProbe` now also reports `parsedHeadersHaveProbe`: it parses each header value, and each `, `-separated part, as JSON. The test covers a fully `\u`-escaped string, a JSON object, a joined value and a double-encoded value. **F9: old runs using a reinstalled plugin's credentials** (`pluginContext.ts`, `pluginHttp.ts`, `pluginStateStore.ts`) - The default credential lookup now checks the run's own installation (matched by `installed_at`) and reads the secret in the same transaction. It uses the same `requireInstallation` check as `ctx.state`, which is now exported for this. - `createPluginHttp` has a new `authorize` option. For authenticated requests the context sets it to re-check the installation after DNS and the credential lookup, just before sending. This also covers a custom `connectionSecret` source and a credential cached from an earlier request. - The new lifecycle test holds DNS and then tries three cases: uninstall only, uninstall and reinstall with a replacement token, and the same reinstall using a lookup that knows only the plugin id. In every case the handler gets `unavailable`, nothing reaches the transport, and the replacement token never appears in the run's result. - One race remains and can't be closed: an uninstall that commits after the final check but before the HTTPS request goes out. In that window the request can only carry the old installation's own secret, never the replacement's. - I added a short "Installation binding" paragraph and the header rule to `docs/docs/architecture/plugin-runtime.md`. **Found along the way, not fixed (outside F8/F9):** - Response header *names* are never scrubbed. An upstream that sends a header named after a lowercase credential would show it to the handler. - The body check only works when the whole body is JSON. A non-JSON body, such as HTML, that contains a `\u`-escaped JSON string of the credential is only scrubbed literally, so the handler could pull out that string and decode it. PR: #3573 Comment by: @propr-ultrafix (ID: 0) Model: Claude Opus 5.5 --- docs/docs/architecture/plugin-runtime.md | 14 ++++- packages/core/src/plugins/pluginContext.ts | 16 ++++-- packages/core/src/plugins/pluginHttp.ts | 31 ++++++++++- packages/core/src/plugins/pluginStateStore.ts | 2 +- .../plugins/fixture-handlers/handlers.ts | 7 ++- .../core/test/pluginHandlerLifecycle.test.ts | 52 ++++++++++++++++++- 6 files changed, 112 insertions(+), 10 deletions(-) diff --git a/docs/docs/architecture/plugin-runtime.md b/docs/docs/architecture/plugin-runtime.md index 39c2deda3d..15757bc6d7 100644 --- a/docs/docs/architecture/plugin-runtime.md +++ b/docs/docs/architecture/plugin-runtime.md @@ -151,8 +151,18 @@ URL-encoded and as the full header value) is replaced with `[REDACTED]` in the response headers and body before the handler sees 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 careless upstream that echoes the -`Authorization` header cannot hand the secret back. +body re-serialized with the secret replaced. A response header whose value +still holds the secret once its JSON escapes are decoded (a JSON-valued header, +also inside a longer or joined value) is replaced with `[REDACTED]` entirely. 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. 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. **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 diff --git a/packages/core/src/plugins/pluginContext.ts b/packages/core/src/plugins/pluginContext.ts index 690ae4cfc6..a1c0f8dc76 100644 --- a/packages/core/src/plugins/pluginContext.ts +++ b/packages/core/src/plugins/pluginContext.ts @@ -10,7 +10,7 @@ import { redactExtensionOutput } from '../extensions/extensionOutput.js'; import { createPluginHttp, type PluginHttpTransport } from './pluginHttp.js'; import { githubPluginProprBackend } from './pluginProprBackend.js'; import { pluginActor, scopedProprClient, type PluginProprBackend, type PluginProprCallRecord } from './pluginProprClient.js'; -import { pluginStateStore } from './pluginStateStore.js'; +import { pluginStateStore, requireInstallation, 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 } @@ -58,9 +58,14 @@ function logFields(fields: Record | undefined): Record { +/** The connection secret of the installation `guard` names, read in one transaction with that installation; refused once it was removed or replaced. */ +async function defaultConnectionSecret({ database, environment }: { database: Knex; environment: NodeJS.ProcessEnv }, pluginId: string, connection: string, guard: PluginStateWriteGuard): Promise { const { PluginStore } = await import('./pluginStore.js'); - return new PluginStore(database, environment).connectionSecret(pluginId, connection); + const store = new PluginStore(database, environment); + return database.transaction(async trx => { + await requireInstallation(trx, pluginId, guard); + return store.connectionSecret(pluginId, connection, trx); + }); } /** @@ -102,6 +107,8 @@ export function buildPluginContext(target: PluginInstallationTarget, deps: Plugi } }; + // The installation this run belongs to: a credential is read from it only, and a request is sent only while it is still installed. + const installation: PluginStateWriteGuard = { installedAt: record.installedAt, signal }; const http = (connection?: string): PluginHttpFetch => { let credential: (() => Promise) | undefined; if (connection !== undefined) { @@ -109,7 +116,7 @@ export function buildPluginContext(target: PluginInstallationTarget, deps: Plugi 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(deps.database, environment, id, name)))(pluginId, connection); + 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; @@ -117,6 +124,7 @@ export function buildPluginContext(target: PluginInstallationTarget, deps: Plugi } return createPluginHttp({ outboundHosts: manifest.outboundHosts, credential, lookup: deps.lookup, transport: deps.transport, signal, + authorize: credential ? () => deps.database.transaction(trx => requireInstallation(trx, pluginId, installation)) : 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 } : {}) }), }); }; diff --git a/packages/core/src/plugins/pluginHttp.ts b/packages/core/src/plugins/pluginHttp.ts index be24753b02..fb816a1808 100644 --- a/packages/core/src/plugins/pluginHttp.ts +++ b/packages/core/src/plugins/pluginHttp.ts @@ -24,6 +24,11 @@ export interface PluginHttpOptions { 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. */ @@ -85,6 +90,29 @@ function scrubbedBody(raw: string, spellings: readonly string[]): string { } } +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); + +/** + * A response header with every credential spelling replaced. A value that + * still holds one once its JSON escapes are decoded (a JSON-valued header + * escaping it as `\uXXXX`, also inside a longer, joined or re-encoded value) + * is withheld entirely: a handler could recover it with `JSON.parse`. + */ +function scrubbedHeader(raw: string, spellings: readonly string[]): string { + const text = scrub(raw, spellings); + if (!spellings.length) return text; + let decoded = text; + for (let round = 0; round < 8; round++) { + const next = jsonUnescapeOnce(decoded); + if (next === decoded) break; + if (scrub(next, spellings) !== next) return '[REDACTED]'; + decoded = next; + } + return text; +} + /** 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 { @@ -191,7 +219,7 @@ export const httpsPluginTransport: PluginHttpTransport = request => new Promise( function bufferedResponse(url: URL, response: PluginHttpTransportResponse, spellings: readonly string[]): PluginHttpResponse { const text = scrubbedBody(response.body.toString('utf8'), spellings); - const headers = Object.fromEntries(Object.entries(response.headers).map(([name, value]) => [name.toLowerCase(), scrub(value, spellings)])); + const headers = Object.fromEntries(Object.entries(response.headers).map(([name, value]) => [name.toLowerCase(), scrubbedHeader(value, spellings)])); return { status: response.status, ok: response.status >= 200 && response.status < 300, url: url.toString(), headers, text: async () => text, @@ -228,6 +256,7 @@ export function createPluginHttp(options: PluginHttpOptions): PluginHttpFetch { 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 }); diff --git a/packages/core/src/plugins/pluginStateStore.ts b/packages/core/src/plugins/pluginStateStore.ts index d97a4d3ebf..0753cc1c09 100644 --- a/packages/core/src/plugins/pluginStateStore.ts +++ b/packages/core/src/plugins/pluginStateStore.ts @@ -45,7 +45,7 @@ function requireLive(guard: PluginStateWriteGuard): void { * commits first (a write is then deleted with the rest), or finds the * installation gone. SQLite runs write transactions one at a time. */ -async function requireInstallation(trx: Knex.Transaction, pluginId: string, guard: PluginStateWriteGuard): Promise { +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') as { installed_at: number | string } | undefined; if (!row || Number(row.installed_at) !== guard.installedAt) throw new PluginSdkError('unavailable', 'The plugin is no longer installed'); diff --git a/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts b/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts index 3e43d00b84..c3bce42496 100644 --- a/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts +++ b/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts @@ -60,7 +60,12 @@ export async function fetchProbe(input: PluginHandlerInput, ctx: PluginContext) const parsed = (() => { try { return JSON.parse(text) as PluginJsonValue; } catch { return null; } })(); const json = await response.json(); 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)); - return { ok: true, textHasProbe: text.includes(probe), parsedHasProbe: decoded(parsed), jsonHasProbe: decoded(json), headersHaveProbe: Object.values(response.headers).some(v => v.includes(probe)) }; + // 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; } }); + return { + ok: true, textHasProbe: text.includes(probe), parsedHasProbe: decoded(parsed), jsonHasProbe: decoded(json), + headersHaveProbe: Object.values(response.headers).some(v => v.includes(probe)), parsedHeadersHaveProbe: Object.values(response.headers).some(parsedHeader), + }; } catch (error) { return { ok: false, code: (error as { code?: string }).code ?? null, message: (error as Error).message }; } diff --git a/packages/core/test/pluginHandlerLifecycle.test.ts b/packages/core/test/pluginHandlerLifecycle.test.ts index 6777f495b2..f5777bdaa7 100644 --- a/packages/core/test/pluginHandlerLifecycle.test.ts +++ b/packages/core/test/pluginHandlerLifecycle.test.ts @@ -28,7 +28,7 @@ test('a JSON-escaped echo of the credential is scrubbed before the handler can d assert.equal(run.outcome, 'succeeded', run.error ?? ''); return run.result as Record; }; - const clean = { ok: true, textHasProbe: false, parsedHasProbe: false, jsonHasProbe: false, headersHaveProbe: false }; + const clean = { ok: true, textHasProbe: false, parsedHasProbe: false, jsonHasProbe: false, headersHaveProbe: false, parsedHeadersHaveProbe: false }; // JSON.stringify escaping (the fixture echo transport): `\"` and `\\`. assert.deepEqual(await probe(f.http.transport), clean); @@ -38,6 +38,25 @@ test('a JSON-escaped echo of the credential is scrubbed before the handler can d 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('text() of a non-JSON body holds no JSON-escaped credential, and a clean JSON body is returned unchanged', async () => { @@ -165,3 +184,34 @@ test('a state write or delete is refused and rolled back when the run ends durin 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); + } +}); From ca5eb57fa81109f4be7f3ec0a53ab32c46f03ee2 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:01:29 +0000 Subject: [PATCH 05/15] feat(ai): I fixed F10 and F11 and added regression tests for both. With the fixes, all the plugin tests pass: 25 in the handler, `ctx.propr` and runner suites, and 30 more in the store, contributions, lint and `packages/plugins` testing suites. When I swapped the original source files back in, all three new tests failed. Core typecheck and zero-warning lint on `src/plugins` pass. I fixed F10 and F11 and added regression tests for both. With the fixes, all the plugin tests pass: 25 in the handler, `ctx.propr` and runner suites, and 30 more in the store, contributions, lint and `packages/plugins` testing suites. When I swapped the original source files back in, all three new tests failed. Core typecheck and zero-warning lint on `src/plugins` pass. **F10: credentials escaped in response bodies** (`pluginHttp.ts`) - There is now one shared check, `revealsCredential`. It decodes the text's JSON escapes one round at a time, up to 32 rounds, and looks for any spelling of the credential after each round. Text that is still decoding after 32 rounds counts as holding the credential, so the check fails closed. - **Bodies:** after the literal scrub, any body (HTML, plain text or JSON) that still decodes to the credential is replaced with `[REDACTED]`. That covers `\uXXXX` escapes inside an HTML script. - **Nested JSON:** inside a JSON body, a string that would reveal the credential when parsed again is replaced with `[REDACTED]` on its own. The rest of the document stays readable. - **Headers:** header filtering had the same weakness, so it now uses the same check. It used to stop after 8 decode rounds and pass the header through unchanged. An escape like `\u005c` turns into the backslash of the next escape, so about 2 KB was enough to hide a 40-character token 10 rounds deep. - The new test covers the HTML script case and the nested JSON case. It also checks 10-round and 40-round escapes in both a body and a header, and that escapes with no credential in them come through unchanged. **F11: uninstalled handlers keeping `ctx.propr` access** - `buildPluginContext` now has one installation check, also used by authenticated `ctx.http`. It confirms the run's installation (same `installed_at`) still exists and the run hasn't ended. - `scopedProprClient` runs this check before every call reaches the backend, after its permission and repository checks. That covers every operation and any backend. - The default GitHub backend runs it again right before each GitHub request, after the App client is ready. It also runs it before adding a task job, after the queue starts up or before a custom `enqueueIssueTask`. - A refused call returns `unavailable` ("The plugin is no longer installed") and is recorded as `handler_refused`. Audit rows don't depend on the installation, so recording still works after uninstall. - The new tests suspend a handler, then uninstall (or uninstall and reinstall) before its next `createIssue`, `createTask` or `commentOnIssue`; nothing reaches the backend. A second test uninstalls while the App token and the queue are still loading; nothing is sent or queued. One race remains and can't be closed: the check commits before the GitHub request goes out, so uninstall doesn't wait for a request that already passed it. `ctx.http` behaves the same way. Docs: `docs/docs/architecture/plugin-runtime.md` now describes the new body and nested-string handling and the new installation rule for `ctx.propr`. **Separate findings, not changed:** - Filtering only covers JSON-style escapes. A plugin author who controls an allowed host can still return the credential in any encoding they make up (reversed, XOR'd, and so on). - `ctx.propr` still uses the permissions and repository list from when the run started. A change to the grant or repository limit, or disabling the plugin, during a run isn't rechecked; only removal or reinstall is. - An existing type error in the `fetchUrl` fixture handler (`handlers.ts:40`, TS2352) shows up when that file is typechecked. Normal typecheck doesn't cover it. There are no visual changes, so I didn't create previews. PR: #3573 Comment by: @propr-ultrafix (ID: 0) Model: Claude Opus 5.5 --- docs/docs/architecture/plugin-runtime.md | 23 +++- packages/core/src/plugins/pluginContext.ts | 8 +- packages/core/src/plugins/pluginHttp.ts | 71 +++++++--- .../core/src/plugins/pluginProprBackend.ts | 26 +++- .../core/src/plugins/pluginProprClient.ts | 8 ++ .../plugins/fixture-handlers/handlers.ts | 6 + .../core/test/pluginHandlerLifecycle.test.ts | 127 +++++++++++++++++- 7 files changed, 235 insertions(+), 34 deletions(-) diff --git a/docs/docs/architecture/plugin-runtime.md b/docs/docs/architecture/plugin-runtime.md index 15757bc6d7..169c703b0c 100644 --- a/docs/docs/architecture/plugin-runtime.md +++ b/docs/docs/architecture/plugin-runtime.md @@ -99,10 +99,18 @@ Every call is checked before it reaches the backend: `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 those of the + installation the run started with. Every call requires that installation + to still be installed, and the default backend checks again right before it + sends each GitHub request or adds a task job, after the App client or the + queue is ready. A handler that resumes after its plugin was uninstalled, or + uninstalled and reinstalled, gets `unavailable`: it creates no issue, + comment, label or task. Uninstall 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` or `not_found`. It is logged as a -`warn` line in the run's log and in the server log. A backend failure reaches +`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. @@ -151,9 +159,14 @@ URL-encoded and as the full header value) is replaced with `[REDACTED]` in the response headers and body before the handler sees 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 response header whose value -still holds the secret once its JSON escapes are decoded (a JSON-valued header, -also inside a longer or joined value) is replaced with `[REDACTED]` entirely. A +body re-serialized with the secret replaced. A string that holds the secret +only after it is parsed again, such as a JSON document serialized inside a JSON +string, is replaced with `[REDACTED]` whole. A response header or body whose +text still holds the secret once its JSON escapes are decoded, once or +repeatedly, is replaced with `[REDACTED]` entirely. This covers a JSON-valued +header (also inside a longer or joined value), `\uXXXX` escapes in an HTML +script, and nested serialized JSON. Text still decoding after 32 rounds counts +as holding it. A careless upstream that echoes the `Authorization` header cannot hand the secret back. diff --git a/packages/core/src/plugins/pluginContext.ts b/packages/core/src/plugins/pluginContext.ts index a1c0f8dc76..ef34af51af 100644 --- a/packages/core/src/plugins/pluginContext.ts +++ b/packages/core/src/plugins/pluginContext.ts @@ -109,6 +109,8 @@ export function buildPluginContext(target: PluginInstallationTarget, deps: Plugi // The installation this run belongs to: a credential is read from it only, and a request is sent only while it is still installed. const installation: PluginStateWriteGuard = { installedAt: record.installedAt, signal }; + /** Refuses once this run's installation was uninstalled (or replaced by a reinstall), or the run ended. */ + const authorizeInstallation = () => deps.database.transaction(trx => requireInstallation(trx, pluginId, installation)); const http = (connection?: string): PluginHttpFetch => { let credential: (() => Promise) | undefined; if (connection !== undefined) { @@ -124,7 +126,7 @@ export function buildPluginContext(target: PluginInstallationTarget, deps: Plugi } return createPluginHttp({ outboundHosts: manifest.outboundHosts, credential, lookup: deps.lookup, transport: deps.transport, signal, - authorize: credential ? () => deps.database.transaction(trx => requireInstallation(trx, pluginId, installation)) : undefined, + authorize: credential ? authorizeInstallation : 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 } : {}) }), }); }; @@ -140,9 +142,11 @@ export function buildPluginContext(target: PluginInstallationTarget, deps: Plugi 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. propr: liveMethods(scopedProprClient({ pluginId, grantedPermissions: effectivePluginPermissions(manifest, record), repositories: record.repositories, - backend: deps.backend ?? githubPluginProprBackend({ database: deps.database, signal }), record: recordCall, + backend: deps.backend ?? githubPluginProprBackend({ database: deps.database, signal, authorize: authorizeInstallation }), record: recordCall, + authorize: authorizeInstallation, })), http: connection => live(http(connection)), // State is tied to this installation: once it is uninstalled (or replaced by a reinstall) every operation is refused. diff --git a/packages/core/src/plugins/pluginHttp.ts b/packages/core/src/plugins/pluginHttp.ts index fb816a1808..d6a4462754 100644 --- a/packages/core/src/plugins/pluginHttp.ts +++ b/packages/core/src/plugins/pluginHttp.ts @@ -57,10 +57,42 @@ function credentialSpellings(secret: string): string[] { } const scrub = (text: string, spellings: readonly string[]) => spellings.reduce((value, spelling) => value.split(spelling).join('[REDACTED]'), text); -/** Scrub every decoded string (keys included) of a parsed JSON value; `changed` when one held a credential. */ +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); +/** Rounds of JSON unescaping checked; each nesting level (a JSON document inside a JSON string) takes one. */ +const UNESCAPE_ROUNDS = 32; + +/** + * Whether `text` (already scrubbed of literal spellings) holds a credential + * once its JSON escapes are decoded, once or repeatedly: `\uXXXX` escapes in + * HTML or a header, or a serialized JSON document inside a JSON string. Text + * still decoding after the last round could hide one deeper, so counts too. + */ +function revealsCredential(text: string, spellings: readonly string[]): boolean { + let decoded = text; + for (let round = 0; round < UNESCAPE_ROUNDS; round++) { + const next = jsonUnescapeOnce(decoded); + if (next === decoded) return false; + if (spellings.some(spelling => next.includes(spelling))) return true; + decoded = 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) is withheld whole. + */ function scrubJson(value: unknown, spellings: readonly string[]): { value: unknown; changed: boolean } { let changed = false; - const clean = (text: string) => { const scrubbed = scrub(text, spellings); if (scrubbed !== text) changed = true; return scrubbed; }; + 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); @@ -75,25 +107,29 @@ function scrubJson(value: unknown, spellings: readonly string[]): { value: unkno * The response text with every credential spelling replaced. 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 + * escapes are decoded is withheld entirely: a handler could recover it by + * parsing the escaped part itself. */ function scrubbedBody(raw: string, spellings: readonly string[]): string { const text = scrub(raw, spellings); if (!spellings.length) return text; + let body = text; let parsed: unknown; - try { parsed = JSON.parse(text); } catch { return text; } - try { - const result = scrubJson(parsed, spellings); - return result.changed ? JSON.stringify(result.value) : text; - } 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'); + 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; } -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); - /** * A response header with every credential spelling replaced. A value that * still holds one once its JSON escapes are decoded (a JSON-valued header @@ -103,14 +139,7 @@ const jsonUnescapeOnce = (value: string) => value.replace(JSON_ESCAPE, (_, escap function scrubbedHeader(raw: string, spellings: readonly string[]): string { const text = scrub(raw, spellings); if (!spellings.length) return text; - let decoded = text; - for (let round = 0; round < 8; round++) { - const next = jsonUnescapeOnce(decoded); - if (next === decoded) break; - if (scrub(next, spellings) !== next) return '[REDACTED]'; - decoded = next; - } - return text; + return revealsCredential(text, spellings) ? '[REDACTED]' : text; } /** The error a request gets once the handler run has ended. */ diff --git a/packages/core/src/plugins/pluginProprBackend.ts b/packages/core/src/plugins/pluginProprBackend.ts index 362f88a193..3987b5cbb9 100644 --- a/packages/core/src/plugins/pluginProprBackend.ts +++ b/packages/core/src/plugins/pluginProprBackend.ts @@ -27,6 +27,12 @@ export interface GitHubPluginBackendOptions { 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: throws to refuse + * it (the plugin's installation was removed or replaced meanwhile). + */ + authorize?: () => Promise; } type GitHubIssue = { @@ -53,11 +59,12 @@ async function defaultIssueQueue(): Promise { const { getIssueQueue } = await import('../queue/taskQueue.js'); return await getIssueQueue() as unknown as PluginIssueQueue; } -function queueEnqueue(issueQueue: () => Promise) { +function queueEnqueue(issueQueue: () => Promise, authorize: () => Promise) { return async (data: IssueJobData, jobId: string, signal?: AbortSignal): Promise => { const queue = await issueQueue(); - // The run may have ended while the queue was initialized: no job is added for an ended handler. + // The run may have ended, or the plugin been uninstalled, while the queue was initialized: no job is added then. if (signal?.aborted) throw new PluginSdkError('unavailable', 'This handler run has ended'); + await authorize(); await queue.add('processGitHubIssue', data, { jobId, attempts: 3, backoff: { type: 'exponential', delay: 2000 }, removeOnComplete: true, removeOnFail: true }); }; } @@ -76,14 +83,23 @@ async function taskState(database: Knex, taskId: string): Promise export function githubPluginProprBackend(options: GitHubPluginBackendOptions): PluginProprBackend { const { database } = options; const github = options.github ?? defaultGitHub; - const enqueue = options.enqueueIssueTask ?? queueEnqueue(options.issueQueue ?? defaultIssueQueue); 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: checked last before anything is dispatched. */ + const authorized = async () => { + live(); + if (options.authorize) await options.authorize(); + live(); + }; + const custom = options.enqueueIssueTask; + const enqueue = custom + ? async (data: IssueJobData, jobId: string, runSignal?: AbortSignal) => { await authorized(); await custom(data, jobId, runSignal); } + : queueEnqueue(options.issueQueue ?? defaultIssueQueue, authorized); const request = async (route: string, parameters: Record) => { live(); const client = await github(); - // The run may have ended while the App token was fetched: nothing is sent for an ended handler. - live(); + // The run may have ended, or the plugin been uninstalled, while the App token was fetched: nothing is sent then. + await authorized(); return (await client.request(route, { ...parameters, ...(signal ? { request: { signal } } : {}) })).data as T; }; /** diff --git a/packages/core/src/plugins/pluginProprClient.ts b/packages/core/src/plugins/pluginProprClient.ts index 3838aa2577..f115bd4315 100644 --- a/packages/core/src/plugins/pluginProprClient.ts +++ b/packages/core/src/plugins/pluginProprClient.ts @@ -44,6 +44,12 @@ export interface ScopedProprOptions { backend: PluginProprBackend; /** Records the call with the plugin as the actor. A failure to record fails the call. */ record: (call: PluginProprCallRecord) => Promise; + /** + * Called before every call reaches the backend, after its own checks: + * throws to refuse it (the installation the permissions and repository + * limit were read from was removed or replaced). + */ + authorize?: () => Promise; } /** The actor ProPR records for anything a plugin's handler does. */ @@ -101,6 +107,8 @@ export function scopedProprClient(options: ScopedProprOptions): PluginProprClien 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. + if (options.authorize) await options.authorize(); result = await run(checked); } catch (error) { // A backend failure reaches the handler without its details (they can name other repositories or carry tokens). diff --git a/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts b/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts index c3bce42496..eab77b3550 100644 --- a/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts +++ b/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts @@ -89,6 +89,12 @@ export async function suspendThenSet(input: PluginHandlerInput, ctx: PluginConte } } +/** 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 { diff --git a/packages/core/test/pluginHandlerLifecycle.test.ts b/packages/core/test/pluginHandlerLifecycle.test.ts index f5777bdaa7..21c0fefef9 100644 --- a/packages/core/test/pluginHandlerLifecycle.test.ts +++ b/packages/core/test/pluginHandlerLifecycle.test.ts @@ -4,7 +4,8 @@ import type { PluginHandlerInput, PluginJsonValue } from '@propr/shared'; import { runPluginHandler } from '../src/plugins/pluginHandlerRunner.js'; import { createPluginHttp, type PluginHttpTransport, type PluginHttpTransportRequest } from '../src/plugins/pluginHttp.js'; import { PluginStore } from '../src/plugins/pluginStore.js'; -import { pluginStateStore } from '../src/plugins/pluginStateStore.js'; +import { githubPluginProprBackend } from '../src/plugins/pluginProprBackend.js'; +import { pluginStateStore, requireInstallation } from '../src/plugins/pluginStateStore.js'; import { ENVIRONMENT, FIXTURE_ID, lookup, pluginHandlerFixture } from './helpers/pluginHandlerFixture.js'; const invoke = (payload: PluginJsonValue = {}): PluginHandlerInput => ({ kind: 'invoke', payload }); @@ -215,3 +216,127 @@ test('a request resuming after an uninstall never acquires or sends a reinstall\ 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'); +}); From 9adc1ac7bd16a8f47d400cf23d9f414670921ba0 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:26:45 +0000 Subject: [PATCH 06/15] feat(ai): I fixed F12: `ctx.propr` now writes an attributed `handler_attempt` audit entry before it sends any change to GitHub or the task queue. If that write fails, nothing is sent. I fixed F12: `ctx.propr` now writes an attributed `handler_attempt` audit entry before it sends any change to GitHub or the task queue. If that write fails, nothing is sent. **How a mutating call works now** (`createIssue`, `createTask`, `commentOnIssue`, `commentOnPullRequest`, `addLabel`, `removeLabel`), in `packages/core/src/plugins/pluginProprClient.ts`: 1. The existing permission, repository-limit and installation checks run first. 2. A `handler_attempt` entry is written, with `plugin:` as the actor. If that write fails, the call is refused as `unavailable` and the backend is never called. 3. The backend runs. 4. The outcome is recorded as a separate entry: `handler_call` on success, `handler_refused` with the reason on failure. All entries for one call share a new `callId`, so the attempt and its outcome can be matched. Once the attempt is recorded, a failure to record the outcome is logged on the server (`[plugins] Could not record the outcome of an attempted handler call`) and the call still returns its result. Rejecting it would only invite a retry that creates a second issue or comment, and the attempt entry already credits the mutation to the plugin. Read operations are unchanged: they get one entry when they finish, and a failure to write it still fails the call. That is safe because nothing outside ProPR changes. **Checking related code:** the GitHub backend still re-checks the installation right before it sends anything. A call refused at that point leaves an attempt entry followed by a refusal. The "uninstalled mid-run" lifecycle test still shows only refusals, because the installation check happens before the attempt is written. The other parts of `ctx` (`ctx.http`, `ctx.state`) make no audit-trail promise. **Other files changed:** - `handler_attempt` is added to the list of audit actions in `packages/shared/src/pluginInstallations.ts`, the OpenAPI spec and the generated client types. `gen:openapi:check` passes. - `pluginContext.ts` maps each outcome to its audit action and does the server-side logging. - Both docs pages (`plugins.md` and `plugin-runtime.md`) describe the attempt entry and what happens when audit writes fail. **Tests** (in `packages/core/test/pluginProprClient.test.ts`): - **Recorded before sending:** for each mutation run through a handler, the backend checks the real `plugin_audit` table at the moment it is called and finds the `handler_attempt` entry already there. - **Attempt can't be written:** a SQLite trigger rejects attempt entries. Every mutation is then refused as `unavailable` and nothing reaches the backend. The same is checked with a record function that throws. - **Outcome can't be written:** `createIssue` still returns the created issue and the attempt entry stays. A backend failure after the attempt leaves an attempt entry and a refusal entry with the same `callId`, and the logging hook is called. - I updated two existing tests to the new entries. **Results:** all 56 plugin tests pass across the six plugin suites. With the original source files swapped back in, all three new tests and the two updated ones fail. Typecheck passes for `shared`, `core`, `client` and `plugins`, and zero-warning lint passes on `src/plugins`. I also ran `taskJournalEvents`, an existing suite this change doesn't touch. It didn't run properly because my command left out the `--experimental-test-module-mocks` flag it needs, so that is no evidence either way. **Separate issue, not changed:** when an audit write fails and no attempt entry exists to fall back on (a refusal, or a read finishing), the handler gets the raw database error instead of `unavailable`. One of the new tests depends on this. The behaviour was already there before this PR's F12 work and wasn't selected, so I left it alone. There are no visual changes, so I made no previews. PR: #3573 Comment by: @propr-ultrafix (ID: 0) Model: Claude Opus 5.5 --- docs/docs/architecture/plugin-runtime.md | 17 ++- docs/docs/features/plugins.md | 3 + docs/static/openapi/propr-api.yaml | 1 + packages/client/src/generated/apiTypes.ts | 2 +- packages/core/src/plugins/pluginContext.ts | 14 ++- .../core/src/plugins/pluginProprClient.ts | 43 ++++++- packages/core/test/pluginProprClient.test.ts | 119 ++++++++++++++++-- packages/shared/src/pluginInstallations.ts | 7 +- 8 files changed, 176 insertions(+), 30 deletions(-) diff --git a/docs/docs/architecture/plugin-runtime.md b/docs/docs/architecture/plugin-runtime.md index 169c703b0c..bcbe3ae51e 100644 --- a/docs/docs/architecture/plugin-runtime.md +++ b/docs/docs/architecture/plugin-runtime.md @@ -116,11 +116,18 @@ other repositories or carry tokens. ### Attribution -Every call, allowed or refused, is appended to `plugin_audit`: action -`handler_call` or `handler_refused`, actor `plugin:`, and detail -`{ handler, operation, permission, repository?, number?, taskId?, reason? }`. The -calls appear in the plugin's audit trail (`GET /api/instance/plugins/v1/{id}`) -with the management actions. +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 diff --git a/docs/docs/features/plugins.md b/docs/docs/features/plugins.md index 3f402464aa..beb16a7cf1 100644 --- a/docs/docs/features/plugins.md +++ b/docs/docs/features/plugins.md @@ -568,6 +568,9 @@ repository limit. 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. diff --git a/docs/static/openapi/propr-api.yaml b/docs/static/openapi/propr-api.yaml index e1840a44bd..88ae7fcd31 100644 --- a/docs/static/openapi/propr-api.yaml +++ b/docs/static/openapi/propr-api.yaml @@ -13113,6 +13113,7 @@ components: - disconnect_oauth - set_oauth_client - clear_oauth_client + - handler_attempt - handler_call - handler_refused actor: diff --git a/packages/client/src/generated/apiTypes.ts b/packages/client/src/generated/apiTypes.ts index 38f2e6018b..09056c5938 100644 --- a/packages/client/src/generated/apiTypes.ts +++ b/packages/client/src/generated/apiTypes.ts @@ -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" | "handler_call" | "handler_refused"; + 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: { diff --git a/packages/core/src/plugins/pluginContext.ts b/packages/core/src/plugins/pluginContext.ts index ef34af51af..db39e63fb5 100644 --- a/packages/core/src/plugins/pluginContext.ts +++ b/packages/core/src/plugins/pluginContext.ts @@ -2,14 +2,14 @@ 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 PluginLogger, type PluginManifest, type PluginPermission, + 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, type PluginHttpTransport } from './pluginHttp.js'; import { githubPluginProprBackend } from './pluginProprBackend.js'; -import { pluginActor, scopedProprClient, type PluginProprBackend, type PluginProprCallRecord } from './pluginProprClient.js'; +import { pluginActor, scopedProprClient, type PluginProprBackend, type PluginProprCallOutcome, type PluginProprCallRecord } from './pluginProprClient.js'; import { pluginStateStore, requireInstallation, type PluginStateWriteGuard } from './pluginStateStore.js'; /** An installed plugin as a handler runs it: the current manifest and the installation record. */ @@ -35,6 +35,9 @@ export interface BuiltPluginContext { redact(value: T): T; } +/** 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: PluginInstallationRecord): PluginPermission[] { return manifest.permissions.filter(permission => record.grantedPermissions.includes(permission)); @@ -96,12 +99,12 @@ export function buildPluginContext(target: PluginInstallationTarget, deps: Plugi 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 { allowed, ...detail } = call; + const { outcome, ...detail } = call; await deps.database('plugin_audit').insert({ - plugin_id: pluginId, action: allowed ? 'handler_call' : 'handler_refused', actor: pluginActor(pluginId), at: now(), + plugin_id: pluginId, action: CALL_AUDIT_ACTIONS[outcome], actor: pluginActor(pluginId), at: now(), detail: JSON.stringify({ handler, ...detail }), }); - if (!allowed) { + 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'); } @@ -146,6 +149,7 @@ export function buildPluginContext(target: PluginInstallationTarget, deps: Plugi propr: liveMethods(scopedProprClient({ pluginId, grantedPermissions: effectivePluginPermissions(manifest, record), repositories: record.repositories, backend: deps.backend ?? githubPluginProprBackend({ database: deps.database, signal, authorize: authorizeInstallation }), 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: authorizeInstallation, })), http: connection => live(http(connection)), diff --git a/packages/core/src/plugins/pluginProprClient.ts b/packages/core/src/plugins/pluginProprClient.ts index f115bd4315..e64e7fea87 100644 --- a/packages/core/src/plugins/pluginProprClient.ts +++ b/packages/core/src/plugins/pluginProprClient.ts @@ -1,3 +1,4 @@ +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, @@ -28,22 +29,43 @@ export interface PluginProprBackend { /** 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; allowed: boolean; + 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 failure to record fails the call. */ + /** + * 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: * throws to refuse it (the installation the permissions and repository @@ -101,22 +123,35 @@ export function scopedProprClient(options: ScopedProprOptions): PluginProprClien 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. if (options.authorize) await options.authorize(); + 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 options.record({ operation, permission, allowed: false, ...checked, reason: refused.code }); + await recordOutcome(entry('refused', refused.code)); throw refused; } - await options.record({ operation, permission, allowed: true, ...checked }); + await recordOutcome(entry('completed')); return result; } diff --git a/packages/core/test/pluginProprClient.test.ts b/packages/core/test/pluginProprClient.test.ts index 67e3ecd835..20e0de04ef 100644 --- a/packages/core/test/pluginProprClient.test.ts +++ b/packages/core/test/pluginProprClient.test.ts @@ -6,7 +6,7 @@ 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 } from './helpers/pluginHandlerFixture.js'; +import { FIXTURE_ID, pluginHandlerFixture, recordingBackend } from './helpers/pluginHandlerFixture.js'; type CallResult = { ok: boolean; value?: PluginJsonValue; code?: string; message?: string }; @@ -17,7 +17,7 @@ async function proprFixture(t: Parameters[0]) { 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_call', 'handler_refused']).orderBy('id').select('action', 'actor', 'detail')) + 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 }; } @@ -100,16 +100,19 @@ test('allowed ctx.propr operations reach the backend attributed to the plugin', assert.equal(f.calls[4].args[2], `Linked thread\n\n${marker}`); const audit = await f.audit(); - assert.ok(audit.every(a => a.action === 'handler_call' && a.actor === `plugin:${FIXTURE_ID}` && a.handler === 'callPropr')); - assert.deepEqual(audit.map(a => [a.operation, a.permission, a.repository, a.number ?? null]), [ - ['getIssue', 'issues.read', 'owner/allowed', 3], - ['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], + 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'); @@ -179,7 +182,7 @@ test('a comment permission covers only its own target kind: issues.comment canno 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.allowed, r.reason]), [['commentOnIssue', false, 'not_found']]); + 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. @@ -242,3 +245,95 @@ test('the default enqueue adds no job when the run ends while the issue queue is 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/shared/src/pluginInstallations.ts b/packages/shared/src/pluginInstallations.ts index 01081758db..5965f8f1a8 100644 --- a/packages/shared/src/pluginInstallations.ts +++ b/packages/shared/src/pluginInstallations.ts @@ -82,11 +82,12 @@ export interface PluginView { contributions?: PluginContributionViews; } /** - * One audit trail entry. Management actions name the administrator; `handler_call` - * and `handler_refused` record a handler's `ctx.propr` calls with `plugin:` as the actor. + * One audit trail entry. Management actions name the administrator; `handler_attempt` + * (a mutation about to be dispatched), `handler_call` (completed) and `handler_refused` + * (refused or failed) record a handler's `ctx.propr` calls with `plugin:` as the actor. */ export interface PluginAuditEvent { action: PluginAuditAction; actor: string; at: number; detail: Record } -export const PLUGIN_AUDIT_ACTIONS = ['install', 'update_settings', 'set_repositories', 'set_connection', 'clear_connection', 'enable', 'disable', 'approve', 'uninstall', 'start_oauth', 'complete_oauth', 'disconnect_oauth', 'set_oauth_client', 'clear_oauth_client', 'handler_call', 'handler_refused'] 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[] } From e641ac95496d44137e02e9c7d641cb34877a5115 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:52:28 +0000 Subject: [PATCH 07/15] feat(ai): I fixed both findings, F13 and F14, and added a regression test for each. When I put the original source files back, the new tests failed; with the fix, all 60 tests in the plugin suites pass. I fixed both findings, F13 and F14, and added a regression test for each. When I put the original source files back, the new tests failed; with the fix, all 60 tests in the plugin suites pass. **F13: credentials in response header names** (`packages/core/src/plugins/pluginHttp.ts`) - `bufferedResponse` now drops any response header whose name contains the credential in any spelling it already checks for. I dropped the header rather than rewriting the name, because rewritten names could collide. - Header names arrive in lowercase, so the check ignores case. Without that, the base64 form or the URL-encoded form (`%2F` arrives as `%2f`) of a mixed-case secret would get through. When I made the check case-sensitive, the new test failed, so the test catches this. - The same check also catches a credential hidden in a name with JSON escapes. Other headers reach the handler as before. **F14: `getTask` in the test kit** (`packages/plugins/testing/index.ts`) - The kit now waits for the `getTask` answer and checks the task's repository against the configured limit, ignoring case. A task from another repository is refused with `repository_not_allowed` and is left out of `kit.calls`. - Two follow-on changes were needed to keep the kit matching production: - **Placeholder task:** an unanswered `getTask` returned a placeholder task in `owner/repo`, which the new check would refuse. It now uses the first repository in the limit. - **Missing task:** a null answer is refused with `not_found`, as in production, instead of being misreported as `repository_not_allowed`. **Tests** - **Handler-visible sentinel test:** the fixture's echo transport now also returns the credential as a header name, and the test checks the header names the handler sees. The `fetchProbe` handler reports a new `headerNamesHaveProbe` flag. - **New test for header names:** a mixed-case secret containing `/` is echoed in seven header-name spellings. None reaches the handler, and `content-type` and `x-request-id` arrive unchanged. - **New test-kit test:** an out-of-limit `getTask` answer is refused and not recorded; an in-limit one passes; the placeholder is in the limit; a null answer gives `not_found`. **Checks:** typecheck passes for core and for `@propr/plugins` (including the handlers config), and zero-warning lint passes on `pluginHttp.ts` and the whole plugins package. The test files aren't covered by any typecheck, so I checked the edited ones separately: the only errors are on lines I didn't touch (untyped migration imports and an existing cast in `fetchUrl`). **Docs:** `docs/docs/architecture/plugin-runtime.md` now says credential-named headers are dropped, and describes the kit's `getTask` check and placeholder. **Separate issue, not fixed:** if a call's repository argument isn't a string, for example `listEvents({})` or `getIssue(undefined, 1)`, the test kit skips the limit check and returns an answer, while production refuses it with `invalid_input`. That's missing input validation, which the kit doesn't do at all, rather than the defect in these findings, so I didn't change it. There's no visible UI change, so I made no previews. PR: #3573 Comment by: @propr-ultrafix (ID: 0) Model: Claude Opus 5.5 --- docs/docs/architecture/plugin-runtime.md | 10 +++++-- packages/core/src/plugins/pluginHttp.ts | 27 +++++++++++++++-- .../plugins/fixture-handlers/handlers.ts | 5 ++++ .../core/test/helpers/pluginHandlerFixture.ts | 7 +++-- .../core/test/pluginHandlerLifecycle.test.ts | 29 ++++++++++++++++++- .../core/test/pluginHandlerRunner.test.ts | 2 ++ packages/plugins/test/testing.test.ts | 18 ++++++++++++ packages/plugins/testing/index.ts | 23 +++++++++++++-- 8 files changed, 109 insertions(+), 12 deletions(-) diff --git a/docs/docs/architecture/plugin-runtime.md b/docs/docs/architecture/plugin-runtime.md index bcbe3ae51e..5e7a4b3731 100644 --- a/docs/docs/architecture/plugin-runtime.md +++ b/docs/docs/architecture/plugin-runtime.md @@ -173,7 +173,9 @@ text still holds the secret once its JSON escapes are decoded, once or repeatedly, is replaced with `[REDACTED]` entirely. This covers a JSON-valued header (also inside a longer or joined value), `\uXXXX` escapes in an HTML script, and nested serialized JSON. Text still decoding after 32 rounds counts -as holding it. A +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. @@ -272,5 +274,7 @@ 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. `ctx.propr` calls nobody answered resolve with minimal -placeholders. An HTTP request with no scripted response throws `http_failed`. +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/packages/core/src/plugins/pluginHttp.ts b/packages/core/src/plugins/pluginHttp.ts index d6a4462754..6b81f35445 100644 --- a/packages/core/src/plugins/pluginHttp.ts +++ b/packages/core/src/plugins/pluginHttp.ts @@ -68,13 +68,15 @@ const UNESCAPE_ROUNDS = 32; * once its JSON escapes are decoded, once or repeatedly: `\uXXXX` escapes in * HTML or a header, or a serialized JSON document inside a JSON string. Text * still decoding after the last round could hide one deeper, so counts too. + * `fold` is applied to each decoded round before comparing (header names + * compare case-insensitively). */ -function revealsCredential(text: string, spellings: readonly string[]): boolean { +function revealsCredential(text: string, spellings: readonly string[], fold: (value: string) => string = value => value): boolean { let decoded = text; for (let round = 0; round < UNESCAPE_ROUNDS; round++) { const next = jsonUnescapeOnce(decoded); if (next === decoded) return false; - if (spellings.some(spelling => next.includes(spelling))) return true; + if (spellings.some(spelling => fold(next).includes(spelling))) return true; decoded = next; } return true; @@ -142,6 +144,21 @@ function scrubbedHeader(raw: string, spellings: readonly string[]): string { return revealsCredential(text, spellings) ? '[REDACTED]' : text; } +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 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 string[]): boolean { + if (!spellings.length) return false; + const folded = spellings.map(lowerCase); + return folded.some(spelling => name.includes(spelling)) || 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 { @@ -248,7 +265,11 @@ export const httpsPluginTransport: PluginHttpTransport = request => new Promise( function bufferedResponse(url: URL, response: PluginHttpTransportResponse, spellings: readonly string[]): PluginHttpResponse { const text = scrubbedBody(response.body.toString('utf8'), spellings); - const headers = Object.fromEntries(Object.entries(response.headers).map(([name, value]) => [name.toLowerCase(), scrubbedHeader(value, 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, diff --git a/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts b/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts index eab77b3550..fd695943fb 100644 --- a/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts +++ b/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts @@ -62,9 +62,14 @@ export async function fetchProbe(input: PluginHandlerInput, ctx: PluginContext) 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())); return { ok: true, textHasProbe: text.includes(probe), parsedHasProbe: decoded(parsed), jsonHasProbe: decoded(json), headersHaveProbe: Object.values(response.headers).some(v => v.includes(probe)), parsedHeadersHaveProbe: Object.values(response.headers).some(parsedHeader), + headerNamesHaveProbe: Object.keys(response.headers).some(nameHasProbe), }; } catch (error) { return { ok: false, code: (error as { code?: string }).code ?? null, message: (error as Error).message }; diff --git a/packages/core/test/helpers/pluginHandlerFixture.ts b/packages/core/test/helpers/pluginHandlerFixture.ts index ecbb8bc2d5..8848b49d63 100644 --- a/packages/core/test/helpers/pluginHandlerFixture.ts +++ b/packages/core/test/helpers/pluginHandlerFixture.ts @@ -28,7 +28,10 @@ export const lookup = async (host: string) => { throw new Error('ENOTFOUND'); }; -/** A transport that echoes the request's headers (the injected Authorization included) back, as a careless upstream would. */ +/** + * 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 => { @@ -36,7 +39,7 @@ export function echoTransport() { const authorization = request.headers.authorization ?? ''; return { status: 200, - headers: { 'content-type': 'application/json', 'x-echo-authorization': authorization }, + 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') })), }; }; diff --git a/packages/core/test/pluginHandlerLifecycle.test.ts b/packages/core/test/pluginHandlerLifecycle.test.ts index 21c0fefef9..1598954c8f 100644 --- a/packages/core/test/pluginHandlerLifecycle.test.ts +++ b/packages/core/test/pluginHandlerLifecycle.test.ts @@ -29,7 +29,7 @@ test('a JSON-escaped echo of the credential is scrubbed before the handler can d 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 }; + const clean = { ok: true, textHasProbe: false, parsedHasProbe: false, jsonHasProbe: false, headersHaveProbe: false, parsedHeadersHaveProbe: false, headerNamesHaveProbe: false }; // JSON.stringify escaping (the fixture echo transport): `\"` and `\\`. assert.deepEqual(await probe(f.http.transport), clean); @@ -60,6 +60,33 @@ test('a JSON-valued response header without the credential reaches the handler u 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'); diff --git a/packages/core/test/pluginHandlerRunner.test.ts b/packages/core/test/pluginHandlerRunner.test.ts index 25a8d58271..66af66a38b 100644 --- a/packages/core/test/pluginHandlerRunner.test.ts +++ b/packages/core/test/pluginHandlerRunner.test.ts @@ -134,6 +134,8 @@ test('ctx.http injects the connection credential, which the handler and the logs 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); }); diff --git a/packages/plugins/test/testing.test.ts b/packages/plugins/test/testing.test.ts index c9c9095f12..1f847f1aa7 100644 --- a/packages/plugins/test/testing.test.ts +++ b/packages/plugins/test/testing.test.ts @@ -48,6 +48,24 @@ test('the test kit refuses ungranted permissions, repositories outside the limit 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); diff --git a/packages/plugins/testing/index.ts b/packages/plugins/testing/index.ts index a3ecb39298..ff5df5fe11 100644 --- a/packages/plugins/testing/index.ts +++ b/packages/plugins/testing/index.ts @@ -98,6 +98,12 @@ function stateStore(state: Map): PluginContext['state'] 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) }[] = []; @@ -106,10 +112,21 @@ export function createTestPluginContext(options: TestPluginContextOptions = {}): 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 (limit && typeof repository === 'string' && !limit.includes(repository.toLowerCase())) throw new PluginSdkError('repository_not_allowed', `This plugin is not enabled for ${repository}`); - calls.push({ operation, args: structuredClone(args) }); + 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; - return answer ? answer(...args) : (placeholders[operation] as (...a: unknown[]) => unknown)(...args); + 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) => { From d7e2ecfa90ed997c3fc4e18578b2a15b1a309f82 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 23:26:23 +0000 Subject: [PATCH 08/15] feat(ai): I fixed F15. Credentials echoed with percent escapes, in either hex case, are now redacted before the handler sees them, and the same gap in the handler's log and result redaction is closed. Both new regression tests fail against the original source files and pass with the fix. All 62 plugin tests pass (the 60 from before plus the 2 new ones), and core typecheck and zero-warning lint on `src/plugins` pass. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I fixed F15. Credentials echoed with percent escapes, in either hex case, are now redacted before the handler sees them, and the same gap in the handler's log and result redaction is closed. Both new regression tests fail against the original source files and pass with the fix. All 62 plugin tests pass (the 60 from before plus the 2 new ones), and core typecheck and zero-warning lint on `src/plugins` pass. **The fix, in `packages/core/src/plugins/pluginHttp.ts`** - **Redaction in place:** every credential spelling is now also matched where some of its characters are percent-escaped, with lowercase or uppercase hex (`%2f`, `%2F`, or an escaped letter). The match is replaced with `[REDACTED]` and the rest of the value stays, so `token=plugin%2fcred…&page=2` becomes `token=[REDACTED]&page=2`. Stored secrets are printable ASCII (`PLUGIN_SECRET_PATTERN`), so only escapes of ASCII characters are needed. - **Withholding:** the existing check for text that decodes back to the credential now undoes percent escapes (`%2f`, `%u002f`) as well as JSON escapes, in any order. So a value encoded twice, or a `%` hidden inside a JSON escape (`\u00252f`), is withheld whole, in bodies, header values and header names. - **Cost:** decoding is still capped at 32 rounds. A round that produces more than 8 different decoded texts also counts as holding the credential. On ordinary JSON, the check now takes about 40 ms for a 1 MiB body, up from 15 ms. On deliberately nasty bodies of about 700 KB it takes up to 125 ms, up from 27 ms. **The same gap in logs and results** The handler's log lines, log fields, error message and result were redacted by the shared extension redactor, which only catches the exact `encodeURIComponent` spelling. A credential that reached the handler some other way, such as in its input payload, could be logged with `%2f` and survive. `pluginContext.ts` now runs the same scrubbing as above before that redactor, through a new exported helper, `scrubCredentialValues`. **Tests, in `pluginHandlerLifecycle.test.ts`** - **Responses:** the upstream echoes the token in a JSON body, a form body and header values. The encodings are lowercase hex, the full `Bearer …` value, every character escaped (both hex cases), encoded twice, `%u` escapes, and a percent escape inside a JSON escape. The handler-side probe decodes everything it can see with `decodeURIComponent`, falling back to `unescape`-style decoding, repeatedly, and must never recover the token. The test also checks that text which only happens to contain escapes reaches the handler unchanged. I checked separately that each of those encodings leaks on the original code, so none of the cases is a no-op. - **Logs and results:** a handler logs and returns a lowercase-encoded token taken from its input. The token is replaced in place, and the other forms can't be recovered by URL-decoding the run. - **Fixture changes:** to support these, `fetchProbe` now reports `urlDecodedHasProbe` and tolerates a body that isn't JSON. I also added a `fetchThenLog` fixture handler. The existing JSON-escape test's expected result now includes the new key. I also updated `docs/docs/architecture/plugin-runtime.md` to describe the percent-escape handling for responses and logs. Nothing visible in the UI changed, so there are no previews. **Found separately, not changed:** the shared extension redaction in `packages/core/src/extensions/extensionOutput.ts`, and the similar spelling list at `operatorCatalog.ts:40`, also match only the exact uppercase `encodeURIComponent` spelling. That code predates this PR and also serves the MCP gateway and extension subprocess output. I left it alone because changing it would fix an older problem outside F15. PR: #3573 Comment by: @propr-ultrafix (ID: 0) Model: Claude Opus 5.5 --- docs/docs/architecture/plugin-runtime.md | 33 +++-- packages/core/src/plugins/pluginContext.ts | 7 +- packages/core/src/plugins/pluginHttp.ts | 118 ++++++++++++++---- .../plugins/fixture-handlers/handlers.ts | 31 ++++- .../core/test/pluginHandlerLifecycle.test.ts | 84 ++++++++++++- 5 files changed, 229 insertions(+), 44 deletions(-) diff --git a/docs/docs/architecture/plugin-runtime.md b/docs/docs/architecture/plugin-runtime.md index 5e7a4b3731..7c898dd12f 100644 --- a/docs/docs/architecture/plugin-runtime.md +++ b/docs/docs/architecture/plugin-runtime.md @@ -163,17 +163,22 @@ the request is sent and is injected as `Authorization: Bearer `. An (`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. A JSON body is also +response headers and body before the handler sees them. 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 parsed again, such as a JSON document serialized inside a JSON -string, is replaced with `[REDACTED]` whole. A response header or body whose -text still holds the secret once its JSON escapes are decoded, once or -repeatedly, is replaced with `[REDACTED]` entirely. This covers a JSON-valued -header (also inside a longer or joined value), `\uXXXX` escapes in an HTML -script, and nested serialized JSON. Text still decoding after 32 rounds counts -as holding it. A response header whose name holds the secret in any of these +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 @@ -233,11 +238,13 @@ credentials. `{ 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 passes the extension output -redaction. That redaction removes the connection credentials used in the run in -every spelling, plus the general secret patterns (GitHub, cloud and API tokens, -`Authorization` values and secret-like assignments) and headers such as -`authorization` and `cookie`. +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`. ## Import rule diff --git a/packages/core/src/plugins/pluginContext.ts b/packages/core/src/plugins/pluginContext.ts index db39e63fb5..81cf29b377 100644 --- a/packages/core/src/plugins/pluginContext.ts +++ b/packages/core/src/plugins/pluginContext.ts @@ -7,7 +7,7 @@ import { import logger from '../utils/logger.js'; import type { ExtensionLookup } from '../extensions/endpointPolicy.js'; import { redactExtensionOutput } from '../extensions/extensionOutput.js'; -import { createPluginHttp, type PluginHttpTransport } from './pluginHttp.js'; +import { createPluginHttp, scrubCredentialValues, type PluginHttpTransport } from './pluginHttp.js'; import { githubPluginProprBackend } from './pluginProprBackend.js'; import { pluginActor, scopedProprClient, type PluginProprBackend, type PluginProprCallOutcome, type PluginProprCallRecord } from './pluginProprClient.js'; import { pluginStateStore, requireInstallation, type PluginStateWriteGuard } from './pluginStateStore.js'; @@ -31,7 +31,7 @@ export interface PluginContextDeps { export interface BuiltPluginContext { ctx: PluginContext; logs: PluginLogLine[]; - /** Redact a value for the caller: connection credentials used in this run and the general secret patterns. */ + /** 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; } @@ -83,7 +83,8 @@ export function buildPluginContext(target: PluginInstallationTarget, deps: Plugi const environment = deps.environment ?? process.env; const credentials: string[] = []; const logs: PluginLogLine[] = []; - const redact = (value: T): T => redactExtensionOutput(value, credentials) as T; + // 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; let dropped = false; const write = (level: PluginLogLevel, message: unknown, fields?: Record) => { diff --git a/packages/core/src/plugins/pluginHttp.ts b/packages/core/src/plugins/pluginHttp.ts index 6b81f35445..b8133192f3 100644 --- a/packages/core/src/plugins/pluginHttp.ts +++ b/packages/core/src/plugins/pluginHttp.ts @@ -55,29 +55,83 @@ function credentialSpellings(secret: string): string[] { // Longest first: the header value's encodings are not made of the secret's. return [...new Set([...encodings(`Bearer ${secret}`), ...encodings(secret)])].filter(Boolean).sort((a, b) => b.length - a.length); } -const scrub = (text: string, spellings: readonly string[]) => spellings.reduce((value, spelling) => value.split(spelling).join('[REDACTED]'), text); + +/** 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 string[]): 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; + }; + const matches: [number, number][] = []; + for (const spelling of spellings) { + for (let at = decoded.indexOf(spelling); at >= 0; at = decoded.indexOf(spelling, at + 1)) matches.push([original(at), original(at + spelling.length)]); + } + if (!matches.length) return text; + // Overlapping matches (the header value holds the secret) become one replacement. + let scrubbed = ''; let cursor = 0; + for (const [start, end] of matches.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); +} +const scrub = (text: string, spellings: readonly string[]) => spellings.length + ? scrubPercentEscaped(spellings.reduce((value, spelling) => value.split(spelling).join('[REDACTED]'), text), 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); -/** Rounds of JSON unescaping checked; each nesting level (a JSON document inside a JSON string) takes one. */ +/** + * 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 escapes are decoded, once or repeatedly: `\uXXXX` escapes in - * HTML or a header, or a serialized JSON document inside a JSON string. Text - * still decoding after the last round could hide one deeper, so counts too. - * `fold` is applied to each decoded round before comparing (header names - * compare case-insensitively). + * 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 string[], fold: (value: string) => string = value => value): boolean { - let decoded = text; + let variants = [text]; for (let round = 0; round < UNESCAPE_ROUNDS; round++) { - const next = jsonUnescapeOnce(decoded); - if (next === decoded) return false; - if (spellings.some(spelling => fold(next).includes(spelling))) return true; - decoded = next; + // 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 => { const folded = fold(variant); return spellings.some(spelling => folded.includes(spelling)); })) return true; + variants = [...next]; } return true; } @@ -85,7 +139,8 @@ function revealsCredential(text: string, spellings: readonly string[], fold: (va /** * 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) is withheld whole. + * (serialized JSON inside the string, a value URL-encoded twice) is withheld + * whole. */ function scrubJson(value: unknown, spellings: readonly string[]): { value: unknown; changed: boolean } { let changed = false; @@ -106,12 +161,13 @@ function scrubJson(value: unknown, spellings: readonly string[]): { value: unkno } /** - * The response text with every credential spelling replaced. 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 - * escapes are decoded is withheld entirely: a handler could recover it by - * parsing the escaped part itself. + * 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 string[]): string { const text = scrub(raw, spellings); @@ -133,10 +189,12 @@ function scrubbedBody(raw: string, spellings: readonly string[]): string { } /** - * A response header with every credential spelling replaced. A value that - * still holds one once its JSON escapes are decoded (a JSON-valued header - * escaping it as `\uXXXX`, also inside a longer, joined or re-encoded value) - * is withheld entirely: a handler could recover it with `JSON.parse`. + * 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 string[]): string { const text = scrub(raw, spellings); @@ -144,12 +202,24 @@ function scrubbedHeader(raw: string, spellings: readonly string[]): string { 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 = [...new Set(secrets.filter(Boolean).flatMap(credentialSpellings))].sort((a, b) => b.length - a.length); + 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 escapes are decoded. + * 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. */ diff --git a/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts b/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts index fd695943fb..5d0fc20063 100644 --- a/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts +++ b/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts @@ -58,7 +58,8 @@ export async function fetchProbe(input: PluginHandlerInput, ctx: PluginContext) const response = await ctx.http(connection)(url); const text = await response.text(); const parsed = (() => { try { return JSON.parse(text) as PluginJsonValue; } catch { return null; } })(); - const json = await response.json(); + // 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; } }); @@ -66,16 +67,40 @@ export async function fetchProbe(input: PluginHandlerInput, ctx: PluginContext) 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: Object.values(response.headers).some(v => v.includes(probe)), parsedHeadersHaveProbe: Object.values(response.headers).some(parsedHeader), - headerNamesHaveProbe: Object.keys(response.headers).some(nameHasProbe), + 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 }; +} + /** * 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`. diff --git a/packages/core/test/pluginHandlerLifecycle.test.ts b/packages/core/test/pluginHandlerLifecycle.test.ts index 1598954c8f..e96e9b4b53 100644 --- a/packages/core/test/pluginHandlerLifecycle.test.ts +++ b/packages/core/test/pluginHandlerLifecycle.test.ts @@ -29,7 +29,7 @@ test('a JSON-escaped echo of the credential is scrubbed before the handler can d 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 }; + 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); @@ -102,6 +102,88 @@ test('text() of a non-JSON body holds no JSON-escaped credential, and a clean JS 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 } } }); From 4222b0395bcfc8ed8128cbf7ec073177e1cc8310 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 23:50:23 +0000 Subject: [PATCH 09/15] feat(ai): I fixed F16. The handler runner now re-redacts every captured log line with the full set of credentials acquired during the run when it builds the final result, so a token logged before the handler's first authenticated request no longer survives in the returned logs. I fixed F16. The handler runner now re-redacts every captured log line with the full set of credentials acquired during the run when it builds the final result, so a token logged before the handler's first authenticated request no longer survives in the returned logs. **What changed** - `packages/core/src/plugins/pluginHandlerRunner.ts`: in `finish`, the logs are mapped through the context's `redact` instead of being copied as is. The result and error were already redacted there; the logs were the gap. - `packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts`: new `logThenFetch` fixture handler, the reverse of the existing `fetchThenLog`. It logs a value from the input first, then makes the authenticated request. - `packages/core/test/pluginHandlerRunner.test.ts`: new regression test. The input carries the sentinel in plain, base64 and percent-encoded spellings. The test asserts the credential was acquired only after the line was logged, that the message and fields come back redacted, that no spelling appears anywhere in the returned run, and that the audit table never holds it. **Sibling check** Every log line goes through the single `write` closure in `pluginContext.ts`, including the `ctx.http` decision lines and the `ctx.propr` refusal lines, and the logs array is only exposed to the caller through `finish`. So one re-redaction at that point covers all paths. Audit rows written by `recordCall` hold operation metadata only, not handler input, and the existing sentinel assertion on `plugin_audit` still passes. I found no other place where captured output escapes before the credential set is complete. **Verification** - The four plugin handler suites (runner, lifecycle, `ctx.propr` client, lint) pass: 34 tests, 0 failures. - With the original runner swapped back in, the new test fails and the other six in that file still pass, so it catches the defect. - Core typecheck passes. Zero-warning lint on `src/plugins` passes. The fixture handler is linted by the existing plugin-lint test, which passes. No visual preview was created: the change is not visually perceptible. PR: #3573 Comment by: @propr-ultrafix (ID: 0) Model: Claude Fable 5.1 --- packages/core/src/plugins/pluginHandlerRunner.ts | 3 ++- .../plugins/fixture-handlers/handlers.ts | 8 ++++++++ packages/core/test/pluginHandlerRunner.test.ts | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/core/src/plugins/pluginHandlerRunner.ts b/packages/core/src/plugins/pluginHandlerRunner.ts index 1217d24f86..f3a2f1d330 100644 --- a/packages/core/src/plugins/pluginHandlerRunner.ts +++ b/packages/core/src/plugins/pluginHandlerRunner.ts @@ -99,7 +99,8 @@ export async function runPluginHandler(installation: PluginInstallationRef, hand 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')); - const result: PluginHandlerRun = { ...run, error: run.error === null ? null : redact(run.error), durationMs, logs: [...logs] }; + // Lines logged before a credential was acquired were redacted without it: redact every line again with all the credentials of this run. + const result: PluginHandlerRun = { ...run, error: run.error === null ? null : redact(run.error), durationMs, logs: logs.map(line => redact(line)) }; try { logger.info({ pluginId, handler: handlerName, outcome: result.outcome, timedOut: result.timedOut, durationMs }, '[plugins] Handler run'); } catch { /* logging never fails a run */ } return result; }; diff --git a/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts b/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts index 5d0fc20063..263b2df538 100644 --- a/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts +++ b/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts @@ -101,6 +101,14 @@ export async function fetchThenLog(input: PluginHandlerInput, ctx: PluginContext 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`. diff --git a/packages/core/test/pluginHandlerRunner.test.ts b/packages/core/test/pluginHandlerRunner.test.ts index 66af66a38b..8f04b22924 100644 --- a/packages/core/test/pluginHandlerRunner.test.ts +++ b/packages/core/test/pluginHandlerRunner.test.ts @@ -139,6 +139,22 @@ test('ctx.http injects the connection credential, which the handler and the logs 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); +}); + 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) => { From 1051eeb63fb44b676f8424ffdf697e9a6e43d9dc Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sun, 15 Nov 2026 00:22:10 +0000 Subject: [PATCH 10/15] feat(ai): Both findings are fixed, with regression tests that fail on the original sources and pass with the fix. Both findings are fixed, with regression tests that fail on the original sources and pass with the fix. **F17, revoked installation authority.** `requireInstallation` in `packages/core/src/plugins/pluginStateStore.ts` now returns the installation's current enabled state, grants and repository limit from the same transaction. A new exported `installationAuthorizer` in `pluginContext.ts` uses it in two forms. The `request` form backs authenticated `ctx.http` requests and refuses a disabled installation. The `call` form backs `ctx.propr` and the default GitHub backend: it also requires the call's permission to still be granted through the run's manifest, and the named repository to still be inside the current limit. The scoped client and the backend now pass the permission and repository of each dispatch, so the recheck right before each GitHub request and queued task covers authority, not only identity. The default connection-secret read refuses a disabled installation too. Refusals map to `unavailable` ("The plugin is disabled"), `repository_not_allowed` and `permission_denied`, and are recorded as `handler_refused` with that code. **F18, uppercase hex echoes.** Spellings in `pluginHttp.ts` are now typed with an `anyCase` flag set only for the hex encoding. Matching folds ASCII letters for that spelling alone, in literal replacement, percent-escaped replacement, the decode-and-recheck pass, header names, and the log and result redaction. Base64, URL-encoded and plain spellings stay case-sensitive, and a value differing from the secret only in case is left untouched. **Tests added** to `packages/core/test/pluginHandlerLifecycle.test.ts`: - Uppercase and mixed-case hex of the token and of the full header value, in JSON and text bodies, header values, `json()`, logs and results, plus negative cases for case-sensitive ordinary spellings. - A suspended handler whose installation is disabled, limited to another repository, or has a grant revoked cannot create an issue or task. Nothing reaches the backend and the audit trail records the refusal code. - An authenticated request suspended in the DNS lookup or the credential read is never sent after a disable. An unauthenticated request is unaffected. - The GitHub backend, held during App token fetch and queue initialisation, refuses the excluded repository while still serving an allowed one, refuses everything when disabled, and refuses a comment whose grant was revoked while still adding a label. **Verification.** Core typecheck and zero-warning lint on `src/plugins` pass. All 63 tests across the six plugin suites pass, and the 4 tests in the plugins package pass. With the original source files swapped back in, the hex test fails with the uppercase hex visible verbatim, and the two handler-level F17 tests fail with the dispatch going through. The backend-level F17 test imports the new helper and so cannot run against the old code. **Docs** in `plugin-runtime.md` and `plugins.md` now describe the live recheck and the any-case hex matching. No visual preview was produced because the change has no user-visible UI. One note outside the selected scope: `ctx.state` still operates while an installation is disabled. The finding named `ctx.propr` and authenticated HTTP, and the feature docs say state is kept across disable, so I left it unchanged. PR: #3573 Comment by: @propr-ultrafix (ID: 0) Model: Claude Fable 5.1 --- docs/docs/architecture/plugin-runtime.md | 36 +++-- docs/docs/features/plugins.md | 5 +- packages/core/src/plugins/pluginContext.ts | 56 +++++-- packages/core/src/plugins/pluginHttp.ts | 97 ++++++++---- .../core/src/plugins/pluginProprBackend.ts | 60 +++---- .../core/src/plugins/pluginProprClient.ts | 16 +- packages/core/src/plugins/pluginStateStore.ts | 30 +++- .../core/test/pluginHandlerLifecycle.test.ts | 146 +++++++++++++++++- 8 files changed, 353 insertions(+), 93 deletions(-) diff --git a/docs/docs/architecture/plugin-runtime.md b/docs/docs/architecture/plugin-runtime.md index 7c898dd12f..fe70c78b90 100644 --- a/docs/docs/architecture/plugin-runtime.md +++ b/docs/docs/architecture/plugin-runtime.md @@ -99,13 +99,19 @@ Every call is checked before it reaches the backend: `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 those of the - installation the run started with. Every call requires that installation - to still be installed, and the default backend checks again right before it - sends each GitHub request or adds a task job, after the App client or the - queue is ready. A handler that resumes after its plugin was uninstalled, or - uninstalled and reinstalled, gets `unavailable`: it creates no issue, - comment, label or task. Uninstall does not wait for a GitHub request that +- **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. 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`, @@ -163,7 +169,10 @@ the request is sent and is injected as `Authorization: Bearer `. An (`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. Each of these spellings +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 @@ -186,10 +195,13 @@ 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. 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. +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 diff --git a/docs/docs/features/plugins.md b/docs/docs/features/plugins.md index beb16a7cf1..70f8351eec 100644 --- a/docs/docs/features/plugins.md +++ b/docs/docs/features/plugins.md @@ -564,7 +564,10 @@ A call `ctx` refuses throws a `PluginSdkError` with a `code` such as 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. +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`. diff --git a/packages/core/src/plugins/pluginContext.ts b/packages/core/src/plugins/pluginContext.ts index 81cf29b377..86d90fa553 100644 --- a/packages/core/src/plugins/pluginContext.ts +++ b/packages/core/src/plugins/pluginContext.ts @@ -9,8 +9,8 @@ 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 } from './pluginProprClient.js'; -import { pluginStateStore, requireInstallation, type PluginStateWriteGuard } from './pluginStateStore.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 } @@ -39,10 +39,40 @@ export interface BuiltPluginContext { 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: PluginInstallationRecord): PluginPermission[] { +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 }); @@ -61,12 +91,13 @@ function logFields(fields: Record | undefined): Record { const { PluginStore } = await import('./pluginStore.js'); const store = new PluginStore(database, environment); return database.transaction(async trx => { - await requireInstallation(trx, pluginId, guard); + const authority = await requireInstallation(trx, pluginId, guard); + if (!authority.enabled) throw new PluginSdkError('unavailable', 'The plugin is disabled'); return store.connectionSecret(pluginId, connection, trx); }); } @@ -111,10 +142,10 @@ export function buildPluginContext(target: PluginInstallationTarget, deps: Plugi } }; - // The installation this run belongs to: a credential is read from it only, and a request is sent only while it is still installed. + // 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), or the run ended. */ - const authorizeInstallation = () => deps.database.transaction(trx => requireInstallation(trx, pluginId, installation)); + /** 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) { @@ -130,7 +161,7 @@ export function buildPluginContext(target: PluginInstallationTarget, deps: Plugi } return createPluginHttp({ outboundHosts: manifest.outboundHosts, credential, lookup: deps.lookup, transport: deps.transport, signal, - authorize: credential ? authorizeInstallation : undefined, + 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 } : {}) }), }); }; @@ -146,12 +177,13 @@ export function buildPluginContext(target: PluginInstallationTarget, deps: Plugi 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. + // 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: authorizeInstallation }), record: recordCall, + 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: authorizeInstallation, + 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. diff --git a/packages/core/src/plugins/pluginHttp.ts b/packages/core/src/plugins/pluginHttp.ts index b8133192f3..fb5f692bf0 100644 --- a/packages/core/src/plugins/pluginHttp.ts +++ b/packages/core/src/plugins/pluginHttp.ts @@ -45,15 +45,62 @@ 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): string[] { - const encodings = (value: string) => { +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); - return [value, json, json.split('/').join('\\/'), Buffer.from(value).toString('base64'), Buffer.from(value).toString('base64url'), Buffer.from(value).toString('hex'), encodeURIComponent(value)]; + 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 }]; }; - // Longest first: the header value's encodings are not made of the secret's. - return [...new Set([...encodings(`Bearer ${secret}`), ...encodings(secret)])].filter(Boolean).sort((a, b) => b.length - a.length); + 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`. */ @@ -67,7 +114,7 @@ const ASCII_PERCENT_ESCAPE = /%[0-7][0-9a-fA-F]/g; * 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 string[]): string { +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) => { @@ -80,21 +127,11 @@ function scrubPercentEscaped(text: string, spellings: readonly string[]): string while (low < high) { const middle = (low + high) >> 1; if (escapes[middle] < index) low = middle + 1; else high = middle; } return index + 2 * low; }; - const matches: [number, number][] = []; - for (const spelling of spellings) { - for (let at = decoded.indexOf(spelling); at >= 0; at = decoded.indexOf(spelling, at + 1)) matches.push([original(at), original(at + spelling.length)]); - } - if (!matches.length) return text; - // Overlapping matches (the header value holds the secret) become one replacement. - let scrubbed = ''; let cursor = 0; - for (const [start, end] of matches.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); + return redactRanges(text, spellingRanges(decoded, spellings).map(([start, end]) => [original(start), original(end)])); } -const scrub = (text: string, spellings: readonly string[]) => spellings.length - ? scrubPercentEscaped(spellings.reduce((value, spelling) => value.split(spelling).join('[REDACTED]'), text), spellings) +/** `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; @@ -123,14 +160,14 @@ const DECODED_VARIANTS = 8; * so counts too. `fold` is applied to each decoded text before comparing * (header names compare case-insensitively). */ -function revealsCredential(text: string, spellings: readonly string[], fold: (value: string) => string = value => value): boolean { +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 => { const folded = fold(variant); return spellings.some(spelling => folded.includes(spelling)); })) return true; + if ([...next].some(variant => holdsSpelling(fold(variant), spellings))) return true; variants = [...next]; } return true; @@ -142,7 +179,7 @@ function revealsCredential(text: string, spellings: readonly string[], fold: (va * (serialized JSON inside the string, a value URL-encoded twice) is withheld * whole. */ -function scrubJson(value: unknown, spellings: readonly string[]): { value: unknown; changed: boolean } { +function scrubJson(value: unknown, spellings: readonly CredentialSpelling[]): { value: unknown; changed: boolean } { let changed = false; const clean = (text: string) => { const literal = scrub(text, spellings); @@ -169,7 +206,7 @@ function scrubJson(value: unknown, spellings: readonly string[]): { value: unkno * are decoded is withheld entirely: a handler could recover it by decoding the * escaped part itself. */ -function scrubbedBody(raw: string, spellings: readonly string[]): string { +function scrubbedBody(raw: string, spellings: readonly CredentialSpelling[]): string { const text = scrub(raw, spellings); if (!spellings.length) return text; let body = text; @@ -196,7 +233,7 @@ function scrubbedBody(raw: string, spellings: readonly string[]): string { * URL-encoded twice) is withheld entirely: a handler could recover it with * `JSON.parse` or `decodeURIComponent`. */ -function scrubbedHeader(raw: string, spellings: readonly string[]): string { +function scrubbedHeader(raw: string, spellings: readonly CredentialSpelling[]): string { const text = scrub(raw, spellings); if (!spellings.length) return text; return revealsCredential(text, spellings) ? '[REDACTED]' : text; @@ -209,7 +246,7 @@ function scrubbedHeader(raw: string, spellings: readonly string[]): string { * string that still decodes to one withheld whole. */ export function scrubCredentialValues(value: T, secrets: readonly string[]): T { - const spellings = [...new Set(secrets.filter(Boolean).flatMap(credentialSpellings))].sort((a, b) => b.length - a.length); + const spellings = uniqueSpellings(secrets.filter(Boolean).flatMap(credentialSpellings)); return spellings.length ? scrubJson(value, spellings).value as T : value; } @@ -223,10 +260,10 @@ const lowerCase = (value: string) => value.toLowerCase(); * 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 string[]): boolean { +function nameRevealsCredential(name: string, spellings: readonly CredentialSpelling[]): boolean { if (!spellings.length) return false; - const folded = spellings.map(lowerCase); - return folded.some(spelling => name.includes(spelling)) || revealsCredential(name, folded, lowerCase); + 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. */ @@ -333,7 +370,7 @@ export const httpsPluginTransport: PluginHttpTransport = request => new Promise( outgoing.end(request.body); }); -function bufferedResponse(url: URL, response: PluginHttpTransportResponse, spellings: readonly string[]): PluginHttpResponse { +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) diff --git a/packages/core/src/plugins/pluginProprBackend.ts b/packages/core/src/plugins/pluginProprBackend.ts index 3987b5cbb9..07ffd0845b 100644 --- a/packages/core/src/plugins/pluginProprBackend.ts +++ b/packages/core/src/plugins/pluginProprBackend.ts @@ -1,9 +1,9 @@ import type { Knex } from 'knex'; -import { PluginSdkError, type PluginIssueSummary, type PluginPullRequestSummary, type PluginTaskSummary, type RepositoryEventDomain } from '@propr/shared'; +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 } from './pluginProprClient.js'; +import type { PluginCommentTarget, PluginProprBackend, PluginProprCallScope } from './pluginProprClient.js'; /** The part of the issue task queue the default enqueue uses. */ export interface PluginIssueQueue { @@ -29,10 +29,12 @@ export interface GitHubPluginBackendOptions { 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: throws to refuse - * it (the plugin's installation was removed or replaced meanwhile). + * 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?: () => Promise; + authorize?: (call: PluginProprCallScope) => Promise; } type GitHubIssue = { @@ -59,12 +61,12 @@ async function defaultIssueQueue(): Promise { const { getIssueQueue } = await import('../queue/taskQueue.js'); return await getIssueQueue() as unknown as PluginIssueQueue; } -function queueEnqueue(issueQueue: () => Promise, authorize: () => Promise) { - return async (data: IssueJobData, jobId: string, signal?: AbortSignal): Promise => { +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, while the queue was initialized: no job is added then. + // 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(); + await authorize(scope); await queue.add('processGitHubIssue', data, { jobId, attempts: 3, backoff: { type: 'exponential', delay: 2000 }, removeOnComplete: true, removeOnFail: true }); }; } @@ -85,30 +87,33 @@ export function githubPluginProprBackend(options: GitHubPluginBackendOptions): P 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: checked last before anything is dispatched. */ - const authorized = async () => { + /** 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(); + if (options.authorize) await options.authorize(scope); live(); }; const custom = options.enqueueIssueTask; const enqueue = custom - ? async (data: IssueJobData, jobId: string, runSignal?: AbortSignal) => { await authorized(); await custom(data, jobId, runSignal); } + ? 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) => { + const request = async (route: string, parameters: Record, scope: PluginProprCallScope) => { live(); const client = await github(); - // The run may have ended, or the plugin been uninstalled, while the App token was fetched: nothing is sent then. - await authorized(); + // 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. + * 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): Promise => { - const issue = await request('GET /repos/{owner}/{repo}/issues/{issue_number}', { ...split(repository), issue_number: number }); + 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'}`); } @@ -116,10 +121,10 @@ export function githubPluginProprBackend(options: GitHubPluginBackendOptions): P }; return { async getIssue(repository, number) { - return issueSummary(repository, await issueOfKind(repository, number, 'issue')); + 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 }); + 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 { @@ -134,26 +139,27 @@ export function githubPluginProprBackend(options: GitHubPluginBackendOptions): P 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 }); + 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, signal); + 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) { - await issueOfKind(repository, number, target); - const created = await request<{ id: number; html_url: string }>('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', { ...split(repository), issue_number: number, body }); + 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] }); + 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 }); + 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 index e64e7fea87..0508e97fc7 100644 --- a/packages/core/src/plugins/pluginProprClient.ts +++ b/packages/core/src/plugins/pluginProprClient.ts @@ -67,13 +67,17 @@ export interface ScopedProprOptions { /** 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: - * throws to refuse it (the installation the permissions and repository - * limit were read from was removed or replaced). + * 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). */ - authorize?: () => Promise; + 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. */ @@ -137,8 +141,8 @@ export function scopedProprClient(options: ScopedProprOptions): PluginProprClien 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. - if (options.authorize) await options.authorize(); + // 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`); } diff --git a/packages/core/src/plugins/pluginStateStore.ts b/packages/core/src/plugins/pluginStateStore.ts index 0753cc1c09..6f1ef8c390 100644 --- a/packages/core/src/plugins/pluginStateStore.ts +++ b/packages/core/src/plugins/pluginStateStore.ts @@ -1,5 +1,5 @@ import type { Knex } from 'knex'; -import { PLUGIN_STATE_LIMITS, PluginSdkError, type PluginJsonValue, type PluginStateStore } from '@propr/shared'; +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]+$/; @@ -38,18 +38,40 @@ 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. + * 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 { +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') as { installed_at: number | string } | undefined; + 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, + }; } /** diff --git a/packages/core/test/pluginHandlerLifecycle.test.ts b/packages/core/test/pluginHandlerLifecycle.test.ts index e96e9b4b53..d82a0e4682 100644 --- a/packages/core/test/pluginHandlerLifecycle.test.ts +++ b/packages/core/test/pluginHandlerLifecycle.test.ts @@ -2,10 +2,11 @@ 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, type PluginHttpTransport, type PluginHttpTransportRequest } from '../src/plugins/pluginHttp.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 }); @@ -449,3 +450,146 @@ test('the GitHub backend rechecks the installation after the App client or the q 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'); +}); From 6a5a4a55612f652d35f7c026548dc113c699075d Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sun, 15 Nov 2026 00:45:32 +0000 Subject: [PATCH 11/15] feat(ai): F19 is fixed. `ctx.propr.getTask` now authorizes the task's repository against the current installation once the task is read, so a repository restriction made while the handler was suspended refuses the read instead of returning the task. F19 is fixed. `ctx.propr.getTask` now authorizes the task's repository against the current installation once the task is read, so a repository restriction made while the handler was suspended refuses the read instead of returning the task. **What changed** - `packages/core/src/plugins/pluginProprClient.ts`: after the backend returns the task and the initial-limit check passes, the scoped client calls the `authorize` option again with `tasks.read` and the task's repository. The initial-limit check is retained. A repository refusal from that recheck is rethrown as `repository_not_allowed` with a message that does not name the repository, matching the existing initial-limit refusal, since the handler never supplied one. Disabled, uninstalled and revoked-grant refusals pass through unchanged. The option's doc comment describes the second call. - `packages/core/test/pluginHandlerLifecycle.test.ts`: a new regression runs a handler that suspends on a held read, applies each administrative change from the existing restriction table, then calls `getTask` for a task in the repository the run started with. The repository restriction case asserts the task was read but withheld, the refusal code and message, and the audit record. The disabled and revoked cases assert the refusal happens before the backend. An unchanged installation still returns the task. The test fails on the original source and passes with the fix. - `docs/docs/architecture/plugin-runtime.md`: one sentence added to the Installation bullet describing when `getTask` checks the task's repository. **Sibling check** `getTask` is the only `ctx.propr` operation whose repository is resolved after the backend call. Every other operation names its repository up front, and the existing per-call authorization already covers it. The default database-backed `getTask` in the backend is a local row read with no outbound dispatch, so the recheck belongs in the scoped client, which covers every backend. The remaining window between the recheck and the return is a race with an external administrative write and cannot be closed further. **Verification** - The three plugin suites pass through the repository runner: lifecycle, scoped client and handler runner, 11 files' worth of tests in the lifecycle file alone and 30 tests across the two directly affected files. - `tsc --noEmit` in `packages/core` passes. - Zero-warning ESLint passes on the changed source file. The test file is outside the core lint configuration, as before. No visual preview was created. The change has no user-visible UI. PR: #3573 Comment by: @propr-ultrafix (ID: 0) Model: Claude Fable 5.1 --- docs/docs/architecture/plugin-runtime.md | 3 +- .../core/src/plugins/pluginProprClient.ts | 14 +++++++ .../core/test/pluginHandlerLifecycle.test.ts | 37 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/docs/docs/architecture/plugin-runtime.md b/docs/docs/architecture/plugin-runtime.md index fe70c78b90..d49c56872a 100644 --- a/docs/docs/architecture/plugin-runtime.md +++ b/docs/docs/architecture/plugin-runtime.md @@ -105,7 +105,8 @@ Every call is checked before it reaches the backend: 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. A handler that resumes after its plugin was uninstalled, or + 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 diff --git a/packages/core/src/plugins/pluginProprClient.ts b/packages/core/src/plugins/pluginProprClient.ts index 0508e97fc7..7d7e38d37c 100644 --- a/packages/core/src/plugins/pluginProprClient.ts +++ b/packages/core/src/plugins/pluginProprClient.ts @@ -71,6 +71,8 @@ export interface ScopedProprOptions { * 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; } @@ -124,6 +126,16 @@ export function scopedProprClient(options: ScopedProprOptions): PluginProprClien 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]; @@ -169,6 +181,8 @@ export function scopedProprClient(options: ScopedProprOptions): PluginProprClien 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({ diff --git a/packages/core/test/pluginHandlerLifecycle.test.ts b/packages/core/test/pluginHandlerLifecycle.test.ts index d82a0e4682..9d39b65bff 100644 --- a/packages/core/test/pluginHandlerLifecycle.test.ts +++ b/packages/core/test/pluginHandlerLifecycle.test.ts @@ -593,3 +593,40 @@ test('the GitHub backend rechecks the installation\'s enabled state, grants and 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'); +}); From ab0ed1a885bf34e9b0569ba87b1d08f6604abd70 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sun, 15 Nov 2026 10:37:47 +0000 Subject: [PATCH 12/15] feat(ai): I fixed F20. Log messages and handler error messages are now redacted before they are cut to length. Before the fix, a credential sitting across the cut point came back with only its last character missing. I fixed F20. Log messages and handler error messages are now redacted before they are cut to length. Before the fix, a credential sitting across the cut point came back with only its last character missing. **What changed** - **Logs** (`packages/core/src/plugins/pluginContext.ts`): when a line is written, it is redacted and stored with 32 KiB of extra text past the 2,000-character limit. When the run ends, each line is redacted again with every credential the run acquired, and only then cut to 2,000 characters. This also covers a line logged before the credential was acquired. - **Error messages** (`packages/core/src/plugins/pluginHandlerRunner.ts`): same order. The message keeps the extra text, and `finish` cuts it to 1,000 characters only after redacting it. - **Fallback for very long credentials:** stored connection secrets are at most 4,096 characters, and the longest form a matcher looks for is about 24,600 characters, so the 32 KiB margin covers every storable secret. Only a custom `connectionSecret` source could supply a credential longer than the margin. If one is cut at the limit, the trailing characters that match its start (raw, base64, base64url, URL-encoded, hex or with `Bearer `) are dropped. - **Docs:** `docs/docs/architecture/plugin-runtime.md` now describes the order. **Regression tests** (`packages/core/test/pluginHandlerRunner.test.ts`) - A credential straddling the 2,000-character log limit, logged before (`logThenFetch`) and after (`fetchThenLog`) the credential is acquired. Also a credential straddling the 1,000-character error limit, via a new fixture `fetchThenThrow`. Each asserts that no 8-character prefix of the credential appears anywhere in the returned run. - A 40 KiB credential cut at the log limit: the returned line ends at the ordinary text, with no part of the credential. **Verification** - Both new tests fail when I put back the original two source files. The log-before-acquisition case reports "a prefix of the credential leaked". The suite stops at its first failing case, so that run didn't check the after-acquisition log case and the error case separately. - With the fix, all 70 tests in the plugin suites pass, including `packages/plugins/test/testing.test.ts`. Core typecheck passes, and zero-warning lint on `src/plugins` passes. **Found separately (not changed):** the same cut-then-redact order exists in a few short echoes of handler-supplied names inside SDK errors. These are the connection name (48 characters, `pluginContext.ts:194`), a header name (64, `pluginHttp.ts:320`) and an HTTP method (16, `pluginHttp.ts:407`). A handler would have to pass a credential as one of those names for it to show up partly. That is far-fetched, so I left them out of this fix. PR: #3573 Comment by: @integry (ID: 6893002429) Model: Claude Opus 5.5 --- docs/docs/architecture/plugin-runtime.md | 6 ++ packages/core/src/plugins/pluginContext.ts | 55 ++++++++++++++++++- .../core/src/plugins/pluginHandlerRunner.ts | 14 +++-- .../plugins/fixture-handlers/handlers.ts | 7 +++ .../core/test/pluginHandlerRunner.test.ts | 39 ++++++++++++- 5 files changed, 111 insertions(+), 10 deletions(-) diff --git a/docs/docs/architecture/plugin-runtime.md b/docs/docs/architecture/plugin-runtime.md index d49c56872a..807c333ffd 100644 --- a/docs/docs/architecture/plugin-runtime.md +++ b/docs/docs/architecture/plugin-runtime.md @@ -259,6 +259,12 @@ 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 diff --git a/packages/core/src/plugins/pluginContext.ts b/packages/core/src/plugins/pluginContext.ts index 86d90fa553..3d8f097241 100644 --- a/packages/core/src/plugins/pluginContext.ts +++ b/packages/core/src/plugins/pluginContext.ts @@ -30,9 +30,45 @@ export interface PluginContextDeps { /** What building a context returns besides `ctx`: the captured log and redaction of anything returned to the caller. */ export interface BuiltPluginContext { ctx: PluginContext; - logs: PluginLogLine[]; + /** 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. */ @@ -113,9 +149,17 @@ export function buildPluginContext(target: PluginInstallationTarget, deps: Plugi 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) => { @@ -124,7 +168,7 @@ export function buildPluginContext(target: PluginInstallationTarget, deps: Plugi 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 = String(message).slice(0, PLUGIN_LOG_LIMITS.messageChars); + 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() }); }; @@ -191,5 +235,10 @@ export function buildPluginContext(target: PluginInstallationTarget, deps: Plugi log: Object.freeze(log), signal, }; - return { ctx: Object.freeze(ctx), logs, redact }; + // 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 index f3a2f1d330..e7d2cb3663 100644 --- a/packages/core/src/plugins/pluginHandlerRunner.ts +++ b/packages/core/src/plugins/pluginHandlerRunner.ts @@ -5,7 +5,7 @@ import { } from '@propr/shared'; import logger from '../utils/logger.js'; import { officialPluginCatalog, type PluginCatalogSource } from './pluginCatalog.js'; -import { buildPluginContext, type PluginContextDeps, type PluginInstallationTarget } from './pluginContext.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. */ @@ -67,7 +67,8 @@ function resultValue(value: unknown): PluginJsonValue | undefined { return JSON.parse(text) as PluginJsonValue; } -const message = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(0, ERROR_CHARS) || 'The handler failed'; +// Kept past ERROR_CHARS: `finish` cuts it only after redacting it, so a credential straddling the limit is redacted whole. +const message = (error: unknown) => retainForRedaction(error instanceof Error ? error.message : String(error), ERROR_CHARS) || 'The handler failed'; /** 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 { @@ -92,15 +93,16 @@ async function withTimeout(run: () => Promise, timeoutMs: number, controll export async function runPluginHandler(installation: PluginInstallationRef, handlerName: string, input: PluginHandlerInput, options: RunPluginHandlerOptions): Promise { const started = performance.now(); const controller = new AbortController(); - let logs: PluginLogLine[] = []; + 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 before a credential was acquired were redacted without it: redact every line again with all the credentials of this run. - const result: PluginHandlerRun = { ...run, error: run.error === null ? null : redact(run.error), durationMs, logs: logs.map(line => redact(line)) }; + // 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; }; @@ -108,7 +110,7 @@ export async function runPluginHandler(installation: PluginInstallationRef, hand 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); - logs = built.logs; redact = built.redact; + 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) }) }); diff --git a/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts b/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts index 263b2df538..b18cb95bca 100644 --- a/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts +++ b/packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts @@ -127,6 +127,13 @@ export async function suspendThenSet(input: PluginHandlerInput, ctx: PluginConte } } +/** 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); diff --git a/packages/core/test/pluginHandlerRunner.test.ts b/packages/core/test/pluginHandlerRunner.test.ts index 8f04b22924..851dc808bc 100644 --- a/packages/core/test/pluginHandlerRunner.test.ts +++ b/packages/core/test/pluginHandlerRunner.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { PLUGIN_STATE_LIMITS, type PluginHandlerInput, type PluginJsonValue } from '@propr/shared'; +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'; @@ -155,6 +155,43 @@ test('a credential logged before the handler acquires it is redacted from the re 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) => { From 4660202fa2dd08bc66358999847b00ac9791a36f Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sun, 15 Nov 2026 11:05:46 +0000 Subject: [PATCH 13/15] feat(ai): I fixed the Shard 2/4 failure. It was a timing flake in a test this PR doesn't touch. I fixed the Shard 2/4 failure. It was a timing flake in a test this PR doesn't touch. **What failed:** one test in `packages/api/test/mcpTunnelMode.test.ts`, "a 409 keeps forcing registration through transient failures until one succeeds". It expected 5 Connect registrations and saw 4. Every other test in the shard passed. Neither this test nor the code it exercises (`packages/api/mcp/connectRegistration.ts`) has changed on this branch. **Cause:** after firing the manager's retry timer by hand, the test waited exactly 100 event-loop turns for the extra registration to arrive. That registration goes through async database and crypto work that runs on Node's background thread pool, so it doesn't finish in a fixed number of turns. On a slow or busy machine the count ran out first. I reproduced it locally: 1 failure in 15 runs of the original test. **Fix:** only that wait changed, at `packages/api/test/mcpTunnelMode.test.ts:321`. It now checks every 5 ms until the fifth registration arrives, giving up after 10 seconds. The assertions are the same, so a registration that never happens still fails the test. **Verification:** - The changed test passed 25 of 25 isolated runs, against 14 of 15 before. - The full `mcpTunnelMode.test.ts` file passes 17 of 17. - Zero-warning ESLint on the file, using the `packages/api` config, is clean. I didn't re-run the whole Shard 2/4 suite locally; the CI re-run on the next commit will confirm the shard. The "Node.js 20 is deprecated" message in the failure evidence is a warning about `actions/checkout`. It didn't cause the failure. No preview was created because nothing in this change is visible to a user. PR: #3573 Comment by: @propr-dev[bot] (ID: 6893300396) Model: Claude Opus 5.5 --- packages/api/test/mcpTunnelMode.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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); From 35afbab25c48547ca9c2b7ade2ed1f4c4fa019ee Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sun, 15 Nov 2026 11:28:37 +0000 Subject: [PATCH 14/15] feat(ai): I fixed the CI failure. The finalize step itself was fine: it failed because an earlier job hung, which caused the whole unsigned packaging matrix to be skipped. Neither the hang nor the fix involves this PR's plugin code. I fixed the CI failure. The finalize step itself was fine: it failed because an earlier job hung, which caused the whole unsigned packaging matrix to be skipped. Neither the hang nor the fix involves this PR's plugin code. **What happened in run 43707247942** - The `Prove stock Chromium existing-target axe boundary` job has to pass before the packaging matrix runs. It hit its 10-minute job timeout in the step `Require exact CSP observer regression across real Chromium navigation`. That step normally takes about 7 seconds and passed on the previous commit. - The log stops after subtest 36. The next test is the only one that drives real Chromium, and when the job was cleaned up, only a single main `chrome-headless-shell` process was still running. - So the packaging matrix finished as `skipped`, and `Finalize unsigned validation checksums` failed because of it. **Cause:** in `apps/desktop/scripts/packaged-acceptance-csp-reload.test.mjs`, the test cleans up with `t.after(() => browser.close())`. The 30-second test timeout doesn't cover cleanup, so a stuck `browser.close()` waits forever, and the open connection to Chromium stops the test process from exiting. I couldn't make the hang happen naturally: 15 local runs against the same Chromium as CI (151.0.7922.34) all passed in about a second. I did check the paths separately: - A hang inside the test body fails cleanly at 30 seconds. - A hung cleanup, simulated on the original code, kept the process alive until my outer timeout killed it. **Fix (that file only):** - The test now starts Chromium as a separate browser server that it owns, and connects to it. - Cleanup gives `close()` 10 seconds; if that runs out, it kills the Chromium process outright (SIGKILL). - The two local fixture servers are closed with `closeAllConnections()`, so a held-open connection can't keep them alive either. **Verification:** - The real-Chromium test passes in 5 of 5 runs. - The full file passes: 52 of 52 with the real-Chromium flag set as CI does it, and 51 passed with 1 skipped without it. - ESLint is clean. - With `close()` forced to hang, the new code exits after 10 seconds instead of hanging. The other failing check, `Prove stock Chromium existing-target axe boundary`, was this same hang, so this change should clear both. PR: #3573 Comment by: @propr-dev[bot] (ID: 6893504078) Model: Claude Opus 5.5 --- .../packaged-acceptance-csp-reload.test.mjs | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) 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'); From c25967814af11543d5df54a0e4100a64bdc55775 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sun, 15 Nov 2026 11:54:08 +0000 Subject: [PATCH 15/15] feat(ai): I fixed F21: `runPluginHandler` now returns a failed outcome when a handler throws a value that can't be turned into a string, instead of rejecting. I fixed F21: `runPluginHandler` now returns a failed outcome when a handler throws a value that can't be turned into a string, instead of rejecting. **Fix** (`packages/core/src/plugins/pluginHandlerRunner.ts`): getting the error message is now exception-safe. If converting the thrown value throws, or doesn't produce a non-empty string, the error is the fixed text `'The handler failed'`. While checking the rest of that catch block, I found `error instanceof HandlerTimeout` has the same problem: a proxy whose `getPrototypeOf` trap throws makes `instanceof` throw too. So that check is now inside a `try` as well. With both guarded, nothing in the catch block can throw any more. That means `finish` always runs and always aborts the run's context. **Regression test** (`packages/core/test/pluginHandlerRunner.test.ts`): a new test throws five values from a handler: - the finding's `{ message: 'Upstream failed', toString: 'upstream field' }` - `Object.create(null)` - an `Error` whose `message` isn't a string - an `Error` whose `message` getter throws - a proxy whose `getPrototypeOf` trap throws For each one it checks that the runner resolves with `outcome: 'failed'`, `timedOut: false` and the fixed message, and that the handler's `ctx.signal` is aborted. It also checks that a thrown string still comes back as its own message. **Verification:** - `pluginHandlerRunner.test.ts` passes (10/10). - With the original runner source put back, the new test fails with the `TypeError` from the finding. - ESLint (zero warnings allowed) passes on the changed source file, and `tsc --noEmit` passes for `packages/core`. ESLint ignores the test file, so it wasn't linted. **Separate, not changed:** a few other spots call `String()` on a value the handler passes in: - `pluginContext.ts:171`: the `ctx.log` message - `pluginContext.ts:197`: the connection name in an error - `pluginHttp.ts:407`: `init.method` in an error - `packages/plugins/testing/index.ts:147`: the test harness's log capture These aren't the same defect. They throw a `TypeError` back to the handler during its own call, and the runner, now fixed, still turns that into a failed outcome. At most, the handler gets a `TypeError` where it would have got a `PluginSdkError`. `pluginContext.ts:229` converts a database error from recording a call, not something the handler throws. PR: #3573 Comment by: @integry (ID: 6893865246) Model: Claude Opus 5.5 --- .../core/src/plugins/pluginHandlerRunner.ts | 24 +++++++++++++-- .../core/test/pluginHandlerRunner.test.ts | 29 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/core/src/plugins/pluginHandlerRunner.ts b/packages/core/src/plugins/pluginHandlerRunner.ts index e7d2cb3663..c698f35806 100644 --- a/packages/core/src/plugins/pluginHandlerRunner.ts +++ b/packages/core/src/plugins/pluginHandlerRunner.ts @@ -67,8 +67,26 @@ function resultValue(value: unknown): PluginJsonValue | undefined { return JSON.parse(text) as PluginJsonValue; } -// Kept past ERROR_CHARS: `finish` cuts it only after redacting it, so a credential straddling the limit is redacted whole. -const message = (error: unknown) => retainForRedaction(error instanceof Error ? error.message : String(error), ERROR_CHARS) || 'The handler failed'; +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 { @@ -115,6 +133,6 @@ export async function runPluginHandler(installation: PluginInstallationRef, hand const result = resultValue(value); return finish({ outcome: 'succeeded', timedOut: false, error: null, ...(result === undefined ? {} : { result: redact(result) }) }); } catch (error) { - return finish({ outcome: 'failed', timedOut: error instanceof HandlerTimeout, error: message(error) }); + return finish({ outcome: 'failed', timedOut: isTimeout(error), error: message(error) }); } } diff --git a/packages/core/test/pluginHandlerRunner.test.ts b/packages/core/test/pluginHandlerRunner.test.ts index 851dc808bc..faff69b4f9 100644 --- a/packages/core/test/pluginHandlerRunner.test.ts +++ b/packages/core/test/pluginHandlerRunner.test.ts @@ -51,6 +51,35 @@ test('a throwing handler, a hanging handler and other failures return a failed o 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');