Files
openclaw/extensions/browser/src/control-service.ts
Peter Steinberger d6801f23d4 feat(browser): restore driver "extension" via a loopback Chrome extension relay (no remote-debugging prompt) (#100619)
* feat(browser): restore driver "extension" via loopback Chrome extension relay

Reintroduces browser profile driver "extension" (removed in 2026.3.22) as a
loopback relay that drives the user's signed-in Chrome through an MV3 extension
instead of the remote-debugging port. This avoids Chrome's blocking "Allow
remote debugging?" prompt, which cannot be clicked when the operator drives
OpenClaw from a phone. Automated tabs live in an "OpenClaw" tab group (the
consent boundary), mirroring the Codex/Claude-in-Chrome model.

- relay bridge synthesizes the CDP browser target surface for Playwright
  connectOverCDP and forwards session-scoped commands to chrome.debugger
- relay server binds loopback only; both sides authenticate with a token
  derived (HMAC-SHA256) from gateway auth, so the raw credential never reaches
  Chrome; extension origin + loopback Host checks guard the upgrade
- built-in "chrome" profile; distinct relay ports per extension profile;
  relay reconciles on auth rotation / cdpPort change and prunes removed profiles
- doctor + status surface the extension transport; doctor keeps repairing the
  retired relay endpoint URL on legacy "extension" profiles

Refs #53599

* feat(browser): bundle the OpenClaw MV3 Chrome extension

Thin MV3 extension (chrome-extension/): a WebSocket client to the loopback
relay plus chrome.debugger forwarding and OpenClaw tab-group management. All
CDP target synthesis lives server-side in the relay bridge, so the extension
stays a dumb transport (the removed 2026.3 extension put that logic in a
1000-line untestable service worker). Popup handles pairing and per-tab share
toggle; `openclaw browser extension path|pair` load and pair it. A build copy
hook stages it into dist so the load path is stable.

Refs #53599

* docs(browser): document the Chrome extension profile

Adds docs/tools/chrome-extension for the restored extension driver (install,
pair, tab-group consent model, security posture) and wires it into the browser
docs profile section and nav.

Refs #53599

* feat(browser): make the extension relay work on remote browser nodes

Derives the relay auth token from a host-local secret in the credentials dir
(created on first use) instead of gateway auth. Each machine that runs a
browser — the gateway host and every browser node host — owns its own token, so
the extension pairs with whichever machine hosts its Chrome and no gateway
credential travels to a node. The node host already runs the shared browser
control bootstrap, so this is all that was missing for cross-machine control.

Also removes the "relay needs gateway auth before it can start" failure mode:
startup and `openclaw browser extension pair` ensure the secret exists.

Refs #53599

* fix(browser): harden relay secret creation and satisfy CI lint/typecheck

- Make the host-local relay secret creation atomic (O_CREAT|O_EXCL + adopt the
  winner on EEXIST) so the gateway service and `extension pair` CLI cannot mint
  divergent tokens on a fresh host (would 401 until restart); credentials dir
  created mode 0700. (adversarial review finding)
- Resolve type-aware oxlint findings across the relay + extension: unknown catch
  vars, addEventListener over ws.on* in the MV3 worker, void async listeners,
  drop useless returns/spreads, Object.assign over map-spread, safe ws frame
  decode (Buffer[]/ArrayBuffer), toSorted.
- Add extensionRelayDefaultPort/extensionRelayPorts to remaining test config
  literals; type the extension relay-core module (.d.ts, excluded from dist);
  regenerate docs_map.

* fix(browser): satisfy OpenGrep security policy on the relay

- Hash both operands before timingSafeEqual so token comparison has no
  length short-circuit (GHSA-JJ6Q-RRRF-H66H).
- Bound the relay WebSocketServer with maxPayload (64 MiB, headroom for CDP
  screenshots/bodies) against oversized frames (GHSA-VW3H-Q6XQ-JJM5).
- Rewrite the config test env helper to avoid the skill-env-host-injection
  shape (GHSA-82G8-464F-2MV7); it is a test-only env swap.
2026-07-06 10:08:27 +01:00

95 lines
3.5 KiB
TypeScript

/**
* Browser control service lifecycle for plugin-managed, in-process operation.
*/
import {
createBrowserControlContext,
ensureBrowserControlRuntime,
getBrowserControlState,
stopBrowserControlRuntime,
} from "./browser-control-state.js";
import { loadBrowserConfigForRuntimeRefresh } from "./browser/config-refresh-source.js";
import { resolveBrowserConfig, resolveProfile } from "./browser/config.js";
import { ensureBrowserControlAuth } from "./browser/control-auth.js";
import { getExtensionRelayModule } from "./browser/extension-relay.runtime.js";
import type { BrowserServerState } from "./browser/server-context.js";
import { getRuntimeConfig } from "./config/config.js";
import { createSubsystemLogger } from "./logging/subsystem.js";
import { isDefaultBrowserPluginEnabled } from "./plugin-enabled.js";
const log = createSubsystemLogger("browser");
const logService = log.child("service");
/** Starts Browser control without binding the HTTP server when config enables it. */
export async function startBrowserControlServiceFromConfig(): Promise<BrowserServerState | null> {
const current = getBrowserControlState();
if (current) {
return current;
}
const cfg = getRuntimeConfig();
const browserCfg = loadBrowserConfigForRuntimeRefresh();
if (!isDefaultBrowserPluginEnabled(browserCfg)) {
return null;
}
let resolved = resolveBrowserConfig(browserCfg.browser, browserCfg);
if (!resolved.enabled) {
return null;
}
try {
const ensured = await ensureBrowserControlAuth({ cfg });
if (ensured.generatedToken) {
logService.info("No browser auth configured; generated gateway.auth.token automatically.");
}
} catch (err) {
logService.warn(`failed to auto-configure browser auth: ${String(err)}`);
}
// Ensure the host-local relay secret exists before profiles are consumed so
// the extension cdpUrl carries auth. Works identically on the gateway host
// and on a browser node host — each owns its own secret.
const hasExtensionProfiles = Object.values(resolved.profiles).some(
(profile) => profile.driver === "extension",
);
if (hasExtensionProfiles) {
const { ensureExtensionRelayToken } = await import("./browser/extension-relay/relay-auth.js");
ensureExtensionRelayToken();
const refreshed = loadBrowserConfigForRuntimeRefresh();
resolved = resolveBrowserConfig(refreshed.browser, refreshed);
}
const state = await ensureBrowserControlRuntime({
server: null,
port: resolved.controlPort,
resolved,
owner: "service",
onWarn: (message) => logService.warn(message),
});
// Extension relays listen from service start so the Chrome extension can
// (re)connect before the first agent browser request arrives.
if (hasExtensionProfiles) {
const { startConfiguredExtensionRelays } = await getExtensionRelayModule();
await startConfiguredExtensionRelays(
state,
(name) => resolveProfile(resolved, name),
(message) => logService.warn(message),
);
}
logService.info(
`Browser control service ready (profiles=${Object.keys(resolved.profiles).length})`,
);
return state;
}
/** Stops the in-process Browser control service runtime. */
export async function stopBrowserControlService(): Promise<void> {
await stopBrowserControlRuntime({
requestedBy: "service",
onWarn: (message) => logService.warn(message),
});
}
/** Re-export Browser control context accessors for gateway-local dispatch. */
export { createBrowserControlContext, getBrowserControlState };