fix: address Copilot review round 12 on #2251

All three findings were the same underlying inconsistency: URI comparisons
on raw strings, in a flow that elsewhere deliberately treats normalized
equivalents as the same resource. One `skillUriIdentity` helper now backs
every comparison, so they cannot disagree.

- skills.ts: `manifest-missing-self` compares identities, so a manifest
  listing the RFC-equivalent `skill://demo/%53KILL.md` is recognized as the
  entry's own file — it is fetchable as that file, so reporting it missing
  was the tool disagreeing with itself.
- skills.ts: `skillEntriesMatch` normalizes the entry URI and every manifest
  URI before comparing, so a server that canonicalizes an escape between the
  listing and the fetch is not reported as a changed snapshot.
- SkillsScreen: the `wrongUri` check uses the same identity, so a
  canonicalizing server is not accused of answering for a different skill —
  the read path already accepts exactly that equivalence.
- skills.ts: apply the Agent Skills name grammar to the RAW value. Trimming
  first let `" demo "` pass, and whitespace is not in the grammar — so an
  entry could report "Conforms" with a name that can never equal its URI
  path segment. The trimmed copy now only distinguishes absent from invalid.
- skills.ts: add `malformed-description` for a description above the
  1024-character Agent Skills limit, with boundary coverage.
- inspectorClient-skills.test: justify the `as unknown as` per AGENTS.md —
  both fields are `private` with no public setter, the asserted shape is
  exactly what the class declares, and the alternative is a live connection.

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-05 00:46:18 -04:00
commit bd28a36f23
3 changed files with 146 additions and 8 deletions

View file

@ -24,6 +24,7 @@ import {
checkSkillConformance,
skillDisplayName,
skillEntriesMatch,
skillUriIdentity,
totalSkillBytes,
verifySkillResource,
type SkillIssue,
@ -544,7 +545,12 @@ export function SkillsScreen({
writeFetched({
entry,
issues: checkSkillConformance(entry),
wrongUri: entry.uri !== selected.uri,
// Compared by identity, not raw string: a server that canonicalizes
// an escape has answered for the same resource, and calling that
// "a different URI" would be the tool disagreeing with the read
// path, which accepts exactly that equivalence.
wrongUri:
skillUriIdentity(entry.uri) !== skillUriIdentity(selected.uri),
// Compared semantically — see `skillEntriesMatch` for why a
// `JSON.stringify` comparison would report key order and manifest
// order as differences.

View file

@ -45,6 +45,21 @@ describe("InspectorClient skills methods (#2234)", () => {
);
}
/**
* A structural view onto two private fields, so the tests can stub the SDK
* client and set `capabilities` without connecting.
*
* The double cast is justified rather than incidental: `InspectorClient`
* declares both members `private`, so no single `as` relates it to a type
* that exposes them, and there is no public setter for either — the public
* path is `connect()`, which needs a transport, a live server and a
* handshake to reach the same state. It is safe because the shape asserted
* here is exactly the shape the class declares (`client` is the SDK client;
* `capabilities` is `ServerCapabilities | undefined`), so a rename or a type
* change on either field breaks these tests at the first use rather than
* silently passing. The same seam is used by
* `inspectorClient-raw-wire.test.ts`.
*/
function internals(client: InspectorClient): SkillsInternals {
return client as unknown as SkillsInternals;
}

View file

@ -11,6 +11,7 @@ import {
isSkillsExtensionSupported,
normalizeSkillUri,
skillEntriesMatch,
skillUriIdentity,
sha256Digest,
skillDisplayName,
skillNameFromUri,
@ -191,6 +192,21 @@ describe("normalizeSkillUri", () => {
});
});
describe("skillUriIdentity", () => {
it("is the normalized form when the URI parses", () => {
expect(skillUriIdentity("skill://demo/a/../SKILL.md")).toBe(
"skill://demo/SKILL.md",
);
});
it("falls back to the raw string, keeping two bad URIs distinct", () => {
expect(skillUriIdentity("not a uri")).toBe("not a uri");
expect(skillUriIdentity("not a uri")).not.toBe(
skillUriIdentity("also not a uri"),
);
});
});
describe("skillEntriesMatch", () => {
const base = (): SkillEntry => ({
uri: "skill://demo/SKILL.md",
@ -237,6 +253,20 @@ describe("skillEntriesMatch", () => {
).toBe(true);
});
it("treats RFC-equivalent URI spellings as the same entry", () => {
// A server that canonicalizes an escape between the listing and the fetch
// has not changed the skill, so it must not read as a new snapshot.
const encoded: SkillEntry = {
...base(),
uri: "skill://demo/%53KILL.md",
resources: [
{ uri: "skill://demo/%53KILL.md", digest: DIGEST, size: 20 },
{ uri: "skill://demo/x/../ref.md", digest: DIGEST, size: 10 },
],
};
expect(skillEntriesMatch(base(), encoded)).toBe(true);
});
it("still sees a real difference", () => {
expect(
skillEntriesMatch(base(), {
@ -307,6 +337,16 @@ describe("checkSkillConformance", () => {
}
});
it("applies the name grammar to the RAW value, not a trimmed copy", () => {
// Trimming first would let `" demo "` through — and whitespace is not in
// the grammar, so the entry would report "Conforms" with a name that can
// never equal its URI path segment.
const issues = checkSkillConformance(
entry({ frontmatter: { name: " demo ", description: "d" } }),
);
expect(issues.map((i) => i.code)).toContain("malformed-name");
});
it("accepts the names the Agent Skills format allows", () => {
for (const name of ["a", "demo", "data-analysis", "a1-b2-c3"]) {
const issues = checkSkillConformance(
@ -330,6 +370,21 @@ describe("checkSkillConformance", () => {
expect(issues.map((i) => i.code)).toEqual(["missing-name"]);
});
it("reports a description above the 1024-character limit", () => {
const issues = checkSkillConformance(
entry({ frontmatter: { name: "demo", description: "d".repeat(1025) } }),
);
expect(issues.map((i) => i.code)).toEqual(["malformed-description"]);
expect(issues[0].severity).toBe("error");
});
it("accepts a description exactly at the limit", () => {
const issues = checkSkillConformance(
entry({ frontmatter: { name: "demo", description: "d".repeat(1024) } }),
);
expect(issues).toEqual([]);
});
it("reports a missing description as an error", () => {
// SEP-2640 requires `description`, so an absent one is a format violation
// and must not read as "0 errors" in the conformance summary.
@ -388,6 +443,20 @@ describe("checkSkillConformance", () => {
expect(issues[0].resourceUri).toBe("skill://demo/ref.md");
});
it("accepts a self-entry listed in an RFC-equivalent spelling", () => {
// The manifest names the same file the entry does, and it is fetchable as
// that file — reporting it missing would be the tool disagreeing with
// itself about which URIs are the same resource.
const issues = checkSkillConformance(
entry({
resources: [
{ uri: "skill://demo/%53KILL.md", digest: DIGEST, size: 20 },
],
}),
);
expect(issues.map((i) => i.code)).not.toContain("manifest-missing-self");
});
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.