Two defaults that each break the Inspector inside a container.
#1951 — the web UI never loads in a VS Code dev container. `HOST` defaulted
to the NAME `localhost`, and `server.listen(port, host)` resolves a name
through `dns.lookup` and binds the SINGLE address it gets back. Since Node 17
the default result order is `verbatim`, so on a glibc Linux host — whose
/etc/hosts maps localhost to both 127.0.0.1 and ::1 — that is `::1`. The
server bound IPv6 loopback only and refused every IPv4 client, including the
dev container port forwarder. It presents as an endless spinner rather than a
connection error because the forwarder accepts on the host side before failing
to connect inward. Default to the ADDRESS 127.0.0.1 instead; an explicit
HOST=localhost is still honored as typed.
#2008 — the Apps tab is unreachable in any container. The MCP Apps sandbox
listened on an OS-assigned port, so it changed every run and could never be
named in a devcontainer `forwardPorts`, a `docker run -p`, or a tunnel. Default
it to a fixed 6275, mirroring CLIENT_PORT's fixed 6274, and retry once on an
OS-assigned port (loudly) if it is taken — the collision a dynamic default
never had.
Verified in the reporter's exact image
(mcr.microsoft.com/devcontainers/typescript-node:4-24-trixie): both listeners
come up on 127.0.0.1 and answer 200 on 6274 and 6275/sandbox.
Also EXPOSE 6275 and set MCP_SANDBOX_PORT in the image, and document both
defaults in the web README, the migration guide, and the root README's Docker
section.
Signed-off-by: cliffhall <cliff@futurescale.com>
Closes#1964.
- README's Docker recipes dropped v1's `127.0.0.1:` prefix, so
`-p 6274:6274` published the Inspector on every host interface. The
container's `HOST=0.0.0.0` governs the container's interfaces, not the
host's, so `DANGEROUSLY_BIND_ALL_INTERFACES` never covered this. Verified
against a real container: from a LAN peer, `GET /` served the injected
`MCP_INSPECTOR_API_TOKEN`, and that token then unlocked `/api/*` with no
`Origin` header (server.ts:241 allows origin-less requests). Restore the
prefix on both recipes, on the port-remap examples, and in the web README.
- Adding a server failed inside the container the moment you mounted a
volume to keep it. Docker seeds a named volume's ownership from the
image's directory at the mount point, and creates it `root:root` when
that directory is absent — so the non-root `node` user got
`EACCES ... open '/home/node/.mcp-inspector/mcp.json.tmp-*'`. Create the
dir in the image, owned by `node`, and document the volume recipe (plus
the bind-mount `--user` caveat) — without a volume the catalog lives in
the writable layer and `--rm` throws it away.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a
Closes#1795.
Enforce localhost-only binding in code (the "never 0.0.0.0" rule was only a spec note): refuse an all-interfaces HOST — across every spelling the OS binds as the wildcard (inet_aton decimal/octal/hex, IPv6, IPv4-mapped, zone-scoped, IDNA/Unicode) — unless DANGEROUSLY_BIND_ALL_INTERFACES=true (Docker's sanctioned opt-in). Fixes the reported stdio-connect 403 by expanding the default loopback origin allow-list to all three interchangeable forms.
Hardening surfaced across an extended review: closes an IDNA/fullwidth wildcard bypass, a blank-ALLOWED_ORIGINS fail-open, and the CLI/TUI OAuth-callback wildcard bind (loopback-only now, RFC 8252); canonicalizes the origin allow-list the way a browser does; derives frame-ancestors from the real embedder list (IPv6-literal exclusion verified in headless Chromium); strictPort on all three bind points; and preserves the CLI exit-code map + JSON envelope through the mcp-inspector --cli launcher path. Shared host normalization lives in core/node/hostUrl.ts. Docs + tests + smokes throughout.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* refactor(web): sharpen src/lib vs src/utils rule and move misfiled modules (#1776)
Codify the empirical rule the web client already followed loosely — utils =
pure functions that compute; lib = things that instantiate, adapt, or touch
the environment — in AGENTS.md and clients/web/README.md, and move the modules
that sat on the wrong side of it.
Moves (source + co-located test):
- lib/sandbox-csp -> utils (pure CSP-string construction; type-only import)
- utils/browserTabVisibility -> lib (registers visibilitychange listeners)
- utils/oauthResume -> lib (reads/writes sessionStorage)
- utils/clearServerOAuthState -> lib (drives live InspectorClient / OAuthStorage)
Borderline audits (kept in place): pendingReauth (pure type declarations),
deepLink / correlateTransportErrors / mcpNetworkHeaders / oauthUx / oauthFlow /
oauthNetworkPhase (pure transforms / type-only core imports) stay in utils;
downloadFile stays whole in lib (dominant side is DOM side effects).
Also relocate the lone src/test/lib/downloadFile.test.ts straggler beside its
source at src/lib/downloadFile.test.ts, and document the test-placement rule
(side-by-side by default; src/test/ only for core/ tests, the integration
project, and shared scaffolding).
Pure move — no behavior change; no build/coverage config keys off the boundary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* refactor(web): address review — fix stale paths, move customHeaders, one-way arrows (#1776)
Responds to the @claude review on PR #1784:
1. Stale path references (fixed): update the four in-repo pointers left at
pre-move paths — specification/v2_auth_mid_session.md (oauthResume,
browserTabVisibility → lib/), and the two load-bearing sandbox security
comments (sandbox_proxy.html, sandbox-controller.ts → src/utils/sandbox-csp).
2. lib/types/customHeaders (moved): it's pure (types + pure transforms), the
rule's clearest counterexample. Moved lib/types/customHeaders.{ts,test.ts} →
utils/ (no external importers; only its own test) and removed the
undocumented lib/types/ subdir. Documented that utils/ is the home for pure
shared domain types + their constructors — no separate types/ bucket.
3. One-way arrows (item 3): declare OAuthResumeAuthKind in utils/pendingReauth
and re-export it from lib/oauthResume, so the only cross-directory arrow now
points lib → utils. Codified that invariant in AGENTS.md.
Wording nit: AGENTS.md + README now state diagnostic logging
(console.warn/error) doesn't count as a side effect for the utils rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* docs: address review round 2 — stale customHeaders refs, rule wording (#1776)
Doc-only follow-up to the round-2 @claude review on PR #1784 (no code changed):
1. Stale paths (same class as round-1 item 1): the customHeaders move left
three references to the deleted lib/types/customHeaders.ts path in
specification/ (v2_ux_interfaces_phase_0_4_inventory.md,
v2_ux_interfaces_plan.md ×2) — updated to utils/customHeaders.ts.
2. customHeaders anchor: note in AGENTS.md that its shape is staged for
ServerSettingsForm (per v2_ux_interfaces_plan.md), so its zero-importer
state reads as intentional rather than rot; kept as the no-types/-bucket
illustration and led the anchor list with load-bearing modules.
3. Type-only-import clause: split the conflated clause — a type-only import
(pendingReauth) and re-exporting core's pure functions/constants
(oauthUx/oauthFlow) are distinct reasons to stay in utils; what makes a
module lib is wrapping core's stateful runtime. Fixed in both AGENTS.md
and the web README.
Cosmetic: re-padded the v2_auth_mid_session.md table row to column width.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* docs: address review round 3 — scope the "no types/ bucket" claim (#1776)
Doc-only follow-up to the round-3 @claude review on PR #1784 (no code changed):
1. "no separate types/ bucket" overreached: clients/web/src/types/ does exist
(navigation.ts + react-syntax-highlighter .d.ts ambient stubs wired via
tsconfig.app.json paths). Scoped the claim in both AGENTS.md and the web
README to "no types/ sub-bucket INSIDE lib/utils", and noted src/types/ is
for ambient .d.ts stubs, not domain types.
2. README example list drifted from AGENTS.md's round-2 load-bearing anchors —
realigned it (inspectorTabs/mcpNetworkHeaders, dropped the zero-importer
customHeaders from the headline list).
3. Clarified v2_ux_interfaces_plan.md:185 so the v2 path reads as the copy's
destination, not as the v1.5 source location.
Follow-up filed as #1785 (board Todo): src/types/navigation.ts InspectorTab is
dead and duplicates utils/inspectorTabs.ts — kept out of this pure-move PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* docs: address review round 4 — reconcile src/types/ claim with #1785 (#1776)
Doc-only follow-up to the round-4 @claude review on PR #1784 (no code changed):
The round-3 scope fix overshot: it asserted src/types/ holds "not domain types
at all", but the dead navigation.ts InspectorTab is a plain-.ts domain type
still living there, and specification/v2_ux_interfaces_plan.md:181-182 actively
prescribes placing InspectorTab there — so AGENTS.md and that spec doc gave
opposite guidance about the same directory.
- AGENTS.md + web README: softened to "not the place for new domain types"
and added a parenthetical that the one lingering domain type (dead
navigation.ts) is tracked for removal in #1785; until then a pure domain
type belongs in utils/, not src/types/.
- v2_ux_interfaces_plan.md:181: marked the tab-enum placement guidance as
superseded by #1785 (live type is InspectorTabId in utils/inspectorTabs.ts).
Self-heals on merge of #1785.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* docs: address review round 5 — fix garbled #1785 clause, restructure utils rule (#1776)
Doc-only follow-up to the round-5 @claude review on PR #1784 (no code changed):
1. Grammar (item 1): the round-4 #1785 clause read "superseding [#1785] will
remove it" — a participle with no subject. Reworded to "the dead
navigation.ts InspectorTab, which #1785 will remove".
2. Readability (item 2): the src/utils/ bullet had accreted into a single
~1,400-char blob across five review rounds. Promoted the carve-outs to
scannable sub-bullets (Domain types / Diagnostic logging / Importing from
core) and lifted the src/types/ note out to its own line, since it's a
statement about a sibling directory, not about utils.
AGENTS.md only; the README's parallel sentence was already clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* docs+config: address review round 6 — correct the coverage-glob claim (#1776)
Responds to the round-6 @claude review on PR #1784:
1. "no build/coverage glob" was factually wrong (should-fix): the web coverage
include in clients/web/vite.config.ts DOES enumerate src/lib/** and
src/utils/**. The conclusion (a move between them is coverage-neutral)
survives, but the reason was wrong. Reworded both AGENTS.md and the web
README to say nothing *enforces* the boundary — no path alias, and the
include lists both dirs so a move is coverage-neutral — plus the important
whitelist caveat: a module outside components/lib/utils/server falls out of
the ≥90 gate entirely.
2. Removed a pre-existing duplicate glob in vite.config.ts (src/lib/**/*.{ts,tsx}
appeared twice, lines 161 and 163). Inert (vitest de-dupes) but it sat three
lines from the config the doc now describes.
Nits: gave the README bullet the same round-5 sub-bullet restructure as
AGENTS.md, and made its #1785 reference a real markdown link (bare #N doesn't
autolink in rendered repo markdown).
npm run ci green (config change re-verified through coverage + smokes + Storybook).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* build(web): fail vite build on a browser-externalized Node built-in (#1769)
Vite 8 (rolldown) externalizes a node:* / Node built-in that reaches the
browser graph with a warning and ships a `module.exports = {}` stub — the
build still succeeds, shipping a broken bundle. The runtime browser smoke
(smoke:web:browser, #1615) only catches the subset where that stub is *called*
at module init; a stub imported but never called ships silently and nothing
gates it.
Add a Vite build plugin that turns that specific browser-externalization
warning into a hard `vite build` error, moving detection upstream into
`npm run build` / `validate` and additionally catching the never-called case
the runtime smoke can't see.
- clients/web/server/browser-externalized-builtin-gate.ts: Vite-agnostic
detection + error + per-build state machine (unit-tested to the ≥90 gate).
- vite.config.ts: thin Vite wiring. A throw inside rolldown's onLog is
swallowed (the one hook where it doesn't abort — verified against vite@8.0.0),
so the gate records the warning in onLog and re-throws in buildEnd. Scoped to
`vite build` (apply: 'build'); the Node runner build (tsup) is unaffected.
- scripts/verify-build-gate.mjs + verify:build-gate (in npm run ci and the
GitHub workflow): runs a real build with a node:fs probe and asserts it fails
via the gate — the only check that catches the warning phrasing drifting in a
future Vite bump and silently disabling the message-keyed gate.
There is no stable log `code` on the rolldown warning (only message + plugin),
so the gate keys off the documented message phrasing; the drift guard above is
what keeps that safe across Vite versions. smoke:web:browser stays as the
runtime backstop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): harden the browser-externalized-builtin gate per review (#1769)
Review follow-ups on PR #1777:
- Per-build state: add gate.reset() called from the plugin's buildStart, so a
`vite build --watch` rebuild doesn't inherit a prior build's recorded warning
(the plugin instance is reused across rebuilds). Makes the docblock true.
- buildEnd(error) now only asserts on the success path — rolldown also calls
buildEnd(error) when the build already failed for another reason, and throwing
then would mask that real error with the #1769 message.
- verify-build-gate.mjs: drop the shell `rm -rf` cache-clear entirely (verified
the gate fires reliably without it — the earlier "caching" symptom was the
onLog-swallow behavior, not a stale cache); add a result.error spawn-failure
check so a missing `npx` isn't misdiagnosed as "not via the gate"; add
SIGINT/SIGTERM handlers so an interrupt during the multi-minute build still
restores the mutated entry; add a progress log before the spawn.
- Match the double-quote style of the sibling server/ files.
- Docs: list browser-externalized-builtin-gate.ts in the AGENTS.md server/ block
and the clients/web/README server/ list (with why a build helper lives under
server/), and add verify-build-gate to the root README scripts/ line.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-2 review hardening for the #1769 build gate
- Scope the gate to the browser (`client`) environment via applyToEnvironment,
so a future SSR/node environment built from this config isn't failed for a
legitimate node:* import — browser-only intent is now structural.
- Report ALL externalized built-ins in one build, deduped, instead of only the
first offender (N leaks no longer means N fix-and-rebuild cycles).
browserExternalizedBuiltinError now takes string[]; gate uses a Set.
- verify-build-gate.mjs: pin Vite with `npx --no-install` so the drift check
can never silently run a registry-fetched Vite instead of the repo-pinned one
(the whole point is version fidelity); append the probe instead of prepending
(ES imports hoist, and appending won't demote a future leading directive);
clarify the signal-handler comment (handlers are a backstop — a blocked
spawnSync tears down via the process group first).
- AGENTS.md: update both `ci` pipeline enumerations to include verify:build-gate
and drop the now-false "Storybook is the only CI step left out" claim.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-3 review polish for the #1769 build gate
- Error message: name the transitive-dependency remedy (a resolve.alias to a
browser shim) — "remove the import / gate behind the backend" doesn't apply
when the node:* import lives in node_modules.
- isBrowserExternalizedBuiltinLog is now a `message is string` type guard, so
recordLog drops its redundant `!== undefined` clause while still typing the
Set.add() — a real one-liner, not a coverage-only removal.
- verify-build-gate.mjs: docblock now states WHY it builds the real config +
entry (catches config-level regressions — plugin removed, onwarn suppression
added — that a temp config would miss), so the minute-long build isn't later
"optimized" into a temp entry and silently lose that coverage. Guard the
entry read so a renamed main.tsx fails through fail() with an actionable
message. On a botched restore, save the captured original to a sidecar .bak
and point there instead of recommending `git checkout --`, which would
discard uncommitted edits.
- AGENTS.md: fix the muddled CI-steps sentence (GitHub CI runs the whole chain
as separate steps, Storybook last) and note the applyToEnvironment client-only
scoping in the gate prose.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-4 review polish — sharper verify-build-gate diagnostics (#1769)
All in scripts/verify-build-gate.mjs's failure paths:
- Split the "build SUCCEEDED" failure into three diagnoses keyed off the
captured output, so it names the right file: warning present but build passed
→ plugin not applying (removed from `plugins`, `applyToEnvironment` env-name
mismatch, or an `onwarn` suppression); node:fs present but phrase absent →
phrasing drift, update the constant; neither → probe never reached the graph.
The old message hard-coded "phrasing drifted", which misdirects for the
config-regression class this script exists to catch. Added applyToEnvironment
to the docblock's config-regression list too.
- Bound the build with a 10-min spawnSync timeout (+ SIGKILL); a timeout sets
result.error (ETIMEDOUT) and reports via the existing branch, instead of
burning to the GitHub job's 360-min default with no output.
- Guard the probe write so a read-only/EACCES checkout fails through fail()
with an actionable message instead of escaping as a raw stack.
- SIGTERM now exits 143 (was 130, which is SIGINT's code).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-5 review polish — self-consistent verify-build-gate (#1769)
All in scripts/verify-build-gate.mjs:
- Drift guard: assert the gate source file still contains the mirrored
KNOWN_PHRASE literal, failing fast if the two drift. Without it, a stale
KNOWN_PHRASE after a phrasing update would make the three-way diagnosis
misreport a plugin-not-applying regression as a phrasing drift — the exact
misdirection the diagnosis exists to avoid.
- Make the .bak safety net reachable on the path that needs it most: restoreEntry
now catches its own write failure and routes to a shared saveBackupAndFail
helper, instead of letting the write escape the finally as a raw stack (which
skipped the .bak fallback and left the probe injected in the entry).
- Add the fourth "plugin isn't applying" cause to the diagnosis: a future Vite
emitting the warning before the client env's buildStart reset clears it (same
symptom as the other three).
- Flag once, on the failing-check path, that clients/web/dist holds a probe
build so a local debugger doesn't serve it unaware.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-6 review polish — verify-build-gate literal contracts (#1769)
All in scripts/verify-build-gate.mjs; one theme — the literals the script
asserts on can each go stale independently of what they mirror:
- Success key: assert on the gate's distinctive thrown-error prefix
"Build failed (#1769)", not a bare "#1769". The issue number also appears in
vite.config.ts comments, so a code frame from an unrelated build error there
could otherwise make the check report OK with the gate dead — the one
direction it must never get wrong (a false pass hiding a dead gate).
- Drift guard now anchors on the BROWSER_EXTERNALIZED_BUILTIN_PHRASE assignment
(regex) instead of a whole-file substring match, so the old wording lingering
in a comment can't mask a changed constant; and its readFileSync of the gate
module is guarded so a moved file fails through fail() actionably.
- Extract PROBE_MODULE ("node:fs") used to build both the PROBE and the
diagnosis check + messages, so changing the probe module can't silently
misroute a phrasing-drift failure into "probe never reached the graph".
- Comment the load-bearing diagnosis order (phrase before module-name).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-7 review polish — drift-guard both mirrored literals (#1769)
- Extract ERROR_PREFIX ("Build failed (#1769)") and drift-guard it against the
gate module too, and use it as the success key. Previously only KNOWN_PHRASE
was guarded; the prefix is mirrored across the same TS/.mjs boundary for the
same reason, so rewording the gate's thrown error would make a correctly-firing
gate report "the build broke for another reason" (benign direction, but the
same misdirection the other guards close).
- Write the restore-failure .bak under the OS temp dir, not next to the entry:
a sidecar inside src/ is untracked and a `git add -A` (likely while recovering)
would sweep it into a commit.
- Branch-(b) remediation now says update BOTH the gate constant AND KNOWN_PHRASE,
so a phrasing-drift fix lands in one pass instead of tripping the drift guard
on the next run.
- Note next to BROWSER_EXTERNALIZED_BUILTIN_PHRASE that it must stay a single
string literal (the script's drift guard anchors a regex on the assignment).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-8 review polish — safe recovery path + structural onLog order (#1769)
- saveBackupAndFail no longer claims a backup it may not have written: it tracks
whether the write succeeded and only points the user at the .bak in that case
(otherwise it says the probe is still injected and to restore from VCS). Uses
mkdtempSync for a unique dir instead of a fixed tmp filename, so a
pre-existing/foreign-owned /tmp file can't make the recovery message point at
someone else's content.
- Guard the restore-verification readFileSync too, routing a failure through
saveBackupAndFail so the whole restore path is uniformly safe (a raw throw
there would skip the backup net).
- vite.config.ts: enforce: 'pre' on the gate plugin so it runs ahead of the
normal-plugin group in the onLog chain — a plugin whose onLog returns false
filters the log for later plugins, so trailing the array would let a future
log-filtering plugin silently blind the gate. Makes "the gate sees every log"
structural rather than array-position-dependent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-9 review polish — prove the gate fired on the probe (#1769)
The success key confirmed the #1769 gate fired, but not that OUR probe tripped
it. If the repo already leaked a Node built-in, the gate would fire on that and
the script would report OK even if the probe was tree-shaken or entryPath went
stale — branch (c)'s failure silently inverted into a pass. Add an assertion
that PROBE_MODULE appears among the gate's embedded offender warnings. Unreachable
inside `npm run ci` (validate's build:web fails first on a pre-existing leak),
but this script is a documented standalone command — run exactly when debugging
a leak, the case where this mattered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-10 review polish — roll back a partial probe write (#1769)
The probe writeFileSync uses the default 'w' flag (truncates at open), so a
mid-write ENOSPC/EIO could leave clients/web/src/main.tsx empty or half-written
— and its catch called fail() directly, the one path on this script that could
destroy the entry rather than merely dirty it (every other read/write is already
routed into the recovery net). Call restoreEntry() in that catch to roll the
partial write back (falling through to the .bak net if the restore itself can't
write), and correct the comment that claimed a write failure happens "before any
mutation". Also widen the enforce:'pre' comment to note it orders ahead of Vite's
core plugins too (harmless — the gate defines no resolve/load/transform).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-11 review polish — don't let the rollback swallow the write error (#1769)
Round 10's restoreEntry() in the probe-write catch swallowed the primary error:
restoreEntry() on its own write failure calls saveBackupAndFail → fail → exit,
so the "could not write the probe" diagnosis never ran when the rollback also
failed — which is exactly the read-only-checkout / ENOSPC case (both writes fail
at open). On a read-only checkout nothing was ever written, yet the developer
was told the restore failed and pointed at a /tmp backup of an untouched file.
Roll back inline instead: attempt the restore without exiting, track whether it
succeeded, then always report the PRIMARY write error, appending "restore from
version control" only when the rollback didn't take. Also pin the
"Build failed (#1769)" error prefix as contiguous in a single string fragment
(a comment symmetric with the phrase pin), since verify-build-gate.mjs
drift-guards on it as ERROR_PREFIX.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-12 review polish — don't let the pin comment defeat its own guard (#1769)
The round-11 pin comment quoted "Build failed (#1769)" verbatim, so the prefix
appeared twice in the gate module and verify-build-gate.mjs's whole-file
includes(ERROR_PREFIX) drift guard passed on the comment alone — rewording the
thrown error would have kept the guard green while a correctly-firing gate
reported "the build broke for another reason." Reworded the comment to not repeat
the full lead (matching how the phrase pin avoids repeating its phrase); the
literal now occurs exactly once, restoring the guard's meaning.
Also make the probe-write recovery message factual instead of inferred: read the
entry back and only warn "restore from version control" when it actually differs
from the original. On a read-only checkout both writes fail at open, so the file
is untouched and the old hedge needlessly implied damage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes#1577
Extends the deep link (#1576) to land on a rendered MCP App:
- `&openApp=<toolName>` — once connected and the tool appears in the app list,
InspectorView switches to the Apps tab and pre-selects it.
- `&appArgs=<base64url(JSON)>` — pre-fills the tool form; values are merged
**over** `collectSchemaDefaults()` so required-with-default fields don't
disable "Open App".
- `&autoOpen=<token>` — token-gated auto-click of "Open App".
Adapted to the current architecture (differs from PR #1510's reference):
- Tab state is lifted to App.tsx (#1417), so the openApp effect lives in
InspectorView and drives `onActiveTabChange("Apps")` + `onAppsUiChange` seed
+ `onSelectApp`, guarded by `deepLinkOpenAppRef` to fire once.
- AppsScreen owns the `running`/iframe state, so `autoOpen` is threaded to it
as a prop; a fire-once effect defers the open one microtask past the
synchronous effect body (satisfying `react-hooks/set-state-in-effect`,
matching App.tsx's connect effect). `resetAppChannels` memoized so the
effect's deps don't churn.
Tests: InspectorView (tab-switch + pre-select + appArgs-over-defaults merge +
non-app openApp ignored), AppsScreen (autoOpen fires once with seeded values;
no-selection stays idle). README documents the params. Coverage gate green.
Part of the #1579 decomposition (Wave 4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Closes#1576
Adds `?serverUrl=<url>&transport=http|sse&autoConnect=<token>` URL-driven
auto-connect so a driver can reach a connected inspector with one navigate.
- `clients/web/src/utils/deepLink.ts`: `parseDeepLink()` (http(s)-only
serverUrl; `autoConnect` CSRF-gated on the per-launch API token),
`deepLinkParseStatus()`, `deepLinkConfigEquals()`, `DEEP_LINK_SERVER_ID`
(stable `deep-link` row so reloads reconnect to the same catalog row).
- App.tsx: ensure/connect effects (two-phase upsert-then-connect against the
hydrated list), `serversRef` read in `onToggleConnection`,
`connectErrorMessage` / `recordConnectError` recording connection-level
failures.
- InspectorView header: `data-testid="connection-status"` with `data-status`,
`data-error-message`, `data-deeplink` so drivers can `waitForSelector` and
read failure reasons without scraping toasts.
- Docs: deep-link scheme + `data-*` contract in clients/web/README.md.
Re-implemented informed by PR #1510 (33fac3f); adapted to the post-#1417
lifted-tab architecture and dropping the non-existent `normalizeServerUrl`
import in favor of `URL.href` canonicalization. Coverage gate green
(deepLink.ts 97.8/95.1/100/97.4).
Part of the #1579 decomposition (Wave 4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* web: sandbox hardening — opaque-origin inner frame, srcdoc-only, frame-ancestors
Harden the MCP Apps sandbox isolation model:
- The inner (untrusted-widget) iframe is sandboxed WITHOUT `allow-same-origin`
(default "allow-scripts allow-forms") and any server-supplied sandbox value
has `allow-same-origin` stripped case-insensitively, so the widget runs under
an opaque origin and cannot reach the proxy's DOM to bypass its own CSP via
the parent realm.
- Widget delivery is `srcdoc`-only (no document.write), which respects the
opaque origin.
- The relay accepts the inner frame's "null" origin with
`event.source === inner.contentWindow` as the unforgeable identity check.
- sandbox-controller.ts serves the proxy with
`Content-Security-Policy: frame-ancestors http://127.0.0.1:* http://localhost:*`;
fetch directives are deliberately omitted so a srcdoc iframe's inherited
policy container does not override the per-app CSP.
Updated the isolation-model comments and the sandbox-controller integration
test asserts the frame-ancestors header (and the absence of fetch directives).
Closes#1565
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* web: enforce per-app _meta.ui.csp in the app bridge + surface resource-read failures
- createAppBridgeFactory now builds each app's `_meta.ui.csp` host-side via the
sandbox-csp library (approveCspSources → buildSandboxCspPolicy) and wraps the
untrusted widget HTML with wrapSandboxedHtml so the CSP <meta> is always the
first <head> child before the HTML reaches the sandboxed iframe. The approved
(post-filter) csp + permissions are echoed back via hostCapabilities.sandbox
so the view sees what was granted. The csp is no longer sent inline through
sendSandboxResourceReady — it is enforced via the wrapped meta.
- A per-app copy of HOST_CAPABILITIES prevents the sandbox echo from mutating
the shared constant.
- New optional `onResourceError` dep on AppBridgeFactoryDeps routes resource
read/post failures to the caller (also always console.error'd) instead of
leaving a silent blank frame.
Closes#1566
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* web: full hostContext delivery + live host-context-changed updates
- createAppBridgeFactory now seeds the full hostContext via
snapshotHostContext(iframe, HOST_AVAILABLE_DISPLAY_MODES): theme, styles
(Mantine design tokens), displayMode ("inline"), availableDisplayModes, and
containerDimensions — replacing the theme-only seed. The local currentTheme
helper is dropped in favor of hostContext.ts.
- AppRenderer gains `displayMode` and `containerRef` props and pushes live
host-context changes via the SDK's sendHostContextChange (partial
host-context-changed notifications), never a hand-maintained snapshot:
- theme + styles via a MutationObserver on <html data-mantine-color-scheme>,
- containerDimensions via a ResizeObserver on the host container (or the
iframe fallback), gated on `initialized`, skipping 0x0 and value-equal
repeats, plus a one-shot push on initialize once layout settles,
- displayMode whenever the prop changes (Maximize/Restore), gated on init.
Adds AppRenderer host-context tests (theme flip incl. resolved styles,
displayMode push + init gate, container-dimensions on init and resize, observer
teardown) and updates the factory + story mocks for the new hostContext shape
and sendHostContextChange method.
Closes#1567
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* web: honor ui/notifications/size-changed and ui/request-display-mode
AppRenderer:
- New `onSizeChange` prop forwards the view's ui/notifications/size-changed
reports (via the bridge `sizechange` event).
- New `onRequestDisplayMode` prop backs an `onrequestdisplaymode` bridge handler
that returns the mode the host actually applied; with no handler the request
is declined by returning the current host-side mode (falling back to inline).
AppsScreen:
- Adds a host-controlled RendererContainer (measured for containerDimensions)
with an inner box sized by the view-reported height (`appHeight`), so
host→view container size and view→host size-changed don't couple.
- `handleSizeChange` records the reported height (ignored while maximized);
`displayMode` is derived from the maximize toggle and pushed to the view;
`handleRequestDisplayMode` filters against HOST_AVAILABLE_DISPLAY_MODES
(declining e.g. "pip") and maps fullscreen↔inline onto the maximize state.
Adds AppRenderer tests (size forwarding, request-display-mode apply/decline/
fallback) and AppsScreen tests (frame sizing on report, no-height ignore,
fullscreen/restore/decline via request-display-mode). Story mocks gain
sendHostContextChange/addEventListener.
Closes#1568
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* web: honor ui/download-file with confirmation and http(s) allowlist
createAppBridgeFactory now advertises the `downloadFile` host capability and
attaches an `ondownloadfile` handler:
- Requires a host-mediated confirmation (window.confirm) listing the requested
file(s); a declined prompt or an empty payload returns isError without acting.
- Inline EmbeddedResources (text or base64 blob) are written to disk via the
shared downloadBlob object-URL anchor; base64 is decoded via base64ToBytes.
- ResourceLinks are opened in a new tab, restricted to http(s) by the shared
isHttpUrl allowlist (javascript:/data:/file:/malformed are rejected).
- Labels shown in the confirmation are sanitized (control + format chars
stripped, length clamped) so a server-supplied filename/URI can't inject
newlines or reflow the prompt.
- Partial-batch success is surfaced (isError only when nothing downloaded), with
skipped items warned.
Adds factory tests for the full download surface (text/blob/link, decline,
empty, throw, non-http rejection, label sanitization/clamping, partial batch).
Closes#1569
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* web: surface ui/message and app logging in panels below the running app
AppRenderer:
- New `onMessage` prop backs an `onmessage` bridge handler that surfaces the
view's ui/message content and returns the spec-required empty result;
with no handler the submission is declined (isError).
- New `onLog` prop forwards MCP `notifications/message` log entries via the
bridge `loggingmessage` event, honoring the advertised `logging` capability.
AppsScreen:
- Adds `messages`/`appLogs` state (cleared with the reported height on
select/open/close/back via resetAppChannels) and `handleMessage`/`handleLog`.
- Renders a "Messages from app" panel (data-testid="apps-messages", reusing
ContentViewer) and a default-expanded collapsible "App logs" panel
(data-testid="apps-logs", reusing LogLevelBadge) with logger names and a
Clear button, both as pinned panels below the running widget.
Adds AppRenderer tests (log forward + no-handler no-throw, ui/message
route/empty-result/decline) and AppsScreen tests (message log render + empty
result + clear-on-close, log panel expand/collapse/clear).
Closes#1570
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* web: stage partial input — replay tool-input-partial before final tool input
AppRenderer:
- New `partialInputs` prop (ordered fragments), snapshotted into pendingPartials
at bridge-build time (read via a ref so prop churn never rebuilds the iframe)
and cleared on dispose. flushPending replays them via
bridge.sendToolInputPartial BEFORE the complete tool-input, per spec.
AppsScreen:
- New `partialStages` state + `handleStagePartialInput` (snapshots current form
values). resetAppChannels gains a `keepPartials` option so Open App preserves
the staged fragments (they're consumed by the renderer) while select/close/
back clear them.
- Adds a "Stage partial input" control (fielded apps only) with a staged count
and "Clear staged", wired to AppRenderer's `partialInputs`.
Adds AppRenderer tests (ordered replay before tool-input; none when omitted) and
AppsScreen tests (stage/count/clear gated on fields; staged partials survive
Open and are replayed).
Closes#1571
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* web: app lifecycle status + machine-readable data attributes on the Apps screen
AppRenderer:
- New AppRendererStatus ("loading" | "ready" | "error") + onAppStatusChange
callback. Fires "loading" at the start of every (re)build, "ready" on the
view's initialized signal, and "error" on a factory throw/rejection.
AppsScreen:
- appStatus/appError state; handleAppError captures the reason locally and
forwards to the parent onError. resetAppChannels clears both.
- Surfaces the contract as data-testid="apps-form" carrying data-app-status and
data-app-error, renders an error panel (data-testid="apps-error") in place of
the blank frame, and tags the stage button data-testid="apps-stage".
- AppDetailPanel's Open App button gains data-testid="open-app".
App.tsx:
- Wires the bridge factory's onResourceError to a red toast so a malformed/404
UI resource is no longer console-only (the renderer separately drives
data-app-status, so a driver times out on never-reaching-ready and reads it).
Documents the data-testid/data-* automation contract in clients/web/README.md.
Adds AppRenderer status-transition tests and AppsScreen status/error-panel
tests.
Closes#1572
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: warn when server sandbox drops allow-scripts + revert incidental lockfile churn
Addresses the claude[bot] review on PR #1650:
- sandbox_proxy.html: log a console.warn when a server-supplied `sandbox`
value lacks `allow-scripts` after the `allow-same-origin` strip, so a
fail-closed (app-never-runs) sandbox is diagnosable instead of silent.
- Restore clients/{cli,launcher,tui,web}/package-lock.json to their
v2/main state — the earlier churn (@emnapi/@napi-rs optional deps, a
dropped version field) was incidental `npm install` regeneration
unrelated to the sandbox change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: document CSP-vs-permissions filtering asymmetry (PR #1652)
Address the claude[bot] review's finding #2: add a comment explaining why
`csp` is injection-filtered (approveCspSources) while `permissions` is passed
through verbatim — permissions is a structured boolean object consumed only by
the sandbox proxy's buildAllowAttribute() (fixed key→token mapping), so it
never reaches the iframe sandbox/allow attribute as raw text. No logic change.
Also merges v2/1565-sandbox-hardening (review fixes) up the stack.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* test: lock in per-app HOST_CAPABILITIES isolation (PR #1652 round 2)
Add a test asserting the shared HOST_CAPABILITIES.sandbox stays undefined after
a sandboxready run sets hostCapabilities.sandbox — guards against a silent
regression if the per-app spread copy were ever dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: gate theme observer on initialized + document seed/dep assumptions (PR #1654)
Address the claude[bot] review:
- Finding #1 (applied): gate the theme+styles MutationObserver on the view's
`initialized` signal, matching the container/displayMode pushes — a theme
flip in the construction→handshake window no longer races ui/initialize. The
factory seeds the construction-time theme, and the first post-init flip
carries the current value. Added tests: pre-init flip is dropped; the live
flip test now completes the handshake first.
- Finding #2 (applied, comment): note in snapshotHostContext that the
`displayMode: "inline"` seed assumes inline-at-open (AppsScreen always opens
inline; #1568's displayMode push carries transitions).
- Finding #4 (applied, comment): explain why `containerRef` is a build-effect
dep yet doesn't force a rebuild (sameInputs ignores it; read lazily).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: guard 0-height size reports, distinct RendererFrame, shared payload type (PR #1656)
Address the claude[bot] review:
- #1 (applied): handleSizeChange ignores a non-positive height — a view's
ResizeObserver can transiently fire 0 pre-layout/teardown, which would
collapse the frame; mirrors AppRenderer's 0×0 skip. Added a test.
- #2 (applied): reinstate a distinct `RendererFrame` constant for the inner
content-sized box so its role reads clearly vs. the outer RendererContainer.
- #3 (declined + comment): keep carrying `appHeight` across maximize→restore —
clearing it would flash the frame to full-card height until the next
size-changed; the carry-over restores at the prior size. Documented.
- #4 (applied): type handleSizeChange's param as AppBridgeEventMap["sizechange"]
so the payload shape has a single source of truth with AppRenderer's prop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: distinguish links, cap batch size, comment truncation (PR #1657)
Address the claude[bot] review:
- #1 (applied): the confirmation now reads "download or open N item(s)" and
resource_links are prefixed "↗" in the summary, so a link that opens in a tab
is distinguishable from an embedded file that saves to disk.
- #2 (applied): cap a single ui/download-file batch at MAX_DOWNLOAD_ITEMS (20);
an oversized batch is rejected (isError) before the prompt, with a warn.
- #3 (declined): can't check window.open's return value to detect a blocked
popup — we pass `noopener`, and per spec window.open returns null even on a
successful noopener open, so null can't distinguish blocked from succeeded.
- #4b (applied, comment): note the start-of-label truncation is intentional so a
link's scheme+host stays visible for the trust decision.
Adds tests: oversized batch rejected without confirming/acting; resource-link ↗
prefix in the summary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: skip embedded resource with neither text nor blob (PR #1657 round 2)
Address the round-2 observation: an untrusted EmbeddedResource carrying neither
`blob` nor a string `text` previously produced `new Blob([undefined])` — a file
containing the literal text "undefined", counted as success. Now such an item
is skipped (returns false, like a rejected link), so isError reflects it. Added
a test asserting no object URL is created and the batch reports isError.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: type-honest formatLogData, cap channels, log-panel a11y (PR #1658)
Address the claude[bot] review:
- #1 (applied): formatLogData coalesces JSON.stringify(undefined) → "" so the
`: string` return type is honest for a data-less log.
- #2 (applied): cap retained messages/logs at MAX_APP_CHANNEL_ENTRIES (500) via
appendCapped — a chatty widget no longer grows the panels/DOM without bound
between Clear/close; oldest entries drop.
- #3 (declined): keep the Messages panel without a Clear/collapse — messages are
sparser and already cleared on close/switch; keeping it simple.
- #4 (applied): the App-logs toggle now has aria-controls pointing at the
Collapse region (id="apps-logs-region") for assistive tech.
Adds tests: no-data log renders (coalesce branch), 501 logs cap at 500 (oldest
dropped), aria-controls assertion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: stable id key for Messages panel (PR #1658 round 2)
Address the round-2 nit: give AppMessage a stable `id` (assigned via
nextMessageIdRef in handleMessage) and key the Messages panel on it instead of
the array index — so the appendCapped front-drop can't renumber keys once a run
exceeds the cap, matching AppLogEntry's treatment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: note partialStages intentionally survives handleOpen (PR #1659)
Address the claude[bot] review's observation #2: add a comment at the
handleOpen call site clarifying that partialStages is intentionally NOT cleared
there — AppRenderer snapshots the fragments into its own pendingPartialsRef at
build time, and the staging UI only renders while not running, so the surviving
state is invisible until the next reset drains it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
* review: align error-panel docs with behavior + gate data-app-error on running (PR #1660)
Address the claude[bot] review:
- #1 (applied, docs): the error panel renders BELOW the (blank) frame, not in
place of it — the renderer stays mounted so a factory error still shows the
reason alongside the iframe (gating the frame off on error would drop the
auto-launched iframe an integration test relies on). Reworded the README row
and the test comment to say "below the frame"; added a code comment.
- #2 (applied): gate data-app-error on `running` (like data-app-status) so the
two attributes can't desync if a future path sets an error without running.
- #3 (no change): resource-read failures staying at data-app-status="loading"
(toast only) is intentional and documented.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The clients/web README was the default "React + TypeScript + Vite" template
(plus a proxy note). Replace it with a web-client-specific guide: the src/
(browser) vs server/ (Node) split and what each server/ file does, the build
pipeline (tsc -b → vite build → build:runner, producing dist/ + build/), the
component layers (elements/groups/screens/views) and theme system, the three
Vitest projects (unit happy-dom / integration node / storybook Chromium) and
the ≥90% coverage gate, Storybook (dev + play-function browser tests), and the
auth-token flow. Preserve the existing HTTP-proxy section. Points at the root
README/AGENTS.md for the repo-wide picture rather than duplicating it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
undici 8.5.0 requires Node >= 22.19.0 (its declared engines), so CI's
Node 20.x could not load undici's CacheStorage (missing
webidl.util.markAsUncloneable) and the proxy integration tests failed.
Bump the CI setup-node to 22.x to match the repo's Node floor.
Reconcile the declared floor with undici's real requirement: bump root
engines.node to >=22.19.0 and correct the CLI README note (undici 8.5.0
needs >=22.19.0, not >=22.7.5). Also document proxy support in the web
README and clarify that a caller-supplied eventSourceInit.fetch bypasses
the proxy wrapper by design.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX