Commit graph inspector/core
Author SHA1 Message Date
cliffhall
78f82475c4 fix: stop the drain and release both tee branches on timeout (#2319 round 24)
Losing the race rejected the caller but did not stop the read. Because
clone() tees the body, a drain still running kept pulling bytes AND
kept buffering them for the unread original branch — unbounded memory
on exactly the signal-ignoring body the race exists to contain.

The clone is now drained through an explicit reader rather than
clone().arrayBuffer(), precisely so the losing side has something it
can cancel: arrayBuffer() locks the stream to an internal reader
nothing else can reach. On any throw both branches are torn down, which
is safe because that path never hands the response to a caller.

The cancels are deliberately not awaited: cancelling one branch of a
tee can itself wait on the other, which would hold the timeout open for
as long as the stall it is meant to end. Each carries its own catch, so
a stream already cancelled, errored or locked elsewhere does not mask
the timeout the caller actually needs — covered by a test whose stream
throws from cancel().

Verified by mutation: removing the teardown makes the new test fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 08:19:57 -04:00
cliffhall
1e501fa5cb fix: redact sensitive query values from timeout diagnostics (#2319 round 23)
The timeout message interpolated the raw request URL, and
createFetchTracker records that message verbatim into the Network log
and the persisted session. An OAuth endpoint's query string can carry a
`code`, an `access_token` or a `client_secret` — exactly the fields
fetchTracking already redacts everywhere else it records a URL — so a
stalled signed endpoint would have written its secret to both.

Redacted with the existing `redactUrlQuery`, in the
OAuthRequestTimeoutError constructor rather than at each display site,
so both the message and the public `url` field are covered by
construction and no future consumer can reintroduce it. The route's own
proxied-timeout message and the URL in its 504 marker get the same
treatment, since both cross back to the browser and are recorded there.

The path and every non-sensitive parameter survive, so the endpoint
stays identifiable — which was the point of naming it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 07:57:38 -04:00
cliffhall
45d87911eb fix: release the discarded discovery response on every throw (#2319 round 22)
The timeout and cancellation exits added over the last few rounds drop
the original failed discovery response, which nobody will ever receive
— and an unread body holds its connection open on Node/undici. The
successful-substitution path already released it for that reason; the
five exceptional exits now do too (pre-abort, timeout, fetch-abort,
post-release abort, body-read abort). Matters most for a standalone
user of the exported wrapper, where no timeout wrapper underneath has
already drained it.

Also drops the last descriptions of the superseded rebuild: the test
file's header, an inline comment asserting the response is "not the
same object" when the file later asserts identity, and the PR
description's account of copied metadata and deleted encoding headers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 07:42:17 -04:00
cliffhall
bb01c1e73b fix: bound the body by draining a clone, not by rebuilding (#2319 round 21)
Rebuilding a Response around the buffered bytes could never be
faithful. `url`, `redirected` and `type` are internal slots, so
shadowing them as own properties satisfied a direct read and was lost
the moment a caller called clone() — which returns a fresh native
Response. The headers guard differed too, and the decoded bytes made
the inherited content-encoding and content-length wrong, so those had
to be stripped: four observable differences from the response fetch
actually produced.

The wrapper now drains `response.clone()` under the deadline and
returns the original. Cloning tees the body, so draining one branch
buffers the other: the bound is unchanged, the caller reads without a
second trip, and every native slot stays intact. rebuildResponse and
its null-body handling are gone, and with them the whole class.

Also: getServerUrl() throws for a non-HTTP configuration, and this
wrapped fetch can still be handed to a transport or a custom factory
that calls it — so the documented fail-open was reachable only until
that getter threw, at which point a configuration error propagated out
of a fetch. Caught, for the same reason readNegotiatesEndpoint catches.

Tests: response identity, the body still readable after the drain, a
re-clone keeping its metadata (the case a rebuild could not satisfy),
content-encoding preserved because nothing invalidates it now, a
null-body status passed through, and the throwing getter failing open
while still not slipping past the form-encoded check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 07:25:56 -04:00
cliffhall
e71b8b9dfb fix: never exempt a form-encoded request (#2319 round 20)
The exemption was URL-only, so an authorization server configured at
the MCP endpoint's own URL — `oauthTokenUrl` takes any absolute URL —
made the transport-internal refresh take the exempt branch and run with
no signal, no body buffering and no deadline. That is the one request
this change exists to bound.

`isExempt` now receives the request's headers as well as its URL, and
`exemptMcpEndpoint` refuses to exempt anything sent as
`application/x-www-form-urlencoded`, before any URL comparison. That is
a discriminator rather than a heuristic: the token exchange, the
refresh and revocation are all form-encoded per RFC 6749 §4.1.3 and RFC
7009 §2.1, and MCP is JSON-RPC over application/json and never is.

Also records, on the no-deadline route test, what it does not catch: at
a 400ms wait an unconditional timer restored at the production 30s
budget would still leave the request pending and the test would pass.
Catching that needs either a ~31s wait on every CI run or a route-level
deadline knob existing only for the test, and a second source of truth
for the deadline is the defect round 13 removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 07:10:00 -04:00
cliffhall
42339ff4a4 fix: narrow the exemption per transport (#2319 round 18)
Round 17 widened exemptMcpEndpoint to the origin for everything, which
exempted same-origin OAuth work as well as MCP traffic — on the very
401/discovery path #2319 exists to bound.

The rule is now as narrow as each transport allows. Streamable HTTP
sends every MCP request to the configured URL, so origin + pathname is
exact there and same-origin OAuth stays bounded. Legacy SSE negotiates
its message endpoint inside the stream, where we never see it, so the
origin rule is the narrowest available — and it is exact rather than
approximate, since the SDK enforces that the message endpoint shares
the stream URL's origin.

The residual gap is therefore confined to legacy SSE against a server
hosting its own OAuth endpoints, and it is stated in the docblock and
pinned by a test rather than left implicit. Neither alternative was
reachable: the SDK takes a single fetch for all network requests, so
there is no separate auth seam to route through, and the negotiated SSE
endpoint is parsed inside the SDK's own stream handler.

Fails open on every uncertainty, including not knowing the transport —
a path rule applied to what turns out to be SSE severs real MCP
traffic, so an absent or throwing hint falls back to the origin rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 06:40:07 -04:00
cliffhall
5a1c157a79 fix: exempt MCP traffic by origin, not path (#2319 round 17)
exemptMcpEndpoint compared origin + pathname, which misses legacy SSE
entirely: SSEClientTransport opens the configured URL and is handed a
SEPARATE message endpoint by the server, then POSTs every JSON-RPC
message to that second pathname. Those were bounded and body-buffered
as OAuth traffic, so a slow tool call over SSE would be severed at 30s
and reported as an OAuthRequestTimeoutError.

It compares origin alone now. That is exact rather than approximate:
the SDK enforces that the SSE message endpoint shares the stream URL's
origin and rejects one that does not, so every MCP request this chain
carries is same-origin with the configured URL by construction.

The cost, stated in the docblock and pinned by a test: an OAuth
endpoint on the MCP server's own origin is exempt here and so unbounded
on this chain. The OAuth chain still bounds the Inspector's own
requests to it, so only the SDK's transport-internal OAuth against a
same-origin server falls back to where it was before #2319.

Caller cancellation was also still swallowed in two later places in the
compat probe loop — `probe.text()` has its own catch, and `fetch`
resolves on headers, so an abort mid-body landed there and was answered
with the preceding 404; and an abort during releaseBody's await would
have carried on probing. Both recheck the signal now, and all three
sites throw the caller's own `reason` rather than whatever shape the
layer beneath rejected with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 06:24:24 -04:00
cliffhall
e234343702 fix: cancellable compat probes and a validated budget (#2319 round 16)
Two real bugs and two stale rationales.

The OIDC fallback probes were issued with `{ headers }` and no signal,
so a caller that aborted after the initial 404 got a timeout reported
in place of its own cancellation, while the probe ran on to its own
deadline. The probes now carry the caller's signal — via the shared
`callerSignalOf`, so the precedence rule has one definition — a
pre-aborted caller is not probed at all, and an abort is rethrown
rather than folded back into the preceding response.

`Math.max(0, Math.round(timeoutMs))` let NaN and Infinity through.
`setTimeout(fn, NaN)` fires immediately, and a delay above 2**31-1
overflows a 32-bit signed int and falls back to 1ms — so a caller
asking for a very large budget, or one whose arithmetic produced NaN,
got an immediate OAuth timeout. `normalizeBudget` falls back to the
default on non-finite and clamps to the timer's range; the route's
envelope-supplied deadline gets the same clamp, but ignores a
non-finite value outright since a malformed deadline reads safest as
no deadline.

The wire-marker docblock and the matching route test still said a
backend deadline is the only one on the transport-internal discovery
path, which round 11 changed. Both now give the real reason the marker
matters: both ends run the same budget, and it is the backend winning
the race — a backgrounded tab throttling setTimeout — that must not
decide whether the error names its endpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 06:06:36 -04:00
cliffhall
93965936ba docs: describe the shipped timeout architecture (#2319 round 14)
Three comments still said transport-internal discovery has no
client-side wrapper, or that the wrapper must never be applied to the
transport fetch. Round 11 made both false, and the second actively
tells a caller to do the opposite of what InspectorClient does — while
hiding that isExempt is the safety-critical part.

- createRemoteFetch's marker comment now gives the real reason the
  reconstruction matters: both ends run the same budget, so this path
  is the backend winning the race, most plausibly a backgrounded tab
  throttling setTimeout while the server's fires on schedule. Which
  deadline fired must not decide whether the error names its endpoint.
- The body-buffering comment now says buffering is the second reason
  isExempt is not optional on a mixed chain, since an exempt request
  skips it and an SSE body stays open rather than being drained.
- The matching test comment is reframed as testing the proxy boundary
  independently of which deadline wins.

The PR description is rewritten around what is actually shipped: both
chains bounded and differing only in the exemption, the per-request
envelope that replaced the fetchTimeoutMs option, and the three
mutation-verified tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 05:42:00 -04:00
cliffhall
de6960ec5a fix: make the proxy deadline per-request, not unconditional (#2319 round 13)
The server-side timer added in round 9 applied to every /api/fetch
request. But that route carries MCP traffic as well as OAuth work, so a
Streamable HTTP tool call that legitimately withholds its response
headers past the budget was aborted there — reintroducing on the
backend exactly the regression exemptMcpEndpoint prevents on the
client, and reporting it as an OAuth timeout besides.

The caller now decides. `withOAuthRequestTimeout` stamps its budget on
the RequestInit it hands down, `createRemoteFetch` reads it back and
puts it in the /api/fetch envelope, and the route applies a timer only
when one is present — so an exempt MCP request, which never gets an
init of its own, is bounded by nothing.

The carrier is a WeakMap keyed on the init rather than a header:
serializeRequest copies request headers verbatim into the payload and
the route re-sends them to the authorization server, so a marker header
would leak to a third party. It needs no cast onto RequestInit and is
collected with the object.

The `fetchTimeoutMs` option from round 9 is gone — the deadline now
lives in exactly one place, the client wrapper, which is also what
makes the 504 marker honest: only a request the OAuth wrapper bounded
can produce one.

Ten tests: the stamping and its rounding, nothing stamped on an exempt
call or an init that never went through the wrapper, the envelope
carrying the budget (and not a header), its omission on both exempt and
direct calls, and at the route the 504 marker, no deadline without one,
and a non-numeric deadline ignored. The no-deadline case is verified by
mutation: an unconditional timer makes it fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 05:24:24 -04:00
cliffhall
234d0a9ac4 fix: preserve Response enumerability, refresh transport docs (#2319 round 12)
Copying url/redirected/type onto the rebuilt response as enumerable own
properties changed what a consumer sees: `Object.keys`, object spread
and `JSON.stringify` are all affected. Which answer is correct turns
out to be the host's business — under the Fetch standard these are
prototype getters with no own enumerable key, while happy-dom defines
them as own enumerable data properties — so the descriptor is copied
from the freshly built Response rather than either answer being
hardcoded. The test compares against a baseline built in the same
runtime for the same reason.

Three comments still described the transport fetch as unbounded, which
round 11 changed:

- the wrapper's own docblock said it must never wrap the transport,
  obscuring that isExempt is what makes doing so safe;
- the auth-chain comment in inspectorClient repeated it;
- buildEffectiveAuthFetch's docblock said transport-internal discovery
  is not covered by a deadline.

All three now state the real distinction — both chains are bounded, and
they differ only in that the transport chain exempts the MCP endpoint —
and the docblock says why reusing the transport chain here would be
wrong rather than merely different.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 05:07:31 -04:00
cliffhall
f491c7fc9a feat: bound SDK-internal OAuth on the transport chain too (#2319 round 11)
The transport chain carried the SDK's own OAuth work — the discovery it
runs from inside the transport on the 401/refresh path — and that leg
was left to the SDK's incidental per-request timeout, reporting a bare
`Request timed out` naming no endpoint. On a direct (non-proxied) path
the /api/fetch backstop cannot reach it either.

`withOAuthRequestTimeout` takes an optional `isExempt` predicate; an
exempt request passes through completely untouched — no deadline, no
signal, and no body buffering, which is what a stream needs.
`exemptMcpEndpoint` exempts the MCP endpoint by origin + pathname, and
FAILS OPEN: no server URL, or either side unparseable, means exempt.
The errors are not symmetric. Bounding what should not be bounded
severs a long-running tool call withholding its headers, or an SSE
body; failing to bound leaves the SDK's timeout as the backstop it
already was.

Two test corrections, both real:

- The metadata-preservation case asserted url/redirected/type against a
  synthetic Response, whose values are the rebuild's own defaults — so
  it stayed green with the defineProperty loop deleted. It now seeds
  non-default values first; verified by mutation that removing the loop
  makes it fail.
- Two signal assertions compared against the controller rather than
  `request.signal`. The Fetch standard gives a Request a dependent
  signal, so those encoded non-standard identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 04:49:05 -04:00
cliffhall
baefc96806 fix: carry the proxy deadline's identity, correct the rounding note (#2319 round 9)
A deadline enforced by the backend could not arrive as an
OAuthRequestTimeoutError: /api/fetch serialized it as a 500 and
createRemoteFetch turned that into a plain Error, so every instanceof
check downstream was false — including the one that lets a stalled
probe escape withRfc8414OidcCompat. That matters most where no
client-side wrapper exists to have fired first: the discovery the SDK
runs from inside the transport. The route now answers 504 with a typed
marker (OAUTH_TIMEOUT_WIRE_CODE plus url and timeoutMs) and
createRemoteFetch reconstructs the error from it, guarded by a shape
check so an upstream's own JSON cannot forge one.

The route's deadline becomes a `fetchTimeoutMs` option defaulting to
the shared constant, so the backstop can be proven to fire in
milliseconds instead of thirty seconds of wall clock — and because a
very slow authorization server is a legitimate reason to want a longer
one.

The rounding rationale was wrong: setTimeout accepts a fractional delay
and truncates it (verified on Node 26); ERR_OUT_OF_RANGE is
AbortSignal.timeout's behaviour, which this wrapper never calls. The
rounding stays, for the reason that is actually true — the budget is
interpolated into the message and exposed as timeoutMs, and a
performance.now() budget would otherwise report 1000.4000000953674ms.

Also drops a sentence in the discarded-stream test that contradicted
the qualification above it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 04:16:15 -04:00
cliffhall
a452305204 fix: cancel the discarded proxy stream, refresh revocation docs (#2319 round 8)
The /api/fetch route classifies an event-stream or NDJSON response as a
stream and then answers with JSON and no body — so nobody downstream
owns the upstream stream and nothing will ever read it, while the
`finally` clears the only deadline on the way out. It is now cancelled
explicitly, best-effort, the way `releaseBody` in oidcDiscoveryCompat
already does. Measured, undici reclaims an unread body here on its own,
so this is belt and braces rather than a demonstrated leak — kept
because relying on that is an implementation detail of the fetch
beneath us, not a contract. The route test and the source comment both
say so rather than implying a stronger claim.

Forwarding the signal also made revocation.ts's rationale stale: it
still described a proxy that drops `init.signal` and a backend that
cannot cancel. Both comments there, and the matching one in
revocation.test.ts, now describe the post-#2319 path — the signal
cancels, the race guarantees, and what the race is still for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 04:00:44 -04:00
cliffhall
f3e4023e35 fix: sanitize rebuilt headers, refresh stale docs, cover the route (#2319 round 7)
Three findings, all real.

`arrayBuffer()` hands back the decoded entity body, so the rebuilt
response was carrying the upstream `content-encoding` and the
compressed `content-length` — a gzipped metadata document came back as
plain JSON bytes still claiming gzip and the wrong size, visible in the
Network capture. Both headers are deleted now, as `responseWithBody` in
endpointOverrides.ts already did for the same reason.

The "why a race, and not just the signal" rationale described a world
this PR had since changed: the signal does now reach the backend's
outbound fetch. The module doc and three implementation comments are
rewritten to say what the race is actually still for — a fetchFn that
ignores AbortSignal, and a backend that is itself wedged — rather than
a proxy hop that no longer drops it.

The backend cancellation path had only mock-level coverage. A
route-level test drives /api/fetch at an upstream that accepts and never
answers, aborts the browser side, and asserts the upstream observes its
connection closing. Verified by mutation: dropping `signal` from the
route's outbound fetch makes it time out rather than pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 03:43:57 -04:00
cliffhall
d848300077 fix: enforce the OAuth deadline across the proxy hop (#2319 round 5)
Two findings, both real.

The race bounded only the caller's promise on the browser path.
`createRemoteFetch` dropped the signal, so an abort — including the
deadline's own — left the POST to /api/fetch in flight; the backend
never saw its request cancelled and its outbound fetch ran on with
neither signal nor timeout, holding a handler and an authorization-server
socket per timed-out attempt. `createRemoteFetch` now forwards the
caller's signal (init.signal when present and not undefined, else a
Request's own), and the /api/fetch route composes `c.req.raw.signal`
with a server-side deadline matching the client budget, cleared in
`finally` so a streaming response is not severed.

A probe's own timeout was swallowed by `withRfc8414OidcCompat`, which
returned the preceding 404 and discarded the endpoint name the deadline
exists to provide. A deadline the Inspector imposed is its own error,
not the server's answer, so it escapes now; every other probe failure
still falls back as documented. Nothing regresses: every candidate the
loop probes is one the SDK's own buildDiscoveryUrls emits, so a stall
there is one the SDK would have hit itself, unbounded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 03:23:26 -04:00
cliffhall
42139c2d23 fix: put the OAuth deadline inside the compat wrapper (#2319 round 3)
`buildEffectiveAuthFetch` took `this.fetchFn` as its base, which already
carried `withRfc8414OidcCompat` — so the deadline sat outside it. A
stalled OIDC probe was then timed on whatever budget the preceding RFC
8414 request had left, and reported under that request's URL rather
than the probe's, defeating the endpoint-level diagnostic this change
exists to add.

The auth chain is now composed separately, deadline innermost, matching
the CLI's storedAuthFetch:

  withRfc8414OidcCompat(withOAuthRequestTimeout(withOverrides))

`this.fetchFn` keeps the transport chain, unbounded, so an SSE or
Streamable HTTP body is still free to stay open. Two tests pin the
ordering by contrast: outside, a stalled probe is misreported under the
preceding URL; innermost, it is bounded on its own budget and cannot be
attributed to the RFC 8414 request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 03:02:37 -04:00
cliffhall
2710c98681 fix: address Copilot review round 2 on #2319
Both findings are on the caller-signal handling added in round 1:

- `"signal" in init` treated a member present as `undefined` as an
  explicit override. `RequestInit` is a WebIDL dictionary, where such a
  member converts as absent — so `{ ...base, signal: undefined }`, which
  is what a spread over an options object with no signal produces, was
  clearing a Request's embedded signal instead of inheriting it. Only a
  non-`undefined` value overrides now; an explicit `null` is still the
  genuine no-signal case.

- An already-aborted caller signal rejected the race, but
  `Promise.race` still evaluated `fetchFn(...)`, so on the
  signal-dropping proxy path the OAuth request went out anyway. It now
  short-circuits before anything is constructed or sent, and the
  now-unreachable aborted branch inside the race is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 02:44:43 -04:00
cliffhall
e89ea4cf19 fix: address Copilot review round 1 on #2319
Three findings, all real:

- The deadline stopped at the response headers. `fetch` resolves once
  headers arrive, so a server that sent headers — or half a JSON
  document — and then stalled left the caller's `response.json()`
  hanging with nothing watching it. The body is now buffered under the
  same race and the response rebuilt around it, preserving `url`,
  `redirected` and `type`, which have no constructor option, and
  passing `null` for a null-body status.

- The caller's signal was only forwarded, not raced. On the browser's
  `createRemoteFetch` path the signal is dropped in flight, so an abort
  left the promise pending for the whole budget rather than the outer
  cancellation winning as documented. It is raced now, with the
  caller's own `reason` preserved, and the listener removed in
  `finally` since one caller signal outlives several requests.

- A `Request` carries its signal on the input, not in `init`, so always
  passing our own `signal` in `init` silently overrode it. The caller
  signal is now derived from the `Request` when `init` has no `signal`
  key — and an explicit `null` there still wins, per the fetch spec.

Eight new tests cover each: the stalled body, caller cancellation
against a signal-ignoring fetch, an already-aborted signal, the
Request-embedded signal, the explicit-null override, listener removal,
the rebuilt response's metadata and a null-body status.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 02:24:30 -04:00
cliffhall
8dd7bc2920 feat: bound every OAuth-path request with a deadline
Protected-resource-metadata discovery, authorization-server metadata
discovery, dynamic client registration, the token exchange and the
refresh all went out with no AbortSignal at all. Token revocation was
the single exception, and its withDeadline race is the pattern this
generalizes.

Adds core/auth/requestTimeout.ts: withOAuthRequestTimeout wraps a fetch
so every call through it is bounded, composing any caller signal with
AbortSignal.any and racing a timer so the deadline holds even where the
signal is dropped in flight (the browser's remote fetch re-issues the
call to /api/fetch and forwards no signal). The rejection is an
OAuthRequestTimeoutError naming the URL, so a stall says which endpoint
stalled rather than riding the SDK handshake timeout and reporting
"Request timed out".

Applied inside InspectorClient.buildEffectiveAuthFetch rather than to
this.fetchFn, because the transport is handed this.fetchFn directly and
a deadline on a Streamable HTTP / SSE response would sever the stream it
is supposed to hold open. Also applied to the CLI's storedAuthFetch,
which calls SDK discovery directly and so has nothing else bounding it.

Closes #2319

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 00:45:04 -04:00
cliffhall
62fd7d1bd4 fix: address Copilot review round 19 on #2248
Four findings, all real.

- No catalog-level bound. The per-skill caps bound what ONE entry can
  cost; nothing bounded how many entries there are, and SEP-2640 puts no
  ceiling on a catalog — every entry costs at least one resources/read,
  so a large listing made `--verify` run indefinitely and transfer
  unboundedly. Adds SKILL_MAX_CATALOG_SKILLS / SKILL_MAX_CATALOG_BYTES.
  Entries past the budget are still reported, with their static findings
  and an `incomplete` reason, rather than dropped or failed: they were
  not checked, which is neither a pass nor a verdict against the server.

- `responseBytes` charged `text.length`, which counts UTF-16 code units.
  That undercharges non-ASCII by up to 3x, so a decoy block of emoji kept
  the counter under 16 MiB while the wire carried far more. Adds
  `utf8Length`, an allocation-free UTF-8 byte count — deliberately not
  TextEncoder, which would copy a payload the server chose the size of.

- `checkSkillNameCollisions` was O(N^2) in both work and output for a
  group of N: every entry filtered all N URIs and embedded the other
  N-1. Duplicate names are legal and a server controls N. Now names a
  bounded sample and counts the rest.

- `--verify` help documented exit 7 but not exit 8.

The three-tier bounding (per skill, per skill on the wire, per run) is
now a table in clients/cli/README.md, since the run bound is this tool's
limit rather than the spec's and should not read as a conformance rule.

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>
2026-09-07 23:55:53 -04:00
cliffhall
a3f36ce863 fix: address Copilot review round 18 on #2248
Three findings, all in paths hardened by earlier rounds.

- The 16 MiB read budget was charged only after a content block had
  matched by URI *and* decoded. A server could answer every manifest row
  with one enormous block labelled a different URI (so `contentsFor`
  found nothing) or one enormous invalid-base64 blob (so `skillFileBytes`
  threw), bank zero against the cap, and have the walk issue up to 512
  more. `responseBytes` now charges the raw response before selection or
  decoding; the exact decoded length is substituted when there is one, so
  the common path is still measured precisely.

- The Directory section checked that a child was inside the skill root
  but not that it was a child of the directory actually being read. A
  grandchild, or the directory echoing itself back, passed containment
  and was rendered as navigable. Non-direct entries are now shown and
  labelled rather than linked — `resources/directory/read` answers with
  direct children, so an entry that is not one is itself the finding.

- Containment was decided on the normalized URI while navigation sent and
  stored the raw one. For `skill://r/a/../templates` the first Up produced
  `skill://r/a/..` and a second walked into `skill://r/a`, a directory
  nothing had validated. Directory descent passes the normalized URI, and
  the Up arithmetic moved into `parentOfSkillUri`, documented as valid
  only on a normalized URI.

The suppressed comment (two stderr lines on a failing `--verify`) is
declined again: `--strict` produces the same shape in `emit-result.ts`,
and clients/cli/README.md already documents it as such.

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>
2026-09-07 23:33:30 -04:00
cliffhall
9117f633aa fix: address Copilot review round 17 on #2248
Four of the five findings share one root cause: the tri-state `outcome`
added in round 15 was not propagated to consumers, which kept branching
on `ok` — true for an `incomplete` report, since nothing that was checked
was wrong.

- CLI `summarizeSkillVerification` counts off `outcome`, not `ok`, so a
  truncated walk no longer prints "no conformance errors" one line before
  exiting SKILL_INCOMPLETE. A mixed failed/incomplete catalog reports
  both counts rather than letting the louder verdict hide the quieter.
- `verifySkills` sets the truncation reason only when entries were
  actually left unread. A file crossing the byte budget as the FINAL
  entry stopped nothing, and "Stopped after 3 of 3" both read as a
  contradiction and demoted a fully-read skill out of `verified`.
- The TUI status line switches on `outcome` through a `Record` over the
  union, so the INCOMPLETE arm is reachable and a fourth outcome would
  be a type error rather than a silently missing label.
- The `incomplete` doc paragraph said it makes `ok` false. It does not,
  deliberately — corrected, and it now names `outcome` as the thing a
  consumer must branch on.
- The web sidebar composes in the per-URI `duplicate-name` warning, so
  two colliding skills are badged in the catalog instead of looking
  clean until one is selected.

The suppressed `selfAttempted` finding is stale — fixed in round 16.

The existing TUI INCOMPLETE test was passing for the wrong reason: its
fixture understated every size, which is itself a mismatch, so the report
was `failed` and only the old `ok`-first branch printed INCOMPLETE. It is
rebuilt with honest digests and sizes, so truncation comes from the
declared-size prefilter and the report is genuinely incomplete.

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>
2026-09-07 23:12:28 -04:00
cliffhall
5a1f0efb5a fix: address Copilot review round 16 on #2248
**A hole my own round-13 change opened.** The byte budget added a `break`,
and `manifestListsSelf` described the bounded slice rather than the rows the
walk reached — so breaking before a later self-entry left the flag true,
suppressed the fallback, and skipped the mandatory frontmatter check
entirely. It is `selfAttempted` now, set inside the loop, and marked before
the read: a row the walk reached but could not read has still been attempted,
and its failure belongs to the loop rather than to a second fetch.

**A preview could overwrite a verification's own bytes.** The digest verdict
on screen was computed from the verification's fetch; replacing only the text
let the frontmatter findings describe different bytes, recreating the
mixed-fetch verdict that state exists to prevent. `entryTextVerified` marks
which read produced it, and a preview no longer wins over a verification.

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>
2026-09-07 22:58:19 -04:00
cliffhall
89438d1a4b fix: address Copilot review round 15 on #2248
**Verification is a tri-state now, because two states could not be right.**
Round 12 was correct that `ok: true` for a manifest whose unread 513th file
may be tampered with is a false pass. Round 15 is correct that forcing `ok:
false` calls a server nonconformant for exceeding limits SEP-2640 states as
SHOULD NOT, with hosts free to support more — contradicting this module's own
rule that a warning never fails a report. Both hold, so:

  verified   -> exit 0   everything checked, everything passed
  failed     -> exit 7   a MUST was broken
  incomplete -> exit 8   nothing checked was wrong; the walk was cut short

`ok` keeps its narrow meaning, `allSkillsVerified` is stricter than
`every(r => r.ok)` since it selects the exit code, and `anySkillFailed`
separates 7 from 8. A job that tolerates oversized catalogs can allow 8 and
still fail on 7.

**`manifestKey` still used `JSON.stringify` on the entry** — the same crash I
fixed in the TUI in round 12, one file over.

**Directory children were navigable outside the skill root.** A server could
return `skill://other-skill/...` and clicking it left the tree, with "Up"
only comparing equality against the root. Containment is checked on the
normalized URI now, and an outside child renders as text rather than a link.
Worth recording from the tests: `..` cannot escape the authority, so
`skill://a/nested/../x.md` resolves back inside and must stay navigable.

**The TUI matched report rows by raw URI**, which missed a row recorded under
an equivalent spelling while `extraReportFiles` suppressed it as covered.
Normalized, like the membership test beside it.

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>
2026-09-07 22:47:32 -04:00
cliffhall
01a8f14c24 fix: address Copilot review round 14 on #2248
**The TUI ignored `incomplete`.** Round 12 added that field precisely so a
consumer could tell "not fully checked" from a real failure, and then the one
client that reads the report did not consume it — which made the field
decorative. The pane shows an Incomplete: block with the report's own reason,
and the status line reads INCOMPLETE rather than FAILED.

It sits ABOVE the manifest, not beside the status line: it explains the list
that follows — only the first N rows were fetched, the rest stay marked `·`
because nobody looked at them — and below a 512-row manifest it would be
off-screen, which is the same as absent. Found by writing the test, whose
first version asserted against a 600-entry fixture and could see neither
string. The test triggers truncation through the BYTE budget instead, so the
manifest stays three rows and the assertions measure the pane rather than the
test's viewport.

**The capped self-entry was reported under the wrong URI.** A manifest may
write its self-entry in a normalized-equivalent form; the fallback recorded
`entry.uri`, so manifest rows keyed on the declared spelling matched nothing
while the normalized extra-files filter suppressed it as already covered. The
verdict existed in the report and appeared nowhere on screen. Recorded under
`declaredSelf.uri` now.

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>
2026-09-07 22:20:20 -04:00
cliffhall
4f12d07d2b fix: address Copilot review round 13 on #2248
**The byte budget trusted server-declared sizes.** A manifest advertising
`size: 1` — or, since the wire schema deliberately accepts it for reporting,
no size at all — sailed through the declared budget and then served
arbitrarily large bodies. The 16 MiB safeguard protected against an honest
server only, which is no safeguard.

The walk now tracks bytes ACTUALLY received and stops issuing reads once the
real total crosses the cap, marking the report `incomplete` and so `ok:
false`. The declared prefilter is kept rather than replaced: it refuses to
schedule an obviously oversized set, and the received-byte counter stops one
that lied. The count happens after verifying the crossing file, so that file
is still reported rather than fetched and discarded.

⚠️ This bounds the total across responses, not any single one: a first
response larger than the cap is already in memory before it can be measured,
which would need a streaming read the client API does not expose. Stated in
the code rather than left to look closed.

**A modern `skills/get` did not require `resultType`.** The "left open"
question SEP-2640 states covers `ttlMs` / `cacheScope` and only those;
`resultType` is base-protocol per SEP-2322 and appears in the SEP's own
`skills/get` example. Requiring it of `resources/directory/read` but not here
was an inconsistency in the module rather than a distinction the spec draws.
Era-selected now, with the caching attributes still optional.

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>
2026-09-07 22:09:19 -04:00
cliffhall
27f1b2f1bd fix: address Copilot review round 12 on #2248
**The read bounds could produce a false `ok: true`.** Entries past the cap are
never fetched, `fileFailed` only examines fetched rows, and
`resource-limit-exceeded` is a WARNING — so a manifest whose 513th file was
tampered with reported the skill verified. That trades a denial of service
for a wrong answer, which is the failure this whole PR argues against. A
truncated read is now recorded as `incomplete` and forces `ok: false`,
reported separately so a consumer can tell "this skill is wrong" from "this
skill was not fully checked". And a self entry excluded by the cap is now
verified against its declared digest, not merely read for frontmatter —
otherwise the skill's own SKILL.md was the one file nobody checked.

**`entryKey` could crash the TUI.** It called `JSON.stringify` on the whole
server-controlled entry, during render. `skillEntryKey` is decided by the
same `jsonGraphError` guard that bounds the frontmatter comparison, with a
coarse fallback that still separates two unrepresentable entries by URI —
collapsing them would show one skill's verdict under another's name.

**The tab bar wraps and `tabsHeight` was hard-coded to 1.** An OAuth-capable
stdio server serving Skills needs ~107 columns, so it wraps at 132 as well as
at 80, and every pane below was sized a row too tall. `tabBarRows` derives it
from the same list `Tabs` renders, via a shared `visibleTabs(flags)` — the
duplicated filter being how the two would drift apart again.

Nine tests in a new `tabsConfig.test.ts`, including the 80-column regression.
Two assert properties rather than instances: rows never decrease as the
terminal narrows, and a tab wider than the row gets its own rather than
looping.

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>
2026-09-07 21:57:48 -04:00
cliffhall
60d5f403e6 fix: address Copilot review round 11 on #2248
**Reads were bounded by entry count but not by total bytes.** A manifest can
sit at exactly 512 entries and declare a gigabyte each, so round 9's cap
still let a server dictate unbounded bandwidth and time after
`size-limit-exceeded` had already been reported. `boundedManifest` caps on
both interoperability limits now.

Sizes are summed exactly as `totalSkillBytes` sums them, so the bound and the
finding that reports the overage cannot disagree about the total. A read is
skipped only when the running total would CROSS the limit, so a conforming
skill — at most 16 MiB by definition — is never truncated; there is a test
for that, because a bound that protected against a hostile server by giving a
wrong answer about a good one would be a poor trade. The self-file fallback
still applies, so the mandatory frontmatter check survives either cap.

**The `vitest.shared.mts` pin rationale had gone stale.** It said `yaml` is
reached only through `test-servers/src`, which this PR made untrue by
importing it from `core/mcp/skillFile.ts`. That comment is what a future
dependency-placement change would read before removing or reclassifying the
pin, so it now names the `core/` path and the bundler `external` lists that
follow from it.

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>
2026-09-07 21:44:26 -04:00
cliffhall
50441f4d85 fix: address Copilot review round 10 on #2248
Round 10 raised no new inline comments; three suppressed findings, two fixed
and one declined with a reason.

**`Object.is` held `0` and `-0` distinct**, so a listing carrying JSON `0`
against a served YAML `-0` produced a mismatch — reported as "the listing
says 0 but the served SKILL.md says 0", a false finding with an
unintelligible explanation. JSON does not distinguish them, so neither may
this: `===` for finite numbers, `Object.is` kept only for the non-finite
cases it was introduced for.

**Browsing a supporting file erased an observed failure.** The frontmatter
check ran off whatever the viewer was showing, so opening a second file made
`showingSkillMd` false and dropped the finding and its error count, with the
skill unchanged. The entry's text is skill-scoped now, written by whichever
read produces it first — the on-selection preview or a verification — and
invalidated only with `manifestKey`. That also subsumes the earlier
same-fetch requirement rather than sitting beside it.

**Declined: the second stderr line on exit 7.** `--verify` writes its summary
and then the ordinary `ErrorEnvelope` that EVERY non-zero exit writes, which
is the documented contract and exactly what `--strict` does. Suppressing it
would make this one command's failure output unparseable by a caller
branching on `.code`. What was wrong is the README, which implied stderr
carries only the summary; it now states both lines and why.

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>
2026-09-07 21:33:34 -04:00
cliffhall
88c1b89575 fix: address Copilot review round 9 on #2248
**Verification reads were unbounded.** The 512-entry limit is checked by
`checkSkillConformance` — as a warning, since the SEP makes it an
interoperability bound rather than a MUST — but checking it constrains
nothing, so a hostile server advertising a million entries had the tool
perform a million sequential reads after the report already knew the manifest
was over. Capped at `SKILL_MAX_RESOURCE_ENTRIES`, with the finding still
reported so bounding the work does not silence the reason it was
unnecessary. `manifestListsSelf` is computed over the READ slice, so a
self-entry pushed past the cap still reaches the fallback: the frontmatter
comparison is mandatory and must not be lost to a limit that bounds other
files.

**A malformed skill URI still produced a directory root.** `skillUriIdentity`
falls back to the raw string, so `not a uri/SKILL.md` yielded the root `not a
uri` and enabled the Directory section — letting the UI build a request from
a URI the conformance checks had already rejected. The comment above it
claimed otherwise. `normalizeSkillUri` now, with the rule written down:
identity is for COMPARING two spellings, not for deciding a URI is
well-formed enough to build a request from.

The existing regression test passed against a weaker input that failed for
lacking the suffix and never reached the fallback; it is a table of three
shapes now.

**The test-server guide overstated its own coverage,** claiming four of the
five conformance scenarios map onto fixtures. Three do: there is no
size-mismatch fixture, because the fixture can override an advertised digest
and nothing else, so that path is covered by unit tests. Corrected, with what
adding one would take.

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>
2026-09-07 21:21:49 -04:00
cliffhall
8ae1a1664d fix: address Copilot review round 8 on #2248
**The depth guard covered only the YAML side.** The listing arrives over
JSON-RPC so it cannot be cyclic, but it is just as unbounded in depth — and
both `jsonLikeEqual` and its message formatter walk it, so a server
advertising a deeply nested value crashed the tool exactly as a cyclic served
one did. Reproduced first: 60,000 levels against a shallow served value gives
`RangeError: Maximum call stack size exceeded`. `jsonGraphError` is shared
now and runs over `entry.frontmatter` before the file is parsed — there is no
point reading a SKILL.md if the thing to compare it against is unusable.

**A frontmatter mismatch stayed behind a collapsed section.** Round 7 made
the badge count these findings; the alerts explaining a mandatory
verification failure were still one click away on a section that opens
collapsed for a structurally clean entry. Conformance now reveals itself when
they appear, keyed on the entry so it fires once rather than fighting a user
who collapses it again.

That reveal exposed three fixtures serving one skill's SKILL.md for every URI
— a genuine frontmatter mismatch the check was right to report. The stale-
batch invariant test and the Storybook fixtures now derive each file from the
frontmatter its own entry advertises, with digests computed from those bytes
rather than hard-coded, so the class of drift is closed rather than the
instances patched. `SAMPLE_FM` is the single source for both halves.

One story subtlety worth recording: only a skill's OWN SKILL.md is derived.
Serving it for `notes.md` too made the tampered fixture fail its SIZE check
first, which is a different finding from the digest mismatch that story
exists to demonstrate.

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>
2026-09-07 21:09:26 -04:00
cliffhall
d6b1e36e7e fix: address Copilot review round 7 on #2248
Four findings.

**The CLI dropped what it promised to print.** `getSkill` transforms the
`skills/get` envelope to a `SkillEntry`, so every other member the loose
schema accepted was gone before the CLI saw it — including the `ttlMs` /
`cacheScope` SEP-2640 explicitly leaves open. The code contradicted a comment
two lines above it saying a CLI whose contract is "print the result" must not
reshape one. `getSkillResult` returns the envelope whole; `getSkill` is a
one-line unwrap over it, which matches the callers: the UIs want the entry,
the CLI wants the result.

**The frontmatter verdict and the digest came from different reads.** The
preview fetch and the Verify fetch are separate `resources/read` calls, so a
resource changing between them could pair a verified digest with a
frontmatter verdict for other bytes. `VerificationState.entryText` now holds
the SKILL.md as the verification read it, written in the SAME state update as
the verdict — a separate write was clobbered, because `write` returns a fresh
object and drops anything not named in it. The preview stays the fallback so
a reader who has not clicked Verify still gets the check.

**The Conformance badge excluded the frontmatter findings** it renders, so it
said `0 error(s)` above a red mismatch. Digest and size mismatches stay in
their own badge: "the listing is wrong" and "the bytes are wrong" are
different answers.

**A repeated skill URI collided two React keys** in the TUI list, in a pane
whose job is to show both entries. Index-keyed like the manifest rows.

The web fixture also gains its collision pair in the served-file lookup —
without it they were handed another skill's SKILL.md, which the badge fix
correctly began reporting as a frontmatter error.

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>
2026-09-07 20:44:51 -04:00
cliffhall
916b08ba93 fix: address Copilot review round 5 on #2248
**A cyclic YAML alias crashed the tool.** `meta: &m [*m]` parses cleanly into
a self-referential array, and the frontmatter comparison is a recursive walk,
so a hostile server could take down `--verify` or the TUI with a stack
overflow instead of receiving a finding. Verified before fixing: it really
did raise `RangeError: Maximum call stack size exceeded`.

Rejected at the parse, so no consumer has to be cycle-safe on its own, and it
becomes an ordinary `frontmatter-unparsable` finding. Detection is per-PATH
rather than per-graph — `seen` unwinds on the way back up — so an alias reused
across siblings, which is ordinary YAML and represents fine in JSON, is not
mistaken for a cycle. A depth bound closes the same hole by its other door: a
legal, acyclic but absurdly nested document exhausts the stack just as well,
and a cycle check alone passes it.

**The web frontmatter check was gated on the presentation MIME.** It ran off
`previewParts`, which exists only when the displayed type is recognized as
markdown — so a `SKILL.md` a server typed `text/plain`, or served as a base64
blob, skipped a mandatory comparison while the pane still read as clean. It
now decodes the same fetched bytes the digest is taken over, which also stops
the two answers describing different derivations of the payload.

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>
2026-09-07 20:10:23 -04:00
cliffhall
799763543f fix: address Copilot review round 4 on #2248
Eight findings. Two were visible in a terminal capture I had already taken
and read past.

**A digest mismatch rendered with no digests.** `verifySkillResource` sets
`reason` for a SIZE mismatch — which short-circuits before hashing — while a
digest mismatch carries `expectedDigest` / `actualDigest` and no `reason`, so
the TUI printed a bare `✗ notes.md` with nothing to act on. `failureDetail`
now falls back to the expected/actual pair, truncated to fit the pane.

**A dynamic skill's read failure rendered nowhere.** Its synthetic
`read-error` row lives in the report, not the manifest, and the manifest is
empty for such a skill by definition — so the pane said only "Verification
FAILED". There is a Read failures block for any report file the manifest does
not cover, matched on normalized identity.

**The non-finite sentinel could be aliased.** A listing whose value genuinely
was `{"#non-finite":"NaN"}` canonicalized identically to a served `.nan`, so
the round-3 fix moved the bug rather than closing it. Any encoding into the
value space can be aliased by a document containing the encoding, so the
comparison is structural now (`jsonLikeEqual`) and there is no sentinel at
all; `Object.is` gives NaN === NaN while keeping ±Infinity distinct.
Serialization is used only for the message.

**`parseSkillFrontmatter("null")` returned `{ fields: {} }`,** contradicting
its own non-mapping contract: an explicit null scalar parses to exactly what
an empty block does. They are told apart by the source, since the parsed
value cannot.

Also: the `files` doc comment still claimed it is empty for a dynamic skill,
which the round-2 fix made untrue; and three inline Mantine elements with two
static styling props each are extracted to `.withProps()` constants, per the
convention the rest of the file follows.

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>
2026-09-07 19:55:17 -04:00
cliffhall
033600e4ca fix: address Copilot review round 2 on #2248
Four findings, two of them silent holes in the verification itself.

**A blob-served SKILL.md skipped the frontmatter check entirely.** The entry's
own file was captured as `contents.text`, so a server returning the markdown
as a base64 `blob` — a legal `resources/read` shape this module already
decodes for the digest — never reached the comparison, and the report still
said `ok`. A MUST that quietly did not run. The file is now held as bytes and
the text derived from them, which also guarantees the digest and the
frontmatter describe one snapshot.

**A dynamic skill whose SKILL.md could not be read reported `ok: true`.** It
has no manifest rows, so `files` stayed empty, and its only static finding is
a warning — a verification that could not be performed was reported as one
that passed. The fallback read now records a `read-error` for all three ways
it can fail. It is gated on `manifestListsSelf` (by normalized identity) so a
self-entry the manifest listed and failed to read is not read, or reported,
twice.

**A served `.nan` compared equal to a listed `null`.** YAML expresses
non-finite numbers, JSON does not, and `JSON.stringify` turns all of them
into `null` — so the canonical comparison reported a real mismatch as
agreement. Verified against the parser before fixing. They now canonicalize
to a form no JSON scalar can equal, and the value is named in the finding.

Also: the self-entry match is by normalized identity rather than raw string,
which stops a second read and stops this function disagreeing with
`checkSkillConformance`; and `SkillsTab.tsx` gains the file header AGENTS.md
requires.

The four `err instanceof Error` ternaries are one `reasonOf` helper now —
extracted because the coverage gate found every one of their non-Error arms
uncovered, and one honestly-tested branch beats four ignores.

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>
2026-09-07 19:24:30 -04:00
cliffhall
4fa00d1cfd feat: report a skill-name collision across the listing (#2248)
SEP-2640: hosts MUST NOT assume name uniqueness, and when two entries in one
listing collide on `name` a host MUST disambiguate them rather than silently
discarding or preferring one.

`checkSkillConformance` structurally cannot see this — it takes one entry and
a collision is a property of the pair — so `checkSkillNameCollisions` walks
the listing and returns a finding per colliding entry, each naming the
others. All three clients merge it into the entry's own findings, so it
carries through the header badge, the CLI report and the TUI row marks with
no new surface.

**It is a `warning`, not an `error`,** and that is the severity split doing
its job. The obligation is on the *consumer*: a server may legitimately
publish two skills with the same name under different paths, and the SEP's
own `acme/billing/refunds` example is exactly that shape. Calling it an error
would tell a conforming author their catalog is invalid. `--verify` therefore
still exits 0 for a collision.

In the web screen it renders as a banner directly under the Conformance
header and is filtered out of the findings list, the same treatment
`dynamic-resources` gets: it changes how everything below it should be read,
and stating one fact twice reads as two findings.

Fixed while adding it: an entry whose only finding was a banner one rendered
an EMPTY findings container instead of "no structural issues", because the
list's presence was decided on the unfiltered set while its contents were
filtered. `listedIssues` is now derived once and used for both.

The fixture gains `acme/reports` + `globex/reports`, both fully conforming
and sharing the name `reports` — also the only fixture with a multi-segment
skill path, which nothing else exercised. Eight skills over four pages now,
and the integration test walks the cursor to exhaustion rather than asserting
a fixed page count, so a future fixture does not require editing it.

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>
2026-09-07 19:02:18 -04:00
cliffhall
775107776d 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>
2026-09-07 18:43:58 -04:00
cliffhall
9a8488681e feat: Skills extension phase 3 — CLI, TUI, directory reads, frontmatter check
Closes #2248.

Completes SEP-2640 support across all three clients, and closes the one
obligation #2234 deliberately left open.

- `checkSkillFrontmatterMatch` compares a served SKILL.md's own YAML
  frontmatter against the entry the listing advertised, field by field. No
  digest can cover this: a digest is taken over the bytes the server served,
  so it proves the file was not altered in transit and says nothing about
  whether the listing described it honestly. Needs a real YAML parser; `yaml`
  was already a root dependency, so this adds no package — but it does newly
  put it on core/'s import graph, hence the three bundler `external` lists.

- `resources/directory/read`: result schemas defined against the normative
  text, `InspectorClient.readResourceDirectory`, a Directory section on the
  Skills screen, and `--method resources/directory/read` in the CLI. The
  client refuses the call locally when the server did not declare
  `directoryRead`, which is the SEP's MUST NOT. A child the directory lists
  but the entry does not is marked `not listed` rather than merged into the
  manifest — the SEP calls a directory result a live observation and forbids
  treating it as extending the manifest.

- CLI: `skills/list`, `skills/get` and `resources/directory/read`, plus
  `--verify` — one NDJSON report per skill, a summary on stderr, exit 7 on a
  violation. Reads are sequential; a conforming manifest may declare 512
  entries.

- TUI: a Skills pane, shown only when the server declares the extension. Each
  row carries its conformance verdict as a glyph as well as a colour, since
  the pane is read over ssh and through script(1). Enter verifies.

- `verifySkills` re-throws `AuthRecoveryRequiredError` rather than recording
  it per file: it says the session's authorization expired, so absorbing it
  would report N identical read failures and swallow the error the TUI and
  the web commands key off to reauthorize.

Two open questions settled, both in code comments where they will be found:
`skills/get` carries no caching attributes because SEP-2640 leaves the
question open in as many words, so requiring them would fail a conforming
server; and there is no `PagedSkillsState` because every consumer of this
list is a whole-catalog verdict — computed over page one of three, "this
server's skills conform" is not merely partial but wrong.

The fixture grows two skills, both for checks that were otherwise
undemonstrable: `lying-listing` (listing and file disagree, digest still
verifies) and `stale-manifest` (serves a file its manifest does not declare).

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>
2026-09-07 18:10:37 -04:00
cliffhall
a5a9d4fe15 fix: the classifier does not survive structuredClone, and the test now proves it
Since round 1 the doc claimed the `name` fallback covers "a structured clone or
a JSON hop". Verified, and it is false for half of that:

  structuredClone(new InsecureTokenEndpointError(url))
    -> name: "Error", tokenEndpoint: undefined, constructor: Error

structuredClone normalizes a custom Error subclass back to Error, so neither
arm has anything left to match. A caller routing this error through
structuredClone or postMessage would silently get the generic retryable
handling back — the exact defect this PR removes.

The test could not have caught it: it hand-built a look-alike plain object, so
it asserted what I believed the boundary does rather than what it does. It now
performs a real JSON round trip (asserting the prototype is gone), and a second
case pins that structuredClone is NOT recognized — turning a false claim into a
tested limitation.

The doc comment now scopes the contract to JSON and states the structuredClone
limit with the remedy: serialize the fields explicitly across such a boundary.

Also removes an orphaned JSDoc block the round-4 refactor stranded above the
report helper, and gives showInsecureTokenEndpointNotice its own doc back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 11:16:51 -04:00
cliffhall
084a083e2d fix: usable IPv6 spelling, and drop a SEP label that means something else here (round 2)
- The recovery text told users to move the endpoint to `::1`. A bare IPv6
  literal is not a legal URL host — `new URL("http://::1/token")` throws — so
  anyone copying that into the Token URL override got a parse error from advice
  meant to unblock them. Now `[::1]`, in both the notice and the manual
  reproduction guide.

  The exemption list still reads `::1`, because that is the host the SDK
  compares; only the remedy is bracketed, because that is what a user types.
  The comment says so, so neither gets "corrected" into the other.

- Dropped SEP-2207 from all nine files it had reached. I took the label from
  the SDK's own source comments, which attribute this check to it — but this
  repo already uses SEP-2207 for OIDC refresh / offline_access
  (specification/v2_auth_hardening.md, plus an e2e test), so grepping it here
  would have turned up two unrelated things. Replaced with plain description
  rather than a different SEP number: having guessed wrong once, guessing again
  is not the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 09:54:17 -04:00
cliffhall
f12ce5aaf8 fix: correct two false claims in the terminal-notice copy (review round 1)
Both are accuracy defects in the one message whose entire job is to be
actionable, so both matter more than their size.

- "before any credentials were sent" is FALSE on the mid-session refresh and
  re-authentication paths this same notice serves, where credentials were
  legitimately sent earlier in the session. A user connected for an hour would
  read it as describing some other failure. Now scoped to the request actually
  refused: "without sending this request".

- The recovery guidance named "Server Settings → Authorization". The UI renders
  that accordion as "OAuth Settings" and the field as "Token URL override"
  (ServerSettingsForm.tsx), so the message directed people to a section that
  does not exist, precisely when it was asking them to go reconfigure
  something.

Both pinned by tests, including negative assertions on the old wording so it
cannot drift back.

The round's third finding was against test-server-http.ts's strictPort
precondition, which no longer exists — that option was dropped in 6c1b532,
after the review ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 09:38:02 -04:00
cliffhall
b4b82493b1 fix: report a terminal OAuth token-endpoint refusal instead of a dead retry
Closes #2280

InsecureTokenEndpointError appeared nowhere in our source, so it fell through
to the generic auth-failure path and rendered a "Re-authentication required"
banner with a Re-authenticate button. That button can never work: the SDK's
assertSecureTokenEndpoint runs inside executeTokenRequest, the error does not
extend OAuthError, and auth() special-cases it to rethrow rather than start a
fresh /authorize redirect. Clicking re-ran the same flow to the same refusal,
under the raw SDK message, which names the three exempt host literals and
nothing actionable. A terminal configuration error was being presented as a
retryable auth error.

Not specific to *.localhost: it fires for any endpoint outside the SDK's
exemption — host.docker.internal (#1911), a LAN hostname, a reverse-proxy
name, a mistyped scheme.

It is now recognized (core/auth/insecureTokenEndpoint.ts, mirroring
issuerBinding.ts's brand-plus-name classifier and walking cause / data.cause,
since era negotiation and the transport wrappers bury the rejection) and
surfaced as the configuration error it is, naming the endpoint and both ways
out, with no action affordance.

Six paths reach the refusal and all six classify it — the connect handshake,
authenticate() during connect, the satisfied-challenge connect retry, the
post-redirect callback, the command/background path, the deferred tab-visible
resume (which re-armed on every focus, an unbounded loop on a terminal error),
and the banner action. They share one wrapper so a seventh cannot omit the
banner clear, and every clear is scoped to its own serverId: these paths are
asynchronous, so a late continuation for one server must not erase a banner
another raised in the meantime.

Adds oauth-insecure-token-endpoint-http.json to reproduce it, whose issuer is
http://localhost.:8091 — the root-anchored spelling every resolver sends to
loopback but which is none of the SDK's exempt literals. transport.strictPort
keeps that fixture honest, since its port is hard-coded inside the issuer.

Making such an endpoint work has to land in the SDK (typescript-sdk#2591);
this changes the reporting, not the outcome.

Split out of #2282, where it was tangled with the *.localhost origin work for
#1944 — a separate question the reporter is still clarifying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 09:09:54 -04:00
cliffhall
aca522de6a fix: delegate issuer context and discovery state through the EMA wrapper
Copilot review (#2287): `EmaTransportOAuthProvider` forwarded
`clientInformation` / `saveClientInformation` without the SDK's `ctx`, and
implemented neither `discoveryState` nor `saveDiscoveryState`. Since the wrapper
does expose `clientMetadataUrl`, an EMA connection can still take SDK `auth()`'s
URL-based-client-ID branch — and the inner provider then saw
`issuer === undefined` with no discovery state to read back, so the CIMD write
was recorded as DCR.

Forward `ctx` on both, and delegate the two discovery-state methods to the inner
provider. Both were pre-existing SEP-2352 gaps in their own right: dropping the
issuer put every EMA read and write on the unkeyed slot, and the missing
discovery state meant the SDK re-discovered on every call and warned that it
could not run its callback-leg authorization-server binding check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 02:38:13 -04:00
cliffhall
6273790077 fix: read the SDK's own discovery state instead of a marker of our own
Copilot review (#2287), two findings — and taken together they said the marker
was the wrong mechanism, so this removes it rather than patching it again:

- A transient failure in our CIMD preflight recorded no marker, so a *successful*
  SDK fallback discovery that advertised CIMD was still stored as `dcr`.
- `existing` and `existingKind` could describe different registrations, because
  `clientInformation()` answers preregistered-first while `clientRegistrationKind()`
  prefers the issuer's dynamic slot.

SDK v2 `auth()` persists the authorization-server metadata via
`saveDiscoveryState` *before* it reads or writes client information, and it takes
its URL-based-client-ID branch — rather than `registerClient` — exactly when that
metadata advertises `client_id_metadata_document_supported` and a
`clientMetadataUrl` is configured. So the branch is not inferred at all now: it is
read back from the state the SDK itself just wrote.

`resolveSdkRegistrationKind` becomes two cases. A save for an issuer that already
has a registration under this `client_id` is a back-stamp, answered by that
registration's recorded kind — so an existing DCR on the metadata URL stays
`dcr`. A save for an issuer with none is a new registration, answered by the
discovery state, guarded on the metadata describing that same issuer.

This drops `cimdClientMetadataUrl` and its storage accessors entirely, and
restores `ensureCimdClientRegistration` to a plain issuer-bound pre-registration.
Invalid-client recovery, second-issuer CIMD, second-issuer DCR minting the
metadata URL, and preflight failure are all consequences of the two cases rather
than separate mechanisms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 02:03:12 -04:00
cliffhall
abb6a47f7d fix: earn the CIMD marker, rather than writing it on AS support alone
Copilot review (#2287): the marker was written as soon as the AS advertised
`client_id_metadata_document_supported`, before checking what registration
already existed. RFC 7591 §3.2 leaves a dynamically issued `client_id` opaque,
so an existing DCR may carry the configured metadata URL — and marking the
issuer then relabels that real dynamic registration as CIMD on the SDK's next
issuer stamp.

The marker now records both facts it is read for: that this AS accepts the URL
as a `client_id`, *and* that the registration standing for it got there through
CIMD. It is written where this helper establishes the registration itself,
reaffirmed for one already recorded as `cimd` under that exact URL, and withdrawn
otherwise — which also covers a static client and an AS that has stopped
advertising CIMD.

Covered at both levels: a mocked case asserting the marker is withdrawn for an
existing DCR on the same URL, and an end-to-end real-storage case asserting such
a registration is still `dcr` after the SDK issuer-stamps it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 01:46:59 -04:00
cliffhall
4350731f36 docs: correct the saveClientInformation contract comment
Copilot review (#2287, suppressed comment): the comment still said internal
callers supply no issuer, which stopped being true when
ensureCimdClientRegistration started binding its save to the discovered issuer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 01:35:03 -04:00
cliffhall
4297ef8449 fix: record CIMD provenance as an issuer-keyed marker, not on the credential
Copilot review (#2287), three findings:

1. **invalid-client recovery lost the provenance.** SDK v2 `auth()` answers
   `invalid_client` / `unauthorized_client` with `invalidateCredentials("client")`
   and an immediate retry; our clear drops the registration *and* its kind, so
   the retry's URL-based client-ID save landed with nothing recorded and was
   stored as `dcr`. Provenance now lives in `cimdClientMetadataUrl`, an
   issuer-keyed marker on the issuer slot that records a property of the AS
   rather than a credential — `clearClientInformation` deliberately leaves it
   alone. `ensureCimdClientRegistration` writes it, and withdraws it when the AS
   stops advertising CIMD, on every connect.

2. **Discovery became a hard network dependency.** Moving the existing-client
   check after discovery meant a well-known outage could fail a reconnect the SDK
   would have served from its persisted discovery state. Reuse
   `provider.discoveryState()` first, and treat a discovery failure as "skip
   pre-registration" rather than an error — this helper is an optimization over
   what `auth()` does for itself.

3. **Dropped an unjustified double cast** in the test fetch helper; the async
   signature is directly assignable to `typeof fetch`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 01:34:03 -04:00
cliffhall
4c6a9fb376 fix: bind CIMD provenance to the issuer it was discovered for
Copilot review (#2287): the active-issuer fallback added last round could copy
issuer A's CIMD provenance onto a real dynamic registration for issuer B, since
RFC 7591 §3.2 permits B to mint the same opaque URL as its `client_id`.

Fix it at the source rather than in the resolver. `ensureCimdClientRegistration`
now runs discovery first and records the registration against the issuer it just
discovered, having confirmed *that* AS advertises
`client_id_metadata_document_supported`. Its "already registered?" check moves
after discovery and is keyed by that issuer — read ctx-less it resolved through
the active issuer and early-returned for every later one, which is what forced
the cross-issuer fallback in the first place.

`resolveSdkRegistrationKind` is therefore issuer-scoped again, with no fallback.
A second AS behind one resource now gets its own determination: CIMD when it
advertises CIMD, `dcr` otherwise — including when it mints the metadata URL as
its own `client_id`.

The cost is a discovery round trip per connect attempt rather than only the
first; noted at the call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 01:16:30 -04:00
cliffhall
f3f68c731b fix: keep CIMD provenance when a resource resolves to a second issuer
Copilot review (#2287): scoping the stored-CIMD check to the incoming issuer
alone mislabels the SDK's own CIMD write for a second authorization server.
SEP-2352 keys registrations per AS, so the first binding promotes the unkeyed
CIMD entry into issuer A's slot and clears the fallback;
`ensureCimdClientRegistration` then early-returns on its ctx-less read, and the
save under issuer B finds nothing recorded for B.

Check the issuer slot and then the server's active registration, so a second
issuer stays CIMD while a `client_id` the AS minted itself still falls through
to `dcr`. Covered by two tests driving a real `OAuthStorageBase` through the
A → B sequence, since the behaviour under test is how storage promotes and
clears slots rather than anything a mock would express.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 00:44:49 -04:00