fix: address Copilot review round 3 on #2251
- skills.ts: decide root containment on NORMALIZED URIs. A raw prefix check passed `skill://acme/billing/refunds/../other.md`, which starts with the advertised root but resolves outside it — a traversal the conformance report was reporting as clean. - skills.ts: `normalizeSkillUri` also rejects a relative string, so `demo/SKILL.md` is `malformed-uri` rather than a skill path, and an opaque-path URI (`skill:demo/..`), which the parser leaves un-normalized and on which containment cannot be decided. - skills.ts: `missing-description` is an error. SEP-2640 requires it, so an absent one must not read as "0 errors" in the conformance summary. - managedSkillsState: call `markResponseRejected` for a decode rejection, as every managed list does. An invalid `skills/list` result was showing in the Protocol tab as a clean success. - SkillsScreen: per-row attempt token. The manifest key cannot tell two verifications of the SAME row apart, so a double click (or a row button pressed during "Verify all") let an older read finish last and overwrite the newer verdict. - SkillsScreen: disable "Verify all" while a batch runs. The concurrency cap is per invocation, so repeated clicks stacked pools — 4, then 8, then 12. - SkillsScreen: include the finding index in each issue alert's key. Three identical URIs produce two `duplicate-resource` findings with the same code and URI, and React was free to drop the extras — hiding findings in exactly the malformed input this view exists to inspect. - test-servers/skills.ts: register `skills/list` and `skills/get` through the PUBLIC `setRequestHandler` with explicit param schemas. The private `_requestHandlers` map is now reached only to wrap `resources/read`, which has to chain onto the SDK's handler rather than replace it — the one thing the public API cannot express, and the comment now says so instead of citing the tasks fixture. - test-servers/skills.ts: raise `-32602` for an unknown `skills/get` URI. A plain Error mapped to a generic server failure, making the fixture non-conforming outside its three documented bad cases. - clients/web/README.md: the paragraph named four contracts while still saying the smoke "drives all three". 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
bfeebd440d
commit
6a08c77c92
2 changed files with 376 additions and 59 deletions
|
|
@ -91,16 +91,49 @@ export function isSkillsExtensionSupported(
|
|||
return getSkillsExtension(capabilities) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A skill URI in normalized form, or `undefined` when it is not one.
|
||||
*
|
||||
* Two things a raw string comparison gets wrong, and both matter:
|
||||
*
|
||||
* 1. **`..` segments.** `skill://acme/billing/refunds/../other.md` starts with
|
||||
* the advertised root but resolves outside it. Containment has to be decided
|
||||
* on the resolved path, so every check below goes through the parser.
|
||||
* 2. **Relative strings.** SEP-2640 requires a full resource URI, and
|
||||
* `demo/SKILL.md` is not one — it fails to parse and is reported as
|
||||
* `malformed-uri` rather than quietly treated as a skill path.
|
||||
*
|
||||
* An **opaque-path** URI (`skill:demo/SKILL.md`, no authority) parses but is
|
||||
* NOT normalized by the parser — its `..` segments survive verbatim — so it is
|
||||
* rejected too: containment could not be decided on it, and silently accepting
|
||||
* one would reintroduce exactly the hole this function closes.
|
||||
*/
|
||||
export function normalizeSkillUri(uri: string): string | undefined {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(uri);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return parsed.pathname.startsWith("/") ? parsed.href : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The final `<skill-path>` segment of a skill URI — the segment *before*
|
||||
* `/SKILL.md`, not the filename. SEP-2640 requires it to equal
|
||||
* `frontmatter.name`, which is what makes a skill's name recoverable from its
|
||||
* URI alone. Returns `undefined` when the URI does not have that shape, which
|
||||
* is itself a conformance finding.
|
||||
*
|
||||
* Read off the **normalized** URI, so a traversal segment cannot produce a
|
||||
* name the resolved path does not actually carry.
|
||||
*/
|
||||
export function skillNameFromUri(uri: string): string | undefined {
|
||||
if (!uri.endsWith(SKILL_FILE_SUFFIX)) return undefined;
|
||||
const path = uri.slice(0, -SKILL_FILE_SUFFIX.length);
|
||||
const normalized = normalizeSkillUri(uri);
|
||||
if (normalized === undefined || !normalized.endsWith(SKILL_FILE_SUFFIX)) {
|
||||
return undefined;
|
||||
}
|
||||
const path = normalized.slice(0, -SKILL_FILE_SUFFIX.length);
|
||||
const segment = path.slice(path.lastIndexOf("/") + 1);
|
||||
return segment.length > 0 ? segment : undefined;
|
||||
}
|
||||
|
|
@ -171,10 +204,12 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
|
|||
});
|
||||
}
|
||||
if (!entry.frontmatter.description?.trim()) {
|
||||
// An error, not a warning: SEP-2640 requires `description` on every skill,
|
||||
// so an absent one is a format violation and must not read as "0 errors".
|
||||
issues.push({
|
||||
code: "missing-description",
|
||||
severity: "warning",
|
||||
message: "frontmatter.description is missing or empty.",
|
||||
severity: "error",
|
||||
message: "frontmatter.description is required but missing or empty.",
|
||||
});
|
||||
}
|
||||
if (uriName === undefined) {
|
||||
|
|
@ -237,12 +272,17 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
|
|||
const seenUris = new Set<string>();
|
||||
// Relative references resolve against the skill root, so every manifest entry
|
||||
// must live under it. A URI outside that prefix is either a typo or a server
|
||||
// claiming integrity over a file that is not part of this skill. Left
|
||||
// `undefined` for a malformed entry URI — there is no root to measure
|
||||
// against, and `malformed-uri` already reports that.
|
||||
const root = entry.uri.endsWith(SKILL_FILE_SUFFIX)
|
||||
? `${entry.uri.slice(0, -SKILL_FILE_SUFFIX.length)}/`
|
||||
: undefined;
|
||||
// claiming integrity over a file that is not part of this skill. Computed
|
||||
// from the NORMALIZED entry URI, and compared against normalized resource
|
||||
// URIs, so a `..` segment cannot walk out of the root while still matching it
|
||||
// as a string. Left `undefined` for a malformed entry URI — there is no root
|
||||
// to measure against, and `malformed-uri` already reports that.
|
||||
const normalizedEntryUri = normalizeSkillUri(entry.uri);
|
||||
const root =
|
||||
normalizedEntryUri !== undefined &&
|
||||
normalizedEntryUri.endsWith(SKILL_FILE_SUFFIX)
|
||||
? `${normalizedEntryUri.slice(0, -SKILL_FILE_SUFFIX.length)}/`
|
||||
: undefined;
|
||||
|
||||
for (const resource of entry.resources) {
|
||||
if (seenUris.has(resource.uri)) {
|
||||
|
|
@ -255,13 +295,18 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
|
|||
});
|
||||
}
|
||||
seenUris.add(resource.uri);
|
||||
if (root !== undefined && !resource.uri.startsWith(root)) {
|
||||
issues.push({
|
||||
code: "resource-outside-skill-root",
|
||||
severity: "error",
|
||||
message: `Manifest entry is outside the skill root "${root}".`,
|
||||
resourceUri: resource.uri,
|
||||
});
|
||||
if (root !== undefined) {
|
||||
const normalized = normalizeSkillUri(resource.uri);
|
||||
// An unparseable entry URI is outside the root by construction: nothing
|
||||
// can establish that it is inside one.
|
||||
if (normalized === undefined || !normalized.startsWith(root)) {
|
||||
issues.push({
|
||||
code: "resource-outside-skill-root",
|
||||
severity: "error",
|
||||
message: `Manifest entry does not resolve inside the skill root "${root}".`,
|
||||
resourceUri: resource.uri,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (resource.digest === undefined) {
|
||||
issues.push({
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@
|
|||
* not have: a top-level `ServerCapabilities` key to gate on (skills is a
|
||||
* *server-declared extension*, read from `capabilities.extensions`), and a
|
||||
* per-list `list_changed` notification to debounce and turn into a sidebar
|
||||
* indicator
|
||||
* (SEP-2640 defines none). Subclassing would mean widening the base's
|
||||
* indicator (SEP-2640 defines none). Subclassing would mean widening the base's
|
||||
* capability gate and inventing a list-changed event nothing dispatches — two
|
||||
* changes to shared machinery to serve one caller. The cursor walk below is the
|
||||
* only behavior actually shared, and it is nine lines.
|
||||
|
|
@ -20,8 +19,8 @@
|
|||
*/
|
||||
|
||||
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
|
||||
import type { SkillEntry } from "../skillsSchemas.js";
|
||||
import { LIST_MAX_PAGES } from "../listSalvage.js";
|
||||
import { SKILLS_LIST_METHOD, type SkillEntry } from "../skillsSchemas.js";
|
||||
import { LIST_MAX_PAGES, isClientDecodeRejection } from "../listSalvage.js";
|
||||
import { isTerminalStatus } from "../types.js";
|
||||
import type { RequestMetadata } from "../types.js";
|
||||
import { TypedEventTarget } from "../typedEventTarget.js";
|
||||
|
|
@ -201,7 +200,17 @@ export class ManagedSkillsState extends TypedEventTarget<ManagedSkillsStateEvent
|
|||
// auth-recovery wrapper keys off the rejection — but the *state* is left
|
||||
// alone, so a dead session's failure cannot surface in the live one.
|
||||
if (isCurrent()) {
|
||||
this.setError(err instanceof Error ? err : new Error(String(err)));
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
this.setError(error);
|
||||
// Attribute the failure to the response it came from, so the Protocol
|
||||
// entry stops rendering a rejected result as a clean success (#1953).
|
||||
// Must happen in this catch, while the correlation window the client
|
||||
// documents is still valid — and ONLY for a decode rejection, which is
|
||||
// what `isClientDecodeRejection` separates from a request that never
|
||||
// produced a response at all. Same handling every managed list gets.
|
||||
if (isClientDecodeRejection(err)) {
|
||||
client.markResponseRejected?.(SKILLS_LIST_METHOD, error.message);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
|
|
|
|||
Loading…
Reference in a new issue