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:
cliffhall 2026-09-07 21:57:48 -04:00
commit 27f1b2f1bd
2 changed files with 380 additions and 33 deletions

View file

@ -9,6 +9,7 @@ import {
checkSkillConformance,
checkSkillFrontmatterMatch,
checkSkillNameCollisions,
skillEntryKey,
getSkillsExtension,
isSkillsExtensionSupported,
normalizeSkillUri,
@ -1201,3 +1202,49 @@ describe("checkSkillNameCollisions (#2248)", () => {
);
});
});
describe("skillEntryKey (#2248)", () => {
const base: SkillEntry = {
uri: "skill://demo/SKILL.md",
frontmatter: { name: "demo", description: "A demo" },
resources: [],
};
it("distinguishes two entries that differ anywhere", () => {
expect(skillEntryKey(base)).toBe(skillEntryKey({ ...base }));
expect(skillEntryKey(base)).not.toBe(
skillEntryKey({
...base,
frontmatter: { name: "demo", description: "changed" },
}),
);
});
it("survives frontmatter too deep to serialize", () => {
// `frontmatter` is unbounded server-controlled JSON, and this key is
// computed during render — so `JSON.stringify` let one catalog entry crash
// the pane that exists to report on it (Copilot).
let deep: unknown = "leaf";
for (let i = 0; i < 60000; i += 1) deep = { a: deep };
const hostile = {
...base,
frontmatter: { name: "demo", deep },
} as unknown as SkillEntry;
expect(() => skillEntryKey(hostile)).not.toThrow();
expect(skillEntryKey(hostile)).toContain("unrepresentable");
});
it("still separates two unrepresentable entries by identity", () => {
// The fallback is coarse, but it must not collapse distinct skills into
// one key — that would show a verdict under the wrong name.
let deep: unknown = "leaf";
for (let i = 0; i < 60000; i += 1) deep = { a: deep };
const a = { ...base, frontmatter: { deep } } as unknown as SkillEntry;
const b = {
...base,
uri: "skill://other/SKILL.md",
frontmatter: { deep },
} as unknown as SkillEntry;
expect(skillEntryKey(a)).not.toBe(skillEntryKey(b));
});
});

View file

@ -538,6 +538,7 @@ describe("verifySkills (#2248)", () => {
const [report] = await verifySkills(client, [skill]);
expect(readResource).toHaveBeenCalledTimes(512);
expect(report.files).toHaveLength(512);
expect(report.ok).toBe(false);
// …and the overage is still REPORTED, so bounding the reads does not
// silence the finding that made them unnecessary.
expect(report.conformance).toEqual(
@ -581,6 +582,9 @@ describe("verifySkills (#2248)", () => {
const [report] = await verifySkills(client, [skill]);
// Two fit exactly; the third would cross, so it is never requested.
expect(readResource).toHaveBeenCalledTimes(2);
// …and a verification that did not finish must not report success.
expect(report.incomplete).toMatch(/2 of 3 manifest entries/);
expect(report.ok).toBe(false);
expect(report.conformance).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "size-limit-exceeded" }),
@ -588,6 +592,61 @@ describe("verifySkills (#2248)", () => {
);
});
it("does not report success for a manifest it could not finish reading", async () => {
// The trade this bound must NOT make: entries past the cap are never
// fetched and `resource-limit-exceeded` is only a warning, so a manifest
// whose 513th file is tampered with returned `ok: true` and the CLI said
// the skill verified — a denial of service swapped for a false pass
// (Copilot).
const skill: SkillEntry = {
uri: "skill://many/SKILL.md",
frontmatter: { name: "many", description: "Over the entry limit" },
resources: Array.from({ length: 600 }, (_, i) => ({
uri: i === 0 ? "skill://many/SKILL.md" : `skill://many/f${i}.md`,
digest: `sha256:${"a".repeat(64)}`,
size: 1,
})),
};
const readResource = vi.fn(async (uri: string) => ({
result: { contents: [{ uri, text: "x" }] },
}));
const client = { readResource } as unknown as InspectorClientProtocol;
const [report] = await verifySkills(client, [skill]);
expect(report.incomplete).toBeDefined();
expect(report.ok).toBe(false);
});
it("verifies the entry's own file even when the cap excluded it", async () => {
// The fallback exists for the frontmatter check, but reading the file and
// then skipping the digest its manifest advertised would leave the skill's
// own SKILL.md the one file nobody verified (Copilot).
const skill: SkillEntry = {
uri: "skill://huge/SKILL.md",
frontmatter: { name: "huge", description: "Listed" },
resources: [
...Array.from({ length: 600 }, (_, i) => ({
uri: `skill://huge/f${i}.md`,
digest: `sha256:${"a".repeat(64)}`,
size: 1,
})),
{
uri: "skill://huge/SKILL.md",
digest: `sha256:${"b".repeat(64)}`,
size: 1,
},
],
};
const readResource = vi.fn(async (uri: string) => ({
result: { contents: [{ uri, text: "x" }] },
}));
const client = { readResource } as unknown as InspectorClientProtocol;
const [report] = await verifySkills(client, [skill]);
const self = report.files.find((f) => f.uri === "skill://huge/SKILL.md");
// A real verdict on the advertised digest, not merely a read.
expect(self?.status).toBe("mismatch");
expect(self?.expectedDigest).toBe(`sha256:${"b".repeat(64)}`);
});
it("does not truncate a conforming manifest", async () => {
// A conforming skill totals at most 16 MiB by definition, so the bound
// must never shorten one — otherwise it would trade a hostile-server
@ -600,6 +659,9 @@ describe("verifySkills (#2248)", () => {
const [report] = await verifySkills(client, [skill]);
expect(readResource).toHaveBeenCalledTimes(2);
expect(report.files).toHaveLength(2);
// Nothing was skipped, so nothing is reported as incomplete.
expect(report.incomplete).toBeUndefined();
expect(report.ok).toBe(true);
});
it("still reads the entry's own file when the cap would exclude it", async () => {