fix: address Copilot review round 1 on #2248

Five findings, all real.

**Critical — `verifySkills` selected `contents[0]`.** These bytes are hashed
against the file's advertised digest, so accepting a block the server
labelled something else verifies one file's content against another file's
digest, and can report that as `verified`. A false pass is worse than a
missing check. `contentsFor(result, uri)` now selects by NORMALIZED identity,
which keeps the canonicalized-echo case that motivated the original code
while rejecting an unrelated block, and reports a read error when nothing
answers for the URI. This is what `onReadSkillFile` already did; the two
paths hash bytes against digests and must not disagree about which bytes.

**`skills/get` was dispatched without the extension gate** that `skills/list`
had. Hoisted into `assertSkillsSupported` so the two cannot drift — declaring
the extension commits a server to both. Without it, an undeclared server's
-32601 is indistinguishable to a script from the -32602 a declared server
returns for a URI it does not serve.

**The TUI could strand a user on a hidden Skills tab.** The tab left the bar
when the gate went false but `activeTab` did not, so the pane kept rendering
for a server that never declared the extension. Reset to `info`, following
the Auth precedent — additionally gated on `connected`, because this gate
reads a server declaration and would otherwise fire during a reconnect.

**The TUI keyed a verdict by URI**, so a refresh replacing the manifest under
the same URI left hashes computed for the previous snapshot describing the
new one. Keyed on the entry now, as the web screen already was.

**A failed "Load more" discarded the pages on screen and the retry cursor.**
Both are preserved now, and the success and failure paths share one
staleness-guarded `commit` helper so they cannot drift on which results they
may write — which is how they came to disagree.

Two nits: the six-skill counts. The docs sentence was more wrong than
reported (five awkward skills of six, three outright violations) and is
rewritten rather than renumbered.

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 18:43:58 -04:00
commit 775107776d
2 changed files with 452 additions and 56 deletions

View file

@ -626,6 +626,28 @@ function App({
!!selectedInspectorClient?.getSkillsExtension() &&
inspectorStatus === "connected";
// Switch away from the Skills tab when the selected server does not serve it.
//
// The same handling the Auth tab gets above, and needed for the same reason:
// the tab disappears from the bar when the gate goes false, but `activeTab`
// is independent of the bar, so the render branch would keep showing the pane
// for a server that never declared the extension — content the user can see
// but can no longer navigate back to (Copilot).
//
// Gated on `connected` rather than on the extension alone: the declaration is
// only knowable after the handshake, so resetting while a reconnect is in
// flight would bounce the user off the tab they were reading and not return
// them to it.
useEffect(() => {
if (
activeTab === "skills" &&
inspectorStatus === "connected" &&
!showSkillsTab
) {
setActiveTab("info");
}
}, [activeTab, inspectorStatus, showSkillsTab]);
// Connect — on 401 or mid-session auth recovery, run OAuth then retry.
type TuiOAuthRunResult =
| "success"

View file

@ -64,6 +64,18 @@ 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 file name a manifest URI ends in, for a list that must fit 40 columns. */
function fileNameOf(uri: string): string {
const cut = uri.lastIndexOf("/");
@ -100,14 +112,23 @@ export function SkillsTab({
const [error, setError] = useState<string | null>(null);
const [verifying, setVerifying] = useState(false);
/**
* The last verification, keyed by the skill URI it was run for. Keyed rather
* than cleared on selection change so moving off a skill and back does not
* silently discard a verdict the user just paid a round trip for — and keyed
* by URI rather than index so a refresh that reorders the list cannot show
* one skill's verdict under another's name.
* The last verification, keyed by the **entry it was computed against**.
*
* Keyed rather than cleared on selection change, so moving off a skill and
* back does not silently discard a verdict the user just paid a round trip
* for. Keyed by a serialization of the entry rather than by its index, so a
* refresh that reorders the list cannot show one skill's verdict under
* another's name — and rather than by its URI alone, because a refresh can
* replace the manifest or the frontmatter *under the same URI*, and a
* URI-keyed verdict would then present hashes and findings computed for the
* previous snapshot as if they described the new one (Copilot).
*
* The same key the web screen uses, for the same reason: re-verifying after a
* metadata-only refresh is the cheap direction to be wrong in; showing a
* verdict computed against a different entry is not.
*/
const [report, setReport] = useState<{
uri: string;
key: string;
result: SkillVerifyReport;
} | null>(null);
const scrollViewRef = useRef<ScrollViewRef>(null);
@ -124,7 +145,7 @@ export function SkillsTab({
void (async () => {
try {
const [result] = await verifySkills(inspectorClient, [skill]);
setReport({ uri: skill.uri, result });
setReport({ key: entryKey(skill), result });
} catch (err) {
if (err instanceof AuthRecoveryRequiredError) {
onAuthRecoveryRequired?.(err);
@ -196,7 +217,9 @@ export function SkillsTab({
const detailWidth = width - listWidth;
const issues = selectedSkill ? checkSkillConformance(selectedSkill) : [];
const activeReport =
selectedSkill && report?.uri === selectedSkill.uri ? report.result : null;
selectedSkill && report?.key === entryKey(selectedSkill)
? report.result
: null;
const manifest =
selectedSkill && selectedSkill.resources !== DYNAMIC_RESOURCES
? selectedSkill.resources