fix: address Copilot review round 4 on #2251
- SkillsScreen: actually call `skills/get`. The client method and its tests existed but no production caller invoked it, so #2234's acceptance criterion ("skills/get retrieves a single entry") was unmet and a server author's required handler could not be exercised. The detail pane now fetches the selected URI on demand and reports whether the fetched entry AGREES with the one skills/list advertised — both describe the same skill, so a disagreement is a server bug only a side-by-side fetch shows. - core/mcp/sha256.ts: a dependency-free SHA-256, used when `crypto.subtle` is absent. `SubtleCrypto` needs a secure context, and this app is documented as servable over plain HTTP on a LAN IP — where every skill verification threw and the UI reported a read failure for files it had fetched fine. Checked against the FIPS 180-4 vectors and differentially against WebCrypto. - skills.ts: require the `skill:` scheme in `normalizeSkillUri`. Checking only that a URI was hierarchical let `https://demo/SKILL.md` pass the name and root checks — a manifest pointing anywhere on the web, reported as conforming. - skills.ts: `missing-digest` and `missing-size` are errors. Both are required fields, and an omitted `size` is what lets a server slip past the 16 MiB pre-fetch limit while the UI reports zero errors. `warning` is now reserved for what is legal yet unverifiable — `"dynamic"`. - SkillsScreen: key the "Verify all" batch guard to the manifest. A global flag left a newly selected skill's button disabled until the previous skill's reads settled — forever, if one hung. Not changed, deliberately: whether a modern-era `skills/list` result MUST carry the SEP-2549 caching attributes. #2234's analysis records it as open and the review asserts the opposite; neither reading was checked against the normative text. Leaving the schema permissive accepts a server that omits them, while tightening on a wrong reading would reject conforming responses — the more expensive direction. `skillsSchemas.ts` states this and #2248 settles it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
6a08c77c92
commit
1cb05ca573
2 changed files with 527 additions and 23 deletions
85
clients/web/src/test/core/mcp/sha256.test.ts
Normal file
85
clients/web/src/test/core/mcp/sha256.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { sha256Bytes } from "@inspector/core/mcp/sha256";
|
||||
import { sha256Digest, textToBytes } from "@inspector/core/mcp/skills";
|
||||
|
||||
const hex = (bytes: Uint8Array) =>
|
||||
Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
|
||||
/**
|
||||
* The fallback exists because `crypto.subtle` is absent in a non-secure
|
||||
* context, and the web client is documented as servable over plain HTTP on a
|
||||
* LAN IP. So it is checked two ways: against the published FIPS 180-4 vectors,
|
||||
* and differentially against WebCrypto — "it agrees with the real thing" is the
|
||||
* property that matters, and it is asserted rather than assumed.
|
||||
*/
|
||||
describe("sha256Bytes (the non-secure-context fallback)", () => {
|
||||
it("matches the FIPS 180-4 vector for the empty message", () => {
|
||||
expect(hex(sha256Bytes(new Uint8Array()))).toBe(
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the FIPS 180-4 vector for 'abc'", () => {
|
||||
expect(hex(sha256Bytes(textToBytes("abc")))).toBe(
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the FIPS 180-4 two-block vector", () => {
|
||||
expect(
|
||||
hex(
|
||||
sha256Bytes(
|
||||
textToBytes(
|
||||
"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq",
|
||||
),
|
||||
),
|
||||
),
|
||||
).toBe("248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1");
|
||||
});
|
||||
|
||||
it("agrees with WebCrypto across the block boundaries", async () => {
|
||||
// 55/56/63/64/65 bracket the padding cases: the last block that still fits
|
||||
// its length field, the one that forces an extra block, and the exact
|
||||
// multiple of 64.
|
||||
for (const length of [0, 1, 55, 56, 63, 64, 65, 200, 1000]) {
|
||||
const bytes = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i += 1) bytes[i] = (i * 7 + 13) % 256;
|
||||
const reference = new Uint8Array(
|
||||
await crypto.subtle.digest("SHA-256", bytes),
|
||||
);
|
||||
expect(hex(sha256Bytes(bytes))).toBe(hex(reference));
|
||||
}
|
||||
});
|
||||
|
||||
it("hashes only the view, not its whole backing buffer", () => {
|
||||
const backing = new Uint8Array([0xff, ...textToBytes("abc"), 0xff]);
|
||||
expect(hex(sha256Bytes(backing.subarray(1, 4)))).toBe(
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sha256Digest without crypto.subtle", () => {
|
||||
it("falls back rather than throwing, and returns the same digest", async () => {
|
||||
// Exactly the shape a plain-HTTP LAN page presents: `crypto` exists,
|
||||
// `crypto.subtle` does not. Before the fallback this threw for every file
|
||||
// and the UI reported a read failure for a file it had fetched fine.
|
||||
const withSubtle = await sha256Digest(textToBytes("abc"));
|
||||
const real = globalThis.crypto;
|
||||
try {
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
configurable: true,
|
||||
value: { getRandomValues: real.getRandomValues.bind(real) },
|
||||
});
|
||||
expect(globalThis.crypto.subtle).toBeUndefined();
|
||||
await expect(sha256Digest(textToBytes("abc"))).resolves.toBe(withSubtle);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
configurable: true,
|
||||
value: real,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -113,6 +113,10 @@ describe("skillNameFromUri", () => {
|
|||
expect(skillNameFromUri("SKILL.md")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for a non-skill scheme", () => {
|
||||
expect(skillNameFromUri("https://demo/SKILL.md")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for a relative string", () => {
|
||||
// SEP-2640 requires a full resource URI; treating `demo/SKILL.md` as one
|
||||
// would let a non-conforming entry report a name and pass the path check.
|
||||
|
|
@ -141,6 +145,14 @@ describe("normalizeSkillUri", () => {
|
|||
expect(normalizeSkillUri("demo/SKILL.md")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a non-skill scheme", () => {
|
||||
// Checking only that a URI is hierarchical would let this through and then
|
||||
// pass the name and root checks — a manifest pointing anywhere on the web,
|
||||
// reported as conforming.
|
||||
expect(normalizeSkillUri("https://demo/SKILL.md")).toBeUndefined();
|
||||
expect(normalizeSkillUri("file:///demo/SKILL.md")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an opaque-path URI, which the parser does not normalize", () => {
|
||||
// `skill:demo/../x.md` parses but keeps its `..` verbatim, so containment
|
||||
// could not be decided on it — accepting it would reopen the hole.
|
||||
|
|
@ -229,7 +241,7 @@ describe("checkSkillConformance", () => {
|
|||
expect(issues[0].severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("reports a manifest entry with no digest as unverifiable", () => {
|
||||
it("reports a manifest entry with no digest as an error", () => {
|
||||
const issues = checkSkillConformance(
|
||||
entry({
|
||||
resources: [
|
||||
|
|
@ -239,6 +251,7 @@ describe("checkSkillConformance", () => {
|
|||
}),
|
||||
);
|
||||
expect(issues.map((i) => i.code)).toEqual(["missing-digest"]);
|
||||
expect(issues[0].severity).toBe("error");
|
||||
expect(issues[0].resourceUri).toBe("skill://demo/ref.md");
|
||||
});
|
||||
|
||||
|
|
@ -331,7 +344,7 @@ describe("checkSkillConformance", () => {
|
|||
expect(issues.map((i) => i.code)).toContain("malformed-uri");
|
||||
});
|
||||
|
||||
it("reports a manifest entry with no size as a warning", () => {
|
||||
it("reports a manifest entry with no size as an error", () => {
|
||||
const issues = checkSkillConformance(
|
||||
entry({
|
||||
resources: [
|
||||
|
|
@ -341,7 +354,7 @@ describe("checkSkillConformance", () => {
|
|||
}),
|
||||
);
|
||||
expect(issues.map((i) => i.code)).toEqual(["missing-size"]);
|
||||
expect(issues[0].severity).toBe("warning");
|
||||
expect(issues[0].severity).toBe("error");
|
||||
});
|
||||
|
||||
it("reports a digest that is not sha256 + 64 lowercase hex", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue