Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions apps/desktop/scripts/packaged-acceptance-csp-reload.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -562,15 +562,27 @@ test('exact observer preserves native denied evidence across real Chromium navig
response.end(`<meta http-equiv="Content-Security-Policy" content="${policy}"><title>CSP fixture</title>`);
});
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');
Expand Down
306 changes: 306 additions & 0 deletions docs/docs/architecture/plugin-runtime.md

Large diffs are not rendered by default.

123 changes: 118 additions & 5 deletions docs/docs/features/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -501,3 +503,114 @@ it gets a `-2`, `-3`, … suffix, so catalog ids are always unique. Ids depend
only on the registry, not on which plugins are enabled. Disabling or
uninstalling the plugin hides its templates; automations created from them are
ordinary automations and are not changed.

## Handlers

A plugin folder may contain a `handlers.ts` that exports named async functions.
Each handler receives `(input, ctx)` and returns nothing or a small JSON result
(at most 16 KiB). `ctx` is the only way a handler touches anything. Handlers of
official plugins run in-process, but only through this interface, so the same
plugin could later run out of process. How ProPR runs them is described in
[Plugin runtime](../architecture/plugin-runtime.md).

```ts
// packages/plugins/my-plugin/handlers.ts
import { definePluginHandler, type PluginEventInput } from '@propr/shared';

export const onTaskEvent = definePluginHandler<PluginEventInput>(async (input, ctx) => {
const { event } = input;
const thread = await ctx.state.get<string>(`thread:${event.entityId}`);
const response = await ctx.http('chat-token')('https://api.chat.example/messages', {
method: 'POST', json: { thread, text: `Task ${event.entityId} is ${event.type}` },
});
if (!response.ok) ctx.log.warn('chat refused the message', { status: response.status });
});
```

| `ctx` member | What it is |
| --- | --- |
| `ctx.settings` | The installation's settings, validated against the manifest, with its defaults. Read-only. |
| `ctx.repositories` | The repository limit (`owner/repo` list), or `null` for all repositories. |
| `ctx.propr` | ProPR, limited to the granted permissions and the repository limit (below). |
| `ctx.http(connection?)` | `fetch`-like outbound HTTPS to the manifest's `outboundHosts` only. With a `token` connection name, its credential is injected. |
| `ctx.state` | A small JSON key-value store for this installation: `get`, `set`, `delete`, `keys(prefix?)`. |
| `ctx.log` | `debug`, `info`, `warn`, `error(message, fields?)`: structured lines returned with the run, redacted. |
| `ctx.signal` | Aborted when the handler runs past its 30 second limit. |

The input is one of the envelopes in `@propr/shared`: `{ kind: 'event', event }`
(a [repository journal event](../architecture/repository-event-journal.md)),
`{ kind: 'webhook', webhook, headers, body, receivedAt }`,
`{ kind: 'command', command, args, repository, actor }` or
`{ kind: 'invoke', payload }`. Delivering events, webhooks and commands to
handlers is added by later releases.

A call `ctx` refuses throws a `PluginSdkError` with a `code` such as
`permission_denied`, `repository_not_allowed`, `host_not_allowed`,
`insecure_url`, `private_address`, `connection_unavailable` or `state_limit`.

### What `ctx.propr` can do

| Operation | Permission |
| --- | --- |
| `getPullRequest(repository, number)` | `pull_requests.read` |
| `getIssue(repository, number)` | `issues.read` |
| `getTask(taskId)` | `tasks.read` |
| `listEvents({ repository, afterSeq?, domains?, limit? })` | `events.read` |
| `createIssue(repository, { title, body?, labels? })` | `issues.create` |
| `createTask(repository, issueNumber)`: an ordinary one-shot task | `tasks.create` |
| `commentOnPullRequest(repository, number, body)` | `pull_requests.comment` |
| `commentOnIssue(repository, number, body)` | `issues.comment` |
| `addLabel(repository, number, label)`, `removeLabel(…)` | `labels.write` |

A permission counts only when the administrator granted it and the current
manifest still asks for it. Each call must also stay inside the plugin's
repository limit. These are checked again, as an administrator last set them,
right before each call is dispatched: a handler that is still running when its
plugin is disabled, restricted to other repositories or uninstalled cannot act
under the authority it started with.

Every call, allowed or refused, is recorded in the plugin's audit trail. The
actor is `plugin:<id>` 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 `<!-- propr-plugin:<id> -->`. A
task it creates records `requestedBy: plugin:<id>` 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.

1 change: 1 addition & 0 deletions docs/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
{
Expand Down
9 changes: 9 additions & 0 deletions docs/static/openapi/propr-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12660,6 +12660,7 @@ components:
- issues.comment
- pull_requests.read
- pull_requests.comment
- labels.write
- repositories.read
- notifications.send
description:
Expand Down Expand Up @@ -12846,6 +12847,7 @@ components:
- issues.comment
- pull_requests.read
- pull_requests.comment
- labels.write
- repositories.read
- notifications.send
acknowledgements:
Expand Down Expand Up @@ -12949,6 +12951,7 @@ components:
- issues.comment
- pull_requests.read
- pull_requests.comment
- labels.write
- repositories.read
- notifications.send
pendingAcknowledgements:
Expand Down Expand Up @@ -13110,6 +13113,9 @@ components:
- disconnect_oauth
- set_oauth_client
- clear_oauth_client
- handler_attempt
- handler_call
- handler_refused
actor:
type: string
at:
Expand Down Expand Up @@ -13320,6 +13326,7 @@ components:
- issues.comment
- pull_requests.read
- pull_requests.comment
- labels.write
- repositories.read
- notifications.send
description:
Expand Down Expand Up @@ -13506,6 +13513,7 @@ components:
- issues.comment
- pull_requests.read
- pull_requests.comment
- labels.write
- repositories.read
- notifications.send
acknowledgements:
Expand Down Expand Up @@ -13609,6 +13617,7 @@ components:
- issues.comment
- pull_requests.read
- pull_requests.comment
- labels.write
- repositories.read
- notifications.send
pendingAcknowledgements:
Expand Down
37 changes: 37 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/<folder>/, 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'] },
{
Expand All @@ -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,
},
]
5 changes: 4 additions & 1 deletion packages/api/test/mcpTunnelMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion packages/api/test/pluginRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading