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); + }); }); });