fix: address Copilot review round 5 on #2251

Two of these reverse round 4, which asked for the opposite. Where the
readings conflict I took the permissive one: a wrong tightening rejects
conforming servers, a wrong loosening only fails to report something.

- skills.ts: stop requiring the `skill:` scheme. SEP-2640 says a server
  SHOULD use `skill://` and explicitly allows a domain-native scheme, so
  round 4's requirement handed a conforming `github://…` skill a false
  `malformed-uri` and skipped its name and root checks. Containment is
  scheme-independent — it compares against THIS entry's own root — so the
  traversal fix that requirement came packaged with is unaffected.
- skills.ts: the 512-entry and 16 MiB limits are warnings. Both are
  SHOULD NOTs for a server and MAYs for a host, so calling them errors
  contradicted the "error = a MUST was broken" contract round 4
  established and told authors a permitted skill was invalid.
- skills.ts: `skillEntriesMatch` compares canonically — keys sorted,
  manifest sorted by URI. `JSON.stringify` treated key order and manifest
  order as differences, though neither carries meaning.
- SkillsScreen: a differing `skills/get` is "a different snapshot"
  (yellow), not a disagreement (red). The SEP defines it as a fresh
  point-in-time read, so a skill that changed since the listing
  legitimately differs.
- useServerCommands: route `onGetSkill` through the command-scoped OAuth
  recovery like every other server command. An expired authorization
  rendered an error and stopped there — no reauthorization, no retry.
- test-servers/skills.ts: stamp the modern base result envelope
  (`resultType` / `ttlMs` / `cacheScope`) on every skills result. The SDK
  stamps it only for methods in its own codec, and `skills/*` are
  consumer-owned, so a 2026-era connection was receiving results without
  it. Unconditional rather than per era: the modern leg builds a server
  per request, so there is no era to branch on at registration time, and
  on legacy they are three members no codec inspects.
- composable-test-server: remove the `skills.directoryRead` option. It was
  publicly settable and advertised a method nothing serves, producing the
  exact false capability the fixture exists to help catch. `skills` is now
  a plain boolean; the flag returns in phase 3 (#2248) with its handler.

Still not changed: whether a modern `skills/list` RESULT SCHEMA must
require that envelope. Stamping it server-side is free; requiring it
client-side would reject servers on a reading nobody has checked against
the normative text. #2248 owns settling it.

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 23:11:46 -04:00
commit 754ce5bd95

View file

@ -107,16 +107,18 @@ describe("skillNameFromUri", () => {
);
});
it("reads the name off a domain-native scheme too", () => {
expect(skillNameFromUri("github://acme/repo/data-analysis/SKILL.md")).toBe(
"data-analysis",
);
});
it("returns undefined for a URI that does not end in /SKILL.md", () => {
expect(skillNameFromUri("skill://demo/other.md")).toBeUndefined();
// The suffix must include the separator: a bare "SKILL.md" has no segment.
expect(skillNameFromUri("SKILL.md")).toBeUndefined();
});
it("returns undefined for a non-skill scheme", () => {
expect(skillNameFromUri("https://demo/SKILL.md")).toBeUndefined();
});
it("returns undefined for a relative string", () => {
// SEP-2640 requires a full resource URI; treating `demo/SKILL.md` as one
// would let a non-conforming entry report a name and pass the path check.
@ -145,12 +147,16 @@ describe("normalizeSkillUri", () => {
expect(normalizeSkillUri("demo/SKILL.md")).toBeUndefined();
});
it("rejects a non-skill scheme", () => {
// Checking only that a URI is hierarchical would let this through and then
// pass the name and root checks — a manifest pointing anywhere on the web,
// reported as conforming.
expect(normalizeSkillUri("https://demo/SKILL.md")).toBeUndefined();
expect(normalizeSkillUri("file:///demo/SKILL.md")).toBeUndefined();
it("does not privilege the skill: scheme", () => {
// SEP-2640 only says a server SHOULD use `skill://`, and explicitly allows
// a domain-native scheme — so rejecting one would hand a conforming server
// a false `malformed-uri` and skip its name and root checks.
expect(normalizeSkillUri("github://acme/repo/SKILL.md")).toBe(
"github://acme/repo/SKILL.md",
);
expect(normalizeSkillUri("https://demo/a/../SKILL.md")).toBe(
"https://demo/SKILL.md",
);
});
it("rejects an opaque-path URI, which the parser does not normalize", () => {
@ -406,10 +412,13 @@ describe("checkSkillConformance", () => {
],
}),
);
expect(issues.map((i) => i.code)).toContain("size-limit-exceeded");
const finding = issues.find((i) => i.code === "size-limit-exceeded");
expect(finding?.severity).toBe("warning");
});
it("reports a manifest over the 512-entry limit", () => {
// Both limits are SHOULD NOTs for a server and MAYs for a host, so exceeding
// one makes a skill less portable rather than invalid.
it("reports a manifest over the 512-entry limit as a warning", () => {
const resources = [
{ uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
...Array.from({ length: SKILL_MAX_RESOURCE_ENTRIES }, (_unused, i) => ({
@ -419,10 +428,11 @@ describe("checkSkillConformance", () => {
})),
];
const issues = checkSkillConformance(entry({ resources }));
expect(issues.map((i) => i.code)).toContain("resource-limit-exceeded");
const finding = issues.find((i) => i.code === "resource-limit-exceeded");
expect(finding?.severity).toBe("warning");
});
it("reports a manifest over the 16 MiB limit", () => {
it("reports a manifest over the 16 MiB limit as a warning", () => {
const issues = checkSkillConformance(
entry({
resources: [