chore(release): bump to 2.7.0, audit clean, and fix sdk-watch suppression (#2379)
* fix: teach sdk-watch the `app/` author spelling so suppression works again `normalizeLogin` lowercased a login and stripped a trailing `[bot]`, but the installed `gh` reports this account as `app/github-actions` from `gh issue list --json author`. The `app/` prefix survived, so `isSweepAuthored()` returned false for every issue the sweep itself wrote, the marker was never trusted, and the re-file check found nothing to suppress against. The sweep filed the same ext-apps 2.0.0 issue three nights running (#2325, #2332, #2348) before anyone noticed by hand. The same predicate gates analysis-retry suppression and supersession notes, so both were affected too — an analyzed issue could be re-queued for a fresh Opus run every night. Strip the prefix alongside the suffix. That widens nothing an outsider can claim: `/` is not legal in a GitHub username, so no human account can normalize onto `github-actions`, and the labels half of the check (`chore` + `dependencies`, both write-access-only) is independent regardless. Because the failure mode is silence, also guard the class rather than only its instance: `sweepIssues` now reports any issue carrying this sweep's marker AND its write-access-only labels whose author does not normalize onto `AUTOMATION_LOGIN`. That combination cannot be produced by an outsider, and it is exactly the signature a fourth spelling would present. It warns rather than throws, so a cosmetic upstream rename does not take the nightly sweep down. Tests cover all three known spellings plus a mixed-case form, five near-miss logins that must stay rejected, and both halves of the split predicate. Closes #2377 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com> * chore(release): bump version to 2.7.0 Step 1 of the v2.7.0 release. The bump goes on `v2/main` before the milestone merge (#2010), so the release lands on `main` with the version already correct and `v2/main` is never left behind `main`. `npm version minor --no-git-tag-version` — no tag here deliberately. The release tag has to point at the merge commit on `main`, so tagging on `v2/main` would create a tag on a commit that is never released. `npm audit --audit-level=high` was run first across all five installs (root, web, cli, tui, launcher) and reported zero vulnerabilities at every severity — info through critical — so the bump sits on a tree that was checked and no audit fixes were forced. Closes #2378 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com> * test: cover the drift guard through main(), not only as a unit (#2379 review) `warnOnUnrecognizedAuthors` was unit-tested, but its call from `sweepIssues` was not: deleting that line left all 65 tests green. So the guard against a silent normalization drift could itself have become disconnected silently — the exact failure mode it exists to prevent. Two main-level cases, driven through the injected spawn: - an issue carrying our marker and our write-access-only labels whose author is `apps/github-actions` (a spelling `normalizeLogin` has never seen) must produce the warning, and the refile it causes is asserted alongside it so the test shows the consequence rather than only the message; - the three readable spellings must produce no warning AND must suppress the refile, because a guard that fires on the ordinary path is noise. Deleting the `sweepIssues` call now fails 1 test rather than 0, and restoring the original `app/` bug fails 5 rather than 4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com> --------- Signed-off-by: cliffhall <cliff@futurescale.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6b97708226
commit
20e679bb1f
4 changed files with 287 additions and 14 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector",
|
||||
"version": "2.6.0",
|
||||
"version": "2.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@modelcontextprotocol/inspector",
|
||||
"version": "2.6.0",
|
||||
"version": "2.7.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector",
|
||||
"version": "2.6.0",
|
||||
"version": "2.7.0",
|
||||
"description": "The Model Context Protocol Inspector",
|
||||
"keywords": [
|
||||
"MCP",
|
||||
|
|
|
|||
|
|
@ -137,10 +137,27 @@ export const ANALYSIS_MARKER = "<!-- sdk-watch:analysis -->";
|
|||
* * forge a supersession marker so the genuine note is never posted.
|
||||
*
|
||||
* So a marker counts only when the thing carrying it was written by this
|
||||
* automation. `gh issue list --json author` reports a bot with the `[bot]`
|
||||
* suffix stripped and `is_bot: true`, while the REST comments endpoint reports
|
||||
* `github-actions[bot]` with `type: "Bot"` — hence the normalization in both
|
||||
* predicates rather than one spelling assumed.
|
||||
* automation.
|
||||
*
|
||||
* ⚠️ **The same account has THREE spellings, and normalizing only some of them
|
||||
* disables suppression silently.** That is not hypothetical — it shipped, and
|
||||
* cost three identical issues on three consecutive nights (#2377):
|
||||
*
|
||||
* | Source | Spelling |
|
||||
* | --- | --- |
|
||||
* | REST comments endpoint | `github-actions[bot]`, `type: "Bot"` |
|
||||
* | older `gh issue list --json author` | `github-actions`, `is_bot: true` |
|
||||
* | newer `gh issue list --json author` | **`app/github-actions`** |
|
||||
*
|
||||
* `normalizeLogin` therefore strips an `app/` **prefix** as well as a `[bot]`
|
||||
* suffix. Stripping the prefix widens nothing an outsider can claim: `/` is not
|
||||
* a legal character in a GitHub username, so no human account can normalize
|
||||
* onto `github-actions` — and the labels half of `isSweepAuthored` is an
|
||||
* independent check regardless.
|
||||
*
|
||||
* A fourth spelling would break it again, and the failure mode is silence. So
|
||||
* `sweepIssues` also reports any issue that carries this sweep's marker and its
|
||||
* labels but fails the author check — see `warnOnUnrecognizedAuthors`.
|
||||
*/
|
||||
export const AUTOMATION_LOGIN = "github-actions";
|
||||
|
||||
|
|
@ -150,6 +167,7 @@ export const SWEEP_LABELS = ["chore", "dependencies"];
|
|||
const normalizeLogin = (login) =>
|
||||
String(login ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/^app\//, "")
|
||||
.replace(/\[bot\]$/, "");
|
||||
|
||||
/**
|
||||
|
|
@ -165,14 +183,90 @@ const normalizeLogin = (login) =>
|
|||
* @returns {boolean}
|
||||
*/
|
||||
export function isSweepAuthored(issue) {
|
||||
return hasSweepAuthor(issue) && hasSweepLabels(issue);
|
||||
}
|
||||
|
||||
/**
|
||||
* The author half of `isSweepAuthored`, on its own.
|
||||
*
|
||||
* Split out so `warnOnUnrecognizedAuthors` can distinguish the two ways an
|
||||
* issue fails the check. "Wrong labels" is the ordinary case — somebody else's
|
||||
* issue that mentions the sweep. "Right labels, unrecognized author" is the
|
||||
* signature of a login spelling this script does not know about, which is a
|
||||
* defect in `normalizeLogin` rather than anything about the issue.
|
||||
*
|
||||
* @param {{author?: {login?: string, is_bot?: boolean}}} issue
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasSweepAuthor(issue) {
|
||||
if (normalizeLogin(issue?.author?.login) !== AUTOMATION_LOGIN) return false;
|
||||
// `is_bot` is absent on some `gh` versions; only an explicit `false` — a human
|
||||
// account that happens to carry the name — is disqualifying.
|
||||
if (issue?.author?.is_bot === false) return false;
|
||||
return issue?.author?.is_bot !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The labels half of `isSweepAuthored`, on its own.
|
||||
*
|
||||
* This is the half an outsider cannot forge: `chore` and `dependencies` both
|
||||
* need write access on the repo.
|
||||
*
|
||||
* @param {{labels?: Array<{name?: string}>}} issue
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasSweepLabels(issue) {
|
||||
const names = new Set((issue?.labels ?? []).map((l) => l?.name));
|
||||
return SWEEP_LABELS.every((label) => names.has(label));
|
||||
}
|
||||
|
||||
/**
|
||||
* Report issues that look like this sweep's own but whose author it does not
|
||||
* recognize.
|
||||
*
|
||||
* ⚠️ **This is the guard against the whole class of #2377, not just its
|
||||
* instance.** Suppression, analysis-retry suppression and supersession notes
|
||||
* all gate on `isSweepAuthored`, and when a login spelling stops normalizing
|
||||
* they do not fail — they quietly decide the sweep has never filed anything,
|
||||
* and the sweep refiles the same issue every night forever. Nothing goes red,
|
||||
* so the only way anyone finds out is by noticing the duplicates by hand, which
|
||||
* took three nights last time.
|
||||
*
|
||||
* An issue carrying a valid marker **and** both write-access-only labels, whose
|
||||
* author does not normalize onto `AUTOMATION_LOGIN`, is that signature. It
|
||||
* cannot be produced by an outsider, because it needs the labels.
|
||||
*
|
||||
* Reporting rather than throwing is deliberate: a hard failure here would take
|
||||
* the nightly sweep down over a cosmetic upstream rename, and the sweep's job —
|
||||
* noticing an SDK release — is still worth doing while the spelling is fixed.
|
||||
*
|
||||
* @param {Array<{author?: {login?: string}, body?: string, labels?: Array<{name?: string}>}>} issues
|
||||
* @param {(msg: string) => void} [warn]
|
||||
* @returns {string[]} the unrecognized logins, deduplicated
|
||||
*/
|
||||
export function warnOnUnrecognizedAuthors(issues, warn = console.warn) {
|
||||
const unrecognized = [
|
||||
...new Set(
|
||||
(issues ?? [])
|
||||
.filter(
|
||||
(issue) =>
|
||||
!hasSweepAuthor(issue) &&
|
||||
hasSweepLabels(issue) &&
|
||||
parseMarker(issue?.body) !== null,
|
||||
)
|
||||
.map((issue) => String(issue?.author?.login ?? "")),
|
||||
),
|
||||
];
|
||||
if (unrecognized.length > 0) {
|
||||
warn(
|
||||
`sdk-watch: ⚠️ ${unrecognized.length} author spelling(s) carry this sweep's marker AND its ` +
|
||||
`labels but do not normalize onto "${AUTOMATION_LOGIN}": ${unrecognized.map((l) => JSON.stringify(l)).join(", ")}. ` +
|
||||
"Suppression, analysis retries and supersession notes are all disabled for those issues — " +
|
||||
"teach `normalizeLogin` the spelling (see #2377).",
|
||||
);
|
||||
}
|
||||
return unrecognized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Was this comment written by the automation?
|
||||
*
|
||||
|
|
@ -562,7 +656,10 @@ function sweepIssues(repo, spawn) {
|
|||
if (result.status !== 0) {
|
||||
throw new Error(`gh issue list failed: ${(result.stderr ?? "").trim()}`);
|
||||
}
|
||||
return JSON.parse(result.stdout || "[]")
|
||||
const issues = JSON.parse(result.stdout || "[]");
|
||||
// Before filtering them away: say so if any of them are ours but unreadable.
|
||||
warnOnUnrecognizedAuthors(issues);
|
||||
return issues
|
||||
.filter(isSweepAuthored)
|
||||
.map((issue) => ({ ...issue, marker: parseMarker(issue.body) }))
|
||||
.filter((issue) => issue.marker && semver.valid(issue.marker.target));
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ import {
|
|||
hasAnalysis,
|
||||
installedVersion,
|
||||
isSweepAuthored,
|
||||
hasSweepAuthor,
|
||||
hasSweepLabels,
|
||||
warnOnUnrecognizedAuthors,
|
||||
main,
|
||||
needsManifestEdit,
|
||||
parseMarker,
|
||||
|
|
@ -147,11 +150,36 @@ test("isSweepAuthored requires both the automation author and the sweep's labels
|
|||
labels: SWEEP_LABELS.map((name) => ({ name })),
|
||||
};
|
||||
assert.equal(isSweepAuthored(owned), true);
|
||||
// gh reports a bot with the suffix stripped; the REST API keeps it. Both spellings.
|
||||
assert.equal(
|
||||
isSweepAuthored({ ...owned, author: { login: "github-actions[bot]" } }),
|
||||
true,
|
||||
);
|
||||
// The SAME account has three spellings; all three must be accepted, or
|
||||
// suppression silently stops working (#2377). See `AUTOMATION_LOGIN`.
|
||||
for (const login of [
|
||||
"github-actions", // older `gh issue list --json author`
|
||||
"github-actions[bot]", // REST comments endpoint
|
||||
"app/github-actions", // newer `gh issue list --json author` — the #2377 miss
|
||||
"App/GitHub-Actions[bot]", // nothing promises a casing or one form at a time
|
||||
]) {
|
||||
assert.equal(
|
||||
isSweepAuthored({ ...owned, author: { login } }),
|
||||
true,
|
||||
`login spelling ${JSON.stringify(login)} must be recognized as the sweep's own`,
|
||||
);
|
||||
}
|
||||
// The prefix strip must not become a way in for an account that is NOT ours.
|
||||
// `/` is not legal in a GitHub username, so these cannot exist — but the
|
||||
// predicate should not be the reason we believe that.
|
||||
for (const login of [
|
||||
"app/someone",
|
||||
"notapp/github-actions",
|
||||
"app/github-actions-nightly",
|
||||
"github-actions-nightly",
|
||||
"app//github-actions",
|
||||
]) {
|
||||
assert.equal(
|
||||
isSweepAuthored({ ...owned, author: { login } }),
|
||||
false,
|
||||
`login spelling ${JSON.stringify(login)} must NOT be accepted`,
|
||||
);
|
||||
}
|
||||
// An outsider's issue carrying a forged marker: right shape, wrong provenance.
|
||||
assert.equal(
|
||||
isSweepAuthored({ ...owned, author: { login: "someone", is_bot: false } }),
|
||||
|
|
@ -174,6 +202,75 @@ test("isSweepAuthored requires both the automation author and the sweep's labels
|
|||
assert.equal(isSweepAuthored({}), false);
|
||||
});
|
||||
|
||||
test("hasSweepAuthor and hasSweepLabels are the two independent halves", () => {
|
||||
const owned = {
|
||||
author: { login: "app/github-actions", is_bot: true },
|
||||
labels: SWEEP_LABELS.map((name) => ({ name })),
|
||||
};
|
||||
assert.equal(hasSweepAuthor(owned), true);
|
||||
assert.equal(hasSweepLabels(owned), true);
|
||||
// Each half ignores the other's input entirely.
|
||||
assert.equal(hasSweepAuthor({ ...owned, labels: [] }), true);
|
||||
assert.equal(
|
||||
hasSweepLabels({ ...owned, author: { login: "someone" } }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasSweepLabels({ ...owned, labels: [{ name: "chore" }] }),
|
||||
false,
|
||||
);
|
||||
assert.equal(hasSweepAuthor({}), false);
|
||||
assert.equal(hasSweepLabels({}), false);
|
||||
});
|
||||
|
||||
test("warnOnUnrecognizedAuthors reports an author spelling that stops normalizing", () => {
|
||||
// The #2377 signature: our own marker, our own write-access-only labels, and
|
||||
// an author this script cannot read. Silent before; loud now.
|
||||
const marked = `<!-- sdk-watch: group=ext-apps; target=2.0.0 -->\nbody`;
|
||||
const labels = SWEEP_LABELS.map((name) => ({ name }));
|
||||
const warnings = [];
|
||||
const found = warnOnUnrecognizedAuthors(
|
||||
[
|
||||
{ author: { login: "apps/github-actions" }, body: marked, labels },
|
||||
// Same unreadable spelling twice — reported once.
|
||||
{ author: { login: "apps/github-actions" }, body: marked, labels },
|
||||
// Readable: the whole point is that these are NOT reported.
|
||||
{ author: { login: "app/github-actions" }, body: marked, labels },
|
||||
{ author: { login: "github-actions[bot]" }, body: marked, labels },
|
||||
// An outsider's issue quoting the marker has no labels to go with it, so
|
||||
// it is an ordinary miss rather than a spelling defect — and reporting it
|
||||
// would hand any commenter a way to spam the sweep's log.
|
||||
{ author: { login: "someone" }, body: marked, labels: [] },
|
||||
// Right author-shape and labels, but no marker of ours: not our issue.
|
||||
{ author: { login: "apps/github-actions" }, body: "no marker", labels },
|
||||
],
|
||||
(m) => warnings.push(m),
|
||||
);
|
||||
assert.deepEqual(found, ["apps/github-actions"]);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /apps\/github-actions/);
|
||||
assert.match(warnings[0], /#2377/);
|
||||
});
|
||||
|
||||
test("warnOnUnrecognizedAuthors stays silent when every author is readable", () => {
|
||||
const marked = `<!-- sdk-watch: group=ext-apps; target=2.0.0 -->\nbody`;
|
||||
const labels = SWEEP_LABELS.map((name) => ({ name }));
|
||||
const warnings = [];
|
||||
assert.deepEqual(
|
||||
warnOnUnrecognizedAuthors(
|
||||
[{ author: { login: "app/github-actions" }, body: marked, labels }],
|
||||
(m) => warnings.push(m),
|
||||
),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(warnings, []);
|
||||
assert.deepEqual(
|
||||
warnOnUnrecognizedAuthors([], (m) => warnings.push(m)),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
/** The parsed workflow, read from disk so the tests assert the shipped file. */
|
||||
function workflowDoc() {
|
||||
return YAML.parse(
|
||||
|
|
@ -1059,6 +1156,85 @@ test("main ignores an outsider's issue carrying the current target's marker", ()
|
|||
);
|
||||
});
|
||||
|
||||
/** Run `fn` with `console.warn` captured, restoring it whatever happens. */
|
||||
function capturingWarnings(fn) {
|
||||
const warnings = [];
|
||||
const original = console.warn;
|
||||
console.warn = (...args) => warnings.push(args.join(" "));
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
console.warn = original;
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
test("main warns when a sweep-shaped issue's author does not normalize", () => {
|
||||
// ⚠️ This is the PRODUCTION wiring, not the helper. Unit-testing
|
||||
// `warnOnUnrecognizedAuthors` alone left `sweepIssues`' call to it deletable
|
||||
// with the whole suite still green — so the guard against a silent
|
||||
// normalization drift could itself have gone silent (#2379 review).
|
||||
const spawn = fakeSpawn({
|
||||
latest: latestAt(SDK, "2.1.0"),
|
||||
issues: [
|
||||
{
|
||||
...existingIssue(400, "2.1.0"),
|
||||
// Labels an outsider cannot set, our own marker, and a spelling
|
||||
// `normalizeLogin` has never seen: the #2377 signature exactly.
|
||||
author: { login: "apps/github-actions", is_bot: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const warnings = capturingWarnings(() =>
|
||||
main("o/r", spawn, noAmbientOutput()),
|
||||
);
|
||||
|
||||
const drift = warnings.filter((w) => w.includes("apps/github-actions"));
|
||||
assert.equal(
|
||||
drift.length > 0,
|
||||
true,
|
||||
`expected a drift warning naming the unreadable author; got ${JSON.stringify(warnings)}`,
|
||||
);
|
||||
assert.match(drift[0], /#2377/);
|
||||
// And the consequence is real: unable to read its own issue, the sweep
|
||||
// refiles. The warning is what makes that visible instead of silent.
|
||||
assert.ok(
|
||||
spawn.calls.some((c) => c.args[0] === "issue" && c.args[1] === "create"),
|
||||
);
|
||||
});
|
||||
|
||||
test("main stays silent when every sweep issue's author is readable", () => {
|
||||
// The other side of the guard: a warning that fires on the ordinary path is
|
||||
// noise, and a channel nobody trusts is one people learn to skim.
|
||||
for (const login of [
|
||||
"github-actions",
|
||||
"github-actions[bot]",
|
||||
"app/github-actions",
|
||||
]) {
|
||||
const spawn = fakeSpawn({
|
||||
latest: latestAt(SDK, "2.1.0"),
|
||||
issues: [
|
||||
{ ...existingIssue(400, "2.1.0"), author: { login, is_bot: true } },
|
||||
],
|
||||
});
|
||||
const warnings = capturingWarnings(() =>
|
||||
main("o/r", spawn, noAmbientOutput()),
|
||||
);
|
||||
assert.deepEqual(
|
||||
warnings.filter((w) => w.includes("normalize")),
|
||||
[],
|
||||
`login ${JSON.stringify(login)} must not be reported as a drift`,
|
||||
);
|
||||
// Readable author ⇒ the marker is trusted ⇒ no refile.
|
||||
assert.equal(
|
||||
spawn.calls.some((c) => c.args[0] === "issue" && c.args[1] === "create"),
|
||||
false,
|
||||
`login ${JSON.stringify(login)} must suppress the refile`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("main ignores an issue that lacks the labels only write access can set", () => {
|
||||
const spawn = fakeSpawn({
|
||||
latest: latestAt(SDK, "2.1.0"),
|
||||
|
|
|
|||
Loading…
Reference in a new issue