fix: address Copilot review round 2 on #2251
- SkillsScreen: key verdicts and React elements by manifest ROW INDEX,
not URI. The checker deliberately tolerates a duplicated URI so it can
report `duplicate-resource`; a URI key collided those rows into one
verdict, so verifying either updated both and "Verify all" raced two
different digest/size declarations into the same slot.
- SkillsScreen: hold the invalidation generation in React state keyed by
the manifest, not a ref bumped during render. `useValueChange` runs its
callback during render and requires setState-only purity — an abandoned
render left the ref incremented and silently discarded a live
verification.
- SkillsScreen: title a size disagreement "Size mismatch" and render its
reason. `verifySkillResource` catches it before hashing, so the alert
was showing "actual undefined" under "Digest mismatch".
- SkillsScreen: pass `contents` to ContentViewer so a base64 SKILL.md
renders. The text-block form substituted "" and painted a blank preview
for a file verification had just read correctly.
- skills.ts: add `malformed-size` for a size that is not a non-negative
safe integer, and exclude such values from the 16 MiB total. A negative
one could pull the sum back under the limit and hide a violation.
- managedSkillsState: make the overlap guard per-session instead of a
boolean. A disconnect during an in-flight walk left it set, so the
reconnect's own load no-oped and was never retried — permanently, if
the stale request never settled. A stale `finally` can no longer clear
the live session's guard either.
- skillsSchemas: require the `{ skill }` envelope for `skills/get`. The
accepted SEP settles it, and normalizing an inline entry would let a
non-conforming response past the one place that could report it.
- #2248 and the PR description: corrected — they said
`ReadResourceDirectoryResultSchema` was already declared, which round 1
removed. #2248 now owns defining it, and records the frontmatter
cross-check gap too.
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
da8cc166ce
commit
bfeebd440d
4 changed files with 406 additions and 143 deletions
|
|
@ -5577,9 +5577,10 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
}
|
||||
|
||||
/**
|
||||
* One skill entry by URI (`skills/get`, SEP-2640). The SEP settles the entry
|
||||
* shape but not the envelope around it, so the result is normalized through
|
||||
* {@link normalizeGetSkillResult} rather than assuming one form.
|
||||
* One skill entry by URI (`skills/get`, SEP-2640). The result envelope is
|
||||
* required — `GetSkillResultSchema` unwraps `{ skill }` and rejects an entry
|
||||
* returned inline, so a non-conforming shape fails here rather than being
|
||||
* silently normalized past the conformance checks.
|
||||
*/
|
||||
async getSkill(uri: string, metadata?: RequestMetadata): Promise<SkillEntry> {
|
||||
if (!this.client) {
|
||||
|
|
@ -5590,8 +5591,8 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
uri,
|
||||
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
|
||||
};
|
||||
// `GetSkillResultSchema` normalizes both accepted envelopes to the entry,
|
||||
// so there is nothing to unwrap here.
|
||||
// `GetSkillResultSchema` unwraps the envelope, so there is nothing to
|
||||
// unwrap here.
|
||||
return this.invokeMcpClient(
|
||||
() =>
|
||||
this.client!.request(
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ export type SkillIssueCode =
|
|||
| "missing-digest"
|
||||
| "malformed-digest"
|
||||
| "missing-size"
|
||||
| "malformed-size"
|
||||
| "duplicate-resource"
|
||||
| "resource-outside-skill-root"
|
||||
| "manifest-missing-self"
|
||||
|
|
@ -288,6 +289,13 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
|
|||
"Manifest entry declares no size, so it is excluded from the 16 MiB total and its length cannot be cross-checked.",
|
||||
resourceUri: resource.uri,
|
||||
});
|
||||
} else if (!isUsableSize(resource.size)) {
|
||||
issues.push({
|
||||
code: "malformed-size",
|
||||
severity: "error",
|
||||
message: `Size ${resource.size} is not a non-negative integer byte length.`,
|
||||
resourceUri: resource.uri,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -295,13 +303,28 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
|
|||
}
|
||||
|
||||
/**
|
||||
* Sum of the manifest's declared `size` fields. An entry that omits `size`
|
||||
* contributes nothing rather than failing the sum — the limit check is about
|
||||
* catching a server that is demonstrably over, and an incomplete manifest can
|
||||
* only ever understate the total, so this never produces a false positive.
|
||||
* Whether a declared `size` is a usable byte length. SEP-2640 defines it as the
|
||||
* raw byte count, so anything that is not a non-negative safe integer is
|
||||
* nonsense — and a *negative* one is worse than nonsense, because summing it
|
||||
* would pull the manifest total back under the 16 MiB limit and hide a
|
||||
* violation. Reported as `malformed-size` and excluded from the sum.
|
||||
*/
|
||||
function isUsableSize(size: number | undefined): size is number {
|
||||
return size !== undefined && Number.isSafeInteger(size) && size >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum of the manifest's declared `size` fields. An entry that omits `size` — or
|
||||
* declares an unusable one — contributes nothing rather than failing the sum:
|
||||
* the limit check is about catching a server that is demonstrably over, and an
|
||||
* incomplete manifest can only ever understate the total, so this never
|
||||
* produces a false positive.
|
||||
*/
|
||||
export function totalSkillBytes(resources: readonly SkillResource[]): number {
|
||||
return resources.reduce((sum, r) => sum + (r.size ?? 0), 0);
|
||||
return resources.reduce(
|
||||
(sum, r) => sum + (isUsableSize(r.size) ? r.size : 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/** Outcome of comparing a fetched file against its advertised digest. */
|
||||
|
|
|
|||
|
|
@ -105,39 +105,26 @@ export const ListSkillsResultSchema = z.looseObject({
|
|||
export type ListSkillsResult = z.infer<typeof ListSkillsResultSchema>;
|
||||
|
||||
/**
|
||||
* The envelope form of a `skills/get` result: the entry wrapped under `skill`.
|
||||
* The `skills/get` result envelope: the entry wrapped under `skill`.
|
||||
*
|
||||
* Required, not one of two accepted shapes. An earlier revision of this module
|
||||
* also accepted a bare entry at the top level, on the reading that the SEP
|
||||
* settled the entry but not its wrapper. It does settle the wrapper, and
|
||||
* accepting the inline form would silently normalize a non-conforming response
|
||||
* — which is exactly the failure this extension's support exists to *report*.
|
||||
* A server that returns the entry inline now fails the parse, loudly.
|
||||
*/
|
||||
const GetSkillEnvelopeSchema = z.looseObject({ skill: SkillEntrySchema });
|
||||
|
||||
/**
|
||||
* Collapse either accepted `skills/get` shape to the entry it carries.
|
||||
* `skills/get` result, unwrapped to the entry it carries.
|
||||
*
|
||||
* Written as a parse rather than an `in` check because both accepted shapes are
|
||||
* loose objects — they carry an index signature, so `"skill" in result` narrows
|
||||
* nothing and would leave the extracted value `unknown`. Parsing the envelope
|
||||
* is what proves its `skill` really is an entry, with no cast anywhere.
|
||||
* The transform means every caller receives a `SkillEntry` and none of them
|
||||
* reaches into the envelope; the strictness lives in the schema.
|
||||
*/
|
||||
export function normalizeGetSkillResult(result: unknown): SkillEntry {
|
||||
const enveloped = GetSkillEnvelopeSchema.safeParse(result);
|
||||
return enveloped.success
|
||||
? enveloped.data.skill
|
||||
: SkillEntrySchema.parse(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* `skills/get` result, normalized to the entry.
|
||||
*
|
||||
* ⚠️ The SEP settles the *entry* shape but not the envelope this result wraps it
|
||||
* in, so both plausible forms are accepted: `{ skill: <entry> }` and the entry
|
||||
* returned inline at the top level. Being permissive here costs nothing (the
|
||||
* two are structurally distinguishable — an inline entry carries `uri` and
|
||||
* `frontmatter`, the envelope carries neither) and spares a server author a
|
||||
* failure whose cause is a spec ambiguity rather than their code. The transform
|
||||
* means every caller receives the entry and none of them branches.
|
||||
*/
|
||||
export const GetSkillResultSchema = z
|
||||
.union([GetSkillEnvelopeSchema, SkillEntrySchema])
|
||||
.transform(normalizeGetSkillResult);
|
||||
export const GetSkillResultSchema = GetSkillEnvelopeSchema.transform(
|
||||
(result) => result.skill,
|
||||
);
|
||||
|
||||
export type GetSkillResult = SkillEntry;
|
||||
|
||||
|
|
|
|||
|
|
@ -63,9 +63,9 @@ export class ManagedSkillsState extends TypedEventTarget<ManagedSkillsStateEvent
|
|||
private error: Error | null = null;
|
||||
private client: InspectorClientProtocol | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
// Overlap guard: a walk in flight makes a second one a no-op so a slow older
|
||||
// walk can't clobber a newer list via last-write-wins.
|
||||
private running = false;
|
||||
// Overlap guard, held as the generation whose walk is in flight (or `null`).
|
||||
// See `refresh` for why this is not a boolean.
|
||||
private runningGeneration: number | null = null;
|
||||
// Session counter, advanced by `reset` (disconnect) and by `destroy`. A walk
|
||||
// captures it and abandons its writes when it no longer matches.
|
||||
private generation = 0;
|
||||
|
|
@ -151,8 +151,6 @@ export class ManagedSkillsState extends TypedEventTarget<ManagedSkillsStateEvent
|
|||
this.applyPages([], 0);
|
||||
return this.getSkills();
|
||||
}
|
||||
if (this.running) return this.getSkills();
|
||||
this.running = true;
|
||||
// The session this walk belongs to. A disconnect, a `destroy()`, or a
|
||||
// reconnect on the same client advances it, and every write below is
|
||||
// gated on it still being current — otherwise a slow walk's continuation
|
||||
|
|
@ -160,6 +158,14 @@ export class ManagedSkillsState extends TypedEventTarget<ManagedSkillsStateEvent
|
|||
// previous session's skills into the next one.
|
||||
const generation = this.generation;
|
||||
const isCurrent = () => this.generation === generation;
|
||||
// The overlap guard is per SESSION, not a bare boolean. A boolean would
|
||||
// stay set while a walk from a dead session was still awaiting the server,
|
||||
// so the reconnect's own load would return here as a no-op and never be
|
||||
// retried — permanently, if that stale request never settles. Keying it on
|
||||
// the generation lets a new session start immediately, and the `finally`
|
||||
// below only clears the guard it actually set.
|
||||
if (this.runningGeneration === generation) return this.getSkills();
|
||||
this.runningGeneration = generation;
|
||||
try {
|
||||
const collected: SkillEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
|
@ -199,7 +205,11 @@ export class ManagedSkillsState extends TypedEventTarget<ManagedSkillsStateEvent
|
|||
}
|
||||
throw err;
|
||||
} finally {
|
||||
this.running = false;
|
||||
// Only the walk that set the guard may clear it: a stale session's
|
||||
// `finally` must not release the guard a live session is holding.
|
||||
if (this.runningGeneration === generation) {
|
||||
this.runningGeneration = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue