fix: address Copilot round 2 on the list-salvage path (#1909)

All four findings taken (one visible, three in the suppressed block), plus the
`excludedTools` sibling leak the round-1 reply had deferred:

- `rawItemsOf` read a missing or non-array list member as an empty page, so a
  top-level violation (`{ tools: "invalid" }`) on a later page was discarded
  and the call returned a silently truncated list. It now returns `undefined`
  for "not a list at all", and the walk rethrows the original strict error.

- A failure of the re-walk ITSELF (a malformed `nextCursor`, a connection lost
  mid-walk) escaped in place of the original error, whose response is the one
  `rejectedResponseId` was captured for. The walk is now wrapped: the original
  surfaces, the secondary is logged so it stays diagnosable.

- The exclusion scan's lenient fallback discarded its malformed entries despite
  the comment claiming they were reported. Observable when the aggregate is
  served from cache: the salvage never runs, so nothing else reports them. The
  scan now returns them and `refreshExcludedTools` reports them — only ever
  adding, so a clean scan can't clear what the salvage path just wrote.

- An empty-string cursor is a valid cursor, and `cursor ? {cursor} : {}` dropped
  it, re-requesting page one and duplicating its entries until the
  repeated-cursor guard fired. Both walks now test `!== undefined`.

- `excludedTools` had the same session leak fixed for `malformedListItems` last
  round: cleared only on an explicit `disconnect()`, so the `onerror`-with-no-
  `onclose` route carried the previous server's set into the next connection.
  Cleared on the start-clean path too — safe because every `listAllTools`
  recomputes it, so this can only drop a stale set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 17:52:36 -04:00
commit 1889e04135

View file

@ -66,14 +66,16 @@ describe("LenientListPageSchema", () => {
).toBe(false);
});
it("reads an absent or non-array items key as no entries", () => {
expect(rawItemsOf({}, "tools")).toEqual([]);
it("distinguishes an empty page from one that is not a list at all", () => {
// Reading either as "no entries" would let a top-level violation pass as a
// silently truncated list.
expect(
rawItemsOf(
LenientListPageSchema.parse({ tools: "not-a-list" }) as never,
"tools",
),
rawItemsOf(LenientListPageSchema.parse({ tools: [] }), "tools"),
).toEqual([]);
expect(rawItemsOf({}, "tools")).toBeUndefined();
expect(
rawItemsOf(LenientListPageSchema.parse({ tools: "not-a-list" }), "tools"),
).toBeUndefined();
});
});