Skip to content

[3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule - #3573

Merged
integry merged 15 commits into
3542-epic-plugin-system-xu8from
3548/claude-opus-5-5-plugin-handlers-sdk-types-20261114-1908-nxn
Nov 15, 2026
Merged

integry merged 15 commits into
3542-epic-plugin-system-xu8from
3548/claude-opus-5-5-plugin-handlers-sdk-types-20261114-1908-nxn

Conversation

@propr-dev

@propr-dev propr-dev Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

AI Implementation Summary

Closes #3548

Branch: 3548/claude-opus-5-5-plugin-handlers-sdk-types-20261114-1908-nxn
Commits: ✅ Changes committed (c7aad41)


AI Processing Completed

Execution Details:

Summary:
Nothing changes here either: the second copy of that wait loop also hit its time limit and was stopped, for the same reason. I won't restart it. The test results were already read straight from the suite log, so the implementation summary and acceptance criteria above still stand.

Review digest

Acceptance criteria (0 of 6 met, as stated by the agent)

  • ⚠️ AC1 (not addressed): With a fixture plugin: `runPluginHandler` runs a handler, returns its logs and outcome; a throwing handler and a handler that hangs past the timeout return a failed outcome without throwing.
  • ⚠️ AC2 (not addressed): `ctx.propr` refuses operations outside the granted permissions and outside the repository limit; allowed operations are attributed to the plugin in the audit trail and task events.
  • ⚠️ AC3 (not addressed): `ctx.http` refuses hosts not in `outboundHosts`, private addresses and plain HTTP; credentials are injected and never visible to the handler (sentinel test on handler-visible values and on logs).
  • ⚠️ AC4 (not addressed): `ctx.state` enforces its size and key-count limits and is deleted on uninstall.
  • ⚠️ AC5 (not addressed): The restricted-imports lint rule fails a fixture plugin that imports a core module.
  • ⚠️ AC6 (not addressed): Docs: handler interface in `docs/docs/features/plugins.md` and a new `docs/docs/architecture/plugin-runtime.md`.

Risk-tiered paths

  • High risk (2 files)
    • packages/core/src/db/migrations/20261114180000_plugin_state.js (added, +20/−0): database migration
    • packages/plugins/package.json (+7/−4): dependency manifest
  • Normal risk (23 files)
    • eslint.config.js (+37/−0)
    • packages/client/src/generated/apiTypes.ts (+7/−7)
    • packages/core/src/extensions/endpointPolicy.ts (+5/−1)
    • packages/core/src/plugins/pluginContext.ts (added, +145/−0)
    • packages/core/src/plugins/pluginHandlerRunner.ts (added, +117/−0)
    • packages/core/src/plugins/pluginHttp.ts (added, +177/−0)
    • packages/core/src/plugins/pluginProprBackend.ts (added, +100/−0)
    • packages/core/src/plugins/pluginProprClient.ts (added, +143/−0)
    • packages/core/src/plugins/pluginStateStore.ts (added, +70/−0)
    • packages/core/src/plugins/pluginStore.ts (+4/−2)
    • packages/core/src/queue/taskQueue.types.ts (+2/−0)
    • packages/core/src/services/taskIntent.ts (+4/−20)
    • packages/core/src/services/taskIntentIssueRef.ts (added, +25/−0)
    • packages/core/src/utils/workerStateManager.ts (+3/−3)
    • packages/core/src/utils/workerStateManager.types.ts (+5/−0)
    • packages/plugins/example-pack/handlers.ts (added, +22/−0)
    • packages/plugins/src/index.ts (+43/−2)
    • packages/plugins/testing/index.ts (added, +142/−0)
    • packages/plugins/tsconfig.handlers.json (added, +11/−0)
    • packages/shared/src/index.ts (+1/−0)
    • packages/shared/src/pluginInstallations.ts (+5/−1)
    • packages/shared/src/pluginSdk.ts (added, +165/−0)
    • packages/shared/src/plugins.ts (+3/−1)
  • Low risk (18 files)
    • docs/docs/architecture/plugin-runtime.md (added, +226/−0)
    • docs/docs/features/plugins.md (+112/−5)
    • docs/sidebars.ts (+1/−0)
    • docs/static/openapi/propr-api.yaml (+8/−0)
    • packages/api/test/pluginRoutes.test.ts (+2/−1)
    • packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts (added, +68/−0)
    • packages/core/test/fixtures/plugins/fixture-handlers/manifest.json (added, +24/−0)
    • packages/core/test/fixtures/plugins/fixture-imports-core/handlers.ts (added, +12/−0)
    • packages/core/test/fixtures/plugins/registry.json (added, +6/−0)
    • packages/core/test/helpers/pluginHandlerFixture.ts (added, +92/−0)
    • packages/core/test/pluginContributions.test.ts (+2/−1)
    • packages/core/test/pluginHandlerLint.test.ts (added, +57/−0)
    • packages/core/test/pluginHandlerRunner.test.ts (added, +165/−0)
    • packages/core/test/pluginProprClient.test.ts (added, +151/−0)
    • packages/core/test/pluginStore.test.ts (+2/−1)
    • packages/core/test/taskJournalEvents.test.ts (+13/−0)
    • packages/plugins/example-pack/README.md (+3/−0)
    • packages/plugins/test/testing.test.ts (added, +61/−0)

Validation evidence

  • No repository validation commands ran.

Diff size: 43 files changed, +2,268/−49 lines

Detailed Logs:

  • Conversation: 263 messages
  • Session: 2fc9a78c-6209-42be-bed8-82719e83f81e

Log files stored at:

  • conversation: /usr/src/app/logs/executions/issue-3548-2026-11-14T19-58-35-980Z-conversation.json
  • output: /usr/src/app/logs/executions/issue-3548-2026-11-14T19-58-35-980Z-output.txt
Latest Conversation Messages
ASSISTANT: [content unavailable]

ASSISTANT: Nothing changes: a background wait loop I'd started was stopped at its time limit. It never exited because its `pgrep -f` pattern also matched its own shell. I had already read the results directly fr...

ASSISTANT: Nothing changes here either: the second copy of that wait loop also hit its time limit and was stopped, for the same reason. I won't restart it. The test results were already read straight from the su...


This PR was created automatically by ProPR after processing issue #3548.


💡 Need changes?

Comment on this PR to request refinements — the AI agent monitors comments and will update the implementation based on your feedback. Keep iterating until you're satisfied!

…it its time limit and was stopped, for the same reason. I won't restart it. The test results were already read straight from the suite log, so the implementation summary and acceptance criteria above still stand.
@propr-dev

propr-dev Bot commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

/ultrafix goal=8 max=10
Triggered automatically by the requested execution settings.

@propr-dev

propr-dev Bot commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

🔄 Ultrafix loop started (goal: 8/10, max cycles: 10)

First action: /review

💡 Tip: Remove the ultrafix label from this PR to stop further ultrafix cycles.

@propr-dev

propr-dev Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @propr-ultrafix

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

This PR adds the handler SDK, runtime, scoped clients, persistent state, fixtures, and documentation. It needs changes before merge: permission enforcement, credential redaction, and handler lifecycle cleanup have concrete gaps.

✅ Repository scoping — Explicit repository arguments are checked before backend calls, and getTask checks the retrieved task’s repository before returning it.

✅ Pinned HTTP destinations — The transport dials the vetted public address, preserves the original TLS identity, and does not follow redirects.

✅ Focused coverage — Added fixtures exercise success, exceptions, asynchronous hangs, permission refusals, state limits, and basic credential echoes.

This review uses static analysis of the supplied complete diff; no commands were run. The authoritative checks report 30 passed, 32 neutral/skipped, four pending, and no failures.

Merge blockers

Every finding below was introduced by this PR and must be resolved before merging.

F1: 🔴 Comment permissions do not enforce target type

  • Category: security

  • Required behavior: AC2 requires ctx.propr to refuse operations outside granted permissions; issue and pull-request comment permissions are separate.

  • Evidence: packages/core/src/plugins/pluginProprClient.ts, commentOnIssue and commentOnPullRequest; packages/core/src/plugins/pluginProprBackend.ts, comment.

    1. A plugin has issues.comment but lacks pull_requests.comment.
    2. It calls commentOnIssue with the number of a pull request in an allowed repository.
    3. The scoped client checks only issues.comment, repository scope, and a positive number.
    4. Both comment methods invoke the same backend, which posts to GitHub’s issue-comment endpoint without checking whether the number identifies an issue or pull request.

    The plugin can therefore comment on a pull request without its required permission; the reverse substitution also works. The method-name permission check does not establish the target’s actual type. The existing refusal test calls commentOnPullRequest directly, so it misses this alternate route.

    Verification provenance — static trace: both public methods converge on the identical unchecked backend operation.

  • Minimum fix: Resolve and validate the target’s actual type before posting, enforcing the corresponding permission for both entry points. Add regression coverage for both substitutions.

F2: 🔴 JSON escaping bypasses credential scrubbing

  • Category: security

  • Required behavior: AC3 requires injected credentials never to become visible to handlers, including when an upstream echoes them.

  • Evidence: packages/core/src/plugins/pluginHttp.ts, credentialSpellings and bufferedResponse.

    1. A configured token contains header-valid characters requiring JSON escaping, such as a quotation mark or backslash.
    2. An upstream echoes the injected authorization value through JSON.stringify, as the added echo fixture already does.
    3. bufferedResponse searches the serialized response for the raw token and its base64, hex, and URL encodings. None matches the JSON-escaped token.
    4. The handler calls response.json(), which decodes the escaping and returns the original credential.

    This exposes the credential inside the handler before runner-level result redaction could help. The existing sentinel contains no JSON-escaped characters and therefore does not exercise this failure.

    Verification provenance — static trace: scrubbing precedes JSON parsing, and credentialSpellings omits JSON-escaped representations.

  • Minimum fix: Scrub decoded JSON values before exposing them and ensure text() cannot expose a recoverable JSON-escaped credential. Cover quoted and backslash-containing credentials in handler-visible response tests.

F3: 🔴 HTTP requests can start after timeout

  • Category: correctness

  • Required behavior: The runtime’s documented lifecycle guarantee says an ended handler can no longer act through its context.

  • Evidence: packages/core/src/plugins/pluginContext.ts, live and HTTP context construction; packages/core/src/plugins/pluginHttp.ts, createPluginHttp.

    1. A handler begins a credentialed HTTP mutation shortly before its execution deadline.
    2. The initial live check passes, then createPluginHttp awaits resolvePublic.
    3. While DNS is pending, the runner’s timer aborts the handler signal and returns a failed, timed-out outcome.
    4. DNS subsequently resolves within its own timeout. The HTTP implementation retrieves the credential and starts the transport, sending the mutation after the handler has ended.

    The competing timer can run during the explicit DNS await. No signal reaches createPluginHttp or the transport, and the only lifetime check occurs before that await. This is an avoidable new outbound request after timeout, rather than an already-sent external operation that cannot be undone. The hanging fixture tests only calls initiated after abort.

    Verification provenance — static trace: the abort signal is absent from HTTP options and transport requests.

  • Minimum fix: Propagate the handler signal through HTTP execution, recheck it after DNS and credential awaits before sending, and cancel active transport work on abort. Test a deferred DNS resolution released after the handler timeout.

F4: 🔴 Active handlers can recreate uninstalled state

  • Category: correctness

  • Required behavior: AC4 requires installation state to be deleted on uninstall.

  • Evidence: packages/core/src/plugins/pluginStateStore.ts, set; packages/core/src/plugins/pluginStore.ts, uninstall; packages/core/src/plugins/pluginContext.ts, context construction.

    1. An enabled handler starts and awaits an asynchronous operation.
    2. An administrator disables and uninstalls the plugin while that operation is pending. Uninstall deletes its state and installation rows.
    3. The handler resumes before its timeout and calls ctx.state.set.
    4. The context checks only whether its invocation signal is aborted. The state store inserts a row using the captured plugin ID without verifying installation existence.

    State now persists for an uninstalled plugin and can be inherited by a later installation with the same ID. The uninstall transaction cannot prevent a new transaction starting after it commits; the new table has no installation foreign key, and uninstall does not invalidate active contexts. Existing cleanup coverage finishes every handler before uninstall.

    Verification provenance — static trace: installation status is loaded once, while state writes remain independently available throughout the invocation.

  • Minimum fix: Make state writes verify installation existence within a transaction coordinated with uninstall, or enforce equivalent database referential integrity. Add a regression that resumes a suspended handler after uninstall and confirms no state is recreated.

Suggestions

These are optional follow-ups and are not sent to /fix.

S1: 🟢 Align test-kit repository checks

packages/plugins/testing/index.ts skips repository enforcement for getTask, whereas production checks the returned task’s repository. Matching that behavior would make plugin unit tests more representative. This is a useful follow-up because production already enforces this boundary; the discrepancy affects the fake client.

S2: 🟢 Exercise compiled handler loading

The registry test explicitly exercises the source .ts path. A build-level smoke test loading the generated dist/plugins/<id>/handlers.js would validate the new compiler configuration and compiled resolver together. This is additional coverage, not evidence of a demonstrated build defect.

Score

The implementation covers substantial functionality, but permission bypasses, credential exposure, and lifecycle gaps require correction. Current checks show no failures, although four remain pending.

Score: 5/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 12s
  • Tokens: 91,958 (90,033 in / 1,925 out)
  • Cost: $0.87

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix F1 F2 F3 F4 S1 S2

🤖 Review by ProPR

@propr-dev

propr-dev Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit 85d63e8

Addressed 1 AI review comment (IDs: 6883614951)

Addressed findings F1, F2, F3, F4

Summary of Changes

I've fixed all four findings and added regression tests for each. To check the tests, I ran the new ones against the original source files: all 7 failed there, and all 48 plugin tests pass with the fix. Core typecheck and zero-warning lint on src/plugins pass too.

F1 – comment permission vs. target type. GitHub's issue endpoints accept pull-request numbers too, so the method name alone didn't prove what a number was. The backend's comment now takes the target kind ('issue' or 'pull_request'). It reads the number first and throws not_found without posting if the kind doesn't match. commentOnIssue and commentOnPullRequest each pass their own kind.

  • Same defect elsewhere: getIssue had the same gap, letting issues.read read a pull request, and now refuses one too.
  • Tests: both substitutions are covered, directly and through a handler, and nothing is posted in either case. One existing test changed: it now expects the extra lookup request before the comment is posted.

F2 – JSON-escaped credentials. The list of credential spellings to scrub now includes the JSON-escaped form, plus a variant with / escaped as \/. A JSON body is also checked after decoding, so an upstream can't spell the credential back with \uXXXX or \". If a decoded string held it, text() and json() return the body re-serialised with it replaced; a clean body is returned unchanged. A body nested too deeply to check is refused rather than returned.

  • Tests: a credential containing a quote, a backslash and a /, echoed four ways: JSON.stringify, all-\u escapes, \/ escapes, and as an object key. A non-JSON page with the escaped credential embedded is covered too.

F3 – HTTP requests after timeout. The handler's abort signal now reaches ctx.http. The DNS lookup, credential read and transport call all stop as soon as the run ends, and the signal is checked again before sending. The default transport cancels an in-flight request on abort.

  • Same defect elsewhere: the default ctx.propr backend had the same gap between fetching the GitHub App token and sending. It now gets the signal too and checks it after the token arrives and before queueing a task.
  • Tests: DNS held until after the timeout, the credential held until after the timeout, and an in-flight request getting the abort. In each case nothing is sent.

F4 – state recreated after uninstall. Inside its transaction, a ctx.state.set now requires the installation to still exist with the same installed_at the run started with, so a reinstall under the same id doesn't count. Uninstall now deletes the installation row before the state rows. Either the write commits first and uninstall deletes its row, or the write finds the installation gone and throws unavailable.

  • On Postgres the write holds a FOR SHARE lock on that row until it commits. Our tests use SQLite, which runs one write transaction at a time, so the Postgres locking path itself hasn't been run.
  • Tests: a handler suspended mid-run resumes after uninstall, and after uninstall plus reinstall. Its write fails and no state row exists.
  • One existing test ran example-pack from a record that was never installed. It now inserts a matching installation row first.

I updated docs/docs/architecture/plugin-runtime.md for each change. Nothing here is visible in the UI, so there are no previews. The new tests are in packages/core/test/pluginHandlerLifecycle.test.ts, plus additions to pluginProprClient.test.ts.

Two things I noticed but didn't change, since they're outside these findings:

  • createTask(repository, issueNumber) doesn't check that the number is an issue rather than a pull request. It only needs one permission, so it can't be used to get around one.
  • A handler that is still running when its plugin is disabled, but not uninstalled, can still write state until it finishes or times out.

Review digest

Risk-tiered paths

  • High risk: none
  • Normal risk (6 files)
    • packages/core/src/plugins/pluginContext.ts (+4/−3)
    • packages/core/src/plugins/pluginHttp.ts (+72/−6)
    • packages/core/src/plugins/pluginProprBackend.ts (+31/−5)
    • packages/core/src/plugins/pluginProprClient.ts (+7/−3)
    • packages/core/src/plugins/pluginStateStore.ts (+27/−2)
    • packages/core/src/plugins/pluginStore.ts (+2/−1)
  • Low risk (5 files)
    • docs/docs/architecture/plugin-runtime.md (+24/−4)
    • packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts (+27/−0)
    • packages/core/test/pluginHandlerLifecycle.test.ts (added, +111/−0)
    • packages/core/test/pluginHandlerRunner.test.ts (+2/−0)
    • packages/core/test/pluginProprClient.test.ts (+72/−3)

Validation evidence

  • No repository validation commands ran.

Diff size: 11 files changed, +379/−27 lines


🤖 Implementation Details

  • Model: Claude Opus 5.5 (medium)
  • Requested By: @propr-ultrafix
  • Time: 9m 19s
  • Tokens: 3,508,050 (3,475,894 in / 32,156 out)
  • Cost: $1.92

View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR

… each. To check the tests, I ran the new ones against the original source files: all 7 failed there, and all 48 plugin tests pass with the fix. Core typecheck and zero-warning lint on `src/plugins` pass too.

I've fixed all four findings and added regression tests for each. To check the tests, I ran the new ones against the original source files: all 7 failed there, and all 48 plugin tests pass with the fix. Core typecheck and zero-warning lint on `src/plugins` pass too.

**F1 – comment permission vs. target type.** GitHub's issue endpoints accept pull-request numbers too, so the method name alone didn't prove what a number was. The backend's `comment` now takes the target kind (`'issue'` or `'pull_request'`). It reads the number first and throws `not_found` without posting if the kind doesn't match. `commentOnIssue` and `commentOnPullRequest` each pass their own kind.
- **Same defect elsewhere:** `getIssue` had the same gap, letting `issues.read` read a pull request, and now refuses one too.
- **Tests:** both substitutions are covered, directly and through a handler, and nothing is posted in either case. One existing test changed: it now expects the extra lookup request before the comment is posted.

**F2 – JSON-escaped credentials.** The list of credential spellings to scrub now includes the JSON-escaped form, plus a variant with `/` escaped as `\/`. A JSON body is also checked after decoding, so an upstream can't spell the credential back with `\uXXXX` or `\"`. If a decoded string held it, `text()` and `json()` return the body re-serialised with it replaced; a clean body is returned unchanged. A body nested too deeply to check is refused rather than returned.
- **Tests:** a credential containing a quote, a backslash and a `/`, echoed four ways: `JSON.stringify`, all-`\u` escapes, `\/` escapes, and as an object key. A non-JSON page with the escaped credential embedded is covered too.

**F3 – HTTP requests after timeout.** The handler's abort signal now reaches `ctx.http`. The DNS lookup, credential read and transport call all stop as soon as the run ends, and the signal is checked again before sending. The default transport cancels an in-flight request on abort.
- **Same defect elsewhere:** the default `ctx.propr` backend had the same gap between fetching the GitHub App token and sending. It now gets the signal too and checks it after the token arrives and before queueing a task.
- **Tests:** DNS held until after the timeout, the credential held until after the timeout, and an in-flight request getting the abort. In each case nothing is sent.

**F4 – state recreated after uninstall.** Inside its transaction, a `ctx.state.set` now requires the installation to still exist with the same `installed_at` the run started with, so a reinstall under the same id doesn't count. Uninstall now deletes the installation row before the state rows. Either the write commits first and uninstall deletes its row, or the write finds the installation gone and throws `unavailable`.
- On Postgres the write holds a `FOR SHARE` lock on that row until it commits. Our tests use SQLite, which runs one write transaction at a time, so the Postgres locking path itself hasn't been run.
- **Tests:** a handler suspended mid-run resumes after uninstall, and after uninstall plus reinstall. Its write fails and no state row exists.
- One existing test ran example-pack from a record that was never installed. It now inserts a matching installation row first.

I updated `docs/docs/architecture/plugin-runtime.md` for each change. Nothing here is visible in the UI, so there are no previews. The new tests are in `packages/core/test/pluginHandlerLifecycle.test.ts`, plus additions to `pluginProprClient.test.ts`.

Two things I noticed but didn't change, since they're outside these findings:
- `createTask(repository, issueNumber)` doesn't check that the number is an issue rather than a pull request. It only needs one permission, so it can't be used to get around one.
- A handler that is still running when its plugin is disabled, but not uninstalled, can still write state until it finishes or times out.

PR: #3573
Comment by: @propr-ultrafix (ID: 0)
Model: Claude Opus 5.5
@propr-dev

propr-dev Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @propr-ultrafix

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

This PR adds the handler SDK, runtime, scoped ProPR client, HTTP transport, persistent state, documentation, and fixtures. It needs changes before merge: installation isolation and timeout fencing remain incomplete in several mutation paths.

✅ Explicit permission checks — Operations enforce granted permissions and repository limits; GitHub issue/comment operations also verify the target kind.

✅ Constrained HTTP transport — Requests resolve and validate addresses before dialing, retain the original TLS identity, and cancel through the handler signal.

✅ Focused regression coverage — Tests cover handler failures, HTTP credential echoes, permission refusals, and state writes after uninstall.

The authoritative checks report 34 passed, none failed, and one pending. This review uses static analysis of the supplied code; no commands were run.

Merge blockers

Every finding below was introduced by this PR and must be resolved before merging.

F5: 🔴 Fence every state operation by installation

  • Category: correctness

  • Required behavior: ctx.state belongs to one installation; an old handler must not access or mutate a replacement installation’s state.

  • Evidence: packages/core/src/plugins/pluginStateStore.ts, get, delete, and keys; packages/core/src/plugins/pluginContext.ts, state construction.

    1. A handler awaits an operation while its installation is uninstalled, as already exercised by suspendThenSet.
    2. The administrator reinstalls the same plugin, and a new handler stores a mapping under an existing key.
    3. The old handler resumes before its timeout and calls ctx.state.delete(key). The deletion filters only by plugin_id and state_key, so it deletes the new installation’s mapping.
    4. Likewise, get and keys allow the old invocation to read replacement state.

    The installation timestamp guard protects only set. The context wrapper checks only whether the invocation’s signal is aborted; uninstall does not abort that signal. The lifecycle test exercises set exclusively, so it does not cover these sibling paths. static trace: the supplied implementations establish this sequence without requiring concurrent transactions.

  • Minimum fix: Apply the installation guard to reads and deletion as well, keeping the installation protected through each database operation. Extend the uninstall/reinstall regression to cover these methods.

F6: 🔴 Recheck cancellation before queue insertion

  • Category: correctness

  • Required behavior: Once a handler run ends, its pending operations must not start new queued work.

  • Evidence: packages/core/src/plugins/pluginProprBackend.ts, createTask and defaultEnqueue.

    1. A live handler calls ctx.propr.createTask; the backend’s live() check passes.
    2. defaultEnqueue awaits the queue-module import and then getIssueQueue().
    3. The handler timeout fires while either initialization await remains pending, aborting the invocation.
    4. Initialization completes and queue.add(...) runs unconditionally, creating work after the caller has received a timed-out outcome.

    The signal is available to the backend but is never passed into defaultEnqueue. The existing test checks a call made after cancellation and delayed GitHub authentication; it does not exercise cancellation during default queue initialization. static trace: there is no cancellation check between either initialization await and the queue mutation.

  • Minimum fix: Pass the invocation signal into the default enqueue path and check it after queue initialization, immediately before queue.add. Add a regression that holds queue initialization until after cancellation.

F7: 🔴 Recheck state cancellation before mutation

  • Category: correctness

  • Required behavior: A pending state write must be refused when its handler run ends before the write commits.

  • Evidence: packages/core/src/plugins/pluginStateStore.ts, requireInstallation and set.

    1. A handler starts ctx.state.set; its transaction reads the installation and passes the signal check in requireInstallation.
    2. The transaction awaits the aggregate count/size query.
    3. The handler timeout fires before that query completes.
    4. The aggregate query returns, and the transaction inserts or updates the state row without checking the now-aborted signal.

    This leaves durable state changes after the run has failed. The installation lock prevents uninstall from overtaking the transaction, but it does not prevent the timer from aborting the handler. The outer context check also predates these awaits. static trace: the only transactional signal check precedes the aggregate query and mutation.

  • Minimum fix: Recheck cancellation after the aggregate query and before mutation, and check again before returning from the transaction so cancellation observed during the mutation rolls it back. Test cancellation while the aggregate query is held.

Suggestions

These are optional follow-ups and are not sent to /fix.

S3: 🟢 Match test-kit task scoping

packages/plugins/testing/index.ts skips repository validation for getTask and never validates the returned task’s repository. Production checks that result before returning it. Matching this behavior would make plugin tests more representative; this is optional because the production access check is already present.

Score

The implementation has substantial coverage and clear boundaries, but the remaining lifecycle defects can delete replacement state or perform mutations after timeout. These require localized fixes before merge.

Score: 6/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 24s
  • Tokens: 97,153 (95,195 in / 1,958 out)
  • Cost: $0.94

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix F5 F6 F7 S3

🤖 Review by ProPR

@propr-dev

propr-dev Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit d30665f

Addressed 1 AI review comment (IDs: 6884070252)

Addressed findings F5, F6, F7

Summary of Changes

I fixed all three findings (F5, F6, F7), each with a regression test. When I ran the new tests against the original source files, all three failed; with the fix, the plugin test files pass. Core typecheck passes, and zero-warning lint passes on src/plugins. Lint only reported "file ignored" for the test files, because core lint covers src/ only.

F5: every state operation is now tied to the installation (pluginStateStore.ts)

  • get, set, delete and keys now all run inside one transaction that first checks the installation is still the same one, by comparing installed_at. On Postgres it also holds a shared lock on the installation row until commit, so an uninstall can't overtake an operation in progress.
  • An old handler that resumes after an uninstall and reinstall now gets unavailable from all four methods. It can no longer read, list or delete the new installation's state.
  • Test: for each of get, keys and delete, a held handler resumes after uninstall plus reinstall, gets unavailable, and the new installation's value is still there. The suspendThenSet fixture handler gained an optional op input so this test could reuse it.

F7: state cancellation is checked again before and after each change

  • set checks the signal again after the count/size query and before the insert.
  • Every operation also checks the signal just before its transaction commits. If the run ended during the insert or delete, the change is rolled back.
  • Test: I abort the signal at the moment the count/size query is sent, then do the same during the insert and during the delete. Each call is refused, and the stored value and key list stay as they were.

F6: no task is queued after cancellation (pluginProprBackend.ts)

  • createTask now passes the run's signal to the enqueue function.
  • The default enqueue loads the queue, checks the signal, and only then calls queue.add.
  • To make the test possible, there is a new optional issueQueue setting so a test can supply the queue. A custom enqueueIssueTask now also receives the signal as a third argument.
  • Test: queue initialization is held until after the abort; no job is added and the call is refused with unavailable. A live run still queues its job.

I checked the backend's other GitHub write calls for the same gap and found none: every request already rechecks the signal after the App token is fetched. I found nothing separate to report outside these three findings, and I made no previews because nothing here is visible to a user.

Review digest

Risk-tiered paths

  • High risk: none
  • Normal risk (3 files)
    • packages/core/src/plugins/pluginContext.ts (+1/−1)
    • packages/core/src/plugins/pluginProprBackend.ts (+24/−7)
    • packages/core/src/plugins/pluginStateStore.ts (+32/−17)
  • Low risk (3 files)
    • packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts (+12/−3)
    • packages/core/test/pluginHandlerLifecycle.test.ts (+56/−0)
    • packages/core/test/pluginProprClient.test.ts (+24/−0)

Validation evidence

  • No repository validation commands ran.

Diff size: 6 files changed, +149/−28 lines


🤖 Implementation Details

  • Model: Claude Opus 5.5 (medium)
  • Requested By: @propr-ultrafix
  • Time: 10m 39s
  • Tokens: 1,356,745 (1,356,462 in / 283 out)
  • Cost: $0.61

View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR

…sion test. When I ran the new tests against the original source files, all three failed; with the fix, the plugin test files pass. Core typecheck passes, and zero-warning lint passes on `src/plugins`. Lint only reported "file ignored" for the test files, because core lint covers `src/` only.

I fixed all three findings (F5, F6, F7), each with a regression test. When I ran the new tests against the original source files, all three failed; with the fix, the plugin test files pass. Core typecheck passes, and zero-warning lint passes on `src/plugins`. Lint only reported "file ignored" for the test files, because core lint covers `src/` only.

**F5: every state operation is now tied to the installation** (`pluginStateStore.ts`)
- `get`, `set`, `delete` and `keys` now all run inside one transaction that first checks the installation is still the same one, by comparing `installed_at`. On Postgres it also holds a shared lock on the installation row until commit, so an uninstall can't overtake an operation in progress.
- An old handler that resumes after an uninstall and reinstall now gets `unavailable` from all four methods. It can no longer read, list or delete the new installation's state.
- Test: for each of `get`, `keys` and `delete`, a held handler resumes after uninstall plus reinstall, gets `unavailable`, and the new installation's value is still there. The `suspendThenSet` fixture handler gained an optional `op` input so this test could reuse it.

**F7: state cancellation is checked again before and after each change**
- `set` checks the signal again after the count/size query and before the insert.
- Every operation also checks the signal just before its transaction commits. If the run ended during the insert or delete, the change is rolled back.
- Test: I abort the signal at the moment the count/size query is sent, then do the same during the insert and during the delete. Each call is refused, and the stored value and key list stay as they were.

**F6: no task is queued after cancellation** (`pluginProprBackend.ts`)
- `createTask` now passes the run's signal to the enqueue function.
- The default enqueue loads the queue, checks the signal, and only then calls `queue.add`.
- To make the test possible, there is a new optional `issueQueue` setting so a test can supply the queue. A custom `enqueueIssueTask` now also receives the signal as a third argument.
- Test: queue initialization is held until after the abort; no job is added and the call is refused with `unavailable`. A live run still queues its job.

I checked the backend's other GitHub write calls for the same gap and found none: every request already rechecks the signal after the App token is fetched. I found nothing separate to report outside these three findings, and I made no previews because nothing here is visible to a user.

PR: #3573
Comment by: @propr-ultrafix (ID: 0)
Model: Claude Opus 5.5
@propr-dev

propr-dev Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @propr-ultrafix

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

The PR adds the handler SDK, runner, scoped clients, persistent state, documentation, and substantial fixture coverage. It needs changes before merge because credential isolation has two remaining gaps.

✅ Permission enforcement — scopedProprClient checks operation permissions and repository limits; the backend also distinguishes issues from pull requests before commenting.

✅ Cancellation coverage — HTTP and GitHub operations recheck cancellation after asynchronous setup, and state operations check cancellation before committing.

✅ State lifecycle protection — State access checks installation identity, preventing an old handler from accessing a later installation’s state.

This review is based on static analysis of the supplied code; no commands were run. Current checks show no failures and five pending.

Merge blockers

Every finding below was introduced by this PR and must be resolved before merging.

F8: 🔴 Escaped response headers expose credentials

  • Category: security

  • Required behavior: AC3 requires injected credentials never to become visible to the handler.

  • Evidence: packages/core/src/plugins/pluginHttp.ts — bufferedResponse, credentialSpellings, and scrubbedBody.

    1. A handler makes an authenticated request through ctx.http('api-token').
    2. The upstream echoes the authorization value in a JSON-valued response header, with its characters encoded as \uXXXX, matching the serialization case already covered for response bodies.
    3. bufferedResponse passes header values through literal scrub only. Neither the plaintext credential nor its enumerated encodings matches the Unicode-escaped value.
    4. The handler receives that header and recovers the credential with JSON.parse.

    The decoded-JSON protection in scrubbedBody applies only to the body. Final result and log redaction cannot undo disclosure to the executing handler. static trace: traced the separate body and header sanitization paths. proposed regression: extend fetchProbe to parse a Unicode-escaped JSON response header and check for the sentinel.

  • Minimum fix: Apply the decoded-JSON credential check to JSON-valued response headers before exposing them, preserving literal scrubbing for other headers. Add the header sentinel regression.

F9: 🔴 Old runs can use reinstalled credentials

  • Category: security

  • Required behavior: A handler context belonging to one installation must not acquire credentials belonging to a replacement installation.

  • Evidence: packages/core/src/plugins/pluginContext.ts — http credential callback and defaultConnectionSecret; packages/core/src/plugins/pluginHttp.ts — createPluginHttp.

    1. An enabled installation starts an authenticated POST through ctx.http; execution pauses at the awaited DNS lookup in createPluginHttp.
    2. Before the handler timeout, an administrator uninstalls the plugin, reinstalls the same ID, and configures a replacement token connection with the same name.
    3. DNS completes. The old context resolves its credential using only pluginId and connection name, so it retrieves the replacement installation’s secret.
    4. The old request is sent with that new credential, allowing work from the removed installation to act under the replacement installation’s account.

    Uninstall does not abort the runner’s controller. The HTTP lifetime checks therefore pass, and the installedAt guard protects only ctx.state. The credential lookup receives no installation identity with which to distinguish the two installations. static trace: traced DNS completion through credential resolution and transport dispatch, comparing it with the explicit state installation guard. proposed regression: hold DNS, uninstall/reinstall with a different sentinel token, release DNS, and assert no request is dispatched.

  • Minimum fix: Bind HTTP credential resolution and dispatch authorization to the originating installation identity. Refuse the request when that installation has been removed or replaced, and add the lifecycle regression.

Suggestions

These are optional follow-ups and are not sent to /fix.

S4: 🟢 Match test-kit task scoping

packages/plugins/testing/index.ts skips repository checks for getTask and never checks the returned task’s repository. Production does check it. Bringing the fake into alignment would prevent misleading handler tests; this is optional because production access enforcement is already present.

Score

The implementation has useful coverage and clear separation of responsibilities, but the two credential-boundary defects require correction. Pending checks should also complete before merging.

Score: 6/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 43s
  • Tokens: 100,097 (97,617 in / 2,480 out)
  • Cost: $0.99

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix F8 F9 S4

🤖 Review by ProPR

@propr-dev

propr-dev Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit 3c22120

Addressed 1 AI review comment (IDs: 6884386689)

Addressed findings F8, F9

Summary of Changes

I fixed F8 and F9 and added regression tests for both. With the fixes, the touched core plugin test files pass (pluginHandlerLifecycle 8/8, pluginHandlerRunner 6/6), and so does packages/plugins/test/testing.test.ts. When I swapped the original source files back in, both new tests failed, so they catch the defects. Core typecheck, the plugins package typecheck and zero-warning lint on src/plugins pass. I made one last small refactor, to clear a lint warning about too many parameters; lint, typecheck and those two suites were re-run after it.

F8: credentials hidden in escaped response headers (pluginHttp.ts)

  • Headers now go through a new scrubbedHeader step instead of the literal scrub alone. After literal scrubbing, it decodes JSON escapes (\uXXXX, \", \\, \/, \n and so on) anywhere in the value, up to 8 times so double-escaped values are caught too. If the decoded text contains any spelling of the credential, the whole header value is replaced with [REDACTED].
  • This goes further than only handling headers that are entirely JSON. A value like "\u0042…", other, where several headers were joined with , , is not valid JSON as a whole, but a handler could split it and parse one part.
  • Headers that don't contain the credential are passed through byte-for-byte; a new test checks this.
  • fetchProbe now also reports parsedHeadersHaveProbe: it parses each header value, and each , -separated part, as JSON. The test covers a fully \u-escaped string, a JSON object, a joined value and a double-encoded value.

F9: old runs using a reinstalled plugin's credentials (pluginContext.ts, pluginHttp.ts, pluginStateStore.ts)

  • The default credential lookup now checks the run's own installation (matched by installed_at) and reads the secret in the same transaction. It uses the same requireInstallation check as ctx.state, which is now exported for this.
  • createPluginHttp has a new authorize option. For authenticated requests the context sets it to re-check the installation after DNS and the credential lookup, just before sending. This also covers a custom connectionSecret source and a credential cached from an earlier request.
  • The new lifecycle test holds DNS and then tries three cases: uninstall only, uninstall and reinstall with a replacement token, and the same reinstall using a lookup that knows only the plugin id. In every case the handler gets unavailable, nothing reaches the transport, and the replacement token never appears in the run's result.
  • One race remains and can't be closed: an uninstall that commits after the final check but before the HTTPS request goes out. In that window the request can only carry the old installation's own secret, never the replacement's.
  • I added a short "Installation binding" paragraph and the header rule to docs/docs/architecture/plugin-runtime.md.

Found along the way, not fixed (outside F8/F9):

  • Response header names are never scrubbed. An upstream that sends a header named after a lowercase credential would show it to the handler.
  • The body check only works when the whole body is JSON. A non-JSON body, such as HTML, that contains a \u-escaped JSON string of the credential is only scrubbed literally, so the handler could pull out that string and decode it.

Review digest

Risk-tiered paths

  • High risk: none
  • Normal risk (3 files)
    • packages/core/src/plugins/pluginContext.ts (+12/−4)
    • packages/core/src/plugins/pluginHttp.ts (+30/−1)
    • packages/core/src/plugins/pluginStateStore.ts (+1/−1)
  • Low risk (3 files)
    • docs/docs/architecture/plugin-runtime.md (+12/−2)
    • packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts (+6/−1)
    • packages/core/test/pluginHandlerLifecycle.test.ts (+51/−1)

Validation evidence

  • No repository validation commands ran.

Diff size: 6 files changed, +112/−10 lines


🤖 Implementation Details

  • Model: Claude Opus 5.5 (medium)
  • Requested By: @propr-ultrafix
  • Time: 6m 9s
  • Tokens: 1,656,318 (1,641,198 in / 15,120 out)
  • Cost: $0.99

View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR

… the fixes, the touched core plugin test files pass (pluginHandlerLifecycle 8/8, pluginHandlerRunner 6/6), and so does `packages/plugins/test/testing.test.ts`. When I swapped the original source files back in, both new tests failed, so they catch the defects. Core typecheck, the plugins package typecheck and zero-warning lint on `src/plugins` pass. I made one last small refactor, to clear a lint warning about too many parameters; lint, typecheck and those two suites were re-run after it.

I fixed F8 and F9 and added regression tests for both. With the fixes, the touched core plugin test files pass (pluginHandlerLifecycle 8/8, pluginHandlerRunner 6/6), and so does `packages/plugins/test/testing.test.ts`. When I swapped the original source files back in, both new tests failed, so they catch the defects. Core typecheck, the plugins package typecheck and zero-warning lint on `src/plugins` pass. I made one last small refactor, to clear a lint warning about too many parameters; lint, typecheck and those two suites were re-run after it.

**F8: credentials hidden in escaped response headers** (`pluginHttp.ts`)
- Headers now go through a new `scrubbedHeader` step instead of the literal `scrub` alone. After literal scrubbing, it decodes JSON escapes (`\uXXXX`, `\"`, `\\`, `\/`, `\n` and so on) anywhere in the value, up to 8 times so double-escaped values are caught too. If the decoded text contains any spelling of the credential, the whole header value is replaced with `[REDACTED]`.
- This goes further than only handling headers that are entirely JSON. A value like `"\u0042…", other`, where several headers were joined with `, `, is not valid JSON as a whole, but a handler could split it and parse one part.
- Headers that don't contain the credential are passed through byte-for-byte; a new test checks this.
- `fetchProbe` now also reports `parsedHeadersHaveProbe`: it parses each header value, and each `, `-separated part, as JSON. The test covers a fully `\u`-escaped string, a JSON object, a joined value and a double-encoded value.

**F9: old runs using a reinstalled plugin's credentials** (`pluginContext.ts`, `pluginHttp.ts`, `pluginStateStore.ts`)
- The default credential lookup now checks the run's own installation (matched by `installed_at`) and reads the secret in the same transaction. It uses the same `requireInstallation` check as `ctx.state`, which is now exported for this.
- `createPluginHttp` has a new `authorize` option. For authenticated requests the context sets it to re-check the installation after DNS and the credential lookup, just before sending. This also covers a custom `connectionSecret` source and a credential cached from an earlier request.
- The new lifecycle test holds DNS and then tries three cases: uninstall only, uninstall and reinstall with a replacement token, and the same reinstall using a lookup that knows only the plugin id. In every case the handler gets `unavailable`, nothing reaches the transport, and the replacement token never appears in the run's result.
- One race remains and can't be closed: an uninstall that commits after the final check but before the HTTPS request goes out. In that window the request can only carry the old installation's own secret, never the replacement's.
- I added a short "Installation binding" paragraph and the header rule to `docs/docs/architecture/plugin-runtime.md`.

**Found along the way, not fixed (outside F8/F9):**
- Response header *names* are never scrubbed. An upstream that sends a header named after a lowercase credential would show it to the handler.
- The body check only works when the whole body is JSON. A non-JSON body, such as HTML, that contains a `\u`-escaped JSON string of the credential is only scrubbed literally, so the handler could pull out that string and decode it.

PR: #3573
Comment by: @propr-ultrafix (ID: 0)
Model: Claude Opus 5.5
@propr-dev

propr-dev Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @propr-ultrafix

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

This PR adds the handler SDK, runner, scoped ProPR client, outbound HTTP, persistent state, documentation, and fixtures. It is not ready to merge: credential-response filtering and authorization after uninstall need correction.

✅ Network destination checks — HTTP requests validate HTTPS, allowed hosts, and public DNS results, then dial the vetted address without following redirects.

✅ State lifecycle protection — State operations check installation identity inside transactions, and the added tests cover uninstall/reinstall and cancellation rollback.

✅ Permission separation — Issue and pull-request comment operations verify the target kind before posting.

This review is based on static tracing of the supplied code; no commands were run. The authoritative checks report 32 passed, 2 pending, and no failures.

Merge blockers

Every finding below was introduced by this PR and must be resolved before merging.

F10: 🔴 Escaped credentials survive response-body filtering

  • Category: security

  • Required behavior: AC3 requires injected credentials never to become visible to the handler, including through HTTP responses.

  • Evidence: packages/core/src/plugins/pluginHttp.ts — scrubbedBody, scrubJson, and bufferedResponse.

    1. A handler requests an allowed HTTPS endpoint using a token connection.
    2. The endpoint echoes the injected authorization value inside an HTML script, encoding the credential’s characters as JSON \uXXXX escapes.
    3. scrubbedBody first replaces literal credential spellings. credentialSpellings does not include the fully Unicode-escaped spelling.
    4. Parsing the complete HTML body as JSON fails, so scrubbedBody returns that body unchanged.
    5. The handler extracts and parses the script’s JSON string and obtains the credential.

    A related path exists when a valid JSON response contains another JSON document as a string: scrubJson checks that string without decoding its nested escapes. The handler can recover the credential by parsing the nested document. Header filtering already addresses repeated escape decoding, but body filtering does not. Redacting the eventual handler result cannot undo disclosure inside the handler.

    static trace: Both paths follow the supplied filtering functions. Existing tests cover Unicode escapes in a directly parsed JSON body and ordinary JSON escaping inside HTML, but not Unicode-escaped HTML or nested serialized JSON.

  • Minimum fix: Apply escape-aware credential detection to response bodies, including non-JSON bodies and nested serialized strings; withhold or scrub responses that would reveal the credential after decoding. Add regressions for both demonstrated paths.

F11: 🔴 Uninstalled handlers retain ProPR authority

  • Category: security

  • Required behavior: Installation-scoped operations must not retain authority after their installation is removed; plugin operations must respect the installation’s granted access.

  • Evidence: packages/core/src/plugins/pluginContext.ts — buildPluginContext, liveMethods, and construction of ctx.propr; packages/core/src/plugins/pluginProprBackend.ts — request and createTask.

    1. An enabled plugin starts a handler with permission to create issues or tasks.
    2. The handler awaits an operation while its 30-second run remains active.
    3. During that await, an administrator disables and uninstalls the plugin. Uninstall removes the installation and state, but does not abort the handler’s controller.
    4. The handler resumes and calls ctx.propr.createIssue or ctx.propr.createTask.
    5. liveMethods checks only the run’s abort signal. scopedProprClient checks the permissions and repository list captured before uninstall. The default backend likewise checks only the signal.
    6. The operation creates an external issue or queues work for a plugin that is no longer installed.

    No installation lock or existence check protects this path. The installation checks added for state and authenticated HTTP do not cover ctx.propr; its audit insert also does not establish installation authority.

    static trace: The context retains its original permission snapshot, and neither the scoped client nor default backend revalidates installation identity. The lifecycle fixtures already demonstrate handlers remaining active across uninstall, but only test subsequent state operations.

  • Minimum fix: Bind ctx.propr authorization to the originating installation and refuse operations after removal or replacement. Recheck before dispatch after asynchronous backend initialization, covering both GitHub mutations and task enqueueing. Add a suspended-handler regression that uninstalls before the next mutation.

Suggestions

These are optional follow-ups and are not sent to /fix.

S5: 🟢 Clarify test-kit fidelity

packages/plugins/testing/index.ts skips repository validation for getTask results, whereas production checks the returned task’s repository. Its state implementation also accepts values such as NaN that production rejects. Aligning these behaviors, or documenting the fake’s limitations, would prevent misleading handler tests. This is a non-blocking improvement to the supplemental test kit.

Score

The implementation has substantial coverage and useful lifecycle protections, but two security-boundary defects remain. Pending checks alone do not affect this assessment.

Score: 5/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 40s
  • Tokens: 101,390 (98,803 in / 2,587 out)
  • Cost: $1.01

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix F10 F11 S5

🤖 Review by ProPR

@propr-dev

propr-dev Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit ca5eb57

Addressed 1 AI review comment (IDs: 6884754775)

Addressed findings F10, F11

Summary of Changes

I fixed F10 and F11 and added regression tests for both. With the fixes, all the plugin tests pass: 25 in the handler, ctx.propr and runner suites, and 30 more in the store, contributions, lint and packages/plugins testing suites. When I swapped the original source files back in, all three new tests failed. Core typecheck and zero-warning lint on src/plugins pass.

F10: credentials escaped in response bodies (pluginHttp.ts)

  • There is now one shared check, revealsCredential. It decodes the text's JSON escapes one round at a time, up to 32 rounds, and looks for any spelling of the credential after each round. Text that is still decoding after 32 rounds counts as holding the credential, so the check fails closed.
  • Bodies: after the literal scrub, any body (HTML, plain text or JSON) that still decodes to the credential is replaced with [REDACTED]. That covers \uXXXX escapes inside an HTML script.
  • Nested JSON: inside a JSON body, a string that would reveal the credential when parsed again is replaced with [REDACTED] on its own. The rest of the document stays readable.
  • Headers: header filtering had the same weakness, so it now uses the same check. It used to stop after 8 decode rounds and pass the header through unchanged. An escape like \u005c turns into the backslash of the next escape, so about 2 KB was enough to hide a 40-character token 10 rounds deep.
  • The new test covers the HTML script case and the nested JSON case. It also checks 10-round and 40-round escapes in both a body and a header, and that escapes with no credential in them come through unchanged.

F11: uninstalled handlers keeping ctx.propr access

  • buildPluginContext now has one installation check, also used by authenticated ctx.http. It confirms the run's installation (same installed_at) still exists and the run hasn't ended.
  • scopedProprClient runs this check before every call reaches the backend, after its permission and repository checks. That covers every operation and any backend.
  • The default GitHub backend runs it again right before each GitHub request, after the App client is ready. It also runs it before adding a task job, after the queue starts up or before a custom enqueueIssueTask.
  • A refused call returns unavailable ("The plugin is no longer installed") and is recorded as handler_refused. Audit rows don't depend on the installation, so recording still works after uninstall.
  • The new tests suspend a handler, then uninstall (or uninstall and reinstall) before its next createIssue, createTask or commentOnIssue; nothing reaches the backend. A second test uninstalls while the App token and the queue are still loading; nothing is sent or queued.

One race remains and can't be closed: the check commits before the GitHub request goes out, so uninstall doesn't wait for a request that already passed it. ctx.http behaves the same way.

Docs: docs/docs/architecture/plugin-runtime.md now describes the new body and nested-string handling and the new installation rule for ctx.propr.

Separate findings, not changed:

  • Filtering only covers JSON-style escapes. A plugin author who controls an allowed host can still return the credential in any encoding they make up (reversed, XOR'd, and so on).
  • ctx.propr still uses the permissions and repository list from when the run started. A change to the grant or repository limit, or disabling the plugin, during a run isn't rechecked; only removal or reinstall is.
  • An existing type error in the fetchUrl fixture handler (handlers.ts:40, TS2352) shows up when that file is typechecked. Normal typecheck doesn't cover it.

There are no visual changes, so I didn't create previews.

Review digest

Risk-tiered paths

  • High risk: none
  • Normal risk (4 files)
    • packages/core/src/plugins/pluginContext.ts (+6/−2)
    • packages/core/src/plugins/pluginHttp.ts (+50/−21)
    • packages/core/src/plugins/pluginProprBackend.ts (+21/−5)
    • packages/core/src/plugins/pluginProprClient.ts (+8/−0)
  • Low risk (3 files)
    • docs/docs/architecture/plugin-runtime.md (+18/−5)
    • packages/core/test/fixtures/plugins/fixture-handlers/handlers.ts (+6/−0)
    • packages/core/test/pluginHandlerLifecycle.test.ts (+126/−1)

Validation evidence

  • No repository validation commands ran.

Diff size: 7 files changed, +235/−34 lines


🤖 Implementation Details

  • Model: Claude Opus 5.5 (high)
  • Requested By: @propr-ultrafix
  • Time: 8m 53s
  • Tokens: 2,801,488 (2,771,562 in / 29,926 out)
  • Cost: $1.70
  • Subscription usage: Fable +2%

View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR

…th the fixes, all the plugin tests pass: 25 in the handler, `ctx.propr` and runner suites, and 30 more in the store, contributions, lint and `packages/plugins` testing suites. When I swapped the original source files back in, all three new tests failed. Core typecheck and zero-warning lint on `src/plugins` pass.

I fixed F10 and F11 and added regression tests for both. With the fixes, all the plugin tests pass: 25 in the handler, `ctx.propr` and runner suites, and 30 more in the store, contributions, lint and `packages/plugins` testing suites. When I swapped the original source files back in, all three new tests failed. Core typecheck and zero-warning lint on `src/plugins` pass.

**F10: credentials escaped in response bodies** (`pluginHttp.ts`)
- There is now one shared check, `revealsCredential`. It decodes the text's JSON escapes one round at a time, up to 32 rounds, and looks for any spelling of the credential after each round. Text that is still decoding after 32 rounds counts as holding the credential, so the check fails closed.
- **Bodies:** after the literal scrub, any body (HTML, plain text or JSON) that still decodes to the credential is replaced with `[REDACTED]`. That covers `\uXXXX` escapes inside an HTML script.
- **Nested JSON:** inside a JSON body, a string that would reveal the credential when parsed again is replaced with `[REDACTED]` on its own. The rest of the document stays readable.
- **Headers:** header filtering had the same weakness, so it now uses the same check. It used to stop after 8 decode rounds and pass the header through unchanged. An escape like `\u005c` turns into the backslash of the next escape, so about 2 KB was enough to hide a 40-character token 10 rounds deep.
- The new test covers the HTML script case and the nested JSON case. It also checks 10-round and 40-round escapes in both a body and a header, and that escapes with no credential in them come through unchanged.

**F11: uninstalled handlers keeping `ctx.propr` access**
- `buildPluginContext` now has one installation check, also used by authenticated `ctx.http`. It confirms the run's installation (same `installed_at`) still exists and the run hasn't ended.
- `scopedProprClient` runs this check before every call reaches the backend, after its permission and repository checks. That covers every operation and any backend.
- The default GitHub backend runs it again right before each GitHub request, after the App client is ready. It also runs it before adding a task job, after the queue starts up or before a custom `enqueueIssueTask`.
- A refused call returns `unavailable` ("The plugin is no longer installed") and is recorded as `handler_refused`. Audit rows don't depend on the installation, so recording still works after uninstall.
- The new tests suspend a handler, then uninstall (or uninstall and reinstall) before its next `createIssue`, `createTask` or `commentOnIssue`; nothing reaches the backend. A second test uninstalls while the App token and the queue are still loading; nothing is sent or queued.

One race remains and can't be closed: the check commits before the GitHub request goes out, so uninstall doesn't wait for a request that already passed it. `ctx.http` behaves the same way.

Docs: `docs/docs/architecture/plugin-runtime.md` now describes the new body and nested-string handling and the new installation rule for `ctx.propr`.

**Separate findings, not changed:**
- Filtering only covers JSON-style escapes. A plugin author who controls an allowed host can still return the credential in any encoding they make up (reversed, XOR'd, and so on).
- `ctx.propr` still uses the permissions and repository list from when the run started. A change to the grant or repository limit, or disabling the plugin, during a run isn't rechecked; only removal or reinstall is.
- An existing type error in the `fetchUrl` fixture handler (`handlers.ts:40`, TS2352) shows up when that file is typechecked. Normal typecheck doesn't cover it.

There are no visual changes, so I didn't create previews.

PR: #3573
Comment by: @propr-ultrafix (ID: 0)
Model: Claude Opus 5.5
@propr-dev

propr-dev Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @propr-ultrafix

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

This PR adds the handler SDK, scoped clients, HTTP transport, installation state, test helpers, and documentation. It needs changes before merge because external mutations can occur without a durable audit record.

✅ Destination checks — HTTP requests validate every resolved address and dial the vetted address while preserving the original TLS hostname.

✅ Installation fencing — State operations check installation identity inside their transactions, and lifecycle tests cover uninstall and reinstall.

✅ Permission separation — The GitHub backend checks issue versus pull-request targets before posting comments.

Review was static; no commands were run. The supplied current-head status reports 29 passed, five pending, and no failed checks.

Merge blockers

Every finding below was introduced by this PR and must be resolved before merging.

F12: 🔴 Record authorization before external mutations

  • Category: correctness

  • Required behavior: AC2 requires allowed operations to be attributed to the plugin in the audit trail. An audit failure must not leave an unattributed external mutation.

  • Evidence: packages/core/src/plugins/pluginProprClient.ts, scopedProprClient.call; packages/core/src/plugins/pluginContext.ts, recordCall.

    1. An installed, authorized handler calls createIssue, commentOnIssue, or createTask.
    2. call awaits run(checked), which creates the GitHub resource or queues the task.
    3. Only afterward does it await options.record(...). If that database insert fails, the call rejects despite the completed mutation.
    4. The external change remains without its required handler_call audit entry. Retrying the reported failure can also create another issue or comment.

    static trace: The mutation precedes the sole success-audit insertion. Installation checks protect authorization, but do not establish durable attribution before dispatch. GitHub body markers do not replace the required audit trail. The supplied tests cover successful recording, not this failure ordering.

  • Minimum fix: Persist an attributed attempt or authorization record before dispatching the backend operation, and refuse dispatch if that write fails. Record completion separately so a completion-recording failure does not erase evidence of the attempted mutation.

Suggestions

These are optional follow-ups and are not sent to /fix.

S6: 🟢 Match task scoping in the test kit

packages/plugins/testing/index.ts skips repository checking for getTask and never checks the returned task’s repository. Consequently, a kit limited to owner/allowed can return a task from other/repo, whereas production rejects it. Adding the production-style result check would make handler tests more representative. This is non-blocking because production already enforces this boundary.

S7: 🟢 Verify concurrent state limits

pluginStateStore.ts uses a shared installation lock on non-SQLite databases before counting and inserting state keys. Shared locks do not serialize competing writers, so concurrent inserts deserve a targeted quota test if those databases are supported. The supplied context does not establish that deployment requirement; this remains a conditional follow-up.

Score

The implementation has substantial coverage and clear boundaries, but the audit ordering leaves a required correctness gap. Pending CI checks should also finish before merge.

Score: 6/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 9s
  • Tokens: 104,527 (103,038 in / 1,489 out)
  • Cost: $0.98

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix F12 S6 S7

🤖 Review by ProPR

@propr-dev

propr-dev Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit 9adc1ac

Addressed 1 AI review comment (IDs: 6885076573)

Addressed finding F12

Summary of Changes

I fixed F12: ctx.propr now writes an attributed handler_attempt audit entry before it sends any change to GitHub or the task queue. If that write fails, nothing is sent.

How a mutating call works now (createIssue, createTask, commentOnIssue, commentOnPullRequest, addLabel, removeLabel), in packages/core/src/plugins/pluginProprClient.ts:

  1. The existing permission, repository-limit and installation checks run first.
  2. A handler_attempt entry is written, with plugin:<id> as the actor. If that write fails, the call is refused as unavailable and the backend is never called.
  3. The backend runs.
  4. The outcome is recorded as a separate entry: handler_call on success, handler_refused with the reason on failure. All entries for one call share a new callId, so the attempt and its outcome can be matched.

Once the attempt is recorded, a failure to record the outcome is logged on the server ([plugins] Could not record the outcome of an attempted handler call) and the call still returns its result. Rejecting it would only invite a retry that creates a second issue or comment, and the attempt entry already credits the mutation to the plugin.

Read operations are unchanged: they get one entry when they finish, and a failure to write it still fails the call. That is safe because nothing outside ProPR changes.

Checking related code: the GitHub backend still re-checks the installation right before it sends anything. A call refused at that point leaves an attempt entry followed by a refusal. The "uninstalled mid-run" lifecycle test still shows only refusals, because the installation check happens before the attempt is written. The other parts of ctx (ctx.http, ctx.state) make no audit-trail promise.

Other files changed:

  • handler_attempt is added to the list of audit actions in packages/shared/src/pluginInstallations.ts, the OpenAPI spec and the generated client types. gen:openapi:check passes.
  • pluginContext.ts maps each outcome to its audit action and does the server-side logging.
  • Both docs pages (plugins.md and plugin-runtime.md) describe the attempt entry and what happens when audit writes fail.

Tests (in packages/core/test/pluginProprClient.test.ts):

  • Recorded before sending: for each mutation run through a handler, the backend checks the real plugin_audit table at the moment it is called and finds the handler_attempt entry already there.
  • Attempt can't be written: a SQLite trigger rejects attempt entries. Every mutation is then refused as unavailable and nothing reaches the backend. The same is checked with a record function that throws.
  • Outcome can't be written: createIssue still returns the created issue and the attempt entry stays. A backend failure after the attempt leaves an attempt entry and a refusal entry with the same callId, and the logging hook is called.
  • I updated two existing tests to the new entries.

Results: all 56 plugin tests pass across the six plugin suites. With the original source files swapped back in, all three new tests and the two updated ones fail. Typecheck passes for shared, core, client and plugins, and zero-warning lint passes on src/plugins. I also ran taskJournalEvents, an existing suite this change doesn't touch. It didn't run properly because my command left out the --experimental-test-module-mocks flag it needs, so that is no evidence either way.

Separate issue, not changed: when an audit write fails and no attempt entry exists to fall back on (a refusal, or a read finishing), the handler gets the raw database error instead of unavailable. One of the new tests depends on this. The behaviour was already there before this PR's F12 work and wasn't selected, so I left it alone.

There are no visual changes, so I made no previews.

Review digest

Risk-tiered paths

  • High risk: none
  • Normal risk (4 files)
    • packages/client/src/generated/apiTypes.ts (+1/−1)
    • packages/core/src/plugins/pluginContext.ts (+9/−5)
    • packages/core/src/plugins/pluginProprClient.ts (+39/−4)
    • packages/shared/src/pluginInstallations.ts (+4/−3)
  • Low risk (4 files)
    • docs/docs/architecture/plugin-runtime.md (+12/−5)
    • docs/docs/features/plugins.md (+3/−0)
    • docs/static/openapi/propr-api.yaml (+1/−0)
    • packages/core/test/pluginProprClient.test.ts (+107/−12)

Validation evidence

  • No repository validation commands ran.

Diff size: 8 files changed, +176/−30 lines


🤖 Implementation Details

  • Model: Claude Opus 5.5 (high)
  • Requested By: @propr-ultrafix
  • Time: 7m 27s
  • Tokens: 2,482,068 (2,459,523 in / 22,545 out)
  • Cost: $1.38

View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR

…attempt` audit entry before it sends any change to GitHub or the task queue. If that write fails, nothing is sent.

I fixed F12: `ctx.propr` now writes an attributed `handler_attempt` audit entry before it sends any change to GitHub or the task queue. If that write fails, nothing is sent.

**How a mutating call works now** (`createIssue`, `createTask`, `commentOnIssue`, `commentOnPullRequest`, `addLabel`, `removeLabel`), in `packages/core/src/plugins/pluginProprClient.ts`:
1. The existing permission, repository-limit and installation checks run first.
2. A `handler_attempt` entry is written, with `plugin:<id>` as the actor. If that write fails, the call is refused as `unavailable` and the backend is never called.
3. The backend runs.
4. The outcome is recorded as a separate entry: `handler_call` on success, `handler_refused` with the reason on failure. All entries for one call share a new `callId`, so the attempt and its outcome can be matched.

Once the attempt is recorded, a failure to record the outcome is logged on the server (`[plugins] Could not record the outcome of an attempted handler call`) and the call still returns its result. Rejecting it would only invite a retry that creates a second issue or comment, and the attempt entry already credits the mutation to the plugin.

Read operations are unchanged: they get one entry when they finish, and a failure to write it still fails the call. That is safe because nothing outside ProPR changes.

**Checking related code:** the GitHub backend still re-checks the installation right before it sends anything. A call refused at that point leaves an attempt entry followed by a refusal. The "uninstalled mid-run" lifecycle test still shows only refusals, because the installation check happens before the attempt is written. The other parts of `ctx` (`ctx.http`, `ctx.state`) make no audit-trail promise.

**Other files changed:**
- `handler_attempt` is added to the list of audit actions in `packages/shared/src/pluginInstallations.ts`, the OpenAPI spec and the generated client types. `gen:openapi:check` passes.
- `pluginContext.ts` maps each outcome to its audit action and does the server-side logging.
- Both docs pages (`plugins.md` and `plugin-runtime.md`) describe the attempt entry and what happens when audit writes fail.

**Tests** (in `packages/core/test/pluginProprClient.test.ts`):
- **Recorded before sending:** for each mutation run through a handler, the backend checks the real `plugin_audit` table at the moment it is called and finds the `handler_attempt` entry already there.
- **Attempt can't be written:** a SQLite trigger rejects attempt entries. Every mutation is then refused as `unavailable` and nothing reaches the backend. The same is checked with a record function that throws.
- **Outcome can't be written:** `createIssue` still returns the created issue and the attempt entry stays. A backend failure after the attempt leaves an attempt entry and a refusal entry with the same `callId`, and the logging hook is called.
- I updated two existing tests to the new entries.

**Results:** all 56 plugin tests pass across the six plugin suites. With the original source files swapped back in, all three new tests and the two updated ones fail. Typecheck passes for `shared`, `core`, `client` and `plugins`, and zero-warning lint passes on `src/plugins`. I also ran `taskJournalEvents`, an existing suite this change doesn't touch. It didn't run properly because my command left out the `--experimental-test-module-mocks` flag it needs, so that is no evidence either way.

**Separate issue, not changed:** when an audit write fails and no attempt entry exists to fall back on (a refusal, or a read finishing), the handler gets the raw database error instead of `unavailable`. One of the new tests depends on this. The behaviour was already there before this PR's F12 work and wasn't selected, so I left it alone.

There are no visual changes, so I made no previews.

PR: #3573
Comment by: @propr-ultrafix (ID: 0)
Model: Claude Opus 5.5
@propr-dev

propr-dev Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @propr-ultrafix

Posted 1 review:

View Task Details

…est this PR doesn't touch.

I fixed the Shard 2/4 failure. It was a timing flake in a test this PR doesn't touch.

**What failed:** one test in `packages/api/test/mcpTunnelMode.test.ts`, "a 409 keeps forcing registration through transient failures until one succeeds". It expected 5 Connect registrations and saw 4. Every other test in the shard passed. Neither this test nor the code it exercises (`packages/api/mcp/connectRegistration.ts`) has changed on this branch.

**Cause:** after firing the manager's retry timer by hand, the test waited exactly 100 event-loop turns for the extra registration to arrive. That registration goes through async database and crypto work that runs on Node's background thread pool, so it doesn't finish in a fixed number of turns. On a slow or busy machine the count ran out first. I reproduced it locally: 1 failure in 15 runs of the original test.

**Fix:** only that wait changed, at `packages/api/test/mcpTunnelMode.test.ts:321`. It now checks every 5 ms until the fifth registration arrives, giving up after 10 seconds. The assertions are the same, so a registration that never happens still fails the test.

**Verification:**
- The changed test passed 25 of 25 isolated runs, against 14 of 15 before.
- The full `mcpTunnelMode.test.ts` file passes 17 of 17.
- Zero-warning ESLint on the file, using the `packages/api` config, is clean.

I didn't re-run the whole Shard 2/4 suite locally; the CI re-run on the next commit will confirm the shard.

The "Node.js 20 is deprecated" message in the failure evidence is a warning about `actions/checkout`. It didn't cause the failure.

No preview was created because nothing in this change is visible to a user.

PR: #3573
Comment by: @propr-dev[bot] (ID: 6893300396)
Model: Claude Opus 5.5
@propr-dev propr-dev Bot changed the title 🔨 [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule ⚠️⑥ [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule Nov 15, 2026
@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

CI failed: Finalize unsigned validation checksums

Please investigate and fix this CI failure.

  • Check: Finalize unsigned validation checksums
  • Result: failure
  • Commit: 4660202fa2dd (4660202fa2dd08bc66358999847b00ac9791a36f)
  • Details: View CI failure

Failure evidence

.github:42
Process completed with exit code 1.

.github:41
Unsigned packaging matrix finished with result 'skipped'.

@propr-dev propr-dev Bot changed the title ⚠️⑥ [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule 🔨 [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule Nov 15, 2026
@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit 35afbab

Summary of Changes

I fixed the CI failure. The finalize step itself was fine: it failed because an earlier job hung, which caused the whole unsigned packaging matrix to be skipped. Neither the hang nor the fix involves this PR's plugin code.

What happened in run 43707247942

  • The Prove stock Chromium existing-target axe boundary job has to pass before the packaging matrix runs. It hit its 10-minute job timeout in the step Require exact CSP observer regression across real Chromium navigation. That step normally takes about 7 seconds and passed on the previous commit.
  • The log stops after subtest 36. The next test is the only one that drives real Chromium, and when the job was cleaned up, only a single main chrome-headless-shell process was still running.
  • So the packaging matrix finished as skipped, and Finalize unsigned validation checksums failed because of it.

Cause: in apps/desktop/scripts/packaged-acceptance-csp-reload.test.mjs, the test cleans up with t.after(() => browser.close()). The 30-second test timeout doesn't cover cleanup, so a stuck browser.close() waits forever, and the open connection to Chromium stops the test process from exiting. I couldn't make the hang happen naturally: 15 local runs against the same Chromium as CI (151.0.7922.34) all passed in about a second. I did check the paths separately:

  • A hang inside the test body fails cleanly at 30 seconds.
  • A hung cleanup, simulated on the original code, kept the process alive until my outer timeout killed it.

Fix (that file only):

  • The test now starts Chromium as a separate browser server that it owns, and connects to it.
  • Cleanup gives close() 10 seconds; if that runs out, it kills the Chromium process outright (SIGKILL).
  • The two local fixture servers are closed with closeAllConnections(), so a held-open connection can't keep them alive either.

Verification:

  • The real-Chromium test passes in 5 of 5 runs.
  • The full file passes: 52 of 52 with the real-Chromium flag set as CI does it, and 51 passed with 1 skipped without it.
  • ESLint is clean.
  • With close() forced to hang, the new code exits after 10 seconds instead of hanging.

The other failing check, Prove stock Chromium existing-target axe boundary, was this same hang, so this change should clear both.

Review digest

Risk-tiered paths

  • High risk: none
  • Normal risk: none
  • Low risk (1 file)
    • apps/desktop/scripts/packaged-acceptance-csp-reload.test.mjs (+17/−5)

Validation evidence

  • No repository validation commands ran.

Diff size: 1 file changed, +17/−5 lines


🤖 Implementation Details

  • Model: Claude Opus 5.5 (medium)
  • Requested By: @propr-dev[bot]
  • Time: 11m 41s
  • Tokens: 1,590,160 (1,578,157 in / 12,003 out)
  • Cost: $0.85

Undo Changes • View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR
Processing comment ID: 6893504078✓

…it failed because an earlier job hung, which caused the whole unsigned packaging matrix to be skipped. Neither the hang nor the fix involves this PR's plugin code.

I fixed the CI failure. The finalize step itself was fine: it failed because an earlier job hung, which caused the whole unsigned packaging matrix to be skipped. Neither the hang nor the fix involves this PR's plugin code.

**What happened in run 43707247942**
- The `Prove stock Chromium existing-target axe boundary` job has to pass before the packaging matrix runs. It hit its 10-minute job timeout in the step `Require exact CSP observer regression across real Chromium navigation`. That step normally takes about 7 seconds and passed on the previous commit.
- The log stops after subtest 36. The next test is the only one that drives real Chromium, and when the job was cleaned up, only a single main `chrome-headless-shell` process was still running.
- So the packaging matrix finished as `skipped`, and `Finalize unsigned validation checksums` failed because of it.

**Cause:** in `apps/desktop/scripts/packaged-acceptance-csp-reload.test.mjs`, the test cleans up with `t.after(() => browser.close())`. The 30-second test timeout doesn't cover cleanup, so a stuck `browser.close()` waits forever, and the open connection to Chromium stops the test process from exiting. I couldn't make the hang happen naturally: 15 local runs against the same Chromium as CI (151.0.7922.34) all passed in about a second. I did check the paths separately:
- A hang inside the test body fails cleanly at 30 seconds.
- A hung cleanup, simulated on the original code, kept the process alive until my outer timeout killed it.

**Fix (that file only):**
- The test now starts Chromium as a separate browser server that it owns, and connects to it.
- Cleanup gives `close()` 10 seconds; if that runs out, it kills the Chromium process outright (SIGKILL).
- The two local fixture servers are closed with `closeAllConnections()`, so a held-open connection can't keep them alive either.

**Verification:**
- The real-Chromium test passes in 5 of 5 runs.
- The full file passes: 52 of 52 with the real-Chromium flag set as CI does it, and 51 passed with 1 skipped without it.
- ESLint is clean.
- With `close()` forced to hang, the new code exits after 10 seconds instead of hanging.

The other failing check, `Prove stock Chromium existing-target axe boundary`, was this same hang, so this change should clear both.

PR: #3573
Comment by: @propr-dev[bot] (ID: 6893504078)
Model: Claude Opus 5.5
@propr-dev propr-dev Bot changed the title 🔨 [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule ⚠️⑥ [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule Nov 15, 2026
@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

CI failed: Packaged Connect (linux-arm64)

Please investigate and fix this CI failure.

  • Check: Packaged Connect (linux-arm64)
  • Result: failure
  • Commit: 35afbab25c48 (35afbab25c48547ca9c2b7ade2ed1f4c4fa019ee)
  • Details: View CI failure

Failure evidence

.github:62
Process completed with exit code 1.

@propr-dev propr-dev Bot changed the title ⚠️⑥ [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule 🔨 [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule Nov 15, 2026
@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

ℹ️ Analyzed the follow-up request by @propr-dev[bot]

Analysis Summary

I looked into it. This failure isn't caused by the PR's code, and I didn't change any files.

What failed: the job died during npm ci, before any build or test ran. better-sqlite3 13.0.3 fell back to compiling from source on the arm64 runner. node-gyp downloaded the Node 22.23.4 headers (both requests returned HTTP 200), then stopped with:

gyp: /home/runner/.cache/node-gyp/22.23.4/common.gypi not found

So the headers download on that runner was incomplete or got corrupted, and the native compile had nothing to work with.

Why it isn't this PR:

  • The PR doesn't touch better-sqlite3. Its lockfile changes are only the packages/plugins additions, and better-sqlite3 is still pinned at 13.0.3 as on main.
  • Run 43707248022 used the same lockfile, the same npm cache key and the same Node 22.23.4 on the previous commit. Packaged Connect (linux-arm64) passed there 23 minutes earlier, and npm ci finished in 24s.
  • The last commit only changed apps/desktop/scripts/packaged-acceptance-csp-reload.test.mjs, which plays no part in installing dependencies.
  • In the failing run 43709101182, linux-arm64 is the only job marked failure. The other Packaged Connect jobs are marked cancelled, and so is the run overall, so it was most likely superseded or stopped.

What to do: re-run the job, or let the next push run it again. The headers download should succeed on a fresh runner. If the same common.gypi not found error keeps coming back on arm64, the right fix is in the workflow: retry npm ci, or clear ~/.cache/node-gyp first. That's outside what this PR changes.

No code changes were necessary based on the current state of the branch.


🤖 Analysis Details

  • Model: Claude Opus 5.5 (medium)
  • Time: 3m 45s
  • Tokens: 393,362 (390,990 in / 2,372 out)
  • Cost: $0.36

View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR
Processing comment ID: 6893655132✓

@propr-dev propr-dev Bot changed the title 🔨 [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule ⚠️⑥ [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule Nov 15, 2026
@integry

integry commented Nov 15, 2026

Copy link
Copy Markdown
Owner

/review

@propr-dev propr-dev Bot changed the title ⚠️⑥ [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule 🔨 [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule Nov 15, 2026
@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @integry

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

The PR implements the handler SDK, runtime, scoped clients, state storage, and documentation with substantial regression coverage. It needs a localized correction before merge: some thrown values escape the runner instead of producing the promised failed outcome.

✅ Mutation attribution — Mutations require a durable handler_attempt record before dispatch, and completion-record failures preserve the mutation’s result.

✅ Authority rechecks — The default backend rechecks installation status, permissions, and repository scope after initializing GitHub or the queue.

✅ Lifecycle coverage — Added tests exercise timeout cancellation, uninstall/reinstall boundaries, and credential redaction.

This review used the supplied code only; no commands were run. Current checks report 22 passed, 10 pending, 10 neutral/skipped, and no failures.

Merge blockers

Every finding below was introduced by this PR and must be resolved before merging.

F21: 🔴 Safely format non-Error throws

  • Category: correctness

  • Required behavior: AC1 and the documented runPluginHandler contract require throwing handlers to return a failed outcome without throwing.

  • Evidence: packages/core/src/plugins/pluginHandlerRunner.ts:message, runPluginHandler catch block.

    1. A handler throws a JSON-compatible value such as { message: 'Upstream failed', toString: 'upstream field' }. JavaScript permits throwing this value, and it can also arrive through an invocation payload.
    2. The runner catches it and calls message(error).
    3. Because it is not an Error, message evaluates String(error). The own, non-callable toString property prevents ordinary string conversion; valueOf() returns the object, so conversion throws a TypeError.
    4. This exception occurs inside the catch block, outside its protection. runPluginHandler rejects instead of returning a failed outcome, and finish never aborts the context, allowing work the handler already started to remain active.

    The existing throwing fixture uses an ordinary Error, so it does not cover this path. static trace: verified against the supplied conversion, catch, and finalization code. proposed regression: throw the object above and assert that the runner resolves with outcome: 'failed' and aborts the captured context signal.

  • Minimum fix: Make error-message extraction exception-safe, falling back to a constant message when conversion fails, and add the regression test.

Suggestions

These are optional follow-ups and are not sent to /fix.

S18: 🟢 Clarify test-kit parity

The documentation says the fake applies the same host and state checks as production, but packages/plugins/testing/index.ts accepts nondefault HTTPS ports and lacks production’s plain-JSON validation. Aligning these checks or narrowing that claim would help plugin authors interpret passing tests. This is optional because production retains its independent enforcement.

S19: 🟢 Extend import-rule coverage

The dynamic-import selectors in eslint.config.js omit propr-ui, although static imports prohibit it. The relative-path patterns also assume files are at least one folder below the package root. Add fixtures for these cases if the rule is intended to cover every file under packages/plugins/**. This is optional enforcement hardening for trusted plugin code; the explicitly requested core-import fixture is covered.

Score

The implementation is thorough, but the runner’s failure-handling contract has a concrete escape path that also bypasses context shutdown. The correction is localized; pending checks remain outstanding.

Score: 6/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 25s
  • Tokens: 120,641 (118,856 in / 1,785 out)
  • Cost: $1.17

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix F21 S18 S19

🤖 Review by ProPR

@propr-dev propr-dev Bot changed the title 🔨 [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule ⚠️⑥ [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule Nov 15, 2026
@integry

integry commented Nov 15, 2026

Copy link
Copy Markdown
Owner

/fix

@propr-dev propr-dev Bot changed the title ⚠️⑥ [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule 🔨 [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule Nov 15, 2026
@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit c259678

Addressed 1 AI review comment (IDs: 6893776445)

Addressed finding F21

Summary of Changes

I fixed F21: runPluginHandler now returns a failed outcome when a handler throws a value that can't be turned into a string, instead of rejecting.

Fix (packages/core/src/plugins/pluginHandlerRunner.ts): getting the error message is now exception-safe. If converting the thrown value throws, or doesn't produce a non-empty string, the error is the fixed text 'The handler failed'. While checking the rest of that catch block, I found error instanceof HandlerTimeout has the same problem: a proxy whose getPrototypeOf trap throws makes instanceof throw too. So that check is now inside a try as well. With both guarded, nothing in the catch block can throw any more. That means finish always runs and always aborts the run's context.

Regression test (packages/core/test/pluginHandlerRunner.test.ts): a new test throws five values from a handler:

  • the finding's { message: 'Upstream failed', toString: 'upstream field' }
  • Object.create(null)
  • an Error whose message isn't a string
  • an Error whose message getter throws
  • a proxy whose getPrototypeOf trap throws

For each one it checks that the runner resolves with outcome: 'failed', timedOut: false and the fixed message, and that the handler's ctx.signal is aborted. It also checks that a thrown string still comes back as its own message.

Verification:

  • pluginHandlerRunner.test.ts passes (10/10).
  • With the original runner source put back, the new test fails with the TypeError from the finding.
  • ESLint (zero warnings allowed) passes on the changed source file, and tsc --noEmit passes for packages/core. ESLint ignores the test file, so it wasn't linted.

Separate, not changed: a few other spots call String() on a value the handler passes in:

  • pluginContext.ts:171: the ctx.log message
  • pluginContext.ts:197: the connection name in an error
  • pluginHttp.ts:407: init.method in an error
  • packages/plugins/testing/index.ts:147: the test harness's log capture

These aren't the same defect. They throw a TypeError back to the handler during its own call, and the runner, now fixed, still turns that into a failed outcome. At most, the handler gets a TypeError where it would have got a PluginSdkError. pluginContext.ts:229 converts a database error from recording a call, not something the handler throws.

Review digest

Risk-tiered paths

  • High risk: none
  • Normal risk (1 file)
    • packages/core/src/plugins/pluginHandlerRunner.ts (+21/−3)
  • Low risk (1 file)
    • packages/core/test/pluginHandlerRunner.test.ts (+29/−0)

Validation evidence

  • No repository validation commands ran.

Diff size: 2 files changed, +50/−3 lines


🤖 Implementation Details

  • Model: Claude Opus 5.5 (medium)
  • Requested By: @integry
  • Time: 4m 14s
  • Tokens: 647,793 (642,006 in / 5,787 out)
  • Cost: $0.49

Undo Changes • View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR
Processing comment ID: 6893865246✓

…e when a handler throws a value that can't be turned into a string, instead of rejecting.

I fixed F21: `runPluginHandler` now returns a failed outcome when a handler throws a value that can't be turned into a string, instead of rejecting.

**Fix** (`packages/core/src/plugins/pluginHandlerRunner.ts`): getting the error message is now exception-safe. If converting the thrown value throws, or doesn't produce a non-empty string, the error is the fixed text `'The handler failed'`. While checking the rest of that catch block, I found `error instanceof HandlerTimeout` has the same problem: a proxy whose `getPrototypeOf` trap throws makes `instanceof` throw too. So that check is now inside a `try` as well. With both guarded, nothing in the catch block can throw any more. That means `finish` always runs and always aborts the run's context.

**Regression test** (`packages/core/test/pluginHandlerRunner.test.ts`): a new test throws five values from a handler:
- the finding's `{ message: 'Upstream failed', toString: 'upstream field' }`
- `Object.create(null)`
- an `Error` whose `message` isn't a string
- an `Error` whose `message` getter throws
- a proxy whose `getPrototypeOf` trap throws

For each one it checks that the runner resolves with `outcome: 'failed'`, `timedOut: false` and the fixed message, and that the handler's `ctx.signal` is aborted. It also checks that a thrown string still comes back as its own message.

**Verification:**
- `pluginHandlerRunner.test.ts` passes (10/10).
- With the original runner source put back, the new test fails with the `TypeError` from the finding.
- ESLint (zero warnings allowed) passes on the changed source file, and `tsc --noEmit` passes for `packages/core`. ESLint ignores the test file, so it wasn't linted.

**Separate, not changed:** a few other spots call `String()` on a value the handler passes in:
- `pluginContext.ts:171`: the `ctx.log` message
- `pluginContext.ts:197`: the connection name in an error
- `pluginHttp.ts:407`: `init.method` in an error
- `packages/plugins/testing/index.ts:147`: the test harness's log capture

These aren't the same defect. They throw a `TypeError` back to the handler during its own call, and the runner, now fixed, still turns that into a failed outcome. At most, the handler gets a `TypeError` where it would have got a `PluginSdkError`. `pluginContext.ts:229` converts a database error from recording a call, not something the handler throws.

PR: #3573
Comment by: @integry (ID: 6893865246)
Model: Claude Opus 5.5
@propr-dev propr-dev Bot changed the title 🔨 [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule ⚠️⑥ [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule Nov 15, 2026
@integry

integry commented Nov 15, 2026

Copy link
Copy Markdown
Owner

/review astra fable

@propr-dev propr-dev Bot changed the title ⚠️⑥ [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule 🔨 [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule Nov 15, 2026
@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @integry

Posted 2 reviews:

View Task Details

@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — astra

Overall Evaluation

The PR adds the handler SDK, runner, scoped ProPR operations, outbound HTTP, persistent state, and documentation. It is ready to merge within the supplied scope; this review found no verified merge blocker.

✅ Audited mutations — Mutations require a durable attempt record before dispatch, while completion-record failures do not incorrectly turn successful mutations into retryable failures.

✅ Lifecycle authorization — The default backend rechecks installation identity, enabled state, permissions, and repository limits after client or queue initialization.

✅ Focused regression coverage — Tests exercise timeout cancellation, uninstall/reinstall isolation, permission boundaries, and credential redaction across multiple encodings.

Review was static, using the supplied diff and context; no commands were run. Current checks report 35 passed, 12 neutral/skipped, and no failures or pending checks.

Merge blockers

No merge blockers.

Suggestions

These are optional follow-ups and are not sent to /fix.

S20: 🟢 Clarify test-kit validation parity

packages/plugins/testing/index.ts applies state size limits but does not reproduce the production serializer’s plain-JSON validation: for example, its state store accepts NaN, whereas pluginStateStore.ts rejects it. The documentation’s claim that tests fail as production would is therefore broader than the implementation. Optionally narrow that claim or add matching validation. This does not block the runtime’s acceptance criteria, which are covered through the real store.

S21: 🟢 Document cooperative timeout limits

pluginHandlerRunner.ts implements timeout enforcement with an event-loop timer. For trusted handlers this is reasonable, but synchronous blocking code prevents that timer from firing. Clarifying this limitation alongside the documented 30-second limit would help authors understand the execution model; process isolation is an optional future improvement.

Score

The implementation addresses the stated acceptance criteria with substantial regression coverage and passing current checks. Static review found no actionable defect; the remaining suggestions concern documentation and test-helper fidelity.

Score: 8/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 10s
  • Tokens: 121,266 (119,957 in / 1,309 out)
  • Cost: $1.14

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix S20 S21

🤖 Review by ProPR

@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — fable

⚠️ Review output was invalid and could not be displayed safely.


🤖 Review Details

  • Model: Claude Fable 5.2
  • Time: 3m 18s
  • Tokens: 199,436 (181,318 in / 18,118 out)
  • Cost: $2.54

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

🤖 Review by ProPR

@propr-dev propr-dev Bot changed the title 🔨 [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule ✅⑧ [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule Nov 15, 2026
@integry
integry merged commit 0f5ff6b into 3542-epic-plugin-system-xu8 Nov 15, 2026
47 checks passed
@integry
integry deleted the 3548/claude-opus-5-5-plugin-handlers-sdk-types-20261114-1908-nxn branch November 15, 2026 14:34
@propr-dev propr-dev Bot changed the title ✅⑧ [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule [3548 by Claude Opus 5.5] Plugin handlers: SDK types, scoped ProPR client, outbound HTTP, plugin state and the core-import lint rule Nov 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant