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:
parent
60d5f403e6
commit
27f1b2f1bd
3 changed files with 380 additions and 33 deletions
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue