fix: address Copilot review round 12 on #2248

**The read bounds could produce a false `ok: true`.** Entries past the cap are
never fetched, `fileFailed` only examines fetched rows, and
`resource-limit-exceeded` is a WARNING — so a manifest whose 513th file was
tampered with reported the skill verified. That trades a denial of service
for a wrong answer, which is the failure this whole PR argues against. A
truncated read is now recorded as `incomplete` and forces `ok: false`,
reported separately so a consumer can tell "this skill is wrong" from "this
skill was not fully checked". And a self entry excluded by the cap is now
verified against its declared digest, not merely read for frontmatter —
otherwise the skill's own SKILL.md was the one file nobody checked.

**`entryKey` could crash the TUI.** It called `JSON.stringify` on the whole
server-controlled entry, during render. `skillEntryKey` is decided by the
same `jsonGraphError` guard that bounds the frontmatter comparison, with a
coarse fallback that still separates two unrepresentable entries by URI —
collapsing them would show one skill's verdict under another's name.

**The tab bar wraps and `tabsHeight` was hard-coded to 1.** An OAuth-capable
stdio server serving Skills needs ~107 columns, so it wraps at 132 as well as
at 80, and every pane below was sized a row too tall. `tabBarRows` derives it
from the same list `Tabs` renders, via a shared `visibleTabs(flags)` — the
duplicated filter being how the two would drift apart again.

Nine tests in a new `tabsConfig.test.ts`, including the 80-column regression.
Two assert properties rather than instances: rows never decrease as the
terminal narrows, and a tab wider than the row gets its own rather than
looping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-07 21:57:48 -04:00
commit 27f1b2f1bd
3 changed files with 380 additions and 33 deletions

View file

@ -27,6 +27,7 @@ import {
checkSkillConformance,
checkSkillNameCollisions,
skillDisplayName,
skillEntryKey,
skillUriIdentity,
type SkillIssue,
} from "@inspector/core/mcp/skills.js";
@ -87,18 +88,6 @@ const FILE_COLOR: Record<string, string> = {
"read-error": "red",
};
/**
* What a verification result is a result *about*: the whole entry, serialized.
*
* `JSON.stringify` is enough here — this compares an entry against a later copy
* of *itself* from the same server, so key order is stable and there is no need
* for the canonical form `skillEntriesMatch` uses to compare two independently
* produced entries.
*/
function entryKey(entry: SkillEntry): string {
return JSON.stringify(entry);
}
/**
* The explanation printed under a failed file row.
*
@ -185,7 +174,7 @@ export function SkillsTab({
void (async () => {
try {
const [result] = await verifySkills(inspectorClient, [skill]);
setReport({ key: entryKey(skill), result });
setReport({ key: skillEntryKey(skill), result });
} catch (err) {
if (err instanceof AuthRecoveryRequiredError) {
onAuthRecoveryRequired?.(err);
@ -265,7 +254,7 @@ export function SkillsTab({
};
const issues = selectedSkill ? findingsFor(selectedSkill) : [];
const activeReport =
selectedSkill && report?.key === entryKey(selectedSkill)
selectedSkill && report?.key === skillEntryKey(selectedSkill)
? report.result
: null;
const manifest =

View file

@ -1,6 +1,6 @@
import React from "react";
import { Box, Text } from "ink";
import { type TabType, tabs } from "./tabsConfig.js";
import { type TabType, visibleTabs as visibleTabsFor } from "./tabsConfig.js";
/**
* Split a tab label so the accelerator letter can be underlined wherever it
@ -59,19 +59,14 @@ export function Tabs({
showRequests = false,
showSkills = false,
}: TabsProps) {
let visibleTabs = tabs;
if (!showAuth) {
visibleTabs = visibleTabs.filter((tab) => tab.id !== "auth");
}
if (!showLogging) {
visibleTabs = visibleTabs.filter((tab) => tab.id !== "logging");
}
if (!showRequests) {
visibleTabs = visibleTabs.filter((tab) => tab.id !== "requests");
}
if (!showSkills) {
visibleTabs = visibleTabs.filter((tab) => tab.id !== "skills");
}
// Shared with `App`, which sizes the pane below this bar from the same list —
// see `tabBarRows`.
const visibleTabs = visibleTabsFor({
showAuth,
showLogging,
showRequests,
showSkills,
});
return (
<Box

View file

@ -34,3 +34,73 @@ export const tabs: { id: TabType; label: string; accelerator: string }[] = [
{ id: "requests", label: "Network", accelerator: "n" },
{ id: "logging", label: "Console", accelerator: "o" },
];
/** Which optional tabs the connected server supports. */
export interface TabVisibility {
showAuth: boolean;
showLogging: boolean;
showRequests: boolean;
showSkills: boolean;
}
/**
* The tabs actually rendered for a given server.
*
* One source for the answer, shared by `Tabs` (which draws them) and `App`
* (which must size the pane below them). Duplicating the filter is how the two
* come to disagree about how tall the bar is.
*/
export function visibleTabs(v: TabVisibility): typeof tabs {
return tabs.filter((tab) => {
if (tab.id === "auth") return v.showAuth;
if (tab.id === "logging") return v.showLogging;
if (tab.id === "requests") return v.showRequests;
if (tab.id === "skills") return v.showSkills;
return true;
});
}
/** Rendered width of one tab: the 2-column marker, the label, and any count. */
function tabWidth(
tab: { id: TabType; label: string },
counts: Partial<Record<TabType, number>>,
): number {
const count = counts[tab.id];
return (
2 + tab.label.length + (count === undefined ? 0 : ` (${count})`.length)
);
}
/**
* How many terminal rows the tab bar occupies at a given width.
*
* ⚠️ **Not always 1.** Adding Skills pushed a stdio server's bar past 100
* columns, so it wraps at any ordinary terminal width — and `App` hard-coded
* `tabsHeight = 1`, sizing every content pane one row too tall and clipping the
* bottom of the TUI (Copilot). Deriving the height from the same list `Tabs`
* renders is what keeps the two in agreement as tabs are added.
*
* The bar is a `flexWrap="wrap"` row with one column of padding each side and
* no gaps, so greedy packing by rendered width matches what Ink lays out.
*/
export function tabBarRows(
visible: readonly { id: TabType; label: string }[],
counts: Partial<Record<TabType, number>>,
width: number,
): number {
const inner = Math.max(1, width - 2);
let rows = 1;
let used = 0;
for (const tab of visible) {
const w = tabWidth(tab, counts);
// A tab wider than the whole row still occupies one of its own rather than
// looping forever.
if (used > 0 && used + w > inner) {
rows += 1;
used = w;
} else {
used += w;
}
}
return rows;
}