Commit graph

3,120 commits

Author SHA1 Message Date
cliffhall
6c499f3ea1 fix(auth): read enumerated issuers exactly, and stop the TUI tone going stale (#2144)
Copilot review round 4.

- `getTokens(url, issuer)` falls back to the legacy unkeyed slot when that
  issuer holds none — right for a connect, wrong for `collectGrants`, which
  labelled the fallback with the issuer it happened to be iterating and paired
  it with that issuer's credentials. During a partially migrated flow the clear
  could have sent an old, unbound token to a newly discovered authorization
  server. `OAuthStorage.getIssuerTokens` is the exact, no-fallback read; only
  the ctx-less read still falls back, which is the one place it belongs.
- The TUI's warning tone was sticky. It lived in its own state while ~30 call
  sites set ordinary OAuth notes without resetting it, so one revocation
  failure left every later "Authorization updated" yellow, across server
  switches. It is now DERIVED — `oauthMessageToneFor(message, warningText)` —
  so it cannot outlive the message that earned it, with nothing to remember to
  clear. Both setters stay plain `useState` setters: a `useCallback` wrapper
  would be a value `react-hooks/exhaustive-deps` demands in seven dependency
  arrays for an identity that never changes.
- Hook-level tests for the web wiring (previously missed): the per-server
  opt-out and the backend-proxied fetch are now asserted to reach
  `clearServerOAuthState`, so handing it the page-origin fetch — which a real
  authorization server's missing CORS headers would reject — fails a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 02:58:04 -04:00
cliffhall
781aa69d19 fix(auth): revoke every issuer-bound grant, not just the active one (#2144)
Copilot review round 3.

`clear(serverUrl)` deletes EVERY `byIssuer` slot, but revocation read only the
context-free (active-issuer) token. A server that had authorized against
issuers A and B therefore revoked B, then destroyed the local record of A while
A's grant stayed live at its authorization server — the same leak this feature
exists to close, one level down.

- `OAuthStorage.listIssuers(serverUrl)` is the new seam (one implementation:
  every backend extends `OAuthStorageBase`). `collectGrants` walks it plus the
  ctx-less read, deduping by token, so every grant the clear will delete is
  covered.
- Metadata is cached once per server, not per issuer, so a grant bound to an
  issuer the cached document does not describe cannot be revoked: sending its
  token to that endpoint would hand a credential to a server that never minted
  it. That grant is reported as failed — dropped unrevoked — rather than
  silently discarded or misdirected.
- `aggregateOutcomes` gives a failure precedence over another grant's success,
  so one live grant cannot hide behind another's 200. Exported for its own
  test, since `computeOutcome` returns early on the empty case.

Also from that round:

- The fixture's /oauth/revoke now verifies token ownership (RFC 7009 §2.1).
  Access tokens record their owning `client_id`; a token belonging to another
  client is left alone and still answered 200 (§2.2 — the response must not
  tell one client whether another's token exists). New e2e case proves a
  second registered client cannot revoke the first's grant.
- The TUI rendered the revocation-failure note in cyan, the informational
  tone. It is a partial success — the local clear really happened — so it is
  not an `error` status, but the grant may still be live and cyan understated
  that. `AuthTab` takes an `oauthMessageTone`; the message says so explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 02:40:28 -04:00
cliffhall
4a24c4880d fix(auth): address Copilot review round 2 on the RFC 7009 revocation (#2144)
- `revokeStoredKeys`' pre-read of `getTokens` sat outside the best-effort
  catch. `getTokens` parses through `OAuthTokensSchema`, so a persisted token
  that no longer validates rejected and abandoned the local delete `--relogin`
  promises — over a grant that could not have been revoked anyway. Caught and
  retained as a failed outcome; the clear proceeds.
- The multi-key bookkeeping was wrong twice. A token is now marked spent only
  on a `revoked` outcome, so a failed or unsupported attempt no longer skips a
  duplicate entry under the other key that may hold the credentials or metadata
  that would have worked. And a failure now outranks an earlier success for
  reporting, so a stale grant left live cannot be hidden behind the first key's
  200.
- `--no-revoke` is now rejected without `--relogin`, ahead of the short-circuit
  returns, matching the parser's existing stance on accepted-but-inert flags
  (`--strict`). On its own it reads as "this run will not revoke anything",
  true only because nothing was being cleared.
- Extracted the App draft merge as `utils/serverWithDraftSettings`, so the rule
  has a test. The hook tests receive whatever they are handed and cannot tell a
  draft from a persisted entry, and `App.test.tsx`'s settings harness connects
  a stdio server where the OAuth section never renders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 01:47:48 -04:00
cliffhall
7f27d7e9c7 fix(auth): address Copilot review round 1 on the RFC 7009 revocation (#2144)
- RFC 8414 §2 defaults an omitted `revocation_endpoint_auth_methods_supported`
  to `client_secret_basic`; it does not inherit the token endpoint's list.
  Inheriting made metadata advertising only `client_secret_post` there send
  POST credentials to an endpoint that never advertised the method.
- The 5s timeout was inert on the web paths: `createRemoteFetch` re-issues the
  call as a POST to `/api/fetch` and drops `init.signal`, and the backend's
  outbound fetch gets none either, so a wedged AS could hold the teardown open
  indefinitely. A wall-clock race now enforces the deadline; the signal is kept
  because it genuinely cancels the direct-fetch paths.
- The settings-modal Clear read `settingsModalTarget`, which comes from the
  persisted `servers` list, while edits live in `settingsDraft` until the save
  debounce. Toggling the checkbox and clearing immediately used the previous
  value. It now merges the draft.
- `--relogin` clears both key spellings, so it now revokes from both rather
  than stopping at the first populated one — a stale entry under the other
  spelling is a live grant. The normalised key goes first, matching
  `findStoredServerState`, and a second key holding the same token is skipped.
- The fixture's /oauth/revoke now ENFORCES RFC 7009 §2.1 client
  authentication (both RFC 6749 §2.3.1 forms), so the e2e's claim that the
  Inspector authenticates correctly is actually tested; two negative cases
  assert a wrong secret and an unidentified client are refused.
- Dropped the `as unknown as` casts from the tests: a typed `InspectorLogger`
  double, and a spy on the real storage instead of a spread-and-cast.
- Added CLI-level tests through `runCli` for `--no-revoke`, the per-server
  `oauth.revokeOnClear`, and the stderr warning, and TUI tests for the
  forwarded opt-out and the rendered failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 01:30:09 -04:00
cliffhall
15f381c6ad test(auth): cover the no-window guard in getWebProxiedFetch (#2144)
Mirrors `remoteOAuthStorage.test.ts`, whose identical guard is covered the
same way. Without it the file sat at 83.33% branches and failed the
per-file gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 01:00:42 -04:00
cliffhall
65fd2b9e35 feat(auth): revoke OAuth tokens at the authorization server on clear (#2144)
Clearing the Inspector's OAuth state deleted the local copy of the tokens
and stopped there. The access token — and the refresh token, which is
long-lived by design — stayed valid at the authorization server until they
expired on their own, so a day of connect/disconnect iteration left it
holding grants for sessions that ended hours ago. RFC 7009 §1 describes
exactly that case, and nothing in the repo constructed a revocation
request, so a server author implementing `revocation_endpoint` had no way
to exercise it with the tool that exists to exercise this.

core/auth/revocation.ts builds and sends the request; the three clear paths
call it, so the behavior is written once — web's "Clear OAuth state and
disconnect" (both the active and stored-only paths), the TUI's Auth tab,
and the CLI's --relogin. It runs BEFORE the local clear, because the token,
the client credentials and the discovered `revocation_endpoint` all live in
the store the clear is about to empty. The request names the refresh token
when there is one: §2.1 asks the AS to also invalidate the access tokens
issued under the same grant, so one request covers both halves.

Best-effort by construction. Every path returns a TokenRevocationOutcome
rather than throwing: no advertised endpoint means nothing is sent and that
authorization server behaves exactly as before, and a network error, a
non-2xx, a 5s timeout or an unreadable store is reported and nothing more.
Forgetting the tokens is what the user asked for. `lost_authorization_state`
recovery deliberately skips it — it clears a half-finished flow to retry it,
and an authorization that never completed has no grant to revoke.

Client authentication resolves the preregistered slot first, mirroring
BaseOAuthClientProvider.clientInformation; reading only the dynamic slot
would send no authentication for a server configured with `oauth.clientId`.
The Basic credential is byte-identical to the SDK's applyBasicAuth so this
request and the token request cannot present the same secret differently.

Opt out per server with `oauth.revokeOnClear` (default on, only `false`
written to disk) or per CLI run with --no-revoke. Read at clear time rather
than connect time, so toggling needs no reconnect. Turning it off is a
testing affordance: a client that walks away still holding live tokens is a
case a server author may want to reproduce.

test-servers now advertises `revocation_endpoint` and serves POST
/oauth/revoke, faithful on §2.2 (an unknown token is a success) and §2.1
(revoking a refresh token invalidates the access tokens under the same
grant), with oauth-revocation-http.json / oauth-no-revocation-http.json as
showcase configs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 00:48:57 -04:00
Cliff Hall
baff393675
Merge pull request #2175 from modelcontextprotocol/v2/fix/2171-raw-json-no-coercion
fix(web): refuse a raw-JSON tool argument the schema would retype
2026-08-27 23:48:15 -04:00
Cliff Hall
904cbf9014
Merge branch 'v2/main' into v2/fix/2171-raw-json-no-coercion 2026-08-27 23:37:33 -04:00
cliffhall
61f58f9ae0 test(web): pin that a refused raw-JSON draft dispatches nothing (#2171)
The panel tests stopped at the disabled button, which leaves the wire claim
unverified: a screen that flagged the draft and still fired its callback
would have satisfied every one of them.

`ToolsScreen` and `AppsScreen` now assert `onCallTool` / `onOpenApp` are not
called for a refused draft, and ARE called once the value is rewritten with
the declared type — so the draft is demonstrably what blocks the dispatch
rather than an inert screen.

The plain and "Run as task" paths need no separate case: the panel has one
Execute button whose disabled state does not depend on the toggle, and the
callTool/callToolStream split happens later in App.tsx, downstream of the
callback a refused draft never reaches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 23:32:32 -04:00
Cliff Hall
667ec74fb5
Merge pull request #2179 from modelcontextprotocol/v2/fix/2174-subscribe-error-recovery
fix(web): surface failed resource subscribe/unsubscribe
2026-08-27 23:21:08 -04:00
cliffhall
9516299d8d fix(web): refuse a raw-JSON tool argument the schema would retype (#2171)
The Tools and Apps tabs' "Edit as JSON" editor sent its draft through the
string-to-declared-type conversion `callTool` applies for the widget form, so
`{"count":"01"}` against a numeric schema left as `{"count":1}` — the editor
showed one payload and the wire carried another.

Both JSON-authoring surfaces now refuse such a draft and name the value to
rewrite. Edit-and-replay already did; this makes the Tools and Apps forms
agree with it, which is the consistency #2171's acceptance asks for.

`coercedArgumentNames` and `coercedArgumentsError` move into core/json so the
two surfaces share one predicate and one sentence — the check runs the real
conversion and compares rather than restating its rules, so it cannot drift
from what would actually be sent. Enforcement is opt-in
(`SchemaFormProps.enforceToolArgumentTypes`): an elicitation renders through
the same form and its values are never converted, so enforcing there would
refuse a draft for a reason that is not true of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 22:57:29 -04:00
Cliff Hall
4d69dbf999
Merge pull request #2176 from modelcontextprotocol/v2/fix/2097-never-acknowledged-subscription
fix: say why a never-acknowledged subscriptions/listen ended, instead of retrying it eight times
2026-08-27 22:45:32 -04:00
cliffhall
37e156c035 fix(web): surface failed resource subscribe/unsubscribe (#2174)
Both handlers discarded their promise with a bare `void`. Nothing owned
those failures: `subscribeToResource` throws when the client is
disconnected, when the server declares no subscription support, and
(wrapped) on a failed request — so a subscribe that failed did nothing
visible and surfaced only as an unhandled browser rejection, while every
neighbouring command in the hook already routed through the shared
recovery.

Both now go through `runCommandInBackground`, so a mid-session 401
recovers the way it does on a refresh and any other failure toasts.

The source is `ambient`, not `resource`: a `resource` step-up failure is
routed into `readResourceState` — the read-preview panel — which
describes a read of whatever is selected rather than this subscribe, so
marking it errored would contradict a read that succeeded. Subscribing
has no panel of its own, the same position the refreshes and the
pagination toggle are in.

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>
2026-08-27 22:42:56 -04:00
Cliff Hall
31ce682c3d
Merge branch 'v2/main' into v2/fix/2097-never-acknowledged-subscription 2026-08-27 22:27:16 -04:00
Cliff Hall
2856f82343
Merge pull request #2173 from modelcontextprotocol/v2/chore/2155-use-server-commands
chore(web): extract App.tsx's server commands into useServerCommands
2026-08-27 22:23:56 -04:00
Cliff Hall
222b65d8c2
Merge branch 'v2/main' into v2/chore/2155-use-server-commands 2026-08-27 22:13:20 -04:00
cliffhall
9136ab6268 fix(test-servers): describe and test after-first's resource-subscription counting
Copilot review on #2176. All three findings were the same gap seen from
three angles: `"after-first"` counts *resource-subscription* listens, not
listens, and both the README and the option's own doc comment said
"the first listen".

That distinction is load-bearing rather than incidental — the Inspector
opens a listen at connect time whenever a list-change opt-in is live
(#1920), so counting listens would spend the allowance there and refuse
the user's very first Subscribe, leaving the documented repro describing
a trace nobody sees. It is also what a reader of the option would
otherwise get wrong.

- README: say "the first listen that subscribes to a resource" in both
  places, and name the connect-time listen as the reason.
- `ServerConfig.modern.neverAcknowledgeSubscriptions`: document the
  list-change exemption and contrast it with `true`.
- Integration test for the mode itself, with every list-change opt-in on
  so the connect-time listen really is opened: it must be acknowledged
  without consuming the allowance, the first resource subscription
  acknowledged, the next refused with the stream left `active`.

Closes #2097

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 21:14:30 -04:00
cliffhall
71cd1130ee chore(web): fix a duplicated word in a moved comment
Copilot round 3, from its suppressed set: "a paginated paginated fetch".
The line came across verbatim with `onLoadMoreTools`, so the typo
predates this PR — fixed here since the comment is the thing being
moved.

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>
2026-08-27 21:02:40 -04:00
cliffhall
f7fe4bc9e9 test(web): make the harness's recovery wrapper actually recover
Copilot round 2. The stand-in `runWithCommandAuthRecovery` was a bare
pass-through while its comment claimed it reproduced the real one, so
`onCompleteArgument` and `onCancelTask` — whose only auth handling *is*
that wrapper — would have passed every test here while bypassing the
shared recovery entirely. That routing is the integration this
extraction exists to preserve.

The stand-in now matches `useOAuthRecovery`'s: it catches an
`AuthRecoveryRequiredError`, hands it to `handleCommandScopedAuthRecovery`
with the call site's `source` and a `retryOperation`, retries once when
the recovery reports satisfied, and rethrows anything else.
`runCommandInBackground` is built on it, as the real one is. Three tests
pin the source and the retry.

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>
2026-08-27 20:50:59 -04:00
cliffhall
8535ae0c2f fix(core): say why a never-acknowledged subscriptions/listen ended
A modern-era `subscriptions/listen` is a long-lived request: the first
message on its stream must be `notifications/subscriptions/acknowledged`,
and the JSON-RPC `result` for the listen id is reserved as the
graceful-closure marker. A server that answers the listen request with a
bare `result` is saying "acknowledged and closed in the same breath" — the
SDK settles the subscription `graceful` and rejects the pending `listen()`.

The Inspector read that rejection as an ordinary drop: eight re-lists over
roughly a minute, the badge flickering `Reconnecting…`, and a final bare
`Stream ended` that said nothing about why. Accepting the message was
never wrong — it is a well-formed graceful-closure response. The silence
was.

- `core/mcp/subscriptionAck.ts` owns the predicate. The SDK gives this
  close no code of its own — it is the same `SdkError(ConnectionClosed)`
  any pre-ack close carries — so the predicate reads the message, since
  the alternative is refusing to retry a genuinely transient drop.
- The condition is deterministic, so the stream ends on the first
  occurrence instead of spending the whole backoff run. Both routes are
  covered: the one a subscribe/unsubscribe reaches, and the one a
  reconnect reaches when an acknowledged stream drops and the server has
  since started refusing.
- A `"never-acknowledged"` status of its own, not `"ended"` — `ended`
  means an expected close. It surfaces as the thrown error's message, an
  orange "Not acknowledged" badge, and a notice in the panel.
- `subscriptions-never-acknowledged-http.json` reproduces it. It
  acknowledges the first listen that subscribes to a resource and refuses
  every one after, which is what makes the badge reachable by hand.

Closes #2097

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 20:49:55 -04:00
cliffhall
5c7c5184cf chore(web): address Copilot review on #2155
Give the test's paginated-list fixture a precise return type instead of
an unjustified double cast. The cast was hiding that the fixture omitted
`error`, so an interface change would not have been type-checked there.

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>
2026-08-27 20:35:33 -04:00
cliffhall
00f1733abe chore(web): extract App.tsx's server commands into useServerCommands
Phase-2 step 4 of the App.tsx decomposition (#2155, under #2129/#2126).

Every command the screens issue against the live server moves into
`useServerCommands`: calling a tool, getting a prompt, reading and
subscribing to a resource, completing an argument, cancelling a task or
a call, setting either log level, refreshing or paging a list, and
flipping the pagination mode. All of them route through the
command-scoped auth recovery `useOAuthRecovery` publishes, which is why
this cluster cannot precede the OAuth one.

The three in-flight result panels move with them, behind a small
`useResultPanels` in the same file — split out only because of call
order, the same reason `useHandshakeTelemetry` sits beside
`useConnectionLifecycle`: `useOAuthRecovery` drops those panels on a
session reset and routes a step-up failure into whichever one issued
the command, and it runs before the commands hook.

The move is inert — no behavior change, #2095's per-server pagination
override included.

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>
2026-08-27 20:29:23 -04:00
Cliff Hall
6bc3251962
Merge pull request #2170 from modelcontextprotocol/v2/feat/2151-ace-json-editor
feat(web): use the Ace JSON editor everywhere, and restore the raw-JSON toggle
2026-08-27 20:19:56 -04:00
Cliff Hall
ccd99083ea
Merge branch 'v2/main' into v2/feat/2151-ace-json-editor 2026-08-27 20:07:11 -04:00
Cliff Hall
7a3a30b148
Merge pull request #2169 from modelcontextprotocol/v2/chore/2154-use-connection-lifecycle
chore(web): extract App.tsx's connection lifecycle into useConnectionLifecycle
2026-08-27 20:04:08 -04:00
Cliff Hall
592446fc8e
Merge branch 'v2/main' into v2/feat/2151-ace-json-editor 2026-08-27 10:30:52 -04:00
Cliff Hall
6bd2841afc
Merge branch 'v2/main' into v2/chore/2154-use-connection-lifecycle 2026-08-27 10:24:40 -04:00
Cliff Hall
df53d718c1
Merge pull request #2167 from modelcontextprotocol/v2/chore/2146-rename-local-gate
chore(scripts): rename the local pre-push gate off `ci`, and guard it out of CI (#2146)
2026-08-27 10:16:05 -04:00
cliffhall
684eb9c7bc fix(web): reject an underflowing literal, and route embedded JSON resources
Copilot's thirteenth pass, two comments, both real.

- `1e-400` has a nonzero mantissa and parses to `0`. The digit comparison skips
  it for being exponent-form and it is not negative zero, so nothing saw a
  document that says one number and sends none of it. Caught now by pairing
  `Number(literal) === 0` with a nonzero digit in the mantissa, which leaves a
  literal written as zero (`0e-400`) and a subnormal that still parses to
  something (`1e-320`) alone.

- The `resource` content-block branch rendered every embedded resource in a
  plain code block, ignoring `resource.mimeType`, so a prompt message carrying
  JSON did not inherit this migration even though the text-block branch did. An
  embedded *text* resource declaring JSON now goes through the same dispatch.

Narrowed to JSON, as the Network tab's bodies are: routing every declared type
through would also put an embedded `text/html` resource into the sandboxed
frame and a `text/csv` one into a table. The Resources screen already renders
resource contents that way, so doing it here too is defensible — but it is a
change to what a tool result *is*, and belongs to whoever decides that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 06:26:23 -04:00
cliffhall
8aa6f01505 fix(web): reject a negative-zero literal, which loses its sign when serialized
Copilot's twelfth pass. The suppressed comment is right and is fixed here; the
inline one is real too, but needs a core API change and is filed as #2171.

`-0` is represented exactly, so the digit comparison had nothing to say about
it — but `JSON.stringify` writes it as `0`, so the loss happens on the way
*out* rather than on the way in. The editor showed `-0` and the wire carried
`0`, and the viewer rewrote a received `-0` while pretty-printing.

`hasImpreciseIntegerLiteral` becomes `findUnsendableNumberLiteral`, checking
`Object.is(Number(literal), -0)` for every numeric spelling — `-0`, `-0.0`,
`-0e1` — before the integer-only digit check. Returning the offending literal
rather than a boolean lets the message name it, which is more use than either
of the two rules behind it and matches how the duplicate-key message reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 06:11:23 -04:00
cliffhall
1b28fc8218 fix(web): detect duplicate object keys, which JSON.parse drops silently
Copilot's eleventh pass: three comments, one defect in three places.

`JSON.parse` accepts duplicate member names and keeps the last without
complaint, so `{"role":"user","role":"admin"}` becomes `{"role":"admin"}` —
a document that renders, and submits, as *less* than it says. In the read-only
viewer that is the Inspector hiding what the server sent; in a draft it is the
editor showing more than the wire will carry. Nothing downstream could catch
it, because by the time `JSON.parse` returns the evidence is gone.

New `findDuplicateObjectKey` scans the source, scoped per object so
`{"a":{"a":1}}` and `[{"a":1},{"a":2}]` are fine, and compares names by decoded
value so `"a"` and `"a"` collide as they should. It shares its
escape-handling with the number-literal scanner, which now reads strings
through the same helper rather than skipping them inline.

Wired into all four JSON-draft parsers and into the viewer's faithfulness
check, so the same document is refused, or shown unindented, everywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 05:53:18 -04:00
cliffhall
390e0c45ed fix(web): stop the read-only viewer reformatting a payload into a different one
Copilot's tenth pass generated no new inline comments. Of its two suppressed
ones, this fixes the first and declines the second.

`ContentViewer` pretty-prints by `JSON.parse` then `JSON.stringify`, which is a
round trip through JavaScript's number type and is not lossless. A server
sending `{"id":9007199254740993}` was shown `…992`, and one sending
`{"limit":1e400}` was shown `null`. Neither is a formatting difference: both
are the viewer telling the reader something untrue about the payload it
received, which for an inspector is the worst kind of bug. It predates this PR
— `formatJson` has always round-tripped — but this PR is where the read-only
JSON path was rebuilt, and the same two predicates the draft parsers use answer
it exactly.

Both formatters now display the payload as it arrived when reindenting would
alter a value: unindented, but honest. The copy button already copied the
original text either way, so this makes the screen agree with it.

Not changed: a literal whose *spelling* is normalized but whose value is not.
`0.10000000000000001` and `0.1` are the same double, as are `1e2` and `100`, so
those still format. Rejecting them would mean refusing any literal not already
in JS's shortest form — `1.50`, `1e2`, `0.5000` — which blocks drafts that send
exactly what they mean. The integer guard stays because there the digits name a
value the double is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 05:34:33 -04:00
cliffhall
03bf9ece42 fix(web): resolve the replay tool from the draft, and say why a draft is stuck
Copilot's ninth pass, three comments, two of them consequences of round 8.

- The coercion check resolved the tool from the *entry's* name while the modal
  lets `name` be edited, so retargeting an `echo` replay at `add` validated
  against `echo`. The modal now takes the whole tool list and resolves from the
  draft's current name, which is the one the dispatch will look up.

- `IMPRECISE_INTEGER_ERROR` (was `ROUNDED_INTEGER_ERROR`) no longer names the
  safe-integer range, because the range is not the rule: a whole number past
  ±(2^53 − 1) that is exactly representable round-trips and is accepted. It
  talks about exact representability instead, and its doc links the helper that
  actually exists.

- `SchemaJsonField` carries the parser's reason instead of flattening every
  case to "Not valid JSON", which misdiagnosed a document that parses fine and
  holds a number this client cannot send — leaving the user reading about
  syntax while looking at syntactically fine JSON.

The last one had a bug the gate caught rather than the tests: empty text is not
a *problem*, so routing the value through the reason check let `JSON.parse("")`
throw out of the change handler. It surfaced as five unhandled errors across
four suites with every assertion still green, which is why `npm run ci` and not
`npm run test` is the gate. Excluded explicitly, with a regression test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 05:16:23 -04:00
cliffhall
fcdc65b289 fix(web): read the literal, not the parsed number; project cursors and tool args
Copilot's eighth pass, three comments, all real.

- The precision check now reads the **source text**. Inferring the literal from
  `String(value)` was wrong in both directions: it accepted every full-form
  integer at or above 1e21 (`1000000000000000000001` parses to `1e21` and is
  written back as `1e+21`), and refused `2^54`, which is written out in full
  and exactly representable. `hasImpreciseIntegerLiteral` scans the draft for
  numeric tokens — skipping strings, so a digit-string value is untouched — and
  compares `BigInt(literal)` against `BigInt(Number(literal))`, which is exact.

- `""` is now reported as a surviving cursor for `tools/list` only. It builds
  its params with `cursor !== undefined` and carries the empty string
  deliberately; the other four list adapters use a truthiness check and drop
  it, so the editor showed `{"cursor":""}` while `{}` went out. The asymmetry
  looks like a latent bug in those four, but this function's job is to describe
  what the dispatch does.

- `callTool` runs every string-valued argument through `convertToolParameters`,
  so `{"count":"2"}` against a numeric schema is sent as `{"count":2}`.
  `reshapedReplayParam` now takes the tool and detects that by running the same
  conversion and comparing, rather than restating its rules — the two cannot
  drift. `tools` is threaded to `ProtocolEntry` for it, optional throughout: an
  unknown tool skips the check rather than blocking a replay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 04:56:22 -04:00
cliffhall
8f4aa7f8af fix(web): reject drafts whose digits or types would not survive being sent
Copilot's seventh pass, three comments, all the same class: the editor showing
one value while the wire carries another.

- `JSON.parse` rounds a whole number past 2^53−1 to the nearest double, so a
  draft reading `{"id":9007199254740993}` submits `…992`. New
  `hasRoundedInteger`, applied by all four JSON-draft parsers — the raw
  arguments editor, `SchemaJsonField`, the Edit-and-replay modal, and
  `parseJsonObjectDraft`, which Copilot did not flag but shares the defect.

  It is narrower than "unsafe integer": it also requires the number's shortest
  form to be written out in full, because JS stringifies from 1e21 upward in
  exponent form and such a value was typed that way — `{"n":1e308}` is a
  legitimate `_meta` value that `parseJsonObjectDraft`'s tests already pin as
  accepted. It still refuses the rare full-form integer that happens to be
  exactly representable, erring toward refusing a sendable value rather than
  misreporting an unsendable one — the trade `toNumericValue` already makes.

- `getPrompt` runs `convertPromptArguments`, which JSON-stringifies every
  non-string argument, so `{"count":2}` leaves as `{"count":"2"}` while the
  editor still shows a number. `reshapedReplayParam` now rejects a non-string
  prompt argument. Not a workaround: the spec types
  `GetPromptRequest.params.arguments` as `Record<string, string>`, so a string
  is the only thing a prompt argument can be. `tools/call` is untouched —
  `callTool` sends its arguments as given.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 04:34:45 -04:00
cliffhall
0e9069a78d fix(web): reject a reshaped replay param, and forward a declared JSON body
Copilot's sixth pass generated no new inline comments; both suppressed ones
were right.

- `arguments` surviving the key check is not the same as being sent as
  written. `replayProtocolRequest` applies `arguments ?? {}`, so a `null` shown
  in the editor reaches the wire as `{}`; an array or scalar is not nullish and
  rides the cast into a call whose type says it is a named-argument record.
  `reshapedReplayParam` rejects both before Send. `name` and `uri` are
  deliberately not checked there — the dispatch already refuses those with a
  reason the caller shows as a toast, so they fail visibly rather than
  silently.

- `NetworkEntry` never forwarded the body's content type, so the Network tab
  could not reach the declared-JSON branch at all and a malformed
  `application/json` response read as plain text with no sign it was meant to
  be JSON. Forwarded — but only when the declared type *is* JSON: passing it
  wholesale would also route an HTML body into the sandboxed frame and a CSV
  one into a table, which is a change to what the Network tab is and not one
  this makes.

The `NetworkEntry > Auth Success` play function moved off `textContent` for
body assertions. Ace repaints its rows on an animation frame and virtualizes
them, so reading the DOM straight after Reveal returned the previous rows
under load — it passed in isolation and failed in the full run. It now reads
the editors' documents alongside the DOM text, and waits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 04:17:19 -04:00
cliffhall
4e7537ea76 fix(web): validate the edited replay draft, not just its seed
Copilot's fifth pass: one comment, and the completion of the previous round's
fix. Seeding the editor from `replayableParams` stopped it *offering* a key
the dispatcher would drop, but nothing stopped one being typed in — adding
`_meta` to a `tools/list` draft still let Send close the modal and discard it.

`parseParamsDraft` now takes the method and rejects any key the dispatch would
not read, so Send is disabled with the offending names rather than silently
dropping them. It asks the same `replayableParams` the seed is built from, so
the editor and the dispatcher cannot disagree about which keys survive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 03:50:57 -04:00
cliffhall
5abe289caa fix(web): seed Edit-and-replay from what replay sends, name each JSON editor
Copilot's fourth pass, one inline plus two suppressed. All three were right.

- The modal presented the whole captured frame as editable, but replay does
  not re-send a frame: it dispatches through the typed InspectorClient
  methods, whose signatures have no room for `_meta` (a captured `tools/call`
  carries `_meta.progressToken`) or for any list-request key but `cursor`. So
  editing `_meta` and pressing Send transmitted something other than what was
  on screen. `replayableParams` now projects the frame down to what the
  dispatcher actually reads, the modal seeds from that, and it names what was
  left out rather than leaving the difference to be discovered. The projection
  lives beside the dispatch switch because the two are halves of one contract.

- `ContentViewer` gains `jsonLabel`, so a read-only JSON editor can say what
  it holds. Every one of them announced "JSON content", and an expanded
  Protocol entry has two while the list has many pairs — leaving a screen
  reader with indistinguishable textboxes and only the visible headings to
  tell them apart. Protocol entries now name theirs per method and direction,
  Network entries per request/response body.

- `isJsonDocument` became `formatJsonDocument`, returning the formatted text
  instead of a boolean. The heuristic path parsed the payload to decide and
  then parsed it again to render; Protocol and Network hand over serialized
  bodies as untyped text and are not virtualized, so a long history paid twice
  per payload per render.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 03:37:03 -04:00
cliffhall
12c00d8c63 fix(web): re-apply the editor's accessible name, and correct the README's scope
Copilot's third pass generated no new inline comments; all three of its
suppressed ones were right.

- `ariaLabel` is now re-applied from the wiring effect, not only at mount.
  `setOptions` updates Ace's option when the prop changes, but Ace recomputes
  the textarea's `aria-label` from it inside `TextInput.setAriaLabel()`, which
  it calls when the cursor moves — so a name that changed while the editor
  stayed mounted (a tool refreshed in place under a new field title, which
  `SchemaJsonField` passes straight through) left the visible label and the
  accessible name disagreeing until the user clicked into the box. `onLoad`
  keeps doing it too, since an effect runs after paint and this is the
  control's only name.

- The README claimed this editor is on "every surface where JSON is typed or
  displayed", which is false in two places it now names instead:
  `ExperimentalFeaturesPanel`'s JSON-RPC *response* is still a read-only
  `Textarea` (only its request box moved), and `OAuthTokenField` renders a
  decoded JWT in a `Code` block beside the raw token. Both bypass
  `ContentViewer` and so are not reached by its JSON branch. Documenting them
  as exceptions is what keeps the section from sending a maintainer to the
  wrong abstraction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 03:16:44 -04:00
cliffhall
b4083ee5fb fix(web): reject non-finite numbers, and keep the branch picker with the raw draft
Two more from Copilot's second pass, both real.

- Every JSON-draft parser now rejects a value `JSON.stringify` cannot write
  back. `JSON.parse("1e400")` yields `Infinity`, which serializes as `null` —
  so an editor showing `1e400` would have sent `null`, which is exactly the
  misreport `isSerializableJson` was written for. `parseJsonObjectDraft`
  already guarded; the raw-arguments editor and the Edit-and-replay modal did
  not, and neither did `SchemaJsonField`'s `parseJsonDraft` — the last
  predates this PR but is the same defect in the same family, so it is fixed
  with them rather than left as the odd one out.

- Editing a root union's discriminator in the raw document now moves the
  Variant picker. Nothing in `values` names a branch, so the index is held by
  the form and was only re-derived on `resetKey` or a rewritten union; a raw
  edit had no other signal, so `{"kind":"email"}` → `{"kind":"sms",…}` left
  the picker on Email and switching back to the widgets rendered the Email
  branch over SMS values. The current branch is kept when the values identify
  none, since the discriminator is absent every time it is retyped.

The raw editor is keyed by entity only, not by entity-and-branch like the
per-field editors: it holds the whole arguments object, so a branch move must
re-sync it rather than remount it — a remount would reformat the text being
typed and drop the caret to the top.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 03:01:47 -04:00
cliffhall
0c0d5faac6 chore(scripts): cover PowerShell's Env: drive, and correct two stale scope summaries (#2146)
Copilot, eleventh review:

- `Set-Item Env:SMOKE_BROWSER firefox` — PowerShell's Env: DRIVE, the other
  standard way to write the environment there — matched none of the patterns.
  Covered now, positional or via -Path/-Value, for Set-Item/New-Item and their
  -ItemProperty forms. A `Get-Item` READ is still not a finding, which has its
  own negative case: reading the variable is not setting it, the same
  distinction as round 5's `echo "SMOKE_BROWSER: firefox"`.

- Two scope summaries had drifted behind the traversal, both saying the guard
  reads only `run` and `env`/`with`: the module header's "ONLY EXECUTABLE
  POSITIONS" paragraph, and the copilot-instructions mirror. Both list the real
  set now — `run`, the `shell` command template at all three levels, and
  `env`/`container.env`/`with`. The mirror one is the AGENTS.md rule this repo
  states about itself, so it is a rule violation rather than a typo.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 02:44:41 -04:00
cliffhall
c9fb461bf0 fix(web): address Copilot review on the shared JSON editor
- Cap the read-only editor's `maxLines` at 200. Uncapped, Ace renders every
  line of a payload and its viewport virtualization never engages — which
  matters because the Protocol and Network lists are not virtualized and keep
  each entry's payload mounted inside its `Collapse`. 200 is chosen so no real
  payload nests a scrollbar inside its host's while the pathological one stays
  bounded; the StructuredOutputPanel `Large` story now pins both halves (full
  payload in the document, only part of it in the DOM).

- Take a disabled editor out of the tab order and give it `aria-disabled`.
  Ace has no disabled state, so its hidden textarea kept `tabindex="0"` and a
  keyboard user could tab into a field every neighbouring control had dropped
  — landing somewhere that looks editable and swallows what they type. A
  merely read-only editor stays focusable, since a payload has to be
  keyboard-scrollable (WCAG SC 2.1.1).

- Reword a confusing comment in the EditReplayModal test.

The `Disabled` story additionally scopes the axe `color-contrast` rule off:
WCAG 1.4.3 exempts inactive components, and axe cannot see that the dimmed
gutter belongs to one because the disabled state lives on Ace's hidden
textarea. Scoped to that one story so the rule still covers everything else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 02:44:21 -04:00
cliffhall
2ead041572 chore(scripts): scan the shell command template, and flag a multi-line env override (#2146)
Copilot, ninth and tenth reviews:

- A custom `shell:` is a COMMAND TEMPLATE that Actions runs around every
  script (`{0}` is the script path), so `shell: npm run local:gate && bash {0}`
  invoked the gate while the `run:` value beside it looked harmless. Step
  `shell`, and `defaults.run.shell` at both workflow and job level, are
  command regions now. An ordinary `bash`/`pwsh`/`cmd` matches no rule, so
  naming them costs nothing.

- GitHub's multiline environment-file syntax (`SMOKE_BROWSER<<EOF`, value,
  delimiter) bypassed the assignment patterns. It is reported as an override
  whose value the guard cannot READ rather than parsed: the value can arrive
  through any number of shapes — a brace group redirected to $GITHUB_ENV, a
  heredoc nested in another — and a parser handling some of them would be a
  coverage claim that holds only for the shapes someone thought of. Nobody
  spells `chromium` that way, and if they did, `SMOKE_BROWSER=chromium` is the
  fix.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 02:32:23 -04:00
cliffhall
1a2a36f0b8 feat(web): use the Ace JSON editor everywhere, and restore the raw-JSON toggle
Extracts the Ace integration #2094 carried inline into a shared
`elements/JsonEditor`, and renders it on every surface where the web
client types or displays JSON.

The element is deliberately text in, text out. The two editing contracts
above it disagree about what an unparseable draft means — JsonObjectInput
keeps the last valid object and says nothing to its parent, SchemaJsonField
reports `undefined` and blocks submission (#2020) — so parsing, and the
draft/value split that goes with it, stays with each caller.

Surfaces: SchemaJsonField (inherited by all four SchemaForm consumers), a
new "Edit as JSON" switch for the whole arguments object, the server.json
File Contents box, the raw JSON-RPC request box, ContentViewer's JSON
branch (read-only, inherited by its eleven consumers), and a new "Edit and
replay" modal on a Protocol entry.

Ace read-only replaces the CodeHighlight JSON path and the Prism `json`
grammar loader is dropped: folding, not colorization, is what it adds, and
two highlighters for one language drift. `wrap={false}` callers and untyped
text that only looks like JSON keep the plain renderer.

Closes #2151

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 02:24:22 -04:00
cliffhall
bc7abaea63 chore(scripts): accept a dynamic SetEnvironmentVariable argument as an override (#2146)
Copilot, ninth review: the .NET arm required a QUOTED second argument, so
`SetEnvironmentVariable("SMOKE_BROWSER", $engine)` — or a ${{ }} expression
there — set the environment with the guard silent. An engine the guard cannot
read is forbidden for the same reason `smoke:web:engine` is, so the arm now
accepts an unquoted value too. Bare values stop at `,` or `)`, so a third
argument ("Process") is not swallowed — pinned by an allowed three-argument
case beside the dynamic one.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 02:18:01 -04:00
cliffhall
53e773d70e chore(scripts): stop the assignment capture at the closing quote (#2146)
Copilot, eighth review: the bare-value pattern ran to whitespace, so a QUOTED
assignment swallowed the closing quote — `echo "SMOKE_BROWSER=chromium" >>
$GITHUB_ENV` captured `chromium"`, which is not `chromium`, and the guard
rejected a PERMITTED assignment. A false positive on the allowed value is the
worse direction to be wrong in: it fails a workflow that is doing the right
thing, and there is no way to satisfy the guard except to stop using the
supported spelling.

The value is now a quoted string or a bare run stopping at a quote or a `;`
(PowerShell's separator), so all three dialects unwrap the same way.
Regression cases for the permitted quoted `chromium` in $GITHUB_ENV, in a
cmd-style `set`, and single-quoted in PowerShell — each paired with the
firefox spelling that must still fail.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 02:06:59 -04:00
cliffhall
47b3c6fc9c chore(scripts): read a shell-expanded name as unreadable, and env keys case-insensitively (#2146)
Copilot, seventh review:

- The unreadable-name rules only knew GitHub expression syntax, so
  `ENGINE: firefox` plus `npm run smoke:web:$ENGINE` ran a forbidden pass with
  the guard silent — the shell resolves the name, which makes it exactly as
  unreadable as a ${{ }} expression and forbidden for the same reason. The
  pattern keys off a leading $ or %, covering $VAR, ${VAR}, $env:VAR and cmd's
  %VAR% in one, for both the engine and `local:` families.

- The env-key comparison was case-sensitive, but Windows environment variables
  are not: `env: { smoke_browser: firefox }` on a windows-latest job sets the
  very variable Node reads as SMOKE_BROWSER. Compared case-insensitively now,
  as is the POSIX assignment pattern. On a POSIX runner that spelling would be
  a different variable and harmless, so this is stricter there than it needs to
  be — the right direction for a guard, and the alternative is a verdict that
  depends on a `runs-on` which may itself be an expression.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 01:54:23 -04:00
cliffhall
74aa94fb1f chore(scripts): detect a SMOKE_BROWSER assignment in any shell dialect (#2146)
Copilot, sixth review: the run-script rule matched POSIX `NAME=value` only.
PowerShell is the DEFAULT shell on a Windows runner, so
`$env:SMOKE_BROWSER = "firefox"; npm run smoke` redirected the suite with the
guard silent — on exactly the workflow least likely to be read closely.

Three patterns now, one per dialect: POSIX (which also covers cmd's
`set NAME=…` and the GitHub-native `>> $GITHUB_ENV` append, since both carry
the same substring), PowerShell's `$env:NAME =` and `${env:NAME} =`, and the
.NET SetEnvironmentVariable call reachable from PowerShell. Regression cases
for all five spellings, plus a negative for a PowerShell assignment of
chromium.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 01:40:27 -04:00
cliffhall
c37e795dca test(web): assert the deep-link 409 race actually reaches the connect phase
Copilot round 2: the "swallows the already-exists race and still
connects" test only asserted that nothing was recorded, so a deep link
that swallowed the 409 and then stopped forever would have stayed green.
Rerender with the hydrated row and wait for the connect.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 01:32:44 -04:00
cliffhall
6d2806de75 chore(scripts): make the browser rule kind-aware, cover container.env, pin the name (#2146)
Copilot, fifth review:

- The SMOKE_BROWSER rule was a blanket text match across every region, so a
  script that merely PRINTS the variable — `echo "SMOKE_BROWSER: firefox"` —
  or a `with:` input mentioning it was reported as an override. It is
  kind-aware now: an `env:` entry counts only when SMOKE_BROWSER is literally
  its key (recognized structurally, not by regex), a `run:` script only on a
  shell assignment, and a `with:` value not at all — whatever it names is
  already covered by the script rules. A shell assignment inside an `echo` is
  indistinguishable without parsing the shell, and inside `run:` erring toward
  the finding is the safe direction; that is stated in the header.

- `jobs.<id>.container.env` is job-wide too — every step in the container sees
  it — so an override there redirected `npm run smoke` with the guard silent.
  Now traversed, and a `container:` given as a bare image string is ignored
  rather than tripping over.

- Nothing asserted the FIRST acceptance criterion: reintroducing a `ci` or
  `ci:*` root script left test:scripts green. Asserted directly over the
  manifest's keys now, and checked to actually bite by reviving `ci` and
  watching the suite fail.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-27 01:25:51 -04:00