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:
parent
18724b2338
commit
bd28a36f23
3 changed files with 146 additions and 8 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue