From c2be0053e8e3a6cd053f1a3dd6374107795f042d Mon Sep 17 00:00:00 2001 From: Sulthan Nauval Abdillah Date: Fri, 14 Aug 2026 06:07:20 +0000 Subject: [PATCH 1/2] fix(console): stand up component tests, and stop the console moving controls it does not own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- README.md | 13 +- docs/DESIGN.md | 2 +- package-lock.json | 684 ++++++++++++++++++ package.json | 3 + src/components/console/console-shell.tsx | 50 +- src/components/console/harness.test.tsx | 16 + src/components/ops/channels-panel.test.ts | 25 +- src/components/ops/channels-panel.tsx | 58 +- src/components/ops/config-panel.tsx | 5 +- src/components/ops/cron-panel.tsx | 48 +- .../ops/doc-intelligence-drawer.tsx | 4 +- src/components/ops/kb-panel.tsx | 4 +- .../ops/knowledge-settings-card.tsx | 4 +- src/components/ops/skill-editor.tsx | 6 +- src/components/ops/skills-panel.tsx | 16 +- src/lib/api-error.test.ts | 23 +- src/lib/api.ts | 27 + src/lib/bff-confinement.test.ts | 46 +- src/lib/console.test.ts | 62 ++ src/lib/console.ts | 57 ++ src/lib/request-origin.ts | 49 ++ src/proxy.ts | 17 +- vitest.config.mts | 7 +- 23 files changed, 1176 insertions(+), 50 deletions(-) create mode 100644 src/components/console/harness.test.tsx diff --git a/README.md b/README.md index ee11b8e..f66b333 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,15 @@ OpenClaw-style ops panels. history with resume / full-text search / rename / delete, inline tool-call cards, syntax-highlighted code blocks, regenerate, model/provider switching, Stop, and a context/details side panel. - **Ops** — live metric bar + panels, most now **interactive**: - - status & doctor, sessions, usage, providers, channels, memory (read-only views) + - status & doctor, sessions, usage, providers, memory (read-only views) + - **Channels** — connect a Telegram bot and edit its sender allowlist - **Cron** — create agent jobs, enable/disable, run-now, delete - **Skills** — enable/disable toggles - **Config** — edit the default model + view full config (secrets redacted) - **Secrets** — set the active provider key (encrypted at rest, never echoed) - **Persona** — switch presets -- **Auth** — optional password gate (signed HttpOnly cookie); set `RANTAICLAW_UI_PASSWORD` to enable. +- **Auth** — optional login gate (signed HttpOnly cookie). See [docs/auth.md](docs/auth.md) for how + it is enabled and what `RANTAICLAW_UI_SECRET` is for. Hardened for self-hosting: server-side proxy (token never reaches the browser), CSP + security headers, standalone Docker image, health endpoint, graceful gateway-down handling. @@ -48,8 +50,8 @@ bun install bun run dev # → http://127.0.0.1:3939 ``` -If the gateway requires pairing, run `bun run pair` (reads the pairing code you paste in, calls -`POST /pair`, writes the token into `.env.local`), or use `scripts/dev.sh` to do it all. +If the gateway requires pairing, put the token from `POST /pair` into `RANTAICLAW_TOKEN` in +`.env.local`, or run `scripts/dev.sh`, which does the pairing and writes it for you. ## Configuration @@ -57,6 +59,9 @@ If the gateway requires pairing, run `bun run pair` (reads the pairing code you |---|---|---| | `RANTAICLAW_GATEWAY_URL` | `http://127.0.0.1:3000` | Gateway base URL (server-side only) | | `RANTAICLAW_TOKEN` | _(empty)_ | Bearer token from `POST /pair`; empty when `require_pairing=false` | +| `RANTAICLAW_UI_SECRET` | _(empty)_ | Cookie-signing secret. Required when login is enabled — the console refuses to serve a forgeable session without it. `rantaiclaw ui start` generates one | +| `RANTAICLAW_UI_TRUST_PROXY` | `0` | Set to `1` behind a TLS-terminating reverse proxy, so the session cookie is marked `Secure` from `X-Forwarded-Proto` | +| `RANTAICLAW_UI_ALLOWED_HOSTS` | loopback | Extra `Host` values the BFF answers `/api/rc/*` on. Loopback is always allowed; add a LAN name or address here if you reach the console by one | ## Remote access diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7b86126..0edb82b 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -129,7 +129,7 @@ Base: `${RANTAICLAW_GATEWAY_URL}/api/v1`. Auth: `Authorization: Bearer ` key, encrypted at rest via `Config::save()`, never echoed). 7. **Personality** ✅ — `PUT /api/v1/personality` wired into the Persona panel. 8. **Auth** ✅ (frontend) — optional password gate (`src/proxy.ts` + signed HttpOnly cookie), - enabled via `RANTAICLAW_UI_PASSWORD`. + enabled per `docs/auth.md`; the cookie is signed with `RANTAICLAW_UI_SECRET`. This brings the management surface to **minimal parity with the Hermes web UI**. diff --git a/package-lock.json b/package-lock.json index 8ed9612..c6cc5a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,10 +24,13 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.9", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/node": "^22.10.2", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@types/react-syntax-highlighter": "^15.5.13", + "jsdom": "^30.0.1", "tailwindcss": "^4.1.9", "tw-animate-css": "^1.3.3", "typescript": "^5.7.2", @@ -47,6 +50,64 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -56,6 +117,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -89,6 +303,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1475,6 +1707,54 @@ "tailwindcss": "4.3.2" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tweenjs/tween.js": { "version": "25.0.0", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", @@ -1492,6 +1772,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1737,6 +2024,39 @@ "node": ">=12" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1767,6 +2087,16 @@ "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001799", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", @@ -1903,6 +2233,20 @@ "dev": true, "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2125,6 +2469,35 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2142,6 +2515,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -2187,6 +2567,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.21.6", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", @@ -2201,6 +2588,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", @@ -2442,6 +2842,19 @@ "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", "license": "CC0-1.0" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -2532,6 +2945,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jerrypick": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz", @@ -2557,6 +2977,47 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/kapsule": { "version": "1.16.3", "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", @@ -2884,6 +3345,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/lucide-react": { "version": "0.454.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz", @@ -2893,6 +3364,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3183,6 +3664,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -3908,6 +4396,19 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3973,6 +4474,28 @@ "url": "https://opencollective.com/preact" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -4003,6 +4526,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -4191,6 +4724,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -4225,6 +4768,19 @@ "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4394,6 +4950,13 @@ } } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", @@ -4475,6 +5038,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -4525,6 +5134,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -4815,6 +5434,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4832,6 +5499,23 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 0e54931..c775852 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,13 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.9", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/node": "^22.10.2", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@types/react-syntax-highlighter": "^15.5.13", + "jsdom": "^30.0.1", "tailwindcss": "^4.1.9", "tw-animate-css": "^1.3.3", "typescript": "^5.7.2", diff --git a/src/components/console/console-shell.tsx b/src/components/console/console-shell.tsx index b5ccc7d..79f156a 100644 --- a/src/components/console/console-shell.tsx +++ b/src/components/console/console-shell.tsx @@ -23,9 +23,11 @@ import { DEFAULT_ACCENT, autonomyPreset, channelDot, + autonomyReadIsStale, initials, levelToRung, NAV, + nextCycledRung, ROUTE_META, type Route, resolveHashRoute, @@ -226,7 +228,7 @@ export function ConsoleShell({ // surface. const applyAutonomyFromConfig = React.useCallback( (c: Record | null | undefined, readStartedAt: number) => { - if (readStartedAt < autonomyWrittenAt.current) return; + if (autonomyReadIsStale(readStartedAt, autonomyWrittenAt.current)) return; const auto = c?.autonomy as | { level?: string; always_ask?: string[] } | undefined; @@ -346,19 +348,39 @@ export function ConsoleShell({ const autonomyRef = React.useRef(autonomy); autonomyRef.current = autonomy; const changeAutonomy = React.useCallback((rung: string) => { + // Captured BEFORE the optimistic set, so a failed write can put it back. + // Without this the console displayed a rung the gateway was not on — "Off" + // while it ran Manual, or the reverse for an operator who believed they had + // just locked the agent down. + const previous = autonomyRef.current; setAutonomyState(rung); api .setAutonomy(rungToAutonomyPayload(rung)) - .then(() => toast.success(`Autonomy → ${autonomyPreset(rung).label}`)) - .catch((e) => - toast.error( - `Autonomy update failed: ${e instanceof Error ? e.message : e}`, - ), - ) - .finally(() => { + .then(() => { + // Stamped in `then` only. In `finally`, a FAILED write armed the + // staleness guard and discarded the very read that would have corrected + // the screen. autonomyWrittenAt.current = Date.now(); + toast.success(`Autonomy → ${autonomyPreset(rung).label}`); + }) + .catch((e) => { + setAutonomyState(previous); + toast.error( + `Autonomy unchanged (still ${autonomyPreset(previous).label}): ${ + e instanceof Error ? e.message : e + }`, + ); + // Re-read once rather than waiting on the 30-second poll, which also + // stops while the tab is hidden. + const startedAt = Date.now(); + api + .config() + .then((c) => applyAutonomyFromConfig(c, startedAt)) + .catch(() => { + /* the toast above already told them; a failed re-read adds noise */ + }); }); - }, []); + }, [applyAutonomyFromConfig]); // Refresh the session list after a turn finishes streaming. const prevStreaming = React.useRef(false); @@ -413,9 +435,15 @@ export function ConsoleShell({ el.tagName === "SELECT" || el.isContentEditable); if (editable || document.querySelector('[role="dialog"]')) return; + // `nextCycledRung` skips `off` ("no prompts") — this binding is one + // keypress from any button, link or rail item, with no confirmation. + // Selecting `off` stays possible from the autonomy menu. + const next = nextCycledRung(autonomyRef.current); + // Only swallow the key when it actually does something. The + // unconditional `preventDefault()` broke reverse-tab navigation across + // the whole console. + if (!next || next === autonomyRef.current) return; e.preventDefault(); - const next = - order[(order.indexOf(autonomyRef.current) + 1) % order.length]; changeAutonomy(next); } }; diff --git a/src/components/console/harness.test.tsx b/src/components/console/harness.test.tsx new file mode 100644 index 0000000..c5b5dfe --- /dev/null +++ b/src/components/console/harness.test.tsx @@ -0,0 +1,16 @@ +// @vitest-environment jsdom +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +/** + * The harness itself. `vitest.config.mts` collected only `.test.ts`, so no + * React component in this repo was testable — this asserts that a `.tsx` test + * is now collected AND that jsdom is available to it, which is the precondition + * every component test in this plan depends on. + */ +describe("component test harness", () => { + it("renders a component into a DOM", () => { + render(); + expect(screen.getByRole("button", { name: "approve" })).toBeTruthy(); + }); +}); diff --git a/src/components/ops/channels-panel.test.ts b/src/components/ops/channels-panel.test.ts index 416c6e4..7768833 100644 --- a/src/components/ops/channels-panel.test.ts +++ b/src/components/ops/channels-panel.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { allowlistDrift } from "./channels-panel"; +import { allowlistDrift, approvalBoundary } from "./channels-panel"; describe("allowlistDrift", () => { it("is silent when the server matches what the editor was seeded from", () => { @@ -27,3 +27,26 @@ describe("allowlistDrift", () => { expect(d?.alsoChanged).toEqual(["bob"]); }); }); + +describe("approvalBoundary", () => { + it("reads owners and the autonomous_tools flag out of GET /config", () => { + const b = approvalBoundary({ + channels_config: { approval_owners: ["1360247715"], autonomous_tools: false }, + }); + expect(b.owners).toEqual(["1360247715"]); + expect(b.autonomousTools).toBe(false); + }); + + it("reports the flag that voids the owner list", () => { + // With this true, an operator reading "no owners" would conclude channel + // senders cannot trigger tools — while every message runs them unprompted. + const b = approvalBoundary({ channels_config: { autonomous_tools: true } }); + expect(b.autonomousTools).toBe(true); + expect(b.owners).toEqual([]); + }); + + it("is empty, not undefined, for a config with no channels section", () => { + expect(approvalBoundary(null)).toEqual({ owners: [], autonomousTools: false }); + expect(approvalBoundary({})).toEqual({ owners: [], autonomousTools: false }); + }); +}); diff --git a/src/components/ops/channels-panel.tsx b/src/components/ops/channels-panel.tsx index 5027406..0475e17 100644 --- a/src/components/ops/channels-panel.tsx +++ b/src/components/ops/channels-panel.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import { api } from "@/lib/api"; +import { api, describeApiError } from "@/lib/api"; import { useAsync } from "@/hooks/use-async"; import { useGatewayStatus } from "@/hooks/use-gateway-status"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -40,6 +40,26 @@ function telegramAllowlist(config: Record | null): string[] { return Array.isArray(allowed) ? (allowed as string[]) : []; } +/** + * Who may approve a gated tool call, and whether the gate is on at all. + * + * `approval_owners` and `autonomous_tools` appeared nowhere in this console — + * so an operator could read a connected channel with no owners and not know + * that anything needing approval is auto-denied, or that `autonomous_tools` + * bypasses the gate entirely and runs everything unprompted. + */ +export function approvalBoundary(config: Record | null): { + owners: string[]; + autonomousTools: boolean; +} { + const cc = config?.["channels_config"] as Record | undefined; + const owners = cc?.["approval_owners"]; + return { + owners: Array.isArray(owners) ? (owners as string[]) : [], + autonomousTools: cc?.["autonomous_tools"] === true, + }; +} + /** * What saving `next` would do to a server list that has moved since the editor * was seeded from `seeded`. @@ -141,6 +161,7 @@ export function ChannelsPanel() { @@ -166,10 +187,12 @@ export function ChannelsPanel() { function TelegramCard({ connected, allowedUsers, + boundary, onReload, }: { connected: boolean; allowedUsers: string[]; + boundary: { owners: string[]; autonomousTools: boolean }; onReload: () => void; }) { const [token, setToken] = React.useState(""); @@ -215,7 +238,7 @@ function TelegramCard({ setToken(""); onReload(); } catch (e) { - toast.error(e instanceof Error ? e.message : String(e)); + toast.error(describeApiError(e)); } finally { setBusy(false); } @@ -237,7 +260,7 @@ function TelegramCard({ notify(r); onReload(); } catch (e) { - toast.error(e instanceof Error ? e.message : String(e)); + toast.error(describeApiError(e)); } finally { setBusy(false); } @@ -277,7 +300,7 @@ function TelegramCard({ setConfirmDisconnect(false); onReload(); } catch (e) { - toast.error(e instanceof Error ? e.message : String(e)); + toast.error(describeApiError(e)); } finally { setBusy(false); } @@ -312,6 +335,33 @@ function TelegramCard({ value={users} onChange={(e) => setUsers(e.target.value)} /> + {/* Who may approve a gated tool call, and whether the gate is on + at all. Neither value appeared anywhere in this console, so a + connected channel with no owners looked the same as one with + them — and `autonomous_tools` silently voided both. */} +
+ {boundary.autonomousTools ? ( +
+ ⚠ autonomous_tools = true — messages on this channel run tools without + approval. The owner list below does not restrain them. +
+ ) : boundary.owners.length === 0 ? ( +
+ No approval owners — anything needing approval is auto-denied. Set + channels_config.approval_owners, or send + /claim <code> from the chat. +
+ ) : ( +
+ May approve tool calls: + {boundary.owners.map((o) => ( + + {o} + + ))} +
+ )} +
Saving reloads the runtime to apply — no need to re-enter the bot token. diff --git a/src/components/ops/config-panel.tsx b/src/components/ops/config-panel.tsx index 84a055d..ee6a7f6 100644 --- a/src/components/ops/config-panel.tsx +++ b/src/components/ops/config-panel.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { Eye, EyeOff, Save } from "lucide-react"; import { api } from "@/lib/api"; +import { maskConfigForDisplay } from "@/lib/console"; import { useAsync } from "@/hooks/use-async"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -66,12 +67,12 @@ export function ConfigPanel() { className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer" > {showRaw ? : } - {showRaw ? "Hide" : "Show"} full config (secrets redacted) + {showRaw ? "Hide" : "Show"} full config (API keys and MCP env values masked) {showRaw && (
-              {JSON.stringify(cfg.data, null, 2)}
+              {JSON.stringify(maskConfigForDisplay(cfg.data), null, 2)}
             
)} diff --git a/src/components/ops/cron-panel.tsx b/src/components/ops/cron-panel.tsx index 0198537..227873f 100644 --- a/src/components/ops/cron-panel.tsx +++ b/src/components/ops/cron-panel.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { History, Pencil, Play, Plus, Power, Trash2 } from "lucide-react"; -import { api } from "@/lib/api"; +import { api, describeApiError } from "@/lib/api"; import type { CronJob, CronRun, CronSchedule } from "@/lib/types"; import { CRON_PRESETS, describeCron, validateCron } from "@/lib/cron"; import { useAsync } from "@/hooks/use-async"; @@ -43,6 +43,11 @@ export function CronPanel() { const [model, setModel] = React.useState(""); const [busy, setBusy] = React.useState(false); const [pendingDelete, setPendingDelete] = React.useState<{ id: string; name: string } | null>(null); + // A run the security policy refused, with the full reason — the operator can + // re-issue it explicitly. + const [pendingApproval, setPendingApproval] = React.useState<{ id: string; reason: string } | null>( + null, + ); const [deleting, setDeleting] = React.useState(false); const [editing, setEditing] = React.useState(null); const [history, setHistory] = React.useState(null); @@ -129,17 +134,30 @@ export function CronPanel() { toast.error(String(e instanceof Error ? e.message : e)); } }; - const run = async (id: string) => { + const run = async (id: string, approved = false) => { const t = toast.loading("Running job…"); try { - const r = await api.runCron(id); - toast[r.success ? "success" : "error"](r.success ? "Job ran" : "Job failed", { - id: t, - description: (r.output || "").slice(0, 200), - }); + const r = await api.runCron(id, approved); + const output = r.output || ""; + if (r.success) { + toast.success("Job ran", { id: t, description: output.slice(0, 200) }); + refresh(); + return; + } + // A refusal by the security policy is not a failed job, and the reason + // used to be truncated at 200 characters — which is exactly where the + // policy's explanation lives. `approved=true` existed on the API with no + // caller, so a gated job was simply unrunnable from the console. It is a + // privileged path, so it is never sent silently. + if (!approved && /approval|not approved|denied|policy/i.test(output)) { + setPendingApproval({ id, reason: output }); + toast.dismiss(t); + return; + } + toast.error("Job failed", { id: t, description: output.slice(0, 200) }); refresh(); } catch (e) { - toast.error(`Run failed: ${e instanceof Error ? e.message : e}`, { id: t }); + toast.error(`Run failed: ${describeApiError(e)}`, { id: t }); } }; const del = async () => { @@ -386,6 +404,18 @@ export function CronPanel() { busy={deleting} onConfirm={del} /> + setPendingApproval(null)} + title="This job needs approval to run" + description={pendingApproval?.reason} + confirmLabel="Run with approval" + onConfirm={async () => { + const p = pendingApproval; + setPendingApproval(null); + if (p) await run(p.id, true); + }} + />
); } @@ -548,7 +578,7 @@ function CronRunsModal({ job, onClose }: { job: CronJob | null; onClose: () => v api .cronRuns(job.id) .then((r) => alive && setRuns(r.runs)) - .catch((e) => alive && setError(e instanceof Error ? e.message : String(e))); + .catch((e) => alive && setError(describeApiError(e))); return () => { alive = false; }; diff --git a/src/components/ops/doc-intelligence-drawer.tsx b/src/components/ops/doc-intelligence-drawer.tsx index 75814a5..956cfa4 100644 --- a/src/components/ops/doc-intelligence-drawer.tsx +++ b/src/components/ops/doc-intelligence-drawer.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { Loader2, Network, Sparkles } from "lucide-react"; import { toast } from "sonner"; -import { api } from "@/lib/api"; +import { api, describeApiError } from "@/lib/api"; import { deriveGraphState } from "./graph-lens-helpers"; import { useAsync } from "@/hooks/use-async"; import { formatNumber } from "@/lib/utils"; @@ -73,7 +73,7 @@ export function DocIntelligenceBody({ documentId }: { documentId: string }) { } intel.refresh(); } catch (e) { - toast.error(`Re-extract failed: ${e instanceof Error ? e.message : String(e)}`, { id: t }); + toast.error(`Re-extract failed: ${describeApiError(e)}`, { id: t }); } finally { setReextracting(false); } diff --git a/src/components/ops/kb-panel.tsx b/src/components/ops/kb-panel.tsx index 091b662..40106f2 100644 --- a/src/components/ops/kb-panel.tsx +++ b/src/components/ops/kb-panel.tsx @@ -23,7 +23,7 @@ import { Sparkles, Eye, } from "lucide-react"; -import { api } from "@/lib/api"; +import { api, describeApiError } from "@/lib/api"; import { useAsync } from "@/hooks/use-async"; import { acceptAttr, @@ -64,7 +64,7 @@ const DEFAULT_KB_COLOR = "var(--brand-sky)"; type SortOption = "newest" | "oldest" | "name" | "retrieved"; type ViewMode = "grid" | "list"; -const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e)); +const errMsg = (e: unknown) => (describeApiError(e)); // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/components/ops/knowledge-settings-card.tsx b/src/components/ops/knowledge-settings-card.tsx index 5913b6d..ae31166 100644 --- a/src/components/ops/knowledge-settings-card.tsx +++ b/src/components/ops/knowledge-settings-card.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { BookOpen } from "lucide-react"; import { toast } from "sonner"; -import { api } from "@/lib/api"; +import { api, describeApiError } from "@/lib/api"; import { useAsync } from "@/hooks/use-async"; import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -57,7 +57,7 @@ export function KnowledgeSettingsCard({ onChanged }: { onChanged?: () => void }) toast.success(okMessage); return true; } catch (e) { - const msg = e instanceof Error ? e.message : String(e); + const msg = describeApiError(e); // The gateway probes the key before persisting; its 400 belongs on // the form, not (only) in a toast. if (body.embedding_api_key) setFormError(msg); diff --git a/src/components/ops/skill-editor.tsx b/src/components/ops/skill-editor.tsx index afb4fd8..2f7f19e 100644 --- a/src/components/ops/skill-editor.tsx +++ b/src/components/ops/skill-editor.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { FilePen, Loader2, Plus, X } from "lucide-react"; -import { api, ApiError } from "@/lib/api"; +import { api, ApiError, describeApiError } from "@/lib/api"; import type { Skill } from "@/lib/types"; import { emptyTemplate, @@ -69,7 +69,7 @@ export function SkillEditor({ if (!cancelled) setMd(r.content); }) .catch((e) => { - if (!cancelled) setLoadError(e instanceof Error ? e.message : String(e)); + if (!cancelled) setLoadError(describeApiError(e)); }) .finally(() => { if (!cancelled) setLoading(false); @@ -152,7 +152,7 @@ export function SkillEditor({ onClose(); } catch (e) { const status = e instanceof ApiError ? e.status : undefined; - const detail = e instanceof Error ? e.message : String(e); + const detail = describeApiError(e); if (status === 409) toast.error(`Name already taken: ${detail}`); else if (status === 413) toast.error("Too large to save — the gateway caps bodies at 64 KB."); diff --git a/src/components/ops/skills-panel.tsx b/src/components/ops/skills-panel.tsx index 9d27941..229d8d2 100644 --- a/src/components/ops/skills-panel.tsx +++ b/src/components/ops/skills-panel.tsx @@ -13,7 +13,7 @@ import { Trash2, X, } from "lucide-react"; -import { api } from "@/lib/api"; +import { api, describeApiError } from "@/lib/api"; import { useAsync } from "@/hooks/use-async"; import type { ClawHubSkill, Skill } from "@/lib/types"; import { @@ -87,7 +87,7 @@ export function SkillsPanel() { const { items } = await api.clawhub(query.trim() || undefined); setHub(items); } catch (e) { - setHubError(e instanceof Error ? e.message : String(e)); + setHubError(describeApiError(e)); setHub([]); } finally { setHubLoading(false); @@ -130,12 +130,18 @@ export function SkillsPanel() { // Keyed on `slug`, not `name`: the gateway rejects a path parameter with a // space in it, so passing the display name 400s for every hand-written skill. const toggle = async (slug: string, label: string, enabled: boolean) => { + // The busy flag was never set here, so the control's `disabled` never + // engaged during the write and a second click raced the first. + setWorking(slug); try { - await api.setSkillEnabled(slug, enabled); - toast.success(`${label} ${enabled ? "enabled" : "disabled"}`); + const r = await api.setSkillEnabled(slug, enabled); + // Report what the server did, not what was asked for. + toast.success(`${label} ${r.enabled ? "enabled" : "disabled"}`); reload(); } catch (e) { - toast.error(String(e instanceof Error ? e.message : e)); + toast.error(describeApiError(e)); + } finally { + setWorking(null); } }; diff --git a/src/lib/api-error.test.ts b/src/lib/api-error.test.ts index a8b9e28..9c18ca3 100644 --- a/src/lib/api-error.test.ts +++ b/src/lib/api-error.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { ApiError, api } from "./api"; +import { ApiError, api, describeApiError } from "./api"; import { candidatesFromError } from "./clawhub"; function stubFetch(status: number, body: unknown) { @@ -79,3 +79,24 @@ describe("installSkill error handling", () => { }); }); }); + +describe("describeApiError", () => { + it("distinguishes a session expiry from a restarting gateway from a bad request", () => { + // All three used to render identically, because every caller flattened the + // error to `.message`. + const auth = describeApiError(new ApiError("token expired", 401, null)); + const down = describeApiError(new ApiError("bad gateway", 502, null)); + const bad = describeApiError(new ApiError("model is required", 400, null)); + + expect(auth).toContain("sign in again"); + expect(down).toContain("may be restarting"); + expect(bad).toBe("model is required"); + + expect(new Set([auth, down, bad]).size).toBe(3); + }); + + it("passes a non-ApiError through unchanged", () => { + expect(describeApiError(new Error("boom"))).toBe("boom"); + expect(describeApiError("plain string")).toBe("plain string"); + }); +}); diff --git a/src/lib/api.ts b/src/lib/api.ts index 16c99a7..c9d27ee 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -47,6 +47,33 @@ export class ApiError extends Error { } } +/** + * An operator-facing sentence for a failed request. + * + * `ApiError` has carried `status` and the parsed `body` since it was written, + * and its comment says callers can act on it — but two of roughly twenty-two + * mutation handlers did, and the rest flattened it to `.message`. So a session + * expiry, a restarting gateway and a genuine 400 all rendered identically, and + * the operator had no way to tell "log in again" from "wait a moment" from + * "your input was wrong". + */ +export function describeApiError(e: unknown): string { + if (!(e instanceof ApiError)) { + return e instanceof Error ? e.message : String(e); + } + switch (e.status) { + case 401: + case 403: + return `Not authorised — sign in again. (${e.message})`; + case 502: + case 503: + case 504: + return `The gateway is unreachable — it may be restarting. (${e.message})`; + default: + return e.message; + } +} + async function rc(path: string, init?: RequestInit): Promise { const res = await fetch(`/api/rc/${path}`, { ...init, diff --git a/src/lib/bff-confinement.test.ts b/src/lib/bff-confinement.test.ts index 26d6d6d..4116cf4 100644 --- a/src/lib/bff-confinement.test.ts +++ b/src/lib/bff-confinement.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from "vitest"; import { resolveGatewayUrl } from "./gateway-path"; -import { isCrossSiteWrite, isStateChanging } from "./request-origin"; +import { + expectedHosts, + isCrossSiteWrite, + isStateChanging, + isUnexpectedHost, +} from "./request-origin"; const GW = "http://127.0.0.1:9393"; @@ -154,3 +159,42 @@ describe("cross-site write rejection", () => { } }); }); + +describe("expected-Host allowlist", () => { + const loopback = expectedHosts({}); + + it("rejects a rebound DNS name that satisfies the same-origin check", () => { + // `evil.test` resolves to 127.0.0.1, so Origin and Host agree and the + // cross-site check passes — the page's script then reads the full config + // and issues writes, all signed with the gateway token. + expect( + isCrossSiteWrite("POST", { + secFetchSite: null, + origin: "http://evil.test:3939", + host: "evil.test:3939", + }), + ).toBe(false); + expect(isUnexpectedHost("evil.test:3939", loopback)).toBe(true); + }); + + it("serves loopback by default, with or without a port", () => { + for (const host of ["localhost:3939", "127.0.0.1:3939", "localhost", "127.0.0.1"]) { + expect(isUnexpectedHost(host, loopback)).toBe(false); + } + }); + + it("honours the operator's configured hosts, bare or as an origin", () => { + const allowed = expectedHosts({ + devOrigins: "http://192.168.1.20:3939", + allowedHosts: "console.lan", + }); + expect(isUnexpectedHost("192.168.1.20:3939", allowed)).toBe(false); + expect(isUnexpectedHost("console.lan:3939", allowed)).toBe(false); + // Still not anything else. + expect(isUnexpectedHost("evil.test", allowed)).toBe(true); + }); + + it("refuses a request with no Host at all", () => { + expect(isUnexpectedHost(null, loopback)).toBe(true); + }); +}); diff --git a/src/lib/console.test.ts b/src/lib/console.test.ts index 05ff633..800148e 100644 --- a/src/lib/console.test.ts +++ b/src/lib/console.test.ts @@ -2,7 +2,10 @@ import { describe, it, expect } from "vitest"; import { AUTONOMY, autonomyPreset, + autonomyReadIsStale, + nextCycledRung, levelToRung, + maskConfigForDisplay, resolveHashRoute, rungToAutonomyPayload, } from "./console"; @@ -71,3 +74,62 @@ describe("autonomy rung encoding", () => { expect(autonomyPreset(levelToRung("Supervised")).label).toBe("Smart"); }); }); + +describe("nextCycledRung", () => { + it("never lands on `off` — the rung with no prompts", () => { + // Shift+Tab is one keypress, from any non-editable focus, with no + // confirmation. It used to walk straight into autonomous execution. + let rung = "manual"; + const seen: string[] = []; + for (let i = 0; i < 8; i++) { + rung = nextCycledRung(rung)!; + seen.push(rung); + } + expect(seen).not.toContain("off"); + expect(new Set(seen)).toEqual(new Set(["manual", "smart", "strict"])); + }); + + it("escapes `off` rather than sticking there", () => { + expect(nextCycledRung("off")).toBe("manual"); + }); +}); + +describe("autonomyReadIsStale", () => { + it("discards a read that began before a local write", () => { + expect(autonomyReadIsStale(100, 200)).toBe(true); + }); + + it("keeps a read that began after the write", () => { + expect(autonomyReadIsStale(300, 200)).toBe(false); + }); +}); + +describe("maskConfigForDisplay", () => { + it("masks MCP env VALUES while keeping their names", () => { + // The backend redacts by key-name suffix, which cannot cover arbitrary + // operator-chosen env names — and API keys live there routinely, under a + // label that claimed "secrets redacted". + const masked = maskConfigForDisplay({ + mcp_servers: { + github: { command: "npx", env: { GITHUB_TOKEN: "ghp_realsecret", DEBUG: "1" } }, + }, + }) as { mcp_servers: { github: { command: string; env: Record } } }; + + expect(masked.mcp_servers.github.env.GITHUB_TOKEN).not.toContain("ghp_realsecret"); + expect(Object.keys(masked.mcp_servers.github.env)).toEqual(["GITHUB_TOKEN", "DEBUG"]); + // Non-secret fields are untouched. + expect(masked.mcp_servers.github.command).toBe("npx"); + }); + + it("does not mutate the caller's config", () => { + const original = { mcp_servers: { x: { env: { K: "secret" } } } }; + maskConfigForDisplay(original); + expect(original.mcp_servers.x.env.K).toBe("secret"); + }); + + it("passes through a config with no MCP servers", () => { + expect(maskConfigForDisplay({ autonomy: { level: "smart" } })).toEqual({ + autonomy: { level: "smart" }, + }); + }); +}); diff --git a/src/lib/console.ts b/src/lib/console.ts index c7f9fc4..907fa95 100644 --- a/src/lib/console.ts +++ b/src/lib/console.ts @@ -167,6 +167,38 @@ export const AUTONOMY: AutonomyPreset[] = [ { id: "off", label: "Off", blurb: "Autonomous execution, no prompts. Trusted envs only.", dot: "var(--accent-green)" }, ]; +/** + * The rung Shift+Tab should move to from `current`. + * + * `off` — "autonomous execution, no prompts" — is deliberately unreachable by + * cycling: the binding is one keypress with no confirmation, fired from any + * non-editable focus. Selecting it stays possible from the autonomy menu. + * Returns `null` when there is nothing to move to, which is also the signal not + * to `preventDefault()` — the unconditional call broke reverse-tab navigation + * everywhere in the console. + */ +export function nextCycledRung(current: string): string | null { + const cyclable = AUTONOMY.filter((p) => p.id !== "off").map((p) => p.id); + if (cyclable.length === 0) return null; + const at = cyclable.indexOf(current); + // Cycling out of `off` lands on the safest rung rather than staying put. + if (at === -1) return cyclable[0]; + return cyclable[(at + 1) % cyclable.length]; +} + +/** + * Whether a config read that began at `readStartedAt` should be discarded + * because a local write has since claimed the value. + * + * The write stamp used to be set in `.finally()`, so a **failed** write armed + * the guard and discarded the very read that would have corrected the screen — + * leaving the console showing a rung the gateway is not on, for up to the + * 30-second poll (which also stops while the tab is hidden). + */ +export function autonomyReadIsStale(readStartedAt: number, writtenAt: number): boolean { + return readStartedAt < writtenAt; +} + /** Gateway `autonomy.level` names → the preset ladder. */ const AUTONOMY_ALIAS: Record = { supervised: "smart", @@ -275,3 +307,28 @@ export function initials(name: string): string { if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[1][0]).toUpperCase(); } + +/** + * A config dump safe to put on screen. + * + * The backend redacts by key-name suffix, which cannot cover + * `mcp_servers.*.env` — those are arbitrary operator-chosen names holding + * arbitrary values, and API keys live there routinely. The panel's label said + * "secrets redacted" over exactly that gap. This masks the env **values** + * client-side; the key names stay, so the operator can still see what is set. + */ +export function maskConfigForDisplay(config: unknown): unknown { + if (!config || typeof config !== "object") return config; + const clone = structuredClone(config) as Record; + const servers = clone.mcp_servers; + if (!servers || typeof servers !== "object") return clone; + for (const server of Object.values(servers as Record)) { + if (!server || typeof server !== "object") continue; + const env = (server as Record).env; + if (!env || typeof env !== "object") continue; + for (const key of Object.keys(env as Record)) { + (env as Record)[key] = "••••••••"; + } + } + return clone; +} diff --git a/src/lib/request-origin.ts b/src/lib/request-origin.ts index 76f41a2..a845b49 100644 --- a/src/lib/request-origin.ts +++ b/src/lib/request-origin.ts @@ -70,3 +70,52 @@ export function isCrossSiteWrite( } return false; } + +/** + * Hosts the console will answer `/api/rc/*` on. + * + * The Origin check above compares Origin against the request's own Host, which + * is correct and bind-address independent — but it means a **rebound DNS name** + * satisfies it: the attacker's page is served from `evil.test`, `evil.test` + * resolves to 127.0.0.1, and both headers then agree. The page's script can + * read the full config and issue privileged writes, all signed with the gateway + * token. + * + * Loopback by default, plus whatever `RANTAICLAW_UI_DEV_ORIGINS` already + * carries, so an operator on a LAN address is not locked out silently — a + * lockout that looks like an outage is its own failure. + */ +export function expectedHosts(env: { + devOrigins?: string | null; + allowedHosts?: string | null; +}): string[] { + const hosts = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); + for (const raw of [env.allowedHosts, env.devOrigins]) { + if (!raw) continue; + for (const entry of raw.split(",")) { + const v = entry.trim(); + if (!v) continue; + // Accept either a bare host or a full origin. + try { + hosts.add(new URL(v).host); + hosts.add(new URL(v).hostname); + } catch { + hosts.add(v); + } + } + } + return [...hosts]; +} + +/** + * Whether the `Host` this request arrived on is one the console serves. + * + * A missing Host is refused: every browser sends one, and something that does + * not is not a context this gate exists to protect. + */ +export function isUnexpectedHost(host: string | null, allowed: string[]): boolean { + if (!host) return true; + // Compare on the hostname alone — the port is the console's own and varies. + const hostname = host.replace(/:\d+$/, ""); + return !allowed.some((a) => a === host || a === hostname || a.replace(/:\d+$/, "") === hostname); +} diff --git a/src/proxy.ts b/src/proxy.ts index f647e4f..c26ebeb 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -13,7 +13,7 @@ import { } from "@/lib/auth"; import { idleTimeoutMs } from "@/lib/auth-required"; import { isBackgroundPath, SESSION_EXPIRED } from "@/lib/activity"; -import { isCrossSiteWrite } from "@/lib/request-origin"; +import { expectedHosts, isCrossSiteWrite, isUnexpectedHost } from "@/lib/request-origin"; // Gate every page and proxy route behind the session cookie when a password is // configured. Static assets, the login page, and the auth endpoints stay open. @@ -56,6 +56,21 @@ export default async function proxy(req: NextRequest) { return NextResponse.json({ error: "cross_site_request_blocked" }, { status: 403 }); } + // Expected-Host allowlist. The cross-site check above compares Origin against + // the request's own Host, so a rebound DNS name satisfies it — both headers + // agree, and the page's script gets to issue privileged writes signed with the + // gateway token. Applies to every `/api/rc/*` request, reads included: the + // config dump is as much of a prize as a write. + if (req.nextUrl.pathname.startsWith("/api/rc/")) { + const allowed = expectedHosts({ + devOrigins: process.env.RANTAICLAW_UI_DEV_ORIGINS, + allowedHosts: process.env.RANTAICLAW_UI_ALLOWED_HOSTS, + }); + if (isUnexpectedHost(req.headers.get("host"), allowed)) { + return NextResponse.json({ error: "unexpected_host" }, { status: 403 }); + } + } + if (!(await authEnabled())) return NextResponse.next(); // Login is enabled but no real cookie secret is set → the gate is diff --git a/vitest.config.mts b/vitest.config.mts index bdc2676..f9934c5 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -3,8 +3,13 @@ import { fileURLToPath } from "node:url"; export default defineConfig({ test: { + // `node` by default; a component test opts into jsdom with a + // `// @vitest-environment jsdom` docblock. Switching the whole suite to + // jsdom would slow every pure-function test for the sake of a few. environment: "node", - include: ["src/**/*.test.ts"], + // `.tsx` was not collected at all, so no React component in this repo could + // be tested even if a test existed — and none did. + include: ["src/**/*.test.{ts,tsx}"], }, resolve: { alias: { From 4cf469e961c798477daceb2f1bdbd419a522f68e Mon Sep 17 00:00:00 2001 From: Sulthan Nauval Abdillah Date: Fri, 14 Aug 2026 06:11:19 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(test):=20use=20happy-dom,=20not=20jsdom?= =?UTF-8?q?=20=E2=80=94=20jsdom=2030=20needs=20a=20newer=20Node=20than=20C?= =?UTF-8?q?I=20has?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package-lock.json | 577 +++--------------------- package.json | 2 +- src/components/console/harness.test.tsx | 11 +- vitest.config.mts | 6 +- 4 files changed, 83 insertions(+), 513 deletions(-) diff --git a/package-lock.json b/package-lock.json index c6cc5a9..d01ccf0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@types/react-syntax-highlighter": "^15.5.13", - "jsdom": "^30.0.1", + "happy-dom": "^20.11.2", "tailwindcss": "^4.1.9", "tw-animate-css": "^1.3.3", "typescript": "^5.7.2", @@ -50,39 +50,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@asamuzakjp/css-color": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", - "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^3.3.0", - "@csstools/css-color-parser": "^4.1.10", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.5.2" - }, - "engines": { - "node": "^22.13.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", - "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "bidi-js": "^1.0.3", - "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.5.2" - }, - "engines": { - "node": "^22.13.0 || >=24.0.0" - } - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -117,159 +84,6 @@ "node": ">=6.9.0" } }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", - "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@csstools/css-calc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", - "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", - "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.1.0", - "@csstools/css-calc": "^3.3.0" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", - "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -303,24 +117,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1896,6 +1692,23 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", @@ -2087,14 +1900,17 @@ "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" } }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", "dev": true, "license": "MIT", "dependencies": { - "require-from-string": "^2.0.2" + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" } }, "node_modules/caniuse-lite": { @@ -2233,20 +2049,6 @@ "dev": true, "license": "MIT" }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2469,35 +2271,6 @@ "node": ">=12" } }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/data-urls/node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2515,13 +2288,6 @@ } } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -2589,13 +2355,13 @@ } }, "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">=20.19.0" + "node": ">=0.12" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -2757,6 +2523,25 @@ "dev": true, "license": "ISC" }, + "node_modules/happy-dom": { + "version": "20.11.2", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.2.tgz", + "integrity": "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/hast-util-parse-selector": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", @@ -2842,19 +2627,6 @@ "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", "license": "CC0-1.0" }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -2945,13 +2717,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, "node_modules/jerrypick": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz", @@ -2977,47 +2742,6 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, - "node_modules/jsdom": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", - "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^6.0.5", - "@asamuzakjp/dom-selector": "^8.3.0", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.7", - "@exodus/bytes": "^1.15.1", - "css-tree": "^3.2.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.5.2", - "parse5": "^8.0.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.2", - "undici": "^8.9.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^17.1.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" - }, - "peerDependencies": { - "canvas": "^3.2.3" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, "node_modules/kapsule": { "version": "1.16.3", "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", @@ -3345,16 +3069,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/lucide-react": { "version": "0.454.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz", @@ -3664,13 +3378,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -4396,19 +4103,6 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, - "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4526,16 +4220,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -4724,16 +4408,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -4768,19 +4442,6 @@ "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4950,13 +4611,6 @@ } } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", @@ -5038,52 +4692,6 @@ "node": ">=14.0.0" } }, - "node_modules/tldts": { - "version": "7.4.10", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", - "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.4.10" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.4.10", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", - "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tough-cookie": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", - "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -5134,16 +4742,6 @@ "node": ">=14.17" } }, - "node_modules/undici": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", - "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -5434,52 +5032,14 @@ } } }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", - "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.15.1", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^22.14.0 || >=24.0.0" + "node": ">=12" } }, "node_modules/why-is-node-running": { @@ -5499,23 +5059,28 @@ "node": ">=8" } }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index c775852..219bb40 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@types/react-syntax-highlighter": "^15.5.13", - "jsdom": "^30.0.1", + "happy-dom": "^20.11.2", "tailwindcss": "^4.1.9", "tw-animate-css": "^1.3.3", "typescript": "^5.7.2", diff --git a/src/components/console/harness.test.tsx b/src/components/console/harness.test.tsx index c5b5dfe..49ad3e8 100644 --- a/src/components/console/harness.test.tsx +++ b/src/components/console/harness.test.tsx @@ -1,12 +1,17 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; /** * The harness itself. `vitest.config.mts` collected only `.test.ts`, so no * React component in this repo was testable — this asserts that a `.tsx` test - * is now collected AND that jsdom is available to it, which is the precondition - * every component test in this plan depends on. + * is now collected AND that a DOM is available to it, which is the precondition + * every component test depends on. + * + * `happy-dom`, not jsdom: jsdom 30 pulls an undici that needs a newer Node than + * CI's 20, and the forks worker died with + * `webidl.util.markAsUncloneable is not a function`. It passed locally on Node + * 26 — local green was not CI green. */ describe("component test harness", () => { it("renders a component into a DOM", () => { diff --git a/vitest.config.mts b/vitest.config.mts index f9934c5..8f63193 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -3,9 +3,9 @@ import { fileURLToPath } from "node:url"; export default defineConfig({ test: { - // `node` by default; a component test opts into jsdom with a - // `// @vitest-environment jsdom` docblock. Switching the whole suite to - // jsdom would slow every pure-function test for the sake of a few. + // `node` by default; a component test opts into a DOM with a + // `// @vitest-environment happy-dom` docblock. Switching the whole suite + // would slow every pure-function test for the sake of a few. environment: "node", // `.tsx` was not collected at all, so no React component in this repo could // be tested even if a test existed — and none did.