Skip to content

fix(console): stand up component tests, and stop the console moving controls it does not own - #60

Merged
sulthannauval merged 2 commits into
mainfrom
fix/console-hardening-and-tests
Aug 14, 2026
Merged

fix(console): stand up component tests, and stop the console moving controls it does not own#60
sulthannauval merged 2 commits into
mainfrom
fix/console-hardening-and-tests

Conversation

@sulthannauval

Copy link
Copy Markdown
Member

Executes plans/138-console-hardening-and-tests.md (plan file lives in the
RantAIClaw 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.mts had include: ["src/**/*.test.ts"] and environment: "node",
so .test.tsx was never collected and there was no DOM. That is why none of
the handlers below had a regression test. jsdom and @testing-library/react are
dev dependencies now, the include pattern takes .test.tsx, and
harness.test.tsx asserts 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.ts as 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. nextCycledRung skips off (still selectable from the menu), and
preventDefault() fires only when the key changes something, so reverse-tab
navigation 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(). So
the 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 on
failure.

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.test resolves to
127.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_HOSTS and the existing dev origins so a LAN
operator 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 invisibleapproval_owners and
autonomous_tools appeared nowhere in src/. Rendered read-only in the Telegram
card: owners as chips, an explicit "no owners — anything needing approval is
auto-denied", and a warning when autonomous_tools voids both.

Control defects (step 6) and the README (step 7)

describeApiError maps status → sentence and the nine catch sites that flattened
to .message route through it. Skills toggle sets its busy flag and reports the
server's enabled. Cron "Run now" shows the full refusal and offers an explicit
"Run with approval" that re-issues with approved=true — a parameter that
existed with no caller, so a gated job was unrunnable from the console. The config
dump masks mcp_servers.*.env values client-side, which is the gap the
backend's suffix heuristic cannot cover and the old label papered over.

README: RANTAICLAW_UI_PASSWORD and bun run pair do not exist; both removed,
RANTAICLAW_UI_SECRET / RANTAICLAW_UI_TRUST_PROXY / RANTAICLAW_UI_ALLOWED_HOSTS
documented, Auth pointed at docs/auth.md, channels moved out of "read-only
views". docs/DESIGN.md swept.

Left alone: docs/superpowers/specs/2026-07-09-*.md still names
RANTAICLAW_UI_PASSWORD. It is a dated design spec — a historical record of
what 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 need
ConsoleShell mounted, which pulls in the chat stack, the gateway status poller
and 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 .tsx test; I did not go on to mount the shell. I am not claiming those two
tests or the four drive observations.
What backs the behaviour instead:

  • nextCycledRung and autonomyReadIsStale are extracted, tested, and
    mutation-checked — those are the decisions the handlers make.
  • The Host allowlist is tested at the layer bff-confinement.test.ts already
    covers, including the rebound-DNS case, and mutation-checked.

The rollback path (catch → restore → re-read) is the one change here with no
test at all. It is eight lines and readable, but that is a statement about its
size, not evidence.

Validation

  • vitest run180 passed, 22 files (was 163 / 21).
  • tsc --noEmit → exit 0.
  • next build → succeeded (route table rendered).
  • Mutation checks:
    • the Host allowlist made permissive → 3 tests FAILED.
    • nextCycledRung allowed to reach off → its test FAILED.

Tooling note

bun/bunx are not installed here; node_modules/.bin binaries were used
directly, and npm install --save-dev added the harness deps (so
package-lock.json moves, not bun.lock).

…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.
@sulthannauval
sulthannauval merged commit c23d2e9 into main Aug 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant