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:
parent
63623b754b
commit
da8cc166ce
3 changed files with 604 additions and 107 deletions
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue