Two review findings from round 2, both correct.
1. The regression suite killed its child only after the assertions. If
`assert.rejects` or either assertion failed, execution never reached the
kill, leaving a 60-second child attached to the runner — a suite guarding a
process leak would leak one itself, precisely on the run where the
regression had come back. Teardown is now registered from `onSpawn` via
`t.after`, so it runs on every path out of every case. That is the helper's
own ownership rule applied to the tests; killing an already-exited child is
a no-op, so the early-exit cases pay nothing for it.
2. Document the new shared helper, per the maintenance rules. AGENTS.md's
scripts/lib tree entry now carries announced-child.mjs and what its contract
buys, and the smoke:web:app paragraph records why the spawn/readiness step
moved out of the smoke (same reason teardownWebServer left prod-web-server's
stop()). README's test:scripts row names the new suite alongside
resolve-node-bin's.
Not mirrored into .github/copilot-instructions.md: that file is a distillation
of the rules a reviewer cites against a diff, and deliberately excludes the
project-structure tree and tooling inventory.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
`lint` failed only on errors, so a warn-level rule was a channel nothing
consumed. `react-hooks/exhaustive-deps` reported two stale-closure
`useCallback` deps in App.tsx on every run, `npm run ci` passed anyway,
and the defect was caught only by a review round on #2076.
Add `--max-warnings 0` to all six lint scopes — each of
clients/{web,cli,tui,launcher}'s `eslint .`, plus the root's `lint:core`
and `lint:shared` — so a warning fails `lint`, and therefore `validate`
and `ci`. All six were already warning-free, so no suppressions, rule
downgrades, or ignore-list widening were needed.
Also promote `react-hooks/exhaustive-deps` to `error` in both React
scopes (web, tui). The flag makes severity irrelevant to the gate; it
does not make it read correctly in an editor.
Document the invariant in AGENTS.md as a new "Lint has no warning tier"
section, note it in the README beside the type-aware-linting paragraph,
and mirror it into .github/copilot-instructions.md.
Signed-off-by: cliffhall <cliff@futurescale.com>
The command reference still ended `npm run smoke` at `smoke:web:app` and
credited the shared prod-web-server helper with three consumers. Adds the new
smoke to both, plus its own entry alongside the sibling smokes: what it drives,
why the not-negotiated half is the load-bearing one, and the two mechanics that
are easy to get wrong (the tabs are a SegmentedControl with no `role="tab"`,
and the prompt string also appears in the hidden Protocol payload).
Signed-off-by: cliffhall <cliff@futurescale.com>
Let a server hand a form `elicitation/create` to an MCP App and return the
app's ordinary `ElicitResult`, with the native elicitation form as the
fallback on every failure.
No second extension, no custom method, and no custom result shape: the only
new wire surface is a nested `elicitation` flag on the existing
`io.modelcontextprotocol/ui` extension on each side, plus
`_meta.ui.resourceUri` on the request. App rendering is selected only when
all four gates hold — client `elicitation.form`, client MCP Apps MIME type,
the nested `elicitation` setting on BOTH peers, and a valid absolute `ui://`
URI on the request.
Only the web client advertises the nested client-side setting, and only
because it has a sandbox renderer: supplying
`InspectorClientOptions.appElicitation` is what opts a client in, so CLI and
TUI keep advertising the MIME type without ever claiming they can resolve an
elicitation through an app.
Routing lives in the one funnel both entry points already use
(`enqueuePendingElicitation`), so the inbound handler and the MRTR driver
cannot diverge. Ownership is request-scoped — a per-request id keys the
renderer, iframe and bridge — so two concurrent elicitations can never
resolve through each other's bridges. An explicit `decline`/`cancel` is a
completed elicitation and goes back to the server; everything else (absent or
malformed metadata, resource/sandbox/bridge failure, an app with no
elicitation capability, a timeout, an invalid result or one that fails the
requested schema) falls back.
The Inspector speaks the ext-apps#733 / SEP-3118 wire protocol but cannot yet
consume its helpers — the released `@modelcontextprotocol/ext-apps` (1.7.5)
predates that PR. `core/mcp/appElicitation.ts` and
`AppRenderer/requestAppElicitation.ts` mirror it exactly and are marked for
deletion once a release containing it ships. One consequence needed its own
seam: ext-apps 1.7.5 parses the view's `ui/initialize` through a schema that
strips `elicitation`, so an app that correctly advertises it would look like
one that did not, silently turning every negotiated elicitation into a
fallback. `AppRenderer/appCapabilities.ts` records the raw frame instead, and
prefers the bridge's own value once it carries the key.
`AppRenderer` now takes an `AppRenderSource` union rather than a `Tool`, so
the same renderer and bridge factory serve an App tool and an elicitation
without either faking the other's shape.
Adds the public fixture (`app_choose_option` + a self-contained
`ui://demo/choose-option.html` app covering accept/decline/cancel), its two
showcase configs, and `smoke:web:elicit`, which drives the negotiated path
end to end in headless Chromium and then the same tool against a server that
never advertised the capability, asserting the native form takes it.
Closes#1854
Signed-off-by: cliffhall <cliff@futurescale.com>
Closes#1950
Before this, a box with no reachable OS keychain — the published
container (no D-Bus session, #1848), Android/Termux (no platform binary,
#1905), a minimal Linux install without libsecret — could not persist an
OAuth client secret or a stdio `env:` value at all. `KeyringSecretStore`
degraded into a store that silently holds nothing: `get` returned null,
`delete` no-opped, and `set` hard-failed with a 503. That is the correct
failure behavior, but it left the documented, supported way to run the
Inspector with no secret persistence whatsoever.
Adds the two missing implementations and, more importantly, the policy
that picks one and the surfaces that say which was picked.
- `FileSecretStore` — one `0600` JSON document at
`~/.mcp-inspector/secrets.json`, encrypted with AES-256-GCM whenever
`MCP_INSPECTOR_SECRET_KEY` is set (scrypt, per-file random salt). The
whole map is encrypted as a unit rather than value-by-value so the
account names — `${serverId}:${field}` — are hidden too; a per-value
scheme would leave a readable index of which servers you hold a client
secret for. A file that can no longer be decrypted reads as empty
(matching the interface's read tolerance) but *refuses to be written*,
because replacing a file of still-valid secrets to satisfy an additive
request destroys data.
- `secret-store-selection.ts` — the policy.
`MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` wins outright;
otherwise probe the keychain and, if unreachable, fall back loudly. The
fallback is `memory` in a container whose secrets directory is not on a
mount, and `file` otherwise — so mounting the volume the README already
recommends for the catalog flips the same run to durable storage with
no configuration, and an unmounted container gets the honest answer
rather than a file `--rm` will discard. Cached per process so the
banner, `/api/config`, and the store doing the writing cannot disagree.
- Automatic, but never silent. The downgrade prints at startup, rides
`GET /api/config`, and is named in a permanent 32px footer at the
bottom of both dialogs that accept a secret. A startup banner is seen
once by whoever started the process; a toast is seen once; a
dismissible banner is the thing a user dismisses before doing the work
it describes. The plaintext and memory cases render their caveat inline
in a warning tone — a warning you must hover to discover is not one.
- `SecretStoreUnavailableError` is now the base every store's `set`
throws, so the routes translate one condition into a 503 instead of
matching `KeychainUnavailableError` by name.
Docs: the container section of the root README gains a table of which
store you get and how to make secrets survive, plus the
`MCP_INSPECTOR_SECRET_KEY` upgrade/mismatch rules.
Tests: the existing interface contract block is extracted to
`secretStoreContract.ts` and run against all three implementations, so
none can drift on the availability contract. `FileSecretStore` is tested
against a real temp filesystem — mode, cross-instance round-trip, that
the ciphertext does not contain the plaintext, the read-modify-write
race, and every refusal path. Full `npm run ci` passes, per-file gate
included.
Signed-off-by: cliffhall <cliff@futurescale.com>
verify:dep-lockstep is a durable guard but not a coverage guard — the two
coverage guards are verify:format-coverage and verify:typecheck-coverage,
which is the terminology the rest of the repo uses. The sentence said three
and then named two.
Signed-off-by: cliffhall <cliff@futurescale.com>
Adds an AGENTS.md rule under the pre-push gate naming the gated surface
(first-party source) and everything excluded from it (each client's build/,
web's dist/ and storybook-static/, coverage/, test-servers/build,
core/**/{build,dist}, *.tsbuildinfo), why gating generated output is harmful
even when it only warns, and the fact that the three coverage guards assert
the converse and so cannot catch this drift. Mirrored into
.github/copilot-instructions.md per the maintenance rules.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 3, all three real:
- `require.resolve.paths()` also appends Node's GLOBAL FOLDERS
($HOME/.node_modules, $HOME/.node_libraries, $PREFIX/lib/node) and any
NODE_PATH entries, so round 2's lookup could satisfy a missing repo
install from a GLOBALLY installed typescript/vite — measuring the
programs with the wrong compiler instead of producing the actionable
"run npm install" this helper promises. Filter to the node_modules
directories on fromDir's own ancestor chain.
- The quoting predicate missed characters cmd.exe still parses, notably
parentheses (`C:\Temp(1)`), and quoting cannot suppress `%VAR%` at all
— expansion happens before quotes are processed. Extracted to
lib/win-shell-args.mjs with a broad predicate and a hard error on `%`
rather than a silently different path. `platform` is a parameter so the
Windows behavior is exercised on the POSIX machines that run the suite.
- AGENTS.md claimed pack-and-verify spawns via `process.execPath`; it does
not, and cannot — its children are `npm` and the installed `.bin` shim.
State the exception and why.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01387r3MotGk52hrkfjzHFsN
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 2 (suppressed comments), all three real:
- `require.resolve("<pkg>/package.json")` is governed by the package's
`exports`, and Node keeps no special case for `./package.json` — so a
package declaring an `exports` map without it threw
ERR_PACKAGE_PATH_NOT_EXPORTED even though its bin was installed and
spawnable, a false "not installed" pointing at `npm install` for a
package already on disk. Walk `resolve.paths()` instead, which never
consults `exports`. The docblock claiming the opposite is corrected.
- `shell: true` (needed at all on Windows to start the `.cmd` shims) makes
Node hand `cmd.exe` one space-joined string, so an argument holding a
path with a space splits in two. Every generated path in
pack-and-verify lives under `tmpdir()`, which sits beneath the user
profile. Quote them via `shellArgs()` at all four spawn sites, the
command path included — the hazard predates this PR, so fix the class.
- Document the new module: `scripts/lib/resolve-node-bin.mjs` in AGENTS.md's
scripts/ tree, its tests in the README's `test:scripts` row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01387r3MotGk52hrkfjzHFsN
Signed-off-by: cliffhall <cliff@futurescale.com>
Addresses Copilot's round-8 review on #2035 (one inline comment, one
suppressed). Both real.
1. RFC 6570 distinguishes an UNDEFINED variable, which omits its whole
expression, from one defined as the empty string, which expands. The
expander was calling `definedValues` on the way in, collapsing the two --
so `x{?q}` with `{ q: "" }` gave `x` instead of `x?q=`, and there was no
way for any caller, `readResourceFromTemplate` included, to request
either that or `x;q`. Key presence is now honored, including §3.2.7's
rule that the matrix operator drops the `=` for an empty value (`x{;q}`
-> `x;q`, while `?`/`&` keep it).
Dropping blanks is a FORM concern, so it moved to the two form
boundaries: the web panel seeds every declared variable with "" and the
TUI's ink-form returns "" for every untouched field, so neither can tell
"never touched" from "deliberately empty" -- but that is a fact about a
text input, not about the template, and it has no business deciding what
a template means for every other caller.
Six unit tests and one integration test encoded the old collapse and now
assert the corrected meaning. The prototype-collision cases get stronger
rather than weaker: a blank `{?toString}` expands to `?toString=` -- its
OWN empty value -- where the original bug expanded the inherited
function's body.
2. The expansion error is now `role="alert"`. It can appear while typing (a
pasted value that cannot be encoded), and plain text is not announced, so
a screen-reader user met a disabled Read Resource with no stated reason.
npm run ci passes.
Signed-off-by: cliffhall <cliff@futurescale.com>
Ports the two behaviors PR #2033 (the parallel attempt at #1919, now closed
as a duplicate) got right and this branch did not.
1. An expression declaring no variable is a malformed template, not one
with a member to skip. RFC 6570 requires at least one varspec per
expression and admits no empty member, so `{}`, `{,}`, `{a,}`, `{?}`
and `{*}` (an explode modifier is not a name) now make the template
invalid. Skipping them was the more dangerous reading: `x://{}`
expanded to `x://` while the form rendered no inputs, so its
"everything required is filled" check was vacuously true and it
submitted a URI that is not the template the server published.
2. The read is withheld when expansion fails. `expandUriTemplate`'s
raw-template fallback exists for the preview, which runs during render
-- but the submit path used it too, so an invalid template was read
with its braces intact and the server answered with a confusing "not
found" for a defect that is not the user's. `tryExpandUriTemplate`
returns the URI or the reason as a value the caller cannot mistake for
one; the panel gates Read Resource on it and prints the reason.
This also covers a value that cannot be encoded -- an unpaired
surrogate has no UTF-8 encoding, so `encodeURIComponent` throws
`URIError` on it, and a text input can hold one via paste.
`requiredGroups` now skips an expression naming no variable: an empty
group can never be satisfied, so it would gate the form a second time on
a condition nothing can meet, and the "any one of" hint built from it
would name no fields. The accurate reason is the malformed template,
which the expansion gate reports.
npm run ci passes.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot round 2 (PR #2039) surfaced no new visible comments but six
suppressed ones. Four were actionable.
`clients/tui/src/App.tsx` — a stale-completion race in the new banner:
start disconnecting server A, switch to B, then let A's disconnect
reject, and A's failure appears under B. Rapid same-server retries had
the same shape, and the stale attempt is the one most likely to reject.
Claim a monotonic attempt token before awaiting and publish only if it
is still current and the server has not changed; `handleConnect` bumps
the same counter so a connect retires an in-flight disconnect. The
rejection is still owned when superseded — just not published. The
regression test drives a deferred rejection across a server switch, and
fails without the guard.
`tsconfig.lint.json` — the root type-aware block matches
`test-servers/src/**/*.tsx` but the parser project omitted that
extension, so such a file would have failed with "not found in any of
the provided project(s)" instead of being linted. Added, and AGENTS.md
now states the invariant: the include must stay a superset of the lint
globs.
`clients/web/server/{server,vite-hono-plugin}.ts` — the `open()`
rejection branch was untested, and duplicated. Both were fixed by
removing the duplication: `openBrowser` now lives beside
`printServerBanner` in `web-server-config.ts`, with one implementation
to test. Two copies of a one-line `.catch` is two chances for one to
drift back to a bare `open(url)`. The dev plugin is not booted in tests
— it returns early under VITEST by design — but it calls the same
tested helper.
`core/mcp/remote/node/server.ts` — `closeAbortedStream`'s rejecting-close
path, the entire reason the helper exists, had no test. Exported it (as
this file already does for `requestIdForSendWait` / `mcpParamHeadersOnly`)
and covered both legs, asserting no unhandledRejection fires.
Also corrects AGENTS.md, which said each client lists "both" tsconfig
projects — true for cli/tui/launcher, but web lists four leaf projects.
Signed-off-by: cliffhall <cliff@futurescale.com>
Addresses Copilot's round-5 review on #2035 — eight suppressed comments,
all of them real.
The consequential one overturns behavior I copied from the SDK and
defended in two earlier rounds. RFC 6570 expands each expression
independently; it carries no cross-expression state. The SDK tracks
whether a query expression has already emitted and rewrites a later
`{?two}`'s `?` to `&`. That looks friendlier and is wrong, and its own
matcher says so: for `x{?one}{?two}` it expands to `x?one=1&two=2`, and
`match("x?one=1&two=2")` on that same template returns **null**, while
`match("x?one=1?two=2")` returns both variables. So the rewrite emitted a
URI the advertised template cannot match — #1919 one level up. A server
that wants a continuation advertises `{?one}{&two}`, which expands to `&`
and matches; producing that shape is the server's choice to declare, not
ours to infer.
Also:
- `{id:abc}` was silently treated as `{id}`, and a test codified it. RFC
6570's max-length is `%x31-39 0*3DIGIT`, so `{id:}`, `{id:0}`,
`{id:abc}` and `{id:10000}` are invalid templates rather than templates
with an ignorable modifier — and the SDK's constructor accepts them all,
so nothing else rejects them. Strict expansion now throws; lenient
returns the raw template. Discovery stays lenient so the panel still
renders rather than going blank.
- The TUI could submit `{a,b}` completely blank while the web panel
blocked the same request: ink-form has no way to express "any one of
these", so its members are left optional and the modal needs its own
group check. Added, with tests — it was also below the coverage gate
without them (functions 76.92%).
- Added the integration test the config never had: it resolves the
checked-in config and drives all four reads over a real transport, so a
misspelt preset fails there rather than only when someone runs the repro
by hand.
- "All three clients" was false. The CLI has no template form and its
`resources/read` passes the already-expanded `--uri` straight to
`readResource`, so nothing here runs for it. Corrected in the README,
AGENTS.md, and the module comment, and dropped a docblock still
describing the SDK-delegation path removed last round.
Signed-off-by: cliffhall <cliff@futurescale.com>
Addresses Copilot's round-4 review on #2035 — again reported as "no new
comments" while carrying three suppressed ones. All three reproduced.
1. The TUI form still named its fields from the SDK. `uriTemplateToForm`
read `UriTemplate.variableNames`, which mangles a name: `{;id}` yields
";id" and `{id:3}` yields "id:3". The form therefore submitted
`{ ";id": "7" }` while the shared expander looked up `id`, found
nothing, and dropped the expression — the value vanished silently.
This is the finding that mattered most, because it falsified the claim
this change is built on. Moving the expander into core only makes the
clients agree if each client's FORM derives its names from the same
parser: a form submits under the names it rendered. The TUI now reads
`templateVariables`, and the SDK template is constructed only to
validate, preserving the existing malformed-template diagnostic.
Its `required` flag comes from `requiredGroups`, and only a variable
that is the sole member of a non-omittable expression is marked: ink-
form cannot express "any one of these", so marking every member of
`{a,b}` required would refuse input the expander accepts.
2. `encodeURIComponent` is not RFC 3986's unreserved set — it leaves the
sub-delims !'()* bare, which RFC 6570 requires encoded for every
operator except + and #.
Fixing that settled a design question left open in the previous round.
With two encoders, the SAME value encoded differently depending on
whether its expression happened to carry a modifier, since only the
modifier pushed it onto our path. So delegation is gone entirely: one
expander, one set of rules. The SDK's `UriTemplate` is still
constructed, but only to validate a template.
3. The "Any one of: a, b" hint could contradict the disabled submit
button. A name can sit in a singleton required group AND a shared one
(`x://{a}/{a,b}`), where the singleton demands that exact field. The
hint is suppressed in that case.
Signed-off-by: cliffhall <cliff@futurescale.com>
A floated promise is invisible at review time — the call reads like an
awaited one minus four characters — and the unhandled rejection it
produces surfaces in a different test, in a different file, as a stack
pointing at SDK internals. In #1947 two un-held `callTool` calls made
`npm run ci` unpassable and took a full investigation to attribute; the
fix was two lines. `@typescript-eslint/no-floating-promises` catches the
class outright, so enable it at `error` everywhere.
The rule is type-aware and `tseslint.configs.recommended` is not, so each
scope's config gains a parser project. The four clients name their own
tsconfig projects (both of them — `src` and `__tests__`/`server` live in
different ones). The root scope has no tsconfig at all, so add
`tsconfig.lint.json`: a lint-only project covering `core/**`,
`test-servers/src/**`, and `vitest.shared.mts`, emitting nothing and
gating nothing. It sets `moduleResolution: bundler` deliberately — under
NodeNext `core/`'s extensionless imports fail to resolve, every import
degrades to `any`, and the rule silently stops seeing promises at all.
Measured surface, and the fixes:
- core (2) — `stream.close()` in two Hono `onAbort` listeners. Hono
invokes subscribers with a bare `subscriber()`, so a promise returned
from the listener is floated one layer up; `closeAbortedStream` owns
the rejection where it can actually be owned.
- tui (5) — `handleDisconnect` had no catch of its own and its only
caller is a key handler that cannot await, so a failing disconnect was
a live unhandled rejection; it now surfaces the message the way
`handleConnect` does. The rest are `void` at callers that genuinely
cannot await, over callees that already own their failures.
- web (92) — 83 are Storybook `expect(...)`, which is instrumented and
returns a promise, now awaited (`scrollAreaStoryAssertions` is async
for the same reason). Two tests were asserting against a provider call
they never awaited. `open(url)` in both web servers now warns instead
of escaping a synchronous callback.
- cli, launcher — already clean.
Type-aware linting costs time: `clients/web`'s `eslint .` goes from ~8s
to ~19s, inside `validate`. Narrowing the loaded projects is the lever if
that needs reducing later; dropping the rule is not.
Closes#1959
Signed-off-by: cliffhall <cliff@futurescale.com>
Adds two optional per-server fields — `oauth.authorizationUrl` and
`oauth.tokenUrl` — that override the endpoints authorization-server
metadata discovery resolved, so a server can be pointed at a development
or staging authorization server without changing what it advertises.
SDK v2 routes both endpoints through the discovered metadata document and
neither through OAuthClientProvider, so the override is applied by
wrapping the client's base fetch and patching the metadata document in
flight (core/auth/endpointOverrides.ts). Wrapping the base fetch — rather
than only the auth fetch — also covers the discovery the SDK runs from
inside the transport on the 401/refresh path.
The fields round-trip through mcp.json, the /api/servers routes, the
runner (CLI/TUI), and the Server Settings → Authorization panel, where a
value that is not an absolute http(s) URL is flagged inline; at runtime
such a value is dropped with a single warning rather than failing the
connection.
Signed-off-by: cliffhall <cliff@futurescale.com>
Addresses Copilot's round-3 review on #2035. It again reported "no new
comments" while carrying two suppressed ones; both reproduced.
1. Requiredness could not live on a variable at all. Deduplication kept the
first occurrence's group, so in `x{?a}{?b}{a,b}` both names ended up
required with singleton groups and the form demanded both -- while the
SDK expands that template with only `a` to "x?a=11". Widening the stored
group would not have been enough either: in `{a,b}{a,c}`, filling `b` and
`c` satisfies both expressions, which no per-variable flag can express.
So requiredness is now returned per expression by `requiredGroups`, and
`hasRequiredValues` asks that each group be satisfied by any one of its
names. `TemplateVariable.groupNames` is gone rather than left as a field
that quietly means something narrower than it reads; `required` remains,
documented as driving the "Optional" marker and nothing else.
2. `encodeURI` is not the allow-reserved encoder `+` and `#` call for. It
escapes `[` and `]`, which are reserved and must survive, and it escapes
`%`, so an already-encoded value is double-encoded. Measured:
"[::1]" -> "%5B::1%5D" and "%2F" -> "%252F". Both corrupt the URI rather
than merely over-escaping it -- an IPv6 literal or a pre-encoded path
reaches the server altered, which is the same class of defect #1919 is
about.
Added an RFC 6570 3.2.1 encoder that preserves reserved characters and
existing pct-triplets, splitting on `%XX` so a lone `%` is still encoded
to `%25`, and matching with the `u` flag so an astral character is
encoded whole. `+` and `#` expressions are now taken over even for a
single name, so both expansion paths agree on what those operators mean.
Signed-off-by: cliffhall <cliff@futurescale.com>
Addresses Copilot's round-2 review on #2035. It reported "no new comments"
but carried three *suppressed* ones; all three reproduced against the
pinned SDK.
1. The `;` path-parameter operator is absent from the SDK's operator list,
so `{;id}` parsed as a variable literally named ";id" and expanded to
nothing. Added the operator and its named expansion (`;a=1;b=2`).
2. An RFC 6570 prefix modifier was folded into the variable name: `{id:3}`
yielded a variable called "id:3" and expanded to nothing. Varspecs are
now parsed properly and the value truncated before encoding. Truncation
is by code point, since `String.prototype.slice` counts UTF-16 units and
would split an astral character into a lone surrogate.
For both of these the wrong URI is the lesser problem: a form has to
*name* the variables it asks the user to fill, so the panel was
rendering fields labelled `;id` and `id:3` that nobody could use.
3. Requiredness was applied per variable, but it is a property of the
*expression*: RFC 6570 drops undefined names from a multi-name
expression, so `{a,b}` with only `a` filled expands to `a`'s value --
the SDK does this too. The panel was refusing input the expander would
have accepted. `hasRequiredValues` now encodes "any one name in a
required group suffices", and such a field reads "Any one of: a, b"
rather than falsely claiming each is mandatory.
Two structural consequences, each pinned by a test:
- Takeover is now per TEMPLATE rather than per expression. Splicing
corrected fragments into a template the SDK re-expands would leave its
cross-expression `?`-to-`&` rewrite blind to the fragments already
resolved.
- Expansion is split into a strict variant that throws and a lenient one
that returns the raw template. `readResourceFromTemplate` wraps the
thrown error with the template name -- three pre-existing integration
tests assert that -- while the form must not throw on a server-supplied
template, since that would take out the panel on render. The strict
variant constructs the SDK template unconditionally, because that
construction is what validates syntax: otherwise `x://{;a}{b,c` would
take the own-expansion path and its unclosed tail would pass as literal
text with nothing objecting.
Signed-off-by: cliffhall <cliff@futurescale.com>
Addresses Copilot's review on #2035. All three findings reproduced against
the pinned SDK before acting.
1. `#` was misclassified as required. Measured: `x://a{#frag}` with no
`frag` expands to exactly `x://a`, a well-formed URI naming a real
resource -- unlike `{+path}` (`x://a/`) or a simple `{userId}`
(`file:///users//profile`), which leave an empty path segment. Moved `#`
into the omittable set; the required cases now assert what the URI
*becomes* when blank, so the rule is checked rather than asserted.
2. Delegating to the SDK did not actually give RFC 6570 expansion.
`UriTemplate.expandPart` takes an early `names.length > 1` branch that
raw-joins values, skipping both `encodeValue` and the operator prefix:
`x://{a,b}` with `a = "foo/bar"` expands to `x://foo/bar,q` -- the very
unencoded-slash defect this PR is about -- and `x://a{/p,q}` to
`x://ax y,z`. Only `?`/`&` are correct, being dispatched earlier.
Fixing that in the web client alone would have left the TUI and CLI
wrong, since `readResourceFromTemplate` expands through the same class.
So parse/classify/expand now live in `core/mcp/uriTemplate.ts` and both
call sites use it. The correction is surgical: a multi-name non-query
expression is expanded here and spliced in as literal text before the
SDK sees the template (safe -- both encoders escape `{`/`}`), while
every single-name and query expression still goes through the SDK
untouched. The preview applies the same correction, so it cannot
promise a URI that submitting would not send.
3. The showcase promised a blank `{?topic}` read that did not work.
`UriTemplate.match()` compiles `{?topic}` to a *required*
`\?topic=([^&]+)`, so `match("foobar://events")` returns null and the
read 404s. A real server exposes the unfiltered collection as its own
resource; the showcase now registers `foobar://events` so the
documented step resolves. Verified end to end through the CLI.
Signed-off-by: cliffhall <cliff@futurescale.com>
Two findings from the Copilot review of #1965, both real.
1. The candidates were flattened to package names before the version
comparison, so a third install holding a different copy failed the guard
even when no program loads that copy beside another — and the diagnostic
named an install that never took part. `crossInstallPackages` now returns
the co-occurrences themselves (name → program → install → entry paths) and
`findSkew` compares per program, across only the installs that met there.
2. Folding a nested copy onto its outermost install lost the path that
identifies which copy was loaded, and the version was then read from the
install's top-level entry — absent or differently versioned, either way
letting a real pair pass. `classifyModulePath` now also returns the
`entryPath` npm keys the lockfile by, `topLevelLockVersions` becomes
`lockVersionsByPath`, and each holder is priced from the entry the program
resolved. A resolved copy with no lockfile entry is now a hard failure
rather than a silently dropped holder.
The failure output prints `in <program>` with each holder's install path
beside its version, so a nested copy is distinguishable from a top-level one.
Signed-off-by: cliffhall <cliff@futurescale.com>
`verify:dep-lockstep` derived its candidate set from the packages the shared
first-party sources named *directly*, so a package whose declarations reach a
program only through another package's `.d.ts` was invisible to it — it could
skew across installs and recreate the two-copy blow-up #1896 was about while
the guard stayed green. `@modelcontextprotocol/sdk` was the live example: never
written in first-party code, yet 16 of its `.d.ts` files land in clients/web's
test program.
Replace the derivation with the measured one: list every client tsconfig
project with `tsc --listFilesOnly`, map each resolved `node_modules` file to
its owning install and package, and keep the packages that reach ONE program
from TWO installs. That is exactly the set that can put two structurally
distinct copies of a type in front of one checker. The rest of the guard is
unchanged — top-level lockfile comparison, deny-by-default, `TOLERATED_SKEW` as
a name allowlist scoped to within-a-major.
The listing machinery moves to `scripts/lib/tsc-program.mjs`, shared with
`verify:typecheck-coverage`, so the two guards can't disagree about what a
program contains. It is deliberately not cached to disk between the two
processes: a stale fingerprint would make a guard measure a program that no
longer exists and pass on a real miss. The second pass costs ~14s.
`TOLERATED_SKEW` is now empty. `jose` and `@modelcontextprotocol/ext-apps` are
root-only since #1970 and cannot skew; `react` (whose types are `@types/react`,
resolved from one install) and `hono` never reach one program from two
installs. Each would be a rationale for a skew that cannot occur.
Closes#1965
Signed-off-by: cliffhall <cliff@futurescale.com>
Real deployments gate IdP selection, prompt behavior, or audience on a query
parameter the OAuth/OIDC core specs never standardized (Keycloak's
`kc_idp_hint`, OIDC's `login_hint` / `prompt` / `acr_values`, Auth0's
`audience`), and none of them could be driven from the Inspector.
The Inspector never builds the authorize URL — the SDK's `auth()` does, and it
exposes no hook for extra parameters — so the merge happens at the one seam
that sees the finished URL: `BaseOAuthClientProvider.redirectToAuthorization`.
It lives in `core/`, so web, CLI, and TUI all inherit it.
- `core/auth/authorizationParams.ts`: the reserved-key list (`client_id`,
`code_challenge`, `code_challenge_method`, `redirect_uri`, `resource`,
`response_type`, `scope`, `state`), the key validator the form renders
inline, and the merge itself. Reserved keys are dropped with a warning
rather than silently overriding PKCE / the state binding / RFC 8707;
blank-key rows are skipped; the caller's URL is never mutated.
- Applied to the authorization request only — nothing wires it into the token
request.
- Persisted as `oauth.authorizationParams` (a string record) on the per-server
config beside `scopes`, edited as key/value rows in Server Settings ->
Authorization ("Additional authorization parameters"), with an inline error
per offending row plus a section-level warning.
- The enterprise-managed (EMA) leg redirects to a *different* authorization
server (the enterprise IdP), so it goes through the explicit
`redirectToExternalAuthorization` entry point, which skips the merge — a
`kc_idp_hint` or `audience` meant for the MCP server's AS must never be
appended to the IdP's authorize URL.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E
Signed-off-by: cliffhall <cliff@futurescale.com>
Closes#2015, folded into #1928 rather than left as a follow-up.
The TUI's `schemaToForm` has the same defect the web `SchemaForm` had: its
local `JsonSchemaProperty` declares only `type?: string` — no `anyOf`, no
array-valued `type` — so a `.nullish()` argument has no top-level `type` or
`enum` for the dispatcher to read and falls to the `switch` default, losing
its select. Milder than the web bug (a plain text field, not a corrupting
JSON textarea), but the same class.
Fixing it in a second place is the argument for not having the logic in a
first place. So the collapse moves to `core/json/nullableUnion.ts` as
`normalizeNullableUnion`, generic over a minimal `NullableUnionSchema`
shape — the least a caller must expose to be *recognized* as a nullable
union — so neither client has to adopt the other's schema type. Both form
builders now share one copy and cannot drift on which schemas they render.
- `core/json/nullableUnion.ts` — the shared collapse, plus tests at
`clients/web/src/test/core/nullableUnion.test.ts` (the web suite is what
gates `core/`).
- `clients/web`: `schemaUtils.normalizeUnionType` is gone; `SchemaForm` and
`resolveRefsInMessage` call the core function directly.
- `clients/tui`: `schemaToForm` normalizes each property before dispatch,
and `JsonSchemaProperty` grows the `anyOf` / array-`type` fields the
encoding uses.
Verified by driving the built TUI under a pty against the
`nullable-fields-http.json` showcase server. Before, every argument of
`record_shipment` rendered as a plain text field; after, `direction` is a
select ("No value"), `quantity` an integer, `express` a boolean
("[Not set]").
Signed-off-by: cliffhall <cliff@futurescale.com>
The SDK validates a list result as a whole, so a single non-conforming entry
rejected the entire response and the panel showed "Couldn't load resources"
with zero items — the exact opposite of what a debugging tool should do with a
server that is 90% right. Reported against a PHP server whose empty
`annotations` object reaches the wire as `[]` rather than `{}`.
`listAll*` now falls back to a lenient re-walk when — and only when — the SDK
rejects a result it received (`InvalidResult` / `UnsupportedResultType`), keeps
the entries that validate, and reports the rest. A conforming server takes the
strict, cache-aware path exactly as before.
Salvaging is not accepting: the dropped entries are named with their reason in
a warning above the list, and the Protocol entry is still marked rejected, so
the wire truth survives. When every entry validates, the original error is
rethrown rather than swallowed — the rejection was about something the
per-item pass cannot explain.
`isClientDecodeRejection` moves to the new module and is now shared with
managedListState, which had the only copy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review:
- `<x.y.z>` / `<major|minor|patch>` inside a bash block is shell redirection,
so both documented commands were syntax errors if copied. Verified: `bash -n`
rejects `git tag <x.y.z> && ...` and accepts `git tag 2.3.0 && ...`. Use a
concrete 2.2.0 -> 2.3.0 example and say what to substitute.
- "agree on the version at every point" / "never disagree" overstated it. The
procedure deliberately bumps v2/main first, so between the bump and the
milestone merge the branches differ — v2/main reads the version being built,
main the one released. Describe the real invariant: they converge when the
release merge lands, and v2/main is never left behind main.
Signed-off-by: cliffhall <cliff@futurescale.com>
v2/main has been at 2.0.0 since the v2.0.0 release while main and npm `latest`
are at 2.2.0. The bump was created on the milestone-merge branch, which is cut
from main, so it only ever existed downstream of v2/main and nothing carried it
back — `chore: 2.1.0` and `chore: 2.2.0` are both on main and neither is on
v2/main.
That is not cosmetic. A branch cut from a milestone-merge branch silently
carries the bump into an unrelated PR — #2009 arrived with a 2.0.0 -> 2.2.0 diff
on a container bugfix — and anything reading the version in development
(readInspectorVersion, --version, GET /api/config) reported a version two
releases old.
Move the bump to v2/main, before the milestone merge, so it flows into main with
the rest of the milestone's work and the branches never disagree. The release
tag is applied to the resulting main commit instead. The publish workflow needs
no change: its tag-vs-package.json assertion runs at the release commit on main,
which still carries the bump.
Deliberately not a back-merge of main into v2/main: main holds the whole pre-v2
v1 history (~230 commits v2/main lacks, retained through `chore: replace main's
tree with v2`), so merging would graft all of it into the develop branch's log
permanently to deliver a two-file change.
Also sets v2/main to 2.2.0 as a one-time catch-up. Nothing in the repo asserts a
specific version value, so this is a safe two-file change.
Closes#2010
Signed-off-by: cliffhall <cliff@futurescale.com>
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>
Review caught a second-order effect of the fix: making the leaked-timer net
global invalidated the rationale of a neighbouring rule.
"Do not hand-roll a bare MantineProvider — that reintroduces the leak class"
stopped being true the moment the net moved into setup.ts, which covers every
unit test regardless of how it renders. The rule itself is still right, so keep
it and replace the justification: a hand-rolled provider skips the project theme
and the helper's options and drifts from every other test. Both copies say
explicitly that the old reason no longer holds, so nobody re-derives it.
Mirrored into .github/copilot-instructions.md in the same PR, as AGENTS.md
requires for review-relevant guidance — it carried the identical stale claim.
That makes four places this one belief had propagated to. A fix can create stale
docs by making a previously-true statement false, which is easy to miss when
only the changed lines get read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
Review follow-up on #1983. Copilot was right, and about a hazard this repo
had already proven and then documented away: the claim that ink's ">=19"
peer "keeps npm from hoisting it next to a React the Inspector couldn't
also use" is false. A consumer pinning React 19.0 satisfies ">=19" while
`^19.2.4` nests underneath, so ink renders through their React and the
bundle through ours — the #1952 crash, one level up, breaking TUI startup
rather than just its forms.
Bundling ink closes it (verified: with a `createRequire` banner for the
inlined CJS, since react-reconciler calls `require("react")` and
signal-exit@3 `require("assert")`), but costs ~1.4MB — react-reconciler
and yoga-layout come along. The root `react` range closes it for free
instead: `^19.0.0` is open to the whole major, so npm can satisfy our
React and a consumer's pinned React 19 with one copy, and an external ink
resolves the same copy the bundle does.
Verified against both consumer trees — the reporter's (React 18 + ink 5)
and a pinned React 19.0.0 + ink 6.8.0, which splits under `^19.2.4`.
That makes the range load-bearing while looking like an ordinary version
bump, so the tests pin it: the root range must equal ink's own declared
peer floor. They also assert each exempt package is both external and a
root dependency — external means consumers install it, the mirror image
of the inlined ones, which must not be root-declared.
The docs in all four places now say ink is exempt on cost and never on a
peer range, and name that claim as one we got wrong.
Closes#1952
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks
A window.setTimeout that outlives its test file fires after happy-dom disposes
that file's window; React's dispatchSetState then throws an uncaught
ReferenceError: window is not defined. Vitest attributes it to whichever file
was running, so a single leak fails the ENTIRE run with every test passing —
landing on an innocent file under a step named "Enforce per-file coverage gate."
Seen twice in CI, both on docs-only branches.
Not a Mantine bug, and not a badly-written test. Both hooks do clear on unmount
(useTransition's clearAllTimeouts, useLockScroll's effect cleanup). It is a rAF
race: clearAllTimeouts cancels the *pending* rAF, but the transition schedules
rAF -> rAF -> setTimeout, so if the inner callback is already in flight when the
unmount lands, cancelAnimationFrame is a no-op and that callback then schedules
a timer after cleanup has run. Nothing owns it. Needs a loaded machine, which is
why it only ever appeared in CI.
Track every timer and clear whatever is still outstanding after cleanup(). The
ordering is load-bearing: legitimate unmount cleanups get their turn first, so
only true leaks are dropped. Fake timers pass through untracked, which is
correct — a fake timer cannot outlive the environment.
Also corrects the claim, in AGENTS.md and in setup.ts's own comment, that
env="test" prevents this. It does not: env is read only by Transition.mjs at its
render branch, while useTransition runs before that check (hooks cannot be
conditional) and still schedules real timers. Measured — opening a <Modal>
through renderWithMantine schedules three 200ms timers. Believing that guarantee
is why this went unexamined.
The regression test is mutation-verified: with the net disabled it fails, with
it restored it passes. It deliberately does not assert that Mantine schedules no
timers — it does, and that is normal; the contract is that none survive.
Closes#1984
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
Round 5 was right on all three counts. Read dcoapp/app/lib/dco.js this time
rather than the README, which is where the earlier errors came from:
const authors = [commit.author.name, commit.committer.name]
const emails = [commit.author.email, commit.committer.email]
if (isMerge || ...) continue // parents.length > 1
else if (author && author.type === 'Bot') continue
So, corrected:
- Scope. Merge commits and bot-authored commits are skipped, not gated. "Every
commit" overstated it and could prompt a needless history rewrite.
- Matching. The trailer may match the author OR the committer, not the author
alone. A cherry-picker's own signoff is accepted.
- Certification. The DCO expressly covers submitting work created by others
that you have the right to submit, so signing off a cherry-pick is legitimate.
My "never sign off someone else's commit" would have told a maintainer not to
do a correct thing. The real prohibition is narrower: never fabricate a
trailer in another person's name.
This also retracts the mechanical argument in bd6c1072 for dropping the hook —
it does satisfy the check, since you are the committer. The recommendation
against it stands on honest grounds now: the trailer is a certification, a hook
makes it for every commit automatically including work merely applied on
someone else's behalf, and it provably cannot tell the difference. A judgment
about deliberateness, not a claim that it breaks.
Mirror corrected to match.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
Round 4 was right that the hook signs with the configured identity while the
DCO check requires a trailer matching the commit *author*, and that the two
diverge on cherry-pick and `git commit -c`.
I tried the suggested author-aware guard and it does not work: inside
prepare-commit-msg, `git var GIT_AUTHOR_IDENT` returns the config identity, not
the preserved author. Verified end to end — cherry-picking Bob's commit as
Alice yields author=Bob, committer=Alice, `Signed-off-by: Alice`. That commit
both still fails the gate and certifies work Alice did not write, which is the
one thing the last bullet of this section forbids.
Three review rounds have now found three holes in this hook, each fix opening
the next. That is a wrong mechanism rather than an unfinished one, so the hook
is out. A subtly-broken hook in the file that governs how this repo is worked
on is worse than no hook.
The bullet now recommends `git commit -s` and names BOTH false automations —
`format.signOff` and the hook — recording the cherry-pick finding so the next
person doesn't rediscover it, and pointing anyone who wants automation at a
layer that actually knows the author. Mirror updated to match.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
`ink-form` and `ink-scroll-view` were externalized by the TUI bundler and
both declare a loose `react` peer (">=18"). Installed under a project that
has its own React 18, npm hoists them to that project's root — where the
peer is satisfied — while the Inspector's React 19 nests underneath. The
bundle then renders through React 19 while those two call hooks on React
18, whose dispatcher is null, so opening a tool test form (or any scroll
view) dies with "Cannot read properties of null (reading 'useState')".
Inline them instead: their `import "react"` is emitted into the bundle and
so resolves from the build directory exactly like the bundle's own, with
npm's placement no longer part of the decision. This also pins their
transitive deps to what this repo resolved — notably `ink-select-input@6`
via `overrides`, which npm ignores for a package installed as a dependency,
so consumers were getting the React-18-era v5.
`ink` stays external: it can't be bundled (its CJS `signal-exit@3` fails
ESM interop with `Dynamic require of "assert"`) and doesn't need to be —
its ">=19" peer keeps npm from hoisting it next to an unusable React.
Both packages are dropped from the root `dependencies`, since the tarball
now ships their code rather than having consumers install them.
`clients/tui/__tests__/tsupConfig.test.ts` is the durable guard: the
failure is invisible in this repo and in every smoke (a dev install has
one React) and only appears once the package is installed under another
project, so the invariant is asserted against the config instead — every
dependency declaring a `react` peer must be bundled unless exempted.
Closes#1952
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks
Two claims were wrong, both verified against the app's own documentation
(dcoapp/app): "The DCO also creates an override button for users with write
access to the repository to create a successful check."
So "there is no per-PR override" and "there is no non-rewrite escape hatch"
were both false, and a maintainer following them would conclude that rewriting
history was the only way to unblock a PR.
Documented, but deliberately not as a repair. The override only silences the
check; it does not make the author certify anything, and the certification is
the whole point of a DCO signoff. Using it on your own unsigned commits asserts
nothing while looking like compliance — so the text says to treat it as
unavailable and sign the commits instead.
The same source confirms two things the doc already claimed: remediation
commits are disabled by default, and the trailer must match the commit author's
email (which is what the identity-aware hook in 6899e0ae exists for).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
The hook skipped insertion on any `Signed-off-by:` line, which is wrong for a
message reused from another commit — cherry-pick, `git commit -c` — since that
message already carries someone else's trailer. The DCO check requires one
matching the *author*, so the guard would be satisfied while the commit still
failed the gate: the exact silent-no-op failure mode this bullet warns about.
Match this identity's exact trailer instead. Verified: a plain commit signs
once; a message already carrying another author's signoff now gets the real
author's added alongside it; `git commit -s` is not double-signed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
Both review points were factually right and my original text was wrong.
1. `git config format.signOff true` — which I had recommended as the *better*
option — only defaults the -s flag for `git format-patch`. `git commit` never
reads it, and there is no `commit.signoff` equivalent. Verified all three in a
throwaway repo: format.signOff → no trailer; commit.signoff → no effect; a
prepare-commit-msg hook → works. Shipping that advice would have been worse
than saying nothing, since it looks like a fix and silently changes nothing.
Now recommends `git commit -s`, gives the verified hook for automation, and
names format.signOff explicitly as a trap — the name invites exactly the
mistake I made, so it is worth warning about rather than omitting.
2. The DCO app's empty remediation-commit flow needs
`allowRemediationCommits.individual`. This repo ships no DCO config, so it
runs with that disabled and the original unsigned commits keep failing. The
escape hatch I offered does not exist here; rewriting is the only repair, so
the text now says to coordinate before rewriting shared history instead.
Mirror updated with both corrections.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
The DCO check fails any PR with an unsigned commit, and the rule appeared
nowhere in the repo — not AGENTS.md, CONTRIBUTING.md, README.md, or the Copilot
mirror. A hard merge gate that is invisible until it fails, whose remedy is a
history rewrite, is exactly what AGENTS.md exists to capture. It blocked #1978.
Added to the "When work is complete" list beside the other PR-hygiene gates,
weighted toward prevention over recitation: `git config format.signOff true`
once per clone beats remembering `-s`, because the failure is only discovered
after pushing. The repair path (`git rebase --signoff` + `--force-with-lease`)
carries the rebase caveat and the empty-remediation-commit alternative for when
you are not the sole author, plus a note that the signoff is a DCO assertion in
the author's own name and is never applied on someone else's behalf.
Mirrored into .github/copilot-instructions.md in the same PR, as AGENTS.md
requires for PR-hygiene rules — two lines next to `Closes #N` and the version
label, since the mirror is read on every review and is a distillation, not a
copy.
Not added to CONTRIBUTING.md: external contributors file issues rather than
PRs, so DCO never reaches them, and documenting it there would muddy that.
Closes#1979
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot was right that the previous test proved only that `removeSafe` can
delete a directory — it never drove `stop()`, so deleting the cleanup call
would have left the unit suite AND all three smokes green. That is exactly the
regression these tests exist to catch, and they did not catch it.
Extract the teardown into an exported `teardownWebServer({ child, catalogDir,
label })`, which `stop()` now delegates to, and drive it against a stand-in
child process (a real `node -e setInterval` that stays alive until signalled)
rather than a mock. Three cases:
- it removes the catalog dir it was given (asserted on the dir itself, not a
spy, so the assertion cannot drift from the behavior);
- it awaits the child's exit before removing — the #1801 race in one
assertion, since losing the await is otherwise invisible;
- an already-dead child is not an error, which is the failure path a smoke
takes when it calls fail() after the launcher has crashed.
Mutation-checked: with the `removeSafe` call deleted, 2 of the 3 now fail
(previously: 0). AGENTS.md updated to match.
Signed-off-by is now on every commit (#1978 DCO); the requirement itself is
undocumented and tracked in #1979.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
Addresses the two review comments that still applied to HEAD (the third, the
stop() race, was already fixed in a8022138).
- AGENTS.md still described the prod-web-server helper as shared by only
smoke:web and smoke:web:browser, and said nothing about catalog isolation.
It is the source of truth for these smoke mechanics, so record the invariant
there: the temp catalog per run and why, that only the catalog (not HOME) is
redirected, that teardown needs both halves of child-cleanup.mjs and is
therefore async for every caller, and where the contract is unit-tested.
- The `label` JSDoc claimed the option prefixes the temp dir. It does not —
that prefix is fixed at `smoke-web-catalog-` (deliberately, so every run's
dir is greppable); `label` only prefixes teardown warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
Took v2/main's project-layout line and quality-gate table (adding
verify:dep-lockstep), keeping this branch's migration-guide mention and
prettier table alignment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
README: kept v2/main's showcase table including the new structured-output
row. AGENTS: kept v2/main's newer type-label and triage rules, rendered in
this branch's markdown formatting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3