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:
parent
9a8488681e
commit
775107776d
1 changed files with 452 additions and 56 deletions
|
|
@ -32,6 +32,7 @@ import {
|
|||
checkSkillFrontmatterMatch,
|
||||
skillDisplayName,
|
||||
skillFileBytes,
|
||||
skillUriIdentity,
|
||||
verifySkillResource,
|
||||
type SkillIssue,
|
||||
type SkillVerification,
|
||||
|
|
@ -85,19 +86,38 @@ interface ReadContents {
|
|||
}
|
||||
|
||||
/**
|
||||
* The first content block of a `resources/read` result.
|
||||
* The block of a `resources/read` result that answers for `uri` — **selected by
|
||||
* URI, never by position**.
|
||||
*
|
||||
* `contents[0]` rather than a search by URI: a server may legitimately answer
|
||||
* with a canonicalized spelling of the URI we asked for, and matching on the
|
||||
* string would reject it. A result with no blocks is a read failure and is
|
||||
* reported as one.
|
||||
* `contents` is an array, and taking `contents[0]` is wrong in the one way that
|
||||
* matters here: these bytes are about to be hashed against `uri`'s advertised
|
||||
* digest, so accepting a block the server labelled something else would verify
|
||||
* one file's content against another file's digest — and could report that as
|
||||
* `verified`. A false pass from a positional read is worse than a missing
|
||||
* check, because it is an affirmative statement about a file nobody looked at.
|
||||
*
|
||||
* A **normalized** match is accepted, because a server may echo the URI back in
|
||||
* a different but equivalent form — a resolved `..`, a percent-encoding
|
||||
* difference. That is what `skillUriIdentity` is for, and it is the same rule
|
||||
* the whole module applies to every other URI comparison, so a server cannot be
|
||||
* treated as conforming by one check and non-conforming by another.
|
||||
*
|
||||
* `undefined` when nothing answers for the URI, which the caller reports as a
|
||||
* read failure. This mirrors `onReadSkillFile` in the web client, deliberately:
|
||||
* two code paths that hash bytes against a digest must not disagree about which
|
||||
* bytes they are.
|
||||
*/
|
||||
function firstContents(result: unknown): ReadContents | undefined {
|
||||
function contentsFor(result: unknown, uri: string): ReadContents | undefined {
|
||||
const contents = (result as { contents?: unknown })?.contents;
|
||||
if (!Array.isArray(contents) || contents.length === 0) return undefined;
|
||||
const first: unknown = contents[0];
|
||||
if (typeof first !== "object" || first === null) return undefined;
|
||||
return first as ReadContents;
|
||||
if (!Array.isArray(contents)) return undefined;
|
||||
const wanted = skillUriIdentity(uri);
|
||||
for (const block of contents) {
|
||||
if (typeof block !== "object" || block === null) continue;
|
||||
const got = (block as { uri?: unknown }).uri;
|
||||
if (typeof got !== "string") continue;
|
||||
if (skillUriIdentity(got) === wanted) return block as ReadContents;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -136,7 +156,7 @@ export async function verifySkills(
|
|||
let contents: ReadContents | undefined;
|
||||
try {
|
||||
const invocation = await client.readResource(resource.uri, metadata);
|
||||
contents = firstContents(invocation.result);
|
||||
contents = contentsFor(invocation.result, resource.uri);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthRecoveryRequiredError) throw err;
|
||||
files.push({
|
||||
|
|
@ -150,7 +170,8 @@ export async function verifySkills(
|
|||
files.push({
|
||||
uri: resource.uri,
|
||||
status: "read-error",
|
||||
reason: "resources/read returned no content blocks.",
|
||||
reason:
|
||||
"resources/read returned no content block for this URI, so there are no bytes that can be checked against its digest.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
@ -179,7 +200,7 @@ export async function verifySkills(
|
|||
if (entryText === undefined) {
|
||||
try {
|
||||
const invocation = await client.readResource(entry.uri, metadata);
|
||||
const contents = firstContents(invocation.result);
|
||||
const contents = contentsFor(invocation.result, entry.uri);
|
||||
if (typeof contents?.text === "string") entryText = contents.text;
|
||||
} catch (err) {
|
||||
// Left undefined: the frontmatter check is skipped below. When the
|
||||
|
|
|
|||
Loading…
Reference in a new issue