fix: address Copilot review round 1 on #2248
Five findings, all real. **Critical — `verifySkills` selected `contents[0]`.** These bytes are hashed against the file's advertised digest, so accepting a block the server labelled something else verifies one file's content against another file's digest, and can report that as `verified`. A false pass is worse than a missing check. `contentsFor(result, uri)` now selects by NORMALIZED identity, which keeps the canonicalized-echo case that motivated the original code while rejecting an unrelated block, and reports a read error when nothing answers for the URI. This is what `onReadSkillFile` already did; the two paths hash bytes against digests and must not disagree about which bytes. **`skills/get` was dispatched without the extension gate** that `skills/list` had. Hoisted into `assertSkillsSupported` so the two cannot drift — declaring the extension commits a server to both. Without it, an undeclared server's -32601 is indistinguishable to a script from the -32602 a declared server returns for a URI it does not serve. **The TUI could strand a user on a hidden Skills tab.** The tab left the bar when the gate went false but `activeTab` did not, so the pane kept rendering for a server that never declared the extension. Reset to `info`, following the Auth precedent — additionally gated on `connected`, because this gate reads a server declaration and would otherwise fire during a reconnect. **The TUI keyed a verdict by URI**, so a refresh replacing the manifest under the same URI left hashes computed for the previous snapshot describing the new one. Keyed on the entry now, as the web screen already was. **A failed "Load more" discarded the pages on screen and the retry cursor.** Both are preserved now, and the success and failure paths share one staleness-guarded `commit` helper so they cannot drift on which results they may write — which is how they came to disagree. Two nits: the six-skill counts. The docs sentence was more wrong than reported (five awkward skills of six, three outright violations) and is rewritten rather than renumbered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
9a8488681e
commit
775107776d
4 changed files with 452 additions and 56 deletions
|
|
@ -806,6 +806,33 @@ describe("App (foundation)", () => {
|
|||
await expectFrame(r, "Select a skill to view details");
|
||||
});
|
||||
|
||||
it("leaves the Skills tab when the selected server does not serve it", async () => {
|
||||
// The tab disappears from the bar when the gate goes false, but `activeTab`
|
||||
// is independent of the bar — so without this the render branch keeps
|
||||
// showing the pane for a server that never declared the extension, and the
|
||||
// user is stranded on content they cannot navigate back to (Copilot).
|
||||
h.ctrl.status = "connected";
|
||||
h.ctrl.skillsExtension = { directoryRead: false };
|
||||
const r = await mount(oneStdio());
|
||||
await expectFrame(r, "Skills");
|
||||
r.stdin.write("k");
|
||||
await expectFrame(r, "Select a skill to view details");
|
||||
|
||||
// The server stops declaring it — the shape of switching to one without
|
||||
// the extension, since the declaration is read off the live client.
|
||||
h.ctrl.skillsExtension = undefined;
|
||||
r.rerender(
|
||||
<App
|
||||
mcpServers={oneStdio()}
|
||||
clientConfig={emptyClientConfig}
|
||||
callbackUrlConfig={callbackUrlConfig}
|
||||
/>,
|
||||
);
|
||||
await tick();
|
||||
await expectFrame(r, "Server Configuration");
|
||||
expect(r.lastFrame() ?? "").not.toContain("Select a skill to view details");
|
||||
});
|
||||
|
||||
it("disconnects with 'd' when connected", async () => {
|
||||
h.ctrl.status = "connected";
|
||||
const { stdin } = await mount(oneStdio());
|
||||
|
|
|
|||
|
|
@ -470,6 +470,100 @@ describe("SkillsTab (#2248)", () => {
|
|||
await tick();
|
||||
});
|
||||
|
||||
it("drops a verdict when the entry changes under the same URI", async () => {
|
||||
// A refresh can replace the manifest or the frontmatter without the URI
|
||||
// moving. A URI-keyed verdict would then present hashes and findings
|
||||
// computed for the PREVIOUS snapshot as if they described the new one.
|
||||
const digest = await sha256Digest(textToBytes(SKILL_MD));
|
||||
const verifiable: SkillEntry = {
|
||||
...clean,
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://clean/SKILL.md",
|
||||
digest,
|
||||
size: textToBytes(SKILL_MD).byteLength,
|
||||
},
|
||||
],
|
||||
};
|
||||
const { lastFrame, stdin, rerender } = render(
|
||||
<SkillsTab
|
||||
skills={[verifiable]}
|
||||
pageCount={1}
|
||||
inspectorClient={mockClient()}
|
||||
width={140}
|
||||
height={30}
|
||||
focusedPane="list"
|
||||
/>,
|
||||
);
|
||||
stdin.write(ENTER);
|
||||
await tick();
|
||||
expect(lastFrame() ?? "").toContain("Verified — Enter to re-verify");
|
||||
|
||||
// Same URI, different manifest — the old verdict must not carry over.
|
||||
rerender(
|
||||
<SkillsTab
|
||||
skills={[
|
||||
{
|
||||
...verifiable,
|
||||
resources: [
|
||||
{ uri: "skill://clean/SKILL.md", digest: CLEAN_DIGEST, size: 9 },
|
||||
],
|
||||
},
|
||||
]}
|
||||
pageCount={1}
|
||||
inspectorClient={mockClient()}
|
||||
width={140}
|
||||
height={30}
|
||||
focusedPane="list"
|
||||
/>,
|
||||
);
|
||||
await tick();
|
||||
expect(lastFrame() ?? "").toContain(
|
||||
"[Enter to verify digests and frontmatter]",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a verdict across a reorder that leaves the entry unchanged", async () => {
|
||||
// The reason the key is the entry rather than the list index: moving a
|
||||
// skill down the list must not discard a verdict the user paid for.
|
||||
const digest = await sha256Digest(textToBytes(SKILL_MD));
|
||||
const verifiable: SkillEntry = {
|
||||
...clean,
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://clean/SKILL.md",
|
||||
digest,
|
||||
size: textToBytes(SKILL_MD).byteLength,
|
||||
},
|
||||
],
|
||||
};
|
||||
const { lastFrame, stdin, rerender } = render(
|
||||
<SkillsTab
|
||||
skills={[verifiable]}
|
||||
pageCount={1}
|
||||
inspectorClient={mockClient()}
|
||||
width={140}
|
||||
height={30}
|
||||
focusedPane="list"
|
||||
/>,
|
||||
);
|
||||
stdin.write(ENTER);
|
||||
await tick();
|
||||
expect(lastFrame() ?? "").toContain("Verified — Enter to re-verify");
|
||||
rerender(
|
||||
<SkillsTab
|
||||
skills={[verifiable, dynamic]}
|
||||
pageCount={1}
|
||||
inspectorClient={mockClient()}
|
||||
width={140}
|
||||
height={30}
|
||||
focusedPane="list"
|
||||
/>,
|
||||
);
|
||||
await tick();
|
||||
expect(lastFrame() ?? "").toContain("Verified — Enter to re-verify");
|
||||
});
|
||||
|
||||
it("falls back to the whole URI when a manifest entry has no path separator", async () => {
|
||||
const odd: SkillEntry = {
|
||||
uri: "skill://odd/SKILL.md",
|
||||
|
|
|
|||
|
|
@ -626,6 +626,28 @@ function App({
|
|||
!!selectedInspectorClient?.getSkillsExtension() &&
|
||||
inspectorStatus === "connected";
|
||||
|
||||
// Switch away from the Skills tab when the selected server does not serve it.
|
||||
//
|
||||
// The same handling the Auth tab gets above, and needed for the same reason:
|
||||
// the tab disappears from the bar when the gate goes false, but `activeTab`
|
||||
// is independent of the bar, so the render branch would keep showing the pane
|
||||
// for a server that never declared the extension — content the user can see
|
||||
// but can no longer navigate back to (Copilot).
|
||||
//
|
||||
// Gated on `connected` rather than on the extension alone: the declaration is
|
||||
// only knowable after the handshake, so resetting while a reconnect is in
|
||||
// flight would bounce the user off the tab they were reading and not return
|
||||
// them to it.
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeTab === "skills" &&
|
||||
inspectorStatus === "connected" &&
|
||||
!showSkillsTab
|
||||
) {
|
||||
setActiveTab("info");
|
||||
}
|
||||
}, [activeTab, inspectorStatus, showSkillsTab]);
|
||||
|
||||
// Connect — on 401 or mid-session auth recovery, run OAuth then retry.
|
||||
type TuiOAuthRunResult =
|
||||
| "success"
|
||||
|
|
|
|||
|
|
@ -64,6 +64,18 @@ const FILE_COLOR: Record<string, string> = {
|
|||
"read-error": "red",
|
||||
};
|
||||
|
||||
/**
|
||||
* What a verification result is a result *about*: the whole entry, serialized.
|
||||
*
|
||||
* `JSON.stringify` is enough here — this compares an entry against a later copy
|
||||
* of *itself* from the same server, so key order is stable and there is no need
|
||||
* for the canonical form `skillEntriesMatch` uses to compare two independently
|
||||
* produced entries.
|
||||
*/
|
||||
function entryKey(entry: SkillEntry): string {
|
||||
return JSON.stringify(entry);
|
||||
}
|
||||
|
||||
/** 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("/");
|
||||
|
|
@ -100,14 +112,23 @@ export function SkillsTab({
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
/**
|
||||
* The last verification, keyed by the skill URI it was run for. Keyed rather
|
||||
* than cleared on selection change so moving off a skill and back does not
|
||||
* silently discard a verdict the user just paid a round trip for — and keyed
|
||||
* by URI rather than index so a refresh that reorders the list cannot show
|
||||
* one skill's verdict under another's name.
|
||||
* The last verification, keyed by the **entry it was computed against**.
|
||||
*
|
||||
* Keyed rather than cleared on selection change, so moving off a skill and
|
||||
* back does not silently discard a verdict the user just paid a round trip
|
||||
* for. Keyed by a serialization of the entry rather than by its index, so a
|
||||
* refresh that reorders the list cannot show one skill's verdict under
|
||||
* another's name — and rather than by its URI alone, because a refresh can
|
||||
* replace the manifest or the frontmatter *under the same URI*, and a
|
||||
* URI-keyed verdict would then present hashes and findings computed for the
|
||||
* previous snapshot as if they described the new one (Copilot).
|
||||
*
|
||||
* The same key the web screen uses, for the same reason: re-verifying after a
|
||||
* metadata-only refresh is the cheap direction to be wrong in; showing a
|
||||
* verdict computed against a different entry is not.
|
||||
*/
|
||||
const [report, setReport] = useState<{
|
||||
uri: string;
|
||||
key: string;
|
||||
result: SkillVerifyReport;
|
||||
} | null>(null);
|
||||
const scrollViewRef = useRef<ScrollViewRef>(null);
|
||||
|
|
@ -124,7 +145,7 @@ export function SkillsTab({
|
|||
void (async () => {
|
||||
try {
|
||||
const [result] = await verifySkills(inspectorClient, [skill]);
|
||||
setReport({ uri: skill.uri, result });
|
||||
setReport({ key: entryKey(skill), result });
|
||||
} catch (err) {
|
||||
if (err instanceof AuthRecoveryRequiredError) {
|
||||
onAuthRecoveryRequired?.(err);
|
||||
|
|
@ -196,7 +217,9 @@ export function SkillsTab({
|
|||
const detailWidth = width - listWidth;
|
||||
const issues = selectedSkill ? checkSkillConformance(selectedSkill) : [];
|
||||
const activeReport =
|
||||
selectedSkill && report?.uri === selectedSkill.uri ? report.result : null;
|
||||
selectedSkill && report?.key === entryKey(selectedSkill)
|
||||
? report.result
|
||||
: null;
|
||||
const manifest =
|
||||
selectedSkill && selectedSkill.resources !== DYNAMIC_RESOURCES
|
||||
? selectedSkill.resources
|
||||
|
|
|
|||
Loading…
Reference in a new issue