= () => ({})) => createPluginHttp({
+ outboundHosts: ['api.fixture.example'], credential: async () => ESCAPED_SECRET, lookup,
+ transport: async request => { const secret = (request.headers.authorization ?? '').slice(7); return { status: 200, headers: headers(secret), body: Buffer.from(body(secret)) }; },
+ })('https://api.fixture.example/v1');
+ const leaks = (text: string) => recoverable(text).some(value => value.includes(ESCAPED_SECRET));
+
+ // HTML: the credential \u-escaped inside a script, which the handler extracts and parses.
+ const html = await fetchWith(secret => ``);
+ const htmlText = await html.text();
+ const script = htmlText.match(/var auth = ("(?:[^"\\]|\\.)*");/);
+ assert.equal(script ? (JSON.parse(script[1]) as string).includes(ESCAPED_SECRET) : false, false, htmlText);
+ assert.equal(leaks(htmlText), false, htmlText);
+
+ // JSON holding another JSON document as a string, the credential \u-escaped inside it: that string is withheld, the rest kept.
+ const nested = await fetchWith(secret => JSON.stringify({ keep: 'visible', doc: unicodeEscapedJson({ token: secret }) }));
+ const nestedJson = await nested.json<{ keep: string; doc: string }>();
+ assert.equal(nestedJson.keep, 'visible');
+ assert.equal((() => { try { return JSON.stringify(JSON.parse(nestedJson.doc)).includes(ESCAPED_SECRET); } catch { return false; } })(), false, nestedJson.doc);
+ assert.equal(leaks(await nested.text()), false, await nested.text());
+
+ // Deeper than any fixed round count: in an HTML body and in a header, ten decodes deep, and beyond what is checked.
+ for (const rounds of [10, 40]) {
+ const deep = await fetchWith(secret => `${deeplyEscaped(secret, rounds)}
`, secret => ({ 'x-echo': `"${deeplyEscaped(secret, rounds)}"` }));
+ assert.equal(leaks(await deep.text()), false, `body, ${rounds} rounds`);
+ assert.equal(leaks(deep.headers['x-echo']), false, `header, ${rounds} rounds`);
+ }
+ assert.ok(recoverable(`${deeplyEscaped(ESCAPED_SECRET, 10)}
`).some(value => value.includes(ESCAPED_SECRET)), 'the probe does recover an unscrubbed deep escape');
+
+ // Escapes that spell nothing secret reach the handler unchanged.
+ const clean = '';
+ assert.equal(await (await fetchWith(() => clean)).text(), clean);
+ const cleanNested = JSON.stringify({ doc: unicodeEscapedJson({ token: 'not the credential' }) });
+ assert.equal(await (await fetchWith(() => cleanNested)).text(), cleanNested);
+});
+
+test('a handler resuming after its plugin was uninstalled cannot create an issue or a task, nor act for a reinstall', async t => {
+ for (const [operation, args] of [['createIssue', ['owner/allowed', { title: 'late', body: 'after uninstall' }]], ['createTask', ['owner/allowed', 3]], ['commentOnIssue', ['owner/allowed', 3, 'late']]] as const) {
+ for (const reinstall of [false, true]) {
+ const f = await pluginHandlerFixture(t);
+ const held = deferred();
+ const reached = deferred();
+ const backend = { ...f.backend, getIssue: async () => { reached.resolve(); await held.promise; throw new Error('released'); } };
+ const running = runPluginHandler(FIXTURE_ID, 'suspendThenPropr', invoke({ operation, args: args as unknown as PluginJsonValue[] }), { ...f.options, backend });
+ await reached.promise;
+
+ await f.store.setEnabled('1', FIXTURE_ID, false);
+ await f.store.uninstall('1', FIXTURE_ID, FIXTURE_ID);
+ if (reinstall) {
+ const later = new PluginStore(f.database, ENVIRONMENT, async () => [f.manifest], () => Date.now() + 60_000);
+ await later.install('1', FIXTURE_ID);
+ await later.setRepositories('1', FIXTURE_ID, ['owner/allowed']);
+ await later.setEnabled('1', FIXTURE_ID, true);
+ }
+ held.resolve();
+ const run = await running;
+ const label = `${operation}, reinstall: ${reinstall}`;
+ assert.equal(run.outcome, 'succeeded', run.error ?? '');
+ assert.deepEqual(run.result, { ok: false, code: 'unavailable', message: 'The plugin is no longer installed' }, label);
+ assert.deepEqual(f.calls, [], `${label}: nothing reached the backend`);
+ // The held read is recorded too (it failed); the mutation after the uninstall is recorded as refused.
+ const refused = (await f.database('plugin_audit').where({ plugin_id: FIXTURE_ID, action: 'handler_refused' }).orderBy('id').select('detail')).map(row => JSON.parse(row.detail) as { operation: string; reason: string });
+ assert.deepEqual(refused.map(r => [r.operation, r.reason]), [['getIssue', 'unavailable'], [operation, 'unavailable']], label);
+ }
+ }
+});
+
+test('the GitHub backend rechecks the installation after the App client or the queue is ready, before dispatching', async t => {
+ const f = await pluginHandlerFixture(t);
+ const installedAt = Number((await f.database('plugin_installations').where({ plugin_id: FIXTURE_ID }).first('installed_at')).installed_at);
+ const signal = new AbortController().signal;
+ const authorize = () => f.database.transaction(trx => requireInstallation(trx, FIXTURE_ID, { installedAt, signal }));
+ const sent: string[] = [];
+ const backend = githubPluginProprBackend({
+ database: f.database, signal, authorize,
+ github: async () => ({ request: async (route: string) => { sent.push(route); return { data: { number: 1, html_url: 'u' } }; } }),
+ issueQueue: async () => ({ add: async (_name: string, _data: unknown, options: Record) => { sent.push(String(options.jobId)); } }),
+ });
+ // A live installation is served.
+ assert.equal((await backend.createIssue('owner/allowed', { title: 't', body: 'b', labels: [] })).number, 1);
+ assert.equal((await backend.createTask('owner/allowed', 3, { requestedBy: 'plugin:x' })).jobId, 'issue-owner-allowed-3');
+ assert.deepEqual(sent, ['POST /repos/{owner}/{repo}/issues', 'issue-owner-allowed-3']);
+ sent.length = 0;
+
+ // Uninstalled while the App token is fetched and while the queue is initialized.
+ const held = { token: deferred(), queue: deferred() };
+ const heldBackend = githubPluginProprBackend({
+ database: f.database, signal, authorize,
+ github: async () => { await held.token.promise; return { request: async (route: string) => { sent.push(route); return { data: {} }; } }; },
+ issueQueue: async () => { await held.queue.promise; return { add: async (_n: string, _d: unknown, options: Record) => { sent.push(String(options.jobId)); } }; },
+ });
+ const issue = heldBackend.createIssue('owner/allowed', { title: 't', body: 'b', labels: [] });
+ const task = heldBackend.createTask('owner/allowed', 3, { requestedBy: 'plugin:x' });
+ await tick();
+ await f.store.setEnabled('1', FIXTURE_ID, false);
+ await f.store.uninstall('1', FIXTURE_ID, FIXTURE_ID);
+ held.token.resolve(); held.queue.resolve();
+ await assert.rejects(issue, { code: 'unavailable', message: 'The plugin is no longer installed' });
+ await assert.rejects(task, { code: 'unavailable', message: 'The plugin is no longer installed' });
+ // A custom enqueue is refused the same way.
+ const custom = githubPluginProprBackend({ database: f.database, signal, authorize, enqueueIssueTask: async (_data, jobId) => { sent.push(jobId); } });
+ await assert.rejects(custom.createTask('owner/allowed', 3, { requestedBy: 'plugin:x' }), { code: 'unavailable' });
+ assert.deepEqual(sent, [], 'nothing was sent or queued');
+});
+
+/** A credential with letters in both cases: its hex encoding differs from itself in any case, and its own case matters. */
+const HEX_SECRET = 'Plugin-Credential-Sentinel-5d0e9a';
+const upperHex = (value: string) => Buffer.from(value).toString('hex').toUpperCase();
+/** Hex digits alternating in case, as a handler could not care about: `Buffer.from(mixed, 'hex')` decodes it all the same. */
+const mixedHex = (value: string) => [...Buffer.from(value).toString('hex')].map((c, i) => (i % 2 ? c.toUpperCase() : c)).join('');
+/** Whether `text` holds a run of hex digits, in any case, that decodes to `secret` (what a handler recovers with `Buffer.from(run, 'hex')`). */
+const hexRecovers = (text: string, secret: string) => (text.match(/[0-9a-fA-F]{2,}/g) ?? []).some(run => Buffer.from(run, 'hex').toString('utf8').includes(secret));
+
+test('an uppercase or mixed-case hex echo of the credential is scrubbed from the body, the headers, the logs and the result', async t => {
+ const f = await pluginHandlerFixture(t, { secret: HEX_SECRET });
+ const echo = (encode: (value: string) => string, body: 'json' | 'text'): PluginHttpTransport => async request => {
+ const authorization = request.headers.authorization ?? '';
+ const secret = encode(authorization.slice('Bearer '.length));
+ const header = encode(authorization);
+ return {
+ status: 200, headers: { 'x-echo-token': secret, 'x-echo-header': `v=${header}; path=/` },
+ body: Buffer.from(body === 'json' ? JSON.stringify({ token: secret, header, keep: 'visible' }) : `token=${secret}&header=${header}&keep=visible`),
+ };
+ };
+ for (const [name, encode] of [['uppercase hex', upperHex], ['mixed-case hex', mixedHex]] as const) {
+ for (const body of ['json', 'text'] as const) {
+ const run = await runPluginHandler(FIXTURE_ID, 'fetchUrl', invoke({ url: 'https://api.fixture.example/v1', connection: 'api-token' }), { ...f.options, transport: echo(encode, body) });
+ const label = `${name}, ${body} body`;
+ assert.equal(run.outcome, 'succeeded', run.error ?? '');
+ const result = run.result as { body: string; headers: Record };
+ // Replaced in place: the rest of the value is kept.
+ assert.equal(result.body, body === 'json' ? JSON.stringify({ token: '[REDACTED]', header: '[REDACTED]', keep: 'visible' }) : 'token=[REDACTED]&header=[REDACTED]&keep=visible', label);
+ assert.deepEqual(result.headers, { 'x-echo-token': '[REDACTED]', 'x-echo-header': 'v=[REDACTED]; path=/' }, label);
+ // Nothing handler-visible (the result, its logged copy) decodes to the credential.
+ assert.equal(hexRecovers(JSON.stringify(run), HEX_SECRET), false, label);
+ }
+ // json() of the parsed body, through the HTTP client directly.
+ const response = await createPluginHttp({ outboundHosts: ['api.fixture.example'], credential: async () => HEX_SECRET, lookup, transport: echo(encode, 'json') })('https://api.fixture.example/v1');
+ assert.deepEqual(await response.json(), { token: '[REDACTED]', header: '[REDACTED]', keep: 'visible' }, name);
+ }
+ // The same spellings in a log line and a result, through the run's redaction.
+ assert.deepEqual(scrubCredentialValues({ a: `id ${upperHex(HEX_SECRET)} b`, [mixedHex(`Bearer ${HEX_SECRET}`)]: 1 }, [HEX_SECRET]), { a: 'id [REDACTED] b', '[REDACTED]': 1 });
+ // Ordinary spellings stay case-sensitive: another case of the secret is a different value, and so is its base64.
+ const other = { a: HEX_SECRET.toUpperCase(), b: Buffer.from(HEX_SECRET).toString('base64').toLowerCase(), c: 'ABCDEF0123' };
+ assert.deepEqual(scrubCredentialValues(other, [HEX_SECRET]), other);
+ const kept = await createPluginHttp({ outboundHosts: ['api.fixture.example'], credential: async () => HEX_SECRET, lookup, transport: async () => ({ status: 200, headers: { 'x-id': 'DEADBEEF' }, body: Buffer.from(HEX_SECRET.toUpperCase()) }) })('https://api.fixture.example/v1');
+ assert.equal(await kept.text(), HEX_SECRET.toUpperCase());
+ assert.equal(kept.headers['x-id'], 'DEADBEEF');
+});
+
+/** Administrative changes to the fixture installation while a handler is suspended, and the refusal each must cause. */
+const RESTRICTIONS = [
+ ['disabled', async (f: Awaited>) => { await f.store.setEnabled('1', FIXTURE_ID, false); }, { code: 'unavailable', message: 'The plugin is disabled' }],
+ ['limited to another repository', async (f: Awaited>) => { await f.store.setRepositories('1', FIXTURE_ID, ['owner/other']); }, { code: 'repository_not_allowed', message: 'This plugin is no longer enabled for owner/allowed' }],
+ ['permission revoked', async (f: Awaited>) => {
+ const row = await f.database('plugin_installations').where({ plugin_id: FIXTURE_ID }).first('granted_permissions');
+ const granted = (JSON.parse(row.granted_permissions) as string[]).filter(permission => permission !== 'issues.create' && permission !== 'tasks.create');
+ await f.database('plugin_installations').where({ plugin_id: FIXTURE_ID }).update({ granted_permissions: JSON.stringify(granted) });
+ }, { code: 'permission_denied', message: /is no longer granted to this plugin$/ }],
+] as const;
+
+test('a handler resuming after its installation was disabled or restricted cannot create an issue or a task', async t => {
+ for (const [operation, args] of [['createIssue', ['owner/allowed', { title: 'late', body: 'after restriction' }]], ['createTask', ['owner/allowed', 3]]] as const) {
+ for (const [change, restrict, expected] of RESTRICTIONS) {
+ const f = await pluginHandlerFixture(t);
+ const held = deferred();
+ const reached = deferred();
+ const backend = { ...f.backend, getIssue: async () => { reached.resolve(); await held.promise; throw new Error('released'); } };
+ const running = runPluginHandler(FIXTURE_ID, 'suspendThenPropr', invoke({ operation, args: args as unknown as PluginJsonValue[] }), { ...f.options, backend });
+ await reached.promise;
+ await restrict(f);
+ held.resolve();
+ const run = await running;
+ const label = `${operation}, ${change}`;
+ assert.equal(run.outcome, 'succeeded', run.error ?? '');
+ const result = run.result as { ok: boolean; code: string; message: string };
+ assert.equal(result.ok, false, label);
+ assert.equal(result.code, expected.code, label);
+ assert.match(result.message, expected.message instanceof RegExp ? expected.message : new RegExp(`^${expected.message}$`), label);
+ assert.deepEqual(f.calls, [], `${label}: nothing reached the backend`);
+ // The mutation is recorded as refused with the refusal's code; no attempt was recorded for it.
+ const audit = (await f.database('plugin_audit').where({ plugin_id: FIXTURE_ID }).whereIn('action', ['handler_attempt', 'handler_refused']).orderBy('id').select('action', 'detail'))
+ .map(row => [row.action, (JSON.parse(row.detail) as { operation: string; reason?: string }).operation, (JSON.parse(row.detail) as { reason?: string }).reason]);
+ assert.deepEqual(audit, [['handler_refused', 'getIssue', 'unavailable'], ['handler_refused', operation, expected.code]], label);
+ }
+ }
+});
+
+test('an authenticated request resuming after its installation was disabled is never sent', async t => {
+ const f = await pluginHandlerFixture(t, { secret: HEX_SECRET });
+ for (const stage of ['lookup', 'credential'] as const) {
+ const held = deferred();
+ const reached = deferred();
+ const heldLookup = async (host: string) => { if (stage === 'lookup') { reached.resolve(); await held.promise; } return lookup(host); };
+ const connectionSecret = stage === 'credential' ? async () => { reached.resolve(); await held.promise; return HEX_SECRET; } : undefined;
+ const running = runPluginHandler(FIXTURE_ID, 'fetchUrl', invoke({ url: 'https://api.fixture.example/v1/mutate', connection: 'api-token', init: { method: 'POST', json: { change: true } } }), { ...f.options, lookup: heldLookup, ...(connectionSecret ? { connectionSecret } : {}) });
+ await reached.promise;
+ await f.store.setEnabled('1', FIXTURE_ID, false);
+ held.resolve();
+ const run = await running;
+ assert.equal(run.outcome, 'succeeded', run.error ?? '');
+ assert.deepEqual(run.result, { ok: false, code: 'unavailable', message: 'The plugin is disabled' }, stage);
+ assert.equal(f.http.requests.length, 0, `${stage}: nothing reached the transport`);
+ assert.equal(run.logs.some(line => line.message === 'ctx.http request to api.fixture.example refused: unavailable'), true, stage);
+ await f.store.setEnabled('1', FIXTURE_ID, true);
+ }
+ // An unauthenticated request carries no credential of the installation and is not bound to its enabled state.
+ await f.store.setEnabled('1', FIXTURE_ID, false);
+ const plain = await createPluginHttp({ outboundHosts: ['api.fixture.example'], lookup, transport: f.http.transport })('https://api.fixture.example/v1');
+ assert.equal(plain.status, 200);
+});
+
+test('the GitHub backend rechecks the installation\'s enabled state, grants and repository limit right before dispatching', async t => {
+ const f = await pluginHandlerFixture(t, { repositories: ['owner/allowed', 'owner/second'] });
+ const installedAt = Number((await f.database('plugin_installations').where({ plugin_id: FIXTURE_ID }).first('installed_at')).installed_at);
+ const authorize = installationAuthorizer(f.database, FIXTURE_ID, f.manifest, { installedAt, signal: new AbortController().signal }).call;
+ const sent: string[] = [];
+ const held = { token: deferred(), queue: deferred() };
+ const backend = githubPluginProprBackend({
+ database: f.database, signal: new AbortController().signal, authorize,
+ github: async () => { await held.token.promise; return { request: async (route: string) => { sent.push(route); return { data: { number: 1, html_url: 'u', id: 5 } }; } }; },
+ issueQueue: async () => { await held.queue.promise; return { add: async (_n: string, _d: unknown, options: Record) => { sent.push(String(options.jobId)); } }; },
+ });
+ // The limit changes to exclude owner/allowed while the App token is fetched and the queue is initialized: nothing for it is sent, a call on owner/second is.
+ const refused = [backend.createIssue('owner/allowed', { title: 't', body: 'b', labels: [] }), backend.createTask('owner/allowed', 3, { requestedBy: 'plugin:x' }), backend.addLabel('owner/allowed', 3, 'late')];
+ const allowed = backend.createIssue('owner/second', { title: 't', body: 'b', labels: [] });
+ await tick();
+ await f.store.setRepositories('1', FIXTURE_ID, ['owner/second']);
+ held.token.resolve(); held.queue.resolve();
+ for (const call of refused) await assert.rejects(call, { code: 'repository_not_allowed', message: 'This plugin is no longer enabled for owner/allowed' });
+ assert.equal((await allowed).number, 1);
+ assert.deepEqual(sent, ['POST /repos/{owner}/{repo}/issues'], 'only the still-allowed repository was reached');
+ sent.length = 0;
+ // Disabled: every dispatch is refused, the custom enqueue included; reads too.
+ await f.store.setEnabled('1', FIXTURE_ID, false);
+ await assert.rejects(backend.createIssue('owner/second', { title: 't', body: 'b', labels: [] }), { code: 'unavailable', message: 'The plugin is disabled' });
+ await assert.rejects(backend.getIssue('owner/second', 3), { code: 'unavailable', message: 'The plugin is disabled' });
+ const custom = githubPluginProprBackend({ database: f.database, authorize, enqueueIssueTask: async (_data, jobId) => { sent.push(jobId); } });
+ await assert.rejects(custom.createTask('owner/second', 3, { requestedBy: 'plugin:x' }), { code: 'unavailable', message: 'The plugin is disabled' });
+ await f.store.setEnabled('1', FIXTURE_ID, true);
+ // A grant revoked: the commenting read and the comment carry the comment permission, the label the label permission.
+ const row = await f.database('plugin_installations').where({ plugin_id: FIXTURE_ID }).first('granted_permissions');
+ await f.database('plugin_installations').where({ plugin_id: FIXTURE_ID }).update({ granted_permissions: JSON.stringify((JSON.parse(row.granted_permissions) as string[]).filter(p => p !== 'issues.comment')) });
+ await assert.rejects(backend.comment('owner/second', 3, 'late', 'issue'), { code: 'permission_denied' });
+ await backend.addLabel('owner/second', 3, 'ok');
+ assert.deepEqual(sent, ['POST /repos/{owner}/{repo}/issues/{issue_number}/labels'], 'the revoked permission\'s read was not sent either');
+});
+
+test('a handler resuming after its repository limit changed cannot read a task of the repository it started with', async t => {
+ // getTask names no repository, so the authorization before the read passes on tasks.read alone; the task's repository is checked once it is read.
+ for (const [change, restrict, expected] of RESTRICTIONS) {
+ const f = await pluginHandlerFixture(t);
+ const held = deferred();
+ const reached = deferred();
+ const backend = { ...f.backend, getIssue: async () => { reached.resolve(); await held.promise; throw new Error('released'); } };
+ const running = runPluginHandler(FIXTURE_ID, 'suspendThenPropr', invoke({ operation: 'getTask', args: ['task-1'] }), { ...f.options, backend });
+ await reached.promise;
+ if (change === 'permission revoked') {
+ await f.database('plugin_installations').where({ plugin_id: FIXTURE_ID }).update({ granted_permissions: JSON.stringify(['issues.read']) });
+ } else {
+ await restrict(f);
+ }
+ held.resolve();
+ const run = await running;
+ assert.equal(run.outcome, 'succeeded', run.error ?? '');
+ const result = run.result as { ok: boolean; code: string; message: string; value?: unknown };
+ assert.equal(result.ok, false, change);
+ assert.equal(result.code, expected.code, change);
+ assert.equal(result.value, undefined, `${change}: the task was not returned`);
+ // The limit changed: the refusal names the task's repository no more than the handler did.
+ if (change === 'limited to another repository') assert.equal(result.message, 'This plugin is no longer enabled for the task\'s repository');
+ else assert.match(result.message, expected.message instanceof RegExp ? expected.message : new RegExp(`^${expected.message}$`), change);
+ // Only the repository restriction is discovered after the read: the task itself is read, then withheld. The other changes refuse before the backend.
+ assert.deepEqual(f.calls.map(c => c.operation), change === 'limited to another repository' ? ['getTask'] : [], change);
+ const refused = (await f.database('plugin_audit').where({ plugin_id: FIXTURE_ID, action: 'handler_refused' }).orderBy('id').select('detail')).map(row => JSON.parse(row.detail) as { operation: string; reason: string });
+ assert.deepEqual(refused.map(r => [r.operation, r.reason]), [['getIssue', 'unavailable'], ['getTask', expected.code]], change);
+ }
+ // Unchanged, the same suspended read returns the task: the recheck refuses only what the administrator took away.
+ const f = await pluginHandlerFixture(t);
+ const backend = { ...f.backend, getIssue: async () => { throw new Error('released'); } };
+ const run = await runPluginHandler(FIXTURE_ID, 'suspendThenPropr', invoke({ operation: 'getTask', args: ['task-1'] }), { ...f.options, backend });
+ assert.equal((run.result as { ok: boolean; value: { repository: string } }).ok, true);
+ assert.equal((run.result as { ok: boolean; value: { repository: string } }).value.repository, 'owner/allowed');
+});
diff --git a/packages/core/test/pluginHandlerLint.test.ts b/packages/core/test/pluginHandlerLint.test.ts
new file mode 100644
index 0000000000..22a12bb790
--- /dev/null
+++ b/packages/core/test/pluginHandlerLint.test.ts
@@ -0,0 +1,57 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { ESLint } from 'eslint';
+import { FIXTURE_REGISTRY } from './helpers/pluginHandlerFixture.js';
+
+/**
+ * The restricted-imports rule for `packages/plugins/**` (root eslint.config.js):
+ * plugin code imports nothing from ProPR but `@propr/shared`. The fixture
+ * handlers are linted as if they were plugin folders of the registry package.
+ */
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
+const eslint = new ESLint({ cwd: ROOT, overrideConfigFile: path.join(ROOT, 'eslint.config.js') });
+const lintAsPlugin = async (fixture: string, code = readFileSync(path.join(FIXTURE_REGISTRY, fixture, 'handlers.ts'), 'utf8')) => {
+ const [result] = await eslint.lintText(code, { filePath: path.join(ROOT, 'packages', 'plugins', fixture, 'handlers.ts') });
+ return result.messages;
+};
+
+test('the restricted-imports rule fails a fixture plugin that imports core modules', async () => {
+ const messages = await lintAsPlugin('fixture-imports-core');
+ assert.deepEqual(messages.map(m => [m.ruleId, m.line, m.severity]), [
+ ['no-restricted-imports', 5, 2],
+ ['no-restricted-imports', 6, 2],
+ ['no-restricted-syntax', 10, 2],
+ ], JSON.stringify(messages, null, 2));
+ assert.ok(messages.every(m => /only @propr\/shared/.test(m.message)));
+});
+
+test('a plugin importing only @propr/shared passes, and every way into ProPR internals is refused', async () => {
+ assert.deepEqual(await lintAsPlugin('fixture-handlers'), []);
+ const refused = [
+ "import { db } from '@propr/core';",
+ "import type { PluginStore } from '@propr/core';",
+ "import { x } from '@propr/api/routes/pluginRoutes.js';",
+ "import { x } from '../../core/src/db/connection.js';",
+ "import { x } from '../../api/server.js';",
+ "import { x } from '../../shared/src/plugins.js';",
+ "import { x } from '../../../src/worker.js';",
+ "import { x } from '../../../packages/core/src/index.js';",
+ "export { x } from '@propr/core';",
+ "const m = await import('@propr/core'); export default m;",
+ "const m = await import('../../core/src/index.js'); export default m;",
+ ];
+ for (const code of refused) {
+ const messages = await lintAsPlugin('some-plugin', `${code}\n`);
+ assert.ok(messages.some(m => m.ruleId === 'no-restricted-imports' || m.ruleId === 'no-restricted-syntax'), `${code} was not refused`);
+ }
+ const allowed = [
+ "import { PluginSdkError, type PluginContext } from '@propr/shared';",
+ "import { createHash } from 'node:crypto';",
+ "import { helper } from './helper.js';",
+ "import { officialPlugins } from '../src/index.js';",
+ ];
+ for (const code of allowed) assert.deepEqual(await lintAsPlugin('some-plugin', `${code}\nexport { };\n`), [], code);
+});
diff --git a/packages/core/test/pluginHandlerRunner.test.ts b/packages/core/test/pluginHandlerRunner.test.ts
new file mode 100644
index 0000000000..faff69b4f9
--- /dev/null
+++ b/packages/core/test/pluginHandlerRunner.test.ts
@@ -0,0 +1,251 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { PLUGIN_LOG_LIMITS, PLUGIN_STATE_LIMITS, type PluginHandlerInput, type PluginJsonValue } from '@propr/shared';
+import { runPluginHandler } from '../src/plugins/pluginHandlerRunner.js';
+import { FIXTURE_ID, pluginHandlerFixture } from './helpers/pluginHandlerFixture.js';
+
+const invoke = (payload: PluginJsonValue = {}): PluginHandlerInput => ({ kind: 'invoke', payload });
+const SENTINEL = 'plugin-http-credential-sentinel-5d0e9a';
+
+test('runPluginHandler runs a fixture handler and returns its outcome, result, duration and redacted logs', async t => {
+ const f = await pluginHandlerFixture(t);
+ const run = await runPluginHandler(FIXTURE_ID, 'succeed', invoke(), f.options);
+ assert.equal(run.outcome, 'succeeded', run.error ?? '');
+ assert.equal(run.timedOut, false);
+ assert.equal(run.error, null);
+ assert.ok(run.durationMs >= 0);
+ assert.deepEqual(run.result, { greeting: 'hi', repositories: ['owner/allowed'] });
+ assert.deepEqual(run.logs.map(l => [l.level, l.message]).slice(0, 1), [['info', 'fixture handler ran']]);
+ assert.deepEqual(run.logs[0].fields, { kind: 'invoke', greeting: 'hi' });
+ assert.doesNotMatch(JSON.stringify(run.logs), /ghp_0123456789/, 'log lines pass the secret redaction');
+ assert.match(run.logs[1].message, /REDACTED/);
+});
+
+test('a throwing handler, a hanging handler and other failures return a failed outcome without throwing', async t => {
+ const f = await pluginHandlerFixture(t);
+ const thrown = await runPluginHandler(FIXTURE_ID, 'throws', invoke(), f.options);
+ assert.equal(thrown.outcome, 'failed');
+ assert.equal(thrown.timedOut, false);
+ assert.equal(thrown.error, 'fixture handler failure');
+ assert.deepEqual(thrown.logs.map(l => l.message), ['about to fail']);
+
+ const started = Date.now();
+ const hung = await runPluginHandler(FIXTURE_ID, 'hangs', invoke(), { ...f.options, timeoutMs: 150 });
+ assert.equal(hung.outcome, 'failed');
+ assert.equal(hung.timedOut, true);
+ assert.match(hung.error ?? '', /did not finish within/);
+ assert.ok(Date.now() - started < 5_000);
+ assert.deepEqual(hung.logs.map(l => l.message), ['hanging'], 'nothing the handler does after the time limit is logged');
+ await new Promise(resolve => setTimeout(resolve, 20));
+ assert.equal(await f.database('plugin_state').where({ plugin_id: FIXTURE_ID, state_key: 'late' }).first(), undefined, 'ctx refuses calls once the run has ended');
+
+ for (const [handler, pattern] of [['missing', /has no handler missing/], ['notAFunction', /has no handler/], ['returnsTooMuch', /more than 16384 bytes/], ['constructor', /has no handler/], ['../x', /Invalid handler name/]] as const) {
+ const run = await runPluginHandler(FIXTURE_ID, handler, invoke(), f.options);
+ assert.equal(run.outcome, 'failed', handler);
+ assert.match(run.error ?? '', pattern, handler);
+ }
+ // A broken handler source, an unknown plugin and a disabled plugin fail the same way.
+ assert.equal((await runPluginHandler(FIXTURE_ID, 'succeed', invoke(), { ...f.options, handlers: async () => { throw new Error('registry unavailable'); } })).error, 'registry unavailable');
+ assert.match((await runPluginHandler('not-installed', 'succeed', invoke(), f.options)).error ?? '', /not in this ProPR's registry/);
+ await f.store.setEnabled('1', FIXTURE_ID, false);
+ assert.match((await runPluginHandler(FIXTURE_ID, 'succeed', invoke(), f.options)).error ?? '', /is not enabled/);
+});
+
+test('a handler throwing a value that cannot be converted to a string returns a failed outcome and ends the run', async t => {
+ const f = await pluginHandlerFixture(t);
+ const signals: AbortSignal[] = [];
+ const throwing = (value: () => unknown) => ({ ...f.options, handlers: async () => ({
+ throwsValue: async (_input: PluginHandlerInput, ctx: { signal: AbortSignal }) => { signals.push(ctx.signal); throw value(); },
+ }) });
+ const nonStringMessage = () => Object.assign(new Error('x'), { message: { toString: 'upstream field' } });
+ const throwingMessage = () => Object.defineProperty(new Error('x'), 'message', { get() { throw new Error('getter'); } });
+ const trappedProxy = () => new Proxy({}, { getPrototypeOf() { throw new Error('trap'); } });
+ const cases: [string, () => unknown][] = [
+ // JSON-compatible: an invocation payload can carry it.
+ ['own non-callable toString', () => ({ message: 'Upstream failed', toString: 'upstream field' })],
+ ['null prototype', () => Object.create(null)],
+ ['Error with a non-string message', nonStringMessage],
+ ['Error with a throwing message getter', throwingMessage],
+ ['proxy whose getPrototypeOf trap throws', trappedProxy],
+ ];
+ for (const [name, value] of cases) {
+ const run = await runPluginHandler(FIXTURE_ID, 'throwsValue', invoke(), throwing(value));
+ assert.equal(run.outcome, 'failed', name);
+ assert.equal(run.timedOut, false, name);
+ assert.equal(run.error, 'The handler failed', name);
+ assert.equal(signals.at(-1)?.aborted, true, `${name}: the run's context is aborted`);
+ }
+ assert.equal(signals.length, cases.length);
+ // A primitive still gives its own message.
+ assert.equal((await runPluginHandler(FIXTURE_ID, 'throwsValue', invoke(), throwing(() => 'plain text'))).error, 'plain text');
+});
+
+test('the official registry loads example-pack handlers through the plugin registry', async t => {
+ const f = await pluginHandlerFixture(t);
+ const { getOfficialPlugin } = await import('../../plugins/src/index.js');
+ const example = getOfficialPlugin('example-pack', { includeDevelopment: true })!;
+ assert.ok(example.assets.handlers?.endsWith('example-pack/handlers.ts'));
+ const { loadPluginHandlers } = await import('../../plugins/src/index.js');
+ const handlers = await loadPluginHandlers('example-pack', { includeDevelopment: true });
+ assert.equal(typeof handlers?.recordEvent, 'function');
+ assert.equal(await loadPluginHandlers('example-pack', { includeDevelopment: false }), null, 'a development plugin is hidden without the flag');
+ const record = { installedVersion: '0.1.0', enabled: true, settings: {}, repositories: null, grantedPermissions: example.manifest.permissions, acknowledgements: {}, installedBy: '1', installedAt: 0, updatedBy: '1', updatedAt: 0 };
+ // ctx.state writes need the installation to exist: the row the record describes.
+ await f.database('plugin_installations').insert({ plugin_id: 'example-pack', manifest_version: '0.1.0', enabled: true, granted_permissions: JSON.stringify(example.manifest.permissions), installed_by: '1', installed_at: 0, updated_by: '1', updated_at: 0 });
+ const run = await runPluginHandler({ pluginId: 'example-pack', manifest: example.manifest, record }, 'recordEvent', {
+ kind: 'event', event: { seq: 12, repository: 'owner/allowed', domain: 'task', type: 'completed', entityId: 'task-1', prNumber: null, issueNumber: 3, planId: null, headSha: null, payload: {}, occurredAt: '' },
+ }, { ...f.options, handlers: async id => loadPluginHandlers(id, { includeDevelopment: true }) });
+ assert.equal(run.outcome, 'succeeded', run.error ?? '');
+ assert.deepEqual(run.result, { repository: 'owner/allowed', cursor: 12 });
+ assert.match(run.logs[0].message, /task task-1 is completed/);
+});
+
+test('ctx.state enforces its key, size and key-count limits and is deleted on uninstall', async t => {
+ const f = await pluginHandlerFixture(t);
+ const state = async (payload: PluginJsonValue) => (await runPluginHandler(FIXTURE_ID, 'useState', invoke(payload), f.options)).result as { ok: boolean; value?: PluginJsonValue; code?: string };
+
+ assert.deepEqual(await state({ op: 'set', key: 'pr:owner/allowed#7', value: { thread: 'C123/1700000000.1' } }), { ok: true });
+ assert.deepEqual(await state({ op: 'get', key: 'pr:owner/allowed#7' }), { ok: true, value: { thread: 'C123/1700000000.1' } });
+ assert.deepEqual(await state({ op: 'keys', prefix: 'pr:' }), { ok: true, value: ['pr:owner/allowed#7'] });
+
+ // Too large a value, a bad key and an over-long key are refused.
+ assert.equal((await state({ op: 'set', key: 'big', value: 'x'.repeat(PLUGIN_STATE_LIMITS.valueBytes) })).code, 'state_limit');
+ assert.equal((await state({ op: 'set', key: 'has space', value: 1 })).code, 'invalid_input');
+ assert.equal((await state({ op: 'set', key: 'k'.repeat(PLUGIN_STATE_LIMITS.keyLength + 1), value: 1 })).code, 'invalid_input');
+
+ // The total size limit: fill with values just under the per-value limit.
+ const chunk = 'y'.repeat(PLUGIN_STATE_LIMITS.valueBytes - 100);
+ const fits = Math.floor(PLUGIN_STATE_LIMITS.totalBytes / (chunk.length + 2));
+ for (let i = 0; i < fits; i++) assert.deepEqual(await state({ op: 'set', key: `chunk:${i}`, value: chunk }), { ok: true }, `chunk ${i}`);
+ assert.equal((await state({ op: 'set', key: 'chunk:over', value: chunk })).code, 'state_limit');
+ // Rewriting an existing key replaces its bytes instead of adding to them.
+ assert.deepEqual(await state({ op: 'set', key: 'chunk:0', value: chunk }), { ok: true });
+ for (let i = 0; i < fits; i++) await state({ op: 'delete', key: `chunk:${i}` });
+
+ // The key-count limit.
+ const rows = Array.from({ length: PLUGIN_STATE_LIMITS.keys - 1 }, (_, i) => ({ plugin_id: FIXTURE_ID, state_key: `n:${i}`, value: '1', bytes: 1, updated_at: 0 }));
+ for (let i = 0; i < rows.length; i += 100) await f.database('plugin_state').insert(rows.slice(i, i + 100));
+ assert.equal(await f.database('plugin_state').where({ plugin_id: FIXTURE_ID }).count({ n: '*' }).first().then(r => Number(r?.n)), PLUGIN_STATE_LIMITS.keys);
+ assert.equal((await state({ op: 'set', key: 'one-too-many', value: 1 })).code, 'state_limit');
+ assert.deepEqual(await state({ op: 'set', key: 'n:0', value: 2 }), { ok: true }, 'an existing key can still be rewritten at the limit');
+
+ // Another plugin's state is separate and survives this plugin's uninstall.
+ await f.database('plugin_state').insert({ plugin_id: 'other-plugin', state_key: 'kept', value: '1', bytes: 1, updated_at: 0 });
+ await f.store.setEnabled('1', FIXTURE_ID, false);
+ await f.store.uninstall('1', FIXTURE_ID, FIXTURE_ID);
+ assert.equal(await f.database('plugin_state').where({ plugin_id: FIXTURE_ID }).first(), undefined);
+ assert.ok(await f.database('plugin_state').where({ plugin_id: 'other-plugin' }).first());
+ const uninstall = await f.database('plugin_audit').where({ plugin_id: FIXTURE_ID, action: 'uninstall' }).first();
+ assert.equal(JSON.parse(uninstall.detail).stateKeys, PLUGIN_STATE_LIMITS.keys);
+});
+
+test('ctx.http injects the connection credential, which the handler and the logs never see', async t => {
+ const f = await pluginHandlerFixture(t, { secret: SENTINEL });
+ const run = await runPluginHandler(FIXTURE_ID, 'fetchUrl', invoke({ url: 'https://api.fixture.example/v1/items?page=2', connection: 'api-token', init: { method: 'POST', json: { a: 1 } } }), f.options);
+ assert.equal(run.outcome, 'succeeded', run.error ?? '');
+ const result = run.result as { ok: boolean; status: number; headers: Record; body: string };
+ assert.equal(result.ok, true, JSON.stringify(result));
+ assert.equal(result.status, 200);
+
+ // The upstream received the credential on the checked address, with the original host as TLS identity.
+ const [sent] = f.http.requests;
+ assert.equal(sent.headers.authorization, `Bearer ${SENTINEL}`);
+ assert.equal(sent.address, '93.184.216.34');
+ assert.equal(sent.url.hostname, 'api.fixture.example');
+ assert.equal(sent.url.search, '?page=2');
+ assert.equal(sent.method, 'POST');
+ assert.equal(sent.body?.toString(), '{"a":1}');
+ assert.equal(sent.headers['content-type'], 'application/json');
+
+ // Sentinel: in no spelling anywhere the handler or its caller can see (ctx, response headers and body, logs, result).
+ const visible = JSON.stringify(run);
+ for (const spelling of [SENTINEL, Buffer.from(SENTINEL).toString('base64'), Buffer.from(`Bearer ${SENTINEL}`).toString('base64')]) {
+ assert.equal(visible.includes(spelling), false, `${spelling} leaked`);
+ }
+ assert.match(result.headers['x-echo-authorization'], /REDACTED/);
+ // The upstream also echoed the credential as a header name: that header is dropped, the others are kept.
+ assert.deepEqual(Object.keys(result.headers).sort(), ['content-type', 'x-echo-authorization']);
+ assert.equal(JSON.stringify(await f.database('plugin_audit').select()).includes(SENTINEL), false);
+});
+
+test('a credential logged before the handler acquires it is redacted from the returned logs', async t => {
+ const f = await pluginHandlerFixture(t, { secret: SENTINEL });
+ // The input carries the opaque token (an upstream callback payload, say); the handler logs it before its first authenticated request.
+ const note = `callback for ${SENTINEL} with ${Buffer.from(SENTINEL).toString('base64')} and ${encodeURIComponent(`/${SENTINEL}`)}`;
+ const run = await runPluginHandler(FIXTURE_ID, 'logThenFetch', invoke({ url: 'https://api.fixture.example/v1', connection: 'api-token', note }), f.options);
+ assert.equal(run.outcome, 'succeeded', run.error ?? '');
+ assert.equal(f.http.requests[0]?.headers.authorization, `Bearer ${SENTINEL}`, 'the credential was acquired after the line was logged');
+ assert.deepEqual(run.logs.map(l => [l.level, l.message]).slice(0, 1), [['info', 'callback for [REDACTED] with [REDACTED] and %2F[REDACTED]']]);
+ assert.deepEqual(run.logs[0].fields, { note: 'callback for [REDACTED] with [REDACTED] and %2F[REDACTED]' });
+ const visible = JSON.stringify(run);
+ for (const spelling of [SENTINEL, Buffer.from(SENTINEL).toString('base64'), encodeURIComponent(SENTINEL)]) {
+ assert.equal(visible.includes(spelling), false, `${spelling} leaked`);
+ }
+ assert.equal(JSON.stringify(await f.database('plugin_audit').select()).includes(SENTINEL), false);
+});
+
+/** Whether `text` shows the first `length` characters of `secret` or more. */
+const showsPrefix = (text: string, secret: string, length = 8) => text.includes(secret.slice(0, length));
+
+test('a credential straddling the log or error length limit is redacted before the message is cut', async t => {
+ const f = await pluginHandlerFixture(t, { secret: SENTINEL });
+ const request = { url: 'https://api.fixture.example/v1', connection: 'api-token' };
+ // All of the credential but its last character falls inside the limit.
+ const straddling = (limit: number) => `${'x'.repeat(limit - SENTINEL.length + 1)}${SENTINEL}`;
+ const note = straddling(PLUGIN_LOG_LIMITS.messageChars);
+ for (const handler of ['logThenFetch', 'fetchThenLog']) {
+ const run = await runPluginHandler(FIXTURE_ID, handler, invoke({ ...request, note }), f.options);
+ assert.equal(run.outcome, 'succeeded', run.error ?? '');
+ const line = run.logs.find(l => l.level === 'info')!;
+ assert.ok(line.message.length <= PLUGIN_LOG_LIMITS.messageChars, handler);
+ assert.ok(line.message.startsWith('x'.repeat(100)), handler);
+ assert.equal(showsPrefix(JSON.stringify(run), SENTINEL), false, `${handler}: a prefix of the credential leaked`);
+ }
+ const failed = await runPluginHandler(FIXTURE_ID, 'fetchThenThrow', invoke({ ...request, note: straddling(1_000) }), f.options);
+ assert.equal(failed.outcome, 'failed');
+ assert.ok((failed.error ?? '').length <= 1_000);
+ assert.match(failed.error ?? '', /^x{100}/);
+ assert.equal(showsPrefix(JSON.stringify(failed), SENTINEL), false, 'a prefix of the credential leaked from the error message');
+});
+
+test('a credential longer than the kept margin and cut at the limit has its visible start withheld', async t => {
+ // Longer than a stored connection secret can be: a custom `connectionSecret` source.
+ const secret = `long-credential-${'k'.repeat(40 * 1024)}-end`;
+ const f = await pluginHandlerFixture(t);
+ const prefix = 'x'.repeat(PLUGIN_LOG_LIMITS.messageChars - 100);
+ const note = `${prefix}${secret}`;
+ const run = await runPluginHandler(FIXTURE_ID, 'logThenFetch', invoke({ url: 'https://api.fixture.example/v1', connection: 'api-token', note }), { ...f.options, connectionSecret: async () => secret });
+ assert.equal(f.http.requests[0]?.headers.authorization, `Bearer ${secret}`, 'the credential was acquired after the line was logged');
+ const line = run.logs.find(l => l.level === 'info')!;
+ assert.equal(line.message, prefix);
+ assert.equal(showsPrefix(JSON.stringify(run), secret, 4), false);
+});
+
+test('ctx.http refuses hosts not in outboundHosts, private addresses, plain HTTP and unusable connections', async t => {
+ const f = await pluginHandlerFixture(t, { secret: SENTINEL });
+ const fetch = async (payload: PluginJsonValue) => {
+ const run = await runPluginHandler(FIXTURE_ID, 'fetchUrl', invoke(payload), f.options);
+ return { ...(run.result as { ok: boolean; code: string; message: string }), logs: run.logs };
+ };
+ const cases: [PluginJsonValue, string][] = [
+ [{ url: 'https://evil.example/steal', connection: 'api-token' }, 'host_not_allowed'],
+ [{ url: 'http://api.fixture.example/v1' }, 'insecure_url'],
+ [{ url: 'https://private.fixture.example/v1', connection: 'api-token' }, 'private_address'],
+ [{ url: 'https://93.184.216.34/v1' }, 'host_not_allowed'],
+ [{ url: 'https://api.fixture.example:8443/v1' }, 'host_not_allowed'],
+ [{ url: 'https://user:pass@api.fixture.example/v1' }, 'invalid_input'],
+ [{ url: 'https://api.fixture.example/v1', init: { headers: { Host: 'evil.example' } } }, 'invalid_input'],
+ [{ url: 'https://api.fixture.example/v1', connection: 'unset-token' }, 'connection_unavailable'],
+ [{ url: 'https://api.fixture.example/v1', connection: 'hook-secret' }, 'connection_unavailable'],
+ [{ url: 'https://api.fixture.example/v1', connection: 'undeclared' }, 'connection_unavailable'],
+ ];
+ for (const [payload, code] of cases) {
+ const result = await fetch(payload);
+ assert.equal(result.ok, false, JSON.stringify(payload));
+ assert.equal(result.code, code, `${JSON.stringify(payload)}: ${result.message}`);
+ }
+ assert.equal(f.http.requests.length, 0, 'no refused request reached the network');
+ const refused = await fetch({ url: 'https://evil.example/steal?token=abc', connection: 'api-token' });
+ assert.deepEqual(refused.logs.map(l => [l.level, l.message]), [['warn', 'ctx.http request to evil.example refused: host_not_allowed']]);
+ assert.equal(JSON.stringify(refused.logs).includes('token=abc'), false, 'only the hostname of a refused URL is logged');
+});
diff --git a/packages/core/test/pluginProprClient.test.ts b/packages/core/test/pluginProprClient.test.ts
new file mode 100644
index 0000000000..20e0de04ef
--- /dev/null
+++ b/packages/core/test/pluginProprClient.test.ts
@@ -0,0 +1,339 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { PLUGIN_PROPR_OPERATIONS, PLUGIN_PERMISSIONS, type PluginJsonValue } from '@propr/shared';
+import { runPluginHandler } from '../src/plugins/pluginHandlerRunner.js';
+import { githubPluginProprBackend } from '../src/plugins/pluginProprBackend.js';
+import { pluginAttributionMarker, scopedProprClient, type PluginProprCallRecord } from '../src/plugins/pluginProprClient.js';
+import type { IssueJobData } from '../src/queue/taskQueue.types.js';
+import { taskIntentIssueRef } from '../src/services/taskIntentIssueRef.js';
+import { FIXTURE_ID, pluginHandlerFixture, recordingBackend } from './helpers/pluginHandlerFixture.js';
+
+type CallResult = { ok: boolean; value?: PluginJsonValue; code?: string; message?: string };
+
+async function proprFixture(t: Parameters[0]) {
+ const f = await pluginHandlerFixture(t);
+ const call = async (operation: string, ...args: PluginJsonValue[]) => {
+ const run = await runPluginHandler(FIXTURE_ID, 'callPropr', { kind: 'invoke', payload: { operation, args } }, f.options);
+ assert.equal(run.outcome, 'succeeded', run.error ?? '');
+ return { ...(run.result as CallResult), logs: run.logs };
+ };
+ const audit = async () => (await f.database('plugin_audit').whereIn('action', ['handler_attempt', 'handler_call', 'handler_refused']).orderBy('id').select('action', 'actor', 'detail'))
+ .map(row => ({ action: row.action as string, actor: row.actor as string, ...JSON.parse(row.detail) as Record }));
+ return { ...f, call, audit };
+}
+
+test('every ctx.propr operation names a permission of the closed set', () => {
+ for (const permission of Object.values(PLUGIN_PROPR_OPERATIONS)) assert.ok((PLUGIN_PERMISSIONS as readonly string[]).includes(permission), permission);
+});
+
+test('ctx.propr refuses operations outside the granted permissions, and logs and records the refusal', async t => {
+ const f = await proprFixture(t);
+ // The fixture does not ask for pull_requests.read or pull_requests.comment.
+ for (const [operation, args, permission] of [['getPullRequest', ['owner/allowed', 7], 'pull_requests.read'], ['commentOnPullRequest', ['owner/allowed', 7, 'hello'], 'pull_requests.comment']] as const) {
+ const result = await f.call(operation, ...args);
+ assert.equal(result.ok, false);
+ assert.equal(result.code, 'permission_denied');
+ assert.match(result.message ?? '', new RegExp(permission.replace('.', '\\.')));
+ assert.deepEqual(result.logs.map(l => [l.level, l.message]), [['warn', `ctx.propr.${operation} refused: permission_denied`]]);
+ }
+ assert.deepEqual(f.calls, [], 'a refused call never reaches the backend');
+
+ // A permission granted at install that the current manifest dropped is not usable either.
+ await f.database('plugin_installations').where({ plugin_id: FIXTURE_ID }).update({ granted_permissions: JSON.stringify(['tasks.read', 'pull_requests.read']) });
+ assert.equal((await f.call('getPullRequest', 'owner/allowed', 7)).code, 'permission_denied');
+ assert.equal((await f.call('listEvents', { repository: 'owner/allowed' })).code, 'permission_denied', 'a requested but ungranted permission is refused');
+ assert.equal((await f.call('getTask', 'task-1')).ok, true);
+
+ const audit = await f.audit();
+ assert.deepEqual(audit.map(a => [a.action, a.actor, a.operation, a.reason ?? null]), [
+ ['handler_refused', `plugin:${FIXTURE_ID}`, 'getPullRequest', 'permission_denied'],
+ ['handler_refused', `plugin:${FIXTURE_ID}`, 'commentOnPullRequest', 'permission_denied'],
+ ['handler_refused', `plugin:${FIXTURE_ID}`, 'getPullRequest', 'permission_denied'],
+ ['handler_refused', `plugin:${FIXTURE_ID}`, 'listEvents', 'permission_denied'],
+ ['handler_call', `plugin:${FIXTURE_ID}`, 'getTask', null],
+ ]);
+ assert.equal(audit[0].handler, 'callPropr');
+});
+
+test('ctx.propr refuses repositories outside the installation limit, tasks of other repositories included', async t => {
+ const f = await proprFixture(t);
+ for (const [operation, args] of [
+ ['getIssue', ['other/repo', 3]], ['createIssue', ['other/repo', { title: 'x' }]], ['createTask', ['Other/Repo', 3]],
+ ['commentOnIssue', ['other/repo', 3, 'hi']], ['addLabel', ['other/repo', 3, 'bug']], ['removeLabel', ['other/repo', 3, 'bug']],
+ ['listEvents', [{ repository: 'other/repo' }]],
+ ] as const) {
+ const result = await f.call(operation, ...(args as unknown as PluginJsonValue[]));
+ assert.equal(result.code, 'repository_not_allowed', `${operation}: ${result.message}`);
+ }
+ assert.deepEqual(f.calls, [], 'no call outside the limit reaches the backend');
+ // A task is read before its repository is known: one outside the limit is not returned.
+ const elsewhere = await f.call('getTask', 'elsewhere');
+ assert.equal(elsewhere.code, 'repository_not_allowed');
+ assert.equal(elsewhere.value, undefined);
+ assert.equal((await f.call('getTask', 'missing')).code, 'not_found');
+ // The limit is case-insensitive, like GitHub names.
+ assert.equal((await f.call('getIssue', 'Owner/Allowed', 3)).ok, true);
+ // Invalid input is refused before the backend too.
+ assert.equal((await f.call('getIssue', 'not-a-repo', 3)).code, 'invalid_input');
+ assert.equal((await f.call('getIssue', 'owner/allowed', -1)).code, 'invalid_input');
+ assert.equal((await f.call('createIssue', 'owner/allowed', { title: '' })).code, 'invalid_input');
+ assert.equal((await f.call('listEvents', { repository: 'owner/allowed', afterSeq: -1 })).code, 'invalid_input');
+ assert.equal((await f.call('listEvents', { repository: 'owner/allowed', domains: ['secrets'] })).code, 'invalid_input');
+});
+
+test('allowed ctx.propr operations reach the backend attributed to the plugin', async t => {
+ const f = await proprFixture(t);
+ assert.equal((await f.call('getIssue', 'owner/allowed', 3)).ok, true);
+ assert.equal((await f.call('listEvents', { repository: 'owner/allowed', limit: 5000, afterSeq: 4, domains: ['task'] })).ok, true);
+ const created = await f.call('createIssue', 'owner/allowed', { title: 'Follow up', body: 'From the chat', labels: ['triage'] });
+ assert.deepEqual(created.value, { repository: 'owner/allowed', number: 11, url: 'https://github.com/owner/allowed/issues/11' });
+ assert.deepEqual((await f.call('createTask', 'owner/allowed', 11)).value, { repository: 'owner/allowed', issueNumber: 11, jobId: 'issue-owner-allowed-11' });
+ assert.equal((await f.call('commentOnIssue', 'owner/allowed', 3, 'Linked thread')).ok, true);
+ assert.equal((await f.call('addLabel', 'owner/allowed', 3, 'synced')).ok, true);
+ assert.equal((await f.call('removeLabel', 'owner/allowed', 3, 'synced')).ok, true);
+
+ const marker = pluginAttributionMarker(FIXTURE_ID);
+ assert.deepEqual(f.calls.map(c => c.operation), ['getIssue', 'listEvents', 'createIssue', 'createTask', 'comment', 'addLabel', 'removeLabel']);
+ assert.deepEqual(f.calls[1].args[0], { repository: 'owner/allowed', afterSeq: 4, domains: ['task'], limit: 100 }, 'event lists are bounded');
+ assert.deepEqual(f.calls[2].args, ['owner/allowed', { title: 'Follow up', body: `From the chat\n\n${marker}`, labels: ['triage'] }]);
+ assert.deepEqual(f.calls[3].args, ['owner/allowed', 11, { requestedBy: `plugin:${FIXTURE_ID}` }]);
+ assert.equal(f.calls[4].args[2], `Linked thread\n\n${marker}`);
+
+ const audit = await f.audit();
+ assert.ok(audit.every(a => a.actor === `plugin:${FIXTURE_ID}` && a.handler === 'callPropr'));
+ // Reads are recorded once they complete; a mutation is recorded as attempted before it is dispatched, then as completed.
+ assert.deepEqual(audit.map(a => [a.action, a.operation, a.permission, a.repository, a.number ?? null]), [
+ ['handler_call', 'getIssue', 'issues.read', 'owner/allowed', 3],
+ ['handler_call', 'listEvents', 'events.read', 'owner/allowed', null],
+ ...([['createIssue', 'issues.create', 'owner/allowed', null], ['createTask', 'tasks.create', 'owner/allowed', 11], ['commentOnIssue', 'issues.comment', 'owner/allowed', 3], ['addLabel', 'labels.write', 'owner/allowed', 3], ['removeLabel', 'labels.write', 'owner/allowed', 3]] as const)
+ .flatMap(call => [['handler_attempt', ...call], ['handler_call', ...call]]),
+ ]);
+ // A mutation's attempt and completion share one call id; each call has its own.
+ const ids = audit.map(a => a.callId as string);
+ assert.ok(ids.every(id => typeof id === 'string' && id.length > 0));
+ assert.equal(new Set(ids).size, 7);
+ for (let i = 2; i < audit.length; i += 2) assert.equal(ids[i], ids[i + 1], `${audit[i].operation}: attempt and completion`);
+ // The audit trail an administrator reads shows them with the plugin as the actor.
+ const detail = await f.store.get('1', FIXTURE_ID);
+ assert.equal(detail.audit[0].action, 'handler_call');
+ assert.equal(detail.audit[0].actor, `plugin:${FIXTURE_ID}`);
+ assert.equal(detail.audit[0].detail.operation, 'removeLabel');
+});
+
+test('the GitHub backend queues the task with the plugin as requester, which the task keeps in its first event', async t => {
+ const f = await pluginHandlerFixture(t);
+ const jobs: { data: IssueJobData; jobId: string }[] = [];
+ const requests: { route: string; parameters: Record }[] = [];
+ const backend = githubPluginProprBackend({
+ database: f.database,
+ enqueueIssueTask: async (data, jobId) => { jobs.push({ data, jobId }); },
+ github: async () => ({ request: async (route, parameters) => {
+ requests.push({ route, parameters });
+ return { data: { number: 5, id: 77, html_url: 'https://github.com/owner/allowed/issues/5', title: 't', state: 'open', labels: [{ name: 'bug' }], user: { login: 'octo' }, created_at: 'c', updated_at: 'u' } };
+ } }),
+ });
+ const run = await runPluginHandler(FIXTURE_ID, 'callPropr', { kind: 'invoke', payload: { operation: 'createTask', args: ['owner/allowed', 5] } }, { ...f.options, backend });
+ assert.equal((run.result as CallResult).ok, true, JSON.stringify(run));
+ assert.equal(jobs.length, 1);
+ assert.equal(jobs[0].jobId, 'issue-owner-allowed-5');
+ assert.equal(jobs[0].data.requestedBy, `plugin:${FIXTURE_ID}`);
+ assert.deepEqual([jobs[0].data.repoOwner, jobs[0].data.repoName, jobs[0].data.number], ['owner', 'allowed', 5]);
+ // The issue job builds the task's state from its data: `requestedBy` survives into the task record.
+ assert.equal(taskIntentIssueRef(jobs[0].data, { kind: 'issue', number: 5, repoOwner: 'owner', repoName: 'allowed' }).requestedBy, `plugin:${FIXTURE_ID}`);
+
+ const issue = await runPluginHandler(FIXTURE_ID, 'callPropr', { kind: 'invoke', payload: { operation: 'getIssue', args: ['owner/allowed', 5] } }, { ...f.options, backend });
+ assert.deepEqual((issue.result as CallResult).value, { repository: 'owner/allowed', number: 5, title: 't', state: 'open', url: 'https://github.com/owner/allowed/issues/5', labels: ['bug'], author: 'octo', createdAt: 'c', updatedAt: 'u' });
+ await runPluginHandler(FIXTURE_ID, 'callPropr', { kind: 'invoke', payload: { operation: 'commentOnIssue', args: ['owner/allowed', 5, 'hi'] } }, { ...f.options, backend });
+ // getIssue reads the number; commentOnIssue reads it again to confirm it is an issue before posting.
+ assert.deepEqual(requests.map(r => r.route), ['GET /repos/{owner}/{repo}/issues/{issue_number}', 'GET /repos/{owner}/{repo}/issues/{issue_number}', 'POST /repos/{owner}/{repo}/issues/{issue_number}/comments']);
+ assert.equal(requests[2].parameters.body, `hi\n\n${pluginAttributionMarker(FIXTURE_ID)}`);
+
+ // A backend failure reaches the handler without its details.
+ const failing = githubPluginProprBackend({ database: f.database, github: async () => ({ request: async () => { throw new Error('HttpError: Bad credentials ghs_secretvalue'); } }) });
+ const failed = await runPluginHandler(FIXTURE_ID, 'callPropr', { kind: 'invoke', payload: { operation: 'getIssue', args: ['owner/allowed', 5] } }, { ...f.options, backend: failing });
+ assert.deepEqual((failed.result as CallResult), { ok: false, code: 'unavailable', message: 'ProPR could not complete getIssue' });
+});
+
+/** A GitHub where #5 is an issue and #7 is a pull request (the issue routes serve both), recording every request. */
+function issueOrPullGitHub() {
+ const requests: { route: string; parameters: Record }[] = [];
+ const item = (number: number) => ({
+ number, id: 100 + number, html_url: `https://github.com/owner/allowed/${number === 7 ? 'pull' : 'issues'}/${number}`, title: 't', state: 'open', labels: [], user: { login: 'octo' }, created_at: 'c', updated_at: 'u',
+ ...(number === 7 ? { pull_request: { url: 'https://api.github.com/repos/owner/allowed/pulls/7' } } : {}),
+ });
+ const github = async () => ({ request: async (route: string, parameters: Record) => {
+ requests.push({ route, parameters });
+ return { data: item(Number(parameters.issue_number)) };
+ } });
+ return { requests, github };
+}
+
+test('a comment permission covers only its own target kind: issues.comment cannot reach a pull request, nor the reverse', async t => {
+ const f = await pluginHandlerFixture(t);
+ const posts = (requests: { route: string; parameters: Record }[]) => requests.filter(r => r.route.startsWith('POST'));
+ const client = (permissions: PluginProprCallRecord['permission'][]) => {
+ const gh = issueOrPullGitHub();
+ const records: PluginProprCallRecord[] = [];
+ const propr = scopedProprClient({ pluginId: FIXTURE_ID, grantedPermissions: permissions, repositories: ['owner/allowed'], backend: githubPluginProprBackend({ database: f.database, github: gh.github }), record: async call => { records.push(call); } });
+ return { propr, records, requests: gh.requests };
+ };
+
+ // issues.comment only: commentOnIssue with a pull request's number is refused and posts nothing.
+ const issueOnly = client(['issues.comment', 'issues.read']);
+ await assert.rejects(issueOnly.propr.commentOnIssue('owner/allowed', 7, 'sneaky'), { code: 'not_found' });
+ assert.deepEqual(posts(issueOnly.requests), []);
+ assert.deepEqual(issueOnly.records.map(r => [r.operation, r.outcome, r.reason]), [['commentOnIssue', 'attempted', undefined], ['commentOnIssue', 'refused', 'not_found']]);
+ // getIssue (issues.read) cannot read a pull request through the issue route either.
+ await assert.rejects(issueOnly.propr.getIssue('owner/allowed', 7), { code: 'not_found' });
+ // The issue itself is still allowed.
+ assert.equal((await issueOnly.propr.commentOnIssue('owner/allowed', 5, 'ok')).id, 105);
+ assert.deepEqual(posts(issueOnly.requests).map(r => r.parameters.issue_number), [5]);
+
+ // pull_requests.comment only: commentOnPullRequest with an issue's number is refused and posts nothing.
+ const pullOnly = client(['pull_requests.comment']);
+ await assert.rejects(pullOnly.propr.commentOnPullRequest('owner/allowed', 5, 'sneaky'), { code: 'not_found' });
+ assert.deepEqual(posts(pullOnly.requests), []);
+ assert.equal((await pullOnly.propr.commentOnPullRequest('owner/allowed', 7, 'ok')).id, 107);
+ // Without the permission the method is refused before GitHub is asked anything.
+ await assert.rejects(pullOnly.propr.commentOnIssue('owner/allowed', 5, 'x'), { code: 'permission_denied' });
+
+ // Through a handler: the fixture has issues.comment but not pull_requests.comment.
+ const gh = issueOrPullGitHub();
+ const run = await runPluginHandler(FIXTURE_ID, 'callPropr', { kind: 'invoke', payload: { operation: 'commentOnIssue', args: ['owner/allowed', 7, 'sneaky'] } }, { ...f.options, backend: githubPluginProprBackend({ database: f.database, github: gh.github }) });
+ assert.equal((run.result as CallResult).code, 'not_found');
+ assert.deepEqual(posts(gh.requests), []);
+});
+
+test('the GitHub backend sends nothing once the handler run has ended, even when the App token arrives later', async () => {
+ const controller = new AbortController();
+ const requests: string[] = [];
+ let release!: () => void;
+ const token = new Promise(resolve => { release = resolve; });
+ const backend = githubPluginProprBackend({
+ database: {} as never, signal: controller.signal,
+ enqueueIssueTask: async () => { requests.push('enqueue'); },
+ github: async () => { await token; return { request: async (route: string) => { requests.push(route); return { data: {} }; } }; },
+ });
+ const pending = backend.comment('owner/allowed', 5, 'late', 'issue');
+ controller.abort();
+ release();
+ await assert.rejects(pending, { code: 'unavailable' });
+ await assert.rejects(backend.createTask('owner/allowed', 5, { requestedBy: 'plugin:x' }), { code: 'unavailable' });
+ assert.deepEqual(requests, []);
+});
+
+test('the default enqueue adds no job when the run ends while the issue queue is initialized', async () => {
+ const controller = new AbortController();
+ const added: string[] = [];
+ let release!: () => void;
+ const initialized = new Promise(resolve => { release = resolve; });
+ let initializing!: () => void;
+ const started = new Promise(resolve => { initializing = resolve; });
+ const backend = githubPluginProprBackend({
+ database: {} as never, signal: controller.signal,
+ issueQueue: async () => { initializing(); await initialized; return { add: async (_name: string, _data: IssueJobData, options: Record) => { added.push(String(options.jobId)); } }; },
+ });
+ const pending = backend.createTask('owner/allowed', 5, { requestedBy: 'plugin:x' });
+ await started;
+ controller.abort();
+ release();
+ await assert.rejects(pending, { code: 'unavailable' });
+ assert.deepEqual(added, []);
+
+ // A live run's task is added to the same queue.
+ const live = githubPluginProprBackend({ database: {} as never, signal: new AbortController().signal, issueQueue: async () => ({ add: async (_n: string, _d: IssueJobData, options: Record) => { added.push(String(options.jobId)); } }) });
+ assert.equal((await live.createTask('owner/allowed', 5, { requestedBy: 'plugin:x' })).jobId, 'issue-owner-allowed-5');
+ assert.deepEqual(added, ['issue-owner-allowed-5']);
+});
+
+/** Every ctx.propr mutation, with arguments inside the fixture's limit. */
+const MUTATION_CALLS = [
+ ['createIssue', ['owner/allowed', { title: 'Follow up' }]], ['createTask', ['owner/allowed', 11]], ['commentOnIssue', ['owner/allowed', 3, 'hi']],
+ ['commentOnPullRequest', ['owner/allowed', 7, 'hi']], ['addLabel', ['owner/allowed', 3, 'synced']], ['removeLabel', ['owner/allowed', 3, 'synced']],
+] as const;
+
+test('a ctx.propr mutation is attributed in the audit trail before it is dispatched', async t => {
+ const f = await proprFixture(t);
+ // What the audit trail held at the moment each mutation reached the backend.
+ const seen: { operation: string; actions: string[] }[] = [];
+ const snapshot = (operation: string) => async () => {
+ const rows = await f.database('plugin_audit').where({ plugin_id: FIXTURE_ID, actor: `plugin:${FIXTURE_ID}` }).orderBy('id').select('action', 'detail');
+ seen.push({ operation, actions: rows.filter(r => JSON.parse(r.detail).operation === operation).map(r => r.action as string) });
+ };
+ const backend = { ...f.backend };
+ for (const name of ['createIssue', 'createTask', 'comment', 'addLabel', 'removeLabel'] as const) {
+ const original = backend[name] as (...args: unknown[]) => Promise;
+ (backend as Record)[name] = async (...args: unknown[]) => {
+ await snapshot(name === 'comment' ? (args[3] === 'issue' ? 'commentOnIssue' : 'commentOnPullRequest') : name)();
+ return original(...args);
+ };
+ }
+ for (const operation of ['createIssue', 'createTask', 'commentOnIssue', 'addLabel', 'removeLabel'] as const) {
+ const [, args] = MUTATION_CALLS.find(([name]) => name === operation)!;
+ const run = await runPluginHandler(FIXTURE_ID, 'callPropr', { kind: 'invoke', payload: { operation, args: args as unknown as PluginJsonValue[] } }, { ...f.options, backend });
+ assert.equal((run.result as CallResult).ok, true, JSON.stringify(run.result));
+ }
+ assert.deepEqual(seen.map(s => [s.operation, s.actions]), ['createIssue', 'createTask', 'commentOnIssue', 'addLabel', 'removeLabel'].map(op => [op, ['handler_attempt']]));
+});
+
+test('a ctx.propr mutation whose attempt cannot be recorded is refused and never dispatched', async t => {
+ const f = await proprFixture(t);
+ // The audit trail accepts everything but attempts, as a failing insert would.
+ await f.database.raw(`CREATE TRIGGER no_attempts BEFORE INSERT ON plugin_audit WHEN NEW.action = 'handler_attempt' BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END`);
+ for (const operation of ['createIssue', 'createTask', 'commentOnIssue', 'addLabel', 'removeLabel'] as const) {
+ const [, args] = MUTATION_CALLS.find(([name]) => name === operation)!;
+ const result = await f.call(operation, ...(args as unknown as PluginJsonValue[]));
+ assert.deepEqual([result.ok, result.code, result.message], [false, 'unavailable', `ProPR could not record ${operation}, so it was not sent`], operation);
+ }
+ assert.deepEqual(f.calls, [], 'no mutation reached the backend');
+ assert.deepEqual((await f.audit()).map(a => [a.action, a.operation, a.reason]), ['createIssue', 'createTask', 'commentOnIssue', 'addLabel', 'removeLabel'].map(op => ['handler_refused', op, 'unavailable']));
+ // Reads are not mutations: they need no attempt record.
+ assert.equal((await f.call('getIssue', 'owner/allowed', 3)).ok, true);
+
+ // Directly: a failing attempt write stops every mutation, before the backend.
+ const { backend, calls } = recordingBackend();
+ const records: PluginProprCallRecord[] = [];
+ const propr = scopedProprClient({
+ pluginId: FIXTURE_ID, grantedPermissions: Object.values(PLUGIN_PROPR_OPERATIONS), repositories: null, backend,
+ record: async call => { if (call.outcome === 'attempted') throw new Error('insert failed'); records.push(call); },
+ });
+ for (const [operation, args] of MUTATION_CALLS) {
+ await assert.rejects((propr[operation] as (...values: unknown[]) => Promise)(...args), { code: 'unavailable' }, operation);
+ }
+ assert.deepEqual(calls, []);
+ assert.deepEqual(records.map(r => [r.operation, r.outcome, r.reason]), MUTATION_CALLS.map(([op]) => [op, 'refused', 'unavailable']));
+});
+
+test('once a ctx.propr mutation is attempted, its outcome record cannot fail the call or erase the attempt', async t => {
+ const f = await proprFixture(t);
+ // Completions cannot be written: the mutation still returns its result, and the attempt attributes it.
+ await f.database.raw(`CREATE TRIGGER no_completions BEFORE INSERT ON plugin_audit WHEN NEW.action = 'handler_call' BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END`);
+ const created = await f.call('createIssue', 'owner/allowed', { title: 'Follow up' });
+ assert.deepEqual(created.value, { repository: 'owner/allowed', number: 11, url: 'https://github.com/owner/allowed/issues/11' }, 'a retry would create a second issue');
+ assert.deepEqual(f.calls.map(c => c.operation), ['createIssue']);
+ assert.deepEqual((await f.audit()).map(a => [a.action, a.operation]), [['handler_attempt', 'createIssue']]);
+ // A read has no attempt to fall back on: failing to record it still fails it.
+ assert.equal((await f.call('getIssue', 'owner/allowed', 3)).ok, false);
+
+ // Directly: the outcome failure is reported, the attempt and the backend failure share a call id.
+ const { backend, calls } = recordingBackend();
+ const records: PluginProprCallRecord[] = [];
+ const failures: [string, string][] = [];
+ const failing = { ...backend, comment: async () => { calls.push({ operation: 'comment', args: [] }); throw new Error('HttpError: timeout after the comment was posted'); } };
+ const propr = scopedProprClient({
+ pluginId: FIXTURE_ID, grantedPermissions: Object.values(PLUGIN_PROPR_OPERATIONS), repositories: null, backend: failing,
+ record: async call => { records.push(call); if (call.outcome !== 'attempted') throw new Error('insert failed'); },
+ onRecordFailure: (call, error) => { failures.push([call.outcome, (error as Error).message]); },
+ });
+ assert.equal((await propr.addLabel('owner/allowed', 3, 'synced')), undefined);
+ await assert.rejects(propr.commentOnIssue('owner/allowed', 3, 'hi'), { code: 'unavailable', message: 'ProPR could not complete commentOnIssue' });
+ assert.deepEqual(calls.map(c => c.operation), ['addLabel', 'comment']);
+ assert.deepEqual(records.map(r => [r.operation, r.outcome, r.reason]), [
+ ['addLabel', 'attempted', undefined], ['addLabel', 'completed', undefined],
+ ['commentOnIssue', 'attempted', undefined], ['commentOnIssue', 'refused', 'unavailable'],
+ ]);
+ assert.equal(records[0].callId, records[1].callId);
+ assert.equal(records[2].callId, records[3].callId);
+ assert.notEqual(records[0].callId, records[2].callId);
+ assert.deepEqual(failures, [['completed', 'insert failed'], ['refused', 'insert failed']]);
+});
diff --git a/packages/core/test/pluginStore.test.ts b/packages/core/test/pluginStore.test.ts
index def20bc3cd..895a7077d1 100644
--- a/packages/core/test/pluginStore.test.ts
+++ b/packages/core/test/pluginStore.test.ts
@@ -5,6 +5,7 @@ import { getOfficialPlugin } from '../../plugins/src/index.js';
import { extensionTestResources } from './helpers/extensionTestResources.js';
import { up as identityMigration } from '../src/db/migrations/20261013210000_extension_profiles.js';
import { up as pluginMigration } from '../src/db/migrations/20261114000000_plugin_installations.js';
+import { up as pluginStateMigration } from '../src/db/migrations/20261114180000_plugin_state.js';
import { up as grantMigration } from '../src/db/migrations/20261013220000_extension_grant_network.js';
import { up as libraryMigration } from '../src/db/migrations/20261113000000_extension_library.js';
import { up as oauthMigration } from '../src/db/migrations/20261113120000_extension_oauth.js';
@@ -36,7 +37,7 @@ async function fixture(t: TestContext, initial: PluginManifest = example) {
await database.schema.createTable('instance_members', c => { c.string('github_user_id').primary(); c.string('role'); });
await database('instance_members').insert([{ github_user_id: '1', role: 'admin' }, { github_user_id: '2', role: 'member' }]);
await database.schema.createTable('agent_definitions', c => { c.string('id').primary(); c.string('name'); });
- await identityMigration(database); await grantMigration(database); await libraryMigration(database); await oauthMigration(database); await pluginMigration(database);
+ await identityMigration(database); await grantMigration(database); await libraryMigration(database); await oauthMigration(database); await pluginMigration(database); await pluginStateMigration(database);
let manifests: PluginManifest[] = [initial];
let clock = 1_700_000_000_000;
const store = new PluginStore(database, { SYSTEM_TASK_SECRET: 'plugin-store-test-root' }, async () => manifests, () => clock);
diff --git a/packages/core/test/taskJournalEvents.test.ts b/packages/core/test/taskJournalEvents.test.ts
index de84bee46a..3e357d41a0 100644
--- a/packages/core/test/taskJournalEvents.test.ts
+++ b/packages/core/test/taskJournalEvents.test.ts
@@ -285,3 +285,16 @@ test('the domain migration keeps rows, indexes and the seq high-water mark, and
await scratch.destroy();
}
});
+
+test('a task a plugin handler created names the plugin in its first task event and its job data', async () => {
+ await manager.createTaskStateIfAbsent('issue-61', issueRef({ number: 61, requestedBy: 'plugin:fixture-handlers' }) as never, null, 'job-issue-61');
+ const [first] = await database('task_history').where({ task_id: 'issue-61' }).orderBy('history_id');
+ assert.equal(first.state, 'pending');
+ assert.deepEqual(JSON.parse(first.metadata), { requestedBy: 'plugin:fixture-handlers' });
+ const task = await database('tasks').where({ task_id: 'issue-61' }).first();
+ assert.equal(JSON.parse(task.initial_job_data).requestedBy, 'plugin:fixture-handlers');
+ assert.deepEqual((await manager.getTaskState('issue-61'))?.history[0].metadata, { requestedBy: 'plugin:fixture-handlers' });
+ // A task with no plugin behind it keeps an empty first event.
+ await manager.createTaskStateIfAbsent('issue-62', issueRef({ number: 62 }) as never, null, 'job-issue-62');
+ assert.equal((await database('task_history').where({ task_id: 'issue-62' }).first()).metadata, '{}');
+});
diff --git a/packages/plugins/example-pack/README.md b/packages/plugins/example-pack/README.md
index 48c0a8e741..ce63d5de09 100644
--- a/packages/plugins/example-pack/README.md
+++ b/packages/plugins/example-pack/README.md
@@ -17,5 +17,8 @@ It contributes:
`--registry` points at the test registry.
- **example-notes**: a skill that tells the agent how to write short notes.
- **example-weekly-digest**: a report-only automation template.
+- **recordEvent** (`handlers.ts`): a handler that keeps the newest journal
+ event per repository as a cursor in `ctx.state` and logs a task event's task
+ state. `../test/testing.test.ts` tests it with the plugin test kit.
See [Plugins](../../../docs/docs/features/plugins.md) for the manifest format.
diff --git a/packages/plugins/example-pack/handlers.ts b/packages/plugins/example-pack/handlers.ts
new file mode 100644
index 0000000000..494fed3038
--- /dev/null
+++ b/packages/plugins/example-pack/handlers.ts
@@ -0,0 +1,22 @@
+import { definePluginHandler, type PluginEventInput } from '@propr/shared';
+
+/**
+ * Example handler: remembers the newest journal event seen per repository (a
+ * cursor in `ctx.state`) and, for a task event, logs the task's current state.
+ * It only reads, so the plugin's `events.read` and `tasks.read` permissions
+ * are enough.
+ */
+export const recordEvent = definePluginHandler(async (input, ctx) => {
+ const { event } = input;
+ const key = `cursor:${event.repository}`;
+ const previous = await ctx.state.get(key);
+ const cursor = Math.max(previous ?? 0, event.seq);
+ await ctx.state.set(key, cursor);
+ if (event.domain === 'task' && event.entityId) {
+ const task = await ctx.propr.getTask(event.entityId);
+ ctx.log.info(`${ctx.settings.greeting ?? 'hello'}: task ${task.taskId} is ${task.state ?? 'unknown'}`, { repository: event.repository, type: event.type });
+ } else {
+ ctx.log.info(`${event.domain} ${event.type}`, { repository: event.repository, seq: event.seq });
+ }
+ return { repository: event.repository, cursor };
+});
diff --git a/packages/plugins/package.json b/packages/plugins/package.json
index 6656db4827..dde3bfd8a7 100644
--- a/packages/plugins/package.json
+++ b/packages/plugins/package.json
@@ -2,15 +2,18 @@
"name": "@propr/plugins",
"version": "0.9.1",
"private": true,
- "description": "Registry of the official ProPR plugins: manifests and their assets",
+ "description": "Registry of the official ProPR plugins: manifests, assets, handlers and the plugin test kit",
"license": "Apache-2.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
- "engines": { "node": ">=22" },
+ "engines": {
+ "node": ">=22"
+ },
"scripts": {
- "build": "tsc",
- "typecheck": "tsc --noEmit"
+ "build": "tsc && tsc -p tsconfig.handlers.json",
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.handlers.json --noEmit",
+ "lint": "eslint ."
},
"dependencies": {
"@propr/shared": "^0.9.1"
diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts
index c1c096e0f6..762c59cd72 100644
--- a/packages/plugins/src/index.ts
+++ b/packages/plugins/src/index.ts
@@ -1,7 +1,7 @@
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-import { formatPluginManifestIssues, pluginIdSchema, pluginManifestSchema, type PluginManifest } from '@propr/shared';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { formatPluginManifestIssues, pluginIdSchema, pluginManifestSchema, type PluginHandlerModule, type PluginManifest } from '@propr/shared';
/**
* The official plugins shipped with ProPR. Each plugin is a folder
@@ -15,6 +15,8 @@ export const PLUGIN_REGISTRY_VERSION = 1;
export const PLUGIN_DEVELOPMENT_FLAG = 'PROPR_PLUGINS_DEVELOPMENT';
/** The registry package folder: the parent of `src/` and of `dist/`. */
export const PLUGINS_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+/** This module runs compiled (from `dist/`), where handlers are the compiled `dist/plugins//handlers.js`. */
+const COMPILED = import.meta.url.endsWith('.js');
export interface PluginRegistryEntry {
id: string;
@@ -29,6 +31,8 @@ export interface PluginAssets {
icon: string;
skills: { name: string; path: string }[];
automationTemplates: { id: string; path: string }[];
+ /** The plugin's handlers module, when its folder has a `handlers.ts`: the compiled file when running compiled and it exists. */
+ handlers: string | null;
}
export interface OfficialPlugin {
@@ -65,9 +69,24 @@ export function resolvePluginAssets(directory: string, manifest: PluginManifest)
icon: at(manifest.icon),
skills: manifest.contributes.skills.map(skill => ({ name: skill.name, path: at(skill.path) })),
automationTemplates: manifest.contributes.automationTemplates.map(template => ({ id: template.id, path: at(template.path) })),
+ handlers: resolvePluginHandlersModule(directory),
};
}
+/**
+ * The handlers module of a plugin folder: `handlers.ts` exporting named async
+ * functions. Compiled, the registry loads `dist/plugins//handlers.js` of
+ * this package, or a `handlers.js` next to the source in another root.
+ */
+export function resolvePluginHandlersModule(directory: string): string | null {
+ const source = path.join(directory, 'handlers.ts');
+ if (!existsSync(source)) return null;
+ if (!COMPILED) return source;
+ const root = path.dirname(directory);
+ const compiled = root === PLUGINS_ROOT ? path.join(PLUGINS_ROOT, 'dist', 'plugins', path.basename(directory), 'handlers.js') : path.join(directory, 'handlers.js');
+ return existsSync(compiled) ? compiled : source;
+}
+
/** Read and validate one plugin folder. Throws with every manifest problem. */
export function loadPlugin(root: string, entry: PluginRegistryEntry): OfficialPlugin {
const directory = path.join(root, entry.id);
@@ -109,3 +128,25 @@ export function officialPlugins(options: OfficialPluginOptions = {}): OfficialPl
export function getOfficialPlugin(id: string, options: OfficialPluginOptions = {}): OfficialPlugin | undefined {
return officialPlugins(options).find(plugin => plugin.manifest.id === id);
}
+
+const handlerModules = new Map>();
+
+/**
+ * The handlers of one official plugin (named async functions of its
+ * `handlers.ts`), or null when it has none or is not in the registry. Only
+ * exported functions are handlers. Loaded once per module path.
+ */
+export async function loadPluginHandlers(id: string, options: OfficialPluginOptions = {}): Promise {
+ const file = getOfficialPlugin(id, options)?.assets.handlers;
+ if (!file) return null;
+ let module = handlerModules.get(file);
+ if (!module) {
+ module = import(pathToFileURL(file).href).then((loaded: Record) => Object.freeze(Object.fromEntries(
+ Object.entries(loaded).filter(([, value]) => typeof value === 'function'),
+ )) as PluginHandlerModule);
+ // A failed import is retried on the next call rather than cached.
+ module.catch(() => handlerModules.delete(file));
+ handlerModules.set(file, module);
+ }
+ return module;
+}
diff --git a/packages/plugins/test/testing.test.ts b/packages/plugins/test/testing.test.ts
new file mode 100644
index 0000000000..1f847f1aa7
--- /dev/null
+++ b/packages/plugins/test/testing.test.ts
@@ -0,0 +1,79 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { PLUGIN_STATE_LIMITS, PluginSdkError, type PluginEventInput, type RepositoryEvent } from '@propr/shared';
+import { createTestPluginContext } from '../testing/index.js';
+import { recordEvent } from '../example-pack/handlers.js';
+
+const event = (overrides: Partial = {}): PluginEventInput => ({
+ kind: 'event',
+ event: { seq: 4, repository: 'owner/repo', domain: 'task', type: 'completed', entityId: 'task-9', prNumber: null, issueNumber: 2, planId: null, headSha: null, payload: {}, occurredAt: '', ...overrides },
+});
+
+test('the test kit runs a handler with recorded propr calls, in-memory state and captured logs', async () => {
+ const kit = createTestPluginContext({
+ pluginId: 'example-pack', settings: { greeting: 'hey' }, grantedPermissions: ['events.read', 'tasks.read'],
+ propr: { getTask: taskId => ({ taskId, repository: 'owner/repo', kind: 'implementation', issueNumber: 2, prNumber: null, state: 'completed', model: null, createdAt: '' }) },
+ });
+ assert.deepEqual(await recordEvent(event(), kit.ctx), { repository: 'owner/repo', cursor: 4 });
+ assert.deepEqual(kit.calls, [{ operation: 'getTask', args: ['task-9'] }]);
+ assert.equal(kit.state.get('cursor:owner/repo'), 4);
+ assert.deepEqual(kit.logs.map(l => [l.level, l.message]), [['info', 'hey: task task-9 is completed']]);
+
+ // The cursor only moves forward.
+ assert.deepEqual(await recordEvent(event({ seq: 2, domain: 'pull_request', type: 'opened', entityId: '5' }), kit.ctx), { repository: 'owner/repo', cursor: 4 });
+ assert.equal(kit.logs[1].message, 'pull_request opened');
+});
+
+test('the test kit refuses ungranted permissions, repositories outside the limit, unlisted hosts and plain HTTP', async () => {
+ const kit = createTestPluginContext({ grantedPermissions: ['issues.read'], repositories: ['owner/repo'], outboundHosts: ['api.example.com'], connections: ['token'] });
+ await assert.rejects(kit.ctx.propr.commentOnIssue('owner/repo', 1, 'hi'), (error: unknown) => error instanceof PluginSdkError && error.code === 'permission_denied');
+ await assert.rejects(kit.ctx.propr.getIssue('other/repo', 1), (error: unknown) => error instanceof PluginSdkError && error.code === 'repository_not_allowed');
+ assert.equal((await kit.ctx.propr.getIssue('owner/repo', 1)).number, 1);
+ assert.deepEqual(kit.calls.map(c => c.operation), ['getIssue']);
+
+ kit.respond('https://api.example.com/v1/', { json: { items: [1, 2] } });
+ kit.respond(/\/v1\/missing$/, { status: 404, body: 'nope' });
+ const fetch = kit.ctx.http('token');
+ const ok = await fetch('https://api.example.com/v1/items', { method: 'POST', json: { q: 1 } });
+ assert.equal(ok.ok, true);
+ assert.deepEqual(await ok.json(), { items: [1, 2] });
+ assert.equal(ok.headers['content-type'], 'application/json');
+ const missing = await fetch('https://api.example.com/v1/missing');
+ assert.equal(missing.status, 404);
+ assert.equal(await missing.text(), 'nope');
+ assert.deepEqual(kit.requests.map(r => [r.url, r.init.method ?? 'GET', r.connection]), [['https://api.example.com/v1/items', 'POST', 'token'], ['https://api.example.com/v1/missing', 'GET', 'token']]);
+ await assert.rejects(fetch('http://api.example.com/v1/items'), (error: unknown) => error instanceof PluginSdkError && error.code === 'insecure_url');
+ await assert.rejects(fetch('https://evil.example/v1/items'), (error: unknown) => error instanceof PluginSdkError && error.code === 'host_not_allowed');
+ await assert.rejects(kit.ctx.http()('https://api.example.com/other'), (error: unknown) => error instanceof PluginSdkError && error.code === 'http_failed');
+ assert.throws(() => kit.ctx.http('unknown'), (error: unknown) => error instanceof PluginSdkError && error.code === 'connection_unavailable');
+});
+
+test('the test kit refuses a task from a repository outside the limit, as the real getTask does', async () => {
+ const task = (taskId: string, repository: string) => ({ taskId, repository, kind: 'implementation' as const, issueNumber: 2, prNumber: null, state: 'completed', model: null, createdAt: '' });
+ const kit = createTestPluginContext({
+ grantedPermissions: ['tasks.read'], repositories: ['owner/allowed'],
+ // Read asynchronously, as a test answering from a fake store would.
+ propr: { getTask: async taskId => task(taskId, taskId === 'elsewhere' ? 'other/repo' : 'Owner/Allowed') },
+ });
+ await assert.rejects(kit.ctx.propr.getTask('elsewhere'), (error: unknown) => error instanceof PluginSdkError && error.code === 'repository_not_allowed');
+ assert.equal((await kit.ctx.propr.getTask('mine')).repository, 'Owner/Allowed', 'compared case-insensitively');
+ assert.deepEqual(kit.calls, [{ operation: 'getTask', args: ['mine'] }], 'the refused call is not recorded');
+
+ // An unanswered getTask resolves with a placeholder task inside the limit; a missing task is not found.
+ const placeholder = createTestPluginContext({ grantedPermissions: ['tasks.read'], repositories: ['owner/allowed'] });
+ assert.equal((await placeholder.ctx.propr.getTask('t')).repository, 'owner/allowed');
+ const missing = createTestPluginContext({ grantedPermissions: ['tasks.read'], repositories: ['owner/allowed'], propr: { getTask: () => null as never } });
+ await assert.rejects(missing.ctx.propr.getTask('gone'), (error: unknown) => error instanceof PluginSdkError && error.code === 'not_found');
+});
+
+test('the test kit state applies the real limits', async () => {
+ const kit = createTestPluginContext({ state: { a: 1 } });
+ assert.equal(await kit.ctx.state.get('a'), 1);
+ await assert.rejects(kit.ctx.state.set('big', 'x'.repeat(PLUGIN_STATE_LIMITS.valueBytes)), (error: unknown) => error instanceof PluginSdkError && error.code === 'state_limit');
+ await assert.rejects(kit.ctx.state.set('bad key', 1), (error: unknown) => error instanceof PluginSdkError && error.code === 'invalid_input');
+ for (let i = 1; i < PLUGIN_STATE_LIMITS.keys; i++) await kit.ctx.state.set(`k${i}`, i);
+ await assert.rejects(kit.ctx.state.set('one-more', 1), (error: unknown) => error instanceof PluginSdkError && error.code === 'state_limit');
+ assert.equal(await kit.ctx.state.delete('a'), true);
+ const expected = Array.from({ length: PLUGIN_STATE_LIMITS.keys - 1 }, (_, i) => `k${i + 1}`).filter(key => key.startsWith('k1')).sort();
+ assert.deepEqual(await kit.ctx.state.keys('k1'), expected);
+});
diff --git a/packages/plugins/testing/index.ts b/packages/plugins/testing/index.ts
new file mode 100644
index 0000000000..ff5df5fe11
--- /dev/null
+++ b/packages/plugins/testing/index.ts
@@ -0,0 +1,159 @@
+import {
+ PLUGIN_PERMISSIONS, PLUGIN_PROPR_OPERATIONS, PLUGIN_STATE_LIMITS, PluginSdkError,
+ type PluginContext, type PluginHttpRequestInit, type PluginHttpResponse, type PluginJsonValue, type PluginLogLevel, type PluginLogLine,
+ type PluginPermission, type PluginProprClient, type PluginProprOperation, type PluginSettingValues,
+} from '@propr/shared';
+
+/**
+ * A fake `ctx` for plugin unit tests: `ctx.propr` calls are recorded (and
+ * answered by the test), `ctx.http` answers from scripted responses, state is
+ * in memory with the real limits and log lines are captured. Like the real
+ * context, a call outside the granted permissions or the repository limit
+ * throws a `PluginSdkError`, and so does a request to a host the plugin does
+ * not list or over plain HTTP.
+ */
+
+export interface RecordedProprCall { operation: PluginProprOperation; args: unknown[] }
+export interface RecordedHttpRequest { url: string; init: PluginHttpRequestInit; connection: string | null }
+export interface ScriptedHttpResponse { status?: number; headers?: Record; body?: string; json?: PluginJsonValue }
+type Matcher = string | RegExp | ((url: string, init: PluginHttpRequestInit) => boolean);
+type Answers = { [K in PluginProprOperation]?: (...args: Parameters) => Awaited> | Promise>> };
+
+export interface TestPluginContextOptions {
+ pluginId?: string;
+ settings?: PluginSettingValues;
+ /** Repositories the installation is limited to; null (the default) for all. */
+ repositories?: string[] | null;
+ /** Granted permissions; every permission by default. */
+ grantedPermissions?: readonly PluginPermission[];
+ /** The manifest's `outboundHosts`; any host when omitted. */
+ outboundHosts?: readonly string[];
+ /** Connection names `ctx.http(connection)` accepts; any when omitted. */
+ connections?: readonly string[];
+ /** Answers to `ctx.propr` calls; an unanswered call resolves with a minimal placeholder. */
+ propr?: Answers;
+ /** Initial state. */
+ state?: Record;
+}
+
+export interface TestPluginContext {
+ ctx: PluginContext;
+ /** Every `ctx.propr` call that passed the permission and repository checks, in order. */
+ calls: RecordedProprCall[];
+ /** Every `ctx.http` request, in order. */
+ requests: RecordedHttpRequest[];
+ logs: PluginLogLine[];
+ state: Map;
+ /** Answer requests matching `matcher` (a URL prefix, a pattern or a predicate) with `response`; the newest script wins. */
+ respond(matcher: Matcher, response: ScriptedHttpResponse | ((url: string, init: PluginHttpRequestInit) => ScriptedHttpResponse)): void;
+}
+
+const placeholders: { [K in PluginProprOperation]: (...args: never[]) => unknown } = {
+ getPullRequest: (repository: string, number: number) => ({ repository, number, title: '', state: 'open', url: '', labels: [], author: null, createdAt: '', updatedAt: '', draft: false, merged: false, headRef: '', baseRef: '', headSha: '' }),
+ getIssue: (repository: string, number: number) => ({ repository, number, title: '', state: 'open', url: '', labels: [], author: null, createdAt: '', updatedAt: '' }),
+ getTask: (taskId: string) => ({ taskId, repository: 'owner/repo', kind: 'implementation', issueNumber: null, prNumber: null, state: null, model: null, createdAt: '' }),
+ listEvents: () => [],
+ createIssue: (repository: string) => ({ repository, number: 1, url: '' }),
+ createTask: (repository: string, issueNumber: number) => ({ repository, issueNumber, jobId: `issue-${repository.replace('/', '-')}-${issueNumber}` }),
+ commentOnPullRequest: (repository: string, number: number) => ({ repository, number, id: 1, url: '' }),
+ commentOnIssue: (repository: string, number: number) => ({ repository, number, id: 1, url: '' }),
+ addLabel: () => undefined,
+ removeLabel: () => undefined,
+};
+
+function matches(matcher: Matcher, url: string, init: PluginHttpRequestInit): boolean {
+ if (typeof matcher === 'string') return url.startsWith(matcher);
+ if (matcher instanceof RegExp) return matcher.test(url);
+ return matcher(url, init);
+}
+
+function response(url: string, scripted: ScriptedHttpResponse): PluginHttpResponse {
+ const status = scripted.status ?? 200;
+ const text = scripted.json !== undefined ? JSON.stringify(scripted.json) : scripted.body ?? '';
+ const headers = Object.fromEntries(Object.entries({ ...(scripted.json !== undefined ? { 'content-type': 'application/json' } : {}), ...scripted.headers }).map(([k, v]) => [k.toLowerCase(), v]));
+ return { status, ok: status >= 200 && status < 300, url, headers, text: async () => text, json: async () => JSON.parse(text) as T };
+}
+
+function stateStore(state: Map): PluginContext['state'] {
+ const size = (skip?: string) => [...state].reduce((sum, [key, value]) => (key === skip ? sum : sum + Buffer.byteLength(JSON.stringify(value))), 0);
+ const check = (key: unknown) => {
+ if (typeof key !== 'string' || !key.length || key.length > PLUGIN_STATE_LIMITS.keyLength || !/^[\x21-\x7e]+$/.test(key)) throw new PluginSdkError('invalid_input', 'Invalid state key');
+ return key;
+ };
+ return {
+ get: async (key: string) => structuredClone(state.get(check(key))) as T | undefined,
+ async set(key, value) {
+ check(key);
+ const bytes = Buffer.byteLength(JSON.stringify(value) ?? '');
+ if (bytes > PLUGIN_STATE_LIMITS.valueBytes) throw new PluginSdkError('state_limit', 'State value too large');
+ if (!state.has(key) && state.size + 1 > PLUGIN_STATE_LIMITS.keys) throw new PluginSdkError('state_limit', 'Too many state keys');
+ if (size(key) + bytes > PLUGIN_STATE_LIMITS.totalBytes) throw new PluginSdkError('state_limit', 'State too large');
+ state.set(key, structuredClone(value));
+ },
+ delete: async key => state.delete(check(key)),
+ keys: async (prefix = '') => [...state.keys()].filter(key => key.startsWith(prefix)).sort(),
+ };
+}
+
+export function createTestPluginContext(options: TestPluginContextOptions = {}): TestPluginContext {
+ const granted = options.grantedPermissions ?? PLUGIN_PERMISSIONS;
+ const limit = options.repositories?.map(r => r.toLowerCase()) ?? null;
+ const allowed = (repository: unknown) => !limit || (typeof repository === 'string' && limit.includes(repository.toLowerCase()));
+ /** An unanswered call's placeholder; a placeholder task belongs to a repository inside the limit. */
+ const placeholder = (operation: PluginProprOperation, args: unknown[]) => {
+ const value = (placeholders[operation] as (...a: unknown[]) => unknown)(...args);
+ return operation === 'getTask' && options.repositories?.length ? { ...(value as object), repository: options.repositories[0] } : value;
+ };
+ const calls: RecordedProprCall[] = []; const requests: RecordedHttpRequest[] = []; const logs: PluginLogLine[] = [];
+ const state = new Map(Object.entries(structuredClone(options.state ?? {})));
+ const scripts: { matcher: Matcher; answer: ScriptedHttpResponse | ((url: string, init: PluginHttpRequestInit) => ScriptedHttpResponse) }[] = [];
+
+ const propr = Object.fromEntries((Object.keys(PLUGIN_PROPR_OPERATIONS) as PluginProprOperation[]).map(operation => [operation, async (...args: unknown[]) => {
+ const permission = PLUGIN_PROPR_OPERATIONS[operation];
+ if (!granted.includes(permission)) throw new PluginSdkError('permission_denied', `${operation} needs the ${permission} permission`);
+ const repository = operation === 'getTask' ? null : operation === 'listEvents' ? (args[0] as { repository?: string })?.repository : args[0];
+ if (typeof repository === 'string' && !allowed(repository)) throw new PluginSdkError('repository_not_allowed', `This plugin is not enabled for ${repository}`);
+ const call: RecordedProprCall = { operation, args: structuredClone(args) };
+ calls.push(call);
+ const answer = options.propr?.[operation] as ((...a: unknown[]) => unknown) | undefined;
+ const result = await (answer ? answer(...args) : placeholder(operation, args));
+ if (operation === 'getTask') {
+ // Like the real getTask: the task's repository is known only once it is read, and a task outside the limit is not returned.
+ if (!result) throw new PluginSdkError('not_found', 'Task not found');
+ if (!allowed((result as { repository?: unknown }).repository)) {
+ // Refused by the repository check, so not one of `calls`.
+ calls.splice(calls.indexOf(call), 1);
+ throw new PluginSdkError('repository_not_allowed', 'This plugin is not enabled for the task\'s repository');
+ }
+ }
+ return result;
+ }])) as unknown as PluginProprClient;
+
+ const http = (connection?: string) => {
+ if (connection !== undefined && options.connections && !options.connections.includes(connection)) throw new PluginSdkError('connection_unavailable', `No connection ${connection}`);
+ return async (url: string, init: PluginHttpRequestInit = {}) => {
+ let parsed: URL;
+ try { parsed = new URL(url); } catch { throw new PluginSdkError('invalid_input', 'Invalid URL'); }
+ if (parsed.protocol !== 'https:') throw new PluginSdkError('insecure_url', 'Only https:// URLs are allowed');
+ if (options.outboundHosts && !options.outboundHosts.includes(parsed.hostname)) throw new PluginSdkError('host_not_allowed', `${parsed.hostname} is not an outbound host`);
+ requests.push({ url, init: structuredClone(init), connection: connection ?? null });
+ const script = [...scripts].reverse().find(s => matches(s.matcher, url, init));
+ if (!script) throw new PluginSdkError('http_failed', `No scripted response for ${init.method ?? 'GET'} ${url}`);
+ return response(url, typeof script.answer === 'function' ? script.answer(url, init) : script.answer);
+ };
+ };
+
+ const write = (level: PluginLogLevel) => (message: string, fields?: Record) => {
+ logs.push({ level, message: String(message), ...(fields ? { fields: JSON.parse(JSON.stringify(fields)) as Record } : {}), at: Date.now() });
+ };
+
+ const ctx: PluginContext = {
+ pluginId: options.pluginId ?? 'test-plugin',
+ settings: structuredClone(options.settings ?? {}),
+ repositories: options.repositories ? [...options.repositories] : null,
+ propr, http, state: stateStore(state),
+ log: { debug: write('debug'), info: write('info'), warn: write('warn'), error: write('error') },
+ signal: new AbortController().signal,
+ };
+ return { ctx, calls, requests, logs, state, respond: (matcher, answer) => { scripts.push({ matcher, answer }); } };
+}
diff --git a/packages/plugins/tsconfig.handlers.json b/packages/plugins/tsconfig.handlers.json
new file mode 100644
index 0000000000..f710c1bda7
--- /dev/null
+++ b/packages/plugins/tsconfig.handlers.json
@@ -0,0 +1,11 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "rootDir": ".",
+ "outDir": "./dist/plugins",
+ "declaration": false,
+ "declarationMap": false
+ },
+ "include": ["*/handlers.ts", "testing/**/*.ts"],
+ "exclude": ["node_modules", "dist", "src", "test"]
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 450466563c..6581d77180 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -593,6 +593,7 @@ export * from './extensionPackages.js';
export * from './extensionOAuth.js';
export * from './plugins.js';
export * from './pluginInstallations.js';
+export * from './pluginSdk.js';
export { sqliteTimestampMs } from './sqliteTimestamp.js';
export { AGENT_FAILURE_LINE_MAX_CHARS, agentFailureLine } from './agentFailureLine.js';
diff --git a/packages/shared/src/pluginInstallations.ts b/packages/shared/src/pluginInstallations.ts
index f5a755fabb..5965f8f1a8 100644
--- a/packages/shared/src/pluginInstallations.ts
+++ b/packages/shared/src/pluginInstallations.ts
@@ -81,8 +81,13 @@ export interface PluginView {
/** Managed library items and templates; items are null until the plugin is installed. */
contributions?: PluginContributionViews;
}
+/**
+ * One audit trail entry. Management actions name the administrator; `handler_attempt`
+ * (a mutation about to be dispatched), `handler_call` (completed) and `handler_refused`
+ * (refused or failed) record a handler's `ctx.propr` calls with `plugin:` as the actor.
+ */
export interface PluginAuditEvent { action: PluginAuditAction; actor: string; at: number; detail: Record }
-export const PLUGIN_AUDIT_ACTIONS = ['install', 'update_settings', 'set_repositories', 'set_connection', 'clear_connection', 'enable', 'disable', 'approve', 'uninstall', 'start_oauth', 'complete_oauth', 'disconnect_oauth', 'set_oauth_client', 'clear_oauth_client'] as const;
+export const PLUGIN_AUDIT_ACTIONS = ['install', 'update_settings', 'set_repositories', 'set_connection', 'clear_connection', 'enable', 'disable', 'approve', 'uninstall', 'start_oauth', 'complete_oauth', 'disconnect_oauth', 'set_oauth_client', 'clear_oauth_client', 'handler_attempt', 'handler_call', 'handler_refused'] as const;
export type PluginAuditAction = typeof PLUGIN_AUDIT_ACTIONS[number];
export interface PluginDetail extends PluginView { audit: PluginAuditEvent[] }
diff --git a/packages/shared/src/pluginSdk.ts b/packages/shared/src/pluginSdk.ts
new file mode 100644
index 0000000000..c99ecb34a1
--- /dev/null
+++ b/packages/shared/src/pluginSdk.ts
@@ -0,0 +1,165 @@
+import type { PluginPermission } from './plugins.js';
+import type { PluginSettingValues } from './pluginInstallations.js';
+import type { RepositoryEvent, RepositoryEventDomain } from './repositoryEvents.js';
+
+/**
+ * The plugin handler SDK. A plugin folder may contain `handlers.ts` exporting
+ * named async functions `(input, ctx) => result`. `ctx` is the only way a
+ * handler touches anything: its settings, a ProPR client limited to the
+ * installation's grant and repository limit, outbound HTTP limited to the
+ * manifest's `outboundHosts`, a small key-value state store and a structured
+ * log. Official plugins run in-process, but only through this interface, so
+ * the same plugin can later run out of process.
+ *
+ * Handlers import nothing from ProPR but `@propr/shared` (these types among
+ * them); a lint rule enforces this for `packages/plugins/**`.
+ */
+
+/** A handler runs at most this long; a handler still running is reported as a failure. */
+export const PLUGIN_HANDLER_TIMEOUT_MS = 30_000;
+
+/** Limits of `ctx.state`, per installation. Values are JSON; sizes are UTF-8 bytes of the serialized value. */
+export const PLUGIN_STATE_LIMITS = { keys: 256, keyLength: 200, valueBytes: 16 * 1024, totalBytes: 256 * 1024 } as const;
+/** Limits of `ctx.http`. */
+export const PLUGIN_HTTP_LIMITS = { requestBytes: 256 * 1024, responseBytes: 1024 * 1024, timeoutMs: 10_000, headers: 64 } as const;
+/** Limits of `ctx.log`: lines kept per invocation and characters per message. */
+export const PLUGIN_LOG_LIMITS = { lines: 200, messageChars: 2_000, fieldsBytes: 4 * 1024 } as const;
+/** Limits of text a handler writes through `ctx.propr`. */
+export const PLUGIN_PROPR_LIMITS = { titleChars: 256, bodyChars: 65_536, labelChars: 50, labels: 20, events: 100 } as const;
+
+export type PluginJsonValue = string | number | boolean | null | PluginJsonValue[] | { [key: string]: PluginJsonValue };
+
+/** Why a `ctx` call was refused. Thrown to the handler as a `PluginSdkError`. */
+export const PLUGIN_SDK_ERROR_CODES = [
+ 'permission_denied', 'repository_not_allowed', 'not_found', 'invalid_input',
+ 'host_not_allowed', 'insecure_url', 'private_address', 'connection_unavailable', 'response_too_large', 'http_failed',
+ 'state_limit', 'unavailable',
+] as const;
+export type PluginSdkErrorCode = typeof PLUGIN_SDK_ERROR_CODES[number];
+export class PluginSdkError extends Error {
+ readonly name = 'PluginSdkError';
+ constructor(readonly code: PluginSdkErrorCode, message: string) { super(message); }
+}
+
+/** The ProPR operations `ctx.propr` offers and the permission each needs. */
+export const PLUGIN_PROPR_OPERATIONS = {
+ getPullRequest: 'pull_requests.read',
+ getIssue: 'issues.read',
+ getTask: 'tasks.read',
+ listEvents: 'events.read',
+ createIssue: 'issues.create',
+ createTask: 'tasks.create',
+ commentOnPullRequest: 'pull_requests.comment',
+ commentOnIssue: 'issues.comment',
+ addLabel: 'labels.write',
+ removeLabel: 'labels.write',
+} as const satisfies Record;
+export type PluginProprOperation = keyof typeof PLUGIN_PROPR_OPERATIONS;
+
+export interface PluginIssueSummary {
+ repository: string; number: number; title: string; state: 'open' | 'closed'; url: string;
+ labels: string[]; author: string | null; createdAt: string; updatedAt: string;
+}
+export interface PluginPullRequestSummary extends PluginIssueSummary {
+ draft: boolean; merged: boolean; headRef: string; baseRef: string; headSha: string;
+}
+export interface PluginTaskSummary {
+ taskId: string; repository: string; kind: string; issueNumber: number | null; prNumber: number | null;
+ state: string | null; model: string | null; createdAt: string;
+}
+export interface PluginCreatedIssue { repository: string; number: number; url: string }
+export interface PluginCreatedTask { repository: string; issueNumber: number; jobId: string }
+export interface PluginCreatedComment { repository: string; number: number; id: number; url: string }
+export interface PluginListEventsInput { repository: string; afterSeq?: number; domains?: RepositoryEventDomain[]; limit?: number }
+
+/**
+ * ProPR, as far as this installation may see and change it. Every call is
+ * checked against the granted permissions and the repository limit and is
+ * recorded with the plugin as the actor; a refused call throws a
+ * `PluginSdkError` and is logged.
+ */
+export interface PluginProprClient {
+ getPullRequest(repository: string, number: number): Promise;
+ getIssue(repository: string, number: number): Promise;
+ getTask(taskId: string): Promise;
+ listEvents(input: PluginListEventsInput): Promise;
+ createIssue(repository: string, issue: { title: string; body?: string; labels?: string[] }): Promise;
+ /** Start an ordinary one-shot task for an issue. Handlers never interact with a running task. */
+ createTask(repository: string, issueNumber: number): Promise;
+ commentOnPullRequest(repository: string, number: number, body: string): Promise;
+ commentOnIssue(repository: string, number: number, body: string): Promise;
+ addLabel(repository: string, number: number, label: string): Promise;
+ removeLabel(repository: string, number: number, label: string): Promise;
+}
+
+export interface PluginHttpRequestInit {
+ method?: 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
+ headers?: Record;
+ /** A text body; use `json` for a JSON body. */
+ body?: string;
+ json?: PluginJsonValue;
+}
+/** A buffered response. Redirects are not followed; the connection's credential never appears in it. */
+export interface PluginHttpResponse {
+ status: number; ok: boolean; url: string;
+ headers: Record;
+ text(): Promise;
+ json(): Promise;
+}
+/** `fetch`-like outbound HTTPS, limited to the manifest's `outboundHosts`. */
+export type PluginHttpFetch = (url: string, init?: PluginHttpRequestInit) => Promise;
+
+/** A small key-value store scoped to the installation; deleted on uninstall. */
+export interface PluginStateStore {
+ get(key: string): Promise;
+ set(key: string, value: PluginJsonValue): Promise;
+ delete(key: string): Promise;
+ /** Keys in order, optionally only those starting with `prefix`. */
+ keys(prefix?: string): Promise;
+}
+
+export type PluginLogLevel = 'debug' | 'info' | 'warn' | 'error';
+export interface PluginLogLine { level: PluginLogLevel; message: string; fields?: Record; at: number }
+export type PluginLogger = Record) => void>;
+
+export interface PluginContext {
+ pluginId: string;
+ /** The validated settings of the installation. */
+ settings: PluginSettingValues;
+ /** Repositories (`owner/repo`) the installation is limited to; `null` for all. */
+ repositories: string[] | null;
+ propr: PluginProprClient;
+ /** Outbound HTTP; with a connection name, that connection's credential is injected. */
+ http(connection?: string): PluginHttpFetch;
+ state: PluginStateStore;
+ log: PluginLogger;
+ /** Aborted when the handler runs past its time limit. */
+ signal: AbortSignal;
+}
+
+/** Handler input envelopes. The dispatcher, webhook route and commands each build one. */
+export interface PluginEventInput { kind: 'event'; event: RepositoryEvent }
+export interface PluginWebhookInput { kind: 'webhook'; webhook: string; headers: Record; body: string; receivedAt: number }
+export interface PluginCommandInput { kind: 'command'; command: string; args: string; repository: string | null; actor: string }
+export interface PluginInvokeInput { kind: 'invoke'; payload: PluginJsonValue }
+export type PluginHandlerInput = PluginEventInput | PluginWebhookInput | PluginCommandInput | PluginInvokeInput;
+
+/** A handler returns nothing or a small JSON result. */
+export type PluginHandlerResultValue = PluginJsonValue | void;
+export type PluginHandler = (input: I, ctx: PluginContext) => Promise;
+export type PluginHandlerModule = Record;
+/** Declare a handler with typed input; identity at runtime. */
+export const definePluginHandler = (handler: PluginHandler): PluginHandler => handler;
+
+/** What `runPluginHandler` returns; it never throws. */
+export interface PluginHandlerRun {
+ outcome: 'succeeded' | 'failed';
+ /** The handler ran past `PLUGIN_HANDLER_TIMEOUT_MS`. */
+ timedOut: boolean;
+ durationMs: number;
+ /** Redacted; null when the handler succeeded. */
+ error: string | null;
+ logs: PluginLogLine[];
+ /** The handler's result, redacted, when it succeeded and returned one. */
+ result?: PluginJsonValue;
+}
diff --git a/packages/shared/src/plugins.ts b/packages/shared/src/plugins.ts
index 51edc0c739..7381efc58f 100644
--- a/packages/shared/src/plugins.ts
+++ b/packages/shared/src/plugins.ts
@@ -23,6 +23,7 @@ export const PLUGIN_PERMISSIONS = [
'tasks.read', 'tasks.create',
'issues.read', 'issues.create', 'issues.comment',
'pull_requests.read', 'pull_requests.comment',
+ 'labels.write',
'repositories.read',
'notifications.send',
] as const;
@@ -36,6 +37,7 @@ export const PLUGIN_PERMISSION_DESCRIPTIONS: Record =
'issues.comment': 'Comment on issues',
'pull_requests.read': 'Read pull requests',
'pull_requests.comment': 'Comment on pull requests',
+ 'labels.write': 'Add and remove labels on issues and pull requests',
'repositories.read': 'List repositories ProPR manages',
'notifications.send': 'Send ProPR notifications',
};
@@ -51,7 +53,7 @@ export const PLUGIN_LIMITS = {
} as const;
/** Folders of the registry package that can never be plugin ids. */
-export const RESERVED_PLUGIN_IDS = ['src', 'dist', 'test', 'node_modules', 'propr'] as const;
+export const RESERVED_PLUGIN_IDS = ['src', 'dist', 'test', 'testing', 'node_modules', 'propr'] as const;
export const pluginIdSchema = z.string().max(48).regex(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/, 'Plugin ids are lowercase slugs such as my-plugin')
.refine(id => !(RESERVED_PLUGIN_IDS as readonly string[]).includes(id), 'This plugin id is reserved');
/** Names of connections, settings, commands and webhooks inside one plugin. */