Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 69 additions & 6 deletions src/app/setup-api/browser/manage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<string | null> {
Expand Down Expand Up @@ -157,19 +189,26 @@ async function persistBrowserEnabled(enabled: boolean): Promise<void> {

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) {
Expand Down Expand Up @@ -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"]);
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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;
Expand Down
88 changes: 61 additions & 27 deletions src/components/BrowserApp.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
}

Expand Down Expand Up @@ -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 (
<div className="h-full flex flex-col bg-[#0f1219] text-white overflow-y-auto">
Expand Down Expand Up @@ -217,7 +238,7 @@ export default function BrowserApp({ onOpenApp }: BrowserAppProps) {
</div>
</div>

{/* Step 2: OpenClaw Integration */}
{/* Step 2: agent integration — a switch on OpenClaw, permanent on Hermes */}
<div className={`rounded-xl border overflow-hidden ${chromiumInstalled ? "border-white/10 bg-white/[0.02]" : "border-white/5 bg-white/[0.01] opacity-50 pointer-events-none"}`}>
<div className="p-4 flex items-start gap-4">
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 text-sm font-bold ${isEnabled ? "text-white" : "bg-white/10 text-white/40"}`}
Expand All @@ -229,15 +250,23 @@ export default function BrowserApp({ onOpenApp }: BrowserAppProps) {
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm">{t("browser.openclawIntegration", { harness: harnessLabel })}</h3>
<p className="text-xs text-white/50 mt-1">
{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 })}
</p>
{isEnabled && (
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1">
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: BRAND_ORANGE }} />
<span className="text-xs text-white/40">tools profile: full</span>
{/* 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. */}
<span className={`text-xs text-white/40${alwaysOn ? " font-mono" : ""}`}>
{alwaysOn ? "browser_open · browser_navigate · browser_screenshot" : "tools profile: full"}
</span>
Comment on lines +267 to +269

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

List every available browser tool.

The always-on label omits browser_close. The route describes it as an available browser tool. The panel therefore reports an incomplete capability set.

Proposed fix
-                      {alwaysOn ? "browser_open · browser_navigate · browser_screenshot" : "tools profile: full"}
+                      {alwaysOn ? "browser_open · browser_navigate · browser_screenshot · browser_close" : "tools profile: full"}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<span className={`text-xs text-white/40${alwaysOn ? " font-mono" : ""}`}>
{alwaysOn ? "browser_open · browser_navigate · browser_screenshot" : "tools profile: full"}
</span>
<span className={`text-xs text-white/40${alwaysOn ? " font-mono" : ""}`}>
{alwaysOn ? "browser_open · browser_navigate · browser_screenshot · browser_close" : "tools profile: full"}
</span>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/BrowserApp.tsx` around lines 267 - 269, Update the alwaysOn
label in BrowserApp to include browser_close alongside the other available
browser tools, while leaving the non-always-on “tools profile: full” label
unchanged.

</div>
<div className="flex items-center gap-1.5">
<span className="material-symbols-rounded text-white/30" style={{ fontSize: 14 }}>bug_report</span>
Expand All @@ -250,29 +279,34 @@ export default function BrowserApp({ onOpenApp }: BrowserAppProps) {
</div>
)}
</div>
<button
onClick={() => isEnabled
? doAction("disable", "Disabling...", "Browser disconnected from OpenClaw")
: doAction("enable", "Enabling...", "Browser connected to OpenClaw")
}
disabled={!!actionLoading}
className={`px-4 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer disabled:opacity-50 shrink-0 ${
isEnabled ? "bg-white/10 text-white/60 hover:bg-white/15" : "text-white"
}`}
style={!isEnabled ? { backgroundColor: BRAND_ORANGE } : undefined}
>
{actionLoading === "Enabling..." || actionLoading === "Disabling..." ? (
<span className="flex items-center gap-1.5">
<span className="material-symbols-rounded animate-spin" style={{ fontSize: 14 }}>progress_activity</span>
{actionLoading === "Enabling..." ? t("browser.enabling") : t("browser.disabling")}
</span>
) : isEnabled ? t("browser.disable") : t("browser.enable")}
</button>
{/* 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 && (
<button
onClick={() => isEnabled
? doAction("disable", "Disabling...", `Browser disconnected from ${harnessLabel}`)
: doAction("enable", "Enabling...", `Browser connected to ${harnessLabel}`)
}
disabled={!!actionLoading}
className={`px-4 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer disabled:opacity-50 shrink-0 ${
isEnabled ? "bg-white/10 text-white/60 hover:bg-white/15" : "text-white"
}`}
style={!isEnabled ? { backgroundColor: BRAND_ORANGE } : undefined}
>
{actionLoading === "Enabling..." || actionLoading === "Disabling..." ? (
<span className="flex items-center gap-1.5">
<span className="material-symbols-rounded animate-spin" style={{ fontSize: 14 }}>progress_activity</span>
{actionLoading === "Enabling..." ? t("browser.enabling") : t("browser.disabling")}
</span>
) : isEnabled ? t("browser.disable") : t("browser.enable")}
</button>
)}
</div>
</div>

{/* Step 3: Browser Controls */}
<div className={`rounded-xl border overflow-hidden ${isEnabled ? "border-white/10 bg-white/[0.02]" : "border-white/5 bg-white/[0.01] opacity-50 pointer-events-none"}`}>
<div className={`rounded-xl border overflow-hidden ${canRunBrowser ? "border-white/10 bg-white/[0.02]" : "border-white/5 bg-white/[0.01] opacity-50 pointer-events-none"}`}>
<div className="p-4 flex items-start gap-4">
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 text-sm font-bold ${browserRunning ? "text-white" : "bg-white/10 text-white/40"}`}
style={browserRunning ? { backgroundColor: BRAND_ORANGE } : undefined}>
Expand Down
3 changes: 3 additions & 0 deletions src/lib/desktop-translations-part1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export const bg: Record<string, string> = {
"browser.openclawIntegration": "Интеграция с {harness}",
"browser.enabledMessage": "Браузърът е свързан с {harness}. Вашият AI може да сърфира в мрежата, попълва формуляри и взаимодейства със страници, използвайки постоянен профил.",
"browser.disabledMessage": "Свържете браузъра с {harness}, за да може вашият AI асистент да го използва за сърфиране, проучване и автоматизация.",
"browser.builtInMessage": "Сърфирането е вградено в това издание. {harness} управлява този браузър на работния плот чрез собствените инструменти за браузър на ClawBox, така че няма какво да се включва.",
"browser.disable": "Деактивирай",
"browser.enable": "Активирай",
"browser.disabling": "Деактивиране...",
Expand Down Expand Up @@ -488,6 +489,7 @@ export const de: Record<string, string> = {
"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...",
Expand Down Expand Up @@ -873,6 +875,7 @@ export const es: Record<string, string> = {
"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...",
Expand Down
3 changes: 3 additions & 0 deletions src/lib/desktop-translations-part2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export const fr: Record<string, string> = {
"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...",
Expand Down Expand Up @@ -488,6 +489,7 @@ export const it: Record<string, string> = {
"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...",
Expand Down Expand Up @@ -873,6 +875,7 @@ export const ja: Record<string, string> = {
"browser.openclawIntegration": "{harness} 連携",
"browser.enabledMessage": "ブラウザは {harness} に接続されています。AI はウェブの閲覧、フォームの入力、ページの操作を永続プロファイルを使って行えます。",
"browser.disabledMessage": "ブラウザを {harness} に接続して、AI アシスタントがウェブ閲覧、調査、自動化に利用できるようにします。",
"browser.builtInMessage": "このエディションではブラウジングが標準で組み込まれています。{harness} は ClawBox 独自のブラウザツールでデスクトップのこのブラウザを操作するため、有効にする設定はありません。",
"browser.disable": "無効にする",
"browser.enable": "有効にする",
"browser.disabling": "無効化中...",
Expand Down
Loading
Loading