fix: address Copilot review round 10 on #2251
- skills.ts: validate `frontmatter.name` against the Agent Skills format (1–64 lowercase alphanumerics and hyphens, no leading, trailing or consecutive hyphen). Checking only for non-emptiness let `Bad Name` read as "Conforms" — and the name is not decorative: it must equal the URI path segment, so a name that cannot appear in a URI is a contradiction the entry cannot satisfy. Suppressed when the name is absent entirely, which `missing-name` already reports. - skills.ts: detect duplicates on the NORMALIZED identity. Containment and the read that fetches the bytes both treat normalized-equivalents as one resource, so `skill://demo/SKILL.md` and `skill://demo/x/../SKILL.md` were passing as two distinct files while naming one. The finding still reports the raw URI, and two different unparseable URIs stay distinct. - SkillsScreen: run `checkSkillConformance` on the fetched `skills/get` entry and treat an error — or an entry answering for a different URI — as invalid (red), not as "a different snapshot" (yellow). A fresh point-in-time read excuses a CHANGE; it does not excuse a violation, and answering with another skill is never a refresh of the one requested. 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
c1a7cfb092
commit
9d162f36c6
2 changed files with 208 additions and 18 deletions
|
|
@ -501,6 +501,39 @@ describe("SkillsScreen", () => {
|
|||
expect(screen.getByTestId("skills-get-result")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls a non-conforming skills/get entry invalid, not a new snapshot", async () => {
|
||||
// A fresh snapshot excuses a CHANGE; it does not excuse a violation. An
|
||||
// entry missing a digest is invalid whether or not the skill moved on.
|
||||
const user = userEvent.setup();
|
||||
const onGetSkill = vi.fn().mockResolvedValue({
|
||||
...CLEAN_SKILL,
|
||||
resources: [{ uri: "skill://data-analysis/SKILL.md" }],
|
||||
});
|
||||
renderWithMantine(<ControlledSkillsScreen onGetSkill={onGetSkill} />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /Fetch with skills\/get/ }),
|
||||
);
|
||||
const result = await screen.findByTestId("skills-get-result");
|
||||
expect(result).toHaveAttribute("data-verdict", "invalid");
|
||||
expect(result).toHaveTextContent("missing-digest");
|
||||
});
|
||||
|
||||
it("calls a skills/get answer for a different uri invalid", async () => {
|
||||
// Answering with another skill is never a valid refresh of the one asked
|
||||
// for, however much that other skill may have changed.
|
||||
const user = userEvent.setup();
|
||||
const onGetSkill = vi.fn().mockResolvedValue(TAMPERED_SKILL);
|
||||
renderWithMantine(<ControlledSkillsScreen onGetSkill={onGetSkill} />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /Fetch with skills\/get/ }),
|
||||
);
|
||||
const result = await screen.findByTestId("skills-get-result");
|
||||
expect(result).toHaveAttribute("data-verdict", "invalid");
|
||||
expect(result).toHaveTextContent("different URI");
|
||||
});
|
||||
|
||||
it("reports a failed skills/get", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onGetSkill = vi.fn().mockRejectedValue(new Error("-32602"));
|
||||
|
|
|
|||
|
|
@ -97,7 +97,12 @@ interface FetchedEntryState {
|
|||
/** The click this result belongs to — see {@link PreviewState.attempt}. */
|
||||
attempt?: number;
|
||||
entry?: SkillEntry;
|
||||
/** Conformance findings for the FETCHED entry, in its own right. */
|
||||
issues?: SkillIssue[];
|
||||
/** True when the fetched entry describes the same skill as the listed one. */
|
||||
matches?: boolean;
|
||||
/** True when the fetched entry is for a different URI than was asked for. */
|
||||
wrongUri?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
|
|
@ -530,10 +535,21 @@ export function SkillsScreen({
|
|||
);
|
||||
void onGetSkill(selected.uri)
|
||||
.then((entry) => {
|
||||
// Compared semantically against what `skills/list` advertised — see
|
||||
// `skillEntriesMatch` for why a `JSON.stringify` comparison would
|
||||
// report key order and manifest order as differences.
|
||||
writeFetched({ entry, matches: skillEntriesMatch(entry, selected) });
|
||||
// The fetched entry is checked ON ITS OWN before being compared. A
|
||||
// snapshot is allowed to have moved on, but it is not allowed to be
|
||||
// non-conforming: an entry missing a digest is invalid whether or not
|
||||
// the skill changed, and an entry for a DIFFERENT uri is never a valid
|
||||
// refresh of the one that was asked for. Only a conforming entry with
|
||||
// the same identity gets the benign "the snapshot moved" reading.
|
||||
writeFetched({
|
||||
entry,
|
||||
issues: checkSkillConformance(entry),
|
||||
wrongUri: entry.uri !== selected.uri,
|
||||
// Compared semantically — see `skillEntriesMatch` for why a
|
||||
// `JSON.stringify` comparison would report key order and manifest
|
||||
// order as differences.
|
||||
matches: skillEntriesMatch(entry, selected),
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
writeFetched({
|
||||
|
|
@ -543,6 +559,16 @@ export function SkillsScreen({
|
|||
}, [manifestKey, onGetSkill, selected]);
|
||||
|
||||
const fetched = fetchedEntry.key === manifestKey ? fetchedEntry : undefined;
|
||||
// `invalid` outranks the snapshot comparison: an entry that breaks a
|
||||
// requirement, or answers for a different URI, is wrong regardless of
|
||||
// whether the skill it describes has changed since the listing.
|
||||
const fetchedVerdict =
|
||||
fetched?.wrongUri ||
|
||||
(fetched?.issues ?? []).some((issue) => issue.severity === "error")
|
||||
? "invalid"
|
||||
: fetched?.matches
|
||||
? "matches"
|
||||
: "differs";
|
||||
const batchRunning = batches.has(manifestKey);
|
||||
|
||||
const preview =
|
||||
|
|
@ -810,24 +836,45 @@ export function SkillsScreen({
|
|||
{fetched?.entry !== undefined && (
|
||||
<Alert
|
||||
data-testid="skills-get-result"
|
||||
// Yellow, not red: `skills/get` is a fresh point-in-time
|
||||
// snapshot, so a skill that genuinely changed since the
|
||||
// listing legitimately differs. Worth showing, not an
|
||||
// accusation.
|
||||
color={fetched.matches ? "green" : "yellow"}
|
||||
data-verdict={fetchedVerdict}
|
||||
// Red only for a genuine violation — a non-conforming entry,
|
||||
// or one answering with a different URI, neither of which a
|
||||
// fresh snapshot excuses. A conforming entry that merely
|
||||
// moved on is yellow: `skills/get` IS a point-in-time read,
|
||||
// so a changed skill legitimately differs.
|
||||
color={
|
||||
fetchedVerdict === "invalid"
|
||||
? "red"
|
||||
: fetchedVerdict === "matches"
|
||||
? "green"
|
||||
: "yellow"
|
||||
}
|
||||
title={
|
||||
fetched.matches
|
||||
? "skills/get matches skills/list"
|
||||
: "skills/get returned a different snapshot"
|
||||
fetchedVerdict === "invalid"
|
||||
? "skills/get returned a non-conforming entry"
|
||||
: fetchedVerdict === "matches"
|
||||
? "skills/get matches skills/list"
|
||||
: "skills/get returned a different snapshot"
|
||||
}
|
||||
>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm">
|
||||
{fetched.matches
|
||||
? "The entry this server returns for this URI describes the same skill it listed (compared ignoring key and manifest order)."
|
||||
: "The entry this server returns for this URI differs from the one it listed. `skills/get` is a fresh snapshot, so this is expected if the skill changed since the list was fetched — and a server inconsistency if it did not."}
|
||||
{fetchedVerdict === "invalid"
|
||||
? fetched.wrongUri
|
||||
? "This entry is for a different URI than the one requested, which is never a valid refresh of it."
|
||||
: "This entry breaks a requirement of its own, so the difference is not simply a newer snapshot."
|
||||
: fetchedVerdict === "matches"
|
||||
? "The entry this server returns for this URI describes the same skill it listed (compared ignoring key and manifest order)."
|
||||
: "The entry this server returns for this URI differs from the one it listed. `skills/get` is a fresh snapshot, so this is expected if the skill changed since the list was fetched — and a server inconsistency if it did not."}
|
||||
</Text>
|
||||
{!fetched.matches && (
|
||||
{(fetched.issues ?? [])
|
||||
.filter((issue) => issue.severity === "error")
|
||||
.map((issue, index) => (
|
||||
<MonoCaption key={`${index}:${issue.code}`}>
|
||||
{issue.code}: {issue.message}
|
||||
</MonoCaption>
|
||||
))}
|
||||
{fetchedVerdict !== "matches" && (
|
||||
<ContentViewer
|
||||
block={{
|
||||
type: "text",
|
||||
|
|
|
|||
Loading…
Reference in a new issue