fix: address Copilot review round 1 on #2251

- skills.ts: report a manifest that omits the skill's own SKILL.md
  (an empty list included), duplicate URIs, entries outside the skill
  root, and a missing size. `Conforms` was reachable for manifests that
  break invariants SEP-2640 states.
- skills.ts: cross-check the declared byte length before hashing. A size
  that disagrees fails verification on its own — the digest is taken over
  the bytes the server served, so agreeing with it says nothing about
  whether the manifest describes them.
- skills.ts: copy the view instead of slicing its backing store in
  `sha256Digest`. `SharedArrayBuffer.prototype.slice()` returns another
  SharedArrayBuffer, which `crypto.subtle.digest` rejects — the cast
  claimed to handle the exact input that would have thrown. No cast now.
- skills.ts: state the one obligation NOT checked here — that an entry's
  frontmatter matches the fetched SKILL.md's. The digest cannot cover it,
  and closing it needs a YAML parser, so it is tracked on #2248.
- managedSkillsState: cap the walk at LIST_MAX_PAGES. The repeated-cursor
  guard only catches a server stuck on one cursor; endlessly unique ones
  walked forever. Raises rather than truncating, like the salvage walk.
- managedSkillsState: gate every write on a session generation, so a walk
  that resolves after a disconnect or destroy cannot repopulate a cleared
  store or deliver the previous session's skills into the next.
- inspectorClient: send a cursor when it is `!== undefined`, not when it
  is truthy. An opaque cursor may be `""`, and dropping it re-requested
  page one — which the store then reported as a repeated-cursor failure.
- SkillsScreen: key verdict invalidation on the manifest (URI + digests +
  sizes), not the URI alone. A Refresh that changed the manifest left a
  green badge attached to a digest nothing had checked.
- SkillsScreen: epoch-guard every read continuation, so a fetch that
  resolves after the selection moved on cannot write into the new one.
- SkillsScreen: bound "Verify all" to 4 concurrent reads. A conforming
  manifest may hold 512 files.
- skillsSchemas: drop the guessed `resources/directory/read` result
  schema. Nothing calls it, so an unverified shape could sit wrong
  indefinitely; phase 3 adds it against the normative text.
- skills-http.json: declare the extension bare. It advertised
  `directoryRead: true` with no handler, so Connection Info reported
  "Supported" for a method that answers -32601.
- types.ts: restore the Tasks doc comment the Skills interface displaced.

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:23:58 -04:00
commit da8cc166ce
7 changed files with 604 additions and 107 deletions

View file

@ -16,10 +16,21 @@ function StatefulSkillsScreen(args: ComponentProps<typeof SkillsScreen>) {
}
const REF_TEXT = "# Column rules\n";
// The digest of REF_TEXT, so the clean skill really does verify when the
// "Verify all" story runs — a placeholder here would demo a false green.
const SELF_TEXT = "# skill\n";
// The real digests of those two strings, so the clean skill actually verifies
// when the "Verify all" story runs — a placeholder would demo a false green.
const REF_DIGEST =
"sha256:e201429aa2684958ca1a0537ab4eb4b7eb3a81c71e7cc7a11397eb500738e015";
const SELF_DIGEST =
"sha256:6504f2de0a1febf7492c3b98f93d9ab49558eb364607a706f02fe9a75aa7f75b";
/** Every manifest lists the skill's own SKILL.md — a manifest is the complete
* file set, so one that omits it is a `manifest-missing-self` error. */
const selfEntry = (path: string) => ({
uri: `skill://${path}/SKILL.md`,
digest: SELF_DIGEST,
size: 8,
});
const sampleSkills: SkillEntry[] = [
{
@ -29,10 +40,11 @@ const sampleSkills: SkillEntry[] = [
description: "Analyze a CSV and summarize its columns",
},
resources: [
selfEntry("data-analysis"),
{
uri: "skill://data-analysis/reference.md",
digest: REF_DIGEST,
size: REF_TEXT.length,
size: 15,
},
],
},
@ -43,10 +55,14 @@ const sampleSkills: SkillEntry[] = [
description: "Advertises a digest its bytes do not match",
},
resources: [
selfEntry("tampered-notes"),
{
// A well-formed digest of bytes the fake read does not return, with a
// size that agrees — so the reported failure is a *digest* mismatch
// rather than the cheaper size cross-check.
uri: "skill://tampered-notes/notes.md",
digest: `sha256:${"b".repeat(64)}`,
size: 12,
size: 8,
},
],
},
@ -64,7 +80,7 @@ const sampleSkills: SkillEntry[] = [
name: "right-name",
description: "URI path segment disagrees with frontmatter.name",
},
resources: [],
resources: [selfEntry("wrong-folder")],
},
];
@ -81,7 +97,7 @@ const meta: Meta<typeof SkillsScreen> = {
onReadSkillFile: fn(async (uri: string) =>
uri.endsWith("reference.md")
? { text: REF_TEXT }
: { text: `# ${uri}\n`, mimeType: "text/markdown" },
: { text: SELF_TEXT, mimeType: "text/markdown" },
),
},
render: (args) => <StatefulSkillsScreen {...args} />,

View file

@ -16,11 +16,17 @@ import {
import { EMPTY_SKILLS_UI } from "../screenUiState";
const REF_TEXT = "# Column rules\n";
// Computed once at module load so the fixture's advertised digest really is the
// digest of the bytes the fake read returns — a hard-coded constant here would
const SELF_TEXT = "# data-analysis\n";
const NOTES_TEXT = "different\n";
// Computed once at module load so each fixture's advertised digest really is
// the digest of the bytes the fake read returns — a hard-coded constant would
// make the "verified" test pass for the wrong reason if the encoder changed.
const REF_DIGEST = await sha256Digest(textToBytes(REF_TEXT));
const SELF_DIGEST = await sha256Digest(textToBytes(SELF_TEXT));
// Every manifest lists the skill's own SKILL.md: a manifest is the complete
// file set, so one that omits it is a `manifest-missing-self` error and no
// fixture here would be "clean".
const CLEAN_SKILL: SkillEntry = {
uri: "skill://data-analysis/SKILL.md",
frontmatter: {
@ -28,10 +34,15 @@ const CLEAN_SKILL: SkillEntry = {
description: "Analyze a CSV and summarize its columns",
},
resources: [
{
uri: "skill://data-analysis/SKILL.md",
digest: SELF_DIGEST,
size: textToBytes(SELF_TEXT).byteLength,
},
{
uri: "skill://data-analysis/reference.md",
digest: REF_DIGEST,
size: REF_TEXT.length,
size: textToBytes(REF_TEXT).byteLength,
},
],
};
@ -41,9 +52,17 @@ const TAMPERED_SKILL: SkillEntry = {
frontmatter: { name: "tampered", description: "Bad digest" },
resources: [
{
uri: "skill://tampered/SKILL.md",
digest: SELF_DIGEST,
size: textToBytes(SELF_TEXT).byteLength,
},
{
// A well-formed digest of bytes the fake read does not return, and a
// size that agrees — so the failure reported is a *digest* mismatch and
// not the cheaper size cross-check.
uri: "skill://tampered/notes.md",
digest: `sha256:${"b".repeat(64)}`,
size: 4,
size: textToBytes(NOTES_TEXT).byteLength,
},
],
};
@ -57,7 +76,13 @@ const DYNAMIC_SKILL: SkillEntry = {
const MISMATCHED_SKILL: SkillEntry = {
uri: "skill://wrong-folder/SKILL.md",
frontmatter: { name: "right-name", description: "Name disagreement" },
resources: [],
resources: [
{
uri: "skill://wrong-folder/SKILL.md",
digest: SELF_DIGEST,
size: textToBytes(SELF_TEXT).byteLength,
},
],
};
const ALL_SKILLS = [
@ -70,8 +95,8 @@ const ALL_SKILLS = [
/** A `resources/read` that serves the fixture bytes for any known URI. */
const readFixtureFile = vi.fn(async (uri: string) => {
if (uri === "skill://data-analysis/reference.md") return { text: REF_TEXT };
if (uri === "skill://tampered/notes.md") return { text: "different\n" };
return { text: `# ${uri}\n`, mimeType: "text/markdown" };
if (uri === "skill://tampered/notes.md") return { text: NOTES_TEXT };
return { text: SELF_TEXT, mimeType: "text/markdown" };
});
const baseProps: SkillsScreenProps = {
@ -184,7 +209,7 @@ describe("SkillsScreen", () => {
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /Verify all/ }));
expect(await screen.findByText("verified")).toBeInTheDocument();
expect(await screen.findAllByText("verified")).toHaveLength(2);
});
it("reports a digest mismatch loudly, with both digests", async () => {
@ -203,7 +228,10 @@ describe("SkillsScreen", () => {
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
const manifest = screen.getByTestId("skill-manifest");
await user.click(within(manifest).getByRole("button", { name: "Verify" }));
// One row at a time: the first row's own Verify button, not "Verify all".
await user.click(
within(manifest).getAllByRole("button", { name: "Verify" })[0],
);
expect(await screen.findByText("verified")).toBeInTheDocument();
});
@ -215,8 +243,9 @@ describe("SkillsScreen", () => {
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /Verify all/ }));
expect(await screen.findByText("Could not read file")).toBeInTheDocument();
expect(screen.getByText("403")).toBeInTheDocument();
// One alert per file in the manifest — both reads failed.
expect(await screen.findAllByText("Could not read file")).toHaveLength(2);
expect(screen.getAllByText("403")).toHaveLength(2);
});
it("wraps a non-Error read rejection", async () => {
@ -227,7 +256,7 @@ describe("SkillsScreen", () => {
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /Verify all/ }));
expect(await screen.findByText("plain string")).toBeInTheDocument();
expect(await screen.findAllByText("plain string")).toHaveLength(2);
});
it("shows the SKILL.md preview on demand", async () => {
@ -267,13 +296,13 @@ describe("SkillsScreen", () => {
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /Verify all/ }));
expect(await screen.findByText("verified")).toBeInTheDocument();
expect(await screen.findAllByText("verified")).toHaveLength(2);
// A verdict belongs to the skill it was computed for; carrying it across a
// selection change would attribute one skill's result to another.
await user.click(screen.getByText("tampered"));
expect(screen.queryByText("verified")).not.toBeInTheDocument();
expect(screen.getByText("—")).toBeInTheDocument();
expect(screen.getAllByText("—")).toHaveLength(2);
});
it("drops the SKILL.md preview when the selection changes", async () => {
@ -286,22 +315,22 @@ describe("SkillsScreen", () => {
expect(screen.queryByTestId("skill-md-preview")).not.toBeInTheDocument();
});
it("renders an em dash for a manifest entry with no size", async () => {
it("renders an em dash for a manifest entry with no size or digest", async () => {
const user = userEvent.setup();
renderWithMantine(
<ControlledSkillsScreen
skills={[
{
...CLEAN_SKILL,
resources: [{ uri: "skill://data-analysis/reference.md" }],
resources: [{ uri: "skill://data-analysis/SKILL.md" }],
},
]}
/>,
);
await user.click(screen.getByText("data-analysis"));
const manifest = screen.getByTestId("skill-manifest");
// Three em dashes in the row: the size cell, the digest cell, and the
// not-yet-run verification badge — which stays distinct from
// Three em dashes in the single row: the size cell, the digest cell, and
// the not-yet-run verification badge — which stays distinct from
// "unverifiable" so an absent digest is never mistaken for an unrun check.
expect(within(manifest).getAllByText("—")).toHaveLength(3);
});
@ -314,7 +343,7 @@ describe("SkillsScreen", () => {
{
...CLEAN_SKILL,
resources: [
{ uri: "skill://data-analysis/a.md", digest: "sha256:short" },
{ uri: "skill://data-analysis/SKILL.md", digest: "sha256:short" },
],
},
]}
@ -331,7 +360,7 @@ describe("SkillsScreen", () => {
skills={[
{
...CLEAN_SKILL,
resources: [{ uri: "skill://data-analysis/reference.md" }],
resources: [{ uri: "skill://data-analysis/SKILL.md" }],
},
]}
/>,
@ -340,4 +369,101 @@ describe("SkillsScreen", () => {
await user.click(screen.getByRole("button", { name: /Verify all/ }));
expect(await screen.findByText("unverifiable")).toBeInTheDocument();
});
it("drops verdicts when a refresh replaces the manifest for the same skill", async () => {
// The selection never changes, so keying invalidation on the URI alone
// would leave a green `verified` badge attached to a digest the refresh
// replaced — the UI vouching for content it has never checked.
const user = userEvent.setup();
const { rerender } = renderWithMantine(
<SkillsScreen
{...baseProps}
skills={[CLEAN_SKILL]}
ui={{ ...EMPTY_SKILLS_UI, selectedSkillUri: CLEAN_SKILL.uri }}
/>,
);
await user.click(screen.getByRole("button", { name: /Verify all/ }));
expect(await screen.findAllByText("verified")).toHaveLength(2);
rerender(
<SkillsScreen
{...baseProps}
skills={[
{
...CLEAN_SKILL,
resources: [
{
uri: "skill://data-analysis/SKILL.md",
digest: `sha256:${"c".repeat(64)}`,
size: 1,
},
],
},
]}
ui={{ ...EMPTY_SKILLS_UI, selectedSkillUri: CLEAN_SKILL.uri }}
/>,
);
expect(screen.queryByText("verified")).not.toBeInTheDocument();
});
it("discards a verification that resolves after the selection moved on", async () => {
// A read still in flight when the user switches skills must not write its
// verdict into the newly selected skill's rows.
const user = userEvent.setup();
let release: ((value: { text: string }) => void) | undefined;
const onReadSkillFile = vi.fn(
() =>
new Promise<{ text: string }>((resolve) => {
release = resolve;
}),
);
renderWithMantine(
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /Verify all/ }));
await user.click(screen.getByText("tampered"));
release?.({ text: SELF_TEXT });
// Nothing from the abandoned read reaches the new selection's rows.
expect(screen.queryByText("verified")).not.toBeInTheDocument();
expect(screen.queryByText("mismatch")).not.toBeInTheDocument();
});
it("discards a SKILL.md read that resolves after the selection moved on", async () => {
const user = userEvent.setup();
let release: ((value: { text: string }) => void) | undefined;
const onReadSkillFile = vi.fn(
() =>
new Promise<{ text: string }>((resolve) => {
release = resolve;
}),
);
renderWithMantine(
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
await user.click(screen.getByText("tampered"));
release?.({ text: SELF_TEXT });
expect(screen.queryByTestId("skill-md-preview")).not.toBeInTheDocument();
});
it("discards a failed SKILL.md read that resolves after the selection moved on", async () => {
const user = userEvent.setup();
let fail: ((err: Error) => void) | undefined;
const onReadSkillFile = vi.fn(
() =>
new Promise<{ text: string }>((_resolve, reject) => {
fail = reject;
}),
);
renderWithMantine(
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
await user.click(screen.getByText("tampered"));
fail?.(new Error("too late"));
expect(screen.queryByText("too late")).not.toBeInTheDocument();
});
});

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import {
Alert,
Badge,
@ -35,6 +35,13 @@ import {
type SkillFileContents,
} from "../../../utils/skillFileBytes";
/**
* How many skill files are read at once by "Verify all". A conforming manifest
* may hold 512 entries, so this is what keeps one click from becoming 512
* simultaneous `resources/read` calls.
*/
const VERIFY_CONCURRENCY = 4;
/** Per-file verification progress, keyed by the manifest entry's URI. */
type FileState =
| { status: "pending" }
@ -235,16 +242,12 @@ export function SkillsScreen({
const [fileStates, setFileStates] = useState<Record<string, FileState>>({});
const [preview, setPreview] = useState<SkillFileContents | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
// Changing the selection invalidates every verdict and the SKILL.md preview:
// they belong to the skill that was selected. Adjusted DURING RENDER via
// `useValueChange` rather than in an effect, so the new skill never paints
// for a frame carrying the previous one's verification results.
useValueChange(selectedSkillUri, () => {
setFileStates({});
setPreview(null);
setPreviewError(null);
});
// Bumped every time the verdicts are invalidated. A read that was already in
// flight captures the value it started under and discards its result when
// this has moved on — otherwise a slow fetch for the previous selection (or
// the previous manifest) lands afterwards and writes a verdict for content
// nobody is looking at, or worse, one that was never checked.
const epoch = useRef(0);
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase();
@ -274,8 +277,37 @@ export function SkillsScreen({
[selected],
);
// What every verdict on screen is a verdict *about*: the selected skill AND
// the manifest it advertised. Keying invalidation on the URI alone would
// leave a green `verified` badge attached to a digest the Refresh replaced,
// so the UI would vouch for content it has never checked. A primitive string
// rather than the manifest object, because `useValueChange` compares with
// `Object.is` and a fresh array every render would loop.
const manifestKey = useMemo(
() =>
[
selectedSkillUri ?? "",
selected?.resources === DYNAMIC_RESOURCES ? "dynamic" : "",
...manifest.map((r) => `${r.uri}|${r.digest ?? ""}|${r.size ?? ""}`),
].join("\n"),
[manifest, selected, selectedSkillUri],
);
// Adjusted DURING RENDER via `useValueChange` rather than in an effect, so a
// new selection (or a refreshed manifest) never paints a frame carrying the
// previous one's verification results. The epoch bump is a ref write, which
// is why it is done in the callback alongside the state resets rather than
// during the render body itself.
useValueChange(manifestKey, () => {
epoch.current += 1;
setFileStates({});
setPreview(null);
setPreviewError(null);
});
const verifyFile = useCallback(
async (resource: SkillResource) => {
const started = epoch.current;
setFileStates((prev) => ({
...prev,
[resource.uri]: { status: "pending" },
@ -286,11 +318,13 @@ export function SkillsScreen({
resource,
skillFileBytes(contents),
);
if (epoch.current !== started) return;
setFileStates((prev) => ({
...prev,
[resource.uri]: { status: "done", verification },
}));
} catch (err) {
if (epoch.current !== started) return;
setFileStates((prev) => ({
...prev,
[resource.uri]: {
@ -304,22 +338,40 @@ export function SkillsScreen({
);
const verifyAll = useCallback(() => {
// Bounded concurrency, not `Promise.all` over the whole manifest: a
// conforming skill may declare 512 files, and firing 512 simultaneous
// `resources/read` calls would bury the transport and the server for no
// gain. Workers pull from a shared cursor so each row still flips to
// `checking…` and then to its verdict as it lands, rather than all at once.
//
// Held rather than floated: each `verifyFile` owns its own failures (it
// records them as per-file state), and this handler cannot be async, so the
// records them as per-row state), and this handler cannot be async, so the
// settled promise is discarded explicitly at one place instead of per file.
void Promise.all(manifest.map((resource) => verifyFile(resource)));
let next = 0;
const worker = async (): Promise<void> => {
for (let i = next++; i < manifest.length; i = next++) {
await verifyFile(manifest[i]);
}
};
const workers = Math.min(VERIFY_CONCURRENCY, manifest.length);
void Promise.all(Array.from({ length: workers }, () => worker()));
}, [manifest, verifyFile]);
const showSkillMd = useCallback(() => {
if (!selected) return;
const started = epoch.current;
// A click handler cannot await, and this chain terminates in its own
// `catch` that surfaces the message in the preview slot.
// `catch` that surfaces the message in the preview slot. Both arms are
// epoch-guarded: a read that resolves after the selection moved on would
// otherwise show one skill's SKILL.md under another's heading.
void onReadSkillFile(selected.uri)
.then((contents) => {
if (epoch.current !== started) return;
setPreview(contents);
setPreviewError(null);
})
.catch((err: unknown) => {
if (epoch.current !== started) return;
setPreview(null);
setPreviewError(err instanceof Error ? err.message : String(err));
});

View file

@ -311,7 +311,6 @@ export interface AppsPanelProps {
onRefreshApps: () => void;
}
/** The Tasks monitor: the task list, its progress map, and actions. */
/** The Skills screen (SEP-2640): the enumerated skills and their verification. */
export interface SkillsPanelProps {
skills: SkillEntry[];
@ -325,6 +324,7 @@ export interface SkillsPanelProps {
onReadSkillFile: (uri: string) => Promise<SkillFileContents>;
}
/** The Tasks monitor: the task list, its progress map, and actions. */
export interface TasksPanelProps {
tasks: Task[];
progressByTaskId?: Record<string, TaskProgress>;

View file

@ -22,16 +22,20 @@ import {
const HELLO_SHA256 =
"sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
const DIGEST = `sha256:${"a".repeat(64)}`;
/**
* A conforming entry: the manifest is complete (it lists the skill's own
* SKILL.md), unique, inside the skill root, and every row carries a digest and
* a size. Overrides make exactly one of those false, one test at a time.
*/
function entry(overrides: Partial<SkillEntry> = {}): SkillEntry {
return {
uri: "skill://demo/SKILL.md",
frontmatter: { name: "demo", description: "A demo skill" },
resources: [
{
uri: "skill://demo/ref.md",
digest: `sha256:${"a".repeat(64)}`,
size: 10,
},
{ uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
{ uri: "skill://demo/ref.md", digest: DIGEST, size: 10 },
],
...overrides,
};
@ -188,12 +192,90 @@ describe("checkSkillConformance", () => {
it("reports a manifest entry with no digest as unverifiable", () => {
const issues = checkSkillConformance(
entry({ resources: [{ uri: "skill://demo/ref.md", size: 1 }] }),
entry({
resources: [
{ uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
{ uri: "skill://demo/ref.md", size: 1 },
],
}),
);
expect(issues.map((i) => i.code)).toEqual(["missing-digest"]);
expect(issues[0].resourceUri).toBe("skill://demo/ref.md");
});
it("reports a manifest that omits the skill's own SKILL.md", () => {
// A manifest is the complete file set, so one without the entry file is
// not "a skill with no extras" — it cannot be checked against the skill.
const issues = checkSkillConformance(
entry({
resources: [{ uri: "skill://demo/ref.md", digest: DIGEST, size: 1 }],
}),
);
expect(issues.map((i) => i.code)).toEqual(["manifest-missing-self"]);
expect(issues[0].severity).toBe("error");
});
it("reports an empty manifest through the same finding", () => {
const issues = checkSkillConformance(entry({ resources: [] }));
expect(issues.map((i) => i.code)).toEqual(["manifest-missing-self"]);
});
it("reports a duplicated manifest URI", () => {
const dup = { uri: "skill://demo/ref.md", digest: DIGEST, size: 1 };
const issues = checkSkillConformance(
entry({
resources: [
{ uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
dup,
dup,
],
}),
);
expect(issues.map((i) => i.code)).toEqual(["duplicate-resource"]);
expect(issues[0].resourceUri).toBe("skill://demo/ref.md");
});
it("reports a manifest entry outside the skill root", () => {
const issues = checkSkillConformance(
entry({
resources: [
{ uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
{ uri: "skill://other/ref.md", digest: DIGEST, size: 1 },
],
}),
);
expect(issues.map((i) => i.code)).toEqual(["resource-outside-skill-root"]);
expect(issues[0].resourceUri).toBe("skill://other/ref.md");
});
it("does not check the skill root when the entry URI is malformed", () => {
// There is no root to measure against, and `malformed-uri` already says so;
// a second finding per resource would present one defect as many.
const issues = checkSkillConformance(
entry({
uri: "skill://demo/other.md",
resources: [{ uri: "skill://elsewhere/a.md", digest: DIGEST, size: 1 }],
}),
);
expect(issues.map((i) => i.code)).not.toContain(
"resource-outside-skill-root",
);
expect(issues.map((i) => i.code)).toContain("malformed-uri");
});
it("reports a manifest entry with no size as a warning", () => {
const issues = checkSkillConformance(
entry({
resources: [
{ uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
{ uri: "skill://demo/ref.md", digest: DIGEST },
],
}),
);
expect(issues.map((i) => i.code)).toEqual(["missing-size"]);
expect(issues[0].severity).toBe("warning");
});
it("reports a digest that is not sha256 + 64 lowercase hex", () => {
for (const digest of [
"sha256:XYZ",
@ -202,20 +284,26 @@ describe("checkSkillConformance", () => {
`sha256:${"a".repeat(63)}`,
]) {
const issues = checkSkillConformance(
entry({ resources: [{ uri: "skill://demo/ref.md", digest }] }),
entry({
resources: [
{ uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
{ uri: "skill://demo/ref.md", digest, size: 1 },
],
}),
);
expect(issues.map((i) => i.code)).toEqual(["malformed-digest"]);
}
});
it("reports a manifest over the 512-entry limit", () => {
const resources = Array.from(
{ length: SKILL_MAX_RESOURCE_ENTRIES + 1 },
(_unused, i) => ({
const resources = [
{ uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
...Array.from({ length: SKILL_MAX_RESOURCE_ENTRIES }, (_unused, i) => ({
uri: `skill://demo/f${i}.md`,
digest: `sha256:${"a".repeat(64)}`,
}),
);
digest: DIGEST,
size: 1,
})),
];
const issues = checkSkillConformance(entry({ resources }));
expect(issues.map((i) => i.code)).toContain("resource-limit-exceeded");
});
@ -224,10 +312,11 @@ describe("checkSkillConformance", () => {
const issues = checkSkillConformance(
entry({
resources: [
{ uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
{
uri: "skill://demo/big.bin",
digest: `sha256:${"a".repeat(64)}`,
size: SKILL_MAX_TOTAL_BYTES + 1,
digest: DIGEST,
size: SKILL_MAX_TOTAL_BYTES,
},
],
}),
@ -240,8 +329,8 @@ describe("checkSkillConformance", () => {
entry({
resources: [
{
uri: "skill://demo/big.bin",
digest: `sha256:${"a".repeat(64)}`,
uri: "skill://demo/SKILL.md",
digest: DIGEST,
size: SKILL_MAX_TOTAL_BYTES,
},
],
@ -310,6 +399,40 @@ describe("verifySkillResource", () => {
expect(result.actualDigest).toBe(HELLO_SHA256);
});
it("fails on a declared size that disagrees with the fetched bytes", async () => {
// A size disagreement is a real inconsistency even when the digest would
// match: the digest is taken over the bytes the server served, so agreeing
// with it says nothing about whether the manifest describes those bytes.
const result = await verifySkillResource(
{ uri: "skill://demo/a.md", digest: HELLO_SHA256, size: 999 },
textToBytes("hello"),
);
expect(result.status).toBe("mismatch");
expect(result.expectedSize).toBe(999);
expect(result.actualSize).toBe(5);
expect(result.reason).toMatch(/999 bytes/);
});
it("checks the size before hashing, so a bad size never reports verified", async () => {
const result = await verifySkillResource(
{ uri: "skill://demo/a.md", size: 1 },
textToBytes("hello"),
);
// No digest at all, and still a mismatch — the length alone settles it.
expect(result.status).toBe("mismatch");
expect(result.actualDigest).toBeUndefined();
});
it("echoes both sizes on a verified result when one was declared", async () => {
const result = await verifySkillResource(
{ uri: "skill://demo/a.md", digest: HELLO_SHA256, size: 5 },
textToBytes("hello"),
);
expect(result.status).toBe("verified");
expect(result.expectedSize).toBe(5);
expect(result.actualSize).toBe(5);
});
it("reports unverifiable when no digest is advertised", async () => {
const result = await verifySkillResource(
{ uri: "skill://demo/a.md" },

View file

@ -3,7 +3,6 @@ import {
DYNAMIC_RESOURCES,
GetSkillResultSchema,
ListSkillsResultSchema,
ReadResourceDirectoryResultSchema,
SKILLS_EXTENSION_KEY,
SKILLS_GET_METHOD,
SKILLS_LIST_METHOD,
@ -107,15 +106,3 @@ describe("GetSkillResultSchema", () => {
expect(() => GetSkillResultSchema.parse({ nothing: true })).toThrow();
});
});
describe("ReadResourceDirectoryResultSchema", () => {
it("parses directory children including the directory mime type", () => {
const parsed = ReadResourceDirectoryResultSchema.parse({
contents: [
{ uri: "skill://demo/sub", mimeType: "inode/directory" },
{ uri: "skill://demo/ref.md", mimeType: "text/markdown", size: 3 },
],
});
expect(parsed.contents).toHaveLength(2);
});
});

View file

@ -3,6 +3,8 @@ import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas";
import {
ManagedSkillsState,
REPEATED_CURSOR_MESSAGE,
SKILLS_MAX_PAGES,
SKILLS_PAGE_LIMIT_MESSAGE,
} from "@inspector/core/mcp/state/managedSkillsState";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
@ -105,6 +107,59 @@ describe("ManagedSkillsState", () => {
expect(state.getError()?.message).toBe(REPEATED_CURSOR_MESSAGE);
});
it("stops and reports when a server hands back endlessly unique cursors", async () => {
client.setStatus("connected");
// The repeated-cursor guard cannot see this shape: every cursor is new, so
// the walk would grow without bound. The cap raises rather than truncating
// — returning what we have would present a partial list as a complete one.
let n = 0;
client.listSkills.mockImplementation(async () => ({
skills: [skill(`s${n}`)],
nextCursor: String(++n),
}));
await expect(state.refresh()).rejects.toThrow(SKILLS_PAGE_LIMIT_MESSAGE);
expect(client.listSkills).toHaveBeenCalledTimes(SKILLS_MAX_PAGES);
// The truncated list is NOT committed.
expect(state.getSkills()).toEqual([]);
});
it("abandons a walk whose session ended mid-flight", async () => {
client.setStatus("connected");
let release: ((value: { skills: SkillEntry[] }) => void) | undefined;
client.listSkills.mockImplementationOnce(
async () =>
new Promise((resolve) => {
release = resolve;
}),
);
const walk = state.refresh();
// A disconnect lands while the request is still out.
await client.disconnect();
release?.({ skills: [skill("stale")] });
await walk;
// The continuation must not repopulate a store the disconnect cleared.
expect(state.getSkills()).toEqual([]);
expect(state.getPagination()).toEqual({ pageCount: 0 });
});
it("does not surface a dead session's failure in the live one", async () => {
client.setStatus("connected");
let fail: ((err: Error) => void) | undefined;
client.listSkills.mockImplementationOnce(
async () =>
new Promise((_resolve, reject) => {
fail = reject;
}),
);
const walk = state.refresh();
await client.disconnect();
fail?.(new Error("from the old session"));
// Still rejected — the caller's auth-recovery wrapper keys off that — but
// the store's observable error is left alone.
await expect(walk).rejects.toThrow("from the old session");
expect(state.getError()).toBeNull();
});
it("records a failure as observable state and re-throws it", async () => {
client.setStatus("connected");
const failure = new Error("boom");