fix: address Copilot review round 2 on #2251

- SkillsScreen: key verdicts and React elements by manifest ROW INDEX,
  not URI. The checker deliberately tolerates a duplicated URI so it can
  report `duplicate-resource`; a URI key collided those rows into one
  verdict, so verifying either updated both and "Verify all" raced two
  different digest/size declarations into the same slot.
- SkillsScreen: hold the invalidation generation in React state keyed by
  the manifest, not a ref bumped during render. `useValueChange` runs its
  callback during render and requires setState-only purity — an abandoned
  render left the ref incremented and silently discarded a live
  verification.
- SkillsScreen: title a size disagreement "Size mismatch" and render its
  reason. `verifySkillResource` catches it before hashing, so the alert
  was showing "actual undefined" under "Digest mismatch".
- SkillsScreen: pass `contents` to ContentViewer so a base64 SKILL.md
  renders. The text-block form substituted "" and painted a blank preview
  for a file verification had just read correctly.
- skills.ts: add `malformed-size` for a size that is not a non-negative
  safe integer, and exclude such values from the 16 MiB total. A negative
  one could pull the sum back under the limit and hide a violation.
- managedSkillsState: make the overlap guard per-session instead of a
  boolean. A disconnect during an in-flight walk left it set, so the
  reconnect's own load no-oped and was never retried — permanently, if
  the stale request never settled. A stale `finally` can no longer clear
  the live session's guard either.
- skillsSchemas: require the `{ skill }` envelope for `skills/get`. The
  accepted SEP settles it, and normalizing an inline entry would let a
  non-conforming response past the one place that could report it.
- #2248 and the PR description: corrected — they said
  `ReadResourceDirectoryResultSchema` was already declared, which round 1
  removed. #2248 now owns defining it, and records the frontmatter
  cross-check gap too.

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:
cliffhall 2026-09-04 21:45:49 -04:00
commit bfeebd440d
4 changed files with 406 additions and 143 deletions

View file

@ -115,18 +115,20 @@ describe("InspectorClient skills methods (#2234)", () => {
expect(request.mock.calls[0][0].params.uri).toBe("skill://demo/SKILL.md");
});
it("normalizes the enveloped skills/get result to the entry", async () => {
it("unwraps the skills/get envelope to the entry", async () => {
const client = makeClient();
stubRequest(client, { skill: ENTRY });
expect(await client.getSkill("skill://demo/SKILL.md")).toEqual(ENTRY);
});
it("normalizes the inline skills/get result to the entry", async () => {
// The SEP settles the entry shape but not the envelope; a server that
// returns the entry at the top level must not fail here.
it("rejects a skills/get result returned without its envelope", async () => {
// Normalizing it would let a non-conforming server through the one place
// that could have reported it.
const client = makeClient();
stubRequest(client, ENTRY);
expect(await client.getSkill("skill://demo/SKILL.md")).toEqual(ENTRY);
await expect(
client.getSkill("skill://demo/SKILL.md"),
).rejects.toBeDefined();
});
it("rejects a skills/list result that is not a skills page", async () => {

View file

@ -295,6 +295,39 @@ describe("checkSkillConformance", () => {
}
});
it("reports a size that is not a non-negative integer byte length", () => {
for (const size of [-1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 2]) {
const issues = checkSkillConformance(
entry({
resources: [
{ uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
{ uri: "skill://demo/ref.md", digest: DIGEST, size },
],
}),
);
expect(issues.map((i) => i.code)).toEqual(["malformed-size"]);
expect(issues[0].severity).toBe("error");
}
});
it("a negative size cannot pull the total back under the 16 MiB limit", () => {
// The reason `malformed-size` is an error and not just noise: summing a
// negative would hide a genuine `size-limit-exceeded`.
const issues = checkSkillConformance(
entry({
resources: [
{
uri: "skill://demo/SKILL.md",
digest: DIGEST,
size: SKILL_MAX_TOTAL_BYTES + 1,
},
{ uri: "skill://demo/ref.md", digest: DIGEST, size: -1000 },
],
}),
);
expect(issues.map((i) => i.code)).toContain("size-limit-exceeded");
});
it("reports a manifest over the 512-entry limit", () => {
const resources = [
{ uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
@ -350,6 +383,20 @@ describe("totalSkillBytes", () => {
]),
).toBe(15);
});
it("excludes an unusable size rather than summing it", () => {
// An incomplete manifest may only ever *understate* the total, which is
// what keeps the limit check free of false positives. A negative or
// fractional value would break that.
expect(
totalSkillBytes([
{ uri: "a", size: 10 },
{ uri: "b", size: -100 },
{ uri: "c", size: 2.5 },
{ uri: "d", size: Number.NaN },
]),
).toBe(10);
});
});
describe("byte helpers", () => {

View file

@ -7,7 +7,6 @@ import {
SKILLS_GET_METHOD,
SKILLS_LIST_METHOD,
SkillEntrySchema,
normalizeGetSkillResult,
} from "@inspector/core/mcp/skillsSchemas";
const ENTRY = {
@ -89,20 +88,24 @@ describe("ListSkillsResultSchema", () => {
});
describe("GetSkillResultSchema", () => {
it("normalizes the enveloped form to the entry", () => {
it("unwraps the envelope to the entry", () => {
expect(GetSkillResultSchema.parse({ skill: ENTRY })).toEqual(ENTRY);
});
it("normalizes the inline form to the entry", () => {
expect(GetSkillResultSchema.parse(ENTRY)).toEqual(ENTRY);
});
it("normalizeGetSkillResult accepts either shape directly", () => {
expect(normalizeGetSkillResult({ skill: ENTRY })).toEqual(ENTRY);
expect(normalizeGetSkillResult(ENTRY)).toEqual(ENTRY);
it("rejects an entry returned inline rather than normalizing it", () => {
// The envelope is required. Accepting the inline form would silently
// normalize a non-conforming response, which is the failure this
// extension's support exists to report.
expect(() => GetSkillResultSchema.parse(ENTRY)).toThrow();
});
it("rejects a result that is neither shape", () => {
expect(() => GetSkillResultSchema.parse({ nothing: true })).toThrow();
});
it("rejects an envelope whose skill is not an entry", () => {
expect(() =>
GetSkillResultSchema.parse({ skill: { frontmatter: {} } }),
).toThrow();
});
});

View file

@ -142,6 +142,52 @@ describe("ManagedSkillsState", () => {
expect(state.getPagination()).toEqual({ pageCount: 0 });
});
it("lets a reconnect load its skills while a stale walk is still hanging", async () => {
client.setStatus("connected");
// The first walk never settles. A boolean overlap guard would stay set,
// so the reconnect's own load would no-op and never be retried — the
// reconnect would show an empty Skills tab forever.
client.listSkills.mockImplementationOnce(() => new Promise(() => {}));
void state.refresh().catch(() => {});
await client.disconnect();
client.skillPages = [{ skills: [skill("fresh")] }];
await client.connect();
expect(state.getSkills().map((s) => s.frontmatter.name)).toEqual(["fresh"]);
});
it("a stale walk settling later cannot release the live session's guard", async () => {
client.setStatus("connected");
let release: ((value: { skills: SkillEntry[] }) => void) | undefined;
client.listSkills.mockImplementationOnce(
async () =>
new Promise((resolve) => {
release = resolve;
}),
);
const stale = state.refresh();
await client.disconnect();
client.setStatus("connected");
// A live walk is now in flight under the new generation.
let releaseLive: ((value: { skills: SkillEntry[] }) => void) | undefined;
client.listSkills.mockImplementationOnce(
async () =>
new Promise((resolve) => {
releaseLive = resolve;
}),
);
const live = state.refresh();
// The stale one settles first; its `finally` must not free the guard.
release?.({ skills: [] });
await stale;
const blocked = await state.refresh();
expect(blocked).toEqual([]);
releaseLive?.({ skills: [skill("live")] });
await live;
expect(state.getSkills().map((s) => s.frontmatter.name)).toEqual(["live"]);
});
it("does not surface a dead session's failure in the live one", async () => {
client.setStatus("connected");
let fail: ((err: Error) => void) | undefined;