feat: surface external plugin review signals - #2623
Conversation
Add repository and homepage heuristics to external plugin intake and use eyes reactions for approval decisions.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>\nCopilot-Session: b745e915-6c5a-4354-ab77-5b52f9e66fea
There was a problem hiding this comment.
Pull request overview
Adds non-blocking repository and homepage signals to external plugin intake for maintainer review.
Changes:
- Collects repository age and activity metrics.
- Inspects homepage content for commercial signals.
- Standardizes approval and rejection reactions.
Show a summary per file
| File | Description |
|---|---|
eng/external-plugin-intake.mjs |
Implements and displays reviewer signals. |
eng/external-plugin-intake.test.mjs |
Tests repository and homepage signals. |
.github/workflows/external-plugin-command-router.yml |
Uses eyes reactions for decisions. |
Review details
Tip
Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 3/3 changed files
- Comments generated: 4
- Review effort level: Balanced
Validate homepage destinations and bound response reads, and correct repository activity metrics.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>\nCopilot-Session: b745e915-6c5a-4354-ab77-5b52f9e66fea
There was a problem hiding this comment.
Review details
Suppressed comments (1)
eng/external-plugin-intake.mjs:372
- When chunk boundaries make
totalBytesreach exactly 512 KB, the loop exits without callingreader.cancel(). Releasing the lock does not cancel the response, and the timeout is then cleared, so an untrusted server can leave the remaining body/socket active indefinitely. Cancel whenever the cap is reached, not only when one chunk crosses it.
while (totalBytes < HOMEPAGE_MAX_BYTES) {
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b745e915-6c5a-4354-ab77-5b52f9e66fea
There was a problem hiding this comment.
Review details
Suppressed comments (4)
eng/external-plugin-intake.mjs:417
- If chunks total exactly 512,000 bytes while the server keeps the stream open, the loop exits via its condition without calling
cancel(). The lock is then released and the inspection timeout is cleared, leaving the untrusted response/socket active indefinitely. Cancel whenever the byte ceiling is reached, not only when one chunk is truncated.
while (totalBytes < HOMEPAGE_MAX_BYTES) {
eng/external-plugin-intake.mjs:475
- Redirect response bodies are neither consumed nor canceled before the next request. A submitted server can return several redirects with never-ending bodies; after the final response completes, the timeout is cleared while those earlier sockets remain active and can keep the workflow alive. Cancel each redirect body before following its
Location.
if (response.status < 300 || response.status >= 400) break;
const location = response.headers?.get?.("location");
if (!location) break;
if (redirectCount === HOMEPAGE_MAX_REDIRECTS) {
return { ...result, status: "warning", output: "Homepage exceeded the redirect limit." };
eng/external-plugin-intake.mjs:397
- This does not implement the dispatcher handler's flow-control contract.
onHeaders/onDatamay returnfalseto pause delivery and later invoke the supplied resume callback, but the no-op callback and unconditionaldatahandling keep the socket flowing. A large untrusted response can consequently be queued byfetchbefore the 512 KB reader cancels it, defeating the intended memory bound.
This issue also appears in the following locations of the same file:
- line 417
- line 471
if (handler.onHeaders(response.statusCode, headers, () => {}, response.statusMessage) === false) {
request.destroy();
return;
}
response.on("data", (chunk) => handler.onData(chunk));
eng/external-plugin-intake.mjs:464
- The advertised 10-second inspection bound does not cover DNS resolution:
lookup()has no connection to theAbortController, so a slow or unresponsive authoritative DNS path can keep this await pending after the timer fires. Apply an enforceable timeout to address resolution as well; otherwise issue opens/edits can occupy workflow runs longer than the configured limit.
const address = await assertPublicUrl(url);
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (4)
eng/external-plugin-intake.mjs:433
- If received chunks total exactly 512,000 bytes while more data remains, the loop condition becomes false without taking the truncation branch, so the reader is released but never canceled. That leaves the response/socket alive after the timeout is cleared. Cancel whenever the byte ceiling is reached, including this exact-boundary case.
return chunks.join("") + decoder.decode();
eng/external-plugin-intake.mjs:479
- Redirect responses are followed without consuming or canceling their bodies. A submitted server can return a redirect with a never-ending body, leaving each prior socket active even after the final inspection clears its timeout. Cancel every redirect response before following or returning from it.
if (response.status < 300 || response.status >= 400) break;
const location = response.headers?.get?.("location");
if (!location) break;
if (redirectCount === HOMEPAGE_MAX_REDIRECTS) {
return { ...result, status: "warning", output: "Homepage exceeded the redirect limit." };
eng/external-plugin-intake.mjs:487
- A non-success final response is also returned without canceling its body. Since the timeout is cleared in
finally, a 4xx/5xx endpoint with an unfinished body can leave the Actions process holding an active request indefinitely. Cancel the body before returning the warning.
if (!response.ok) {
return { ...result, status: "warning", output: `Homepage returned HTTP ${response.status}.` };
eng/external-plugin-intake.mjs:401
- The custom dispatcher does not honor response backpressure: the empty resume callback cannot restart a paused consumer, and
handler.onData's return value is discarded while the Node response remains in flowing mode. An untrusted homepage can therefore be buffered beyond the advertised 512 KB limit. Pause the source when the handler returnsfalseand supply a working resume callback.
This issue also appears in the following locations of the same file:
- line 433
- line 475
- line 486
if (handler.onHeaders(response.statusCode, headers, () => {}, response.statusMessage) === false) {
request.destroy();
return;
}
response.on("data", (chunk) => handler.onData(chunk));
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Pull Request Checklist
npm startand verified thatREADME.mdis up to date.mainbranch for this pull request.Description
External plugin intake currently validates repository structure and metadata, but gives maintainers little visibility into signals such as a newly created repository with no community activity or a homepage that is primarily marketing a paid service. This change surfaces those signals without attempting to decide whether a plugin is genuinely awesome.
The intake now records repository age, stars, watchers, forks, and open issues, and performs a bounded, timed homepage inspection for pricing, sales, trial, and checkout language. These results appear in a clearly labeled, non-blocking Reviewer signals section. Approval and rejection commands now both react with eyes to acknowledge the maintainer decision consistently.
Type of Contribution
Additional Notes
The website check is intentionally deterministic and non-blocking rather than agentic: this keeps it reproducible, auditable, bounded, and less exposed to prompt injection from submitted webpages. Focused intake and quality-gate tests pass.
By submitting this pull request, I confirm that my contribution abides by the Code of Conduct and will be licensed under the MIT License.