diff --git a/clients/tui/__tests__/SkillsTab.test.tsx b/clients/tui/__tests__/SkillsTab.test.tsx
index e4b44f8c..e8cf89ee 100644
--- a/clients/tui/__tests__/SkillsTab.test.tsx
+++ b/clients/tui/__tests__/SkillsTab.test.tsx
@@ -670,6 +670,88 @@ describe("SkillsTab (#2248)", () => {
expect(lastFrame() ?? "").toContain("skill://acme/reports/SKILL.md");
});
+ it("shows the digests for a mismatch, not just the failed mark", async () => {
+ // `verifySkillResource` sets `reason` for a SIZE mismatch but not a digest
+ // one, so a pane rendering only `reason` left a bare `✗` with no diagnosis
+ // — a failed verification the reader cannot act on (Copilot).
+ // The declared size must be RIGHT, or the cheaper size cross-check
+ // short-circuits before hashing and reports its own `reason` instead —
+ // which is the path that already rendered.
+ const digestOnly: SkillEntry = {
+ ...clean,
+ resources: [
+ {
+ uri: "skill://clean/SKILL.md",
+ digest: CLEAN_DIGEST,
+ size: textToBytes(SKILL_MD).byteLength,
+ },
+ ],
+ };
+ const { lastFrame, stdin } = render(
+ ,
+ );
+ stdin.write(ENTER);
+ await tick();
+ const frame = lastFrame() ?? "";
+ expect(frame).toContain("Verification FAILED");
+ // Truncated to keep the line inside a narrow pane; the CLI report carries
+ // the digests in full.
+ expect(frame).toMatch(/expected sha256:0+…/);
+ expect(frame).toMatch(/got sha256:[0-9a-f]+…/);
+ });
+
+ it("shows the reason for a size mismatch, which carries no digest", async () => {
+ // The other arm: a length disagreement fails before the hash, so there is
+ // no actual digest to print and the reason is the whole diagnosis.
+ const { lastFrame, stdin } = render(
+ ,
+ );
+ stdin.write(ENTER);
+ await tick();
+ expect(lastFrame() ?? "").toContain(
+ "Manifest declares 51 bytes but the fetched file is 52.",
+ );
+ });
+
+ it("shows a read failure the manifest does not cover", async () => {
+ // A dynamic skill has no manifest rows, so the synthetic read-error row
+ // `verifySkills` records for its own SKILL.md was rendered nowhere and the
+ // pane said only "Verification FAILED".
+ const { lastFrame, stdin } = render(
+ ,
+ );
+ stdin.write(ENTER);
+ await tick();
+ const frame = lastFrame() ?? "";
+ expect(frame).toContain("Read failures:");
+ expect(frame).toContain("SKILL.md");
+ expect(frame).toContain("upstream gone");
+ expect(frame).toContain("Verification FAILED");
+ });
+
it("shows the details footer only when the details pane is focused", () => {
const unfocused = render(
(d ? `${d.slice(0, 23)}…` : "—");
+ return `expected ${short(file.expectedDigest)}, got ${short(file.actualDigest)}`;
+}
+
/** The file name a manifest URI ends in, for a list that must fit 40 columns. */
function fileNameOf(uri: string): string {
const cut = uri.lastIndexOf("/");
@@ -254,6 +272,14 @@ export function SkillsTab({
selectedSkill && selectedSkill.resources !== DYNAMIC_RESOURCES
? selectedSkill.resources
: [];
+ // Compared on normalized identity, like every other URI comparison here, so a
+ // manifest entry written in an equivalent form is not reported twice.
+ const manifestIdentities = new Set(
+ manifest.map((resource) => skillUriIdentity(resource.uri)),
+ );
+ const extraReportFiles = (activeReport?.files ?? []).filter(
+ (file) => !manifestIdentities.has(skillUriIdentity(file.uri)),
+ );
return (
@@ -410,15 +436,48 @@ export function SkillsTab({
({resource.size} B)
) : null}
- {fileReport?.reason && (
+ {fileReport && failureDetail(fileReport) && (
- {fileReport.reason}
+ {failureDetail(fileReport)}
)}
);
})}
+ {/* A report can carry a file the MANIFEST does not — a dynamic
+ skill has no rows at all, yet a failed read of its own
+ SKILL.md is recorded so the failure is visible. Rendering only
+ manifest rows left "Verification FAILED" with no diagnosis
+ anywhere on screen (Copilot). */}
+ {extraReportFiles.length > 0 && (
+ <>
+
+ Read failures:
+
+ {extraReportFiles.map((file, idx) => (
+
+
+
+ {FILE_MARK[file.status] ?? "?"}{" "}
+
+ {fileNameOf(file.uri)}
+
+ {failureDetail(file) && (
+
+ {failureDetail(file)}
+
+ )}
+
+ ))}
+ >
+ )}
+
{activeReport && activeReport.frontmatter.length > 0 && (
<>
diff --git a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx
index 2431c2aa..39eae21a 100644
--- a/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx
+++ b/clients/web/src/components/screens/SkillsScreen/SkillsScreen.tsx
@@ -380,6 +380,26 @@ const ResourceNameCaption = Text.withProps({
maw: "50%",
});
+/** A read that failed, in the Directory section. */
+const ReadFailureAlert = Alert.withProps({
+ color: "red",
+ variant: "light",
+ title: "Read failed",
+});
+
+/**
+ * The directory-vs-manifest divergence banner. Yellow: the server is not
+ * necessarily wrong — its listing may simply be newer than the held entry.
+ */
+const UnlistedChildrenAlert = Alert.withProps({
+ color: "yellow",
+ variant: "light",
+ title: "This directory lists files the entry does not",
+});
+
+/** The em dash standing in for a verdict that does not apply to a row. */
+const NoVerdictText = Text.withProps({ size: "xs", c: "dimmed" });
+
const IssueStack = Stack.withProps({
gap: "xs",
});
@@ -1984,9 +2004,7 @@ export function SkillsScreen({
{directoryError !== undefined && (
-
- {directoryError}
-
+ {directoryError}
)}
{/* Stated in prose the first time the two views
disagree, because the per-row chip alone does not say
@@ -1994,12 +2012,7 @@ export function SkillsScreen({
re-approval" is what SEP-2640 asks a host to present
here, rather than a read error. */}
{unlistedChildren.length > 0 && (
-
+
The server is serving {unlistedChildren.length} file
{unlistedChildren.length === 1 ? "" : "s"} here that
the held skills/list entry does not
@@ -2009,7 +2022,7 @@ export function SkillsScreen({
verification failure equivalent to a digest mismatch.
Re-fetch the entry with skills/get to see
whether the skill has changed.
-
+
)}
{directoryChildren !== undefined &&
(directoryChildren.length === 0 ? (
@@ -2074,9 +2087,7 @@ export function SkillsScreen({
{child.mimeType ?? "—"}
{isDir || isDynamic ? (
-
- —
-
+ —
) : (
{
expect(parseSkillFrontmatter("# just a comment")).toEqual({ fields: {} });
});
+ it("reports an explicit null scalar as an error, not as no fields", () => {
+ // `null` and `~` parse to the same value an EMPTY block does, but only the
+ // empty one is a degenerate mapping — returning `{ fields: {} }` for an
+ // explicit null scalar contradicts this function's own contract (Copilot).
+ for (const src of ["null", "~", " null ", "# lead\nnull"]) {
+ expect(parseSkillFrontmatter(src)).toEqual({
+ error: expect.stringContaining("mapping"),
+ });
+ }
+ });
+
+ it("still reads a comment-only block as a mapping of no fields", () => {
+ // The distinction is made on the SOURCE, so this must not regress.
+ expect(parseSkillFrontmatter("# just a comment\n\n # another")).toEqual({
+ fields: {},
+ });
+ });
+
+ it("does not mistake a leading # inside a value for a comment", () => {
+ // Comments are stripped only at the start of a line; a `#` inside a value
+ // is part of it, and treating it as a comment would call a block with real
+ // content empty.
+ expect(parseSkillFrontmatter('name: "#hashtag"')).toEqual({
+ fields: { name: "#hashtag" },
+ });
+ });
+
it("reports a scalar block as an error rather than as no fields", () => {
// `just a string` parses successfully as a scalar. Reporting it as an
// empty mapping would present a malformed file as one that merely omitted
diff --git a/clients/web/src/test/core/mcp/skills.test.ts b/clients/web/src/test/core/mcp/skills.test.ts
index 551f86b3..67132a11 100644
--- a/clients/web/src/test/core/mcp/skills.test.ts
+++ b/clients/web/src/test/core/mcp/skills.test.ts
@@ -1005,6 +1005,29 @@ describe("checkSkillFrontmatterMatch (#2248)", () => {
expect(both[1].message).toContain("-Infinity");
});
+ it("cannot be fooled by a listing that looks like an encoding", () => {
+ // The regression this guards: encoding non-finite numbers as a sentinel
+ // object let a listing whose value genuinely WAS that object alias it and
+ // match a served `.nan` (Copilot). The comparison is structural now, so
+ // there is no encoding to alias.
+ const issues = checkSkillFrontmatterMatch(
+ entry({ x: { "#non-finite": "NaN" } }),
+ file("x: .nan"),
+ );
+ expect(issues).toHaveLength(1);
+ expect(issues[0].code).toBe("frontmatter-mismatch");
+ });
+
+ it("still matches a listing object that equals the served mapping", () => {
+ // …and the guard must not make a genuine agreement look like a difference.
+ expect(
+ checkSkillFrontmatterMatch(
+ entry({ x: { "#non-finite": "NaN" } }),
+ file('x:\n "#non-finite": NaN'),
+ ),
+ ).toEqual([]);
+ });
+
it("still matches a null the served file also writes as null", () => {
// The fix must not turn a genuine agreement into a finding.
expect(
diff --git a/core/mcp/skillFile.ts b/core/mcp/skillFile.ts
index c49b7fe0..297591b2 100644
--- a/core/mcp/skillFile.ts
+++ b/core/mcp/skillFile.ts
@@ -93,6 +93,21 @@ export type ParsedFrontmatter =
* everything. An *empty* block (`fields: {}`) is a different fact and is
* reported as a successful parse of nothing.
*/
+/**
+ * Whether a frontmatter block holds anything but whitespace and comments.
+ *
+ * Comments are stripped only from the start of a line: a `#` inside a value is
+ * part of that value, and treating it as a comment would call a block with real
+ * content empty. That is the safe direction — mistaking content for emptiness
+ * here would turn a malformed document back into "no fields", which is the bug
+ * this exists to prevent.
+ */
+function hasContent(yamlText: string): boolean {
+ return yamlText
+ .split("\n")
+ .some((line) => line.trim() !== "" && !line.trimStart().startsWith("#"));
+}
+
export function parseSkillFrontmatter(yamlText: string): ParsedFrontmatter {
let parsed: unknown;
try {
@@ -100,9 +115,16 @@ export function parseSkillFrontmatter(yamlText: string): ParsedFrontmatter {
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
- // `null` is what an empty (or comment-only) block parses to — a real, if
- // degenerate, mapping of no fields rather than a malformed one.
- if (parsed === null || parsed === undefined) return { fields: {} };
+ // An empty (or comment-only) block parses to `null`, and so does an explicit
+ // `null` / `~` scalar — but only the first is a degenerate mapping of no
+ // fields. The second is a non-mapping document, and returning `{ fields: {} }`
+ // for it contradicts this function's own contract (Copilot). They are told
+ // apart by the SOURCE, since the parsed value cannot distinguish them.
+ if (parsed === null || parsed === undefined) {
+ return hasContent(yamlText)
+ ? { error: "Frontmatter is not a YAML mapping of fields." }
+ : { fields: {} };
+ }
if (typeof parsed !== "object" || Array.isArray(parsed)) {
return {
error: "Frontmatter is not a YAML mapping of fields.",
diff --git a/core/mcp/skills.ts b/core/mcp/skills.ts
index be8d2748..d33847b9 100644
--- a/core/mcp/skills.ts
+++ b/core/mcp/skills.ts
@@ -561,16 +561,6 @@ function canonicalEntry(entry: SkillEntry): string {
/** Object keys sorted recursively; array ORDER is preserved throughout. */
function canonicalize(value: unknown): unknown {
- // YAML can express `.nan` and `.inf`; JSON cannot. `JSON.stringify` turns
- // every one of them into `null`, so without this a served `x: .nan` would
- // compare EQUAL to a listing declaring `x: null` — a mismatch silently
- // reported as agreement (Copilot). The listing side arrived over JSON-RPC and
- // can never hold a non-finite number, so one appearing here is always a real
- // difference. Rendered as an object, which cannot equal any JSON scalar, and
- // which names the value in the finding rather than hiding it.
- if (typeof value === "number" && !Number.isFinite(value)) {
- return { "#non-finite": String(value) };
- }
if (Array.isArray(value)) return value.map(canonicalize);
if (value === null || typeof value !== "object") return value;
return sortKeys(value as Record);
@@ -760,6 +750,65 @@ export async function verifySkillResource(
};
}
+/**
+ * Structural equality for JSON-like values, with YAML's extra scalars handled.
+ *
+ * ⚠️ **Comparison is structural rather than serialized, and that is the point.**
+ * `JSON.stringify` is not injective over what a YAML parser produces: `.nan`,
+ * `.inf` and `-.inf` all serialize to `null`, so a served `x: .nan` compared
+ * EQUAL to a listing declaring `x: null` — and to each other. An earlier fix
+ * encoded non-finite numbers as a sentinel object, which merely moved the
+ * problem: a listing whose value genuinely *was* that object aliased the
+ * sentinel and matched a served `.nan` (Copilot). Any encoding into the value
+ * space can be aliased by a document containing the encoding, so there is no
+ * sentinel here at all.
+ *
+ * `Object.is` on the number path is what makes it work: it holds `NaN` equal to
+ * `NaN`, keeps `Infinity` and `-Infinity` distinct, and never equates either
+ * with `null`.
+ */
+function jsonLikeEqual(a: unknown, b: unknown): boolean {
+ if (typeof a === "number" || typeof b === "number") return Object.is(a, b);
+ if (a === null || b === null) return a === b;
+ if (typeof a !== "object" || typeof b !== "object") return Object.is(a, b);
+ const aArray = Array.isArray(a);
+ if (aArray !== Array.isArray(b)) return false;
+ if (aArray) {
+ const x = a as unknown[];
+ const y = b as unknown[];
+ // Array ORDER is significant — a YAML sequence is ordered.
+ return x.length === y.length && x.every((v, i) => jsonLikeEqual(v, y[i]));
+ }
+ const x = a as Record;
+ const y = b as Record;
+ const keys = Object.keys(x);
+ // Key order is not meaningful in either JSON or YAML, so only the key SET and
+ // the values matter.
+ return (
+ keys.length === Object.keys(y).length &&
+ keys.every((k) => Object.hasOwn(y, k) && jsonLikeEqual(x[k], y[k]))
+ );
+}
+
+/**
+ * A frontmatter value as it should READ in a finding.
+ *
+ * `JSON.stringify` renders every non-finite number as `null`, which would print
+ * "the listing says null but the served file says null" for a real difference.
+ * Only the display is special-cased; the comparison above never goes through a
+ * string, so this cannot reintroduce an aliasing bug.
+ */
+function displayValue(value: unknown): string {
+ if (typeof value === "number" && !Number.isFinite(value)) {
+ return String(value);
+ }
+ return JSON.stringify(canonicalize(value), (_key, member: unknown) =>
+ typeof member === "number" && !Number.isFinite(member)
+ ? `<${String(member)}>`
+ : member,
+ );
+}
+
/**
* Compare the fetched `SKILL.md`'s own frontmatter against the frontmatter the
* entry advertised, field by field — the SEP-2640 obligation a digest cannot
@@ -848,13 +897,11 @@ export function checkSkillFrontmatterMatch(
});
continue;
}
- const listedJson = JSON.stringify(canonicalize(listed));
- const servedJson = JSON.stringify(canonicalize(served));
- if (listedJson !== servedJson) {
+ if (!jsonLikeEqual(listed, served)) {
issues.push({
code: "frontmatter-mismatch",
severity: "error",
- message: `Field "${field}" differs: the listing says ${listedJson} but the served SKILL.md says ${servedJson}.`,
+ message: `Field "${field}" differs: the listing says ${displayValue(listed)} but the served SKILL.md says ${displayValue(served)}.`,
resourceUri: entry.uri,
});
}
diff --git a/core/mcp/skillsVerification.ts b/core/mcp/skillsVerification.ts
index 84e0fdce..6bb2f96d 100644
--- a/core/mcp/skillsVerification.ts
+++ b/core/mcp/skillsVerification.ts
@@ -67,7 +67,16 @@ export interface SkillVerifyReport {
* frontmatter discrepancy.
*/
frontmatter: SkillIssue[];
- /** One entry per manifest file, in manifest order. Empty for `"dynamic"`. */
+ /**
+ * One entry per manifest file, in manifest order.
+ *
+ * ⚠️ **Not necessarily empty for a `"dynamic"` skill.** Such a skill has no
+ * manifest rows, but a failed read of its own `SKILL.md` — the file the
+ * mandatory frontmatter comparison needs — is recorded here as a synthetic
+ * `read-error` row against the entry's URI, so the failure is visible and
+ * fails the report rather than passing silently. A consumer must not assume
+ * `files` mirrors the manifest one-for-one (Copilot).
+ */
files: SkillFileReport[];
/**
* False when anything the SEP makes a MUST was broken: an error-severity