fix(console): stand up component tests, and stop the console moving controls it does not own - #60
Merged
Merged
Conversation
…ontrols it does not own `vitest.config.mts` collected only `.test.ts` and there was no DOM environment, so no React component in this repo was testable — which is why none of the handlers below had a regression test. jsdom + @testing-library/react are in, the include pattern takes `.test.tsx`, and a harness test asserts both. Security controls that moved without the operator meaning it: - **Shift+Tab cycled the agent's approval policy** from any non-editable focus — a button, a link, the rail nav — and one of the four rungs is "autonomous execution, no prompts". `nextCycledRung` skips `off` (still selectable from the menu), and `preventDefault()` now fires only when the key actually does something, so reverse-tab navigation works again across the console. - **A failed autonomy write left the wrong rung on screen** and armed the staleness guard that would have corrected it, because the stamp was set in `.finally()`. The previous rung is captured before the optimistic set and restored in `catch`, the stamp moved to `.then()`, and a failure re-reads the config once instead of waiting on a 30-second poll that stops when the tab is hidden. - **The BFF had no expected-Host allowlist**, so a rebound DNS name satisfied its only same-origin check — both Origin and Host say `evil.test` — and the page's script could read the full config and issue privileged writes, signed with the gateway token. Loopback by default, widened by `RANTAICLAW_UI_ALLOWED_HOSTS` and the existing dev origins so a LAN operator is not locked out silently. - **The approval boundary was invisible**: `approval_owners` and `autonomous_tools` appeared nowhere in `src/`. The Telegram card now renders owners as chips, "no owners — anything needing approval is auto-denied" when the list is empty, and a warning when `autonomous_tools` voids both. Control defects: - `describeApiError` maps status to an operator-facing sentence, and the nine catch sites that flattened everything to `.message` route through it — a session expiry, a restarting gateway and a genuine 400 rendered identically. - The skills toggle sets its busy flag (it never did, so `disabled` never engaged during the write) and reports the server's `enabled`, not the requested value. - Cron "Run now" surfaces the full refusal instead of truncating it at 200 characters — which is exactly where the policy's reason lives — and offers an explicit "Run with approval" that re-issues with `approved=true`. The parameter existed with no caller, so a gated job was unrunnable from here. It is a privileged path, so it is never sent silently. - The config dump masks `mcp_servers.*.env` **values** client-side. The backend redacts by key-name suffix, which cannot cover arbitrary operator-chosen env names, and the label said "secrets redacted" over exactly that gap. README: `RANTAICLAW_UI_PASSWORD` and `bun run pair` do not exist. Removed, with `RANTAICLAW_UI_SECRET`, `RANTAICLAW_UI_TRUST_PROXY` and the new `RANTAICLAW_UI_ALLOWED_HOSTS` documented, Auth pointed at `docs/auth.md`, and channels moved out of "read-only views" now that the panel edits an allowlist.
…n CI has
CI (Node 20) died starting the forks worker for the one `.tsx` test:
Failed to start forks worker for src/components/console/harness.test.tsx
Caused by: TypeError: webidl.util.markAsUncloneable is not a function
That comes from the undici jsdom 30 pulls in, which expects internals Node 20
does not have. It passed locally on Node 26 — local green was not CI green.
`happy-dom` has no undici dependency and is one of the two environments the plan
named. Only the environment changes; the test and the include pattern are the
same.
Note: this could not be reproduced locally. The only Node versions on this
machine are 18 (too old for vitest 4 to start at all) and 26 (where jsdom
worked). The fix targets the stated cause rather than a reproduction.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Executes
plans/138-console-hardening-and-tests.md(plan file lives in theRantAIClaw repo). Depends on #59, which is merged.
Problem
Everything else the console gets wrong about state it does not own — including
two ways a security control moves without the operator meaning it — on top of a
repo where no React component was testable, let alone tested.
Step 1 first, deliberately
vitest.config.mtshadinclude: ["src/**/*.test.ts"]andenvironment: "node",so
.test.tsxwas never collected and there was no DOM. That is why none ofthe handlers below had a regression test. jsdom and
@testing-library/reactaredev dependencies now, the include pattern takes
.test.tsx, andharness.test.tsxasserts both — a component renders into a DOM.Per the plan, the cheap wins were harvested as pure functions in
src/lib/first, following
console.test.tsas the model.The security fixes
Shift+Tab cycled the approval policy from any non-editable focus — a button,
a link, the rail nav — and one rung is "autonomous execution, no prompts". The
code's own comment named the hazard and then guarded only text inputs and
dialogs.
nextCycledRungskipsoff(still selectable from the menu), andpreventDefault()fires only when the key changes something, so reverse-tabnavigation works again everywhere.
A failed autonomy write left the wrong rung on screen and armed the
staleness guard that would have corrected it — the stamp was in
.finally(). Sothe console could show "Off" while the gateway ran Manual, or the reverse for an
operator who believed they had just locked the agent down, until a 30-second poll
that stops when the tab is hidden. Previous rung captured before the optimistic
set and restored in
catch; stamp moved to.then(); one immediate re-read onfailure.
The BFF had no expected-Host allowlist. The Origin check compares Origin
against the request's own Host — correct and bind-address independent, and
exactly what a rebound DNS name satisfies:
evil.testresolves to127.0.0.1, both headers agree, and the page's script reads the full config and
issues privileged writes signed with the gateway token. Loopback by default,
widened by
RANTAICLAW_UI_ALLOWED_HOSTSand the existing dev origins so a LANoperator is not locked out silently — the plan's point that a lockout looks like
an outage. Applied to reads too: the config dump is as much of a prize as a
write.
The approval boundary was invisible —
approval_ownersandautonomous_toolsappeared nowhere insrc/. Rendered read-only in the Telegramcard: owners as chips, an explicit "no owners — anything needing approval is
auto-denied", and a warning when
autonomous_toolsvoids both.Control defects (step 6) and the README (step 7)
describeApiErrormaps status → sentence and the nine catch sites that flattenedto
.messageroute through it. Skills toggle sets its busy flag and reports theserver's
enabled. Cron "Run now" shows the full refusal and offers an explicit"Run with approval" that re-issues with
approved=true— a parameter thatexisted with no caller, so a gated job was unrunnable from the console. The config
dump masks
mcp_servers.*.envvalues client-side, which is the gap thebackend's suffix heuristic cannot cover and the old label papered over.
README:
RANTAICLAW_UI_PASSWORDandbun run pairdo not exist; both removed,RANTAICLAW_UI_SECRET/RANTAICLAW_UI_TRUST_PROXY/RANTAICLAW_UI_ALLOWED_HOSTSdocumented, Auth pointed at
docs/auth.md, channels moved out of "read-onlyviews".
docs/DESIGN.mdswept.Left alone:
docs/superpowers/specs/2026-07-09-*.mdstill namesRANTAICLAW_UI_PASSWORD. It is a dated design spec — a historical record ofwhat was proposed then — and this repo family treats date-stamped snapshots as
immutable. Rewriting it would falsify the record.
What I did NOT do, and why
Step 8 (drive it in a browser) was not done, and the two component tests
(
autonomy_rollback_restores_the_previous_rung,backtab_from_a_button_does_not_change_the_rung) are not written. Both needConsoleShellmounted, which pulls in the chat stack, the gateway status pollerand roughly a dozen API calls — a mount harness that is its own piece of work.
I built the harness the plan asked for as step 1 and proved it collects and runs
a
.tsxtest; I did not go on to mount the shell. I am not claiming those twotests or the four drive observations. What backs the behaviour instead:
nextCycledRungandautonomyReadIsStaleare extracted, tested, andmutation-checked — those are the decisions the handlers make.
bff-confinement.test.tsalreadycovers, including the rebound-DNS case, and mutation-checked.
The rollback path (
catch→ restore → re-read) is the one change here with notest at all. It is eight lines and readable, but that is a statement about its
size, not evidence.
Validation
vitest run→ 180 passed, 22 files (was 163 / 21).tsc --noEmit→ exit 0.next build→ succeeded (route table rendered).nextCycledRungallowed to reachoff→ its test FAILED.Tooling note
bun/bunxare not installed here;node_modules/.binbinaries were useddirectly, and
npm install --save-devadded the harness deps (sopackage-lock.jsonmoves, notbun.lock).