From 8c8706adb0c1aca94c962ea8ec323ea70ec625b9 Mon Sep 17 00:00:00 2001 From: KrasimirKralev <263465593+KrasimirKralev@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:15:54 +0300 Subject: [PATCH] fix: state the browser integration honestly on the edition that has no switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Browser app's second step offered "Activate", and on a device with no OpenClaw CLI that button could only ever fail: the action wrote `tools.profile: full` and `tools.web.search.enabled` through the openclaw binary, so the owner got "The OpenClaw CLI is not available on this edition." next to a red "Fix the error" prompt — for a capability that was already working. There is nothing to activate there. The four ClawBox browser tools (browser_open / browser_navigate / browser_screenshot / browser_close) are registered on that edition unconditionally, scripts/register-mcp.sh wires the ClawBox MCP server into the harness config at every web-server boot, and it turns the harness's own browser toolset off so browsing goes through those tools and therefore through the Chromium window on the desktop. No tools profile to flip, no separate web-search tool to arm. So the route now answers `alwaysOn` and the panel renders that state: step 2 reads as already satisfied, names the tools the agent actually holds instead of an OpenClaw config key, and carries no button — a control with nothing to control is worse than no control at all. `enable` and `disable` are guarded server-side too, so a stale page or a direct call gets a plain answer rather than a CLI that isn't installed. Keyed on the edition rather than the active harness, so a dual device — which does have the CLI and the gateway — keeps the switch exactly as it was. The two hand-rolled `systemctl restart clawbox-gateway` calls now go through the existing restartGateway() helper, which already knows which editions have a gateway to bounce. Step 3 checks for Chromium itself rather than inheriting that check from step 2, which no longer implies it. New string in all ten locales; success toasts now name the running harness instead of hardcoding OpenClaw. --- src/app/setup-api/browser/manage/route.ts | 75 +++++++++++- src/components/BrowserApp.tsx | 88 +++++++++----- src/lib/desktop-translations-part1.ts | 3 + src/lib/desktop-translations-part2.ts | 3 + src/lib/desktop-translations-part3.ts | 3 + src/lib/desktop-translations.ts | 1 + src/tests/components/browser-app.test.tsx | 96 +++++++++++++-- src/tests/routes/browser/manage.test.ts | 141 +++++++++++++++++++++- 8 files changed, 367 insertions(+), 43 deletions(-) diff --git a/src/app/setup-api/browser/manage/route.ts b/src/app/setup-api/browser/manage/route.ts index 47a604c5..295e4df8 100644 --- a/src/app/setup-api/browser/manage/route.ts +++ b/src/app/setup-api/browser/manage/route.ts @@ -6,7 +6,8 @@ import { promisify } from "util"; import { constants as fsConstants } from "fs"; import fs from "fs/promises"; import path from "path"; -import { readConfig, runOpenclawConfigSet } from "@/lib/openclaw-config"; +import type { OpenClawConfig } from "@/lib/openclaw-config"; +import { openclawIsAbsent, readConfig, restartGateway, runOpenclawConfigSet } from "@/lib/openclaw-config"; import { sqliteGet, sqliteSet } from "@/lib/sqlite-store"; import { findClawboxBrowserPids, terminateClawboxBrowser } from "@/lib/process-match"; @@ -18,6 +19,37 @@ const PLAYWRIGHT_BROWSERS_DIR = path.join(HOME, ".cache", "ms-playwright"); const CDP_PORT = 18800; const BROWSER_ENABLED_KEY = "browser:integration-enabled"; +/** + * Is the browser↔agent link a switch the owner flips, or is it simply always on? + * + * On OpenClaw it is a switch. "Enable" writes `tools.profile: full` and + * `tools.web.search.enabled: true` into ~/.openclaw/openclaw.json and bounces + * the gateway, because the agent ships with a restricted `coding` tool profile + * that has no browsing in it. + * + * On Hermes there is no switch, because there is nothing to switch. The four + * ClawBox browser tools (browser_open / browser_navigate / browser_screenshot / + * browser_close in mcp/tools/browser.ts) are registered on this edition + * unconditionally, scripts/register-mcp.sh wires the ClawBox MCP server into + * ~/.hermes/config.yaml at every web-server boot, and that same script turns the + * harness's own browser toolset off so browsing goes through those tools and + * therefore through the Chromium window on the desktop. Hermes has no + * `tools.profile` to flip and no separate web-search tool to arm. + * + * So the panel previously offered an Activate button here that could only ever + * fail: the action reached for the `openclaw` CLI, which this edition does not + * ship, and the owner got "The OpenClaw CLI is not available on this edition." + * for a capability that was already working. `alwaysOn` is how the route tells + * the client which of the two worlds it is in, so the panel can state the truth + * instead of offering a switch. + * + * Keyed on the EDITION, not the active harness: a `dual` box still has the + * OpenClaw CLI and its gateway, so it keeps the switch exactly as before. + */ +function integrationIsAlwaysOn(): boolean { + return openclawIsAbsent(); +} + // ─── Helpers ───────────────────────────────────────────────────────────────── async function findPlaywrightChromium(): Promise { @@ -157,19 +189,26 @@ async function persistBrowserEnabled(enabled: boolean): Promise { export async function GET() { try { + const alwaysOn = integrationIsAlwaysOn(); + + // On an always-on edition neither of the last two reads means anything: + // there is no ~/.openclaw/openclaw.json to hold a tools profile, and the + // sqlite flag only ever recorded the OpenClaw switch. Skip them rather than + // derive a "disabled" answer from files this edition does not keep. const [chromium, browser, config, persistedEnabled] = await Promise.all([ checkChromium(), getBrowserStatus(), - readOpenClawConfig(), - getPersistedBrowserEnabled(), + alwaysOn ? Promise.resolve({} as OpenClawConfig) : readOpenClawConfig(), + alwaysOn ? Promise.resolve(null) : getPersistedBrowserEnabled(), ]); - const enabled = persistedEnabled ?? (config.tools?.profile === "full"); + const enabled = alwaysOn || (persistedEnabled ?? (config.tools?.profile === "full")); return NextResponse.json({ chromium, browser, enabled, + alwaysOn, cdpPort: CDP_PORT, }); } catch (err) { @@ -230,6 +269,15 @@ export async function POST(req: Request) { await fs.mkdir(PROFILE_DIR, { recursive: true }); + // Always-on edition: the profile dir above is the only preparation this + // action can usefully do. Report the state as it already is instead of + // reaching for a CLI that isn't installed here — see + // integrationIsAlwaysOn(). The client hides the button on this edition; + // this guard is what keeps a stale page or a direct call honest too. + if (integrationIsAlwaysOn()) { + return NextResponse.json({ ok: true, enabled: true, alwaysOn: true, profileDir: PROFILE_DIR }); + } + try { await runOpenclawConfigSet(["tools.profile", "full"]); await runOpenclawConfigSet(["tools.web.search.enabled", "true", "--json"]); @@ -244,7 +292,7 @@ export async function POST(req: Request) { let enableRestartOk = true; try { - await exec("/usr/bin/sudo", ["systemctl", "restart", "clawbox-gateway"], { timeout: 15000 }); + await restartGateway(); } catch (err) { console.error("[browser] Gateway restart failed:", err); enableRestartOk = false; @@ -254,6 +302,21 @@ export async function POST(req: Request) { } case "disable": { + // Nothing to take away on an always-on edition: the browser tools are + // part of the tool set the harness is given at boot, not a stored + // preference. Say so plainly rather than report a success that changed + // nothing. + if (integrationIsAlwaysOn()) { + return NextResponse.json( + { + error: "Browser integration is built into this edition and cannot be turned off.", + enabled: true, + alwaysOn: true, + }, + { status: 400 }, + ); + } + try { await runOpenclawConfigSet(["tools.profile", "coding"]); await persistBrowserEnabled(false); @@ -267,7 +330,7 @@ export async function POST(req: Request) { let disableRestartOk = true; try { - await exec("/usr/bin/sudo", ["systemctl", "restart", "clawbox-gateway"], { timeout: 15000 }); + await restartGateway(); } catch (err) { console.error("[browser] Gateway restart failed:", err); disableRestartOk = false; diff --git a/src/components/BrowserApp.tsx b/src/components/BrowserApp.tsx index f2e03c8b..fd20d7f0 100644 --- a/src/components/BrowserApp.tsx +++ b/src/components/BrowserApp.tsx @@ -1,9 +1,15 @@ "use client"; /** - * BrowserApp — Real desktop browser integration for OpenClaw. - * Installs Chromium if needed, configures OpenClaw computer-use, - * and provides open/close controls for the real desktop browser. + * BrowserApp — the desktop browser, and the agent's access to it. + * + * Three steps: install Chromium, link it to the agent, open/close the real + * window. Step 2 is the one that differs by edition, and the route says which + * shape it takes via `alwaysOn`: OpenClaw needs the link switched on (it writes + * the agent's tool profile), Hermes has it permanently because the ClawBox + * browser_* tools are part of the tool set it is given at boot. The panel never + * decides this itself — see integrationIsAlwaysOn() in + * src/app/setup-api/browser/manage/route.ts. */ import { useEffect, useState, useCallback, useRef } from "react"; @@ -18,6 +24,14 @@ interface BrowserStatus { chromium: { installed: boolean; path?: string; version?: string }; browser: { running: boolean; pid?: number; cdpReady?: boolean }; enabled: boolean; + /** + * True when this edition has no integration switch because the link is + * permanent — Hermes drives the desktop browser through the ClawBox + * browser_* tools, which it is given at every boot. The route decides this + * (see integrationIsAlwaysOn there); the panel only renders it, so a device + * and its UI can never disagree about whether a button should exist. + */ + alwaysOn?: boolean; cdpPort?: number; } @@ -141,6 +155,13 @@ export default function BrowserApp({ onOpenApp }: BrowserAppProps) { const chromiumInstalled = status?.chromium?.installed ?? false; const browserRunning = status?.browser?.running ?? false; const isEnabled = status?.enabled ?? false; + const alwaysOn = status?.alwaysOn ?? false; + // Step 3 drives the Chromium that step 1 installs. Enabled-but-no-Chromium is + // unreachable on the switch editions (you cannot enable without it), but on an + // always-on edition step 2 is satisfied from the moment the device boots — so + // the browser controls have to check for the binary themselves rather than + // inherit that check from step 2. + const canRunBrowser = isEnabled && chromiumInstalled; return (
@@ -217,7 +238,7 @@ export default function BrowserApp({ onOpenApp }: BrowserAppProps) {
- {/* Step 2: OpenClaw Integration */} + {/* Step 2: agent integration — a switch on OpenClaw, permanent on Hermes */}

{t("browser.openclawIntegration", { harness: harnessLabel })}

- {isEnabled - ? t("browser.enabledMessage", { harness: harnessLabel }) - : t("browser.disabledMessage", { harness: harnessLabel })} + {alwaysOn + ? t("browser.builtInMessage", { harness: harnessLabel }) + : isEnabled + ? t("browser.enabledMessage", { harness: harnessLabel }) + : t("browser.disabledMessage", { harness: harnessLabel })}

{isEnabled && (
- tools profile: full + {/* Name the actual mechanism. "tools profile: full" is the + OpenClaw config key the switch writes; on an always-on + edition there is no such key, and the honest detail is + which tools the agent holds. */} + + {alwaysOn ? "browser_open · browser_navigate · browser_screenshot" : "tools profile: full"} +
bug_report @@ -250,29 +279,34 @@ export default function BrowserApp({ onOpenApp }: BrowserAppProps) {
)}
- + {/* No button where there is no choice: the link is part of the + edition, so anything offered here would be a control that does + nothing — or, as it did before, one that only ever errored. */} + {!alwaysOn && ( + + )}
{/* Step 3: Browser Controls */} -
+
diff --git a/src/lib/desktop-translations-part1.ts b/src/lib/desktop-translations-part1.ts index 5fe0932b..9cef30e0 100644 --- a/src/lib/desktop-translations-part1.ts +++ b/src/lib/desktop-translations-part1.ts @@ -103,6 +103,7 @@ export const bg: Record = { "browser.openclawIntegration": "Интеграция с {harness}", "browser.enabledMessage": "Браузърът е свързан с {harness}. Вашият AI може да сърфира в мрежата, попълва формуляри и взаимодейства със страници, използвайки постоянен профил.", "browser.disabledMessage": "Свържете браузъра с {harness}, за да може вашият AI асистент да го използва за сърфиране, проучване и автоматизация.", + "browser.builtInMessage": "Сърфирането е вградено в това издание. {harness} управлява този браузър на работния плот чрез собствените инструменти за браузър на ClawBox, така че няма какво да се включва.", "browser.disable": "Деактивирай", "browser.enable": "Активирай", "browser.disabling": "Деактивиране...", @@ -488,6 +489,7 @@ export const de: Record = { "browser.openclawIntegration": "{harness}-Integration", "browser.enabledMessage": "Der Browser ist mit {harness} verbunden. Ihre KI kann im Web surfen, Formulare ausfüllen und mit Seiten über ein dauerhaftes Profil interagieren.", "browser.disabledMessage": "Verbinden Sie den Browser mit {harness}, damit Ihr KI-Assistent ihn zum Surfen, Recherchieren und für Automatisierung nutzen kann.", + "browser.builtInMessage": "Das Surfen ist in dieser Edition fest eingebaut. {harness} steuert diesen Desktop-Browser über die ClawBox-eigenen Browser-Tools, es gibt also nichts einzuschalten.", "browser.disable": "Deaktivieren", "browser.enable": "Aktivieren", "browser.disabling": "Wird deaktiviert...", @@ -873,6 +875,7 @@ export const es: Record = { "browser.openclawIntegration": "Integración con {harness}", "browser.enabledMessage": "El navegador está conectado a {harness}. Su IA puede navegar por la web, rellenar formularios e interactuar con páginas usando un perfil persistente.", "browser.disabledMessage": "Conecte el navegador a {harness} para que su asistente de IA pueda usarlo para navegar, investigar y automatizar.", + "browser.builtInMessage": "La navegación viene integrada en esta edición. {harness} controla este navegador de escritorio mediante las propias herramientas de navegador de ClawBox, así que no hay nada que activar.", "browser.disable": "Desactivar", "browser.enable": "Activar", "browser.disabling": "Desactivando...", diff --git a/src/lib/desktop-translations-part2.ts b/src/lib/desktop-translations-part2.ts index 17e6b21d..b0870e12 100644 --- a/src/lib/desktop-translations-part2.ts +++ b/src/lib/desktop-translations-part2.ts @@ -103,6 +103,7 @@ export const fr: Record = { "browser.openclawIntegration": "Intégration {harness}", "browser.enabledMessage": "Le navigateur est connecté à {harness}. Votre IA peut naviguer sur le web, remplir des formulaires et interagir avec les pages en utilisant un profil persistant.", "browser.disabledMessage": "Connectez le navigateur à {harness} pour que votre assistant IA puisse l'utiliser pour la navigation web, la recherche et l'automatisation.", + "browser.builtInMessage": "La navigation est intégrée à cette édition. {harness} pilote ce navigateur du bureau via les outils de navigation propres à ClawBox : il n'y a rien à activer.", "browser.disable": "Désactiver", "browser.enable": "Activer", "browser.disabling": "Désactivation...", @@ -488,6 +489,7 @@ export const it: Record = { "browser.openclawIntegration": "Integrazione {harness}", "browser.enabledMessage": "Il browser è connesso a {harness}. La tua IA può navigare sul web, compilare moduli e interagire con le pagine usando un profilo persistente.", "browser.disabledMessage": "Connetti il browser a {harness} per permettere al tuo assistente IA di navigare sul web, fare ricerche e automatizzare operazioni.", + "browser.builtInMessage": "La navigazione è integrata in questa edizione. {harness} controlla questo browser del desktop tramite gli strumenti browser di ClawBox, quindi non c'è nulla da attivare.", "browser.disable": "Disattiva", "browser.enable": "Attiva", "browser.disabling": "Disattivazione...", @@ -873,6 +875,7 @@ export const ja: Record = { "browser.openclawIntegration": "{harness} 連携", "browser.enabledMessage": "ブラウザは {harness} に接続されています。AI はウェブの閲覧、フォームの入力、ページの操作を永続プロファイルを使って行えます。", "browser.disabledMessage": "ブラウザを {harness} に接続して、AI アシスタントがウェブ閲覧、調査、自動化に利用できるようにします。", + "browser.builtInMessage": "このエディションではブラウジングが標準で組み込まれています。{harness} は ClawBox 独自のブラウザツールでデスクトップのこのブラウザを操作するため、有効にする設定はありません。", "browser.disable": "無効にする", "browser.enable": "有効にする", "browser.disabling": "無効化中...", diff --git a/src/lib/desktop-translations-part3.ts b/src/lib/desktop-translations-part3.ts index 08cf9cfd..9073389f 100644 --- a/src/lib/desktop-translations-part3.ts +++ b/src/lib/desktop-translations-part3.ts @@ -103,6 +103,7 @@ export const nl: Record = { "browser.openclawIntegration": "{harness}-integratie", "browser.enabledMessage": "De browser is verbonden met {harness}. Je AI kan websites bezoeken, formulieren invullen en pagina's bedienen met een blijvend profiel.", "browser.disabledMessage": "Verbind de browser met {harness} zodat je AI-assistent deze kan gebruiken voor surfen, onderzoek en automatisering.", + "browser.builtInMessage": "Browsen is ingebouwd in deze editie. {harness} bestuurt deze desktopbrowser via de eigen browsertools van ClawBox, dus er valt niets in te schakelen.", "browser.disable": "Uitschakelen", "browser.enable": "Inschakelen", "browser.disabling": "Uitschakelen...", @@ -488,6 +489,7 @@ export const sv: Record = { "browser.openclawIntegration": "{harness}-integration", "browser.enabledMessage": "Webbläsaren är ansluten till {harness}. Din AI kan surfa på webben, fylla i formulär och interagera med sidor med en beständig profil.", "browser.disabledMessage": "Anslut webbläsaren till {harness} så att din AI-assistent kan använda den för att surfa, forska och automatisera.", + "browser.builtInMessage": "Webbläsning är inbyggd i den här utgåvan. {harness} styr den här skrivbordswebbläsaren via ClawBox egna webbläsarverktyg, så det finns inget att slå på.", "browser.disable": "Inaktivera", "browser.enable": "Aktivera", "browser.disabling": "Inaktiverar...", @@ -873,6 +875,7 @@ export const zh: Record = { "browser.openclawIntegration": "{harness} 集成", "browser.enabledMessage": "浏览器已连接到 {harness}。您的 AI 可以浏览网页、填写表单,并使用持久化配置文件与页面交互。", "browser.disabledMessage": "将浏览器连接到 {harness},让您的 AI 助手可以用它来浏览网页、进行研究和自动化操作。", + "browser.builtInMessage": "此版本已内置网页浏览功能。{harness} 通过 ClawBox 自带的浏览器工具控制桌面上的这个浏览器,无需另行开启。", "browser.disable": "禁用", "browser.enable": "启用", "browser.disabling": "正在禁用...", diff --git a/src/lib/desktop-translations.ts b/src/lib/desktop-translations.ts index 46ecaa71..72991a31 100644 --- a/src/lib/desktop-translations.ts +++ b/src/lib/desktop-translations.ts @@ -127,6 +127,7 @@ export const desktopTranslations: Record> = { "browser.openclawIntegration": "{harness} Integration", "browser.enabledMessage": "Browser is connected to {harness}. Your AI can browse the web, fill forms, and interact with pages using a persistent profile.", "browser.disabledMessage": "Connect the browser to {harness} so your AI assistant can use it for web browsing, research, and automation.", + "browser.builtInMessage": "Browsing is built into this edition. {harness} drives this desktop browser through ClawBox's own browser tools, so there is nothing to switch on.", "browser.disable": "Disable", "browser.enable": "Enable", "browser.disabling": "Disabling...", diff --git a/src/tests/components/browser-app.test.tsx b/src/tests/components/browser-app.test.tsx index 235769f7..5b82a1c8 100644 --- a/src/tests/components/browser-app.test.tsx +++ b/src/tests/components/browser-app.test.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, waitFor } from "@/tests/helpers/test-utils"; import BrowserApp from "@/components/BrowserApp"; +import { resetHarnessCache } from "@/lib/client-harness"; vi.mock("@/lib/i18n", () => ({ useT: () => ({ @@ -16,6 +17,7 @@ vi.mock("@/lib/i18n", () => ({ "browser.chromiumRequired": "Chromium is required.", "browser.enabledMessage": "Browser is connected to OpenClaw.", "browser.disabledMessage": "Connect the browser to OpenClaw.", + "browser.builtInMessage": "Browsing is built into this edition.", "browser.launchMessage": "Launch a real Chromium window on the desktop that OpenClaw can control.", "browser.runningMessage": "Browser is already running.", "browser.installChromium": "Install Chromium", @@ -38,17 +40,35 @@ vi.mock("@/lib/i18n", () => ({ I18nProvider: ({ children }: { children: ReactNode }) => <>{children}, })); +/** + * Serve the status route the given payload and the harness route the given + * harness, so a test can describe a whole device rather than one endpoint. + */ +function stubDevice(status: Record, harness = "openclaw") { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/setup-api/harness/active")) { + return { ok: true, json: async () => ({ active: harness, edition: harness }) }; + } + return { ok: true, json: async () => status }; + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +const READY_STATUS = { + chromium: { installed: true, path: "/usr/bin/chromium-browser", version: "Chromium 146" }, + browser: { running: false, cdpReady: false }, + enabled: true, + cdpPort: 18800, +}; + describe("BrowserApp", () => { beforeEach(() => { - vi.stubGlobal("fetch", vi.fn(async () => ({ - ok: true, - json: async () => ({ - chromium: { installed: true, path: "/usr/bin/chromium-browser", version: "Chromium 146" }, - browser: { running: false, cdpReady: false }, - enabled: true, - cdpPort: 18800, - }), - }))); + // The harness lookup is cached for the document's lifetime; without this a + // later test would be answered from an earlier test's device. + resetHarnessCache(); + stubDevice(READY_STATUS); }); it("shows the VNC button even before the desktop browser is running", async () => { @@ -65,4 +85,62 @@ describe("BrowserApp", () => { fireEvent.click(openVncButton); expect(onOpenApp).toHaveBeenCalledWith("vnc"); }); + + describe("when the integration is a switch", () => { + it("offers the toggle", async () => { + const { getByRole } = render(); + + await waitFor(() => { + expect(getByRole("button", { name: /Disable/i })).toBeInTheDocument(); + }); + }); + + it("describes the link as a connection that was made", async () => { + const { findByText } = render(); + expect(await findByText(/Browser is connected to OpenClaw\./)).toBeInTheDocument(); + }); + + it("shows the OpenClaw tools profile it wrote", async () => { + const { findByText } = render(); + expect(await findByText("tools profile: full")).toBeInTheDocument(); + }); + }); + + // On an edition with no OpenClaw CLI the link is permanent, so a toggle here + // would be a control with nothing to control — and, before this, one whose + // only possible outcome was an error banner. + describe("when the integration is always on", () => { + beforeEach(() => { + stubDevice({ ...READY_STATUS, alwaysOn: true }, "hermes"); + }); + + it("offers no toggle", async () => { + const { queryByRole, findByRole } = render(); + + // Wait for the panel to finish loading before asserting an absence. + await findByRole("button", { name: /Open Browser/i }); + + expect(queryByRole("button", { name: /^Enable$/i })).toBeNull(); + expect(queryByRole("button", { name: /^Disable$/i })).toBeNull(); + }); + + it("says the capability is built in rather than connected", async () => { + const { findByText, queryByText } = render(); + + expect(await findByText(/Browsing is built into this edition\./)).toBeInTheDocument(); + expect(queryByText(/Browser is connected to OpenClaw\./)).toBeNull(); + }); + + it("names the tools the agent actually holds instead of an OpenClaw config key", async () => { + const { findByText, queryByText } = render(); + + expect(await findByText(/browser_open/)).toBeInTheDocument(); + expect(queryByText("tools profile: full")).toBeNull(); + }); + + it("still lets the owner open the desktop browser", async () => { + const { findByRole } = render(); + expect(await findByRole("button", { name: /Open Browser/i })).toBeInTheDocument(); + }); + }); }); diff --git a/src/tests/routes/browser/manage.test.ts b/src/tests/routes/browser/manage.test.ts index 5ecca6a3..fb28dd4a 100644 --- a/src/tests/routes/browser/manage.test.ts +++ b/src/tests/routes/browser/manage.test.ts @@ -23,6 +23,10 @@ vi.mock("@/lib/openclaw-config", () => ({ // enable/disable now route their config writes through this helper instead // of shelling out directly; default it to a successful no-op. runOpenclawConfigSet: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }), + // Default to a device that ships the OpenClaw CLI — the edition where the + // integration is a switch. The Hermes block below flips this. + openclawIsAbsent: vi.fn().mockReturnValue(false), + restartGateway: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/sqlite-store", () => ({ @@ -33,7 +37,7 @@ vi.mock("@/lib/sqlite-store", () => ({ const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); -import { readConfig } from "@/lib/openclaw-config"; +import { openclawIsAbsent, readConfig, restartGateway, runOpenclawConfigSet } from "@/lib/openclaw-config"; import { sqliteGet, sqliteSet } from "@/lib/sqlite-store"; import fs from "fs/promises"; import { promisify } from "util"; @@ -47,6 +51,9 @@ describe("/setup-api/browser/manage", () => { vi.resetModules(); vi.clearAllMocks(); vi.mocked(readConfig).mockResolvedValue({ tools: { profile: "full" } } as never); + vi.mocked(openclawIsAbsent).mockReturnValue(false); + vi.mocked(restartGateway).mockResolvedValue(undefined); + vi.mocked(runOpenclawConfigSet).mockResolvedValue({ stdout: "", stderr: "" } as never); vi.mocked(sqliteGet).mockResolvedValue(null); vi.mocked(sqliteSet).mockResolvedValue(); vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT")); @@ -79,6 +86,16 @@ describe("/setup-api/browser/manage", () => { expect(body.enabled).toBe(true); }); + it("reports the integration as a switch on an edition that ships the CLI", async () => { + vi.mocked(readConfig).mockResolvedValue({ tools: { profile: "coding" } } as never); + + const res = await GET(); + const body = await res.json(); + + expect(body.alwaysOn).toBe(false); + expect(body.enabled).toBe(false); + }); + it("detects the Playwright Chromium runtime when it is installed", async () => { vi.mocked(fs.readdir).mockResolvedValue([ { name: "chromium-1180", isDirectory: () => true }, @@ -228,5 +245,127 @@ describe("/setup-api/browser/manage", () => { expect(body.enabled).toBe(true); expect(sqliteSet).toHaveBeenCalledWith("browser:integration-enabled", "true"); }); + + it("bounces the gateway through the shared helper, not a hand-rolled systemctl", async () => { + mockExec.mockResolvedValue({ stdout: "", stderr: "" }); + const req = new Request("http://localhost/setup-api/browser/manage", { + method: "POST", + body: JSON.stringify({ action: "disable" }), + }); + + await POST(req); + + // The helper knows which editions have a clawbox-gateway to restart and + // no-ops on the ones that don't; a raw exec here did not. + expect(restartGateway).toHaveBeenCalledTimes(1); + const systemctlCalls = mockExec.mock.calls.filter( + ([, args]) => Array.isArray(args) && args.includes("clawbox-gateway"), + ); + expect(systemctlCalls).toHaveLength(0); + }); + }); + + // The Hermes SKU ships no `openclaw` binary, so every one of these actions + // used to end in "The OpenClaw CLI is not available on this edition." for a + // capability that was already working: the ClawBox browser_* tools are + // registered on this edition at every boot. The route must therefore answer + // "already on" here and never reach for the CLI. + describe("on an edition with no OpenClaw CLI", () => { + const chromiumPresent = () => { + vi.mocked(fs.access).mockResolvedValue(undefined as never); + mockExec.mockImplementation(async (...args: unknown[]) => { + const [command, commandArgs] = args as [string, string[]]; + if (command === "/usr/bin/chromium-browser" && commandArgs[0] === "--version") { + return { stdout: "Chromium 146.0.0", stderr: "" }; + } + return { stdout: "", stderr: "" }; + }); + }; + + beforeEach(() => { + vi.mocked(openclawIsAbsent).mockReturnValue(true); + }); + + it("GET reports the integration as on, and flags that there is no switch", async () => { + const res = await GET(); + const body = await res.json(); + + expect(body.enabled).toBe(true); + expect(body.alwaysOn).toBe(true); + }); + + it("GET does not read an OpenClaw config this edition never writes", async () => { + await GET(); + expect(readConfig).not.toHaveBeenCalled(); + }); + + it("GET stays on even when the OpenClaw switch was once persisted as off", async () => { + vi.mocked(sqliteGet).mockResolvedValue("false"); + + const res = await GET(); + const body = await res.json(); + + expect(body.enabled).toBe(true); + }); + + it("enable succeeds without ever calling the OpenClaw CLI", async () => { + chromiumPresent(); + const req = new Request("http://localhost/setup-api/browser/manage", { + method: "POST", + body: JSON.stringify({ action: "enable" }), + }); + + const res = await POST(req); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + expect(body.enabled).toBe(true); + expect(body.alwaysOn).toBe(true); + expect(runOpenclawConfigSet).not.toHaveBeenCalled(); + expect(restartGateway).not.toHaveBeenCalled(); + }); + + it("enable still refuses when Chromium is missing", async () => { + const req = new Request("http://localhost/setup-api/browser/manage", { + method: "POST", + body: JSON.stringify({ action: "enable" }), + }); + + const res = await POST(req); + + expect(res.status).toBe(400); + expect(runOpenclawConfigSet).not.toHaveBeenCalled(); + }); + + it("disable says plainly that there is nothing to turn off", async () => { + const req = new Request("http://localhost/setup-api/browser/manage", { + method: "POST", + body: JSON.stringify({ action: "disable" }), + }); + + const res = await POST(req); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.enabled).toBe(true); + expect(body.error).not.toMatch(/OpenClaw CLI/i); + expect(runOpenclawConfigSet).not.toHaveBeenCalled(); + expect(sqliteSet).not.toHaveBeenCalled(); + }); + + it("leaves the desktop browser controls alone", async () => { + mockExec.mockResolvedValue({ stdout: "", stderr: "" }); + const req = new Request("http://localhost/setup-api/browser/manage", { + method: "POST", + body: JSON.stringify({ action: "close-browser" }), + }); + + const res = await POST(req); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + }); }); });