- progressTokenOf now delegates to the SDK's ProgressTokenSchema instead of a
bare typeof check, so fractional and past-MAX_SAFE_INTEGER numbers are
rejected (matching isProgressToken in inspectorClient.ts); tests cover both.
- Browser relay timeout tests use fake timers with advanceTimersByTimeAsync and
flush the SSE consumer between pushes, replacing real-sleep windows that a
loaded runner could race (#1596).
- The log-only timeout test holds and awaits its rejection rather than
swallowing it in an empty catch.
- progressToken.test.ts gains a file header; the remote-session malformed-token
fixture uses a single-cast helper instead of `as unknown as`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
The web client drives its MCP Client through a two-hop relay
(RemoteClientTransport -> Node backend -> upstream transport). The relay
added two request-response waits that the direct transports don't have —
the backend's RemoteSession.waitForRequestResponse and the browser's
RemoteClientTransport.waitForSseResponse — and both were hardcoded to 60s
and blind to notifications/progress. The SDK Client's own request timeout
resets on progress (resetTimeoutOnProgress, which the Inspector enables),
but the relay's progress-blind waits fire first and shadow it, so a
long-running tool that reports progress is cancelled at 60s on the web
client alone while it completes over the CLI/TUI and other MCP clients.
Reset both relay waits on a notifications/progress whose progressToken
matches the pending request id, mirroring the SDK's resetTimeoutOnProgress.
A shared browser-safe progressTokenOf helper extracts the token. Log
messages (notifications/message, i.e. ctx.info) deliberately do not reset
the deadline, matching the SDK and every other client.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
- requestAppElicitation.ts, appElicitation.ts, docs/test-servers.md:
ext-apps 2.0.0 does not "predate" #733 (the PR is from July; the
release from September) — it simply does not include it. Reworded as
omission, and the version the appElicitation.ts JSDoc names is now
2.0.0 rather than 1.7.5.
- core/mcp/types.ts: the public `AppRendererClient` JSDoc described the
app-renderer proxy; it is the real SDK `Client`, handed out only while
connected, since #1745 removed the proxy.
- appCapabilities.test.ts: the two comments verified against 1.7.5 now
cite 2.0.0, which the test runs against.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
Every site tagged `ext-apps#702` carried a workaround for ext-apps
peering on the v1 SDK while the Inspector runs v2. With 2.0.0 the
package's `AppBridge` extends the v2 `Protocol`, takes the v2 `Client`
and `Implementation` directly, and registers its list-changed forwarding
on that client with `setNotificationHandler("notifications/…", handler)`,
so:
- core/mcp/apps.ts: `getToolUiResourceUri(tool)` takes the v2 `Tool`
without a cast; `UiMetaShape` is replaced by ext-apps' own
`McpUiToolMeta` / `McpUiResourceMeta`, which resolve under NodeNext
since ext-apps#705. `AppInfo.csp` / `.permissions` / `.visibility`
take the named types too.
- core/mcp/inspectorClient.ts: `getAppRendererClient()` returns the SDK
client itself; the Proxy that translated the 1.x schema-first
`setNotificationHandler(Schema, handler)` into the method-string form,
its `notificationMethodFromSchema` helper and its cached field are
gone. `AppRendererClient` is now `Client` rather than `unknown`.
- createAppBridgeFactory.ts: `new AppBridge(client, HOST_INFO, …)` with
no `as unknown as`; `resolveSourceUri` passes the tool straight through.
- AppRenderer.tsx: `sendToolResult(result)` — its params ARE the v2
`CallToolResult` now.
- requestAppElicitation.ts: the double cast on the `elicitation/create`
send goes too — v2 `Protocol.request` takes the standard `Request`
shape, which `ElicitRequest` is. The helper itself stays: ext-apps#733
is not in 2.0.0.
- core/mcp/extensions.ts: `MCP_APP_MIME_TYPE` re-exports ext-apps'
`RESOURCE_MIME_TYPE` from `/app-bridge`; `UI_EXTENSION_KEY` stays
hardcoded because `EXTENSION_ID` is still `/server`-only in 2.0.0.
Tests: the Proxy-translation test is removed with the Proxy; the
`getAppRendererClient` tests assert the real `Client` instance and that
disconnect withdraws it; the `useInspectorClient` sentinel is a real
(unconnected) `Client`. Comments that cited ext-apps 1.7.5 now cite 2.0.0
where the fact still holds (#733 still unreleased).
Closes#1745
Refs #2309
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
The "Long Skill Document" story compared the section heights before and
after a collapse-then-reopen of the Skill Resource panel, and #2291 wrapped
the second sample in a `waitFor` on the reading that the first was fine and
the second was taken mid-layout. It was not: `[…, 155, 285]` is the layout
with the Frontmatter section showing `CodeHighlight`'s plain `Code`
placeholder (a 57px `<pre>`), and `[…, 178, 262]` is the same layout once
the lazily imported Prism runtime and YAML grammar have landed and the
highlighter has replaced it (an 80px `<pre><code>`). Both are settled; the
section's content changed between the two samples, so no retry converges.
Whether the swap has happened when the story reaches its first sample
depends on how long those dynamic imports take. On a quiet machine an
earlier story in the file has already loaded them; under the gate's load
they land between the samples. Running the story on its own, so the cache
is cold, reproduces the failure deterministically: 3/3 red with the exact
four numbers from #2350, 3/3 green with this change, 3/3 red reverted. The
whole storybook project under a concurrent unit-suite load (load average
38-51) is 3/3 green with it.
The wait's budget is stated at the site: three dynamic imports through the
Vite server every story iframe shares measure 100-150ms cold on an idle
server and exceeded Testing Library's 1000ms default under load, so it
takes the same 10s the Ace-worker wait in `JsonObjectInput.stories.tsx`
carries, under the project's 15s `testTimeout`.
Closes#2350
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
The 5000 arms of #2335's re-run each had exactly one red test — the
asyncUtilTimeout guard refusing the raised value — so "0 failures in
every arm" was a misstatement; both comments now say so. And the
AGENTS.md rule still gave the expiring-wait mechanism as the reason the
pin was measured worse, which the corrected record contradicts; it now
cites both measurements and keeps the mechanism as the hazard the rule
guards against.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
Both web test-setup comments explained the 1000ms pin by saying the
tests that let a Testing Library wait expire "are the real defect and
are #2335; revisit when they are gone." #2335 found there are no such
tests, and its interleaved three-arm re-run showed a raise to 5000 is a
no-op on the passing path. The comments now record that finding and
keep the pin on the measured ground: raising buys nothing, so the value
stays until a measurement says otherwise.
Comment-only; the pin and the runtime assertion are unchanged.
Closes#2347
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
The storybook project's optimizeDeps now comes from
`getStorybookOptimizeDeps()` in `vite-base-config.ts`, beside the dev-server
helper it mirrors, so the `force` flag and the base include/exclude it carries
are asserted by `vite-base-config.test.ts` rather than living only in an
inline object a config refactor could drop.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
Round 8 of the review asked for an AST. That was the right diagnosis of the
wrong problem: the question was never which parser to use, it was why a
test-timeout guard was parsing JavaScript at all.
It got there one reasonable step at a time. The guard enforces two rules no
Vitest config can report — a per-test `retry`, and Testing Library's
`asyncUtilTimeout` — so it read them out of source text, and the review found a
new valid spelling it missed in five consecutive rounds: a trailing comment,
nested parens in `it.each([makeCase()])`, a member-access prefix, the shorthand
`{ retry }`, an aliased import, a non-literal title. Each fix was correct and
each made it more parser-shaped, until it carried eight helpers doing quote
tracking and bracket balancing.
Both rules are observable at runtime, exactly:
- `vitest.setup.shared.mts` is loaded by all six projects and reads
`ctx.task.retry` — the value Vitest resolved for the test. That covers
strictly more than the scan did: a per-test option, a `describe` option
inherited by children, a project-level setting, and a `--retry` flag typed at
a command line, which the scan could never see since it only read npm scripts.
- `clients/web/src/test/asyncUtilTimeout.test.ts` asserts
`getConfig().asyncUtilTimeout`, the value this project's own waitFors read.
Neither has a spelling to anticipate, so neither can be outrun by a new one.
Both were observed firing before being trusted.
The guard keeps only what it can answer by asking Vitest: resolved budgets,
project-level retry, config discovery, and that each project still loads the
setup file — the one thing about the runtime assertion a config CAN report.
868 lines to 361, every scanner helper gone, and 33 of 48 unit tests deleted
because they pinned edge cases of a thing that no longer exists.
storybookSetup.ts goes with it. At the library's own value its configure() call
was a no-op, and the assertion is what pins the number.
AGENTS.md gains the general rule: a rule about what the code DOES is asserted at
runtime, not read out of the source — reach for a verify:* guard when the
question is about configuration, which a tool can be asked to resolve.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
No inline comments this round; all four came from the suppressed block.
The AppRenderer one is a regression I introduced by combining two changes that
were individually right. #2323 deleted that `waitFor`'s `{ timeout: 5000 }` as a
budget nested inside an equal one, which it was — and then round 2 pinned
asyncUtilTimeout at 1000, which is the exact default the site's own comment
records failing twice. The raise is restored: the enclosing budget is 15000 now,
so it is observable rather than unwinnable, and the value it beats is a measured
failure rather than a library default.
The guard could approve a configure() imported from the wrong Testing Library
copy — `cleanup` from @testing-library/react and `configure` from
@testing-library/dom satisfied two independent regexes while configuring a copy
no test calls. It now resolves the binding `configure` has in the import from
the required module, follows an alias, and reads asyncUtilTimeout out of that
call rather than out of the file. Three regression tests, including the split
import and a decoy constant elsewhere in the module.
secret-store-selection's 60s exception is hoisted to
LOCK_RETRIES_EXHAUSTED_MS, matching file-lock.test.ts, which bounds the same
wait — the new rule asks for named as well as justified.
And the testing skill still prescribed `HEADER_ANIM_MS + 200`, so a test written
by following it would have bypassed the shared slack constant this change
introduced. It now prescribes `HEADER_ANIM_MS + RAF_SLACK_MS` and says why the
two terms are separate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
The inline finding was a real bug: main() never called the discovery check, so
the deny-by-default claim rested on test:scripts happening to run the same
comparison — a different command, which `npm run verify:test-timeouts` does not
invoke. `failures` is now seeded from it, and a probe config under clients/ was
observed to fail the guard.
Three suppressed findings, all correct. The afterEach in oauth-interactive
restated the shared hookTimeout and labelled it as a round trip, when it only
stops a server and unlinks a file; it now carries no budget. The file-lock
constants were named for the opposite of what they bound — 90s covers retries
running out against a holder that never yields, 60s covers the stale-lock
takeover that succeeds — so they are LOCK_RETRIES_EXHAUSTED_MS and
STALE_LOCK_TAKEOVER_MS. The browser-timeouts header said 15 budgets; it is 19.
Separately, and more importantly: the asyncUtilTimeout raise is reverted, on
measurement. Verifying round 2 turned up intermittent 15s timeouts across
unrelated unit files, so three arms of three full unit runs were taken in this
worktree at comparable load — unmodified v2/main 3/3 green, this change with
5000 producing 1, 4 and 0 timing-out files, and the same change with that one
line reverted 3/3 green. A wait that is MEANT to expire spends its whole budget
on the happy path, so a 5x raise is a 5x cost on exactly those tests and
saturates the worker pool, starving tests that were never slow.
Both web setup files therefore state asyncUtilTimeout at the library's own
value rather than raising it, which is what #2323's rule actually asks for: a
budget nobody chose is the defect, not a budget that is small. The tests that
assert absence by letting a wait expire are the real problem and are #2335.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
All nine findings taken.
Two budgets I had lowered are restored as named exceptions rather than folded
into the shared value. cli's e2e child timer goes back to 15s and the two tests
that spawn it get E2E_SPAWN_MS = 25s, so the bound carrying the diagnostic still
wins without taking load tolerance off the one suite that spawns a real process.
App.test.tsx's three-modal sequence keeps THREE_MODAL_SEQUENCE_MS = 20s — a
value chosen against an observed failure, which the shared ceiling would have
replaced with a general one.
Three real false-positive paths in the guard are closed. It now discovers every
Vitest config under clients/ and fails on one it is not configured to check —
without which "a project with no row is an error" was vacuous, since an
undiscovered config yields no project to reject. It strips comments before
looking for configure() and requires the exact expected value, so neither a
commented-out call nor asyncUtilTimeout: 1 passes. And it compares each
resolved project's setupFiles against the site it claims to configure, so
deleting a setupFiles entry can no longer leave both projects reported as
configured while Testing Library silently reverts to 1000ms. Each has a test,
and the setupFiles check was observed to fire against a real deletion.
Three stale comments the change had invalidated: main.yml still counted four
durable guards, PreviewAnnotations.stories.tsx still said the storybook project
had no setupFiles, and the new AGENTS.md rule read as though no Group F row had
been settled when this PR settles F6. browser-timeouts.test.mjs gains the
purpose header this repo's files carry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
Three of the six Vitest projects ran on Vitest's own testTimeout: 5000 and
five on its hookTimeout/teardownTimeout: 10000, and all 788 Testing Library
waits in the web unit project ran on asyncUtilTimeout: 1000. None of those
numbers was chosen here, and all of them were sized for an idle machine rather
than for three or four concurrent worktree sessions each running a full
local:gate on eight cores. #2292, #2278, #2250, #1942 and #1742 were each that
class, diagnosed one site at a time.
This is the complement of #1596, not a retreat from it: every fix that issue
produced stands, and no race is hidden here. What changes is that a correct,
deterministic test is no longer cut off by a budget nobody picked.
- vitest.shared.mts exports frozen TIMEOUTS / INTEGRATION_TIMEOUTS; all six
projects spread one of them, clients/launcher importing the shared module for
the first time to do so.
- Both web projects configure Testing Library's asyncUtilTimeout. The storybook
one needs its own setup file, deliberately outside .storybook/ and free of
setProjectAnnotations so the automatic preview-annotation provisioning
(#1898) is untouched.
- 28 per-suite restatements of the integration project's own budget are gone,
so raising the shared value now moves every site under it. The handful that
genuinely differ — cross-process lock contention, a full OAuth round trip —
keep their budget as a named constant that says why.
- AppRenderer's waitFor no longer asks for exactly its enclosing budget, which
it could never win (#2292's shape); cli's e2e child timer drops below the
project budget so the bound carrying the diagnostic is the one that fires.
- The transition auto-settle's slack term is named RAF_SLACK_MS and raised;
the term derived from HEADER_ANIM_MS is untouched. The two answer to
different things and must not be scaled as one number.
- The 19 Playwright budgets scattered across three web smokes become four
named constants in scripts/lib/browser-timeouts.mjs.
scripts/verify-test-timeouts.mjs resolves each project through Vitest itself
and asserts the budgets, the asyncUtilTimeout sites, and that retry stays
unset. It was observed red against the pre-change config: unit/tui/launcher at
testTimeout 5000, five projects at hookTimeout 10000, both asyncUtilTimeout
sites unmet.
Closes#2323
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 1 on #2329. Four findings, all correct.
The whole-modal claim was false for the three rows whose value is a Badge:
`ThemeBadge` defaults to `fw: 600`, so Transport, Era and Status still rendered
bold-label/bold-value. A `ValueBadge` constant carries the normal weight for
Transport and Status, and `EraBadge` takes an optional `fw` so Connection Info
can ask for it without changing the standalone chip in ProtocolListPanel. The
chip still reads as a chip — its emphasis is the outline and colour, not the
font weight.
Three test weaknesses, each of which let a regression through:
- The weight assertion used `queryByText(...)?.style.fontWeight ?? ""`, which
passes vacuously when the row is absent entirely. Now `getByText`, and
deleting a value row fails three tests instead of none.
- The full-width test asserted only the transparent background, so swapping
`FullWidthField` back for the two-column `SimpleGrid` would still have passed.
It now asserts the structure: label and value are siblings, and the value is
in no SimpleGrid.
- `OAuthTokenField`'s bold caption was asserted nowhere — the weight test
renders no token. A case now covers Access Token and ID Token through it.
Note for anyone asserting a Badge's weight later: `getByText` lands on the
inner label span and `fw` is applied to the root, so the assertion has to walk
up or it reads an empty string and passes against anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB
Signed-off-by: cliffhall <cliff@futurescale.com>
Client ID sat third in OAuth Details, between Status and Client registration,
so its label-over-value block interrupted the run of inline two-column rows and
broke the scan down the label column for everything after it. Access Token and
ID Token already used that layout, at the bottom.
Moved Client ID and Auth URL down to sit with them: the inline rows now run
Protocol, Status, Client registration, IdP session, Scopes, and the full-width
fields follow as one group — Auth URL, Client ID, Access Token, ID Token — so
Client ID is directly above the only other field a reader sees laid out the
same way.
A test asserts the order off the DOM rather than each field's presence, since
order is the property; putting Client ID back in its old slot fails it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB
Signed-off-by: cliffhall <cliff@futurescale.com>
Connection Info rendered every label/value row as a plain label against a bold
value. That is backwards for a reference panel: the label is the fixed
scaffolding a reader scans down, the value is what differs per connection, and
bolding every value means nothing stands out. Reversed across the whole modal —
Server Implementation, Discovery and OAuth Details, plus the token captions —
rather than in one section, since two halves disagreeing is worse than either
convention.
Client ID and Auth URL were a Code block in the right half of a two-column grid,
so a long value got roughly half the modal width. A CIMD client id IS a URL, so
it is long by construction, and it broke mid-token —
`…/client-metadata.` / `json` — which reads as a rendering fault rather than as
one value. Both now put their label on its own line with the value full width
beneath, the way OAuthTokenField already lays out a token, and neither carries
an inset surface any more: the grey framed a value that is simply the answer to
"Client ID", not a quoted blob needing separation from prose.
The transparent treatment is a new `wrapping-plain` theme variant rather than a
change to `wrapping`. That one is shared with ElicitationUrlPanel,
ListLoadError, MalformedItemsWarning and the CSV fallback, where the inset is
doing real work; a test asserts `wrapping` keeps its surface so those consumers
cannot be changed by accident.
Closes#2328
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB
Signed-off-by: cliffhall <cliff@futurescale.com>
Third and last site of the same class. `ReadableStream.cancel()` adopts
the underlying source's cancel promise, which is permitted never to
settle, so awaiting it kept the handler pending forever — and on an
unbounded request there is no timer to release it either. The cancel is
started and the handler returns, so the JSON response and the `finally`
complete regardless.
The `void` carries the justification for which of AGENTS.md's permitted
cases it is: the callee owns its failures through its own `catch`, and
the caller cannot await.
Test: a `/stream-hostile` upstream that sends event-stream headers and
one frame and then never closes, asserting the route still answers 200
with no body.
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>
`ReadableStream.cancel()` adopts the underlying source's cancel promise,
which is permitted never to settle — so awaiting `releaseBody` while
propagating a cancellation can hang the very thing it was meant to end.
The five exceptional exits in the compat probe loop now start the
release without waiting on it: the release is a courtesy to the
connection pool, and the caller's abort or timeout must reach it
regardless. The two non-exceptional awaits are unchanged.
Same class as the tee teardown in round 24, one file over.
Also moves the `redactUrlQuery` import above
DEFAULT_OAUTH_REQUEST_TIMEOUT_MS's JSDoc, which it had split from the
constant — so hover and API tooling see the doc again.
Verified by mutation: awaiting the release makes the new test time out.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
requestTimeout.test.ts had no file-level header, which AGENTS.md
requires of every file; it now explains what each group of cases pins
and why, including why the composition cases are written as a contrast.
The two mock fetches I added used `as unknown as typeof fetch`, which
AGENTS.md rules out without an inline justification. A contextually
typed forwarding function keeps the vitest mock inspectable and needs
no cast at all.
The PR description still described the pre-fix cancellation flow; it is
rewritten to say that the signal now reaches the backend's outbound
fetch 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>
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>
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>
Copilot review round 5. Two of three findings taken; the third rests on a false
premise about the SDK and is declined with evidence in the PR thread.
The hint and the input description are part of this fix's user-facing contract —
an HTTPS-only instruction there puts the local-fixture workflow back out of
reach even while the validator stays permissive — and nothing asserted them.
Two cases now do, plus one asserting a loopback http URL raises no error in the
form. Each mutation (restoring either piece of old copy) fails exactly one of
them.
The docs said there was "no loopback exemption at all" before #2305, which
contradicts the sentence above it: the runtime already tolerated an `http://`
already-stored `client_id`, and that asymmetry is what the issue is about. The
gap was in the config/form validator, and the wording now says so.
Also added: a note to use the canonical spelling of the host. `127.1` and
`2130706433` are accepted here and by the SDK, since both canonicalize to
`127.0.0.1` — but a CIMD `client_id` is string-compared by the authorization
server against the URL it dereferenced, so an exotic spelling is a real footgun
independent of what either validator allows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB
Signed-off-by: cliffhall <cliff@futurescale.com>
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>
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>
Copilot review round 2.
The exemption compares the WHATWG-canonicalized `URL.hostname`, so it covers
alternate spellings of three loopback *addresses* rather than three input
strings: `127.1`, `2130706433`, `0x7f.0.0.1`, `[0:0:0:0:0:0:0:1]` and a
root-anchored `127.0.0.1.` all pass. The behavior is right — the security
argument is about the address the request reaches, and all of these are the
loopback interface — but the comment described it as a literal-input match,
which it is not. It now states the contract, and six regression cases pin the
aliases alongside the existing near-miss cases.
This is also still an exact match for the SDK: `assertSecureTokenEndpoint` tests
`url.hostname` against the same three values after the same `new URL`
canonicalization, so an alias accepted here is one the SDK accepts too.
Separately, the smoke guide's fixture recipe left the shell in `clients/web`
after the build line, so the repo-root-relative paths on the next line resolved
under `clients/web` and the copy-pasted setup failed before the server started.
The build now runs in a subshell, and the block notes that the server announces
its URL on stderr and walks past a taken port.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB
Signed-off-by: cliffhall <cliff@futurescale.com>
`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>
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>
`getCimdClientMetadataUrlError` rejected every non-HTTPS CIMD client metadata
URL with no loopback exemption, and the same check runs against `client.json`
on disk through `refineCimdMetadataUrl`, so it could not be worked around by
hand-editing the file either. Every test server in this repo speaks plain HTTP,
so CIMD could not be driven against any local fixture: #2242 shipped verified by
tests alone, and reproducing it by hand meant standing up a self-signed HTTPS
listener plus `NODE_TLS_REJECT_UNAUTHORIZED=0` in the test server's environment.
Accept plain `http:` on the three literals the SDK itself exempts from its
token-endpoint TLS assertion — `localhost`, `127.0.0.1` and `[::1]`. That is
option 1 in #2305 and matches what the runtime already tolerated:
`core/auth/cimd.ts` notes that an already-stored `client_id` may be an `http://`
URL used by local dev/test metadata servers. Anything outside those three,
`localhost.` and `tenant.app.localhost` included, is still rejected — matching
the SDK literal for literal keeps the two allow-lists from disagreeing about one
URL.
The exemption is a literal set rather than `isLoopbackHost` because
`core/client/config-parse.ts` is browser-safe and `core/node/hostUrl.ts` imports
`node:net`.
The field error now names the exemption instead of saying only "must use
HTTPS", and the form's hint and description say the same, so a developer
pointing the field at a local fixture is told which spellings work.
Closes#2305
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfHQ8vMm5NBgzbUJ6UwxmB
Signed-off-by: cliffhall <cliff@futurescale.com>
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>
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>
Validate `oauth.clientMetadataPath` the way the other two metadata paths
are validated — in `loadConfig` for the JSON route and again at server
setup for a `ServerConfig` built in code. This path is not merely
advertised: it becomes the served document's own `client_id`, so an
off-origin or query-bearing value would publish a client id this server
cannot honour.
Derive that `client_id` from `req.originalUrl` rather than from the
registered route, so a document fetched with a query string answers with
a `client_id` byte-identical to the URL it was fetched from — the
equality CIMD turns on.
Correct the docs' fixed-port warning: this fixture configures no
`issuerUrl` and derives the `client_id` from the request, which is
exactly why the integration test can drive it on a harness-chosen port.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWGH2ytPzSPxnp4cibaV3Z
Signed-off-by: cliffhall <cliff@futurescale.com>
In CIMD (SEP-991) the `client_id` *is* a URL the authorization server
dereferences to learn the client's metadata. Nothing in this repo served such a
document, so a fixture could advertise `client_id_metadata_document_supported`
and still be unusable — exercising CIMD meant standing up a second host by hand.
That is why #2242 shipped verified by its tests alone, and why the v2.6.0
release smoke recorded it as the one contribution with an observable UI surface
that could not be reached.
The composable server now hosts the document itself when `oauth.clientMetadata`
is set, gated on `supportCIMD` — advertising a client the server would then
refuse to honour is a worse fixture than serving nothing. The document's
`client_id` is derived from the **request** rather than from a configured
issuer, so it stays correct when the harness picks the port, as the integration
test does.
`supportDCR: false` in the fixture is load-bearing rather than incidental. With
DCR available a CIMD failure silently succeeds via dynamic registration and the
reproduction proves nothing — during development a misconfigured run connected
happily and reported `Dynamic (DCR)` with a `test_client_…` id, which reads as
success until you check the client id. With DCR off, CIMD is the only path that
can complete, so reaching a connected state is itself the assertion; the test
pins the absent `registration_endpoint` so it stays that way.
Verified end to end against the fixture: Connection Info read
`Client registration — Client ID Metadata (CIMD)` with the client id equal to
the metadata URL. That drive needed a self-signed HTTPS listener, because the
Inspector requires the CIMD metadata URL to be HTTPS with no loopback
exemption — filed separately as #2305, since it is a validation gap rather than
a fixture one.
Closes#2306
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsmR1iQstcrEzJFmgZXGLi
Signed-off-by: cliffhall <cliff@futurescale.com>
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>
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>
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>
**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>