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..d01ccf0 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", + "happy-dom": "^20.11.2", "tailwindcss": "^4.1.9", "tw-animate-css": "^1.3.3", "typescript": "^5.7.2", @@ -47,6 +50,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", @@ -1475,6 +1503,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 +1568,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", @@ -1609,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", @@ -1737,6 +1837,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 +1900,19 @@ "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" } }, + "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": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001799", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", @@ -2187,6 +2333,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 +2354,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "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": ">=0.12" + }, + "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", @@ -2357,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", @@ -2893,6 +3078,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", @@ -3973,6 +4168,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", @@ -4815,6 +5032,16 @@ } } }, + "node_modules/whatwg-mimetype": { + "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": ">=12" + } + }, "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 +5059,28 @@ "node": ">=8" } }, + "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": "MIT", + "engines": { + "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/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..219bb40 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", + "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/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..49ad3e8 --- /dev/null +++ b/src/components/console/harness.test.tsx @@ -0,0 +1,21 @@ +// @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 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", () => { + 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..8f63193 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 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", - 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: {